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