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