From 89697b484befb174482c92f5072f01836678d46c Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Fri, 3 Feb 2023 08:56:17 -0800 Subject: Add optional text exclusion from media + text share Add an option to the user to exclude text from being shared from a media + text sharing case. The text is ecluded only if the primary send inten is used i.e. it is possible for senders to specify alterntive intents that can also be used. Bug: 262277421 Test: manual testing of: (1) regualr image sharing, (2) image and text/web-link sharing, (3) using of an alternative intent, (4) sharing to a shortcut. All of those for one and two (+work) profiles. Test: integration tests Change-Id: Iebb33b951853c6588157fa92d3826b6a6e096591 --- java/res/layout/chooser_grid_preview_image.xml | 8 + java/res/values/strings.xml | 9 ++ .../android/intentresolver/ChooserActivity.java | 30 ++++ .../intentresolver/ChooserContentPreviewUi.java | 50 +++++-- .../intentresolver/chooser/DisplayResolveInfo.java | 5 + .../chooser/MultiDisplayResolveInfo.java | 5 + .../chooser/NotSelectableTargetInfo.java | 7 + .../chooser/SelectableTargetInfo.java | 6 + .../android/intentresolver/chooser/TargetInfo.java | 6 + .../intentresolver/ResolverDataProvider.java | 27 ++++ .../UnbundledChooserActivityTest.java | 165 +++++++++++++++++++++ 11 files changed, 309 insertions(+), 9 deletions(-) (limited to 'java') diff --git a/java/res/layout/chooser_grid_preview_image.xml b/java/res/layout/chooser_grid_preview_image.xml index 6af0af11..80c12e6c 100644 --- a/java/res/layout/chooser_grid_preview_image.xml +++ b/java/res/layout/chooser_grid_preview_image.xml @@ -24,6 +24,14 @@ android:orientation="vertical" android:background="?android:attr/colorBackground"> + + Select Images Select Text + + + Exclude text + + Include text + + Exclude link + + Include link diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index e741b06c..3a7d4e68 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -262,6 +262,8 @@ public class ChooserActivity extends ResolverActivity implements private final SparseArray mProfileRecords = new SparseArray<>(); + private boolean mExcludeSharedText = false; + public ChooserActivity() {} @Override @@ -771,6 +773,11 @@ public class ChooserActivity extends ResolverActivity implements ? null : createReselectionRunnable(reselectionAction); } + + @Override + public Consumer getExcludeSharedTextAction() { + return (isExcluded) -> mExcludeSharedText = isExcluded; + } }; ViewGroup layout = mChooserContentPreviewUi.displayContentPreview( @@ -1196,6 +1203,7 @@ public class ChooserActivity extends ResolverActivity implements } } updateModelAndChooserCounts(target); + maybeRemoveSharedText(target); return super.onTargetSelected(target, alwaysCheck); } @@ -1384,6 +1392,27 @@ public class ChooserActivity extends ResolverActivity implements mIsSuccessfullySelected = true; } + private void maybeRemoveSharedText(@androidx.annotation.NonNull TargetInfo targetInfo) { + Intent targetIntent = targetInfo.getTargetIntent(); + if (targetIntent == null) { + return; + } + Intent originalTargetIntent = new Intent(mChooserRequest.getTargetIntent()); + // Our TargetInfo implementations add associated component to the intent, let's do the same + // for the sake of the comparison below. + if (targetIntent.getComponent() != null) { + originalTargetIntent.setComponent(targetIntent.getComponent()); + } + // Use filterEquals as a way to check that the primary intent is in use (and not an + // alternative one). For example, an app is sharing an image and a link with mime type + // "image/png" and provides an alternative intent to share only the link with mime type + // "text/uri". Should there be a target that accepts only the latter, the alternative intent + // will be used and we don't want to exclude the link from it. + if (mExcludeSharedText && originalTargetIntent.filterEquals(targetIntent)) { + targetIntent.removeExtra(Intent.EXTRA_TEXT); + } + } + private void sendImpressionToAppPredictor(TargetInfo targetInfo, ChooserListAdapter adapter) { // Send DS target impression info to AppPredictor, only when user chooses app share. if (targetInfo.isChooserTargetInfo()) { @@ -1451,6 +1480,7 @@ public class ChooserActivity extends ResolverActivity implements + " cannot match refined source intent " + matchingIntent); } else { TargetInfo clonedTarget = selectedTarget.cloneFilledIn(matchingIntent, 0); + maybeRemoveSharedText(clonedTarget); if (super.onTargetSelected(clonedTarget, false)) { updateModelAndChooserCounts(clonedTarget); finish(); diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java index 9bef3553..91abd9d0 100644 --- a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java @@ -40,6 +40,7 @@ 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; @@ -59,6 +60,7 @@ 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; /** @@ -100,6 +102,17 @@ public final class ChooserContentPreviewUi { */ @Nullable Runnable getReselectionAction(); + + /** + *

+ * Creates an exclude-text action that can be called when the user changes shared text + * status in the Media + Text preview. + *

+ *

+ * true argument value indicates that the text should be excluded. + *

+ */ + Consumer getExcludeSharedTextAction(); } /** @@ -222,7 +235,8 @@ public final class ChooserContentPreviewUi { transitionElementStatusCallback, contentResolver, imageClassifier, - actionRowLayout); + actionRowLayout, + actionFactory); break; case CONTENT_PREVIEW_FILE: layout = displayFileContentPreview( @@ -360,7 +374,8 @@ public final class ChooserContentPreviewUi { TransitionElementStatusCallback transitionElementStatusCallback, ContentResolver contentResolver, ImageMimeTypeClassifier imageClassifier, - @LayoutRes int actionRowLayout) { + @LayoutRes int actionRowLayout, + ActionFactory actionFactory) { ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_image, parent, false); ImagePreviewView imagePreview = inflateImagePreviewView(contentPreviewLayout); @@ -391,7 +406,8 @@ public final class ChooserContentPreviewUi { setTextInImagePreviewVisibility( contentPreviewLayout, - targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT)); + targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT), + actionFactory); imagePreview.setTransitionElementStatusCallback(transitionElementStatusCallback); imagePreview.setImages(imageUris, imageLoader); @@ -399,20 +415,36 @@ public final class ChooserContentPreviewUi { } private void setTextInImagePreviewVisibility( - ViewGroup contentPreview, CharSequence text) { + ViewGroup contentPreview, CharSequence text, ActionFactory actionFactory) { int visibility = mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW) && !TextUtils.isEmpty(text) ? View.VISIBLE : View.GONE; - TextView textView = contentPreview + 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); - int linkMask = visibility == View.VISIBLE && HttpUriMatcher.isHttpUri(text.toString()) - ? Linkify.WEB_URLS - : 0; - textView.setAutoLinkMask(linkMask); + 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 shareTextAction = actionFactory.getExcludeSharedTextAction(); + actionView.setChecked(true); + actionView.setText(actionLabels[1]); + shareTextAction.accept(false); + actionView.setOnCheckedChangeListener((view, isChecked) -> { + view.setText(actionLabels[isChecked ? 1 : 0]); + textView.setVisibility(isChecked ? View.VISIBLE : View.GONE); + shareTextAction.accept(!isChecked); + }); + } + actionView.setVisibility(visibility); } private static List createImagePreviewActions( diff --git a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java index 1b729c0e..4bbf59d8 100644 --- a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java @@ -224,6 +224,11 @@ public class DisplayResolveInfo implements TargetInfo { return false; } + @Override + public Intent getTargetIntent() { + return mResolvedIntent; + } + public boolean isSuspended() { return mIsSuspended; } diff --git a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java index e4cec887..0d79e5d5 100644 --- a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java @@ -119,4 +119,9 @@ public class MultiDisplayResolveInfo extends DisplayResolveInfo { public boolean startAsUser(Activity activity, Bundle options, UserHandle user) { return mTargetInfos.get(mSelected).startAsUser(activity, options, user); } + + @Override + public Intent getTargetIntent() { + return mTargetInfos.get(mSelected).getTargetIntent(); + } } diff --git a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java index d6333374..9a2c971f 100644 --- a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java @@ -16,6 +16,7 @@ package com.android.intentresolver.chooser; +import android.annotation.Nullable; import android.app.Activity; import android.content.ComponentName; import android.content.Context; @@ -101,6 +102,12 @@ public abstract class NotSelectableTargetInfo extends ChooserTargetInfo { return false; } + @Nullable + @Override + public Intent getTargetIntent() { + return null; + } + public boolean startAsUser(Activity activity, Bundle options, UserHandle user) { return false; } diff --git a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java index 3ab50175..ca778233 100644 --- a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java @@ -346,6 +346,12 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { return mActivityStarter.startAsUser(activity, options, user); } + @Nullable + @Override + public Intent getTargetIntent() { + return mBaseIntentToSend; + } + @Override public ResolveInfo getResolveInfo() { return mResolveInfo; diff --git a/java/src/com/android/intentresolver/chooser/TargetInfo.java b/java/src/com/android/intentresolver/chooser/TargetInfo.java index 72dd1b0b..7dcf66b2 100644 --- a/java/src/com/android/intentresolver/chooser/TargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/TargetInfo.java @@ -87,6 +87,12 @@ public interface TargetInfo { */ Intent getResolvedIntent(); + /** + * Get the target intent, the one that will be used with one of the start methods. + * @return the intent with target will be launced with. + */ + @Nullable Intent getTargetIntent(); + /** * Get the resolved component name that represents this target. Note that this may not * be the component that will be directly launched by calling one of the start diff --git a/java/tests/src/com/android/intentresolver/ResolverDataProvider.java b/java/tests/src/com/android/intentresolver/ResolverDataProvider.java index fb928e09..6807bfd6 100644 --- a/java/tests/src/com/android/intentresolver/ResolverDataProvider.java +++ b/java/tests/src/com/android/intentresolver/ResolverDataProvider.java @@ -41,6 +41,14 @@ public class ResolverDataProvider { createResolverIntent(i), createResolveInfo(i, UserHandle.USER_CURRENT)); } + static ResolverActivity.ResolvedComponentInfo createResolvedComponentInfo( + ComponentName componentName, Intent intent) { + return new ResolverActivity.ResolvedComponentInfo( + componentName, + intent, + createResolveInfo(componentName, UserHandle.USER_CURRENT)); + } + static ResolverActivity.ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i) { return new ResolverActivity.ResolvedComponentInfo(createComponentName(i), createResolverIntent(i), createResolveInfo(i, USER_SOMEONE_ELSE)); @@ -64,6 +72,13 @@ public class ResolverDataProvider { return resolveInfo; } + public static ResolveInfo createResolveInfo(ComponentName componentName, int userId) { + final ResolveInfo resolveInfo = new ResolveInfo(); + resolveInfo.activityInfo = createActivityInfo(componentName); + resolveInfo.targetUserId = userId; + return resolveInfo; + } + static ActivityInfo createActivityInfo(int i) { ActivityInfo ai = new ActivityInfo(); ai.name = "activity_name" + i; @@ -75,6 +90,18 @@ public class ResolverDataProvider { return ai; } + static ActivityInfo createActivityInfo(ComponentName componentName) { + ActivityInfo ai = new ActivityInfo(); + ai.name = componentName.getClassName(); + ai.packageName = componentName.getPackageName(); + ai.enabled = true; + ai.exported = true; + ai.permission = null; + ai.applicationInfo = createApplicationInfo(); + ai.applicationInfo.packageName = componentName.getPackageName(); + return ai; + } + static ApplicationInfo createApplicationInfo() { ApplicationInfo ai = new ApplicationInfo(); ai.name = "app_name"; diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 249dca62..c90f0b63 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -128,6 +128,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import java.util.function.Function; @@ -755,6 +756,170 @@ public class UnbundledChooserActivityTest { assertThat(chosen[0], is(toChoose)); } + @Test + public void testImagePlusTextSharing_ExcludeText() { + ChooserActivityOverrideData.getInstance().featureFlagRepository = + new TestFeatureFlagRepository( + Collections.singletonMap(Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW, true)); + Intent sendIntent = createSendImageIntent( + Uri.parse("android.resource://com.android.frameworks.coretests/" + + R.drawable.test320x240)); + ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); + ChooserActivityOverrideData.getInstance().isImageType = true; + sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); + + List resolvedComponentInfos = Arrays.asList( + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.imageviewer", "ImageTarget"), + sendIntent), + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.textviewer", "UriTarget"), + new Intent("VIEW_TEXT")) + ); + + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(resolvedComponentInfos); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(R.id.include_text_action)) + .check(matches(isDisplayed())) + .perform(click()); + waitForIdle(); + + AtomicReference launchedIntentRef = new AtomicReference<>(); + ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> { + launchedIntentRef.set(targetInfo.getTargetIntent()); + return true; + }; + + onView(withText(resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.name)) + .perform(click()); + waitForIdle(); + assertThat(launchedIntentRef.get().hasExtra(Intent.EXTRA_TEXT)).isFalse(); + } + + @Test + public void testImagePlusTextSharing_RemoveAndAddBackText() { + ChooserActivityOverrideData.getInstance().featureFlagRepository = + new TestFeatureFlagRepository( + Collections.singletonMap(Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW, true)); + Intent sendIntent = createSendImageIntent( + Uri.parse("android.resource://com.android.frameworks.coretests/" + + R.drawable.test320x240)); + ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); + ChooserActivityOverrideData.getInstance().isImageType = true; + final String text = "https://google.com/search?q=google"; + sendIntent.putExtra(Intent.EXTRA_TEXT, text); + + List resolvedComponentInfos = Arrays.asList( + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.imageviewer", "ImageTarget"), + sendIntent), + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.textviewer", "UriTarget"), + new Intent("VIEW_TEXT")) + ); + + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(resolvedComponentInfos); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(R.id.include_text_action)) + .check(matches(isDisplayed())) + .perform(click()); + waitForIdle(); + onView(withId(R.id.include_text_action)) + .perform(click()); + waitForIdle(); + + AtomicReference launchedIntentRef = new AtomicReference<>(); + ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> { + launchedIntentRef.set(targetInfo.getTargetIntent()); + return true; + }; + + onView(withText(resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.name)) + .perform(click()); + waitForIdle(); + assertThat(launchedIntentRef.get().getStringExtra(Intent.EXTRA_TEXT)).isEqualTo(text); + } + + @Test + public void testImagePlusTextSharing_TextExclusionDoesNotAffectAlternativeIntent() { + ChooserActivityOverrideData.getInstance().featureFlagRepository = + new TestFeatureFlagRepository( + Collections.singletonMap(Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW, true)); + Intent sendIntent = createSendImageIntent( + Uri.parse("android.resource://com.android.frameworks.coretests/" + + R.drawable.test320x240)); + ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); + ChooserActivityOverrideData.getInstance().isImageType = true; + sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); + + Intent alternativeIntent = createSendTextIntent(); + final String text = "alternative intent"; + alternativeIntent.putExtra(Intent.EXTRA_TEXT, text); + + List resolvedComponentInfos = Arrays.asList( + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.imageviewer", "ImageTarget"), + sendIntent), + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.textviewer", "UriTarget"), + alternativeIntent) + ); + + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(resolvedComponentInfos); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(R.id.include_text_action)) + .check(matches(isDisplayed())) + .perform(click()); + waitForIdle(); + + AtomicReference launchedIntentRef = new AtomicReference<>(); + ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> { + launchedIntentRef.set(targetInfo.getTargetIntent()); + return true; + }; + + onView(withText(resolvedComponentInfos.get(1).getResolveInfoAt(0).activityInfo.name)) + .perform(click()); + waitForIdle(); + assertThat(launchedIntentRef.get().getStringExtra(Intent.EXTRA_TEXT)).isEqualTo(text); + } + @Test public void copyTextToClipboard() throws Exception { Intent sendIntent = createSendTextIntent(); -- cgit v1.2.3-59-g8ed1b