From 4649ef1769d53727d59423f184cb3ee068ce40db Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Mon, 13 Mar 2023 16:39:23 -0700 Subject: Add unified preview UI ChooserContentPreviewUi applies various heuristic to determine if each shared URI has a preview and, if any, displays a scrollable preview list. Each preview item in the list is badge accroding ot its type: no badge for images, a video-file badge for videos and a generic-file badge for all others. All URIs without a previwe are groupped under a single item at list end (+N files). Collateral changes: * FileContentPreviewUi$FileInfo is moved into the package level; * ChooserContentPreviewUi$ImageMimeTypeClassifier internface is moved into the package level, renamted to MimeTypeClassifier and defines a new method, isVideoType(); * ScrollableImagePreviewView is modified to support badges and the "+N" item; * A new class, UnfifiedContentPreviewUi is clonned from ImageContentPreviewUi class, and is reponsible for drawing the new unified preview ui, ImageContentPreviewUi is used only with the legacy image content preview. Bug: 271613784 Test: manual testing Change-Id: Ia25f5a1565226ac679cc8ecefd58acb95cb60142 --- .../android/intentresolver/ChooserActivity.java | 2 +- .../intentresolver/ImagePreviewImageLoader.kt | 10 +- .../contentpreview/ChooserContentPreviewUi.java | 306 +++++++++++++-------- .../contentpreview/FileContentPreviewUi.java | 101 +------ .../intentresolver/contentpreview/FileInfo.kt | 48 ++++ .../contentpreview/ImageContentPreviewUi.java | 19 +- .../contentpreview/MimeTypeClassifier.java | 38 +++ .../contentpreview/UnifiedContentPreviewUi.java | 202 ++++++++++++++ .../widget/ChooserImagePreviewView.kt | 2 +- .../intentresolver/widget/ImagePreviewView.kt | 1 - .../widget/RoundedRectImageView.java | 13 +- .../widget/ScrollableImagePreviewView.kt | 125 ++++++--- 12 files changed, 612 insertions(+), 255 deletions(-) create mode 100644 java/src/com/android/intentresolver/contentpreview/FileInfo.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/MimeTypeClassifier.java create mode 100644 java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index ae5be26d..37a17e79 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -714,7 +714,7 @@ public class ChooserActivity extends ResolverActivity implements } @VisibleForTesting - protected boolean isImageType(String mimeType) { + protected boolean isImageType(@Nullable String mimeType) { return mimeType != null && mimeType.startsWith("image/"); } diff --git a/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt index 7b6651a2..9650403e 100644 --- a/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt +++ b/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt @@ -19,6 +19,7 @@ package com.android.intentresolver import android.content.Context import android.graphics.Bitmap import android.net.Uri +import android.util.Log import android.util.Size import androidx.annotation.GuardedBy import androidx.annotation.VisibleForTesting @@ -32,6 +33,8 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.util.function.Consumer +private const val TAG = "ImagePreviewImageLoader" + @VisibleForTesting class ImagePreviewImageLoader @JvmOverloads constructor( private val context: Context, @@ -79,9 +82,12 @@ class ImagePreviewImageLoader @JvmOverloads constructor( } private fun CompletableDeferred.loadBitmap(uri: Uri) { - val bitmap = runCatching { + val bitmap = try { context.contentResolver.loadThumbnail(uri, thumbnailSize, null) - }.getOrNull() + } catch (t: Throwable) { + Log.d(TAG, "failed to load $uri preview", t) + null + } complete(bitmap) } } diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index 205be444..526b15ae 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -16,17 +16,23 @@ package com.android.intentresolver.contentpreview; -import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_FILE; +import static android.provider.DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL; + import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_IMAGE; -import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_TEXT; import android.content.ClipData; import android.content.ClipDescription; import android.content.ContentInterface; import android.content.Intent; import android.content.res.Resources; +import android.database.Cursor; +import android.media.MediaMetadata; import android.net.Uri; import android.os.RemoteException; +import android.provider.DocumentsContract; +import android.provider.Downloads; +import android.provider.OpenableColumns; +import android.text.TextUtils; import android.view.LayoutInflater; import android.view.ViewGroup; @@ -34,14 +40,14 @@ import androidx.annotation.Nullable; import com.android.intentresolver.ImageLoader; import com.android.intentresolver.flags.FeatureFlagRepository; +import com.android.intentresolver.flags.Flags; import com.android.intentresolver.widget.ActionRow; -import com.android.intentresolver.widget.ImagePreviewView; import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.function.Consumer; -import java.util.stream.Collectors; /** * Collection of helpers for building the content preview UI displayed in @@ -88,24 +94,12 @@ public final class ChooserContentPreviewUi { Consumer getExcludeSharedTextAction(); } - /** - * 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 com.android.intentresolver.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); - } - private final ContentPreviewUi mContentPreviewUi; public ChooserContentPreviewUi( Intent targetIntent, ContentInterface contentResolver, - ImageMimeTypeClassifier imageClassifier, + MimeTypeClassifier imageClassifier, ImageLoader imageLoader, ActionFactory actionFactory, TransitionElementStatusCallback transitionElementStatusCallback, @@ -127,37 +121,78 @@ public final class ChooserContentPreviewUi { private ContentPreviewUi createContentPreview( Intent targetIntent, ContentInterface contentResolver, - ImageMimeTypeClassifier imageClassifier, + MimeTypeClassifier typeClassifier, ImageLoader imageLoader, ActionFactory actionFactory, TransitionElementStatusCallback transitionElementStatusCallback, FeatureFlagRepository featureFlagRepository) { - int type = findPreferredContentPreview(targetIntent, contentResolver, imageClassifier); - switch (type) { - case CONTENT_PREVIEW_TEXT: - return createTextPreview( - targetIntent, actionFactory, imageLoader, featureFlagRepository); - case CONTENT_PREVIEW_FILE: - return new FileContentPreviewUi( - extractContentUris(targetIntent), - actionFactory, - imageLoader, - contentResolver, - featureFlagRepository); + /* 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. */ + final String action = targetIntent.getAction(); + final String type = targetIntent.getType(); + final boolean isSend = Intent.ACTION_SEND.equals(action); + final boolean isSendMultiple = Intent.ACTION_SEND_MULTIPLE.equals(action); - case CONTENT_PREVIEW_IMAGE: - return createImagePreview( - targetIntent, + if (!(isSend || isSendMultiple) + || (type != null && ClipDescription.compareMimeTypes(type, "text/*"))) { + return createTextPreview( + targetIntent, actionFactory, imageLoader, featureFlagRepository); + } + List uris = extractContentUris(targetIntent); + if (uris.isEmpty()) { + return createTextPreview( + targetIntent, actionFactory, imageLoader, featureFlagRepository); + } + ArrayList files = new ArrayList<>(uris.size()); + int previewCount = readFileInfo(contentResolver, typeClassifier, uris, files); + if (previewCount == 0) { + return new FileContentPreviewUi( + files, + actionFactory, + imageLoader, + featureFlagRepository); + } + if (featureFlagRepository.isEnabled(Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW)) { + return new UnifiedContentPreviewUi( + files, + targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT), + actionFactory, + imageLoader, + typeClassifier, + transitionElementStatusCallback, + featureFlagRepository); + } + if (previewCount < uris.size()) { + return new FileContentPreviewUi( + files, + actionFactory, + imageLoader, + featureFlagRepository); + } + // The legacy (3-image) image preview is on it's way out and it's unlikely that we'd end up + // here. To preserve the legacy behavior, before using it, check that all uris are images. + for (FileInfo fileInfo: files) { + if (!typeClassifier.isImageType(fileInfo.getMimeType())) { + return new FileContentPreviewUi( + files, actionFactory, - contentResolver, - imageClassifier, imageLoader, - transitionElementStatusCallback, featureFlagRepository); + } } - - return new NoContextPreviewUi(type); + return new ImageContentPreviewUi( + files.stream() + .map(FileInfo::getPreviewUri) + .filter(Objects::nonNull) + .toList(), + targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT), + actionFactory, + imageLoader, + transitionElementStatusCallback, + featureFlagRepository); } public int getPreferredContentPreview() { @@ -174,61 +209,98 @@ public final class ChooserContentPreviewUi { return mContentPreviewUi.display(resources, layoutInflater, parent); } - /** Determine the most appropriate type of preview to show for the provided {@link Intent}. */ - @ContentPreviewType - private static int findPreferredContentPreview( - Intent targetIntent, - ContentInterface 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. */ - final String action = targetIntent.getAction(); - final String type = targetIntent.getType(); - final boolean isSend = Intent.ACTION_SEND.equals(action); - final boolean isSendMultiple = Intent.ACTION_SEND_MULTIPLE.equals(action); - - if (!(isSend || isSendMultiple) - || (type != null && ClipDescription.compareMimeTypes(type, "text/*"))) { - return CONTENT_PREVIEW_TEXT; + private static int readFileInfo( + ContentInterface contentResolver, + MimeTypeClassifier typeClassifier, + List uris, + List fileInfos) { + int previewCount = 0; + for (Uri uri: uris) { + FileInfo fileInfo = getFileInfo(contentResolver, typeClassifier, uri); + if (fileInfo.getPreviewUri() != null) { + previewCount++; + } + fileInfos.add(fileInfo); } + return previewCount; + } - if (isSend) { - Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); - return findPreferredContentPreview(uri, resolver, imageClassifier); + private static FileInfo getFileInfo( + ContentInterface resolver, MimeTypeClassifier typeClassifier, Uri uri) { + FileInfo.Builder builder = new FileInfo.Builder(uri) + .withName(getFileName(uri)); + String mimeType = getType(resolver, uri); + builder.withMimeType(mimeType); + if (typeClassifier.isImageType(mimeType)) { + return builder.withPreviewUri(uri).build(); } - - List uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); - if (uris == null || uris.isEmpty()) { - return CONTENT_PREVIEW_TEXT; + readFileMetadata(resolver, uri, builder); + if (builder.getPreviewUri() == null) { + readOtherFileTypes(resolver, uri, typeClassifier, builder); } + return builder.build(); + } - 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; + private static void readFileMetadata( + ContentInterface resolver, Uri uri, FileInfo.Builder builder) { + Cursor cursor = query(resolver, uri); + if (cursor == null || !cursor.moveToFirst()) { + return; + } + int flagColIdx = -1; + int displayIconUriColIdx = -1; + int nameColIndex = -1; + int titleColIndex = -1; + String[] columns = cursor.getColumnNames(); + // TODO: double-check why Cursor#getColumnInded didn't work + for (int i = 0; i < columns.length; i++) { + String columnName = columns[i]; + if (DocumentsContract.Document.COLUMN_FLAGS.equals(columnName)) { + flagColIdx = i; + } else if (MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI.equals(columnName)) { + displayIconUriColIdx = i; + } else if (OpenableColumns.DISPLAY_NAME.equals(columnName)) { + nameColIndex = i; + } else if (Downloads.Impl.COLUMN_TITLE.equals(columnName)) { + titleColIndex = i; } } + String fileName = ""; + if (nameColIndex >= 0) { + fileName = cursor.getString(nameColIndex); + } else if (titleColIndex >= 0) { + fileName = cursor.getString(titleColIndex); + } + if (!TextUtils.isEmpty(fileName)) { + builder.withName(fileName); + } - return CONTENT_PREVIEW_IMAGE; - } - - @ContentPreviewType - private static int findPreferredContentPreview( - Uri uri, ContentInterface resolver, ImageMimeTypeClassifier imageClassifier) { - if (uri == null) { - return CONTENT_PREVIEW_TEXT; + Uri previewUri = null; + if (flagColIdx >= 0 && ((cursor.getInt(flagColIdx) & FLAG_SUPPORTS_THUMBNAIL) != 0)) { + previewUri = uri; + } else if (displayIconUriColIdx >= 0) { + String uriStr = cursor.getString(displayIconUriColIdx); + previewUri = uriStr == null ? null : Uri.parse(uriStr); } + if (previewUri != null) { + builder.withPreviewUri(previewUri); + } + } - String mimeType = null; - try { - mimeType = resolver.getType(uri); - } catch (RemoteException ignored) { + private static void readOtherFileTypes( + ContentInterface resolver, + Uri uri, + MimeTypeClassifier typeClassifier, + FileInfo.Builder builder) { + String[] otherTypes = getStreamTypes(resolver, uri); + if (otherTypes != null && otherTypes.length > 0) { + for (String mimeType : otherTypes) { + if (typeClassifier.isImageType(mimeType)) { + builder.withPreviewUri(uri); + break; + } + } } - return imageClassifier.isImageType(mimeType) ? CONTENT_PREVIEW_IMAGE : CONTENT_PREVIEW_FILE; } private static TextContentPreviewUi createTextPreview( @@ -255,39 +327,6 @@ public final class ChooserContentPreviewUi { featureFlagRepository); } - static ImageContentPreviewUi createImagePreview( - Intent targetIntent, - ChooserContentPreviewUi.ActionFactory actionFactory, - ContentInterface contentResolver, - ChooserContentPreviewUi.ImageMimeTypeClassifier imageClassifier, - ImageLoader imageLoader, - ImagePreviewView.TransitionElementStatusCallback transitionElementStatusCallback, - FeatureFlagRepository featureFlagRepository) { - CharSequence text = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); - String action = targetIntent.getAction(); - // TODO: why don't we use image classifier for single-element ACTION_SEND? - final List imageUris = Intent.ACTION_SEND.equals(action) - ? extractContentUris(targetIntent) - : extractContentUris(targetIntent) - .stream() - .filter(uri -> { - String type = null; - try { - type = contentResolver.getType(uri); - } catch (RemoteException ignored) { - } - return imageClassifier.isImageType(type); - }) - .collect(Collectors.toList()); - return new ImageContentPreviewUi( - imageUris, - text, - actionFactory, - imageLoader, - transitionElementStatusCallback, - featureFlagRepository); - } - private static List extractContentUris(Intent targetIntent) { List uris = new ArrayList<>(); if (Intent.ACTION_SEND.equals(targetIntent.getAction())) { @@ -307,4 +346,41 @@ public final class ChooserContentPreviewUi { } return uris; } + + @Nullable + private static String getType(ContentInterface resolver, Uri uri) { + try { + return resolver.getType(uri); + } catch (RemoteException e) { + return null; + } + } + + @Nullable + private static Cursor query(ContentInterface resolver, Uri uri) { + try { + return resolver.query(uri, null, null, null); + } catch (RemoteException e) { + return null; + } + } + + @Nullable + private static String[] getStreamTypes(ContentInterface resolver, Uri uri) { + try { + return resolver.getStreamTypes(uri, "*/*"); + } catch (RemoteException e) { + return null; + } + } + + private static String getFileName(Uri uri) { + String fileName = uri.getPath(); + fileName = fileName == null ? "" : fileName; + int index = fileName.lastIndexOf('/'); + if (index != -1) { + fileName = fileName.substring(index + 1); + } + return fileName; + } } diff --git a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java index 7cd71475..2c5def8b 100644 --- a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java @@ -16,15 +16,7 @@ package com.android.intentresolver.contentpreview; -import android.content.ContentInterface; import android.content.res.Resources; -import android.database.Cursor; -import android.net.Uri; -import android.os.RemoteException; -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; @@ -49,21 +41,19 @@ class FileContentPreviewUi extends ContentPreviewUi { private static final String PLURALS_COUNT = "count"; private static final String PLURALS_FILE_NAME = "file_name"; - private final List mUris; + private final List mFiles; private final ChooserContentPreviewUi.ActionFactory mActionFactory; private final ImageLoader mImageLoader; - private final ContentInterface mContentResolver; private final FeatureFlagRepository mFeatureFlagRepository; - FileContentPreviewUi(List uris, + FileContentPreviewUi( + List files, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, - ContentInterface contentResolver, FeatureFlagRepository featureFlagRepository) { - mUris = uris; + mFiles = files; mActionFactory = actionFactory; mImageLoader = imageLoader; - mContentResolver = contentResolver; mFeatureFlagRepository = featureFlagRepository; } @@ -85,7 +75,7 @@ class FileContentPreviewUi extends ContentPreviewUi { ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_file, parent, false); - final int uriCount = mUris.size(); + final int uriCount = mFiles.size(); if (uriCount == 0) { contentPreviewLayout.setVisibility(View.GONE); @@ -95,13 +85,13 @@ class FileContentPreviewUi extends ContentPreviewUi { } if (uriCount == 1) { - loadFileUriIntoView(mUris.get(0), contentPreviewLayout, mImageLoader, mContentResolver); + loadFileUriIntoView(mFiles.get(0), contentPreviewLayout, mImageLoader); } else { - FileInfo fileInfo = extractFileInfo(mUris.get(0), mContentResolver); + FileInfo fileInfo = mFiles.get(0); int remUriCount = uriCount - 1; Map arguments = new HashMap<>(); arguments.put(PLURALS_COUNT, remUriCount); - arguments.put(PLURALS_FILE_NAME, fileInfo.name); + arguments.put(PLURALS_FILE_NAME, fileInfo.getName()); String fileName = PluralsMessageFormatter.format(resources, arguments, R.string.file_count); @@ -143,19 +133,16 @@ class FileContentPreviewUi extends ContentPreviewUi { } private static void loadFileUriIntoView( - final Uri uri, + final FileInfo fileInfo, final View parent, - final ImageLoader imageLoader, - final ContentInterface contentResolver) { - FileInfo fileInfo = extractFileInfo(uri, contentResolver); - + final ImageLoader imageLoader) { TextView fileNameView = parent.findViewById( com.android.internal.R.id.content_preview_filename); - fileNameView.setText(fileInfo.name); + fileNameView.setText(fileInfo.getName()); - if (fileInfo.hasThumbnail) { + if (fileInfo.getPreviewUri() != null) { imageLoader.loadImage( - uri, + fileInfo.getPreviewUri(), (bitmap) -> updateViewWithImage( parent.findViewById( com.android.internal.R.id.content_preview_file_thumbnail), @@ -171,66 +158,4 @@ class FileContentPreviewUi extends ContentPreviewUi { fileIconView.setImageResource(R.drawable.chooser_file_generic); } } - - private static FileInfo extractFileInfo(Uri uri, ContentInterface 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) { - // 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"); - } - - if (TextUtils.isEmpty(fileName)) { - fileName = uri.getPath(); - fileName = fileName == null ? "" : fileName; - int index = fileName.lastIndexOf('/'); - if (index != -1) { - fileName = fileName.substring(index + 1); - } - } - - return new FileInfo(fileName, hasThumbnail); - } - - private static Cursor queryResolver(ContentInterface resolver, Uri uri) { - try { - return resolver.query(uri, null, null, null); - } catch (RemoteException e) { - return null; - } - } - - private static class FileInfo { - public final String name; - public final boolean hasThumbnail; - - FileInfo(String name, boolean hasThumbnail) { - this.name = name; - this.hasThumbnail = hasThumbnail; - } - } } diff --git a/java/src/com/android/intentresolver/contentpreview/FileInfo.kt b/java/src/com/android/intentresolver/contentpreview/FileInfo.kt new file mode 100644 index 00000000..527bfc8e --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/FileInfo.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2023 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.contentpreview + +import android.net.Uri + +internal class FileInfo private constructor( + val uri: Uri, + val name: String?, + val previewUri: Uri?, + val mimeType: String? +) { + class Builder(val uri: Uri) { + var name: String = "" + private set + var previewUri: Uri? = null + private set + var mimeType: String? = null + private set + + fun withName(name: String): Builder = apply { + this.name = name + } + + fun withPreviewUri(uri: Uri?): Builder = apply { + previewUri = uri + } + + fun withMimeType(mimeType: String?): Builder = apply { + this.mimeType = mimeType + } + + fun build(): FileInfo = FileInfo(uri, name, previewUri, mimeType) + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java index db26ab1b..5f3bdf40 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java @@ -39,7 +39,8 @@ import com.android.intentresolver.R; import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.flags.Flags; import com.android.intentresolver.widget.ActionRow; -import com.android.intentresolver.widget.ImagePreviewView; +import com.android.intentresolver.widget.ChooserImagePreviewView; +import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; import java.util.ArrayList; import java.util.List; @@ -51,7 +52,7 @@ class ImageContentPreviewUi extends ContentPreviewUi { private final CharSequence mText; private final ChooserContentPreviewUi.ActionFactory mActionFactory; private final ImageLoader mImageLoader; - private final ImagePreviewView.TransitionElementStatusCallback mTransitionElementStatusCallback; + private final TransitionElementStatusCallback mTransitionElementStatusCallback; private final FeatureFlagRepository mFeatureFlagRepository; ImageContentPreviewUi( @@ -59,7 +60,7 @@ class ImageContentPreviewUi extends ContentPreviewUi { @Nullable CharSequence text, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, - ImagePreviewView.TransitionElementStatusCallback transitionElementStatusCallback, + TransitionElementStatusCallback transitionElementStatusCallback, FeatureFlagRepository featureFlagRepository) { mImageUris = imageUris; mText = text; @@ -87,7 +88,7 @@ class ImageContentPreviewUi extends ContentPreviewUi { @LayoutRes int actionRowLayout = getActionRowLayout(mFeatureFlagRepository); ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_image, parent, false); - ImagePreviewView imagePreview = inflateImagePreviewView(contentPreviewLayout); + ChooserImagePreviewView imagePreview = inflateImagePreviewView(contentPreviewLayout); final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout); if (actionRow != null) { @@ -103,7 +104,7 @@ class ImageContentPreviewUi extends ContentPreviewUi { TAG, "Attempted to display image preview area with zero" + " available images detected in EXTRA_STREAM list"); - ((View) imagePreview).setVisibility(View.GONE); + imagePreview.setVisibility(View.GONE); mTransitionElementStatusCallback.onAllTransitionElementsReady(); return contentPreviewLayout; } @@ -129,14 +130,10 @@ class ImageContentPreviewUi extends ContentPreviewUi { return actions; } - private ImagePreviewView inflateImagePreviewView(ViewGroup previewLayout) { + private ChooserImagePreviewView inflateImagePreviewView(ViewGroup previewLayout) { ViewStub stub = previewLayout.findViewById(R.id.image_preview_stub); if (stub != null) { - int layoutId = - mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW) - ? R.layout.scrollable_image_preview_view - : R.layout.chooser_image_preview_view; - stub.setLayoutResource(layoutId); + stub.setLayoutResource(R.layout.chooser_image_preview_view); stub.inflate(); } return previewLayout.findViewById( diff --git a/java/src/com/android/intentresolver/contentpreview/MimeTypeClassifier.java b/java/src/com/android/intentresolver/contentpreview/MimeTypeClassifier.java new file mode 100644 index 00000000..5172dd29 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/MimeTypeClassifier.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2023 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.contentpreview; + +import android.content.ClipDescription; + +import androidx.annotation.Nullable; + +/** + * 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 com.android.intentresolver.ChooserActivity#isImageType(String)} into this + * class. + */ +public interface MimeTypeClassifier { + /** @return whether the specified {@code mimeType} is classified as an "image" type. */ + boolean isImageType(@Nullable String mimeType); + + /** @return whether the specified {@code mimeType} is classified as an "video" type */ + default boolean isVideoType(@Nullable String mimeType) { + return ClipDescription.compareMimeTypes(mimeType, "video/*"); + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java new file mode 100644 index 00000000..ee24d18f --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -0,0 +1,202 @@ +/* + * Copyright (C) 2023 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.contentpreview; + +import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_IMAGE; + +import android.content.res.Resources; +import android.text.TextUtils; +import android.text.util.Linkify; +import android.transition.TransitionManager; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewStub; +import android.widget.CheckBox; +import android.widget.TextView; + +import androidx.annotation.LayoutRes; +import androidx.annotation.Nullable; + +import com.android.intentresolver.ImageLoader; +import com.android.intentresolver.R; +import com.android.intentresolver.flags.FeatureFlagRepository; +import com.android.intentresolver.flags.Flags; +import com.android.intentresolver.widget.ActionRow; +import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; +import com.android.intentresolver.widget.ScrollableImagePreviewView; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; + +class UnifiedContentPreviewUi extends ContentPreviewUi { + private final List mFiles; + @Nullable + private final CharSequence mText; + private final ChooserContentPreviewUi.ActionFactory mActionFactory; + private final ImageLoader mImageLoader; + private final MimeTypeClassifier mTypeClassifier; + private final TransitionElementStatusCallback mTransitionElementStatusCallback; + private final FeatureFlagRepository mFeatureFlagRepository; + + UnifiedContentPreviewUi( + List files, + @Nullable CharSequence text, + ChooserContentPreviewUi.ActionFactory actionFactory, + ImageLoader imageLoader, + MimeTypeClassifier typeClassifier, + TransitionElementStatusCallback transitionElementStatusCallback, + FeatureFlagRepository featureFlagRepository) { + mFiles = files; + mText = text; + mActionFactory = actionFactory; + mImageLoader = imageLoader; + mTypeClassifier = typeClassifier; + mTransitionElementStatusCallback = transitionElementStatusCallback; + mFeatureFlagRepository = featureFlagRepository; + + mImageLoader.prePopulate(mFiles.stream() + .map(FileInfo::getPreviewUri) + .filter(Objects::nonNull) + .toList()); + } + + @Override + public int getType() { + return CONTENT_PREVIEW_IMAGE; + } + + @Override + public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { + ViewGroup layout = displayInternal(layoutInflater, parent); + displayPayloadReselectionAction(layout, mActionFactory, mFeatureFlagRepository); + return layout; + } + + private ViewGroup displayInternal(LayoutInflater layoutInflater, ViewGroup parent) { + @LayoutRes int actionRowLayout = getActionRowLayout(mFeatureFlagRepository); + ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( + R.layout.chooser_grid_preview_image, parent, false); + ScrollableImagePreviewView imagePreview = inflateImagePreviewView(contentPreviewLayout); + + final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout); + if (actionRow != null) { + actionRow.setActions( + createActions( + createImagePreviewActions(), + mActionFactory.createCustomActions(), + mFeatureFlagRepository)); + } + + if (mFiles.size() == 0) { + Log.i( + TAG, + "Attempted to display image preview area with zero" + + " available images detected in EXTRA_STREAM list"); + imagePreview.setVisibility(View.GONE); + mTransitionElementStatusCallback.onAllTransitionElementsReady(); + return contentPreviewLayout; + } + + setTextInImagePreviewVisibility(contentPreviewLayout, mActionFactory); + imagePreview.setTransitionElementStatusCallback(mTransitionElementStatusCallback); + List previews = mFiles.stream() + .filter(fileInfo -> fileInfo.getPreviewUri() != null) + .map(fileInfo -> + new ScrollableImagePreviewView.Preview( + getPreviewType(fileInfo.getMimeType()), + fileInfo.getPreviewUri())) + .toList(); + imagePreview.setPreviews( + previews, + mFiles.size() - previews.size(), + mImageLoader); + + return contentPreviewLayout; + } + + private List createImagePreviewActions() { + ArrayList actions = new ArrayList<>(2); + //TODO: add copy action; + ActionRow.Action action = mActionFactory.createNearbyButton(); + if (action != null) { + actions.add(action); + } + action = mActionFactory.createEditButton(); + if (action != null) { + actions.add(action); + } + return actions; + } + + private ScrollableImagePreviewView inflateImagePreviewView(ViewGroup previewLayout) { + ViewStub stub = previewLayout.findViewById(R.id.image_preview_stub); + if (stub != null) { + stub.setLayoutResource(R.layout.scrollable_image_preview_view); + stub.inflate(); + } + return previewLayout.findViewById( + com.android.internal.R.id.content_preview_image_area); + } + + private void setTextInImagePreviewVisibility( + ViewGroup contentPreview, ChooserContentPreviewUi.ActionFactory actionFactory) { + int visibility = mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW) + && !TextUtils.isEmpty(mText) + ? View.VISIBLE + : View.GONE; + + final TextView textView = contentPreview + .requireViewById(com.android.internal.R.id.content_preview_text); + CheckBox actionView = contentPreview + .requireViewById(R.id.include_text_action); + textView.setVisibility(visibility); + boolean isLink = visibility == View.VISIBLE && HttpUriMatcher.isHttpUri(mText.toString()); + textView.setAutoLinkMask(isLink ? Linkify.WEB_URLS : 0); + textView.setText(mText); + + if (visibility == View.VISIBLE) { + final int[] actionLabels = isLink + ? new int[] { R.string.include_link, R.string.exclude_link } + : new int[] { R.string.include_text, R.string.exclude_text }; + final Consumer shareTextAction = actionFactory.getExcludeSharedTextAction(); + actionView.setChecked(true); + actionView.setText(actionLabels[1]); + shareTextAction.accept(false); + actionView.setOnCheckedChangeListener((view, isChecked) -> { + view.setText(actionLabels[isChecked ? 1 : 0]); + TransitionManager.beginDelayedTransition((ViewGroup) textView.getParent()); + textView.setVisibility(isChecked ? View.VISIBLE : View.GONE); + shareTextAction.accept(!isChecked); + }); + } + actionView.setVisibility(visibility); + } + + private ScrollableImagePreviewView.PreviewType getPreviewType(String mimeType) { + if (mTypeClassifier.isImageType(mimeType)) { + return ScrollableImagePreviewView.PreviewType.Image; + } + if (mTypeClassifier.isVideoType(mimeType)) { + return ScrollableImagePreviewView.PreviewType.Video; + } + return ScrollableImagePreviewView.PreviewType.File; + } +} diff --git a/java/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt index ca94a95d..6273296d 100644 --- a/java/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt +++ b/java/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt @@ -74,7 +74,7 @@ class ChooserImagePreviewView : RelativeLayout, ImagePreviewView { transitionStatusElementCallback = callback } - override fun setImages(uris: List, imageLoader: ImageLoader) { + fun setImages(uris: List, imageLoader: ImageLoader) { loadImageJob?.cancel() loadImageJob = coroutineScope.launch { when (uris.size) { diff --git a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt index a166ef27..8813adca 100644 --- a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt +++ b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt @@ -23,7 +23,6 @@ internal typealias ImageLoader = suspend (Uri) -> Bitmap? interface ImagePreviewView { fun setTransitionElementStatusCallback(callback: TransitionElementStatusCallback?) - fun setImages(uris: List, imageLoader: ImageLoader) /** * [ImagePreviewView] progressively prepares views for shared element transition and reports diff --git a/java/src/com/android/intentresolver/widget/RoundedRectImageView.java b/java/src/com/android/intentresolver/widget/RoundedRectImageView.java index 8538041b..8ca6ed14 100644 --- a/java/src/com/android/intentresolver/widget/RoundedRectImageView.java +++ b/java/src/com/android/intentresolver/widget/RoundedRectImageView.java @@ -17,6 +17,7 @@ package com.android.intentresolver.widget; import android.content.Context; +import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; @@ -52,7 +53,17 @@ public class RoundedRectImageView extends ImageView { public RoundedRectImageView( Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); - mRadius = context.getResources().getDimensionPixelSize(R.dimen.chooser_corner_radius); + + final TypedArray a = context.obtainStyledAttributes( + attrs, + R.styleable.RoundedRectImageView, + defStyleAttr, + 0); + mRadius = a.getDimensionPixelSize(R.styleable.RoundedRectImageView_radius, -1); + if (mRadius < 0) { + mRadius = context.getResources().getDimensionPixelSize(R.dimen.chooser_corner_radius); + } + a.recycle(); mOverlayPaint.setColor(0x99000000); mOverlayPaint.setStyle(Paint.Style.FILL); diff --git a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt index 467c404a..c02a10a2 100644 --- a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt +++ b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt @@ -20,11 +20,13 @@ import android.content.Context import android.graphics.Rect import android.net.Uri import android.util.AttributeSet +import android.util.PluralsMessageFormatter import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ImageView +import android.widget.TextView import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.android.intentresolver.R @@ -33,11 +35,12 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import kotlinx.coroutines.cancel -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.plus +import kotlin.math.sign private const val TRANSITION_NAME = "screenshot_preview_image" +private const val PLURALS_COUNT = "count" class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { constructor(context: Context) : this(context, null) @@ -66,41 +69,64 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { previewAdapter.transitionStatusElementCallback = callback } - override fun setImages(uris: List, imageLoader: ImageLoader) { - previewAdapter.setImages(uris, imageLoader) + fun setPreviews(previews: List, otherItemCount: Int, imageLoader: ImageLoader) = + previewAdapter.setPreviews(previews, otherItemCount, imageLoader) + + class Preview(val type: PreviewType, val uri: Uri) + enum class PreviewType { + Image, Video, File } private class Adapter(private val context: Context) : RecyclerView.Adapter() { - private val uris = ArrayList() + private val previews = ArrayList() private var imageLoader: ImageLoader? = null + private var firstImagePos = -1 var transitionStatusElementCallback: TransitionElementStatusCallback? = null + private var otherItemCount = 0 - fun setImages(uris: List, imageLoader: ImageLoader) { - this.uris.clear() - this.uris.addAll(uris) + fun setPreviews( + previews: List, otherItemCount: Int, imageLoader: ImageLoader + ) { + this.previews.clear() + this.previews.addAll(previews) this.imageLoader = imageLoader + firstImagePos = previews.indexOfFirst { it.type == PreviewType.Image } + this.otherItemCount = maxOf(0, otherItemCount) notifyDataSetChanged() } override fun onCreateViewHolder(parent: ViewGroup, itemType: Int): ViewHolder { - return ViewHolder( - LayoutInflater.from(context) - .inflate(R.layout.image_preview_image_item, parent, false) - ) + val view = LayoutInflater.from(context).inflate(itemType, parent, false); + return if (itemType == R.layout.image_preview_other_item) { + OtherItemViewHolder(view) + } else { + PreviewViewHolder(view) + } } - override fun getItemCount(): Int = uris.size + override fun getItemCount(): Int = previews.size + otherItemCount.sign + + override fun getItemViewType(position: Int): Int { + return if (position == previews.size) { + R.layout.image_preview_other_item + } else { + R.layout.image_preview_image_item + } + } override fun onBindViewHolder(vh: ViewHolder, position: Int) { - vh.bind( - uris[position], - imageLoader ?: error("ImageLoader is missing"), - if (position == 0 && transitionStatusElementCallback != null) { - this::onTransitionElementReady - } else { - null - } - ) + when (vh) { + is OtherItemViewHolder -> vh.bind(otherItemCount) + is PreviewViewHolder -> vh.bind( + previews[position], + imageLoader ?: error("ImageLoader is missing"), + if (position == firstImagePos && transitionStatusElementCallback != null) { + this::onTransitionElementReady + } else { + null + } + ) + } } override fun onViewRecycled(vh: ViewHolder) { @@ -121,12 +147,18 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { } } - private class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + private sealed class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + abstract fun unbind() + } + + private class PreviewViewHolder(view: View) : ViewHolder(view) { private val image = view.requireViewById(R.id.image) + private val badgeFrame = view.requireViewById(R.id.badge_frame) + private val badge = view.requireViewById(R.id.badge) private var scope: CoroutineScope? = null fun bind( - uri: Uri, + preview: Preview, imageLoader: ImageLoader, previewReadyCallback: ((String) -> Unit)? ) { @@ -136,26 +168,35 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { } else { null } + badgeFrame.visibility = when (preview.type) { + PreviewType.Image -> View.GONE + PreviewType.Video -> { + badge.setImageResource(R.drawable.ic_file_video) + View.VISIBLE + } + else -> { + badge.setImageResource(R.drawable.chooser_file_generic) + View.VISIBLE + } + } resetScope().launch { - loadImage(uri, imageLoader, previewReadyCallback) + loadImage(preview.uri, imageLoader) + if (preview.type == PreviewType.Image) { + previewReadyCallback?.let { callback -> + image.waitForPreDraw() + callback(TRANSITION_NAME) + } + } } } - private suspend fun loadImage( - uri: Uri, - imageLoader: ImageLoader, - previewReadyCallback: ((String) -> Unit)? - ) { + private suspend fun loadImage(uri: Uri, imageLoader: ImageLoader) { val bitmap = runCatching { // it's expected for all loading/caching optimizations to be implemented by the // loader imageLoader(uri) }.getOrNull() image.setImageBitmap(bitmap) - previewReadyCallback?.let { callback -> - image.waitForPreDraw() - callback(TRANSITION_NAME) - } } private fun resetScope(): CoroutineScope = @@ -164,13 +205,27 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { scope = it } - fun unbind() { + override fun unbind() { scope?.cancel() scope = null } } - private class SpacingDecoration(private val margin: Int) : RecyclerView.ItemDecoration() { + private class OtherItemViewHolder(view: View) : ViewHolder(view) { + private val label = view.requireViewById(R.id.label) + + fun bind(count: Int) { + label.text = PluralsMessageFormatter.format( + itemView.context.resources, + mapOf(PLURALS_COUNT to count), + R.string.other_files + ) + } + + override fun unbind() = Unit + } + + private class SpacingDecoration(private val margin: Int) : ItemDecoration() { override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: State) { outRect.set(margin, 0, margin, 0) } -- cgit v1.2.3-59-g8ed1b