| /* |
| * Copyright (C) 2018 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.providers.media; |
| |
| import static com.android.providers.media.MediaProvider.AUDIO_MEDIA_ID; |
| import static com.android.providers.media.MediaProvider.IMAGES_MEDIA_ID; |
| import static com.android.providers.media.MediaProvider.VIDEO_MEDIA_ID; |
| import static com.android.providers.media.MediaProvider.collectUris; |
| import static com.android.providers.media.util.DatabaseUtils.getAsBoolean; |
| import static com.android.providers.media.util.Logging.TAG; |
| |
| import android.app.Activity; |
| import android.app.AlertDialog; |
| import android.app.ProgressDialog; |
| import android.content.ContentProviderOperation; |
| import android.content.ContentResolver; |
| import android.content.ContentValues; |
| import android.content.Context; |
| import android.content.DialogInterface; |
| import android.content.Intent; |
| import android.content.pm.ApplicationInfo; |
| import android.content.pm.PackageManager; |
| import android.content.pm.PackageManager.NameNotFoundException; |
| import android.content.res.Resources; |
| import android.database.Cursor; |
| import android.graphics.Bitmap; |
| import android.graphics.ImageDecoder; |
| import android.graphics.ImageDecoder.ImageInfo; |
| import android.graphics.ImageDecoder.Source; |
| import android.net.Uri; |
| import android.os.AsyncTask; |
| import android.os.Bundle; |
| import android.os.Handler; |
| import android.provider.MediaStore; |
| import android.provider.MediaStore.MediaColumns; |
| import android.text.TextUtils; |
| import android.text.format.DateUtils; |
| import android.util.DisplayMetrics; |
| import android.util.Log; |
| import android.util.Size; |
| import android.view.KeyEvent; |
| import android.view.View; |
| import android.view.ViewGroup; |
| import android.view.WindowManager; |
| import android.view.accessibility.AccessibilityEvent; |
| import android.widget.ImageView; |
| import android.widget.TextView; |
| |
| import androidx.annotation.NonNull; |
| import androidx.annotation.Nullable; |
| |
| import com.android.providers.media.MediaProvider.LocalUriMatcher; |
| import com.android.providers.media.util.Metrics; |
| |
| import java.io.IOException; |
| import java.util.ArrayList; |
| import java.util.List; |
| import java.util.Objects; |
| import java.util.function.Predicate; |
| import java.util.stream.Collectors; |
| |
| /** |
| * Permission dialog that asks for user confirmation before performing a |
| * specific action, such as granting access for a narrow set of media files to |
| * the calling app. |
| * |
| * @see MediaStore#createWriteRequest |
| * @see MediaStore#createTrashRequest |
| * @see MediaStore#createFavoriteRequest |
| * @see MediaStore#createDeleteRequest |
| */ |
| public class PermissionActivity extends Activity { |
| // TODO: narrow metrics to specific verb that was requested |
| |
| public static final int REQUEST_CODE = 42; |
| |
| private List<Uri> uris; |
| private ContentValues values; |
| |
| private CharSequence label; |
| private String verb; |
| private String data; |
| private String volumeName; |
| private ApplicationInfo appInfo; |
| |
| private ProgressDialog progressDialog; |
| private TextView titleView; |
| |
| private static final Long LEAST_SHOW_PROGRESS_TIME_MS = 300L; |
| |
| private static final String VERB_WRITE = "write"; |
| private static final String VERB_TRASH = "trash"; |
| private static final String VERB_UNTRASH = "untrash"; |
| private static final String VERB_FAVORITE = "favorite"; |
| private static final String VERB_UNFAVORITE = "unfavorite"; |
| private static final String VERB_DELETE = "delete"; |
| |
| private static final String DATA_AUDIO = "audio"; |
| private static final String DATA_VIDEO = "video"; |
| private static final String DATA_IMAGE = "image"; |
| private static final String DATA_GENERIC = "generic"; |
| |
| @Override |
| public void onCreate(Bundle savedInstanceState) { |
| super.onCreate(savedInstanceState); |
| |
| // Strategy borrowed from PermissionController |
| getWindow().addSystemFlags( |
| WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); |
| setFinishOnTouchOutside(false); |
| |
| // All untrusted input values here were validated when generating the |
| // original PendingIntent |
| try { |
| uris = collectUris(getIntent().getExtras().getParcelable(MediaStore.EXTRA_CLIP_DATA)); |
| values = getIntent().getExtras().getParcelable(MediaStore.EXTRA_CONTENT_VALUES); |
| |
| appInfo = resolveCallingAppInfo(); |
| label = resolveAppLabel(appInfo); |
| verb = resolveVerb(); |
| data = resolveData(); |
| volumeName = MediaStore.getVolumeName(uris.get(0)); |
| } catch (Exception e) { |
| Log.w(TAG, e); |
| finish(); |
| return; |
| } |
| |
| progressDialog = new ProgressDialog(this); |
| |
| // Favorite-related requests are automatically granted for now; we still |
| // make developers go through this no-op dialog flow to preserve our |
| // ability to start prompting in the future |
| switch (verb) { |
| case VERB_FAVORITE: |
| case VERB_UNFAVORITE: { |
| onPositiveAction(null, 0); |
| return; |
| } |
| } |
| |
| // Kick off async loading of description to show in dialog |
| final View bodyView = getLayoutInflater().inflate(R.layout.permission_body, null, false); |
| new DescriptionTask(bodyView).execute(uris); |
| |
| final CharSequence message = resolveMessageText(); |
| if (!TextUtils.isEmpty(message)) { |
| final TextView messageView = bodyView.requireViewById(R.id.message); |
| messageView.setVisibility(View.VISIBLE); |
| messageView.setText(message); |
| } |
| |
| final AlertDialog.Builder builder = new AlertDialog.Builder(this); |
| // We set the title in message so that the text doesn't get truncated |
| builder.setMessage(resolveTitleText()); |
| builder.setPositiveButton(R.string.allow, this::onPositiveAction); |
| builder.setNegativeButton(R.string.deny, this::onNegativeAction); |
| builder.setCancelable(false); |
| builder.setView(bodyView); |
| |
| final AlertDialog dialog = builder.show(); |
| |
| // The title is being set as a message above. |
| // We need to style it like the default AlertDialog title |
| TextView dialogMessage = (TextView) dialog.findViewById( |
| android.R.id.message); |
| if (dialogMessage != null) { |
| dialogMessage.setTextAppearance( |
| android.R.style.TextAppearance_DeviceDefault_DialogWindowTitle); |
| } else { |
| Log.w(TAG, "Couldn't find message element"); |
| } |
| |
| final WindowManager.LayoutParams params = dialog.getWindow().getAttributes(); |
| params.width = getResources().getDimensionPixelSize(R.dimen.permission_dialog_width); |
| dialog.getWindow().setAttributes(params); |
| |
| // Hunt around to find the title of our newly created dialog so we can |
| // adjust accessibility focus once descriptions have been loaded |
| titleView = (TextView) findViewByPredicate(dialog.getWindow().getDecorView(), (view) -> { |
| return (view instanceof TextView) && view.isImportantForAccessibility(); |
| }); |
| } |
| |
| private void onPositiveAction(@Nullable DialogInterface dialog, int which) { |
| // Disable the buttons |
| if (dialog != null) { |
| ((AlertDialog) dialog).getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false); |
| ((AlertDialog) dialog).getButton(AlertDialog.BUTTON_NEGATIVE).setEnabled(false); |
| } |
| |
| progressDialog.show(); |
| final long startTime = System.currentTimeMillis(); |
| new AsyncTask<Void, Void, Void>() { |
| @Override |
| protected Void doInBackground(Void... params) { |
| Log.d(TAG, "User allowed grant for " + uris); |
| Metrics.logPermissionGranted(volumeName, appInfo.uid, |
| getCallingPackage(), uris.size()); |
| try { |
| switch (getIntent().getAction()) { |
| case MediaStore.CREATE_WRITE_REQUEST_CALL: { |
| for (Uri uri : uris) { |
| grantUriPermission(getCallingPackage(), uri, |
| Intent.FLAG_GRANT_READ_URI_PERMISSION |
| | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); |
| } |
| break; |
| } |
| case MediaStore.CREATE_TRASH_REQUEST_CALL: |
| case MediaStore.CREATE_FAVORITE_REQUEST_CALL: { |
| final ArrayList<ContentProviderOperation> ops = new ArrayList<>(); |
| for (Uri uri : uris) { |
| ops.add(ContentProviderOperation.newUpdate(uri) |
| .withValues(values) |
| .withExtra(MediaStore.QUERY_ARG_ALLOW_MOVEMENT, true) |
| .withExceptionAllowed(true) |
| .build()); |
| } |
| getContentResolver().applyBatch(MediaStore.AUTHORITY, ops); |
| break; |
| } |
| case MediaStore.CREATE_DELETE_REQUEST_CALL: { |
| final ArrayList<ContentProviderOperation> ops = new ArrayList<>(); |
| for (Uri uri : uris) { |
| ops.add(ContentProviderOperation.newDelete(uri) |
| .withExceptionAllowed(true) |
| .build()); |
| } |
| getContentResolver().applyBatch(MediaStore.AUTHORITY, ops); |
| break; |
| } |
| } |
| } catch (Exception e) { |
| Log.w(TAG, e); |
| } |
| return null; |
| } |
| |
| @Override |
| protected void onPostExecute(Void result) { |
| setResult(Activity.RESULT_OK); |
| // Don't dismiss the progress dialog too quick, it will cause bad UX. |
| final long duration = System.currentTimeMillis() - startTime; |
| if (duration > LEAST_SHOW_PROGRESS_TIME_MS) { |
| progressDialog.dismiss(); |
| finish(); |
| } else { |
| Handler handler = new Handler(getMainLooper()); |
| handler.postDelayed(() -> { |
| progressDialog.dismiss(); |
| finish(); |
| }, LEAST_SHOW_PROGRESS_TIME_MS - duration); |
| } |
| } |
| }.execute(); |
| } |
| |
| private void onNegativeAction(DialogInterface dialog, int which) { |
| new AsyncTask<Void, Void, Void>() { |
| @Override |
| protected Void doInBackground(Void... params) { |
| Log.d(TAG, "User declined request for " + uris); |
| Metrics.logPermissionDenied(volumeName, appInfo.uid, getCallingPackage(), |
| 1); |
| return null; |
| } |
| |
| @Override |
| protected void onPostExecute(Void result) { |
| setResult(Activity.RESULT_CANCELED); |
| finish(); |
| } |
| }.execute(); |
| } |
| |
| @Override |
| public boolean onKeyDown(int keyCode, KeyEvent event) { |
| // Strategy borrowed from PermissionController |
| return keyCode == KeyEvent.KEYCODE_BACK; |
| } |
| |
| @Override |
| public boolean onKeyUp(int keyCode, KeyEvent event) { |
| // Strategy borrowed from PermissionController |
| return keyCode == KeyEvent.KEYCODE_BACK; |
| } |
| |
| /** |
| * Resolve a label that represents the app denoted by given {@link ApplicationInfo}. |
| */ |
| private @NonNull CharSequence resolveAppLabel(final ApplicationInfo ai) |
| throws NameNotFoundException { |
| final PackageManager pm = getPackageManager(); |
| final CharSequence callingLabel = pm.getApplicationLabel(ai); |
| if (TextUtils.isEmpty(callingLabel)) { |
| throw new NameNotFoundException("Missing calling package"); |
| } |
| |
| return callingLabel; |
| } |
| |
| /** |
| * Resolve the application info of the calling app. |
| */ |
| private @NonNull ApplicationInfo resolveCallingAppInfo() throws NameNotFoundException { |
| final String callingPackage = getCallingPackage(); |
| if (TextUtils.isEmpty(callingPackage)) { |
| throw new NameNotFoundException("Missing calling package"); |
| } |
| |
| return getPackageManager().getApplicationInfo(callingPackage, 0); |
| } |
| |
| private @NonNull String resolveVerb() { |
| switch (getIntent().getAction()) { |
| case MediaStore.CREATE_WRITE_REQUEST_CALL: |
| return VERB_WRITE; |
| case MediaStore.CREATE_TRASH_REQUEST_CALL: |
| return getAsBoolean(values, MediaColumns.IS_TRASHED, false) |
| ? VERB_TRASH : VERB_UNTRASH; |
| case MediaStore.CREATE_FAVORITE_REQUEST_CALL: |
| return getAsBoolean(values, MediaColumns.IS_FAVORITE, false) |
| ? VERB_FAVORITE : VERB_UNFAVORITE; |
| case MediaStore.CREATE_DELETE_REQUEST_CALL: |
| return VERB_DELETE; |
| default: |
| throw new IllegalArgumentException("Invalid action: " + getIntent().getAction()); |
| } |
| } |
| |
| /** |
| * Resolve what kind of data this permission request is asking about. If the |
| * requested data is of mixed types, this returns {@link #DATA_GENERIC}. |
| */ |
| private @NonNull String resolveData() { |
| final LocalUriMatcher matcher = new LocalUriMatcher(MediaStore.AUTHORITY); |
| final int firstMatch = matcher.matchUri(uris.get(0), false); |
| for (int i = 1; i < uris.size(); i++) { |
| final int match = matcher.matchUri(uris.get(i), false); |
| if (match != firstMatch) { |
| // Any mismatch means we need to use generic strings |
| return DATA_GENERIC; |
| } |
| } |
| switch (firstMatch) { |
| case AUDIO_MEDIA_ID: return DATA_AUDIO; |
| case VIDEO_MEDIA_ID: return DATA_VIDEO; |
| case IMAGES_MEDIA_ID: return DATA_IMAGE; |
| default: return DATA_GENERIC; |
| } |
| } |
| |
| /** |
| * Resolve the dialog title string to be displayed to the user. All |
| * arguments have been bound and this string is ready to be displayed. |
| */ |
| private @Nullable CharSequence resolveTitleText() { |
| final String resName = "permission_" + verb + "_" + data; |
| final int resId = getResources().getIdentifier(resName, "plurals", |
| getResources().getResourcePackageName(R.string.app_label)); |
| if (resId != 0) { |
| final int count = uris.size(); |
| final CharSequence text = getResources().getQuantityText(resId, count); |
| return TextUtils.expandTemplate(text, label, String.valueOf(count)); |
| } else { |
| // We always need a string to prompt the user with |
| throw new IllegalStateException("Invalid resource: " + resName); |
| } |
| } |
| |
| /** |
| * Resolve the dialog message string to be displayed to the user, if any. |
| * All arguments have been bound and this string is ready to be displayed. |
| */ |
| private @Nullable CharSequence resolveMessageText() { |
| final String resName = "permission_" + verb + "_" + data + "_info"; |
| final int resId = getResources().getIdentifier(resName, "plurals", |
| getResources().getResourcePackageName(R.string.app_label)); |
| if (resId != 0) { |
| final int count = uris.size(); |
| final long durationMillis = (values.getAsLong(MediaColumns.DATE_EXPIRES) * 1000) |
| - System.currentTimeMillis(); |
| final long durationDays = (durationMillis + DateUtils.DAY_IN_MILLIS) |
| / DateUtils.DAY_IN_MILLIS; |
| final CharSequence text = getResources().getQuantityText(resId, count); |
| return TextUtils.expandTemplate(text, label, String.valueOf(count), |
| String.valueOf(durationDays)); |
| } else { |
| // Only some actions have a secondary message string; it's okay if |
| // there isn't one defined |
| return null; |
| } |
| } |
| |
| private @NonNull CharSequence resolvePositiveText() { |
| final String resName = "permission_" + verb + "_grant"; |
| final int resId = getResources().getIdentifier(resName, "string", |
| getResources().getResourcePackageName(R.string.app_label)); |
| return getResources().getText(resId); |
| } |
| |
| private @NonNull CharSequence resolveNegativeText() { |
| final String resName = "permission_" + verb + "_deny"; |
| final int resId = getResources().getIdentifier(resName, "string", |
| getResources().getResourcePackageName(R.string.app_label)); |
| return getResources().getText(resId); |
| } |
| |
| /** |
| * Recursively walk the given view hierarchy looking for the first |
| * {@link View} which matches the given predicate. |
| */ |
| private static @Nullable View findViewByPredicate(@NonNull View root, |
| @NonNull Predicate<View> predicate) { |
| if (predicate.test(root)) { |
| return root; |
| } |
| if (root instanceof ViewGroup) { |
| final ViewGroup group = (ViewGroup) root; |
| for (int i = 0; i < group.getChildCount(); i++) { |
| final View res = findViewByPredicate(group.getChildAt(i), predicate); |
| if (res != null) { |
| return res; |
| } |
| } |
| } |
| return null; |
| } |
| |
| /** |
| * Task that will load a set of {@link Description} to be eventually |
| * displayed in the body of the dialog. |
| */ |
| private class DescriptionTask extends AsyncTask<List<Uri>, Void, List<Description>> { |
| private static final int MAX_THUMBS = 3; |
| |
| private View bodyView; |
| private Resources res; |
| |
| public DescriptionTask(@NonNull View bodyView) { |
| this.bodyView = bodyView; |
| this.res = bodyView.getContext().getResources(); |
| } |
| |
| @Override |
| protected List<Description> doInBackground(List<Uri>... params) { |
| final List<Uri> uris = params[0]; |
| final List<Description> res = new ArrayList<>(); |
| |
| // If the size is zero, return the res directly. |
| if (uris.isEmpty()) { |
| return res; |
| } |
| |
| // Default information that we'll load for each item |
| int loadFlags = Description.LOAD_THUMBNAIL | Description.LOAD_CONTENT_DESCRIPTION; |
| int neededThumbs = MAX_THUMBS; |
| |
| // If we're only asking for single item, load the full image |
| if (uris.size() == 1) { |
| // Set visible to the thumb_full to avoid the size |
| // changed of the dialog in full decoding. |
| final ImageView thumbFull = bodyView.requireViewById(R.id.thumb_full); |
| thumbFull.setVisibility(View.VISIBLE); |
| loadFlags |= Description.LOAD_FULL; |
| } else { |
| // If the size equals 2, we will remove thumb1 later. |
| // Set visible to the thumb2 and thumb3 first to avoid |
| // the size changed of the dialog. |
| ImageView thumb = bodyView.requireViewById(R.id.thumb2); |
| thumb.setVisibility(View.VISIBLE); |
| thumb = bodyView.requireViewById(R.id.thumb3); |
| thumb.setVisibility(View.VISIBLE); |
| // If the count of thumbs equals to MAX_THUMBS, set visible to thumb1. |
| if (uris.size() == MAX_THUMBS) { |
| thumb = bodyView.requireViewById(R.id.thumb1); |
| thumb.setVisibility(View.VISIBLE); |
| } else if (uris.size() > MAX_THUMBS) { |
| // If the count is larger than MAX_THUMBS, set visible to |
| // thumb_more_container. |
| final View container = bodyView.requireViewById(R.id.thumb_more_container); |
| container.setVisibility(View.VISIBLE); |
| } |
| } |
| |
| for (Uri uri : uris) { |
| try { |
| final Description desc = new Description(bodyView.getContext(), uri, loadFlags); |
| res.add(desc); |
| |
| // Once we've loaded enough information to bind our UI, we |
| // can skip loading data for remaining requested items, but |
| // we still need to create them to show the correct counts |
| if (desc.isVisual()) { |
| neededThumbs--; |
| } |
| if (neededThumbs == 0) { |
| loadFlags = 0; |
| } |
| } catch (Exception e) { |
| // Keep rolling forward to try getting enough descriptions |
| Log.w(TAG, e); |
| } |
| } |
| return res; |
| } |
| |
| @Override |
| protected void onPostExecute(List<Description> results) { |
| // Decide how to bind results based on how many are visual |
| final List<Description> visualResults = results.stream().filter(Description::isVisual) |
| .collect(Collectors.toList()); |
| if (results.size() == 1 && visualResults.size() == 1) { |
| bindAsFull(results.get(0)); |
| } else if (!visualResults.isEmpty()) { |
| bindAsThumbs(results, visualResults); |
| } else { |
| bindAsText(results); |
| } |
| |
| // This is pretty hacky, but somehow our dynamic loading of content |
| // can confuse accessibility focus, so refocus on the actual dialog |
| // title to announce ourselves properly |
| titleView.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED); |
| } |
| |
| /** |
| * Bind dialog as a single full-bleed image. |
| */ |
| private void bindAsFull(@NonNull Description result) { |
| final ImageView thumbFull = bodyView.requireViewById(R.id.thumb_full); |
| result.bindFull(thumbFull); |
| } |
| |
| /** |
| * Bind dialog as a list of multiple thumbnails. |
| */ |
| private void bindAsThumbs(@NonNull List<Description> results, |
| @NonNull List<Description> visualResults) { |
| final List<ImageView> thumbs = new ArrayList<>(); |
| thumbs.add(bodyView.requireViewById(R.id.thumb1)); |
| thumbs.add(bodyView.requireViewById(R.id.thumb2)); |
| thumbs.add(bodyView.requireViewById(R.id.thumb3)); |
| |
| // We're going to show the "more" tile when we can't display |
| // everything requested, but we have at least one visual item |
| final boolean showMore = (visualResults.size() != results.size()) |
| || (visualResults.size() > MAX_THUMBS); |
| if (showMore) { |
| final View thumbMoreContainer = bodyView.requireViewById(R.id.thumb_more_container); |
| final ImageView thumbMore = bodyView.requireViewById(R.id.thumb_more); |
| final TextView thumbMoreText = bodyView.requireViewById(R.id.thumb_more_text); |
| |
| // Since we only want three tiles displayed maximum, swap out |
| // the first tile for our "more" tile |
| thumbs.remove(0); |
| thumbs.add(thumbMore); |
| |
| final int shownCount = Math.min(visualResults.size(), MAX_THUMBS - 1); |
| final int moreCount = results.size() - shownCount; |
| final CharSequence moreText = TextUtils.expandTemplate(res.getQuantityText( |
| R.plurals.permission_more_thumb, moreCount), String.valueOf(moreCount)); |
| |
| thumbMoreText.setText(moreText); |
| thumbMoreContainer.setVisibility(View.VISIBLE); |
| } |
| |
| // Trim off extra thumbnails from the front of our list, so that we |
| // always bind any "more" item last |
| while (thumbs.size() > visualResults.size()) { |
| thumbs.remove(0); |
| } |
| |
| // Finally we can bind all our thumbnails into place |
| for (int i = 0; i < thumbs.size(); i++) { |
| final Description desc = visualResults.get(i); |
| final ImageView imageView = thumbs.get(i); |
| desc.bindThumbnail(imageView); |
| } |
| } |
| |
| /** |
| * Bind dialog as a list of text descriptions, typically when there's no |
| * visual representation of the items. |
| */ |
| private void bindAsText(@NonNull List<Description> results) { |
| final List<CharSequence> list = new ArrayList<>(); |
| for (int i = 0; i < results.size(); i++) { |
| list.add(results.get(i).contentDescription); |
| |
| if (list.size() >= MAX_THUMBS && results.size() > list.size()) { |
| final int moreCount = results.size() - list.size(); |
| final CharSequence moreText = TextUtils.expandTemplate(res.getQuantityText( |
| R.plurals.permission_more_text, moreCount), String.valueOf(moreCount)); |
| list.add(moreText); |
| break; |
| } |
| } |
| |
| final TextView text = bodyView.requireViewById(R.id.list); |
| text.setText(TextUtils.join("\n", list)); |
| text.setVisibility(View.VISIBLE); |
| } |
| } |
| |
| /** |
| * Description of a single media item. |
| */ |
| private static class Description { |
| public @Nullable CharSequence contentDescription; |
| public @Nullable Bitmap thumbnail; |
| public @Nullable Bitmap full; |
| |
| public static final int LOAD_CONTENT_DESCRIPTION = 1 << 0; |
| public static final int LOAD_THUMBNAIL = 1 << 1; |
| public static final int LOAD_FULL = 1 << 2; |
| |
| public Description(Context context, Uri uri, int loadFlags) { |
| final Resources res = context.getResources(); |
| final ContentResolver resolver = context.getContentResolver(); |
| |
| try { |
| // Load description first so that we'll always have something |
| // textual to display in case we have image trouble below |
| if ((loadFlags & LOAD_CONTENT_DESCRIPTION) != 0) { |
| try (Cursor c = resolver.query(uri, |
| new String[] { MediaColumns.DISPLAY_NAME }, null, null)) { |
| if (c.moveToFirst()) { |
| contentDescription = c.getString(0); |
| } |
| } |
| } |
| if ((loadFlags & LOAD_THUMBNAIL) != 0) { |
| final Size size = new Size(res.getDisplayMetrics().widthPixels, |
| res.getDisplayMetrics().widthPixels); |
| thumbnail = resolver.loadThumbnail(uri, size, null); |
| } |
| if ((loadFlags & LOAD_FULL) != 0) { |
| // Only offer full decodes when a supported file type; |
| // otherwise fall back to using thumbnail |
| final String mimeType = resolver.getType(uri); |
| if (ImageDecoder.isMimeTypeSupported(mimeType)) { |
| full = ImageDecoder.decodeBitmap(ImageDecoder.createSource(resolver, uri), |
| new Resizer(context.getResources().getDisplayMetrics())); |
| } else { |
| full = thumbnail; |
| } |
| } |
| } catch (IOException e) { |
| Log.w(TAG, e); |
| } |
| } |
| |
| public boolean isVisual() { |
| return thumbnail != null || full != null; |
| } |
| |
| public void bindThumbnail(ImageView imageView) { |
| Objects.requireNonNull(thumbnail); |
| imageView.setImageBitmap(thumbnail); |
| imageView.setContentDescription(contentDescription); |
| imageView.setVisibility(View.VISIBLE); |
| imageView.setClipToOutline(true); |
| } |
| |
| public void bindFull(ImageView imageView) { |
| Objects.requireNonNull(full); |
| imageView.setImageBitmap(full); |
| imageView.setContentDescription(contentDescription); |
| imageView.setVisibility(View.VISIBLE); |
| } |
| } |
| |
| /** |
| * Utility that will speed up decoding of large images, since we never need |
| * them to be larger than the screen dimensions. |
| */ |
| private static class Resizer implements ImageDecoder.OnHeaderDecodedListener { |
| private final int maxSize; |
| |
| public Resizer(DisplayMetrics metrics) { |
| this.maxSize = Math.max(metrics.widthPixels, metrics.heightPixels); |
| } |
| |
| @Override |
| public void onHeaderDecoded(ImageDecoder decoder, ImageInfo info, Source source) { |
| // We requested a rough thumbnail size, but the remote size may have |
| // returned something giant, so defensively scale down as needed. |
| final int widthSample = info.getSize().getWidth() / maxSize; |
| final int heightSample = info.getSize().getHeight() / maxSize; |
| final int sample = Math.max(widthSample, heightSample); |
| if (sample > 1) { |
| decoder.setTargetSampleSize(sample); |
| } |
| } |
| } |
| } |