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