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