blob: 390c47c729ac407879018530ec074973c7ac8ac2 [file] [log] [blame]
/*
* Copyright (C) 2022 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.intentresolver;
import static java.lang.annotation.RetentionPolicy.SOURCE;
import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.annotation.IntDef;
import android.content.ClipData;
import android.content.ContentResolver;
import android.content.Intent;
import android.content.res.Resources;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.net.Uri;
import android.provider.DocumentsContract;
import android.provider.Downloads;
import android.provider.OpenableColumns;
import android.text.TextUtils;
import android.util.Log;
import android.util.PluralsMessageFormatter;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewStub;
import android.view.animation.DecelerateInterpolator;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.LayoutRes;
import androidx.annotation.Nullable;
import com.android.intentresolver.widget.ActionRow;
import com.android.intentresolver.widget.ImagePreviewView;
import com.android.intentresolver.widget.RoundedRectImageView;
import com.android.internal.annotations.VisibleForTesting;
import java.lang.annotation.Retention;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
/**
* Collection of helpers for building the content preview UI displayed in {@link ChooserActivity}.
*
* TODO: this "namespace" was pulled out of {@link ChooserActivity} as a bucket of static methods
* to show that they're one-shot procedures with no dependencies back to {@link ChooserActivity}
* state other than the delegates that are explicitly provided. There may be more appropriate
* abstractions (e.g., maybe this can be a "widget" added directly to the view hierarchy to show the
* appropriate preview), or it may at least be safe (and more convenient) to adopt a more "object
* oriented" design where the static specifiers are removed and some of the dependencies are cached
* as ivars when this "class" is initialized.
*/
public final class ChooserContentPreviewUi {
private static final int IMAGE_FADE_IN_MILLIS = 150;
/**
* Delegate to build the default system action buttons to display in the preview layout, if/when
* they're determined to be appropriate for the particular preview we display.
* TODO: clarify why action buttons are part of preview logic.
*/
public interface ActionFactory {
/** Create an action that copies the share content to the clipboard. */
ActionRow.Action createCopyButton();
/** Create an action that opens the share content in a system-default editor. */
@Nullable
ActionRow.Action createEditButton();
/** Create an "Share to Nearby" action. */
@Nullable
ActionRow.Action createNearbyButton();
/** Create custom actions */
List<ActionRow.Action> createCustomActions();
/**
* Provides a re-selection action, if any.
*/
@Nullable
Runnable getReselectionAction();
}
/**
* Testing shim to specify whether a given mime type is considered to be an "image."
*
* TODO: move away from {@link ChooserActivityOverrideData} as a model to configure our tests,
* then migrate {@link ChooserActivity#isImageType(String)} into this class.
*/
public interface ImageMimeTypeClassifier {
/** @return whether the specified {@code mimeType} is classified as an "image" type. */
boolean isImageType(String mimeType);
}
@Retention(SOURCE)
@IntDef({CONTENT_PREVIEW_FILE, CONTENT_PREVIEW_IMAGE, CONTENT_PREVIEW_TEXT})
private @interface ContentPreviewType {
}
// Starting at 1 since 0 is considered "undefined" for some of the database transformations
// of tron logs.
@VisibleForTesting
public static final int CONTENT_PREVIEW_IMAGE = 1;
@VisibleForTesting
public static final int CONTENT_PREVIEW_FILE = 2;
@VisibleForTesting
public static final int CONTENT_PREVIEW_TEXT = 3;
private static final String TAG = "ChooserPreview";
private static final String PLURALS_COUNT = "count";
private static final String PLURALS_FILE_NAME = "file_name";
/** Determine the most appropriate type of preview to show for the provided {@link Intent}. */
@ContentPreviewType
public static int findPreferredContentPreview(
Intent targetIntent,
ContentResolver resolver,
ImageMimeTypeClassifier imageClassifier) {
/* In {@link android.content.Intent#getType}, the app may specify a very general mime type
* that broadly covers all data being shared, such as {@literal *}/* when sending an image
* and text. We therefore should inspect each item for the preferred type, in order: IMAGE,
* FILE, TEXT. */
String action = targetIntent.getAction();
if (Intent.ACTION_SEND.equals(action)) {
Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM);
return findPreferredContentPreview(uri, resolver, imageClassifier);
} else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
List<Uri> uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
if (uris == null || uris.isEmpty()) {
return CONTENT_PREVIEW_TEXT;
}
for (Uri uri : uris) {
// Defaulting to file preview when there are mixed image/file types is
// preferable, as it shows the user the correct number of items being shared
int uriPreviewType = findPreferredContentPreview(uri, resolver, imageClassifier);
if (uriPreviewType == CONTENT_PREVIEW_FILE) {
return CONTENT_PREVIEW_FILE;
}
}
return CONTENT_PREVIEW_IMAGE;
}
return CONTENT_PREVIEW_TEXT;
}
/**
* Display a content preview of the specified {@code previewType} to preview the content of the
* specified {@code intent}.
*/
public static ViewGroup displayContentPreview(
@ContentPreviewType int previewType,
Intent targetIntent,
Resources resources,
LayoutInflater layoutInflater,
ActionFactory actionFactory,
@LayoutRes int actionRowLayout,
ViewGroup parent,
ImageLoader previewImageLoader,
Consumer<Boolean> onTransitionTargetReady,
ContentResolver contentResolver,
ImageMimeTypeClassifier imageClassifier) {
ViewGroup layout = null;
List<ActionRow.Action> customActions = actionFactory.createCustomActions();
switch (previewType) {
case CONTENT_PREVIEW_TEXT:
layout = displayTextContentPreview(
targetIntent,
layoutInflater,
createActions(
createTextPreviewActions(actionFactory),
customActions),
parent,
previewImageLoader,
actionRowLayout);
break;
case CONTENT_PREVIEW_IMAGE:
layout = displayImageContentPreview(
targetIntent,
layoutInflater,
createActions(
createImagePreviewActions(actionFactory),
customActions),
parent,
previewImageLoader,
onTransitionTargetReady,
contentResolver,
imageClassifier,
actionRowLayout);
break;
case CONTENT_PREVIEW_FILE:
layout = displayFileContentPreview(
targetIntent,
resources,
layoutInflater,
createActions(
createFilePreviewActions(actionFactory),
customActions),
parent,
previewImageLoader,
contentResolver,
actionRowLayout);
break;
default:
Log.e(TAG, "Unexpected content preview type: " + previewType);
}
Runnable reselectionAction = actionFactory.getReselectionAction();
if (reselectionAction != null && layout != null
&& ChooserActivity.ENABLE_RESELECTION_ACTION) {
View reselectionView = layout.findViewById(R.id.reselection_action);
if (reselectionView != null) {
reselectionView.setVisibility(View.VISIBLE);
reselectionView.setOnClickListener(view -> reselectionAction.run());
}
}
return layout;
}
private static List<ActionRow.Action> createActions(
List<ActionRow.Action> systemActions, List<ActionRow.Action> customActions) {
ArrayList<ActionRow.Action> actions =
new ArrayList<>(systemActions.size() + customActions.size());
actions.addAll(systemActions);
if (ChooserActivity.ENABLE_CUSTOM_ACTIONS) {
actions.addAll(customActions);
}
return actions;
}
private static Cursor queryResolver(ContentResolver resolver, Uri uri) {
return resolver.query(uri, null, null, null, null);
}
@ContentPreviewType
private static int findPreferredContentPreview(
Uri uri, ContentResolver resolver, ImageMimeTypeClassifier imageClassifier) {
if (uri == null) {
return CONTENT_PREVIEW_TEXT;
}
String mimeType = resolver.getType(uri);
return imageClassifier.isImageType(mimeType) ? CONTENT_PREVIEW_IMAGE : CONTENT_PREVIEW_FILE;
}
private static ViewGroup displayTextContentPreview(
Intent targetIntent,
LayoutInflater layoutInflater,
List<ActionRow.Action> actions,
ViewGroup parent,
ImageLoader previewImageLoader,
@LayoutRes int actionRowLayout) {
ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate(
R.layout.chooser_grid_preview_text, parent, false);
final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout);
if (actionRow != null) {
actionRow.setActions(actions);
}
CharSequence sharingText = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT);
if (sharingText == null) {
contentPreviewLayout
.findViewById(com.android.internal.R.id.content_preview_text_layout)
.setVisibility(View.GONE);
} else {
TextView textView = contentPreviewLayout.findViewById(
com.android.internal.R.id.content_preview_text);
textView.setText(sharingText);
}
String previewTitle = targetIntent.getStringExtra(Intent.EXTRA_TITLE);
if (TextUtils.isEmpty(previewTitle)) {
contentPreviewLayout
.findViewById(com.android.internal.R.id.content_preview_title_layout)
.setVisibility(View.GONE);
} else {
TextView previewTitleView = contentPreviewLayout.findViewById(
com.android.internal.R.id.content_preview_title);
previewTitleView.setText(previewTitle);
ClipData previewData = targetIntent.getClipData();
Uri previewThumbnail = null;
if (previewData != null) {
if (previewData.getItemCount() > 0) {
ClipData.Item previewDataItem = previewData.getItemAt(0);
previewThumbnail = previewDataItem.getUri();
}
}
ImageView previewThumbnailView = contentPreviewLayout.findViewById(
com.android.internal.R.id.content_preview_thumbnail);
if (previewThumbnail == null) {
previewThumbnailView.setVisibility(View.GONE);
} else {
previewImageLoader.loadImage(
previewThumbnail,
(bitmap) -> updateViewWithImage(
contentPreviewLayout.findViewById(
com.android.internal.R.id.content_preview_thumbnail),
bitmap));
}
}
return contentPreviewLayout;
}
private static List<ActionRow.Action> createTextPreviewActions(ActionFactory actionFactory) {
ArrayList<ActionRow.Action> actions = new ArrayList<>(2);
actions.add(actionFactory.createCopyButton());
ActionRow.Action nearbyAction = actionFactory.createNearbyButton();
if (nearbyAction != null) {
actions.add(nearbyAction);
}
return actions;
}
private static ViewGroup displayImageContentPreview(
Intent targetIntent,
LayoutInflater layoutInflater,
List<ActionRow.Action> actions,
ViewGroup parent,
ImageLoader imageLoader,
Consumer<Boolean> onTransitionTargetReady,
ContentResolver contentResolver,
ImageMimeTypeClassifier imageClassifier,
@LayoutRes int actionRowLayout) {
ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate(
R.layout.chooser_grid_preview_image, parent, false);
ImagePreviewView imagePreview = contentPreviewLayout.findViewById(
com.android.internal.R.id.content_preview_image_area);
final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout);
if (actionRow != null) {
actionRow.setActions(actions);
}
final ArrayList<Uri> imageUris = new ArrayList<>();
String action = targetIntent.getAction();
if (Intent.ACTION_SEND.equals(action)) {
// TODO: why don't we use image classifier in this case as well?
Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM);
imageUris.add(uri);
} else {
List<Uri> uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
for (Uri uri : uris) {
if (imageClassifier.isImageType(contentResolver.getType(uri))) {
imageUris.add(uri);
}
}
}
if (imageUris.size() == 0) {
Log.i(TAG, "Attempted to display image preview area with zero"
+ " available images detected in EXTRA_STREAM list");
((View) imagePreview).setVisibility(View.GONE);
onTransitionTargetReady.accept(false);
return contentPreviewLayout;
}
imagePreview.setSharedElementTransitionTarget(
ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME,
onTransitionTargetReady);
imagePreview.setImages(imageUris, imageLoader);
return contentPreviewLayout;
}
private static List<ActionRow.Action> createImagePreviewActions(
ActionFactory buttonFactory) {
ArrayList<ActionRow.Action> actions = new ArrayList<>(2);
//TODO: add copy action;
ActionRow.Action action = buttonFactory.createNearbyButton();
if (action != null) {
actions.add(action);
}
action = buttonFactory.createEditButton();
if (action != null) {
actions.add(action);
}
return actions;
}
private static ViewGroup displayFileContentPreview(
Intent targetIntent,
Resources resources,
LayoutInflater layoutInflater,
List<ActionRow.Action> actions,
ViewGroup parent,
ImageLoader imageLoader,
ContentResolver contentResolver,
@LayoutRes int actionRowLayout) {
ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate(
R.layout.chooser_grid_preview_file, parent, false);
List<Uri> uris = extractFileUris(targetIntent);
final int uriCount = uris.size();
if (uriCount == 0) {
contentPreviewLayout.setVisibility(View.GONE);
Log.i(TAG,
"Appears to be no uris available in EXTRA_STREAM, removing "
+ "preview area");
return contentPreviewLayout;
}
if (uriCount == 1) {
loadFileUriIntoView(uris.get(0), contentPreviewLayout, imageLoader, contentResolver);
} else {
FileInfo fileInfo = extractFileInfo(uris.get(0), contentResolver);
int remUriCount = uriCount - 1;
Map<String, Object> arguments = new HashMap<>();
arguments.put(PLURALS_COUNT, remUriCount);
arguments.put(PLURALS_FILE_NAME, fileInfo.name);
String fileName =
PluralsMessageFormatter.format(resources, arguments, R.string.file_count);
TextView fileNameView = contentPreviewLayout.findViewById(
com.android.internal.R.id.content_preview_filename);
fileNameView.setText(fileName);
View thumbnailView = contentPreviewLayout.findViewById(
com.android.internal.R.id.content_preview_file_thumbnail);
thumbnailView.setVisibility(View.GONE);
ImageView fileIconView = contentPreviewLayout.findViewById(
com.android.internal.R.id.content_preview_file_icon);
fileIconView.setVisibility(View.VISIBLE);
fileIconView.setImageResource(R.drawable.ic_file_copy);
}
final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout);
if (actionRow != null) {
actionRow.setActions(actions);
}
return contentPreviewLayout;
}
private static List<Uri> extractFileUris(Intent targetIntent) {
List<Uri> uris = new ArrayList<>();
if (Intent.ACTION_SEND.equals(targetIntent.getAction())) {
Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM);
if (uri != null) {
uris.add(uri);
}
} else {
List<Uri> receivedUris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
if (receivedUris != null) {
for (Uri uri : receivedUris) {
if (uri != null) {
uris.add(uri);
}
}
}
}
return uris;
}
private static List<ActionRow.Action> createFilePreviewActions(ActionFactory actionFactory) {
List<ActionRow.Action> actions = new ArrayList<>(1);
//TODO(b/120417119):
// add action buttonFactory.createCopyButton()
ActionRow.Action action = actionFactory.createNearbyButton();
if (action != null) {
actions.add(action);
}
return actions;
}
private static ActionRow inflateActionRow(ViewGroup parent, @LayoutRes int actionRowLayout) {
final ViewStub stub = parent.findViewById(com.android.intentresolver.R.id.action_row_stub);
if (stub != null) {
stub.setLayoutResource(actionRowLayout);
stub.inflate();
}
return parent.findViewById(com.android.internal.R.id.chooser_action_row);
}
private static void logContentPreviewWarning(Uri uri) {
// The ContentResolver already logs the exception. Log something more informative.
Log.w(TAG, "Could not load (" + uri.toString() + ") thumbnail/name for preview. If "
+ "desired, consider using Intent#createChooser to launch the ChooserActivity, "
+ "and set your Intent's clipData and flags in accordance with that method's "
+ "documentation");
}
private static void loadFileUriIntoView(
final Uri uri,
final View parent,
final ImageLoader imageLoader,
final ContentResolver contentResolver) {
FileInfo fileInfo = extractFileInfo(uri, contentResolver);
TextView fileNameView = parent.findViewById(
com.android.internal.R.id.content_preview_filename);
fileNameView.setText(fileInfo.name);
if (fileInfo.hasThumbnail) {
imageLoader.loadImage(
uri,
(bitmap) -> updateViewWithImage(
parent.findViewById(
com.android.internal.R.id.content_preview_file_thumbnail),
bitmap));
} else {
View thumbnailView = parent.findViewById(
com.android.internal.R.id.content_preview_file_thumbnail);
thumbnailView.setVisibility(View.GONE);
ImageView fileIconView = parent.findViewById(
com.android.internal.R.id.content_preview_file_icon);
fileIconView.setVisibility(View.VISIBLE);
fileIconView.setImageResource(R.drawable.chooser_file_generic);
}
}
private static void updateViewWithImage(RoundedRectImageView imageView, Bitmap image) {
if (image == null) {
imageView.setVisibility(View.GONE);
return;
}
imageView.setVisibility(View.VISIBLE);
imageView.setAlpha(0.0f);
imageView.setImageBitmap(image);
ValueAnimator fadeAnim = ObjectAnimator.ofFloat(imageView, "alpha", 0.0f, 1.0f);
fadeAnim.setInterpolator(new DecelerateInterpolator(1.0f));
fadeAnim.setDuration(IMAGE_FADE_IN_MILLIS);
fadeAnim.start();
}
private static FileInfo extractFileInfo(Uri uri, ContentResolver resolver) {
String fileName = null;
boolean hasThumbnail = false;
try (Cursor cursor = queryResolver(resolver, uri)) {
if (cursor != null && cursor.getCount() > 0) {
int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
int titleIndex = cursor.getColumnIndex(Downloads.Impl.COLUMN_TITLE);
int flagsIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_FLAGS);
cursor.moveToFirst();
if (nameIndex != -1) {
fileName = cursor.getString(nameIndex);
} else if (titleIndex != -1) {
fileName = cursor.getString(titleIndex);
}
if (flagsIndex != -1) {
hasThumbnail = (cursor.getInt(flagsIndex)
& DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL) != 0;
}
}
} catch (SecurityException | NullPointerException e) {
logContentPreviewWarning(uri);
}
if (TextUtils.isEmpty(fileName)) {
fileName = uri.getPath();
int index = fileName.lastIndexOf('/');
if (index != -1) {
fileName = fileName.substring(index + 1);
}
}
return new FileInfo(fileName, hasThumbnail);
}
private static class FileInfo {
public final String name;
public final boolean hasThumbnail;
FileInfo(String name, boolean hasThumbnail) {
this.name = name;
this.hasThumbnail = hasThumbnail;
}
}
private ChooserContentPreviewUi() {}
}