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