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