• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2018 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;
18 
19 import static com.android.providers.media.MediaProvider.AUDIO_MEDIA_ID;
20 import static com.android.providers.media.MediaProvider.IMAGES_MEDIA_ID;
21 import static com.android.providers.media.MediaProvider.VIDEO_MEDIA_ID;
22 import static com.android.providers.media.MediaProvider.collectUris;
23 import static com.android.providers.media.util.DatabaseUtils.getAsBoolean;
24 import static com.android.providers.media.util.Logging.TAG;
25 import static com.android.providers.media.util.PermissionUtils.checkPermissionAccessMediaLocation;
26 import static com.android.providers.media.util.PermissionUtils.checkPermissionManageMedia;
27 import static com.android.providers.media.util.PermissionUtils.checkPermissionManager;
28 import static com.android.providers.media.util.PermissionUtils.checkPermissionReadStorage;
29 
30 import android.app.Activity;
31 import android.app.AlertDialog;
32 import android.app.Dialog;
33 import android.content.ContentProviderOperation;
34 import android.content.ContentResolver;
35 import android.content.ContentValues;
36 import android.content.Context;
37 import android.content.DialogInterface;
38 import android.content.Intent;
39 import android.content.pm.ApplicationInfo;
40 import android.content.pm.PackageManager;
41 import android.content.pm.PackageManager.NameNotFoundException;
42 import android.content.res.Resources;
43 import android.database.Cursor;
44 import android.graphics.Bitmap;
45 import android.graphics.ImageDecoder;
46 import android.graphics.ImageDecoder.ImageInfo;
47 import android.graphics.ImageDecoder.Source;
48 import android.graphics.drawable.ColorDrawable;
49 import android.graphics.drawable.Icon;
50 import android.net.Uri;
51 import android.os.AsyncTask;
52 import android.os.Bundle;
53 import android.os.Handler;
54 import android.provider.MediaStore;
55 import android.provider.MediaStore.MediaColumns;
56 import android.text.TextUtils;
57 import android.util.DisplayMetrics;
58 import android.util.Log;
59 import android.util.Size;
60 import android.view.KeyEvent;
61 import android.view.View;
62 import android.view.ViewGroup;
63 import android.view.WindowManager;
64 import android.view.accessibility.AccessibilityEvent;
65 import android.widget.ImageView;
66 import android.widget.ProgressBar;
67 import android.widget.TextView;
68 
69 import androidx.annotation.NonNull;
70 import androidx.annotation.Nullable;
71 import androidx.annotation.VisibleForTesting;
72 
73 import com.android.providers.media.MediaProvider.LocalUriMatcher;
74 import com.android.providers.media.util.Metrics;
75 
76 import java.io.IOException;
77 import java.util.ArrayList;
78 import java.util.Comparator;
79 import java.util.List;
80 import java.util.Objects;
81 import java.util.function.Predicate;
82 import java.util.function.ToIntFunction;
83 import java.util.stream.Collectors;
84 
85 /**
86  * Permission dialog that asks for user confirmation before performing a
87  * specific action, such as granting access for a narrow set of media files to
88  * the calling app.
89  *
90  * @see MediaStore#createWriteRequest
91  * @see MediaStore#createTrashRequest
92  * @see MediaStore#createFavoriteRequest
93  * @see MediaStore#createDeleteRequest
94  */
95 public class PermissionActivity extends Activity {
96     // TODO: narrow metrics to specific verb that was requested
97 
98     public static final int REQUEST_CODE = 42;
99 
100     private List<Uri> uris;
101     private ContentValues values;
102 
103     private CharSequence label;
104     private String verb;
105     private String data;
106     private String volumeName;
107     private ApplicationInfo appInfo;
108 
109     private AlertDialog actionDialog;
110     private AsyncTask<Void, Void, Void> positiveActionTask;
111     private Dialog progressDialog;
112     private TextView titleView;
113     private Handler mHandler;
114     private Runnable mShowProgressDialogRunnable = () -> {
115         // We will show the progress dialog, add the dim effect back.
116         getWindow().clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND);
117         progressDialog.show();
118     };
119 
120     private static final Long LEAST_SHOW_PROGRESS_TIME_MS = 300L;
121     private static final Long BEFORE_SHOW_PROGRESS_TIME_MS = 300L;
122 
123     @VisibleForTesting
124     static final String VERB_WRITE = "write";
125     @VisibleForTesting
126     static final String VERB_TRASH = "trash";
127     @VisibleForTesting
128     static final String VERB_FAVORITE = "favorite";
129     @VisibleForTesting
130     static final String VERB_UNFAVORITE = "unfavorite";
131 
132     private static final String VERB_UNTRASH = "untrash";
133     private static final String VERB_DELETE = "delete";
134 
135     private static final String DATA_AUDIO = "audio";
136     private static final String DATA_VIDEO = "video";
137     private static final String DATA_IMAGE = "image";
138     private static final String DATA_GENERIC = "generic";
139 
140     // Use to sort the thumbnails.
141     private static final int ORDER_IMAGE = 1;
142     private static final int ORDER_VIDEO = 2;
143     private static final int ORDER_AUDIO = 3;
144     private static final int ORDER_GENERIC = 4;
145 
146     private static final int MAX_THUMBS = 3;
147 
148     @Override
onCreate(Bundle savedInstanceState)149     public void onCreate(Bundle savedInstanceState) {
150         super.onCreate(savedInstanceState);
151 
152         // Strategy borrowed from PermissionController
153         getWindow().addSystemFlags(
154                 WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS);
155         setFinishOnTouchOutside(false);
156         // remove the dim effect
157         // We may not show the progress dialog, if we don't remove the dim effect,
158         // it may have flicker.
159         getWindow().addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND);
160         getWindow().setDimAmount(0.0f);
161 
162 
163         // All untrusted input values here were validated when generating the
164         // original PendingIntent
165         try {
166             uris = collectUris(getIntent().getExtras().getParcelable(MediaStore.EXTRA_CLIP_DATA));
167             values = getIntent().getExtras().getParcelable(MediaStore.EXTRA_CONTENT_VALUES);
168 
169             appInfo = resolveCallingAppInfo();
170             label = resolveAppLabel(appInfo);
171             verb = resolveVerb();
172             data = resolveData();
173             volumeName = MediaStore.getVolumeName(uris.get(0));
174         } catch (Exception e) {
175             Log.w(TAG, e);
176             finish();
177             return;
178         }
179 
180         mHandler = new Handler(getMainLooper());
181         // Create Progress dialog
182         createProgressDialog();
183 
184         if (!shouldShowActionDialog(this, -1 /* pid */, appInfo.uid, getCallingPackage(),
185                 null /* attributionTag */, verb)) {
186             onPositiveAction(null, 0);
187             return;
188         }
189 
190         // Kick off async loading of description to show in dialog
191         final View bodyView = getLayoutInflater().inflate(R.layout.permission_body, null, false);
192         handleImageViewVisibility(bodyView, uris);
193         new DescriptionTask(bodyView).execute(uris);
194 
195         final AlertDialog.Builder builder = new AlertDialog.Builder(this);
196         // We set the title in message so that the text doesn't get truncated
197         builder.setMessage(resolveTitleText());
198         builder.setPositiveButton(R.string.allow, this::onPositiveAction);
199         builder.setNegativeButton(R.string.deny, this::onNegativeAction);
200         builder.setCancelable(false);
201         builder.setView(bodyView);
202 
203         actionDialog = builder.show();
204 
205         // The title is being set as a message above.
206         // We need to style it like the default AlertDialog title
207         TextView dialogMessage = (TextView) actionDialog.findViewById(
208                 android.R.id.message);
209         if (dialogMessage != null) {
210             dialogMessage.setTextAppearance(R.style.PermissionAlertDialogTitle);
211         } else {
212             Log.w(TAG, "Couldn't find message element");
213         }
214 
215         final WindowManager.LayoutParams params = actionDialog.getWindow().getAttributes();
216         params.width = getResources().getDimensionPixelSize(R.dimen.permission_dialog_width);
217         actionDialog.getWindow().setAttributes(params);
218 
219         // Hunt around to find the title of our newly created dialog so we can
220         // adjust accessibility focus once descriptions have been loaded
221         titleView = (TextView) findViewByPredicate(actionDialog.getWindow().getDecorView(),
222                 (view) -> {
223                     return (view instanceof TextView) && view.isImportantForAccessibility();
224                 });
225     }
226 
createProgressDialog()227     private void createProgressDialog() {
228         final ProgressBar progressBar = new ProgressBar(this);
229         final int padding = getResources().getDimensionPixelOffset(R.dimen.dialog_space);
230 
231         progressBar.setIndeterminate(true);
232         progressBar.setPadding(0, padding / 2, 0, padding);
233         progressDialog = new AlertDialog.Builder(this)
234                 .setTitle(resolveProgressMessageText())
235                 .setView(progressBar)
236                 .setCancelable(false)
237                 .create();
238     }
239 
240     @Override
onDestroy()241     public void onDestroy() {
242         super.onDestroy();
243         mHandler.removeCallbacks(mShowProgressDialogRunnable);
244         // Cancel and interrupt the AsyncTask of the positive action. This avoids
245         // calling the old activity during "onPostExecute", but the AsyncTask could
246         // still finish its background task. For now we are ok with:
247         // 1. the task potentially runs again after the configuration is changed
248         // 2. the task completed successfully, but the activity doesn't return
249         // the response.
250         if (positiveActionTask != null) {
251             positiveActionTask.cancel(true /* mayInterruptIfRunning */);
252         }
253         // Dismiss the dialogs to avoid the window is leaked
254         if (actionDialog != null) {
255             actionDialog.dismiss();
256         }
257         if (progressDialog != null) {
258             progressDialog.dismiss();
259         }
260     }
261 
onPositiveAction(@ullable DialogInterface dialog, int which)262     private void onPositiveAction(@Nullable DialogInterface dialog, int which) {
263         // Disable the buttons
264         if (dialog != null) {
265             ((AlertDialog) dialog).getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false);
266             ((AlertDialog) dialog).getButton(AlertDialog.BUTTON_NEGATIVE).setEnabled(false);
267         }
268 
269         final long startTime = System.currentTimeMillis();
270 
271         mHandler.postDelayed(mShowProgressDialogRunnable, BEFORE_SHOW_PROGRESS_TIME_MS);
272 
273         positiveActionTask = new AsyncTask<Void, Void, Void>() {
274             @Override
275             protected Void doInBackground(Void... params) {
276                 Log.d(TAG, "User allowed grant for " + uris);
277                 Metrics.logPermissionGranted(volumeName, appInfo.uid,
278                         getCallingPackage(), uris.size());
279                 try {
280                     switch (getIntent().getAction()) {
281                         case MediaStore.CREATE_WRITE_REQUEST_CALL: {
282                             for (Uri uri : uris) {
283                                 grantUriPermission(getCallingPackage(), uri,
284                                         Intent.FLAG_GRANT_READ_URI_PERMISSION
285                                                 | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
286                             }
287                             break;
288                         }
289                         case MediaStore.CREATE_TRASH_REQUEST_CALL:
290                         case MediaStore.CREATE_FAVORITE_REQUEST_CALL: {
291                             final ArrayList<ContentProviderOperation> ops = new ArrayList<>();
292                             for (Uri uri : uris) {
293                                 ops.add(ContentProviderOperation.newUpdate(uri)
294                                         .withValues(values)
295                                         .withExtra(MediaStore.QUERY_ARG_ALLOW_MOVEMENT, true)
296                                         .withExceptionAllowed(true)
297                                         .build());
298                             }
299                             getContentResolver().applyBatch(MediaStore.AUTHORITY, ops);
300                             break;
301                         }
302                         case MediaStore.CREATE_DELETE_REQUEST_CALL: {
303                             final ArrayList<ContentProviderOperation> ops = new ArrayList<>();
304                             for (Uri uri : uris) {
305                                 ops.add(ContentProviderOperation.newDelete(uri)
306                                         .withExceptionAllowed(true)
307                                         .build());
308                             }
309                             getContentResolver().applyBatch(MediaStore.AUTHORITY, ops);
310                             break;
311                         }
312                     }
313                 } catch (Exception e) {
314                     Log.w(TAG, e);
315                 }
316 
317                 return null;
318             }
319 
320             @Override
321             protected void onPostExecute(Void result) {
322                 setResult(Activity.RESULT_OK);
323                 mHandler.removeCallbacks(mShowProgressDialogRunnable);
324 
325                 if (!progressDialog.isShowing()) {
326                     finish();
327                 } else {
328                     // Don't dismiss the progress dialog too quick, it will cause bad UX.
329                     final long duration =
330                             System.currentTimeMillis() - startTime - BEFORE_SHOW_PROGRESS_TIME_MS;
331                     if (duration > LEAST_SHOW_PROGRESS_TIME_MS) {
332                         progressDialog.dismiss();
333                         finish();
334                     } else {
335                         mHandler.postDelayed(() -> {
336                             progressDialog.dismiss();
337                             finish();
338                         }, LEAST_SHOW_PROGRESS_TIME_MS - duration);
339                     }
340                 }
341             }
342         }.execute();
343     }
344 
onNegativeAction(DialogInterface dialog, int which)345     private void onNegativeAction(DialogInterface dialog, int which) {
346         new AsyncTask<Void, Void, Void>() {
347             @Override
348             protected Void doInBackground(Void... params) {
349                 Log.d(TAG, "User declined request for " + uris);
350                 Metrics.logPermissionDenied(volumeName, appInfo.uid, getCallingPackage(),
351                         1);
352                 return null;
353             }
354 
355             @Override
356             protected void onPostExecute(Void result) {
357                 setResult(Activity.RESULT_CANCELED);
358                 finish();
359             }
360         }.execute();
361     }
362 
363     @Override
onKeyDown(int keyCode, KeyEvent event)364     public boolean onKeyDown(int keyCode, KeyEvent event) {
365         // Strategy borrowed from PermissionController
366         return keyCode == KeyEvent.KEYCODE_BACK;
367     }
368 
369     @Override
onKeyUp(int keyCode, KeyEvent event)370     public boolean onKeyUp(int keyCode, KeyEvent event) {
371         // Strategy borrowed from PermissionController
372         return keyCode == KeyEvent.KEYCODE_BACK;
373     }
374 
375     @VisibleForTesting
shouldShowActionDialog(@onNull Context context, int pid, int uid, @NonNull String packageName, @Nullable String attributionTag, @NonNull String verb)376     static boolean shouldShowActionDialog(@NonNull Context context, int pid, int uid,
377             @NonNull String packageName, @Nullable String attributionTag, @NonNull String verb) {
378         // Favorite-related requests are automatically granted for now; we still
379         // make developers go through this no-op dialog flow to preserve our
380         // ability to start prompting in the future
381         if (TextUtils.equals(VERB_FAVORITE, verb) || TextUtils.equals(VERB_UNFAVORITE, verb)) {
382             return false;
383         }
384 
385         // check READ_EXTERNAL_STORAGE and MANAGE_EXTERNAL_STORAGE permissions
386         if (!checkPermissionReadStorage(context, pid, uid, packageName, attributionTag)
387                 && !checkPermissionManager(context, pid, uid, packageName, attributionTag)) {
388             Log.d(TAG, "No permission READ_EXTERNAL_STORAGE or MANAGE_EXTERNAL_STORAGE");
389             return true;
390         }
391         // check MANAGE_MEDIA permission
392         if (!checkPermissionManageMedia(context, pid, uid, packageName, attributionTag)) {
393             Log.d(TAG, "No permission MANAGE_MEDIA");
394             return true;
395         }
396 
397         // if verb is write, check ACCESS_MEDIA_LOCATION permission
398         if (TextUtils.equals(verb, VERB_WRITE) && !checkPermissionAccessMediaLocation(context, pid,
399                 uid, packageName, attributionTag)) {
400             Log.d(TAG, "No permission ACCESS_MEDIA_LOCATION");
401             return true;
402         }
403         return false;
404     }
405 
handleImageViewVisibility(View bodyView, List<Uri> uris)406     private void handleImageViewVisibility(View bodyView, List<Uri> uris) {
407         if (uris.isEmpty()) {
408             return;
409         }
410         if (uris.size() == 1) {
411             // Set visible to the thumb_full to avoid the size
412             // changed of the dialog in full decoding.
413             final ImageView thumbFull = bodyView.requireViewById(R.id.thumb_full);
414             thumbFull.setVisibility(View.VISIBLE);
415         } else {
416             // If the size equals 2, we will remove thumb1 later.
417             // Set visible to the thumb2 and thumb3 first to avoid
418             // the size changed of the dialog.
419             ImageView thumb = bodyView.requireViewById(R.id.thumb2);
420             thumb.setVisibility(View.VISIBLE);
421             thumb = bodyView.requireViewById(R.id.thumb3);
422             thumb.setVisibility(View.VISIBLE);
423             // If the count of thumbs equals to MAX_THUMBS, set visible to thumb1.
424             if (uris.size() == MAX_THUMBS) {
425                 thumb = bodyView.requireViewById(R.id.thumb1);
426                 thumb.setVisibility(View.VISIBLE);
427             } else if (uris.size() > MAX_THUMBS) {
428                 // If the count is larger than MAX_THUMBS, set visible to
429                 // thumb_more_container.
430                 final View container = bodyView.requireViewById(R.id.thumb_more_container);
431                 container.setVisibility(View.VISIBLE);
432             }
433         }
434     }
435 
436     /**
437      * Resolve a label that represents the app denoted by given {@link ApplicationInfo}.
438      */
resolveAppLabel(final ApplicationInfo ai)439     private @NonNull CharSequence resolveAppLabel(final ApplicationInfo ai)
440             throws NameNotFoundException {
441         final PackageManager pm = getPackageManager();
442         final CharSequence callingLabel = pm.getApplicationLabel(ai);
443         if (TextUtils.isEmpty(callingLabel)) {
444             throw new NameNotFoundException("Missing calling package");
445         }
446 
447         return callingLabel;
448     }
449 
450     /**
451      * Resolve the application info of the calling app.
452      */
resolveCallingAppInfo()453     private @NonNull ApplicationInfo resolveCallingAppInfo() throws NameNotFoundException {
454         final String callingPackage = getCallingPackage();
455         if (TextUtils.isEmpty(callingPackage)) {
456             throw new NameNotFoundException("Missing calling package");
457         }
458 
459         return getPackageManager().getApplicationInfo(callingPackage, 0);
460     }
461 
resolveVerb()462     private @NonNull String resolveVerb() {
463         switch (getIntent().getAction()) {
464             case MediaStore.CREATE_WRITE_REQUEST_CALL:
465                 return VERB_WRITE;
466             case MediaStore.CREATE_TRASH_REQUEST_CALL:
467                 return getAsBoolean(values, MediaColumns.IS_TRASHED, false)
468                         ? VERB_TRASH : VERB_UNTRASH;
469             case MediaStore.CREATE_FAVORITE_REQUEST_CALL:
470                 return getAsBoolean(values, MediaColumns.IS_FAVORITE, false)
471                         ? VERB_FAVORITE : VERB_UNFAVORITE;
472             case MediaStore.CREATE_DELETE_REQUEST_CALL:
473                 return VERB_DELETE;
474             default:
475                 throw new IllegalArgumentException("Invalid action: " + getIntent().getAction());
476         }
477     }
478 
479     /**
480      * Resolve what kind of data this permission request is asking about. If the
481      * requested data is of mixed types, this returns {@link #DATA_GENERIC}.
482      */
resolveData()483     private @NonNull String resolveData() {
484         final LocalUriMatcher matcher = new LocalUriMatcher(MediaStore.AUTHORITY);
485         final int firstMatch = matcher.matchUri(uris.get(0), false);
486         for (int i = 1; i < uris.size(); i++) {
487             final int match = matcher.matchUri(uris.get(i), false);
488             if (match != firstMatch) {
489                 // Any mismatch means we need to use generic strings
490                 return DATA_GENERIC;
491             }
492         }
493         switch (firstMatch) {
494             case AUDIO_MEDIA_ID: return DATA_AUDIO;
495             case VIDEO_MEDIA_ID: return DATA_VIDEO;
496             case IMAGES_MEDIA_ID: return DATA_IMAGE;
497             default: return DATA_GENERIC;
498         }
499     }
500 
501     /**
502      * Resolve the dialog title string to be displayed to the user. All
503      * arguments have been bound and this string is ready to be displayed.
504      */
resolveTitleText()505     private @Nullable CharSequence resolveTitleText() {
506         final String resName = "permission_" + verb + "_" + data;
507         final int resId = getResources().getIdentifier(resName, "plurals",
508                 getResources().getResourcePackageName(R.string.app_label));
509         if (resId != 0) {
510             final int count = uris.size();
511             final CharSequence text = getResources().getQuantityText(resId, count);
512             return TextUtils.expandTemplate(text, label, String.valueOf(count));
513         } else {
514             // We always need a string to prompt the user with
515             throw new IllegalStateException("Invalid resource: " + resName);
516         }
517     }
518 
519     /**
520      * Resolve the progress message string to be displayed to the user. All
521      * arguments have been bound and this string is ready to be displayed.
522      */
resolveProgressMessageText()523     private @Nullable CharSequence resolveProgressMessageText() {
524         final String resName = "permission_progress_" + verb + "_" + data;
525         final int resId = getResources().getIdentifier(resName, "plurals",
526                 getResources().getResourcePackageName(R.string.app_label));
527         if (resId != 0) {
528             final int count = uris.size();
529             final CharSequence text = getResources().getQuantityText(resId, count);
530             return TextUtils.expandTemplate(text, String.valueOf(count));
531         } else {
532             // Only some actions have a progress message string; it's okay if
533             // there isn't one defined
534             return null;
535         }
536     }
537 
538     /**
539      * Recursively walk the given view hierarchy looking for the first
540      * {@link View} which matches the given predicate.
541      */
findViewByPredicate(@onNull View root, @NonNull Predicate<View> predicate)542     private static @Nullable View findViewByPredicate(@NonNull View root,
543             @NonNull Predicate<View> predicate) {
544         if (predicate.test(root)) {
545             return root;
546         }
547         if (root instanceof ViewGroup) {
548             final ViewGroup group = (ViewGroup) root;
549             for (int i = 0; i < group.getChildCount(); i++) {
550                 final View res = findViewByPredicate(group.getChildAt(i), predicate);
551                 if (res != null) {
552                     return res;
553                 }
554             }
555         }
556         return null;
557     }
558 
559     /**
560      * Task that will load a set of {@link Description} to be eventually
561      * displayed in the body of the dialog.
562      */
563     private class DescriptionTask extends AsyncTask<List<Uri>, Void, List<Description>> {
564         private View bodyView;
565         private Resources res;
566 
DescriptionTask(@onNull View bodyView)567         public DescriptionTask(@NonNull View bodyView) {
568             this.bodyView = bodyView;
569             this.res = bodyView.getContext().getResources();
570         }
571 
572         @Override
doInBackground(List<Uri>.... params)573         protected List<Description> doInBackground(List<Uri>... params) {
574             final List<Uri> uris = params[0];
575             final List<Description> res = new ArrayList<>();
576 
577             // If the size is zero, return the res directly.
578             if (uris.isEmpty()) {
579                 return res;
580             }
581 
582             // Default information that we'll load for each item
583             int loadFlags = Description.LOAD_THUMBNAIL | Description.LOAD_CONTENT_DESCRIPTION;
584             int neededThumbs = MAX_THUMBS;
585 
586             // If we're only asking for single item, load the full image
587             if (uris.size() == 1) {
588                 loadFlags |= Description.LOAD_FULL;
589             }
590 
591             // Sort the uris in DATA_GENERIC case (Image, Video, Audio, Others)
592             if (TextUtils.equals(data, DATA_GENERIC) && uris.size() > 1) {
593                 final ToIntFunction<Uri> score = (uri) -> {
594                     final LocalUriMatcher matcher = new LocalUriMatcher(MediaStore.AUTHORITY);
595                     final int match = matcher.matchUri(uri, false);
596 
597                     switch (match) {
598                         case AUDIO_MEDIA_ID: return ORDER_AUDIO;
599                         case VIDEO_MEDIA_ID: return ORDER_VIDEO;
600                         case IMAGES_MEDIA_ID: return ORDER_IMAGE;
601                         default: return ORDER_GENERIC;
602                     }
603                 };
604                 final Comparator<Uri> bestScore = (a, b) ->
605                         score.applyAsInt(a) - score.applyAsInt(b);
606 
607                 uris.sort(bestScore);
608             }
609 
610             for (Uri uri : uris) {
611                 try {
612                     final Description desc = new Description(bodyView.getContext(), uri, loadFlags);
613                     res.add(desc);
614 
615                     // Once we've loaded enough information to bind our UI, we
616                     // can skip loading data for remaining requested items, but
617                     // we still need to create them to show the correct counts
618                     if (desc.isVisual()) {
619                         neededThumbs--;
620                     }
621                     if (neededThumbs == 0) {
622                         loadFlags = 0;
623                     }
624                 } catch (Exception e) {
625                     // Keep rolling forward to try getting enough descriptions
626                     Log.w(TAG, e);
627                 }
628             }
629             return res;
630         }
631 
632         @Override
onPostExecute(List<Description> results)633         protected void onPostExecute(List<Description> results) {
634             // Decide how to bind results based on how many are visual
635             final List<Description> visualResults = results.stream().filter(Description::isVisual)
636                     .collect(Collectors.toList());
637             if (results.size() == 1 && visualResults.size() == 1) {
638                 bindAsFull(results.get(0));
639             } else if (!visualResults.isEmpty()) {
640                 bindAsThumbs(results, visualResults);
641             } else {
642                 bindAsText(results);
643             }
644 
645             // This is pretty hacky, but somehow our dynamic loading of content
646             // can confuse accessibility focus, so refocus on the actual dialog
647             // title to announce ourselves properly
648             titleView.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED);
649         }
650 
651         /**
652          * Bind dialog as a single full-bleed image. If there is no image, use
653          * the icon of Mime type instead.
654          */
bindAsFull(@onNull Description result)655         private void bindAsFull(@NonNull Description result) {
656             final ImageView thumbFull = bodyView.requireViewById(R.id.thumb_full);
657             if (result.full != null) {
658                 result.bindFull(thumbFull);
659             } else {
660                 thumbFull.setScaleType(ImageView.ScaleType.FIT_CENTER);
661                 thumbFull.setBackground(new ColorDrawable(getColor(R.color.thumb_gray_color)));
662                 result.bindMimeIcon(thumbFull);
663             }
664         }
665 
666         /**
667          * Bind dialog as a list of multiple thumbnails. If there is no thumbnail for some
668          * items, use the icons of the MIME type instead.
669          */
bindAsThumbs(@onNull List<Description> results, @NonNull List<Description> visualResults)670         private void bindAsThumbs(@NonNull List<Description> results,
671                 @NonNull List<Description> visualResults) {
672             final List<ImageView> thumbs = new ArrayList<>();
673             thumbs.add(bodyView.requireViewById(R.id.thumb1));
674             thumbs.add(bodyView.requireViewById(R.id.thumb2));
675             thumbs.add(bodyView.requireViewById(R.id.thumb3));
676 
677             // We're going to show the "more" tile when we can't display
678             // everything requested, but we have at least one visual item
679             final boolean showMore = (visualResults.size() != results.size())
680                     || (visualResults.size() > MAX_THUMBS);
681             if (showMore) {
682                 final View thumbMoreContainer = bodyView.requireViewById(R.id.thumb_more_container);
683                 final ImageView thumbMore = bodyView.requireViewById(R.id.thumb_more);
684                 final TextView thumbMoreText = bodyView.requireViewById(R.id.thumb_more_text);
685                 final View gradientView = bodyView.requireViewById(R.id.thumb_more_gradient);
686 
687                 // Since we only want three tiles displayed maximum, swap out
688                 // the first tile for our "more" tile
689                 thumbs.remove(0);
690                 thumbs.add(thumbMore);
691 
692                 final int shownCount = Math.min(visualResults.size(), MAX_THUMBS - 1);
693                 final int moreCount = results.size() - shownCount;
694                 final CharSequence moreText = TextUtils.expandTemplate(res.getQuantityText(
695                         R.plurals.permission_more_thumb, moreCount), String.valueOf(moreCount));
696 
697                 thumbMoreText.setText(moreText);
698                 thumbMoreContainer.setVisibility(View.VISIBLE);
699                 gradientView.setVisibility(View.VISIBLE);
700             }
701 
702             // Trim off extra thumbnails from the front of our list, so that we
703             // always bind any "more" item last
704             while (thumbs.size() > visualResults.size()) {
705                 thumbs.remove(0);
706             }
707 
708             // Finally we can bind all our thumbnails into place
709             for (int i = 0; i < thumbs.size(); i++) {
710                 final Description desc = visualResults.get(i);
711                 final ImageView imageView = thumbs.get(i);
712                 if (desc.thumbnail != null) {
713                     desc.bindThumbnail(imageView);
714                 } else {
715                     desc.bindMimeIcon(imageView);
716                 }
717             }
718         }
719 
720         /**
721          * Bind dialog as a list of text descriptions, typically when there's no
722          * visual representation of the items.
723          */
bindAsText(@onNull List<Description> results)724         private void bindAsText(@NonNull List<Description> results) {
725             final List<CharSequence> list = new ArrayList<>();
726             for (int i = 0; i < results.size(); i++) {
727                 if (TextUtils.isEmpty(results.get(i).contentDescription)) {
728                     continue;
729                 }
730                 list.add(results.get(i).contentDescription);
731 
732                 if (list.size() >= MAX_THUMBS && results.size() > list.size()) {
733                     final int moreCount = results.size() - list.size();
734                     final CharSequence moreText = TextUtils.expandTemplate(res.getQuantityText(
735                             R.plurals.permission_more_text, moreCount), String.valueOf(moreCount));
736                     list.add(moreText);
737                     break;
738                 }
739             }
740             if (!list.isEmpty()) {
741                 final TextView text = bodyView.requireViewById(R.id.list);
742                 text.setText(TextUtils.join("\n", list));
743                 text.setVisibility(View.VISIBLE);
744             }
745         }
746     }
747 
748     /**
749      * Description of a single media item.
750      */
751     private static class Description {
752         public @Nullable CharSequence contentDescription;
753         public @Nullable Bitmap thumbnail;
754         public @Nullable Bitmap full;
755         public @Nullable Icon mimeIcon;
756 
757         public static final int LOAD_CONTENT_DESCRIPTION = 1 << 0;
758         public static final int LOAD_THUMBNAIL = 1 << 1;
759         public static final int LOAD_FULL = 1 << 2;
760 
Description(Context context, Uri uri, int loadFlags)761         public Description(Context context, Uri uri, int loadFlags) {
762             final Resources res = context.getResources();
763             final ContentResolver resolver = context.getContentResolver();
764 
765             try {
766                 // Load description first so that we'll always have something
767                 // textual to display in case we have image trouble below
768                 if ((loadFlags & LOAD_CONTENT_DESCRIPTION) != 0) {
769                     try (Cursor c = resolver.query(uri,
770                             new String[] { MediaColumns.DISPLAY_NAME }, null, null)) {
771                         if (c.moveToFirst()) {
772                             contentDescription = c.getString(0);
773                         }
774                     }
775                 }
776                 if ((loadFlags & LOAD_THUMBNAIL) != 0) {
777                     final Size size = new Size(res.getDisplayMetrics().widthPixels,
778                             res.getDisplayMetrics().widthPixels);
779                     thumbnail = resolver.loadThumbnail(uri, size, null);
780                 }
781                 if ((loadFlags & LOAD_FULL) != 0) {
782                     // Only offer full decodes when a supported file type;
783                     // otherwise fall back to using thumbnail
784                     final String mimeType = resolver.getType(uri);
785                     if (ImageDecoder.isMimeTypeSupported(mimeType)) {
786                         full = ImageDecoder.decodeBitmap(ImageDecoder.createSource(resolver, uri),
787                                 new Resizer(context.getResources().getDisplayMetrics()));
788                     } else {
789                         full = thumbnail;
790                     }
791                 }
792             } catch (IOException e) {
793                 Log.w(TAG, e);
794                 if (thumbnail == null && full == null) {
795                     final String mimeType = resolver.getType(uri);
796                     if (mimeType != null) {
797                         mimeIcon = resolver.getTypeInfo(mimeType).getIcon();
798                     }
799                 }
800             }
801         }
802 
isVisual()803         public boolean isVisual() {
804             return thumbnail != null || full != null || mimeIcon != null;
805         }
806 
bindThumbnail(ImageView imageView)807         public void bindThumbnail(ImageView imageView) {
808             Objects.requireNonNull(thumbnail);
809             imageView.setImageBitmap(thumbnail);
810             imageView.setContentDescription(contentDescription);
811             imageView.setVisibility(View.VISIBLE);
812             imageView.setClipToOutline(true);
813         }
814 
bindFull(ImageView imageView)815         public void bindFull(ImageView imageView) {
816             Objects.requireNonNull(full);
817             imageView.setImageBitmap(full);
818             imageView.setContentDescription(contentDescription);
819             imageView.setVisibility(View.VISIBLE);
820         }
821 
bindMimeIcon(ImageView imageView)822         public void bindMimeIcon(ImageView imageView) {
823             Objects.requireNonNull(mimeIcon);
824             imageView.setImageIcon(mimeIcon);
825             imageView.setContentDescription(contentDescription);
826             imageView.setVisibility(View.VISIBLE);
827             imageView.setClipToOutline(true);
828         }
829     }
830 
831     /**
832      * Utility that will speed up decoding of large images, since we never need
833      * them to be larger than the screen dimensions.
834      */
835     private static class Resizer implements ImageDecoder.OnHeaderDecodedListener {
836         private final int maxSize;
837 
Resizer(DisplayMetrics metrics)838         public Resizer(DisplayMetrics metrics) {
839             this.maxSize = Math.max(metrics.widthPixels, metrics.heightPixels);
840         }
841 
842         @Override
onHeaderDecoded(ImageDecoder decoder, ImageInfo info, Source source)843         public void onHeaderDecoded(ImageDecoder decoder, ImageInfo info, Source source) {
844             // We requested a rough thumbnail size, but the remote size may have
845             // returned something giant, so defensively scale down as needed.
846             final int widthSample = info.getSize().getWidth() / maxSize;
847             final int heightSample = info.getSize().getHeight() / maxSize;
848             final int sample = Math.max(widthSample, heightSample);
849             if (sample > 1) {
850                 decoder.setTargetSampleSize(sample);
851             }
852         }
853     }
854 }
855