Split ChooserContentPreviewUi into multiple components
A prep step for the new preview UI: extract each preview logic from
ChooserContentPreviewUi into individual components leaving the former as
a facade for the latter.
More specifically:
* move ChooserContnetPreviewUi into a new package;
* make #displayTextContentPreview() to be the core of the
TextContentPreviewUI (with related methods);
* make #displayFileContentPreview() to be the core of the
FileContentPreviewUi (with related methods);
* make #displayImageContentPreview() to the core of the
ImageContentPreviewUi (with the related methods);
* for all aforementioned new components, pass component specific
dependencies as constructor arguments, capture the common component
contract in the base abstract class, ContentPreviewUi, along with the
static utility methods.
Bug: 271613784
Test: Manual testing of
* Text sharing with and without preview;
* File sharing with and without preivew, single file and multiple
fiels;
* Image sharing, single and multiple files, image + text sharing;
* Custom actions with text, image and files sharing;
* Reselection action with text, image and files sharing.
Test: screenshot transition animation
Test: atest IntentResolverUnitTests
Change-Id: I392de610b3d3e044e23c83d29fd11061fbc7192d
diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java
index 14d5972..947155f 100644
--- a/java/src/com/android/intentresolver/ChooserActionFactory.java
+++ b/java/src/com/android/intentresolver/ChooserActionFactory.java
@@ -39,6 +39,7 @@
import com.android.intentresolver.chooser.DisplayResolveInfo;
import com.android.intentresolver.chooser.TargetInfo;
+import com.android.intentresolver.contentpreview.ChooserContentPreviewUi;
import com.android.intentresolver.flags.FeatureFlagRepository;
import com.android.intentresolver.flags.Flags;
import com.android.intentresolver.widget.ActionRow;
diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java
index 3a7c892..ae5be26 100644
--- a/java/src/com/android/intentresolver/ChooserActivity.java
+++ b/java/src/com/android/intentresolver/ChooserActivity.java
@@ -83,6 +83,7 @@
import com.android.intentresolver.chooser.DisplayResolveInfo;
import com.android.intentresolver.chooser.MultiDisplayResolveInfo;
import com.android.intentresolver.chooser.TargetInfo;
+import com.android.intentresolver.contentpreview.ChooserContentPreviewUi;
import com.android.intentresolver.flags.FeatureFlagRepository;
import com.android.intentresolver.flags.FeatureFlagRepositoryFactory;
import com.android.intentresolver.flags.Flags;
@@ -205,7 +206,6 @@
private ChooserRefinementManager mRefinementManager;
private FeatureFlagRepository mFeatureFlagRepository;
- private ChooserActionFactory mChooserActionFactory;
private ChooserContentPreviewUi mChooserContentPreviewUi;
private boolean mShouldDisplayLandscape;
@@ -231,9 +231,6 @@
private final ExecutorService mBackgroundThreadPoolExecutor = Executors.newFixedThreadPool(5);
- @Nullable
- private ImageLoader mPreviewImageLoader;
-
private int mScrollStatus = SCROLL_STATUS_IDLE;
@VisibleForTesting
@@ -273,38 +270,6 @@
return;
}
- mChooserActionFactory = new ChooserActionFactory(
- this,
- mChooserRequest,
- mFeatureFlagRepository,
- mIntegratedDeviceComponents,
- getChooserActivityLogger(),
- (isExcluded) -> mExcludeSharedText = isExcluded,
- this::getFirstVisibleImgPreviewView,
- new ChooserActionFactory.ActionActivityStarter() {
- @Override
- public void safelyStartActivityAsPersonalProfileUser(TargetInfo targetInfo) {
- safelyStartActivityAsUser(targetInfo, getPersonalProfileUserHandle());
- finish();
- }
-
- @Override
- public void safelyStartActivityAsPersonalProfileUserWithSharedElementTransition(
- TargetInfo targetInfo, View sharedElement, String sharedElementName) {
- ActivityOptions options = ActivityOptions.makeSceneTransitionAnimation(
- ChooserActivity.this, sharedElement, sharedElementName);
- safelyStartActivityAsUser(
- targetInfo, getPersonalProfileUserHandle(), options.toBundle());
- startFinishAnimation();
- }
- },
- (status) -> {
- if (status != null) {
- setResult(status);
- }
- finish();
- });
-
mRefinementManager = new ChooserRefinementManager(
this,
mChooserRequest.getRefinementIntentSender(),
@@ -319,7 +284,14 @@
finish();
});
- mChooserContentPreviewUi = new ChooserContentPreviewUi(mFeatureFlagRepository);
+ mChooserContentPreviewUi = new ChooserContentPreviewUi(
+ mChooserRequest.getTargetIntent(),
+ getContentResolver(),
+ this::isImageType,
+ createPreviewImageLoader(),
+ createChooserActionFactory(),
+ mEnterTransitionAnimationDelegate,
+ mFeatureFlagRepository);
setAdditionalTargets(mChooserRequest.getAdditionalTargets());
@@ -339,8 +311,6 @@
mChooserRequest.getTargetIntentFilter()),
mChooserRequest.getTargetIntentFilter());
- mPreviewImageLoader = createPreviewImageLoader();
-
super.onCreate(
savedInstanceState,
mChooserRequest.getTargetIntent(),
@@ -392,8 +362,7 @@
(mChooserRequest.getInitialIntents() == null)
? 0 : mChooserRequest.getInitialIntents().length,
isWorkProfile(),
- ChooserContentPreviewUi.findPreferredContentPreview(
- getTargetIntent(), getContentResolver(), this::isImageType),
+ mChooserContentPreviewUi.getPreferredContentPreview(),
mChooserRequest.getTargetAction(),
mChooserRequest.getChooserActions().size(),
mChooserRequest.getModifyShareAction() != null
@@ -594,8 +563,7 @@
|| mChooserMultiProfilePagerAdapter
.getCurrentRootAdapter().getSystemRowCount() != 0) {
getChooserActivityLogger().logActionShareWithPreview(
- ChooserContentPreviewUi.findPreferredContentPreview(
- getTargetIntent(), getContentResolver(), this::isImageType));
+ mChooserContentPreviewUi.getPreferredContentPreview());
}
return postRebuildListInternal(rebuildCompleted);
}
@@ -717,22 +685,11 @@
* @param parent reference to the parent container where the view should be attached to
* @return content preview view
*/
- protected ViewGroup createContentPreviewView(ViewGroup parent, ImageLoader imageLoader) {
- Intent targetIntent = getTargetIntent();
- int previewType = ChooserContentPreviewUi.findPreferredContentPreview(
- targetIntent, getContentResolver(), this::isImageType);
-
+ protected ViewGroup createContentPreviewView(ViewGroup parent) {
ViewGroup layout = mChooserContentPreviewUi.displayContentPreview(
- previewType,
- targetIntent,
getResources(),
getLayoutInflater(),
- mChooserActionFactory,
- parent,
- imageLoader,
- mEnterTransitionAnimationDelegate,
- getContentResolver(),
- this::isImageType);
+ parent);
if (layout != null) {
adjustPreviewWidth(getResources().getConfiguration().orientation, layout);
@@ -1223,7 +1180,7 @@
@Override
public View buildContentPreview(ViewGroup parent) {
- return createContentPreviewView(parent, mPreviewImageLoader);
+ return createContentPreviewView(parent);
}
@Override
@@ -1350,6 +1307,40 @@
return new ImagePreviewImageLoader(this, getLifecycle(), cacheSize);
}
+ private ChooserActionFactory createChooserActionFactory() {
+ return new ChooserActionFactory(
+ this,
+ mChooserRequest,
+ mFeatureFlagRepository,
+ mIntegratedDeviceComponents,
+ getChooserActivityLogger(),
+ (isExcluded) -> mExcludeSharedText = isExcluded,
+ this::getFirstVisibleImgPreviewView,
+ new ChooserActionFactory.ActionActivityStarter() {
+ @Override
+ public void safelyStartActivityAsPersonalProfileUser(TargetInfo targetInfo) {
+ safelyStartActivityAsUser(targetInfo, getPersonalProfileUserHandle());
+ finish();
+ }
+
+ @Override
+ public void safelyStartActivityAsPersonalProfileUserWithSharedElementTransition(
+ TargetInfo targetInfo, View sharedElement, String sharedElementName) {
+ ActivityOptions options = ActivityOptions.makeSceneTransitionAnimation(
+ ChooserActivity.this, sharedElement, sharedElementName);
+ safelyStartActivityAsUser(
+ targetInfo, getPersonalProfileUserHandle(), options.toBundle());
+ startFinishAnimation();
+ }
+ },
+ (status) -> {
+ if (status != null) {
+ setResult(status);
+ }
+ finish();
+ });
+ }
+
private void handleScroll(View view, int x, int y, int oldx, int oldy) {
if (mChooserMultiProfilePagerAdapter.getCurrentRootAdapter() != null) {
mChooserMultiProfilePagerAdapter.getCurrentRootAdapter().handleScroll(view, y, oldy);
@@ -1712,10 +1703,10 @@
// We don't show it in landscape as otherwise there is no room for scrolling.
// If the sticky content preview will be shown at some point with orientation change,
// then always preload it to avoid subsequent resizing of the share sheet.
- ViewGroup contentPreviewContainer = findViewById(com.android.internal.R.id.content_preview_container);
+ ViewGroup contentPreviewContainer =
+ findViewById(com.android.internal.R.id.content_preview_container);
if (contentPreviewContainer.getChildCount() == 0) {
- ViewGroup contentPreviewView =
- createContentPreviewView(contentPreviewContainer, mPreviewImageLoader);
+ ViewGroup contentPreviewView = createContentPreviewView(contentPreviewContainer);
contentPreviewContainer.addView(contentPreviewView);
}
}
diff --git a/java/src/com/android/intentresolver/ChooserActivityLogger.java b/java/src/com/android/intentresolver/ChooserActivityLogger.java
index f7ab595..1f606f2 100644
--- a/java/src/com/android/intentresolver/ChooserActivityLogger.java
+++ b/java/src/com/android/intentresolver/ChooserActivityLogger.java
@@ -24,6 +24,7 @@
import android.util.HashedStringCache;
import android.util.Log;
+import com.android.intentresolver.contentpreview.ContentPreviewType;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.logging.InstanceId;
import com.android.internal.logging.InstanceIdSequence;
@@ -432,11 +433,11 @@
*/
private static int typeFromPreviewInt(int previewType) {
switch(previewType) {
- case ChooserContentPreviewUi.CONTENT_PREVIEW_IMAGE:
+ case ContentPreviewType.CONTENT_PREVIEW_IMAGE:
return FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_IMAGE;
- case ChooserContentPreviewUi.CONTENT_PREVIEW_FILE:
+ case ContentPreviewType.CONTENT_PREVIEW_FILE:
return FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_FILE;
- case ChooserContentPreviewUi.CONTENT_PREVIEW_TEXT:
+ case ContentPreviewType.CONTENT_PREVIEW_TEXT:
default:
return FrameworkStatsLog
.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_TYPE_UNKNOWN;
diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java
deleted file mode 100644
index 60ea012..0000000
--- a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java
+++ /dev/null
@@ -1,699 +0,0 @@
-/*
- * Copyright (C) 2022 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.intentresolver;
-
-import static android.content.ContentProvider.getUserIdFromUri;
-
-import static java.lang.annotation.RetentionPolicy.SOURCE;
-
-import android.animation.ObjectAnimator;
-import android.animation.ValueAnimator;
-import android.annotation.IntDef;
-import android.content.ClipData;
-import android.content.ContentResolver;
-import android.content.Intent;
-import android.content.res.Resources;
-import android.database.Cursor;
-import android.graphics.Bitmap;
-import android.net.Uri;
-import android.os.UserHandle;
-import android.provider.DocumentsContract;
-import android.provider.Downloads;
-import android.provider.OpenableColumns;
-import android.text.TextUtils;
-import android.text.util.Linkify;
-import android.transition.TransitionManager;
-import android.util.Log;
-import android.util.PluralsMessageFormatter;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.view.ViewStub;
-import android.view.animation.DecelerateInterpolator;
-import android.widget.CheckBox;
-import android.widget.ImageView;
-import android.widget.TextView;
-
-import androidx.annotation.LayoutRes;
-import androidx.annotation.Nullable;
-
-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 com.android.intentresolver.widget.RoundedRectImageView;
-import com.android.internal.annotations.VisibleForTesting;
-
-import java.lang.annotation.Retention;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.function.Consumer;
-import java.util.stream.Collectors;
-
-/**
- * Collection of helpers for building the content preview UI displayed in {@link ChooserActivity}.
- *
- * TODO: this "namespace" was pulled out of {@link ChooserActivity} as a bucket of static methods
- * to show that they're one-shot procedures with no dependencies back to {@link ChooserActivity}
- * state other than the delegates that are explicitly provided. There may be more appropriate
- * abstractions (e.g., maybe this can be a "widget" added directly to the view hierarchy to show the
- * appropriate preview), or it may at least be safe (and more convenient) to adopt a more "object
- * oriented" design where the static specifiers are removed and some of the dependencies are cached
- * as ivars when this "class" is initialized.
- */
-public final class ChooserContentPreviewUi {
- private static final int IMAGE_FADE_IN_MILLIS = 150;
-
- /**
- * Delegate to build the default system action buttons to display in the preview layout, if/when
- * they're determined to be appropriate for the particular preview we display.
- * TODO: clarify why action buttons are part of preview logic.
- */
- public interface ActionFactory {
- /** Create an action that copies the share content to the clipboard. */
- ActionRow.Action createCopyButton();
-
- /** Create an action that opens the share content in a system-default editor. */
- @Nullable
- ActionRow.Action createEditButton();
-
- /** Create an "Share to Nearby" action. */
- @Nullable
- ActionRow.Action createNearbyButton();
-
- /** Create custom actions */
- List<ActionRow.Action> createCustomActions();
-
- /**
- * Provides a share modification action, if any.
- */
- @Nullable
- Runnable getModifyShareAction();
-
- /**
- * <p>
- * Creates an exclude-text action that can be called when the user changes shared text
- * status in the Media + Text preview.
- * </p>
- * <p>
- * <code>true</code> argument value indicates that the text should be excluded.
- * </p>
- */
- 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 ChooserActivity#isImageType(String)} into this class.
- */
- public interface ImageMimeTypeClassifier {
- /** @return whether the specified {@code mimeType} is classified as an "image" type. */
- boolean isImageType(String mimeType);
- }
-
- @Retention(SOURCE)
- @IntDef({CONTENT_PREVIEW_FILE, CONTENT_PREVIEW_IMAGE, CONTENT_PREVIEW_TEXT})
- private @interface ContentPreviewType {
- }
-
- // Starting at 1 since 0 is considered "undefined" for some of the database transformations
- // of tron logs.
- @VisibleForTesting
- public static final int CONTENT_PREVIEW_IMAGE = 1;
- @VisibleForTesting
- public static final int CONTENT_PREVIEW_FILE = 2;
- @VisibleForTesting
- public static final int CONTENT_PREVIEW_TEXT = 3;
-
- private static final String TAG = "ChooserPreview";
-
- private static final String PLURALS_COUNT = "count";
- private static final String PLURALS_FILE_NAME = "file_name";
-
- private final FeatureFlagRepository mFeatureFlagRepository;
-
- /** Determine the most appropriate type of preview to show for the provided {@link Intent}. */
- @ContentPreviewType
- public static int findPreferredContentPreview(
- Intent targetIntent,
- ContentResolver resolver,
- ImageMimeTypeClassifier imageClassifier) {
- /* In {@link android.content.Intent#getType}, the app may specify a very general mime type
- * that broadly covers all data being shared, such as {@literal *}/* when sending an image
- * and text. We therefore should inspect each item for the preferred type, in order: IMAGE,
- * FILE, TEXT. */
- String action = targetIntent.getAction();
- if (Intent.ACTION_SEND.equals(action)) {
- Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM);
- return findPreferredContentPreview(uri, resolver, imageClassifier);
- } else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
- List<Uri> uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
- if (uris == null || uris.isEmpty()) {
- return CONTENT_PREVIEW_TEXT;
- }
-
- for (Uri uri : uris) {
- // Defaulting to file preview when there are mixed image/file types is
- // preferable, as it shows the user the correct number of items being shared
- int uriPreviewType = findPreferredContentPreview(uri, resolver, imageClassifier);
- if (uriPreviewType == CONTENT_PREVIEW_FILE) {
- return CONTENT_PREVIEW_FILE;
- }
- }
-
- return CONTENT_PREVIEW_IMAGE;
- }
-
- return CONTENT_PREVIEW_TEXT;
- }
-
- public ChooserContentPreviewUi(
- FeatureFlagRepository featureFlagRepository) {
- mFeatureFlagRepository = featureFlagRepository;
- }
-
- /**
- * Display a content preview of the specified {@code previewType} to preview the content of the
- * specified {@code intent}.
- */
- public ViewGroup displayContentPreview(
- @ContentPreviewType int previewType,
- Intent targetIntent,
- Resources resources,
- LayoutInflater layoutInflater,
- ActionFactory actionFactory,
- ViewGroup parent,
- ImageLoader previewImageLoader,
- TransitionElementStatusCallback transitionElementStatusCallback,
- ContentResolver contentResolver,
- ImageMimeTypeClassifier imageClassifier) {
- ViewGroup layout = null;
-
- if (previewType != CONTENT_PREVIEW_IMAGE) {
- transitionElementStatusCallback.onAllTransitionElementsReady();
- }
- int actionRowLayout = mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_CUSTOM_ACTIONS)
- ? R.layout.scrollable_chooser_action_row
- : R.layout.chooser_action_row;
- List<ActionRow.Action> customActions = actionFactory.createCustomActions();
- switch (previewType) {
- case CONTENT_PREVIEW_TEXT:
- layout = displayTextContentPreview(
- targetIntent,
- layoutInflater,
- createActions(
- createTextPreviewActions(actionFactory),
- customActions),
- parent,
- previewImageLoader,
- actionRowLayout);
- break;
- case CONTENT_PREVIEW_IMAGE:
- layout = displayImageContentPreview(
- targetIntent,
- layoutInflater,
- createActions(
- createImagePreviewActions(actionFactory),
- customActions),
- parent,
- previewImageLoader,
- transitionElementStatusCallback,
- contentResolver,
- imageClassifier,
- actionRowLayout,
- actionFactory);
- break;
- case CONTENT_PREVIEW_FILE:
- layout = displayFileContentPreview(
- targetIntent,
- resources,
- layoutInflater,
- createActions(
- createFilePreviewActions(actionFactory),
- customActions),
- parent,
- previewImageLoader,
- contentResolver,
- actionRowLayout);
- break;
- default:
- Log.e(TAG, "Unexpected content preview type: " + previewType);
- }
- Runnable modifyShareAction = actionFactory.getModifyShareAction();
- if (modifyShareAction != null && layout != null
- && mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION)) {
- View modifyShareView = layout.findViewById(R.id.reselection_action);
- if (modifyShareView != null) {
- modifyShareView.setVisibility(View.VISIBLE);
- modifyShareView.setOnClickListener(view -> modifyShareAction.run());
- }
- }
-
- return layout;
- }
-
- private List<ActionRow.Action> createActions(
- List<ActionRow.Action> systemActions, List<ActionRow.Action> customActions) {
- ArrayList<ActionRow.Action> actions =
- new ArrayList<>(systemActions.size() + customActions.size());
- actions.addAll(systemActions);
- if (mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_CUSTOM_ACTIONS)) {
- actions.addAll(customActions);
- }
- return actions;
- }
-
- private static Cursor queryResolver(ContentResolver resolver, Uri uri) {
- return resolver.query(uri, null, null, null, null);
- }
-
- @ContentPreviewType
- private static int findPreferredContentPreview(
- Uri uri, ContentResolver resolver, ImageMimeTypeClassifier imageClassifier) {
- if (uri == null) {
- return CONTENT_PREVIEW_TEXT;
- }
-
- String mimeType = resolver.getType(uri);
- return imageClassifier.isImageType(mimeType) ? CONTENT_PREVIEW_IMAGE : CONTENT_PREVIEW_FILE;
- }
-
- private static ViewGroup displayTextContentPreview(
- Intent targetIntent,
- LayoutInflater layoutInflater,
- List<ActionRow.Action> actions,
- ViewGroup parent,
- ImageLoader previewImageLoader,
- @LayoutRes int actionRowLayout) {
- ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate(
- R.layout.chooser_grid_preview_text, parent, false);
-
- final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout);
- if (actionRow != null) {
- actionRow.setActions(actions);
- }
-
- CharSequence sharingText = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT);
- if (sharingText == null) {
- contentPreviewLayout
- .findViewById(com.android.internal.R.id.content_preview_text_layout)
- .setVisibility(View.GONE);
- } else {
- TextView textView = contentPreviewLayout.findViewById(
- com.android.internal.R.id.content_preview_text);
- textView.setText(sharingText);
- }
-
- String previewTitle = targetIntent.getStringExtra(Intent.EXTRA_TITLE);
- if (TextUtils.isEmpty(previewTitle)) {
- contentPreviewLayout
- .findViewById(com.android.internal.R.id.content_preview_title_layout)
- .setVisibility(View.GONE);
- } else {
- TextView previewTitleView = contentPreviewLayout.findViewById(
- com.android.internal.R.id.content_preview_title);
- previewTitleView.setText(previewTitle);
-
- ClipData previewData = targetIntent.getClipData();
- Uri previewThumbnail = null;
- if (previewData != null) {
- if (previewData.getItemCount() > 0) {
- ClipData.Item previewDataItem = previewData.getItemAt(0);
- previewThumbnail = previewDataItem.getUri();
- }
- }
-
- ImageView previewThumbnailView = contentPreviewLayout.findViewById(
- com.android.internal.R.id.content_preview_thumbnail);
- if (!validForContentPreview(previewThumbnail)) {
- previewThumbnailView.setVisibility(View.GONE);
- } else {
- previewImageLoader.loadImage(
- previewThumbnail,
- (bitmap) -> updateViewWithImage(
- contentPreviewLayout.findViewById(
- com.android.internal.R.id.content_preview_thumbnail),
- bitmap));
- }
- }
-
- return contentPreviewLayout;
- }
-
- private static List<ActionRow.Action> createTextPreviewActions(ActionFactory actionFactory) {
- ArrayList<ActionRow.Action> actions = new ArrayList<>(2);
- actions.add(actionFactory.createCopyButton());
- ActionRow.Action nearbyAction = actionFactory.createNearbyButton();
- if (nearbyAction != null) {
- actions.add(nearbyAction);
- }
- return actions;
- }
-
- private ViewGroup displayImageContentPreview(
- Intent targetIntent,
- LayoutInflater layoutInflater,
- List<ActionRow.Action> actions,
- ViewGroup parent,
- ImageLoader imageLoader,
- TransitionElementStatusCallback transitionElementStatusCallback,
- ContentResolver contentResolver,
- ImageMimeTypeClassifier imageClassifier,
- @LayoutRes int actionRowLayout,
- ActionFactory actionFactory) {
- ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate(
- R.layout.chooser_grid_preview_image, parent, false);
- ImagePreviewView imagePreview = inflateImagePreviewView(contentPreviewLayout);
-
- final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout);
- if (actionRow != null) {
- actionRow.setActions(actions);
- }
-
- 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 ->
- imageClassifier.isImageType(contentResolver.getType(uri))
- )
- .collect(Collectors.toList());
-
- if (imageUris.size() == 0) {
- Log.i(TAG, "Attempted to display image preview area with zero"
- + " available images detected in EXTRA_STREAM list");
- ((View) imagePreview).setVisibility(View.GONE);
- transitionElementStatusCallback.onAllTransitionElementsReady();
- return contentPreviewLayout;
- }
-
- setTextInImagePreviewVisibility(
- contentPreviewLayout,
- targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT),
- actionFactory);
- imagePreview.setTransitionElementStatusCallback(transitionElementStatusCallback);
- imagePreview.setImages(imageUris, imageLoader);
- imageLoader.prePopulate(imageUris);
-
- return contentPreviewLayout;
- }
-
- private void setTextInImagePreviewVisibility(
- ViewGroup contentPreview, CharSequence text, ActionFactory actionFactory) {
- int visibility = mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW)
- && !TextUtils.isEmpty(text)
- ? 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(text.toString());
- textView.setAutoLinkMask(isLink ? Linkify.WEB_URLS : 0);
- textView.setText(text);
-
- 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 static List<ActionRow.Action> createImagePreviewActions(
- ActionFactory buttonFactory) {
- ArrayList<ActionRow.Action> actions = new ArrayList<>(2);
- //TODO: add copy action;
- ActionRow.Action action = buttonFactory.createNearbyButton();
- if (action != null) {
- actions.add(action);
- }
- action = buttonFactory.createEditButton();
- if (action != null) {
- actions.add(action);
- }
- return actions;
- }
-
- private ImagePreviewView 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.inflate();
- }
- return previewLayout.findViewById(
- com.android.internal.R.id.content_preview_image_area);
- }
-
- private static ViewGroup displayFileContentPreview(
- Intent targetIntent,
- Resources resources,
- LayoutInflater layoutInflater,
- List<ActionRow.Action> actions,
- ViewGroup parent,
- ImageLoader imageLoader,
- ContentResolver contentResolver,
- @LayoutRes int actionRowLayout) {
- ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate(
- R.layout.chooser_grid_preview_file, parent, false);
-
- List<Uri> uris = extractContentUris(targetIntent);
- final int uriCount = uris.size();
-
- if (uriCount == 0) {
- contentPreviewLayout.setVisibility(View.GONE);
- Log.i(TAG,
- "Appears to be no uris available in EXTRA_STREAM, removing "
- + "preview area");
- return contentPreviewLayout;
- }
-
- if (uriCount == 1) {
- loadFileUriIntoView(uris.get(0), contentPreviewLayout, imageLoader, contentResolver);
- } else {
- FileInfo fileInfo = extractFileInfo(uris.get(0), contentResolver);
- int remUriCount = uriCount - 1;
- Map<String, Object> arguments = new HashMap<>();
- arguments.put(PLURALS_COUNT, remUriCount);
- arguments.put(PLURALS_FILE_NAME, fileInfo.name);
- String fileName =
- PluralsMessageFormatter.format(resources, arguments, R.string.file_count);
-
- TextView fileNameView = contentPreviewLayout.findViewById(
- com.android.internal.R.id.content_preview_filename);
- fileNameView.setText(fileName);
-
- View thumbnailView = contentPreviewLayout.findViewById(
- com.android.internal.R.id.content_preview_file_thumbnail);
- thumbnailView.setVisibility(View.GONE);
-
- ImageView fileIconView = contentPreviewLayout.findViewById(
- com.android.internal.R.id.content_preview_file_icon);
- fileIconView.setVisibility(View.VISIBLE);
- fileIconView.setImageResource(R.drawable.ic_file_copy);
- }
-
- final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout);
- if (actionRow != null) {
- actionRow.setActions(actions);
- }
-
- return contentPreviewLayout;
- }
-
- private static List<Uri> extractContentUris(Intent targetIntent) {
- List<Uri> uris = new ArrayList<>();
- if (Intent.ACTION_SEND.equals(targetIntent.getAction())) {
- Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM);
- if (validForContentPreview(uri)) {
- uris.add(uri);
- }
- } else {
- List<Uri> receivedUris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
- if (receivedUris != null) {
- for (Uri uri : receivedUris) {
- if (validForContentPreview(uri)) {
- uris.add(uri);
- }
- }
- }
- }
- return uris;
- }
-
- /**
- * Indicate if the incoming content URI should be allowed.
- *
- * @param uri the uri to test
- * @return true if the URI is allowed for content preview
- */
- private static boolean validForContentPreview(Uri uri) throws SecurityException {
- if (uri == null) {
- return false;
- }
- int userId = getUserIdFromUri(uri, UserHandle.USER_CURRENT);
- if (userId != UserHandle.USER_CURRENT && userId != UserHandle.myUserId()) {
- Log.e(TAG, "dropped invalid content URI belonging to user " + userId);
- return false;
- }
- return true;
- }
-
-
- private static List<ActionRow.Action> createFilePreviewActions(ActionFactory actionFactory) {
- List<ActionRow.Action> actions = new ArrayList<>(1);
- //TODO(b/120417119):
- // add action buttonFactory.createCopyButton()
- ActionRow.Action action = actionFactory.createNearbyButton();
- if (action != null) {
- actions.add(action);
- }
- return actions;
- }
-
- private static ActionRow inflateActionRow(ViewGroup parent, @LayoutRes int actionRowLayout) {
- final ViewStub stub = parent.findViewById(com.android.intentresolver.R.id.action_row_stub);
- if (stub != null) {
- stub.setLayoutResource(actionRowLayout);
- stub.inflate();
- }
- return parent.findViewById(com.android.internal.R.id.chooser_action_row);
- }
-
- private static void logContentPreviewWarning(Uri uri) {
- // The ContentResolver already logs the exception. Log something more informative.
- Log.w(TAG, "Could not load (" + uri.toString() + ") thumbnail/name for preview. If "
- + "desired, consider using Intent#createChooser to launch the ChooserActivity, "
- + "and set your Intent's clipData and flags in accordance with that method's "
- + "documentation");
- }
-
- private static void loadFileUriIntoView(
- final Uri uri,
- final View parent,
- final ImageLoader imageLoader,
- final ContentResolver contentResolver) {
- FileInfo fileInfo = extractFileInfo(uri, contentResolver);
-
- TextView fileNameView = parent.findViewById(
- com.android.internal.R.id.content_preview_filename);
- fileNameView.setText(fileInfo.name);
-
- if (fileInfo.hasThumbnail) {
- imageLoader.loadImage(
- uri,
- (bitmap) -> updateViewWithImage(
- parent.findViewById(
- com.android.internal.R.id.content_preview_file_thumbnail),
- bitmap));
- } else {
- View thumbnailView = parent.findViewById(
- com.android.internal.R.id.content_preview_file_thumbnail);
- thumbnailView.setVisibility(View.GONE);
-
- ImageView fileIconView = parent.findViewById(
- com.android.internal.R.id.content_preview_file_icon);
- fileIconView.setVisibility(View.VISIBLE);
- fileIconView.setImageResource(R.drawable.chooser_file_generic);
- }
- }
-
- private static void updateViewWithImage(RoundedRectImageView imageView, Bitmap image) {
- if (image == null) {
- imageView.setVisibility(View.GONE);
- return;
- }
- imageView.setVisibility(View.VISIBLE);
- imageView.setAlpha(0.0f);
- imageView.setImageBitmap(image);
-
- ValueAnimator fadeAnim = ObjectAnimator.ofFloat(imageView, "alpha", 0.0f, 1.0f);
- fadeAnim.setInterpolator(new DecelerateInterpolator(1.0f));
- fadeAnim.setDuration(IMAGE_FADE_IN_MILLIS);
- fadeAnim.start();
- }
-
- private static FileInfo extractFileInfo(Uri uri, ContentResolver resolver) {
- String fileName = null;
- boolean hasThumbnail = false;
-
- try (Cursor cursor = queryResolver(resolver, uri)) {
- if (cursor != null && cursor.getCount() > 0) {
- int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
- int titleIndex = cursor.getColumnIndex(Downloads.Impl.COLUMN_TITLE);
- int flagsIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_FLAGS);
-
- cursor.moveToFirst();
- if (nameIndex != -1) {
- fileName = cursor.getString(nameIndex);
- } else if (titleIndex != -1) {
- fileName = cursor.getString(titleIndex);
- }
-
- if (flagsIndex != -1) {
- hasThumbnail = (cursor.getInt(flagsIndex)
- & DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL) != 0;
- }
- }
- } catch (SecurityException | NullPointerException e) {
- logContentPreviewWarning(uri);
- }
-
- if (TextUtils.isEmpty(fileName)) {
- fileName = uri.getPath();
- int index = fileName.lastIndexOf('/');
- if (index != -1) {
- fileName = fileName.substring(index + 1);
- }
- }
-
- return new FileInfo(fileName, hasThumbnail);
- }
-
- private static class FileInfo {
- public final String name;
- public final boolean hasThumbnail;
-
- FileInfo(String name, boolean hasThumbnail) {
- this.name = name;
- this.hasThumbnail = hasThumbnail;
- }
- }
-}
diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java
index 7a0c0f1..d224299 100644
--- a/java/src/com/android/intentresolver/ResolverActivity.java
+++ b/java/src/com/android/intentresolver/ResolverActivity.java
@@ -1581,7 +1581,7 @@
* @param cti TargetInfo to be launched.
* @param user User to launch this activity as.
*/
- @VisibleForTesting
+ @VisibleForTesting(visibility = VisibleForTesting.Visibility.PROTECTED)
public final void safelyStartActivityAsUser(TargetInfo cti, UserHandle user) {
safelyStartActivityAsUser(cti, user, null);
}
diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java
new file mode 100644
index 0000000..205be44
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java
@@ -0,0 +1,310 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.contentpreview;
+
+import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_FILE;
+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.net.Uri;
+import android.os.RemoteException;
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import androidx.annotation.Nullable;
+
+import com.android.intentresolver.ImageLoader;
+import com.android.intentresolver.flags.FeatureFlagRepository;
+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.function.Consumer;
+import java.util.stream.Collectors;
+
+/**
+ * Collection of helpers for building the content preview UI displayed in
+ * {@link com.android.intentresolver.ChooserActivity}.
+ *
+ * A content preview façade.
+ */
+public final class ChooserContentPreviewUi {
+ /**
+ * Delegate to build the default system action buttons to display in the preview layout, if/when
+ * they're determined to be appropriate for the particular preview we display.
+ * TODO: clarify why action buttons are part of preview logic.
+ */
+ public interface ActionFactory {
+ /** Create an action that copies the share content to the clipboard. */
+ ActionRow.Action createCopyButton();
+
+ /** Create an action that opens the share content in a system-default editor. */
+ @Nullable
+ ActionRow.Action createEditButton();
+
+ /** Create an "Share to Nearby" action. */
+ @Nullable
+ ActionRow.Action createNearbyButton();
+
+ /** Create custom actions */
+ List<ActionRow.Action> createCustomActions();
+
+ /**
+ * Provides a share modification action, if any.
+ */
+ @Nullable
+ Runnable getModifyShareAction();
+
+ /**
+ * <p>
+ * Creates an exclude-text action that can be called when the user changes shared text
+ * status in the Media + Text preview.
+ * </p>
+ * <p>
+ * <code>true</code> argument value indicates that the text should be excluded.
+ * </p>
+ */
+ 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,
+ ImageLoader imageLoader,
+ ActionFactory actionFactory,
+ TransitionElementStatusCallback transitionElementStatusCallback,
+ FeatureFlagRepository featureFlagRepository) {
+
+ mContentPreviewUi = createContentPreview(
+ targetIntent,
+ contentResolver,
+ imageClassifier,
+ imageLoader,
+ actionFactory,
+ transitionElementStatusCallback,
+ featureFlagRepository);
+ if (mContentPreviewUi.getType() != CONTENT_PREVIEW_IMAGE) {
+ transitionElementStatusCallback.onAllTransitionElementsReady();
+ }
+ }
+
+ private ContentPreviewUi createContentPreview(
+ Intent targetIntent,
+ ContentInterface contentResolver,
+ ImageMimeTypeClassifier imageClassifier,
+ 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);
+
+ case CONTENT_PREVIEW_IMAGE:
+ return createImagePreview(
+ targetIntent,
+ actionFactory,
+ contentResolver,
+ imageClassifier,
+ imageLoader,
+ transitionElementStatusCallback,
+ featureFlagRepository);
+ }
+
+ return new NoContextPreviewUi(type);
+ }
+
+ public int getPreferredContentPreview() {
+ return mContentPreviewUi.getType();
+ }
+
+ /**
+ * Display a content preview of the specified {@code previewType} to preview the content of the
+ * specified {@code intent}.
+ */
+ public ViewGroup displayContentPreview(
+ Resources resources, LayoutInflater layoutInflater, ViewGroup parent) {
+
+ 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;
+ }
+
+ if (isSend) {
+ Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM);
+ return findPreferredContentPreview(uri, resolver, imageClassifier);
+ }
+
+ List<Uri> uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
+ if (uris == null || uris.isEmpty()) {
+ return CONTENT_PREVIEW_TEXT;
+ }
+
+ for (Uri uri : uris) {
+ // Defaulting to file preview when there are mixed image/file types is
+ // preferable, as it shows the user the correct number of items being shared
+ int uriPreviewType = findPreferredContentPreview(uri, resolver, imageClassifier);
+ if (uriPreviewType == CONTENT_PREVIEW_FILE) {
+ return CONTENT_PREVIEW_FILE;
+ }
+ }
+
+ return CONTENT_PREVIEW_IMAGE;
+ }
+
+ @ContentPreviewType
+ private static int findPreferredContentPreview(
+ Uri uri, ContentInterface resolver, ImageMimeTypeClassifier imageClassifier) {
+ if (uri == null) {
+ return CONTENT_PREVIEW_TEXT;
+ }
+
+ String mimeType = null;
+ try {
+ mimeType = resolver.getType(uri);
+ } catch (RemoteException ignored) {
+ }
+ return imageClassifier.isImageType(mimeType) ? CONTENT_PREVIEW_IMAGE : CONTENT_PREVIEW_FILE;
+ }
+
+ private static TextContentPreviewUi createTextPreview(
+ Intent targetIntent,
+ ChooserContentPreviewUi.ActionFactory actionFactory,
+ ImageLoader imageLoader,
+ FeatureFlagRepository featureFlagRepository) {
+ CharSequence sharingText = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT);
+ String previewTitle = targetIntent.getStringExtra(Intent.EXTRA_TITLE);
+ ClipData previewData = targetIntent.getClipData();
+ Uri previewThumbnail = null;
+ if (previewData != null) {
+ if (previewData.getItemCount() > 0) {
+ ClipData.Item previewDataItem = previewData.getItemAt(0);
+ previewThumbnail = previewDataItem.getUri();
+ }
+ }
+ return new TextContentPreviewUi(
+ sharingText,
+ previewTitle,
+ previewThumbnail,
+ actionFactory,
+ imageLoader,
+ 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())) {
+ Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM);
+ if (ContentPreviewUi.validForContentPreview(uri)) {
+ uris.add(uri);
+ }
+ } else {
+ List<Uri> receivedUris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
+ if (receivedUris != null) {
+ for (Uri uri : receivedUris) {
+ if (ContentPreviewUi.validForContentPreview(uri)) {
+ uris.add(uri);
+ }
+ }
+ }
+ }
+ return uris;
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java
new file mode 100644
index 0000000..ebab147
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java
@@ -0,0 +1,35 @@
+/*
+ * 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 java.lang.annotation.RetentionPolicy.SOURCE;
+
+import android.annotation.IntDef;
+
+import java.lang.annotation.Retention;
+
+@Retention(SOURCE)
+@IntDef({ContentPreviewType.CONTENT_PREVIEW_FILE,
+ ContentPreviewType.CONTENT_PREVIEW_IMAGE,
+ ContentPreviewType.CONTENT_PREVIEW_TEXT})
+public @interface ContentPreviewType {
+ // Starting at 1 since 0 is considered "undefined" for some of the database transformations
+ // of tron logs.
+ int CONTENT_PREVIEW_IMAGE = 1;
+ int CONTENT_PREVIEW_FILE = 2;
+ int CONTENT_PREVIEW_TEXT = 3;
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java
new file mode 100644
index 0000000..39856e6
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java
@@ -0,0 +1,130 @@
+/*
+ * 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 android.content.ContentProvider.getUserIdFromUri;
+
+import android.animation.ObjectAnimator;
+import android.animation.ValueAnimator;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.os.UserHandle;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewStub;
+import android.view.animation.DecelerateInterpolator;
+
+import androidx.annotation.LayoutRes;
+
+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.RoundedRectImageView;
+
+import java.util.ArrayList;
+import java.util.List;
+
+abstract class ContentPreviewUi {
+ private static final int IMAGE_FADE_IN_MILLIS = 150;
+ static final String TAG = "ChooserPreview";
+
+ @ContentPreviewType
+ public abstract int getType();
+
+ public abstract ViewGroup display(
+ Resources resources, LayoutInflater layoutInflater, ViewGroup parent);
+
+ protected static int getActionRowLayout(FeatureFlagRepository featureFlagRepository) {
+ return featureFlagRepository.isEnabled(Flags.SHARESHEET_CUSTOM_ACTIONS)
+ ? R.layout.scrollable_chooser_action_row
+ : R.layout.chooser_action_row;
+ }
+
+ protected static ActionRow inflateActionRow(ViewGroup parent, @LayoutRes int actionRowLayout) {
+ final ViewStub stub = parent.findViewById(com.android.intentresolver.R.id.action_row_stub);
+ if (stub != null) {
+ stub.setLayoutResource(actionRowLayout);
+ stub.inflate();
+ }
+ return parent.findViewById(com.android.internal.R.id.chooser_action_row);
+ }
+
+ protected static List<ActionRow.Action> createActions(
+ List<ActionRow.Action> systemActions,
+ List<ActionRow.Action> customActions,
+ FeatureFlagRepository featureFlagRepository) {
+ ArrayList<ActionRow.Action> actions =
+ new ArrayList<>(systemActions.size() + customActions.size());
+ actions.addAll(systemActions);
+ if (featureFlagRepository.isEnabled(Flags.SHARESHEET_CUSTOM_ACTIONS)) {
+ actions.addAll(customActions);
+ }
+ return actions;
+ }
+
+ /**
+ * Indicate if the incoming content URI should be allowed.
+ *
+ * @param uri the uri to test
+ * @return true if the URI is allowed for content preview
+ */
+ protected static boolean validForContentPreview(Uri uri) throws SecurityException {
+ if (uri == null) {
+ return false;
+ }
+ int userId = getUserIdFromUri(uri, UserHandle.USER_CURRENT);
+ if (userId != UserHandle.USER_CURRENT && userId != UserHandle.myUserId()) {
+ Log.e(ContentPreviewUi.TAG, "dropped invalid content URI belonging to user " + userId);
+ return false;
+ }
+ return true;
+ }
+
+ protected static void updateViewWithImage(RoundedRectImageView imageView, Bitmap image) {
+ if (image == null) {
+ imageView.setVisibility(View.GONE);
+ return;
+ }
+ imageView.setVisibility(View.VISIBLE);
+ imageView.setAlpha(0.0f);
+ imageView.setImageBitmap(image);
+
+ ValueAnimator fadeAnim = ObjectAnimator.ofFloat(imageView, "alpha", 0.0f, 1.0f);
+ fadeAnim.setInterpolator(new DecelerateInterpolator(1.0f));
+ fadeAnim.setDuration(IMAGE_FADE_IN_MILLIS);
+ fadeAnim.start();
+ }
+
+ protected static void displayPayloadReselectionAction(
+ ViewGroup layout,
+ ChooserContentPreviewUi.ActionFactory actionFactory,
+ FeatureFlagRepository featureFlagRepository) {
+ Runnable modifyShareAction = actionFactory.getModifyShareAction();
+ if (modifyShareAction != null && layout != null
+ && featureFlagRepository.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION)) {
+ View modifyShareView = layout.findViewById(R.id.reselection_action);
+ if (modifyShareView != null) {
+ modifyShareView.setVisibility(View.VISIBLE);
+ modifyShareView.setOnClickListener(view -> modifyShareAction.run());
+ }
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java
new file mode 100644
index 0000000..7cd7147
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java
@@ -0,0 +1,236 @@
+/*
+ * 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.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;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.annotation.LayoutRes;
+
+import com.android.intentresolver.ImageLoader;
+import com.android.intentresolver.R;
+import com.android.intentresolver.flags.FeatureFlagRepository;
+import com.android.intentresolver.widget.ActionRow;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+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 ChooserContentPreviewUi.ActionFactory mActionFactory;
+ private final ImageLoader mImageLoader;
+ private final ContentInterface mContentResolver;
+ private final FeatureFlagRepository mFeatureFlagRepository;
+
+ FileContentPreviewUi(List<Uri> uris,
+ ChooserContentPreviewUi.ActionFactory actionFactory,
+ ImageLoader imageLoader,
+ ContentInterface contentResolver,
+ FeatureFlagRepository featureFlagRepository) {
+ mUris = uris;
+ mActionFactory = actionFactory;
+ mImageLoader = imageLoader;
+ mContentResolver = contentResolver;
+ mFeatureFlagRepository = featureFlagRepository;
+ }
+
+ @Override
+ public int getType() {
+ return ContentPreviewType.CONTENT_PREVIEW_FILE;
+ }
+
+ @Override
+ public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) {
+ ViewGroup layout = displayInternal(resources, layoutInflater, parent);
+ displayPayloadReselectionAction(layout, mActionFactory, mFeatureFlagRepository);
+ return layout;
+ }
+
+ private ViewGroup displayInternal(
+ Resources resources, LayoutInflater layoutInflater, ViewGroup parent) {
+ @LayoutRes int actionRowLayout = getActionRowLayout(mFeatureFlagRepository);
+ ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate(
+ R.layout.chooser_grid_preview_file, parent, false);
+
+ final int uriCount = mUris.size();
+
+ if (uriCount == 0) {
+ contentPreviewLayout.setVisibility(View.GONE);
+ Log.i(TAG, "Appears to be no uris available in EXTRA_STREAM,"
+ + " removing preview area");
+ return contentPreviewLayout;
+ }
+
+ if (uriCount == 1) {
+ loadFileUriIntoView(mUris.get(0), contentPreviewLayout, mImageLoader, mContentResolver);
+ } else {
+ FileInfo fileInfo = extractFileInfo(mUris.get(0), mContentResolver);
+ int remUriCount = uriCount - 1;
+ Map<String, Object> arguments = new HashMap<>();
+ arguments.put(PLURALS_COUNT, remUriCount);
+ arguments.put(PLURALS_FILE_NAME, fileInfo.name);
+ String fileName =
+ PluralsMessageFormatter.format(resources, arguments, R.string.file_count);
+
+ TextView fileNameView = contentPreviewLayout.findViewById(
+ com.android.internal.R.id.content_preview_filename);
+ fileNameView.setText(fileName);
+
+ View thumbnailView = contentPreviewLayout.findViewById(
+ com.android.internal.R.id.content_preview_file_thumbnail);
+ thumbnailView.setVisibility(View.GONE);
+
+ ImageView fileIconView = contentPreviewLayout.findViewById(
+ com.android.internal.R.id.content_preview_file_icon);
+ fileIconView.setVisibility(View.VISIBLE);
+ fileIconView.setImageResource(R.drawable.ic_file_copy);
+ }
+
+ final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout);
+ if (actionRow != null) {
+ actionRow.setActions(
+ createActions(
+ createFilePreviewActions(),
+ mActionFactory.createCustomActions(),
+ mFeatureFlagRepository));
+ }
+
+ return contentPreviewLayout;
+ }
+
+ private List<ActionRow.Action> createFilePreviewActions() {
+ List<ActionRow.Action> actions = new ArrayList<>(1);
+ //TODO(b/120417119):
+ // add action buttonFactory.createCopyButton()
+ ActionRow.Action action = mActionFactory.createNearbyButton();
+ if (action != null) {
+ actions.add(action);
+ }
+ return actions;
+ }
+
+ private static void loadFileUriIntoView(
+ final Uri uri,
+ final View parent,
+ final ImageLoader imageLoader,
+ final ContentInterface contentResolver) {
+ FileInfo fileInfo = extractFileInfo(uri, contentResolver);
+
+ TextView fileNameView = parent.findViewById(
+ com.android.internal.R.id.content_preview_filename);
+ fileNameView.setText(fileInfo.name);
+
+ if (fileInfo.hasThumbnail) {
+ imageLoader.loadImage(
+ uri,
+ (bitmap) -> updateViewWithImage(
+ parent.findViewById(
+ com.android.internal.R.id.content_preview_file_thumbnail),
+ bitmap));
+ } else {
+ View thumbnailView = parent.findViewById(
+ com.android.internal.R.id.content_preview_file_thumbnail);
+ thumbnailView.setVisibility(View.GONE);
+
+ ImageView fileIconView = parent.findViewById(
+ com.android.internal.R.id.content_preview_file_icon);
+ fileIconView.setVisibility(View.VISIBLE);
+ fileIconView.setImageResource(R.drawable.chooser_file_generic);
+ }
+ }
+
+ private static 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/ImageContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java
new file mode 100644
index 0000000..db26ab1
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java
@@ -0,0 +1,179 @@
+/*
+ * 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.net.Uri;
+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;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+
+class ImageContentPreviewUi extends ContentPreviewUi {
+ private final List<Uri> mImageUris;
+ @Nullable
+ private final CharSequence mText;
+ private final ChooserContentPreviewUi.ActionFactory mActionFactory;
+ private final ImageLoader mImageLoader;
+ private final ImagePreviewView.TransitionElementStatusCallback mTransitionElementStatusCallback;
+ private final FeatureFlagRepository mFeatureFlagRepository;
+
+ ImageContentPreviewUi(
+ List<Uri> imageUris,
+ @Nullable CharSequence text,
+ ChooserContentPreviewUi.ActionFactory actionFactory,
+ ImageLoader imageLoader,
+ ImagePreviewView.TransitionElementStatusCallback transitionElementStatusCallback,
+ FeatureFlagRepository featureFlagRepository) {
+ mImageUris = imageUris;
+ mText = text;
+ mActionFactory = actionFactory;
+ mImageLoader = imageLoader;
+ mTransitionElementStatusCallback = transitionElementStatusCallback;
+ mFeatureFlagRepository = featureFlagRepository;
+
+ mImageLoader.prePopulate(mImageUris);
+ }
+
+ @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);
+ ImagePreviewView imagePreview = inflateImagePreviewView(contentPreviewLayout);
+
+ final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout);
+ if (actionRow != null) {
+ actionRow.setActions(
+ createActions(
+ createImagePreviewActions(),
+ mActionFactory.createCustomActions(),
+ mFeatureFlagRepository));
+ }
+
+ if (mImageUris.size() == 0) {
+ Log.i(
+ TAG,
+ "Attempted to display image preview area with zero"
+ + " available images detected in EXTRA_STREAM list");
+ ((View) imagePreview).setVisibility(View.GONE);
+ mTransitionElementStatusCallback.onAllTransitionElementsReady();
+ return contentPreviewLayout;
+ }
+
+ setTextInImagePreviewVisibility(contentPreviewLayout, mActionFactory);
+ imagePreview.setTransitionElementStatusCallback(mTransitionElementStatusCallback);
+ imagePreview.setImages(mImageUris, 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 ImagePreviewView 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.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);
+ }
+}
diff --git a/java/src/com/android/intentresolver/HttpUriMatcher.kt b/java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt
similarity index 81%
rename from java/src/com/android/intentresolver/HttpUriMatcher.kt
rename to java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt
index 0f59df2..8023253 100644
--- a/java/src/com/android/intentresolver/HttpUriMatcher.kt
+++ b/java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt
@@ -15,15 +15,13 @@
*/
@file:JvmName("HttpUriMatcher")
-package com.android.intentresolver
+package com.android.intentresolver.contentpreview
-import com.android.internal.annotations.VisibleForTesting
import java.net.URI
-@VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)
-fun String.isHttpUri() =
+internal fun String.isHttpUri() =
kotlin.runCatching {
URI(this).scheme.takeIf { scheme ->
"http".compareTo(scheme, true) == 0 || "https".compareTo(scheme, true) == 0
}
- }.getOrNull() != null
\ No newline at end of file
+ }.getOrNull() != null
diff --git a/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt b/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt
new file mode 100644
index 0000000..9001693
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt
@@ -0,0 +1,33 @@
+/*
+ * 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.res.Resources
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.ViewGroup
+
+internal class NoContextPreviewUi(private val type: Int) : ContentPreviewUi() {
+ override fun getType(): Int = type
+
+ override fun display(
+ resources: Resources?, layoutInflater: LayoutInflater?, parent: ViewGroup?
+ ): ViewGroup? {
+ Log.e(TAG, "Unexpected content preview type: $type")
+ return null
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java
new file mode 100644
index 0000000..7901e4c
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java
@@ -0,0 +1,138 @@
+/*
+ * 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.res.Resources;
+import android.net.Uri;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+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.widget.ActionRow;
+
+import java.util.ArrayList;
+import java.util.List;
+
+class TextContentPreviewUi extends ContentPreviewUi {
+ @Nullable
+ private final CharSequence mSharingText;
+ @Nullable
+ private final CharSequence mPreviewTitle;
+ @Nullable
+ private final Uri mPreviewThumbnail;
+ private final ImageLoader mImageLoader;
+ private final ChooserContentPreviewUi.ActionFactory mActionFactory;
+ private final FeatureFlagRepository mFeatureFlagRepository;
+
+ TextContentPreviewUi(
+ @Nullable CharSequence sharingText,
+ @Nullable CharSequence previewTitle,
+ @Nullable Uri previewThumbnail,
+ ChooserContentPreviewUi.ActionFactory actionFactory,
+ ImageLoader imageLoader,
+ FeatureFlagRepository featureFlagRepository) {
+ mSharingText = sharingText;
+ mPreviewTitle = previewTitle;
+ mPreviewThumbnail = previewThumbnail;
+ mImageLoader = imageLoader;
+ mActionFactory = actionFactory;
+ mFeatureFlagRepository = featureFlagRepository;
+ }
+
+ @Override
+ public int getType() {
+ return ContentPreviewType.CONTENT_PREVIEW_TEXT;
+ }
+
+ @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_text, parent, false);
+
+ final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout);
+ if (actionRow != null) {
+ actionRow.setActions(
+ createActions(
+ createTextPreviewActions(),
+ mActionFactory.createCustomActions(),
+ mFeatureFlagRepository));
+ }
+
+ if (mSharingText == null) {
+ contentPreviewLayout
+ .findViewById(com.android.internal.R.id.content_preview_text_layout)
+ .setVisibility(View.GONE);
+ } else {
+ TextView textView = contentPreviewLayout.findViewById(
+ com.android.internal.R.id.content_preview_text);
+ textView.setText(mSharingText);
+ }
+
+ if (TextUtils.isEmpty(mPreviewTitle)) {
+ contentPreviewLayout
+ .findViewById(com.android.internal.R.id.content_preview_title_layout)
+ .setVisibility(View.GONE);
+ } else {
+ TextView previewTitleView = contentPreviewLayout.findViewById(
+ com.android.internal.R.id.content_preview_title);
+ previewTitleView.setText(mPreviewTitle);
+
+ ImageView previewThumbnailView = contentPreviewLayout.findViewById(
+ com.android.internal.R.id.content_preview_thumbnail);
+ if (!validForContentPreview(mPreviewThumbnail)) {
+ previewThumbnailView.setVisibility(View.GONE);
+ } else {
+ mImageLoader.loadImage(
+ mPreviewThumbnail,
+ (bitmap) -> updateViewWithImage(
+ contentPreviewLayout.findViewById(
+ com.android.internal.R.id.content_preview_thumbnail),
+ bitmap));
+ }
+ }
+
+ return contentPreviewLayout;
+ }
+
+ private List<ActionRow.Action> createTextPreviewActions() {
+ ArrayList<ActionRow.Action> actions = new ArrayList<>(2);
+ actions.add(mActionFactory.createCopyButton());
+ ActionRow.Action nearbyAction = mActionFactory.createNearbyButton();
+ if (nearbyAction != null) {
+ actions.add(nearbyAction);
+ }
+ return actions;
+ }
+}
diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java b/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java
index 7d1b248..aa42c24 100644
--- a/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java
+++ b/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java
@@ -36,6 +36,7 @@
import com.android.intentresolver.ChooserActivityLogger.SharesheetStandardEvent;
import com.android.intentresolver.ChooserActivityLogger.SharesheetStartedEvent;
import com.android.intentresolver.ChooserActivityLogger.SharesheetTargetSelectedEvent;
+import com.android.intentresolver.contentpreview.ContentPreviewType;
import com.android.internal.logging.InstanceId;
import com.android.internal.logging.MetricsLogger;
import com.android.internal.logging.UiEventLogger;
@@ -117,7 +118,7 @@
final int appProvidedDirectTargets = 123;
final int appProvidedAppTargets = 456;
final boolean workProfile = true;
- final int previewType = ChooserContentPreviewUi.CONTENT_PREVIEW_FILE;
+ final int previewType = ContentPreviewType.CONTENT_PREVIEW_FILE;
final String intentAction = Intent.ACTION_SENDTO;
final int numCustomActions = 3;
final boolean modifyShareProvided = true;
@@ -233,7 +234,7 @@
@Test
public void testLogActionShareWithPreview() {
- final int previewType = ChooserContentPreviewUi.CONTENT_PREVIEW_TEXT;
+ final int previewType = ContentPreviewType.CONTENT_PREVIEW_TEXT;
mChooserLogger.logActionShareWithPreview(previewType);
diff --git a/java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt b/java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt
index 5a159d2..b904771 100644
--- a/java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt
+++ b/java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt
@@ -21,7 +21,7 @@
import com.android.systemui.flags.ReleasedFlag
import com.android.systemui.flags.UnreleasedFlag
-internal class TestFeatureFlagRepository(
+class TestFeatureFlagRepository(
private val overrides: Map<BooleanFlag, Boolean>
) : FeatureFlagRepository {
override fun isEnabled(flag: UnreleasedFlag): Boolean = getValue(flag)
diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt
new file mode 100644
index 0000000..d870a8c
--- /dev/null
+++ b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt
@@ -0,0 +1,203 @@
+/*
+ * 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 android.content.ContentInterface
+import android.content.Intent
+import android.graphics.Bitmap
+import android.net.Uri
+import com.android.intentresolver.ImageLoader
+import com.android.intentresolver.TestFeatureFlagRepository
+import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory
+import com.android.intentresolver.flags.Flags
+import com.android.intentresolver.mock
+import com.android.intentresolver.whenever
+import com.android.intentresolver.widget.ActionRow
+import com.android.intentresolver.widget.ImagePreviewView
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.mockito.Mockito.never
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+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 ->
+ mimeType != null && ClipDescription.compareMimeTypes(mimeType, "image/*")
+ }
+ private val imageLoader = object : ImageLoader {
+ override fun loadImage(uri: Uri, callback: Consumer<Bitmap?>) {
+ callback.accept(null)
+ }
+ override fun prePopulate(uris: List<Uri>) = Unit
+ override suspend fun invoke(uri: Uri): Bitmap? = null
+ }
+ private val actionFactory = object : ActionFactory {
+ override fun createCopyButton() = ActionRow.Action(label = "Copy", icon = null) {}
+ override fun createEditButton(): ActionRow.Action? = null
+ override fun createNearbyButton(): ActionRow.Action? = null
+ override fun createCustomActions(): List<ActionRow.Action> = emptyList()
+ override fun getModifyShareAction(): Runnable? = null
+ override fun getExcludeSharedTextAction(): Consumer<Boolean> = Consumer<Boolean> {}
+ }
+ private val transitionCallback = mock<ImagePreviewView.TransitionElementStatusCallback>()
+ private val featureFlagRepository = TestFeatureFlagRepository(
+ mapOf(
+ Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW to true
+ )
+ )
+
+ @Test
+ fun test_ChooserContentPreview_non_send_intent_action_to_text_preview() {
+ val targetIntent = Intent(Intent.ACTION_VIEW)
+ val testSubject = ChooserContentPreviewUi(
+ targetIntent,
+ contentResolver,
+ imageClassifier,
+ imageLoader,
+ actionFactory,
+ transitionCallback,
+ featureFlagRepository
+ )
+ assertThat(testSubject.preferredContentPreview)
+ .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT)
+ verify(transitionCallback, times(1)).onAllTransitionElementsReady()
+ }
+
+ @Test
+ fun test_ChooserContentPreview_text_mime_type_to_text_preview() {
+ val targetIntent = Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_TEXT, "Text Extra")
+ }
+ val testSubject = ChooserContentPreviewUi(
+ targetIntent,
+ contentResolver,
+ imageClassifier,
+ imageLoader,
+ actionFactory,
+ transitionCallback,
+ featureFlagRepository
+ )
+ assertThat(testSubject.preferredContentPreview)
+ .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT)
+ verify(transitionCallback, times(1)).onAllTransitionElementsReady()
+ }
+
+ @Test
+ fun test_ChooserContentPreview_single_image_uri_to_image_preview() {
+ val uri = Uri.parse("content://$PROVIDER_NAME/test.png")
+ val targetIntent = Intent(Intent.ACTION_SEND).apply {
+ putExtra(Intent.EXTRA_STREAM, uri)
+ }
+ whenever(contentResolver.getType(uri)).thenReturn("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_single_non_image_uri_to_file_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")
+ val testSubject = ChooserContentPreviewUi(
+ targetIntent,
+ contentResolver,
+ imageClassifier,
+ imageLoader,
+ actionFactory,
+ transitionCallback,
+ featureFlagRepository
+ )
+ assertThat(testSubject.preferredContentPreview)
+ .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE)
+ verify(transitionCallback, times(1)).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")
+ val targetIntent = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
+ putExtra(
+ Intent.EXTRA_STREAM,
+ ArrayList<Uri>().apply {
+ add(uri1)
+ add(uri2)
+ }
+ )
+ }
+ whenever(contentResolver.getType(uri1)).thenReturn("image/png")
+ whenever(contentResolver.getType(uri2)).thenReturn("image/jpeg")
+ 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_some_non_image_uri_to_file_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 {
+ putExtra(
+ Intent.EXTRA_STREAM,
+ ArrayList<Uri>().apply {
+ add(uri1)
+ add(uri2)
+ }
+ )
+ }
+ whenever(contentResolver.getType(uri1)).thenReturn("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_FILE)
+ verify(transitionCallback, times(1)).onAllTransitionElementsReady()
+ }
+}