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