• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2022 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.providers.media.photopicker;
18 
19 import static android.os.Process.THREAD_PRIORITY_FOREGROUND;
20 import static android.os.Process.setThreadPriority;
21 
22 import static java.util.Objects.requireNonNull;
23 
24 import android.annotation.SuppressLint;
25 import android.app.Activity;
26 import android.app.AlertDialog;
27 import android.app.ProgressDialog;
28 import android.content.ContentResolver;
29 import android.content.Context;
30 import android.net.Uri;
31 import android.os.Looper;
32 import android.util.Log;
33 
34 import androidx.annotation.NonNull;
35 import androidx.annotation.Nullable;
36 import androidx.annotation.UiThread;
37 import androidx.lifecycle.LiveData;
38 import androidx.lifecycle.MutableLiveData;
39 import androidx.lifecycle.Observer;
40 import androidx.tracing.Trace;
41 
42 import com.android.providers.media.R;
43 
44 import java.io.FileNotFoundException;
45 import java.util.ArrayList;
46 import java.util.List;
47 import java.util.concurrent.Executor;
48 import java.util.concurrent.Executors;
49 import java.util.concurrent.ThreadFactory;
50 import java.util.concurrent.atomic.AtomicInteger;
51 
52 /**
53  * Responsible for "preloading" selected media items including showing the appropriate UI
54  * ({@link ProgressDialog}).
55  *
56  * @see #preload(Context, List)
57  */
58 class SelectedMediaPreloader {
59     private static final String TRACE_SECTION_NAME = "preload-selected-media";
60     private static final String TAG = "SelectedMediaPreloader";
61     private static final boolean DEBUG = false;
62 
63     @Nullable
64     private static volatile Executor sExecutor;
65 
66     @NonNull
67     private final List<Uri> mItems;
68     private final int mCount;
69     @NonNull
70     private final AtomicInteger mFinishedCount = new AtomicInteger(0);
71     @NonNull
72     private final MutableLiveData<Integer> mFinishedCountLiveData = new MutableLiveData<>(0);
73     @NonNull
74     private final MutableLiveData<Boolean> mIsFinishedLiveData = new MutableLiveData<>(false);
75     @NonNull
76     private final ContentResolver mContentResolver;
77 
78     /**
79      * Creates, start and eventually returns a new {@link SelectedMediaPreloader} instance.
80      * Additionally, creates and shows an {@link AlertDialog} which displays the progress
81      * (e.g. "X out of Y ready."), and is automatically dismissed when preloading is fully finished.
82      * @return a new (and {@link #start(Executor)} "started") {@link SelectedMediaPreloader}.
83      */
84     @UiThread
85     @NonNull
preload( @onNull Activity activity, @NonNull List<Uri> selectedMedia)86     static SelectedMediaPreloader preload(
87             @NonNull Activity activity, @NonNull List<Uri> selectedMedia) {
88         if (Looper.myLooper() != Looper.getMainLooper()) {
89             throw new IllegalStateException("Must be called from the Main (UI) thread");
90         }
91 
92         // Make a copy of the list.
93         final List<Uri> items = new ArrayList<>(requireNonNull(selectedMedia));
94         final int count = items.size();
95 
96         Log.d(TAG, "preload() " + count + " items");
97         if (DEBUG) {
98             Log.v(TAG, "  Items:");
99             for (int i = 0; i < count; i++) {
100                 Log.v(TAG, "    (" + i + ") " + items.get(i));
101             }
102         }
103 
104         final var context = requireNonNull(activity).getApplicationContext();
105         final var contentResolver = context.getContentResolver();
106         final var preloader = new SelectedMediaPreloader(items, contentResolver);
107 
108         Trace.beginAsyncSection(TRACE_SECTION_NAME, /* cookie */ preloader.hashCode());
109 
110         final var dialog = createProgressDialog(activity, items);
111 
112         preloader.mIsFinishedLiveData.observeForever(new Observer<>() {
113             @Override
114             public void onChanged(Boolean isFinished) {
115                 if (isFinished) {
116                     preloader.mIsFinishedLiveData.removeObserver(this);
117                     dialog.dismiss();
118 
119                     Trace.endAsyncSection(TRACE_SECTION_NAME, /* cookie */ preloader.hashCode());
120                 }
121             }
122         });
123         preloader.mFinishedCountLiveData.observeForever(new Observer<>() {
124             @Override
125             public void onChanged(Integer finishedCount) {
126                 if (finishedCount == count) {
127                     preloader.mFinishedCountLiveData.removeObserver(this);
128                 }
129                 // "X of Y ready"
130                 final String message = context.getString(
131                         R.string.preloading_progress_message, finishedCount, count);
132                 dialog.setMessage(message);
133             }
134         });
135 
136         ensureExecutor();
137         preloader.start(sExecutor);
138 
139         return preloader;
140     }
141 
142     /**
143      * The constructor is intentionally {@code private}: clients should use static
144      * {@link #preload(Context, List)} method.
145      */
SelectedMediaPreloader( @onNull List<Uri> items, @NonNull ContentResolver contentResolver)146     private SelectedMediaPreloader(
147             @NonNull List<Uri> items, @NonNull ContentResolver contentResolver) {
148         mContentResolver = contentResolver;
149         mItems = items;
150         mCount = items.size();
151     }
152 
153     @NonNull
getIsFinishedLiveData()154     LiveData<Boolean> getIsFinishedLiveData() {
155         return mIsFinishedLiveData;
156     }
157 
158     /**
159      * This method is intentionally {@code private}: clients should use static
160      * {@link #preload(Context, List)} method.
161      */
162     @UiThread
start(@onNull Executor executor)163     private void start(@NonNull Executor executor) {
164         for (var item : mItems) {
165             // Off-loading to an Executor (presumable backed up by a thread pool)
166             executor.execute(new Runnable() {
167                 @Override
168                 public void run() {
169                     openFileDescriptor(item);
170 
171                     final int preloadedCount = mFinishedCount.incrementAndGet();
172                     if (DEBUG) {
173                         Log.d(TAG, "Preloaded " + preloadedCount + " (of " + mCount + ") items");
174                     }
175                     if (preloadedCount == mCount) {
176                         // Don't need to "synchronize" here: mCount is our final value for
177                         // preloadedCount, it won't be changing anymore.
178                         mIsFinishedLiveData.postValue(true);
179                     }
180 
181                     // In order to prevent race conditions where we may "post" a lower value after
182                     // another has already posted a higher value let's "synchronize", and get
183                     // the finished count from the AtomicInt once again.
184                     synchronized (this) {
185                         mFinishedCountLiveData.postValue(mFinishedCount.get());
186                     }
187                 }
188             });
189         }
190     }
191 
192     @Nullable
openFileDescriptor(@onNull Uri uri)193     private void openFileDescriptor(@NonNull Uri uri) {
194         long start = 0;
195         if (DEBUG) {
196             Log.d(TAG, "openFileDescriptor() START, " + Thread.currentThread() + ", " + uri);
197             start = System.currentTimeMillis();
198         }
199 
200         Trace.beginSection("Preloader.openFd");
201         try {
202             mContentResolver.openAssetFileDescriptor(uri, "r");
203         } catch (FileNotFoundException e) {
204             Log.w(TAG, "Could not open FileDescriptor for " + uri, e);
205         } finally {
206             Trace.endSection();
207 
208             if (DEBUG) {
209                 final long elapsed = System.currentTimeMillis() - start;
210                 Log.d(TAG, "openFileDescriptor() DONE, took " + humanReadableTimeDuration(elapsed)
211                         + ", " + uri);
212             }
213         }
214     }
215 
216     @NonNull
createProgressDialog( @onNull Activity activity, @NonNull List<Uri> selectedMedia)217     private static AlertDialog createProgressDialog(
218             @NonNull Activity activity, @NonNull List<Uri> selectedMedia) {
219         return ProgressDialog.show(activity,
220                 /* tile */ "Preparing your selected media",
221                 /* message */ "0 of " + selectedMedia.size() + " ready.",
222                 /* indeterminate */ true);
223     }
224 
ensureExecutor()225     private static void ensureExecutor() {
226         if (sExecutor == null) {
227             synchronized (SelectedMediaPreloader.class) {
228                 if (sExecutor == null) {
229                     final ThreadFactory threadFactory = new ThreadFactory() {
230 
231                         final AtomicInteger mCount = new AtomicInteger(1);
232 
233                         @Override
234                         public Thread newThread(Runnable r) {
235                             final String threadName = "preloader#" + mCount.getAndIncrement();
236                             if (DEBUG) {
237                                 Log.d(TAG, "newThread() " + threadName);
238                             }
239 
240                             return new Thread(r, threadName) {
241                                 @Override
242                                 public void run() {
243                                     // For now the preloading only starts when the user has made
244                                     // the final selection, at which point we show a (not
245                                     // dismissible) loading dialog, which, technically, makes the
246                                     // preloading a "foreground" task.
247                                     // Thus THREAD_PRIORITY_FOREGROUND.
248                                     setThreadPriority(THREAD_PRIORITY_FOREGROUND);
249                                     super.run();
250                                 }
251                             };
252                         }
253                     };
254                     sExecutor = Executors.newCachedThreadPool(threadFactory);
255                 }
256             }
257         }
258     }
259 
260     @SuppressLint("DefaultLocale")
261     @NonNull
humanReadableTimeDuration(long ms)262     private static String humanReadableTimeDuration(long ms) {
263         if (ms < 1000) {
264             return ms + " ms";
265         }
266         return String.format("%.1f s", ms / 1000.0);
267     }
268 }
269