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