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