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.content.DialogInterface;
31 import android.net.Uri;
32 import android.os.Looper;
33 import android.util.Log;
34 import android.widget.Button;
35 
36 import androidx.annotation.NonNull;
37 import androidx.annotation.Nullable;
38 import androidx.annotation.UiThread;
39 import androidx.lifecycle.LiveData;
40 import androidx.lifecycle.MutableLiveData;
41 import androidx.lifecycle.Observer;
42 import androidx.tracing.Trace;
43 
44 import com.android.providers.media.R;
45 
46 import java.io.FileNotFoundException;
47 import java.io.IOException;
48 import java.util.ArrayList;
49 import java.util.List;
50 import java.util.concurrent.Executor;
51 import java.util.concurrent.Executors;
52 import java.util.concurrent.ThreadFactory;
53 import java.util.concurrent.atomic.AtomicInteger;
54 import java.util.concurrent.atomic.AtomicReference;
55 
56 /**
57  * Responsible for "preloading" selected media items including showing the appropriate UI
58  * ({@link ProgressDialog}).
59  *
60  * @see #preload(Context, List)
61  */
62 class SelectedMediaPreloader {
63     private static final long TIMEOUT_IN_SECONDS = 4L;
64     private static final String TRACE_SECTION_NAME = "preload-selected-media";
65     private static final String TAG = "SelectedMediaPreloader";
66     private static final boolean DEBUG = true;
67 
68     @Nullable
69     private static volatile Executor sExecutor;
70 
71     @NonNull
72     private final List<Uri> mItems;
73     private final int mCount;
74     private boolean mIsPreloadingCancelled = false;
75     @NonNull
76     private final AtomicInteger mFinishedCount = new AtomicInteger(0);
77     @NonNull
78     private final MutableLiveData<Integer> mFinishedCountLiveData = new MutableLiveData<>(0);
79     @NonNull
80     private final MutableLiveData<Boolean> mIsFinishedLiveData = new MutableLiveData<>(false);
81     @NonNull
82     private static final MutableLiveData<Boolean> mIsPreloadingCancelledLiveData =
83             new MutableLiveData<>(false);
84     @NonNull
85     private final MutableLiveData<List<Integer>> mUnavailableMediaIndexes =
86             new MutableLiveData<>(new ArrayList<>());
87     @NonNull
88     private final ContentResolver mContentResolver;
89     private List<Integer> mSuccessfullyPreloadedMediaIndexes = new ArrayList<>();
90 
91     /**
92      * Creates, start and eventually returns a new {@link SelectedMediaPreloader} instance.
93      * Additionally, creates and shows an {@link AlertDialog} which displays the progress
94      * (e.g. "X out of Y ready."), and is automatically dismissed when preloading is fully finished.
95      * @return a new (and {@link #start(Executor)} "started") {@link SelectedMediaPreloader}.
96      */
97     @UiThread
98     @NonNull
preload( @onNull Activity activity, @NonNull List<Uri> selectedMedia)99     static SelectedMediaPreloader preload(
100             @NonNull Activity activity, @NonNull List<Uri> selectedMedia) {
101         if (Looper.myLooper() != Looper.getMainLooper()) {
102             throw new IllegalStateException("Must be called from the Main (UI) thread");
103         }
104 
105         // Make a copy of the list.
106         final List<Uri> items = new ArrayList<>(requireNonNull(selectedMedia));
107         final int count = items.size();
108         mIsPreloadingCancelledLiveData.setValue(false);
109 
110         Log.d(TAG, "preload() " + count + " items");
111         if (DEBUG) {
112             Log.v(TAG, "  Items:");
113             for (int i = 0; i < count; i++) {
114                 Log.v(TAG, "    (" + i + ") " + items.get(i));
115             }
116         }
117 
118         final var context = requireNonNull(activity).getApplicationContext();
119         final var contentResolver = context.getContentResolver();
120         final var preloader = new SelectedMediaPreloader(items, contentResolver);
121 
122         Trace.beginAsyncSection(TRACE_SECTION_NAME, /* cookie */ preloader.hashCode());
123 
124         final var dialog = createProgressDialog(activity, items, context);
125 
126         preloader.mIsFinishedLiveData.observeForever(new Observer<>() {
127             @Override
128             public void onChanged(Boolean isFinished) {
129                 if (isFinished) {
130                     preloader.mIsFinishedLiveData.removeObserver(this);
131                     Trace.endAsyncSection(TRACE_SECTION_NAME, /* cookie */ preloader.hashCode());
132                 }
133             }
134         });
135 
136         preloader.mFinishedCountLiveData.observeForever(new Observer<>() {
137             @Override
138             public void onChanged(Integer finishedCount) {
139                 if (finishedCount == count) {
140                     preloader.mFinishedCountLiveData.removeObserver(this);
141                     dialog.dismiss();
142                 }
143                 // "X of Y ready"
144                 final String message = context.getString(
145                         R.string.preloading_progress_message, finishedCount, count);
146                 dialog.setMessage(message);
147             }
148         });
149 
150         mIsPreloadingCancelledLiveData.observeForever(new Observer<>() {
151             @Override
152             public void onChanged(Boolean isPreloadingCancelled) {
153                 if (isPreloadingCancelled) {
154                     preloader.mIsPreloadingCancelled = true;
155                     mIsPreloadingCancelledLiveData.removeObserver(this);
156                     List<Integer> unsuccessfullyPreloadedMediaIndexes = new ArrayList<>();
157                     for (int index = 0; index < preloader.mItems.size(); index++) {
158                         if (!preloader.mSuccessfullyPreloadedMediaIndexes.contains(index)) {
159                             unsuccessfullyPreloadedMediaIndexes.add(index);
160                         }
161                     }
162                     // this extra "-1" element indicates that preloading has been cancelled by
163                     // the user
164                     unsuccessfullyPreloadedMediaIndexes.add(-1);
165                     preloader.mUnavailableMediaIndexes.setValue(
166                             unsuccessfullyPreloadedMediaIndexes);
167                     preloader.mIsFinishedLiveData.setValue(false);
168                     preloader.mFinishedCountLiveData.setValue(preloader.mItems.size());
169                 }
170             }
171         });
172 
173         ensureExecutor();
174         preloader.start(sExecutor);
175         return preloader;
176     }
177 
178     /**
179      * The constructor is intentionally {@code private}: clients should use static
180      * {@link #preload(Context, List)} method.
181      */
SelectedMediaPreloader( @onNull List<Uri> items, @NonNull ContentResolver contentResolver)182     private SelectedMediaPreloader(
183             @NonNull List<Uri> items, @NonNull ContentResolver contentResolver) {
184         mContentResolver = contentResolver;
185         mItems = items;
186         mCount = items.size();
187     }
188 
189     @NonNull
getIsFinishedLiveData()190     LiveData<Boolean> getIsFinishedLiveData() {
191         return mIsFinishedLiveData;
192     }
193 
194     @NonNull
getUnavailableMediaIndexes()195     LiveData<List<Integer>> getUnavailableMediaIndexes() {
196         return mUnavailableMediaIndexes;
197     }
198 
199     /**
200      * This method is intentionally {@code private}: clients should use static
201      * {@link #preload(Context, List)} method.
202      */
203     @UiThread
start(@onNull Executor executor)204     private void start(@NonNull Executor executor) {
205         List<Integer> unavailableMediaIndexes = new ArrayList<>();
206         for (int index = 0; index < mItems.size(); index++) {
207             int currIndex = index;
208             // Off-loading to an Executor (presumable backed up by a thread pool)
209             executor.execute(new Runnable() {
210                 @Override
211                 public void run() {
212                     boolean isOpenedSuccessfully = false;
213                     if (!mIsPreloadingCancelled) {
214                         isOpenedSuccessfully = openFileDescriptor(mItems.get(currIndex));
215                     }
216 
217                     if (!isOpenedSuccessfully) {
218                         unavailableMediaIndexes.add(currIndex);
219                     } else {
220                         mSuccessfullyPreloadedMediaIndexes.add(currIndex);
221                     }
222 
223                     final int preloadedCount = mFinishedCount.incrementAndGet();
224                     if (DEBUG) {
225                         Log.d(TAG, "Preloaded " + preloadedCount + " (of " + mCount + ") items");
226                     }
227 
228                     if (preloadedCount == mCount && !mIsPreloadingCancelled) {
229                         // Don't need to "synchronize" here: mCount is our final value for
230                         // preloadedCount, it won't be changing anymore.
231                         if (unavailableMediaIndexes.size() == 0) {
232                             mIsFinishedLiveData.postValue(true);
233                         } else {
234                             mUnavailableMediaIndexes.postValue(unavailableMediaIndexes);
235                             mIsFinishedLiveData.postValue(false);
236                         }
237                     }
238 
239                     // In order to prevent race conditions where we may "post" a lower value after
240                     // another has already posted a higher value let's "synchronize", and get
241                     // the finished count from the AtomicInt once again.
242                     synchronized (this) {
243                         mFinishedCountLiveData.postValue(mFinishedCount.get());
244                     }
245                 }
246             });
247         }
248     }
249 
250     @Nullable
openFileDescriptor(@onNull Uri uri)251     private Boolean openFileDescriptor(@NonNull Uri uri) {
252         AtomicReference<Boolean> isOpenedSuccessfully = new AtomicReference<>(true);
253         long start = 0;
254         if (DEBUG) {
255             Log.d(TAG, "openFileDescriptor() START, " + Thread.currentThread() + ", " + uri);
256             start = System.currentTimeMillis();
257         }
258 
259         Trace.beginSection("Preloader.openFd");
260 
261         try {
262             mContentResolver.openAssetFileDescriptor(uri, "r").close();
263         } catch (FileNotFoundException e) {
264             isOpenedSuccessfully.set(false);
265             Log.w(TAG, "Could not open FileDescriptor for " + uri, e);
266         } catch (IOException e) {
267             isOpenedSuccessfully.set(false);
268             Log.w(TAG, "Failed to preload media file ", e);
269         } finally {
270             Trace.endSection();
271 
272             if (DEBUG) {
273                 final long elapsed = System.currentTimeMillis() - start;
274                 Log.d(TAG, "openFileDescriptor() DONE, took " + humanReadableTimeDuration(elapsed)
275                         + ", " + uri);
276             }
277         }
278 
279         return isOpenedSuccessfully.get();
280     }
281 
282     @NonNull
createProgressDialog( @onNull Activity activity, @NonNull List<Uri> selectedMedia, Context context)283     private static AlertDialog createProgressDialog(
284             @NonNull Activity activity, @NonNull List<Uri> selectedMedia, Context context) {
285         ProgressDialog dialog = new ProgressDialog(activity,
286                 R.style.SelectedMediaPreloaderDialogTheme);
287         dialog.setTitle(/* title */ context.getString(R.string.preloading_dialog_title));
288         dialog.setMessage(/* message */ context.getString(
289                 R.string.preloading_progress_message, 0, selectedMedia.size()));
290         dialog.setIndeterminate(/* indeterminate */ true);
291         dialog.setCancelable(false);
292 
293         dialog.setButton(DialogInterface.BUTTON_NEGATIVE,
294                 context.getString(R.string.preloading_cancel_button), (dialog1, which) -> {
295                 mIsPreloadingCancelledLiveData.setValue(true);
296             });
297         dialog.create();
298 
299         Button cancelButton = dialog.getButton(DialogInterface.BUTTON_NEGATIVE);
300         if (cancelButton != null) {
301             cancelButton.setTextAppearance(R.style.ProgressDialogCancelButtonStyle);
302             cancelButton.setAllCaps(false);
303         }
304 
305         dialog.show();
306 
307         return dialog;
308     }
309 
ensureExecutor()310     private static void ensureExecutor() {
311         if (sExecutor == null) {
312             synchronized (SelectedMediaPreloader.class) {
313                 if (sExecutor == null) {
314                     sExecutor = Executors.newFixedThreadPool(2, new ThreadFactory() {
315 
316                         final AtomicInteger mCount = new AtomicInteger(1);
317 
318                         @Override
319                         public Thread newThread(Runnable r) {
320                             final String threadName = "preloader#" + mCount.getAndIncrement();
321                             if (DEBUG) {
322                                 Log.d(TAG, "newThread() " + threadName);
323                             }
324 
325                             return new Thread(r, threadName) {
326                                 @Override
327                                 public void run() {
328                                     // For now the preloading only starts when the user has made
329                                     // the final selection, at which point we show a (not
330                                     // dismissible) loading dialog, which, technically, makes the
331                                     // preloading a "foreground" task.
332                                     // Thus THREAD_PRIORITY_FOREGROUND.
333                                     setThreadPriority(THREAD_PRIORITY_FOREGROUND);
334                                     super.run();
335                                 }
336                             };
337                         }
338                     });
339                 }
340             }
341         }
342     }
343 
344     @SuppressLint("DefaultLocale")
345     @NonNull
humanReadableTimeDuration(long ms)346     private static String humanReadableTimeDuration(long ms) {
347         if (ms < 1000) {
348             return ms + " ms";
349         }
350         return String.format("%.1f s", ms / 1000.0);
351     }
352 }
353