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