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