summaryrefslogtreecommitdiff
path: root/java
diff options
context:
space:
mode:
Diffstat (limited to 'java')
-rw-r--r--java/res/drawable/content_preview_badge_bg.xml27
-rw-r--r--java/res/drawable/ic_file_video.xml27
-rw-r--r--java/res/layout/image_preview_image_item.xml36
-rw-r--r--java/res/layout/image_preview_other_item.xml31
-rw-r--r--java/res/values/attrs.xml4
-rw-r--r--java/res/values/dimens.xml1
-rw-r--r--java/res/values/strings.xml7
-rw-r--r--java/src/com/android/intentresolver/ChooserActivity.java2
-rw-r--r--java/src/com/android/intentresolver/ImagePreviewImageLoader.kt10
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java306
-rw-r--r--java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java101
-rw-r--r--java/src/com/android/intentresolver/contentpreview/FileInfo.kt48
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java19
-rw-r--r--java/src/com/android/intentresolver/contentpreview/MimeTypeClassifier.java38
-rw-r--r--java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java202
-rw-r--r--java/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt2
-rw-r--r--java/src/com/android/intentresolver/widget/ImagePreviewView.kt1
-rw-r--r--java/src/com/android/intentresolver/widget/RoundedRectImageView.java13
-rw-r--r--java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt125
-rw-r--r--java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt91
20 files changed, 826 insertions, 265 deletions
diff --git a/java/res/drawable/content_preview_badge_bg.xml b/java/res/drawable/content_preview_badge_bg.xml
new file mode 100644
index 00000000..087247e7
--- /dev/null
+++ b/java/res/drawable/content_preview_badge_bg.xml
@@ -0,0 +1,27 @@
+<!--
+ ~ 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.
+ -->
+
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+ android:shape="rectangle">
+ <corners android:radius="@dimen/chooser_corner_radius_small" />
+ <gradient
+ android:type="radial"
+ android:centerX="1"
+ android:centerY="1"
+ android:gradientRadius="@dimen/chooser_preview_image_width"
+ android:startColor="#60000000"
+ android:endColor="#00000000" />
+</shape>
diff --git a/java/res/drawable/ic_file_video.xml b/java/res/drawable/ic_file_video.xml
new file mode 100644
index 00000000..8c7a3650
--- /dev/null
+++ b/java/res/drawable/ic_file_video.xml
@@ -0,0 +1,27 @@
+<!--
+ ~ 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.
+ -->
+
+<vector
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M9,18H13Q13.425,18 13.713,17.712Q14,17.425 14,17V16L16,17.05V12.95L14,14V13Q14,12.575 13.713,12.287Q13.425,12 13,12H9Q8.575,12 8.288,12.287Q8,12.575 8,13V17Q8,17.425 8.288,17.712Q8.575,18 9,18ZM6,22Q5.175,22 4.588,21.413Q4,20.825 4,20V4Q4,3.175 4.588,2.587Q5.175,2 6,2H14L20,8V20Q20,20.825 19.413,21.413Q18.825,22 18,22ZM13,9V4H6Q6,4 6,4Q6,4 6,4V20Q6,20 6,20Q6,20 6,20H18Q18,20 18,20Q18,20 18,20V9ZM6,4V9V4V9V20Q6,20 6,20Q6,20 6,20Q6,20 6,20Q6,20 6,20V4Q6,4 6,4Q6,4 6,4Z"/>
+</vector>
diff --git a/java/res/layout/image_preview_image_item.xml b/java/res/layout/image_preview_image_item.xml
index c18cc279..81fa5c8e 100644
--- a/java/res/layout/image_preview_image_item.xml
+++ b/java/res/layout/image_preview_image_item.xml
@@ -14,11 +14,35 @@
~ limitations under the License.
-->
-<com.android.intentresolver.widget.RoundedRectImageView
+<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
- android:id="@+id/image"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="@dimen/chooser_preview_image_width"
- android:layout_height="@dimen/chooser_preview_image_height"
- android:layout_alignParentTop="true"
- android:adjustViewBounds="false"
- android:scaleType="centerCrop"/>
+ android:layout_height="@dimen/chooser_preview_image_height" >
+
+<com.android.intentresolver.widget.RoundedRectImageView
+ android:id="@+id/image"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:layout_alignParentTop="true"
+ android:adjustViewBounds="false"
+ android:scaleType="centerCrop"
+ app:radius="@dimen/chooser_corner_radius_small" />
+
+ <FrameLayout
+ android:id="@+id/badge_frame"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="@drawable/content_preview_badge_bg">
+
+ <ImageView
+ android:id="@+id/badge"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:scaleType="center"
+ android:layout_marginEnd="2dp"
+ android:layout_marginBottom="4dp"
+ android:tint="@android:color/white"
+ android:layout_gravity="bottom|end" />
+ </FrameLayout>
+</FrameLayout>
diff --git a/java/res/layout/image_preview_other_item.xml b/java/res/layout/image_preview_other_item.xml
new file mode 100644
index 00000000..b7cc4350
--- /dev/null
+++ b/java/res/layout/image_preview_other_item.xml
@@ -0,0 +1,31 @@
+<!--
+ ~ 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.
+ -->
+
+<FrameLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="@dimen/chooser_preview_image_width"
+ android:layout_height="@dimen/chooser_preview_image_height">
+
+ <TextView
+ android:id="@+id/label"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center"
+ android:drawableTop="@drawable/ic_file_copy"
+ android:drawablePadding="8dp"
+ android:textAppearance="@style/TextAppearance.ChooserDefault" />
+
+</FrameLayout>
diff --git a/java/res/values/attrs.xml b/java/res/values/attrs.xml
index 2f2bbda2..eba6b9b7 100644
--- a/java/res/values/attrs.xml
+++ b/java/res/values/attrs.xml
@@ -41,4 +41,8 @@
<attr name="layout_hasNestedScrollIndicator" format="boolean" />
<attr name="layout_maxHeight" format="dimension"/>
</declare-styleable>
+
+ <declare-styleable name="RoundedRectImageView">
+ <attr name="radius" format="dimension" />
+ </declare-styleable>
</resources>
diff --git a/java/res/values/dimens.xml b/java/res/values/dimens.xml
index 87eec7fb..af90c4ef 100644
--- a/java/res/values/dimens.xml
+++ b/java/res/values/dimens.xml
@@ -19,6 +19,7 @@
<!-- chooser/resolver (sharesheet) spacing -->
<dimen name="chooser_width">412dp</dimen>
<dimen name="chooser_corner_radius">28dp</dimen>
+ <dimen name="chooser_corner_radius_small">14dp</dimen>
<dimen name="chooser_row_text_option_translate">25dp</dimen>
<dimen name="chooser_view_spacing">18dp</dimen>
<dimen name="chooser_edge_margin_thin">16dp</dimen>
diff --git a/java/res/values/strings.xml b/java/res/values/strings.xml
index 24604ed3..11f8bc59 100644
--- a/java/res/values/strings.xml
+++ b/java/res/values/strings.xml
@@ -48,6 +48,13 @@
}
</string>
+ <!-- Represents a number of other files also being shared; used as an item at the end of a list -->
+ <string name="other_files">{count, plural,
+ =1 {+ # file}
+ other {+ # files}
+ }
+ </string>
+
<!-- ChooserActivity - No direct share targets are available. [CHAR LIMIT=NONE] -->
<string name="chooser_no_direct_share_targets">No recommended people to share with</string>
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<Bitmap?>.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<Boolean> 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<Uri> uris = extractContentUris(targetIntent);
+ if (uris.isEmpty()) {
+ return createTextPreview(
+ targetIntent, actionFactory, imageLoader, featureFlagRepository);
+ }
+ ArrayList<FileInfo> 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<Uri> uris,
+ List<FileInfo> 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<Uri> 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<Uri> 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<Uri> extractContentUris(Intent targetIntent) {
List<Uri> 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<Uri> mUris;
+ private final List<FileInfo> mFiles;
private final ChooserContentPreviewUi.ActionFactory mActionFactory;
private final ImageLoader mImageLoader;
- private final ContentInterface mContentResolver;
private final FeatureFlagRepository mFeatureFlagRepository;
- FileContentPreviewUi(List<Uri> uris,
+ FileContentPreviewUi(
+ List<FileInfo> 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<String, Object> 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<FileInfo> 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<FileInfo> 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<ScrollableImagePreviewView.Preview> 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<ActionRow.Action> createImagePreviewActions() {
+ ArrayList<ActionRow.Action> 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<Boolean> 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<Uri>, imageLoader: ImageLoader) {
+ fun setImages(uris: List<Uri>, 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<Uri>, 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<Uri>, imageLoader: ImageLoader) {
- previewAdapter.setImages(uris, imageLoader)
+ fun setPreviews(previews: List<Preview>, 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<ViewHolder>() {
- private val uris = ArrayList<Uri>()
+ private val previews = ArrayList<Preview>()
private var imageLoader: ImageLoader? = null
+ private var firstImagePos = -1
var transitionStatusElementCallback: TransitionElementStatusCallback? = null
+ private var otherItemCount = 0
- fun setImages(uris: List<Uri>, imageLoader: ImageLoader) {
- this.uris.clear()
- this.uris.addAll(uris)
+ fun setPreviews(
+ previews: List<Preview>, 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<ImageView>(R.id.image)
+ private val badgeFrame = view.requireViewById<View>(R.id.badge_frame)
+ private val badge = view.requireViewById<ImageView>(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<TextView>(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)
}
diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt
index d870a8c2..ba89c367 100644
--- a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt
+++ b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt
@@ -39,7 +39,7 @@ import java.util.function.Consumer
private const val PROVIDER_NAME = "org.pkg.app"
class ChooserContentPreviewUiTest {
private val contentResolver = mock<ContentInterface>()
- private val imageClassifier = ChooserContentPreviewUi.ImageMimeTypeClassifier { mimeType ->
+ private val imageClassifier = MimeTypeClassifier { mimeType ->
mimeType != null && ClipDescription.compareMimeTypes(mimeType, "image/*")
}
private val imageLoader = object : ImageLoader {
@@ -123,7 +123,7 @@ class ChooserContentPreviewUiTest {
}
@Test
- fun test_ChooserContentPreview_single_non_image_uri_to_file_preview() {
+ fun test_ChooserContentPreview_single_uri_without_preview_to_file_preview() {
val uri = Uri.parse("content://$PROVIDER_NAME/test.pdf")
val targetIntent = Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_STREAM, uri)
@@ -144,6 +144,29 @@ class ChooserContentPreviewUiTest {
}
@Test
+ fun test_ChooserContentPreview_single_uri_with_preview_to_image_preview() {
+ val uri = Uri.parse("content://$PROVIDER_NAME/test.pdf")
+ val targetIntent = Intent(Intent.ACTION_SEND).apply {
+ putExtra(Intent.EXTRA_STREAM, uri)
+ }
+ whenever(contentResolver.getType(uri)).thenReturn("application/pdf")
+ whenever(contentResolver.getStreamTypes(uri, "*/*"))
+ .thenReturn(arrayOf("application/pdf", "image/png"))
+ val testSubject = ChooserContentPreviewUi(
+ targetIntent,
+ contentResolver,
+ imageClassifier,
+ imageLoader,
+ actionFactory,
+ transitionCallback,
+ featureFlagRepository
+ )
+ assertThat(testSubject.preferredContentPreview)
+ .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE)
+ verify(transitionCallback, never()).onAllTransitionElementsReady()
+ }
+
+ @Test
fun test_ChooserContentPreview_multiple_image_uri_to_image_preview() {
val uri1 = Uri.parse("content://$PROVIDER_NAME/test.png")
val uri2 = Uri.parse("content://$PROVIDER_NAME/test.jpg")
@@ -173,7 +196,7 @@ class ChooserContentPreviewUiTest {
}
@Test
- fun test_ChooserContentPreview_some_non_image_uri_to_file_preview() {
+ fun test_ChooserContentPreview_some_non_image_uri_to_image_preview() {
val uri1 = Uri.parse("content://$PROVIDER_NAME/test.png")
val uri2 = Uri.parse("content://$PROVIDER_NAME/test.pdf")
val targetIntent = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
@@ -197,7 +220,67 @@ class ChooserContentPreviewUiTest {
featureFlagRepository
)
assertThat(testSubject.preferredContentPreview)
- .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE)
+ .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE)
+ verify(transitionCallback, never()).onAllTransitionElementsReady()
+ }
+
+ @Test
+ fun test_ChooserContentPreview_some_non_image_uri_with_preview_to_image_preview() {
+ val uri1 = Uri.parse("content://$PROVIDER_NAME/test.mp4")
+ val uri2 = Uri.parse("content://$PROVIDER_NAME/test.pdf")
+ val targetIntent = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
+ putExtra(
+ Intent.EXTRA_STREAM,
+ ArrayList<Uri>().apply {
+ add(uri1)
+ add(uri2)
+ }
+ )
+ }
+ whenever(contentResolver.getType(uri1)).thenReturn("video/mpeg4")
+ whenever(contentResolver.getStreamTypes(uri1, "*/*"))
+ .thenReturn(arrayOf("image/png"))
+ whenever(contentResolver.getType(uri2)).thenReturn("application/pdf")
+ val testSubject = ChooserContentPreviewUi(
+ targetIntent,
+ contentResolver,
+ imageClassifier,
+ imageLoader,
+ actionFactory,
+ transitionCallback,
+ featureFlagRepository
+ )
+ assertThat(testSubject.preferredContentPreview)
+ .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE)
+ verify(transitionCallback, never()).onAllTransitionElementsReady()
+ }
+
+ @Test
+ fun test_ChooserContentPreview_all_non_image_uris_without_preview_to_file_preview() {
+ val uri1 = Uri.parse("content://$PROVIDER_NAME/test.html")
+ val uri2 = Uri.parse("content://$PROVIDER_NAME/test.pdf")
+ val targetIntent = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
+ putExtra(
+ Intent.EXTRA_STREAM,
+ ArrayList<Uri>().apply {
+ add(uri1)
+ add(uri2)
+ }
+ )
+ }
+ whenever(contentResolver.getType(uri1)).thenReturn("text/html")
+ whenever(contentResolver.getType(uri2)).thenReturn("application/pdf")
+ val testSubject = ChooserContentPreviewUi(
+ targetIntent,
+ contentResolver,
+ imageClassifier,
+ imageLoader,
+ actionFactory,
+ transitionCallback,
+ featureFlagRepository
+ )
+ assertThat(testSubject.preferredContentPreview)
+ .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE)
verify(transitionCallback, times(1)).onAllTransitionElementsReady()
}
}