From 8e7e2cd26ff2f125abb361ba8a9997aae1d14892 Mon Sep 17 00:00:00 2001 From: Vinh Tran Date: Mon, 9 May 2022 18:52:09 -0400 Subject: Unset platform apis when sdk_version is already set Similar to ag/18129686, unsetting platform_apis while sdk_version doesn't affect the build of the module Test: CI Bug: 219755537 Change-Id: I4ec2e56f7c9d876d2c246132b296b7c6ecbab9bc --- java/tests/Android.bp | 1 - 1 file changed, 1 deletion(-) (limited to 'java/tests') diff --git a/java/tests/Android.bp b/java/tests/Android.bp index fdabc4e0..20cde8d4 100644 --- a/java/tests/Android.bp +++ b/java/tests/Android.bp @@ -29,7 +29,6 @@ android_test { ], test_suites: ["general-tests"], sdk_version: "core_platform", - platform_apis: true, compile_multilib: "both", dont_merge_manifests: true, -- cgit v1.2.3-59-g8ed1b From 6333997c2c04f2e71c8fefad56a651e88bd25922 Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Sun, 5 Feb 2023 04:03:56 +0000 Subject: [intentresolver] Reselection -> modify share Doesn't change the UI or logging in this change, just API and code naming. Bug: 267870268 Test: atest UnbundledChooserActivityTest Change-Id: I2b8a68ed1e3fe1e6d4bdb1a89f155afdf377159a --- java/src/com/android/intentresolver/ChooserActivity.java | 4 ++-- .../android/intentresolver/ChooserContentPreviewUi.java | 16 ++++++++-------- .../android/intentresolver/ChooserRequestParameters.java | 16 ++++++++-------- .../intentresolver/UnbundledChooserActivityTest.java | 10 +++++----- 4 files changed, 23 insertions(+), 23 deletions(-) (limited to 'java/tests') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 3a7d4e68..c11b8060 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -763,12 +763,12 @@ public class ChooserActivity extends ResolverActivity implements @Nullable @Override - public Runnable getReselectionAction() { + public Runnable getModifyShareAction() { if (!mFeatureFlagRepository .isEnabled(Flags.SHARESHEET_RESELECTION_ACTION)) { return null; } - PendingIntent reselectionAction = mChooserRequest.getReselectionAction(); + PendingIntent reselectionAction = mChooserRequest.getModifyShareAction(); return reselectionAction == null ? null : createReselectionRunnable(reselectionAction); diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java index 91abd9d0..a294e1a1 100644 --- a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java @@ -98,10 +98,10 @@ public final class ChooserContentPreviewUi { List createCustomActions(); /** - * Provides a re-selection action, if any. + * Provides a share modification action, if any. */ @Nullable - Runnable getReselectionAction(); + Runnable getModifyShareAction(); /** *

@@ -254,13 +254,13 @@ public final class ChooserContentPreviewUi { default: Log.e(TAG, "Unexpected content preview type: " + previewType); } - Runnable reselectionAction = actionFactory.getReselectionAction(); - if (reselectionAction != null && layout != null + Runnable modifyShareAction = actionFactory.getModifyShareAction(); + if (modifyShareAction != null && layout != null && mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION)) { - View reselectionView = layout.findViewById(R.id.reselection_action); - if (reselectionView != null) { - reselectionView.setVisibility(View.VISIBLE); - reselectionView.setOnClickListener(view -> reselectionAction.run()); + 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/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java index 0d004b0d..2b67b273 100644 --- a/java/src/com/android/intentresolver/ChooserRequestParameters.java +++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java @@ -76,7 +76,7 @@ public class ChooserRequestParameters { private final ImmutableList mFilteredComponentNames; private final ImmutableList mCallerChooserTargets; private final ImmutableList mChooserActions; - private final PendingIntent mReselectionAction; + private final PendingIntent mModifyShareAction; private final boolean mRetainInOnStop; @Nullable @@ -142,8 +142,8 @@ public class ChooserRequestParameters { mChooserActions = featureFlags.isEnabled(Flags.SHARESHEET_CUSTOM_ACTIONS) ? getChooserActions(clientIntent) : ImmutableList.of(); - mReselectionAction = featureFlags.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION) - ? getReselectionActionExtra(clientIntent) + mModifyShareAction = featureFlags.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION) + ? getModifyShareAction(clientIntent) : null; } @@ -191,8 +191,8 @@ public class ChooserRequestParameters { } @Nullable - public PendingIntent getReselectionAction() { - return mReselectionAction; + public PendingIntent getModifyShareAction() { + return mModifyShareAction; } /** @@ -335,15 +335,15 @@ public class ChooserRequestParameters { } @Nullable - private static PendingIntent getReselectionActionExtra(Intent intent) { + private static PendingIntent getModifyShareAction(Intent intent) { try { return intent.getParcelableExtra( - Intent.EXTRA_CHOOSER_PAYLOAD_RESELECTION_ACTION, + Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION, PendingIntent.class); } catch (Throwable t) { Log.w( TAG, - "Unable to retrieve Intent.EXTRA_CHOOSER_PAYLOAD_RESELECTION_ACTION argument", + "Unable to retrieve Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION argument", t); return null; } diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index c90f0b63..17fd5bd9 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -1998,7 +1998,7 @@ public class UnbundledChooserActivityTest { } @Test - public void testLaunchWithPayloadReselection() throws InterruptedException { + public void testLaunchWithShareModification() throws InterruptedException { ChooserActivityOverrideData.getInstance().featureFlagRepository = new TestFeatureFlagRepository( Collections.singletonMap(Flags.SHARESHEET_RESELECTION_ACTION, true)); @@ -2015,14 +2015,14 @@ public class UnbundledChooserActivityTest { .thenReturn(resolvedComponentInfos); Context testContext = InstrumentationRegistry.getInstrumentation().getContext(); - final String reselectionAction = "test-broadcast-receiver-action"; + final String modifyShareAction = "test-broadcast-receiver-action"; Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null); chooserIntent.putExtra( - Intent.EXTRA_CHOOSER_PAYLOAD_RESELECTION_ACTION, + Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION, PendingIntent.getBroadcast( testContext, 123, - new Intent(reselectionAction), + new Intent(modifyShareAction), PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT)); // Start activity mActivityRule.launchActivity(chooserIntent); @@ -2035,7 +2035,7 @@ public class UnbundledChooserActivityTest { broadcastInvoked.countDown(); } }; - testContext.registerReceiver(testReceiver, new IntentFilter(reselectionAction)); + testContext.registerReceiver(testReceiver, new IntentFilter(modifyShareAction)); try { onView(withText(R.string.select_text)).perform(click()); -- cgit v1.2.3-59-g8ed1b From 5b4432dbb7972e99a8ae30fa7421b805116d691d Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Mon, 6 Feb 2023 21:49:25 +0000 Subject: Release sharesheet API and preview features. Update flag and test code to allow for released flags. Test: atest CtsSharesheetDeviceTest Test: atest UnbundledChooserActivityTest Bug: 266983432 Bug: 266982749 Bug: 266983474 Bug: 267355521 Change-Id: I1c45840bd454264f553d6d25c4029f3cc8f3f073 --- java/src/com/android/intentresolver/flags/Flags.kt | 14 +++++++++----- .../android/intentresolver/TestFeatureFlagRepository.kt | 6 ++++-- 2 files changed, 13 insertions(+), 7 deletions(-) (limited to 'java/tests') diff --git a/java/src/com/android/intentresolver/flags/Flags.kt b/java/src/com/android/intentresolver/flags/Flags.kt index 9c206265..917983ed 100644 --- a/java/src/com/android/intentresolver/flags/Flags.kt +++ b/java/src/com/android/intentresolver/flags/Flags.kt @@ -16,6 +16,7 @@ package com.android.intentresolver.flags +import com.android.systemui.flags.ReleasedFlag import com.android.systemui.flags.UnreleasedFlag // Flag id, name and namespace should be kept in sync with [com.android.systemui.flags.Flags] to @@ -23,24 +24,27 @@ import com.android.systemui.flags.UnreleasedFlag object Flags { // TODO(b/266983432) Tracking Bug @JvmField - val SHARESHEET_CUSTOM_ACTIONS = unreleasedFlag(1501, "sharesheet_custom_actions", teamfood = true) + val SHARESHEET_CUSTOM_ACTIONS = releasedFlag(1501, "sharesheet_custom_actions") // TODO(b/266982749) Tracking Bug @JvmField - val SHARESHEET_RESELECTION_ACTION = unreleasedFlag(1502, "sharesheet_reselection_action", teamfood = true) + val SHARESHEET_RESELECTION_ACTION = releasedFlag(1502, "sharesheet_reselection_action") // TODO(b/266983474) Tracking Bug @JvmField - val SHARESHEET_IMAGE_AND_TEXT_PREVIEW = unreleasedFlag( - id = 1503, name = "sharesheet_image_text_preview", teamfood = true + val SHARESHEET_IMAGE_AND_TEXT_PREVIEW = releasedFlag( + id = 1503, name = "sharesheet_image_text_preview" ) // TODO(b/267355521) Tracking Bug @JvmField - val SHARESHEET_SCROLLABLE_IMAGE_PREVIEW = unreleasedFlag( + val SHARESHEET_SCROLLABLE_IMAGE_PREVIEW = releasedFlag( 1504, "sharesheet_scrollable_image_preview" ) + private fun releasedFlag(id: Int, name: String) = + ReleasedFlag(id, name, "systemui") + private fun unreleasedFlag(id: Int, name: String, teamfood: Boolean = false) = UnreleasedFlag(id, name, "systemui", teamfood) } diff --git a/java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt b/java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt index abc24efb..b8f6a5c1 100644 --- a/java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt +++ b/java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt @@ -17,14 +17,16 @@ package com.android.intentresolver import com.android.intentresolver.flags.FeatureFlagRepository +import com.android.systemui.flags.BooleanFlag import com.android.systemui.flags.ReleasedFlag import com.android.systemui.flags.UnreleasedFlag internal class TestFeatureFlagRepository( - private val overrides: Map + private val overrides: Map ) : FeatureFlagRepository { override fun isEnabled(flag: UnreleasedFlag): Boolean = overrides.getOrDefault(flag, flag.default) - override fun isEnabled(flag: ReleasedFlag): Boolean = flag.default + override fun isEnabled(flag: ReleasedFlag): Boolean = + overrides.getOrDefault(flag, flag.default) } -- cgit v1.2.3-59-g8ed1b From 880417ed82485d63c87737d38a270a3367d27594 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Mon, 6 Feb 2023 21:31:44 -0800 Subject: Run Chooser integration tests for the new features Add a new test parameter, feature flag set, and run Chooser tests for two set values: all flags are off and all flags are on. This ensures that our integration tests cover the new features. A new test rule is added to ignore tests that were designed for a specific flag values when running in a set that does not have those values set. Bug: 258838272 Test: the modified test itself Change-Id: If646ef123a383e801fda55d601e10b186c6c5c1f --- java/src/com/android/intentresolver/flags/Flags.kt | 13 ++- .../com/android/intentresolver/FeatureFlagRule.kt | 56 ++++++++++ .../android/intentresolver/RequireFeatureFlags.kt | 23 +++++ .../intentresolver/TestFeatureFlagRepository.kt | 7 +- .../UnbundledChooserActivityTest.java | 114 +++++++++++++-------- 5 files changed, 165 insertions(+), 48 deletions(-) create mode 100644 java/tests/src/com/android/intentresolver/FeatureFlagRule.kt create mode 100644 java/tests/src/com/android/intentresolver/RequireFeatureFlags.kt (limited to 'java/tests') diff --git a/java/src/com/android/intentresolver/flags/Flags.kt b/java/src/com/android/intentresolver/flags/Flags.kt index 917983ed..40f32bf3 100644 --- a/java/src/com/android/intentresolver/flags/Flags.kt +++ b/java/src/com/android/intentresolver/flags/Flags.kt @@ -22,24 +22,29 @@ import com.android.systemui.flags.UnreleasedFlag // Flag id, name and namespace should be kept in sync with [com.android.systemui.flags.Flags] to // make the flags available in the flag flipper app (see go/sysui-flags). object Flags { + const val SHARESHEET_CUSTOM_ACTIONS_NAME = "sharesheet_custom_actions" + const val SHARESHEET_RESELECTION_ACTION_NAME = "sharesheet_reselection_action" + const val SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME = "sharesheet_image_text_preview" + const val SHARESHEET_SCROLLABLE_IMAGE_PREVIEW_NAME = "sharesheet_scrollable_image_preview" + // TODO(b/266983432) Tracking Bug @JvmField - val SHARESHEET_CUSTOM_ACTIONS = releasedFlag(1501, "sharesheet_custom_actions") + val SHARESHEET_CUSTOM_ACTIONS = releasedFlag(1501, SHARESHEET_CUSTOM_ACTIONS_NAME) // TODO(b/266982749) Tracking Bug @JvmField - val SHARESHEET_RESELECTION_ACTION = releasedFlag(1502, "sharesheet_reselection_action") + val SHARESHEET_RESELECTION_ACTION = releasedFlag(1502, SHARESHEET_RESELECTION_ACTION_NAME) // TODO(b/266983474) Tracking Bug @JvmField val SHARESHEET_IMAGE_AND_TEXT_PREVIEW = releasedFlag( - id = 1503, name = "sharesheet_image_text_preview" + id = 1503, name = SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME ) // TODO(b/267355521) Tracking Bug @JvmField val SHARESHEET_SCROLLABLE_IMAGE_PREVIEW = releasedFlag( - 1504, "sharesheet_scrollable_image_preview" + 1504, SHARESHEET_SCROLLABLE_IMAGE_PREVIEW_NAME ) private fun releasedFlag(id: Int, name: String) = diff --git a/java/tests/src/com/android/intentresolver/FeatureFlagRule.kt b/java/tests/src/com/android/intentresolver/FeatureFlagRule.kt new file mode 100644 index 00000000..3fa01bcc --- /dev/null +++ b/java/tests/src/com/android/intentresolver/FeatureFlagRule.kt @@ -0,0 +1,56 @@ +/* + * 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 + +import com.android.systemui.flags.BooleanFlag +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +/** + * Ignores tests annotated with [RequireFeatureFlags] which flag requirements does not + * meet in the active flag set. + * @param flags active flag set + */ +internal class FeatureFlagRule(flags: Map) : TestRule { + private val flags = flags.entries.fold(HashMap()) { map, (key, value) -> + map.apply { + put(key.name, value) + } + } + private val skippingStatement = object : Statement() { + override fun evaluate() = Unit + } + + override fun apply(base: Statement, description: Description): Statement { + val annotation = description.annotations.firstOrNull { + it is RequireFeatureFlags + } as? RequireFeatureFlags + ?: return base + + if (annotation.flags.size != annotation.values.size) { + error("${description.className}#${description.methodName}: inconsistent number of" + + " flags and values in $annotation") + } + for (i in annotation.flags.indices) { + val flag = annotation.flags[i] + val value = annotation.values[i] + if (flags.getOrDefault(flag, !value) != value) return skippingStatement + } + return base + } +} diff --git a/java/tests/src/com/android/intentresolver/RequireFeatureFlags.kt b/java/tests/src/com/android/intentresolver/RequireFeatureFlags.kt new file mode 100644 index 00000000..1ddf7462 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/RequireFeatureFlags.kt @@ -0,0 +1,23 @@ +/* + * 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 + +/** + * Specifies expected feature flag values for a test. + */ +@Target(AnnotationTarget.FUNCTION) +annotation class RequireFeatureFlags(val flags: Array, val values: BooleanArray) diff --git a/java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt b/java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt index b8f6a5c1..5a159d24 100644 --- a/java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt +++ b/java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt @@ -24,9 +24,8 @@ import com.android.systemui.flags.UnreleasedFlag internal class TestFeatureFlagRepository( private val overrides: Map ) : FeatureFlagRepository { - override fun isEnabled(flag: UnreleasedFlag): Boolean = - overrides.getOrDefault(flag, flag.default) + override fun isEnabled(flag: UnreleasedFlag): Boolean = getValue(flag) + override fun isEnabled(flag: ReleasedFlag): Boolean = getValue(flag) - override fun isEnabled(flag: ReleasedFlag): Boolean = - overrides.getOrDefault(flag, flag.default) + private fun getValue(flag: BooleanFlag) = overrides.getOrDefault(flag, flag.default) } diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 17fd5bd9..aaf7a252 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -107,6 +107,7 @@ import com.android.intentresolver.flags.Flags; import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; +import com.android.systemui.flags.BooleanFlag; import org.hamcrest.Description; import org.hamcrest.Matcher; @@ -115,6 +116,8 @@ import org.junit.Before; import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; +import org.junit.rules.RuleChain; +import org.junit.rules.TestRule; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; import org.mockito.ArgumentCaptor; @@ -123,7 +126,6 @@ import org.mockito.Mockito; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -162,14 +164,40 @@ public class UnbundledChooserActivityTest { return mock; }; + private static final List ALL_FLAGS = + Arrays.asList( + Flags.SHARESHEET_CUSTOM_ACTIONS, + Flags.SHARESHEET_RESELECTION_ACTION, + Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW, + Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW); + + private static final Map ALL_FLAGS_OFF = + createAllFlagsOverride(false); + private static final Map ALL_FLAGS_ON = + createAllFlagsOverride(true); + @Parameterized.Parameters public static Collection packageManagers() { return Arrays.asList(new Object[][] { - {0, "Default PackageManager", DEFAULT_PM}, - {1, "No App Prediction Service", NO_APP_PREDICTION_SERVICE_PM} + // Default PackageManager and all flags off + { DEFAULT_PM, ALL_FLAGS_OFF}, + // Default PackageManager and all flags on + { DEFAULT_PM, ALL_FLAGS_ON}, + // No App Prediction Service and all flags off + { NO_APP_PREDICTION_SERVICE_PM, ALL_FLAGS_OFF }, + // No App Prediction Service and all flags on + { NO_APP_PREDICTION_SERVICE_PM, ALL_FLAGS_ON } }); } + private static Map createAllFlagsOverride(boolean value) { + HashMap overrides = new HashMap<>(ALL_FLAGS.size()); + for (BooleanFlag flag : ALL_FLAGS) { + overrides.put(flag, value); + } + return overrides; + } + /* -------- * Subclasses can override the following methods to customize test behavior. * -------- @@ -189,6 +217,8 @@ public class UnbundledChooserActivityTest { .adoptShellPermissionIdentity(); cleanOverrideData(); + ChooserActivityOverrideData.getInstance().featureFlagRepository = + new TestFeatureFlagRepository(mFlags); } /** @@ -221,11 +251,13 @@ public class UnbundledChooserActivityTest { * -------- */ + @Rule + public final TestRule mRule; + // Shared test code references the activity under test as ChooserActivity, the common ancestor // of any (inheritance-based) chooser implementation. For testing purposes, that activity will // usually be cast to IChooserWrapper to expose instrumentation. - @Rule - public ActivityTestRule mActivityRule = + private ActivityTestRule mActivityRule = new ActivityTestRule<>(ChooserActivity.class, false, false) { @Override public ChooserActivity launchActivity(Intent clientIntent) { @@ -252,16 +284,20 @@ public class UnbundledChooserActivityTest { private static final int CONTENT_PREVIEW_IMAGE = 1; private static final int CONTENT_PREVIEW_FILE = 2; private static final int CONTENT_PREVIEW_TEXT = 3; - private Function mPackageManagerOverride; - private int mTestNum; + + private final Function mPackageManagerOverride; + private final Map mFlags; public UnbundledChooserActivityTest( - int testNum, - String testName, - Function packageManagerOverride) { + Function packageManagerOverride, + Map flags) { mPackageManagerOverride = packageManagerOverride; - mTestNum = testNum; + mFlags = flags; + + mRule = RuleChain + .outerRule(new FeatureFlagRule(flags)) + .around(mActivityRule); } private void setDeviceConfigProperty( @@ -757,10 +793,10 @@ public class UnbundledChooserActivityTest { } @Test + @RequireFeatureFlags( + flags = { Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME }, + values = { true }) 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)); @@ -809,10 +845,10 @@ public class UnbundledChooserActivityTest { } @Test + @RequireFeatureFlags( + flags = { Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME }, + values = { true }) 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)); @@ -865,10 +901,10 @@ public class UnbundledChooserActivityTest { } @Test + @RequireFeatureFlags( + flags = { Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME }, + values = { true }) 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)); @@ -1070,10 +1106,10 @@ public class UnbundledChooserActivityTest { } @Test + @RequireFeatureFlags( + flags = { Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW_NAME }, + values = { false }) public void twoVisibleImagePreview() { - ChooserActivityOverrideData.getInstance().featureFlagRepository = - new TestFeatureFlagRepository( - Collections.singletonMap(Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW, false)); Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" + R.drawable.test320x240); @@ -1110,11 +1146,10 @@ public class UnbundledChooserActivityTest { } @Test + @RequireFeatureFlags( + flags = { Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW_NAME }, + values = { false }) public void threeOrMoreVisibleImagePreview() { - ChooserActivityOverrideData.getInstance().featureFlagRepository = - new TestFeatureFlagRepository( - Collections.singletonMap( - Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW, false)); Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" + R.drawable.test320x240); @@ -1154,11 +1189,10 @@ public class UnbundledChooserActivityTest { } @Test + @RequireFeatureFlags( + flags = { Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW_NAME }, + values = { true }) public void testManyVisibleImagePreview_ScrollableImagePreview() { - ChooserActivityOverrideData.getInstance().featureFlagRepository = - new TestFeatureFlagRepository( - Collections.singletonMap( - Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW, true)); Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" + R.drawable.test320x240); @@ -1204,10 +1238,10 @@ public class UnbundledChooserActivityTest { } @Test + @RequireFeatureFlags( + flags = { Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME }, + values = { true }) public void testImageAndTextPreview() { - ChooserActivityOverrideData.getInstance().featureFlagRepository = - new TestFeatureFlagRepository( - Collections.singletonMap(Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW, true)); final Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" + R.drawable.test320x240); final String sharedText = "text-" + System.currentTimeMillis(); @@ -1943,10 +1977,10 @@ public class UnbundledChooserActivityTest { } @Test + @RequireFeatureFlags( + flags = { Flags.SHARESHEET_CUSTOM_ACTIONS_NAME }, + values = { true }) public void testLaunchWithCustomAction() throws InterruptedException { - ChooserActivityOverrideData.getInstance().featureFlagRepository = - new TestFeatureFlagRepository( - Collections.singletonMap(Flags.SHARESHEET_CUSTOM_ACTIONS, true)); List resolvedComponentInfos = createResolvedComponentsForTest(2); when( ChooserActivityOverrideData @@ -1998,10 +2032,10 @@ public class UnbundledChooserActivityTest { } @Test + @RequireFeatureFlags( + flags = { Flags.SHARESHEET_RESELECTION_ACTION_NAME }, + values = { true }) public void testLaunchWithShareModification() throws InterruptedException { - ChooserActivityOverrideData.getInstance().featureFlagRepository = - new TestFeatureFlagRepository( - Collections.singletonMap(Flags.SHARESHEET_RESELECTION_ACTION, true)); List resolvedComponentInfos = createResolvedComponentsForTest(2); when( ChooserActivityOverrideData -- cgit v1.2.3-59-g8ed1b From 6f3ea1e9310afb2a56c1491148802e1b6154a094 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Mon, 13 Feb 2023 19:10:50 +0000 Subject: Extract ChooserActionFactory. This was a sizable chunk of code dedicated to one logical set of responsibilities, so it's nice to separate. Test: `atest IntentResolverUnitTests` Bug: 202167050 Change-Id: I3b033e975afeee66e33da38d0dc0eeba768d0ed4 --- .../intentresolver/ChooserActionFactory.java | 477 +++++++++++++++++++++ .../android/intentresolver/ChooserActivity.java | 403 ++--------------- .../ChooserIntegratedDeviceComponents.java | 77 ++++ .../intentresolver/ChooserRequestParameters.java | 20 +- .../intentresolver/ChooserWrapperActivity.java | 19 +- 5 files changed, 626 insertions(+), 370 deletions(-) create mode 100644 java/src/com/android/intentresolver/ChooserActionFactory.java create mode 100644 java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java (limited to 'java/tests') diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java new file mode 100644 index 00000000..1fe55890 --- /dev/null +++ b/java/src/com/android/intentresolver/ChooserActionFactory.java @@ -0,0 +1,477 @@ +/* + * 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; + +import android.annotation.Nullable; +import android.app.Activity; +import android.app.PendingIntent; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; +import android.content.pm.ResolveInfo; +import android.content.res.Resources; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Bundle; +import android.service.chooser.ChooserAction; +import android.text.TextUtils; +import android.util.Log; +import android.view.View; + +import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.flags.FeatureFlagRepository; +import com.android.intentresolver.flags.Flags; +import com.android.intentresolver.widget.ActionRow; +import com.android.internal.annotations.VisibleForTesting; + +import com.google.common.collect.ImmutableList; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +/** + * Implementation of {@link ChooserContentPreviewUi.ActionFactory} specialized to the application + * requirements of Sharesheet / {@link ChooserActivity}. + */ +public final class ChooserActionFactory implements ChooserContentPreviewUi.ActionFactory { + /** Delegate interface to launch activities when the actions are selected. */ + public interface ActionActivityStarter { + /** + * Request an activity launch for the provided target. Implementations may choose to exit + * the current activity when the target is launched. + */ + void safelyStartActivityAsPersonalProfileUser(TargetInfo info); + + /** + * Request an activity launch for the provided target, optionally employing the specified + * shared element transition. Implementations may choose to exit the current activity when + * the target is launched. + */ + default void safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( + TargetInfo info, View sharedElement, String sharedElementName) { + safelyStartActivityAsPersonalProfileUser(info); + } + } + + private static final String TAG = "ChooserActions"; + + private static final int URI_PERMISSION_INTENT_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_WRITE_URI_PERMISSION + | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION; + + private static final String CHIP_LABEL_METADATA_KEY = "android.service.chooser.chip_label"; + private static final String CHIP_ICON_METADATA_KEY = "android.service.chooser.chip_icon"; + + private static final String IMAGE_EDITOR_SHARED_ELEMENT = "screenshot_preview_image"; + + private final Context mContext; + private final String mCopyButtonLabel; + private final Drawable mCopyButtonDrawable; + private final Runnable mOnCopyButtonClicked; + private final TargetInfo mEditSharingTarget; + private final Runnable mOnEditButtonClicked; + private final TargetInfo mNearbySharingTarget; + private final Runnable mOnNearbyButtonClicked; + private final ImmutableList mCustomActions; + private final PendingIntent mReselectionIntent; + private final Consumer mExcludeSharedTextAction; + private final Consumer mFinishCallback; + + /** + * @param context + * @param chooserRequest data about the invocation of the current Sharesheet session. + * @param featureFlagRepository feature flags that may control the eligibility of some actions. + * @param integratedDeviceComponents info about other components that are available on this + * device to implement the supported action types. + * @param onUpdateSharedTextIsExcluded a delegate to be invoked when the "exclude shared text" + * setting is updated. The argument is whether the shared text is to be excluded. + * @param firstVisibleImageQuery a delegate that provides a reference to the first visible image + * View in the Sharesheet UI, if any, or null. + * @param activityStarter a delegate to launch activities when actions are selected. + * @param finishCallback a delegate to close the Sharesheet UI (e.g. because some action was + * completed). + */ + public ChooserActionFactory( + Context context, + ChooserRequestParameters chooserRequest, + FeatureFlagRepository featureFlagRepository, + ChooserIntegratedDeviceComponents integratedDeviceComponents, + ChooserActivityLogger logger, + Consumer onUpdateSharedTextIsExcluded, + Callable firstVisibleImageQuery, + ActionActivityStarter activityStarter, + Consumer finishCallback) { + this( + context, + context.getString(com.android.internal.R.string.copy), + context.getDrawable(com.android.internal.R.drawable.ic_menu_copy_material), + makeOnCopyRunnable( + context, + chooserRequest.getTargetIntent(), + chooserRequest.getReferrerPackageName(), + finishCallback, + logger), + getEditSharingTarget( + context, + chooserRequest.getTargetIntent(), + integratedDeviceComponents), + makeOnEditRunnable( + getEditSharingTarget( + context, + chooserRequest.getTargetIntent(), + integratedDeviceComponents), + firstVisibleImageQuery, + activityStarter, + logger), + getNearbySharingTarget( + context, + chooserRequest.getTargetIntent(), + integratedDeviceComponents), + makeOnNearbyShareRunnable( + getNearbySharingTarget( + context, + chooserRequest.getTargetIntent(), + integratedDeviceComponents), + activityStarter, + finishCallback, + logger), + chooserRequest.getChooserActions(), + (featureFlagRepository.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION) + ? chooserRequest.getModifyShareAction() : null), + onUpdateSharedTextIsExcluded, + finishCallback); + } + + @VisibleForTesting + ChooserActionFactory( + Context context, + String copyButtonLabel, + Drawable copyButtonDrawable, + Runnable onCopyButtonClicked, + TargetInfo editSharingTarget, + Runnable onEditButtonClicked, + TargetInfo nearbySharingTarget, + Runnable onNearbyButtonClicked, + List customActions, + @Nullable PendingIntent reselectionIntent, + Consumer onUpdateSharedTextIsExcluded, + Consumer finishCallback) { + mContext = context; + mCopyButtonLabel = copyButtonLabel; + mCopyButtonDrawable = copyButtonDrawable; + mOnCopyButtonClicked = onCopyButtonClicked; + mEditSharingTarget = editSharingTarget; + mOnEditButtonClicked = onEditButtonClicked; + mNearbySharingTarget = nearbySharingTarget; + mOnNearbyButtonClicked = onNearbyButtonClicked; + mCustomActions = ImmutableList.copyOf(customActions); + mReselectionIntent = reselectionIntent; + mExcludeSharedTextAction = onUpdateSharedTextIsExcluded; + mFinishCallback = finishCallback; + } + + /** Create an action that copies the share content to the clipboard. */ + @Override + public ActionRow.Action createCopyButton() { + return new ActionRow.Action( + com.android.internal.R.id.chooser_copy_button, + mCopyButtonLabel, + mCopyButtonDrawable, + mOnCopyButtonClicked); + } + + /** Create an action that opens the share content in a system-default editor. */ + @Override + @Nullable + public ActionRow.Action createEditButton() { + if (mEditSharingTarget == null) { + return null; + } + + return new ActionRow.Action( + com.android.internal.R.id.chooser_edit_button, + mEditSharingTarget.getDisplayLabel(), + mEditSharingTarget.getDisplayIconHolder().getDisplayIcon(), + mOnEditButtonClicked); + } + + /** Create a "Share to Nearby" action. */ + @Override + @Nullable + public ActionRow.Action createNearbyButton() { + if (mNearbySharingTarget == null) { + return null; + } + + return new ActionRow.Action( + com.android.internal.R.id.chooser_nearby_button, + mNearbySharingTarget.getDisplayLabel(), + mNearbySharingTarget.getDisplayIconHolder().getDisplayIcon(), + mOnNearbyButtonClicked); + } + + /** Create custom actions */ + @Override + public List createCustomActions() { + return mCustomActions.stream() + .map(target -> createCustomAction(mContext, target, mFinishCallback)) + .filter(action -> action != null) + .collect(Collectors.toList()); + } + + /** + * Provides a share modification action, if any. + */ + @Override + @Nullable + public Runnable getModifyShareAction() { + return (mReselectionIntent == null) ? null : createReselectionRunnable(mReselectionIntent); + } + + private Runnable createReselectionRunnable(PendingIntent pendingIntent) { + return () -> { + try { + pendingIntent.send(); + } catch (PendingIntent.CanceledException e) { + Log.d(TAG, "Payload reselection action has been cancelled"); + } + // TODO: add reporting + mFinishCallback.accept(Activity.RESULT_OK); + }; + } + + /** + *

+ * 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. + *

+ */ + @Override + public Consumer getExcludeSharedTextAction() { + return mExcludeSharedTextAction; + } + + private static Runnable makeOnCopyRunnable( + Context context, + Intent targetIntent, + String referrerPackageName, + Consumer finishCallback, + ChooserActivityLogger logger) { + return () -> { + if (targetIntent == null) { + finishCallback.accept(null); + return; + } + + final String action = targetIntent.getAction(); + + ClipData clipData = null; + if (Intent.ACTION_SEND.equals(action)) { + String extraText = targetIntent.getStringExtra(Intent.EXTRA_TEXT); + Uri extraStream = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); + + if (extraText != null) { + clipData = ClipData.newPlainText(null, extraText); + } else if (extraStream != null) { + clipData = ClipData.newUri(context.getContentResolver(), null, extraStream); + } else { + Log.w(TAG, "No data available to copy to clipboard"); + return; + } + } else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) { + final ArrayList streams = targetIntent.getParcelableArrayListExtra( + Intent.EXTRA_STREAM); + clipData = ClipData.newUri(context.getContentResolver(), null, streams.get(0)); + for (int i = 1; i < streams.size(); i++) { + clipData.addItem( + context.getContentResolver(), + new ClipData.Item(streams.get(i))); + } + } else { + // expected to only be visible with ACTION_SEND or ACTION_SEND_MULTIPLE + // so warn about unexpected action + Log.w(TAG, "Action (" + action + ") not supported for copying to clipboard"); + return; + } + + ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService( + Context.CLIPBOARD_SERVICE); + clipboardManager.setPrimaryClipAsPackage(clipData, referrerPackageName); + + logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_COPY); + finishCallback.accept(Activity.RESULT_OK); + }; + } + + private static TargetInfo getEditSharingTarget( + Context context, + Intent originalIntent, + ChooserIntegratedDeviceComponents integratedComponents) { + final ComponentName editorComponent = integratedComponents.getEditSharingComponent(); + + final Intent resolveIntent = new Intent(originalIntent); + // Retain only URI permission grant flags if present. Other flags may prevent the scene + // transition animation from running (i.e FLAG_ACTIVITY_NO_ANIMATION, + // FLAG_ACTIVITY_NEW_TASK, FLAG_ACTIVITY_NEW_DOCUMENT) but also not needed. + resolveIntent.setFlags(originalIntent.getFlags() & URI_PERMISSION_INTENT_FLAGS); + resolveIntent.setComponent(editorComponent); + resolveIntent.setAction(Intent.ACTION_EDIT); + String originalAction = originalIntent.getAction(); + if (Intent.ACTION_SEND.equals(originalAction)) { + if (resolveIntent.getData() == null) { + Uri uri = resolveIntent.getParcelableExtra(Intent.EXTRA_STREAM); + if (uri != null) { + String mimeType = context.getContentResolver().getType(uri); + resolveIntent.setDataAndType(uri, mimeType); + } + } + } else { + Log.e(TAG, originalAction + " is not supported."); + return null; + } + final ResolveInfo ri = context.getPackageManager().resolveActivity( + resolveIntent, PackageManager.GET_META_DATA); + if (ri == null || ri.activityInfo == null) { + Log.e(TAG, "Device-specified editor (" + editorComponent + ") not available"); + return null; + } + + final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( + originalIntent, + ri, + context.getString(com.android.internal.R.string.screenshot_edit), + "", + resolveIntent, + null); + dri.getDisplayIconHolder().setDisplayIcon( + context.getDrawable(com.android.internal.R.drawable.ic_screenshot_edit)); + return dri; + } + + private static Runnable makeOnEditRunnable( + TargetInfo editSharingTarget, + Callable firstVisibleImageQuery, + ActionActivityStarter activityStarter, + ChooserActivityLogger logger) { + return () -> { + // Log share completion via edit. + logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_EDIT); + + View firstImageView = null; + try { + firstImageView = firstVisibleImageQuery.call(); + } catch (Exception e) { /* ignore */ } + // Action bar is user-independent; always start as primary. + if (firstImageView == null) { + activityStarter.safelyStartActivityAsPersonalProfileUser(editSharingTarget); + } else { + activityStarter.safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( + editSharingTarget, firstImageView, IMAGE_EDITOR_SHARED_ELEMENT); + } + }; + } + + private static TargetInfo getNearbySharingTarget( + Context context, + Intent originalIntent, + ChooserIntegratedDeviceComponents integratedComponents) { + final ComponentName cn = integratedComponents.getNearbySharingComponent(); + if (cn == null) return null; + + final Intent resolveIntent = new Intent(originalIntent); + resolveIntent.setComponent(cn); + final ResolveInfo ri = context.getPackageManager().resolveActivity( + resolveIntent, PackageManager.GET_META_DATA); + if (ri == null || ri.activityInfo == null) { + Log.e(TAG, "Device-specified nearby sharing component (" + cn + + ") not available"); + return null; + } + + // Allow the nearby sharing component to provide a more appropriate icon and label + // for the chip. + CharSequence name = null; + Drawable icon = null; + final Bundle metaData = ri.activityInfo.metaData; + if (metaData != null) { + try { + final Resources pkgRes = context.getPackageManager().getResourcesForActivity(cn); + final int nameResId = metaData.getInt(CHIP_LABEL_METADATA_KEY); + name = pkgRes.getString(nameResId); + final int resId = metaData.getInt(CHIP_ICON_METADATA_KEY); + icon = pkgRes.getDrawable(resId); + } catch (NameNotFoundException | Resources.NotFoundException ex) { /* ignore */ } + } + if (TextUtils.isEmpty(name)) { + name = ri.loadLabel(context.getPackageManager()); + } + if (icon == null) { + icon = ri.loadIcon(context.getPackageManager()); + } + + final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( + originalIntent, ri, name, "", resolveIntent, null); + dri.getDisplayIconHolder().setDisplayIcon(icon); + return dri; + } + + private static Runnable makeOnNearbyShareRunnable( + TargetInfo nearbyShareTarget, + ActionActivityStarter activityStarter, + Consumer finishCallback, + ChooserActivityLogger logger) { + return () -> { + logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_NEARBY); + // Action bar is user-independent; always start as primary. + activityStarter.safelyStartActivityAsPersonalProfileUser(nearbyShareTarget); + }; + } + + @Nullable + private static ActionRow.Action createCustomAction( + Context context, ChooserAction action, Consumer finishCallback) { + Drawable icon = action.getIcon().loadDrawable(context); + if (icon == null && TextUtils.isEmpty(action.getLabel())) { + return null; + } + return new ActionRow.Action( + action.getLabel(), + icon, + () -> { + try { + action.getAction().send(); + } catch (PendingIntent.CanceledException e) { + Log.d(TAG, "Custom action, " + action.getLabel() + ", has been cancelled"); + } + // TODO: add reporting + finishCallback.accept(Activity.RESULT_OK); + } + ); + } +} diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 34390770..a2f2bbde 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -32,13 +32,10 @@ import android.annotation.Nullable; import android.app.Activity; import android.app.ActivityManager; import android.app.ActivityOptions; -import android.app.PendingIntent; import android.app.prediction.AppPredictor; import android.app.prediction.AppTarget; import android.app.prediction.AppTargetEvent; import android.app.prediction.AppTargetId; -import android.content.ClipData; -import android.content.ClipboardManager; import android.content.ComponentName; import android.content.ContentResolver; import android.content.Context; @@ -49,31 +46,24 @@ import android.content.IntentSender.SendIntentException; import android.content.SharedPreferences; import android.content.pm.ActivityInfo; import android.content.pm.PackageManager; -import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.ResolveInfo; import android.content.pm.ShortcutInfo; import android.content.res.Configuration; -import android.content.res.Resources; import android.database.Cursor; import android.graphics.Insets; -import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Bundle; import android.os.Environment; import android.os.Handler; import android.os.Parcel; import android.os.Parcelable; -import android.os.PatternMatcher; import android.os.ResultReceiver; import android.os.SystemClock; import android.os.UserHandle; import android.os.UserManager; import android.os.storage.StorageManager; import android.provider.DeviceConfig; -import android.provider.Settings; -import android.service.chooser.ChooserAction; import android.service.chooser.ChooserTarget; -import android.text.TextUtils; import android.util.Log; import android.util.Slog; import android.util.SparseArray; @@ -100,7 +90,6 @@ import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.flags.FeatureFlagRepositoryFactory; -import com.android.intentresolver.flags.Flags; import com.android.intentresolver.grid.ChooserGridAdapter; import com.android.intentresolver.grid.DirectShareViewHolder; import com.android.intentresolver.model.AbstractResolverComparator; @@ -108,7 +97,6 @@ import com.android.intentresolver.model.AppPredictionServiceResolverComparator; import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; import com.android.intentresolver.shortcuts.AppPredictorFactory; import com.android.intentresolver.shortcuts.ShortcutLoader; -import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; @@ -116,8 +104,6 @@ import com.android.internal.content.PackageMonitor; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import com.android.internal.util.FrameworkStatsLog; -import com.google.common.collect.ImmutableList; - import java.io.File; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -210,6 +196,8 @@ public class ChooserActivity extends ResolverActivity implements | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION; + private ChooserIntegratedDeviceComponents mIntegratedDeviceComponents; + /* TODO: this is `nullable` because we have to defer the assignment til onCreate(). We make the * only assignment there, and expect it to be ready by the time we ever use it -- * someday if we move all the usage to a component with a narrower lifecycle (something that @@ -220,6 +208,7 @@ public class ChooserActivity extends ResolverActivity implements private ChooserRequestParameters mChooserRequest; private FeatureFlagRepository mFeatureFlagRepository; + private ChooserActionFactory mChooserActionFactory; private ChooserContentPreviewUi mChooserContentPreviewUi; private boolean mShouldDisplayLandscape; @@ -274,11 +263,14 @@ public class ChooserActivity extends ResolverActivity implements getChooserActivityLogger().logSharesheetTriggered(); mFeatureFlagRepository = createFeatureFlagRepository(); + mIntegratedDeviceComponents = getIntegratedDeviceComponents(); + try { mChooserRequest = new ChooserRequestParameters( getIntent(), + getReferrerPackageName(), getReferrer(), - getNearbySharingComponent(), + mIntegratedDeviceComponents, mFeatureFlagRepository); } catch (IllegalArgumentException e) { Log.e(TAG, "Caller provided invalid Chooser request parameters", e); @@ -286,6 +278,39 @@ public class ChooserActivity extends ResolverActivity implements super_onCreate(null); 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(); + }); + mChooserContentPreviewUi = new ChooserContentPreviewUi(mFeatureFlagRepository); setAdditionalTargets(mChooserRequest.getAdditionalTargets()); @@ -368,6 +393,11 @@ public class ChooserActivity extends ResolverActivity implements mEnterTransitionAnimationDelegate.postponeTransition(); } + @VisibleForTesting + protected ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() { + return ChooserIntegratedDeviceComponents.get(this); + } + @Override protected int appliedThemeResId() { return R.style.Theme_DeviceDefault_Chooser; @@ -607,51 +637,6 @@ public class ChooserActivity extends ResolverActivity implements updateProfileViewButton(); } - private void onCopyButtonClicked() { - Intent targetIntent = getTargetIntent(); - if (targetIntent == null) { - finish(); - } else { - final String action = targetIntent.getAction(); - - ClipData clipData = null; - if (Intent.ACTION_SEND.equals(action)) { - String extraText = targetIntent.getStringExtra(Intent.EXTRA_TEXT); - Uri extraStream = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); - - if (extraText != null) { - clipData = ClipData.newPlainText(null, extraText); - } else if (extraStream != null) { - clipData = ClipData.newUri(getContentResolver(), null, extraStream); - } else { - Log.w(TAG, "No data available to copy to clipboard"); - return; - } - } else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) { - final ArrayList streams = targetIntent.getParcelableArrayListExtra( - Intent.EXTRA_STREAM); - clipData = ClipData.newUri(getContentResolver(), null, streams.get(0)); - for (int i = 1; i < streams.size(); i++) { - clipData.addItem(getContentResolver(), new ClipData.Item(streams.get(i))); - } - } else { - // expected to only be visible with ACTION_SEND or ACTION_SEND_MULTIPLE - // so warn about unexpected action - Log.w(TAG, "Action (" + action + ") not supported for copying to clipboard"); - return; - } - - ClipboardManager clipboardManager = (ClipboardManager) getSystemService( - Context.CLIPBOARD_SERVICE); - clipboardManager.setPrimaryClipAsPackage(clipData, getReferrerPackageName()); - - getChooserActivityLogger().logActionSelected(ChooserActivityLogger.SELECTION_TYPE_COPY); - - setResult(RESULT_OK); - finish(); - } - } - @Override protected void onResume() { super.onResume(); @@ -728,64 +713,12 @@ public class ChooserActivity extends ResolverActivity implements int previewType = ChooserContentPreviewUi.findPreferredContentPreview( targetIntent, getContentResolver(), this::isImageType); - ChooserContentPreviewUi.ActionFactory actionFactory = - new ChooserContentPreviewUi.ActionFactory() { - @Override - public ActionRow.Action createCopyButton() { - return ChooserActivity.this.createCopyAction(); - } - - @Nullable - @Override - public ActionRow.Action createEditButton() { - return ChooserActivity.this.createEditAction(targetIntent); - } - - @Nullable - @Override - public ActionRow.Action createNearbyButton() { - return ChooserActivity.this.createNearbyAction(targetIntent); - } - - @Override - public List createCustomActions() { - ImmutableList customActions = - mChooserRequest.getChooserActions(); - List actions = new ArrayList<>(customActions.size()); - for (ChooserAction customAction : customActions) { - ActionRow.Action action = createCustomAction(customAction); - if (action != null) { - actions.add(action); - } - } - return actions; - } - - @Nullable - @Override - public Runnable getModifyShareAction() { - if (!mFeatureFlagRepository - .isEnabled(Flags.SHARESHEET_RESELECTION_ACTION)) { - return null; - } - PendingIntent reselectionAction = mChooserRequest.getModifyShareAction(); - return reselectionAction == null - ? null - : createReselectionRunnable(reselectionAction); - } - - @Override - public Consumer getExcludeSharedTextAction() { - return (isExcluded) -> mExcludeSharedText = isExcluded; - } - }; - ViewGroup layout = mChooserContentPreviewUi.displayContentPreview( previewType, targetIntent, getResources(), getLayoutInflater(), - actionFactory, + mChooserActionFactory, parent, imageLoader, mEnterTransitionAnimationDelegate, @@ -799,211 +732,6 @@ public class ChooserActivity extends ResolverActivity implements return layout; } - @VisibleForTesting - protected ComponentName getNearbySharingComponent() { - String nearbyComponent = Settings.Secure.getString( - getContentResolver(), - Settings.Secure.NEARBY_SHARING_COMPONENT); - if (TextUtils.isEmpty(nearbyComponent)) { - nearbyComponent = getString(R.string.config_defaultNearbySharingComponent); - } - if (TextUtils.isEmpty(nearbyComponent)) { - return null; - } - return ComponentName.unflattenFromString(nearbyComponent); - } - - @VisibleForTesting - protected @Nullable ComponentName getEditSharingComponent() { - String editorPackage = getApplicationContext().getString(R.string.config_systemImageEditor); - if (editorPackage == null || TextUtils.isEmpty(editorPackage)) { - return null; - } - return ComponentName.unflattenFromString(editorPackage); - } - - @VisibleForTesting - protected TargetInfo getEditSharingTarget(Intent originalIntent) { - final ComponentName cn = getEditSharingComponent(); - - final Intent resolveIntent = new Intent(originalIntent); - // Retain only URI permission grant flags if present. Other flags may prevent the scene - // transition animation from running (i.e FLAG_ACTIVITY_NO_ANIMATION, - // FLAG_ACTIVITY_NEW_TASK, FLAG_ACTIVITY_NEW_DOCUMENT) but also not needed. - resolveIntent.setFlags(originalIntent.getFlags() & URI_PERMISSION_INTENT_FLAGS); - resolveIntent.setComponent(cn); - resolveIntent.setAction(Intent.ACTION_EDIT); - String originalAction = originalIntent.getAction(); - if (Intent.ACTION_SEND.equals(originalAction)) { - if (resolveIntent.getData() == null) { - Uri uri = resolveIntent.getParcelableExtra(Intent.EXTRA_STREAM); - if (uri != null) { - String mimeType = getContentResolver().getType(uri); - resolveIntent.setDataAndType(uri, mimeType); - } - } - } else { - Log.e(TAG, originalAction + " is not supported."); - return null; - } - final ResolveInfo ri = getPackageManager().resolveActivity( - resolveIntent, PackageManager.GET_META_DATA); - if (ri == null || ri.activityInfo == null) { - Log.e(TAG, "Device-specified image edit component (" + cn - + ") not available"); - return null; - } - - final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( - originalIntent, - ri, - getString(com.android.internal.R.string.screenshot_edit), - "", - resolveIntent, - null); - dri.getDisplayIconHolder().setDisplayIcon( - getDrawable(com.android.internal.R.drawable.ic_screenshot_edit)); - return dri; - } - - @VisibleForTesting - protected TargetInfo getNearbySharingTarget(Intent originalIntent) { - final ComponentName cn = getNearbySharingComponent(); - if (cn == null) return null; - - final Intent resolveIntent = new Intent(originalIntent); - resolveIntent.setComponent(cn); - final ResolveInfo ri = getPackageManager().resolveActivity( - resolveIntent, PackageManager.GET_META_DATA); - if (ri == null || ri.activityInfo == null) { - Log.e(TAG, "Device-specified nearby sharing component (" + cn - + ") not available"); - return null; - } - - // Allow the nearby sharing component to provide a more appropriate icon and label - // for the chip. - CharSequence name = null; - Drawable icon = null; - final Bundle metaData = ri.activityInfo.metaData; - if (metaData != null) { - try { - final Resources pkgRes = getPackageManager().getResourcesForActivity(cn); - final int nameResId = metaData.getInt(CHIP_LABEL_METADATA_KEY); - name = pkgRes.getString(nameResId); - final int resId = metaData.getInt(CHIP_ICON_METADATA_KEY); - icon = pkgRes.getDrawable(resId); - } catch (Resources.NotFoundException ex) { - } catch (NameNotFoundException ex) { - } - } - if (TextUtils.isEmpty(name)) { - name = ri.loadLabel(getPackageManager()); - } - if (icon == null) { - icon = ri.loadIcon(getPackageManager()); - } - - final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( - originalIntent, ri, name, "", resolveIntent, null); - dri.getDisplayIconHolder().setDisplayIcon(icon); - return dri; - } - - private ActionRow.Action createCopyAction() { - return new ActionRow.Action( - com.android.internal.R.id.chooser_copy_button, - getString(com.android.internal.R.string.copy), - getDrawable(com.android.internal.R.drawable.ic_menu_copy_material), - this::onCopyButtonClicked); - } - - @Nullable - private ActionRow.Action createNearbyAction(Intent originalIntent) { - final TargetInfo ti = getNearbySharingTarget(originalIntent); - if (ti == null) { - return null; - } - - return new ActionRow.Action( - com.android.internal.R.id.chooser_nearby_button, - ti.getDisplayLabel(), - ti.getDisplayIconHolder().getDisplayIcon(), - () -> { - getChooserActivityLogger().logActionSelected( - ChooserActivityLogger.SELECTION_TYPE_NEARBY); - // Action bar is user-independent, always start as primary - safelyStartActivityAsUser(ti, getPersonalProfileUserHandle()); - finish(); - }); - } - - @Nullable - private ActionRow.Action createEditAction(Intent originalIntent) { - final TargetInfo ti = getEditSharingTarget(originalIntent); - if (ti == null) { - return null; - } - - return new ActionRow.Action( - com.android.internal.R.id.chooser_edit_button, - ti.getDisplayLabel(), - ti.getDisplayIconHolder().getDisplayIcon(), - () -> { - // Log share completion via edit - getChooserActivityLogger().logActionSelected( - ChooserActivityLogger.SELECTION_TYPE_EDIT); - View firstImgView = getFirstVisibleImgPreviewView(); - // Action bar is user-independent, always start as primary - if (firstImgView == null) { - safelyStartActivityAsUser(ti, getPersonalProfileUserHandle()); - finish(); - } else { - ActivityOptions options = ActivityOptions.makeSceneTransitionAnimation( - this, firstImgView, IMAGE_EDITOR_SHARED_ELEMENT); - safelyStartActivityAsUser( - ti, getPersonalProfileUserHandle(), options.toBundle()); - startFinishAnimation(); - } - } - ); - } - - @Nullable - private ActionRow.Action createCustomAction(ChooserAction action) { - Drawable icon = action.getIcon().loadDrawable(this); - if (icon == null && TextUtils.isEmpty(action.getLabel())) { - return null; - } - return new ActionRow.Action( - action.getLabel(), - icon, - () -> { - try { - action.getAction().send(); - } catch (PendingIntent.CanceledException e) { - Log.d(TAG, "Custom action, " + action.getLabel() + ", has been cancelled"); - } - // TODO: add reporting - setResult(RESULT_OK); - finish(); - } - ); - } - - private Runnable createReselectionRunnable(PendingIntent pendingIntent) { - return () -> { - try { - pendingIntent.send(); - } catch (PendingIntent.CanceledException e) { - Log.d(TAG, "Payload reselection action has been cancelled"); - } - // TODO: add reporting - setResult(RESULT_OK); - finish(); - }; - } - @Nullable private View getFirstVisibleImgPreviewView() { View firstImage = findViewById(com.android.internal.R.id.content_preview_image_1_large); @@ -1315,45 +1043,6 @@ public class ChooserActivity extends ResolverActivity implements } } - private IntentFilter getTargetIntentFilter() { - return getTargetIntentFilter(getTargetIntent()); - } - - private IntentFilter getTargetIntentFilter(final Intent intent) { - try { - String dataString = intent.getDataString(); - if (intent.getType() == null) { - if (!TextUtils.isEmpty(dataString)) { - return new IntentFilter(intent.getAction(), dataString); - } - Log.e(TAG, "Failed to get target intent filter: intent data and type are null"); - return null; - } - IntentFilter intentFilter = new IntentFilter(intent.getAction(), intent.getType()); - List contentUris = new ArrayList<>(); - if (Intent.ACTION_SEND.equals(intent.getAction())) { - Uri uri = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM); - if (uri != null) { - contentUris.add(uri); - } - } else { - List uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); - if (uris != null) { - contentUris.addAll(uris); - } - } - for (Uri uri : contentUris) { - intentFilter.addDataScheme(uri.getScheme()); - intentFilter.addDataAuthority(uri.getAuthority(), null); - intentFilter.addDataPath(uri.getPath(), PatternMatcher.PATTERN_LITERAL); - } - return intentFilter; - } catch (Exception e) { - Log.e(TAG, "Failed to get target intent filter", e); - return null; - } - } - private void logDirectShareTargetReceived(UserHandle forUser) { ProfileRecord profileRecord = getProfileRecord(forUser); if (profileRecord == null) { diff --git a/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java b/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java new file mode 100644 index 00000000..9b124c20 --- /dev/null +++ b/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java @@ -0,0 +1,77 @@ +/* + * 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; + +import android.annotation.Nullable; +import android.content.ComponentName; +import android.content.Context; +import android.provider.Settings; +import android.text.TextUtils; + +import com.android.internal.annotations.VisibleForTesting; + +/** + * Helper to look up the components available on this device to handle assorted built-in actions + * like "Edit" that may be displayed for certain content/preview types. The components are queried + * when this record is instantiated, and are then immutable for a given instance. + * + * Because this describes the app's external execution environment, test methods may prefer to + * provide explicit values to override the default lookup logic. + */ +public final class ChooserIntegratedDeviceComponents { + @Nullable + private final ComponentName mEditSharingComponent; + + @Nullable + private final ComponentName mNearbySharingComponent; + + /** Look up the integrated components available on this device. */ + public static ChooserIntegratedDeviceComponents get(Context context) { + return new ChooserIntegratedDeviceComponents( + getEditSharingComponent(context), + getNearbySharingComponent(context)); + } + + @VisibleForTesting + ChooserIntegratedDeviceComponents( + ComponentName editSharingComponent, ComponentName nearbySharingComponent) { + mEditSharingComponent = editSharingComponent; + mNearbySharingComponent = nearbySharingComponent; + } + + public ComponentName getEditSharingComponent() { + return mEditSharingComponent; + } + + public ComponentName getNearbySharingComponent() { + return mNearbySharingComponent; + } + + private static ComponentName getEditSharingComponent(Context context) { + String editorComponent = context.getApplicationContext().getString( + R.string.config_systemImageEditor); + return TextUtils.isEmpty(editorComponent) + ? null : ComponentName.unflattenFromString(editorComponent); + } + + private static ComponentName getNearbySharingComponent(Context context) { + String nearbyComponent = Settings.Secure.getString( + context.getContentResolver(), Settings.Secure.NEARBY_SHARING_COMPONENT); + return TextUtils.isEmpty(nearbyComponent) + ? null : ComponentName.unflattenFromString(nearbyComponent); + } +} diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java index 2b67b273..83a0e2e1 100644 --- a/java/src/com/android/intentresolver/ChooserRequestParameters.java +++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java @@ -71,6 +71,8 @@ public class ChooserRequestParameters { Intent.FLAG_ACTIVITY_NEW_DOCUMENT | Intent.FLAG_ACTIVITY_MULTIPLE_TASK; private final Intent mTarget; + private final ChooserIntegratedDeviceComponents mIntegratedDeviceComponents; + private final String mReferrerPackageName; private final Pair mTitleSpec; private final Intent mReferrerFillInIntent; private final ImmutableList mFilteredComponentNames; @@ -102,13 +104,18 @@ public class ChooserRequestParameters { public ChooserRequestParameters( final Intent clientIntent, + String referrerPackageName, final Uri referrer, - @Nullable final ComponentName nearbySharingComponent, + ChooserIntegratedDeviceComponents integratedDeviceComponents, FeatureFlagRepository featureFlags) { final Intent requestedTarget = parseTargetIntentExtra( clientIntent.getParcelableExtra(Intent.EXTRA_INTENT)); mTarget = intentWithModifiedLaunchFlags(requestedTarget); + mIntegratedDeviceComponents = integratedDeviceComponents; + + mReferrerPackageName = referrerPackageName; + mAdditionalTargets = intentsWithModifiedLaunchFlagsFromExtraIfPresent( clientIntent, Intent.EXTRA_ALTERNATE_INTENTS); @@ -128,7 +135,8 @@ public class ChooserRequestParameters { mRefinementIntentSender = clientIntent.getParcelableExtra( Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER); - mFilteredComponentNames = getFilteredComponentNames(clientIntent, nearbySharingComponent); + mFilteredComponentNames = getFilteredComponentNames( + clientIntent, mIntegratedDeviceComponents.getNearbySharingComponent()); mCallerChooserTargets = parseCallerTargetsFromClientIntent(clientIntent); @@ -165,6 +173,10 @@ public class ChooserRequestParameters { return getTargetIntent().getType(); } + public String getReferrerPackageName() { + return mReferrerPackageName; + } + @Nullable public CharSequence getTitle() { return mTitleSpec.first; @@ -245,6 +257,10 @@ public class ChooserRequestParameters { return mTargetIntentFilter; } + public ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() { + return mIntegratedDeviceComponents; + } + private static boolean isSendAction(@Nullable String action) { return (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action)); } diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index a47014e8..17084e1c 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -37,7 +37,6 @@ import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileI import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeManager; import com.android.intentresolver.chooser.DisplayResolveInfo; -import com.android.intentresolver.chooser.NotSelectableTargetInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.grid.ChooserGridAdapter; @@ -120,15 +119,13 @@ public class ChooserWrapperActivity } @Override - protected ComponentName getNearbySharingComponent() { - // an arbitrary pre-installed activity that handles this type of intent - return ComponentName.unflattenFromString("com.google.android.apps.messaging/" - + "com.google.android.apps.messaging.ui.conversationlist.ShareIntentActivity"); - } - - @Override - protected TargetInfo getNearbySharingTarget(Intent originalIntent) { - return NotSelectableTargetInfo.newEmptyTargetInfo(); + protected ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() { + return new ChooserIntegratedDeviceComponents( + /* editSharingComponent=*/ null, + // An arbitrary pre-installed activity that handles this type of intent: + /* nearbySharingComponent=*/ new ComponentName( + "com.google.android.apps.messaging", + ".ui.conversationlist.ShareIntentActivity")); } @Override @@ -172,7 +169,7 @@ public class ChooserWrapperActivity } @Override - public void safelyStartActivity(com.android.intentresolver.chooser.TargetInfo cti) { + public void safelyStartActivity(TargetInfo cti) { if (sOverrides.onSafelyStartCallback != null && sOverrides.onSafelyStartCallback.apply(cti)) { return; -- cgit v1.2.3-59-g8ed1b From 7c05ee5c8b7c934776d1c74a558f29dadde5ee9d Mon Sep 17 00:00:00 2001 From: 1 Date: Thu, 9 Mar 2023 22:01:50 +0000 Subject: Switch EXTRA_MODIFY_SHARE from PendingIntent to ChooserAction Honor the label provided in the action. Bug: 272008339 Test: atest IntentResolverUnitTests Test: atest CtsSharesheetDeviceTest Change-Id: I48f9bb46f1be1f8ab42a93169b4c5ab332cd8400 --- java/res/layout/chooser_grid_preview_file.xml | 1 - java/res/layout/chooser_grid_preview_image.xml | 1 - java/res/layout/chooser_grid_preview_text.xml | 1 - java/res/values/strings.xml | 7 --- .../intentresolver/ChooserActionFactory.java | 59 ++++++++++------------ .../intentresolver/ChooserRequestParameters.java | 9 ++-- .../contentpreview/ChooserContentPreviewUi.java | 2 +- .../contentpreview/ContentPreviewUi.java | 8 +-- .../intentresolver/ChooserActionFactoryTest.kt | 12 ++++- .../UnbundledChooserActivityTest.java | 16 +++--- .../contentpreview/ChooserContentPreviewUiTest.kt | 2 +- 11 files changed, 58 insertions(+), 60 deletions(-) (limited to 'java/tests') diff --git a/java/res/layout/chooser_grid_preview_file.xml b/java/res/layout/chooser_grid_preview_file.xml index 095e5d62..6ba06b3d 100644 --- a/java/res/layout/chooser_grid_preview_file.xml +++ b/java/res/layout/chooser_grid_preview_file.xml @@ -74,7 +74,6 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:visibility="gone" - android:text="@string/select_files" android:gravity="center" style="@style/ReselectionAction" /> diff --git a/java/res/layout/chooser_grid_preview_image.xml b/java/res/layout/chooser_grid_preview_image.xml index 792b7d4d..1c0e4c2e 100644 --- a/java/res/layout/chooser_grid_preview_image.xml +++ b/java/res/layout/chooser_grid_preview_image.xml @@ -64,7 +64,6 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:visibility="gone" - android:text="@string/select_images" android:gravity="center" style="@style/ReselectionAction" /> diff --git a/java/res/layout/chooser_grid_preview_text.xml b/java/res/layout/chooser_grid_preview_text.xml index 49a2edff..f25eca9a 100644 --- a/java/res/layout/chooser_grid_preview_text.xml +++ b/java/res/layout/chooser_grid_preview_text.xml @@ -57,7 +57,6 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:visibility="gone" - android:text="@string/select_text" android:gravity="center" style="@style/ReselectionAction" /> diff --git a/java/res/values/strings.xml b/java/res/values/strings.xml index 24604ed3..6881feff 100644 --- a/java/res/values/strings.xml +++ b/java/res/values/strings.xml @@ -102,13 +102,6 @@ Use work browser - - Select Files - - Select Images - - Select Text - Exclude text diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java index 947155f3..1cadd314 100644 --- a/java/src/com/android/intentresolver/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/ChooserActionFactory.java @@ -97,7 +97,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio private final TargetInfo mNearbySharingTarget; private final Runnable mOnNearbyButtonClicked; private final ImmutableList mCustomActions; - private final Runnable mOnModifyShareClicked; + private final @Nullable ChooserAction mModifyShareAction; private final Consumer mExcludeSharedTextAction; private final Consumer mFinishCallback; private final ChooserActivityLogger mLogger; @@ -162,10 +162,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio logger), chooserRequest.getChooserActions(), (featureFlagRepository.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION) - ? createModifyShareRunnable( - chooserRequest.getModifyShareAction(), - finishCallback, - logger) + ? chooserRequest.getModifyShareAction() : null), onUpdateSharedTextIsExcluded, logger, @@ -183,7 +180,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio TargetInfo nearbySharingTarget, Runnable onNearbyButtonClicked, List customActions, - @Nullable Runnable onModifyShareClicked, + @Nullable ChooserAction modifyShareAction, Consumer onUpdateSharedTextIsExcluded, ChooserActivityLogger logger, Consumer finishCallback) { @@ -196,7 +193,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio mNearbySharingTarget = nearbySharingTarget; mOnNearbyButtonClicked = onNearbyButtonClicked; mCustomActions = ImmutableList.copyOf(customActions); - mOnModifyShareClicked = onModifyShareClicked; + mModifyShareAction = modifyShareAction; mExcludeSharedTextAction = onUpdateSharedTextIsExcluded; mLogger = logger; mFinishCallback = finishCallback; @@ -247,8 +244,15 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio public List createCustomActions() { List actions = new ArrayList<>(); for (int i = 0; i < mCustomActions.size(); i++) { + final int position = i; ActionRow.Action actionRow = createCustomAction( - mContext, mCustomActions.get(i), mFinishCallback, i, mLogger); + mContext, + mCustomActions.get(i), + mFinishCallback, + () -> { + mLogger.logCustomActionSelected(position); + } + ); if (actionRow != null) { actions.add(actionRow); } @@ -261,27 +265,14 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio */ @Override @Nullable - public Runnable getModifyShareAction() { - return mOnModifyShareClicked; - } - - private static Runnable createModifyShareRunnable( - PendingIntent pendingIntent, - Consumer finishCallback, - ChooserActivityLogger logger) { - if (pendingIntent == null) { - return null; - } - - return () -> { - try { - pendingIntent.send(); - } catch (PendingIntent.CanceledException e) { - Log.d(TAG, "Payload reselection action has been cancelled"); - } - logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_MODIFY_SHARE); - finishCallback.accept(Activity.RESULT_OK); - }; + public ActionRow.Action getModifyShareAction() { + return createCustomAction( + mContext, + mModifyShareAction, + mFinishCallback, + () -> { + mLogger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_MODIFY_SHARE); + }); } /** @@ -481,8 +472,10 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio Context context, ChooserAction action, Consumer finishCallback, - int position, - ChooserActivityLogger logger) { + Runnable loggingRunnable) { + if (action == null || action.getAction() == null) { + return null; + } Drawable icon = action.getIcon().loadDrawable(context); if (icon == null && TextUtils.isEmpty(action.getLabel())) { return null; @@ -507,7 +500,9 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio } catch (PendingIntent.CanceledException e) { Log.d(TAG, "Custom action, " + action.getLabel() + ", has been cancelled"); } - logger.logCustomActionSelected(position); + if (loggingRunnable != null) { + loggingRunnable.run(); + } finishCallback.accept(Activity.RESULT_OK); } ); diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java index 3d99e475..948fe4cd 100644 --- a/java/src/com/android/intentresolver/ChooserRequestParameters.java +++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java @@ -18,7 +18,6 @@ package com.android.intentresolver; import android.annotation.NonNull; import android.annotation.Nullable; -import android.app.PendingIntent; import android.content.ComponentName; import android.content.Intent; import android.content.IntentFilter; @@ -78,7 +77,7 @@ public class ChooserRequestParameters { private final ImmutableList mFilteredComponentNames; private final ImmutableList mCallerChooserTargets; private final @NonNull ImmutableList mChooserActions; - private final PendingIntent mModifyShareAction; + private final ChooserAction mModifyShareAction; private final boolean mRetainInOnStop; @Nullable @@ -204,7 +203,7 @@ public class ChooserRequestParameters { } @Nullable - public PendingIntent getModifyShareAction() { + public ChooserAction getModifyShareAction() { return mModifyShareAction; } @@ -353,11 +352,11 @@ public class ChooserRequestParameters { } @Nullable - private static PendingIntent getModifyShareAction(Intent intent) { + private static ChooserAction getModifyShareAction(Intent intent) { try { return intent.getParcelableExtra( Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION, - PendingIntent.class); + ChooserAction.class); } catch (Throwable t) { Log.w( TAG, diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index 205be444..8cc747bf 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -74,7 +74,7 @@ public final class ChooserContentPreviewUi { * Provides a share modification action, if any. */ @Nullable - Runnable getModifyShareAction(); + ActionRow.Action getModifyShareAction(); /** *

diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java index 39856e66..96f1c376 100644 --- a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java @@ -30,6 +30,7 @@ import android.view.View; import android.view.ViewGroup; import android.view.ViewStub; import android.view.animation.DecelerateInterpolator; +import android.widget.TextView; import androidx.annotation.LayoutRes; @@ -117,13 +118,14 @@ abstract class ContentPreviewUi { ViewGroup layout, ChooserContentPreviewUi.ActionFactory actionFactory, FeatureFlagRepository featureFlagRepository) { - Runnable modifyShareAction = actionFactory.getModifyShareAction(); + ActionRow.Action modifyShareAction = actionFactory.getModifyShareAction(); if (modifyShareAction != null && layout != null && featureFlagRepository.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION)) { - View modifyShareView = layout.findViewById(R.id.reselection_action); + TextView modifyShareView = layout.findViewById(R.id.reselection_action); if (modifyShareView != null) { + modifyShareView.setText(modifyShareAction.getLabel()); modifyShareView.setVisibility(View.VISIBLE); - modifyShareView.setOnClickListener(view -> modifyShareAction.run()); + modifyShareView.setOnClickListener(view -> modifyShareAction.getOnClicked().run()); } } } diff --git a/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt b/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt index af134fcd..98c7d5ee 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt +++ b/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt @@ -33,6 +33,7 @@ import com.android.intentresolver.flags.Flags import com.google.common.collect.ImmutableList import com.google.common.truth.Truth.assertThat import org.junit.After +import org.junit.Assert import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test @@ -50,6 +51,7 @@ class ChooserActionFactoryTest { private val logger = mock() private val flags = mock() private val actionLabel = "Action label" + private val modifyShareLabel = "Modify share" private val testAction = "com.android.intentresolver.testaction" private val countdown = CountDownLatch(1) private val testReceiver: BroadcastReceiver = object : BroadcastReceiver() { @@ -115,7 +117,8 @@ class ChooserActionFactoryTest { fun testModifyShareAction() { val factory = createFactory(includeModifyShare = true) - factory.modifyShareAction!!.run() + val action = factory.modifyShareAction ?: error("Modify share action should not be null") + action.onClicked.run() Mockito.verify(logger).logActionSelected( eq(ChooserActivityLogger.SELECTION_TYPE_MODIFY_SHARE)) @@ -137,7 +140,12 @@ class ChooserActionFactoryTest { whenever(chooserRequest.chooserActions).thenReturn(ImmutableList.of(action)) if (includeModifyShare) { - whenever(chooserRequest.modifyShareAction).thenReturn(testPendingIntent) + val modifyShare = ChooserAction.Builder( + Icon.createWithResource("", Resources.ID_NULL), + modifyShareLabel, + testPendingIntent + ).build() + whenever(chooserRequest.modifyShareAction).thenReturn(modifyShare) } return ChooserActionFactory( diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 0aab0536..65e95e30 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -1726,13 +1726,17 @@ public class UnbundledChooserActivityTest { Context testContext = InstrumentationRegistry.getInstrumentation().getContext(); final String modifyShareAction = "test-broadcast-receiver-action"; Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null); + String label = "modify share"; + PendingIntent pendingIntent = PendingIntent.getBroadcast( + testContext, + 123, + new Intent(modifyShareAction), + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT); + ChooserAction action = new ChooserAction.Builder(Icon.createWithBitmap( + createBitmap()), label, pendingIntent).build(); chooserIntent.putExtra( Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION, - PendingIntent.getBroadcast( - testContext, - 123, - new Intent(modifyShareAction), - PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT)); + action); // Start activity mActivityRule.launchActivity(chooserIntent); waitForIdle(); @@ -1747,7 +1751,7 @@ public class UnbundledChooserActivityTest { testContext.registerReceiver(testReceiver, new IntentFilter(modifyShareAction)); try { - onView(withText(R.string.select_text)).perform(click()); + onView(withText(label)).perform(click()); broadcastInvoked.await(); } finally { testContext.unregisterReceiver(testReceiver); diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt index d870a8c2..23bfaf9f 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt @@ -54,7 +54,7 @@ class ChooserContentPreviewUiTest { override fun createEditButton(): ActionRow.Action? = null override fun createNearbyButton(): ActionRow.Action? = null override fun createCustomActions(): List = emptyList() - override fun getModifyShareAction(): Runnable? = null + override fun getModifyShareAction(): ActionRow.Action? = null override fun getExcludeSharedTextAction(): Consumer = Consumer {} } private val transitionCallback = mock() -- cgit v1.2.3-59-g8ed1b From 961d141bc72743ee8c42ce21bb6b0e5e01b30c4c Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Mon, 13 Mar 2023 19:57:27 +0000 Subject: Stop sharing framework strings from unbundled Java This kind of sharing was a relic of unbundling, and is known to cause inscrutable failures (e.g. b/270760957) when the unbundled tests were run on a device with an out-of-date framework version. We've already hard-forked (duplicated into the IntentResolver package) most string resources, and this CL addresses the remainder, w/ exceptions: 1. Layout XML and other resource files may not have the same compatibility issues, so I've left them unchanged or now; we may need to make a similar change to those files in the future. 2. I left one string, `com.android.internal.R.string.copy`, because I don't think we'd ever want to diverge from the system value and it's currently exported in the frameworks `public-final.xml` resource (so there's no risk of binary-incompatibility issues). In some cases, the resource-sharing wasn't apparent due to file-level imports of `com.android.internal.R`; I removed those imports (so that all the unbundled code loads resources from the `intentresolver` package by default) and converted the non-string resource references to explicitly reference the framework symbols by fully-qualified name. This only addresses string resources and may be inadequate to prevent all regressions in the class of b/270760957, and view ID resources are particularly concerning because it wouldn't be straightforward to fix them by an analogous "hard-fork" process. In fact, an earlier workaround (removed in ag/20065287) had separate cases specifically to address resource sharing for strings vs. view IDs, so we may expect to need some fix there. If we encounter similar regressions based on the view IDs (and don't have any better ideas), we may be able to reinstate the workaround from ag/20065287 (just for the view IDs; strings should never need the old workaround now). Bug: 270760957 Test: `atest IntentResolverUnitTests`, before and after adding a new placeholder string resource at the top of frameworks' `strings.xml`. Prior to this CL, that modification would've caused the tests to start failing unless the framework was rebuilt (e.g. by `mp droid`). Change-Id: Ifaf069124ba677a79517894d7aba847c5d869b74 --- java/res/values/strings.xml | 86 +++++++++++++++++++++- .../intentresolver/ChooserActionFactory.java | 2 +- .../intentresolver/ChooserRequestParameters.java | 3 +- .../intentresolver/IntentForwarderActivity.java | 4 +- .../NoAppsAvailableEmptyStateProvider.java | 1 - .../android/intentresolver/ResolverActivity.java | 66 ++++++++--------- .../WorkProfilePausedEmptyStateProvider.java | 1 - .../intentresolver/ResolverActivityTest.java | 57 +++++++------- .../UnbundledChooserActivityTest.java | 4 +- .../UnbundledChooserActivityWorkProfileTest.java | 3 +- 10 files changed, 151 insertions(+), 76 deletions(-) (limited to 'java/tests') diff --git a/java/res/values/strings.xml b/java/res/values/strings.xml index 24604ed3..1cd39d42 100644 --- a/java/res/values/strings.xml +++ b/java/res/values/strings.xml @@ -17,17 +17,92 @@ - IntentResolver - + IntentResolver + + + Complete action using + + Complete action using %1$s + + Complete action + + Open with + + Open with %1$s Open + + Open %1$s links with + + Open links with + + Open links with %1$s + + Open %1$s links with %2$s + + Give access + + Edit with + + Edit with %1$s + + Edit + + Share + + Share with %1$s + + Share + + Send using + + Send using %1$s + + Send + + Select a Home app + + Use %1$s as Home + + Capture image + + + Capture image with + + Capture image with %1$s + + Capture image + Use a different app + + Choose an action No apps can perform this action. + + You\'re using this app outside of your work profile + + You\'re using this app in your work profile + Always @@ -36,12 +111,19 @@ from the activity resolver to use just this once. [CHAR LIMIT=25] --> Just once + + %1$s doesn\'t support work profile + Pin %1$s Unpin %1$s + + Edit + {count, plural, =1 {{file_name} + # file} other {{file_name} + # files} diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java index 947155f3..82103b39 100644 --- a/java/src/com/android/intentresolver/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/ChooserActionFactory.java @@ -386,7 +386,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( originalIntent, ri, - context.getString(com.android.internal.R.string.screenshot_edit), + context.getString(R.string.screenshot_edit), "", resolveIntent, null); diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java index 3d99e475..dbd72a1f 100644 --- a/java/src/com/android/intentresolver/ChooserRequestParameters.java +++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java @@ -310,8 +310,7 @@ public class ChooserRequestParameters { requestedTitle = null; } - int defaultTitleRes = - (requestedTitle == null) ? com.android.internal.R.string.chooseActivity : 0; + int defaultTitleRes = (requestedTitle == null) ? R.string.chooseActivity : 0; return Pair.create(requestedTitle, defaultTitleRes); } diff --git a/java/src/com/android/intentresolver/IntentForwarderActivity.java b/java/src/com/android/intentresolver/IntentForwarderActivity.java index 78240250..5e8945f1 100644 --- a/java/src/com/android/intentresolver/IntentForwarderActivity.java +++ b/java/src/com/android/intentresolver/IntentForwarderActivity.java @@ -162,13 +162,13 @@ public class IntentForwarderActivity extends Activity { private String getForwardToPersonalMessage() { return getSystemService(DevicePolicyManager.class).getResources().getString( FORWARD_INTENT_TO_PERSONAL, - () -> getString(com.android.internal.R.string.forward_intent_to_owner)); + () -> getString(R.string.forward_intent_to_owner)); } private String getForwardToWorkMessage() { return getSystemService(DevicePolicyManager.class).getResources().getString( FORWARD_INTENT_TO_WORK, - () -> getString(com.android.internal.R.string.forward_intent_to_work)); + () -> getString(R.string.forward_intent_to_work)); } private boolean isIntentForwarderResolveInfo(ResolveInfo resolveInfo) { diff --git a/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java b/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java index c1373f4b..d424f295 100644 --- a/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java @@ -31,7 +31,6 @@ import android.stats.devicepolicy.nano.DevicePolicyEnums; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider; -import com.android.internal.R; import java.util.List; diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index d224299e..a240968b 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -237,47 +237,43 @@ public class ResolverActivity extends FragmentActivity implements private enum ActionTitle { VIEW(Intent.ACTION_VIEW, - com.android.internal.R.string.whichViewApplication, - com.android.internal.R.string.whichViewApplicationNamed, - com.android.internal.R.string.whichViewApplicationLabel), + R.string.whichViewApplication, + R.string.whichViewApplicationNamed, + R.string.whichViewApplicationLabel), EDIT(Intent.ACTION_EDIT, - com.android.internal.R.string.whichEditApplication, - com.android.internal.R.string.whichEditApplicationNamed, - com.android.internal.R.string.whichEditApplicationLabel), + R.string.whichEditApplication, + R.string.whichEditApplicationNamed, + R.string.whichEditApplicationLabel), SEND(Intent.ACTION_SEND, - com.android.internal.R.string.whichSendApplication, - com.android.internal.R.string.whichSendApplicationNamed, - com.android.internal.R.string.whichSendApplicationLabel), + R.string.whichSendApplication, + R.string.whichSendApplicationNamed, + R.string.whichSendApplicationLabel), SENDTO(Intent.ACTION_SENDTO, - com.android.internal.R.string.whichSendToApplication, - com.android.internal.R.string.whichSendToApplicationNamed, - com.android.internal.R.string.whichSendToApplicationLabel), + R.string.whichSendToApplication, + R.string.whichSendToApplicationNamed, + R.string.whichSendToApplicationLabel), SEND_MULTIPLE(Intent.ACTION_SEND_MULTIPLE, - com.android.internal.R.string.whichSendApplication, - com.android.internal.R.string.whichSendApplicationNamed, - com.android.internal.R.string.whichSendApplicationLabel), + R.string.whichSendApplication, + R.string.whichSendApplicationNamed, + R.string.whichSendApplicationLabel), CAPTURE_IMAGE(MediaStore.ACTION_IMAGE_CAPTURE, - com.android.internal.R.string.whichImageCaptureApplication, - com.android.internal.R.string.whichImageCaptureApplicationNamed, - com.android.internal.R.string.whichImageCaptureApplicationLabel), + R.string.whichImageCaptureApplication, + R.string.whichImageCaptureApplicationNamed, + R.string.whichImageCaptureApplicationLabel), DEFAULT(null, - com.android.internal.R.string.whichApplication, - com.android.internal.R.string.whichApplicationNamed, - com.android.internal.R.string.whichApplicationLabel), + R.string.whichApplication, + R.string.whichApplicationNamed, + R.string.whichApplicationLabel), HOME(Intent.ACTION_MAIN, - com.android.internal.R.string.whichHomeApplication, - com.android.internal.R.string.whichHomeApplicationNamed, - com.android.internal.R.string.whichHomeApplicationLabel); + R.string.whichHomeApplication, + R.string.whichHomeApplicationNamed, + R.string.whichHomeApplicationLabel); // titles for layout that deals with http(s) intents - public static final int BROWSABLE_TITLE_RES = - com.android.internal.R.string.whichOpenLinksWith; - public static final int BROWSABLE_HOST_TITLE_RES = - com.android.internal.R.string.whichOpenHostLinksWith; - public static final int BROWSABLE_HOST_APP_TITLE_RES = - com.android.internal.R.string.whichOpenHostLinksWithApp; - public static final int BROWSABLE_APP_TITLE_RES = - com.android.internal.R.string.whichOpenLinksWithApp; + public static final int BROWSABLE_TITLE_RES = R.string.whichOpenLinksWith; + public static final int BROWSABLE_HOST_TITLE_RES = R.string.whichOpenHostLinksWith; + public static final int BROWSABLE_HOST_APP_TITLE_RES = R.string.whichOpenHostLinksWithApp; + public static final int BROWSABLE_APP_TITLE_RES = R.string.whichOpenLinksWithApp; public final String action; public final int titleRes; @@ -1361,13 +1357,13 @@ public class ResolverActivity extends FragmentActivity implements private String getForwardToPersonalMsg() { return getSystemService(DevicePolicyManager.class).getResources().getString( FORWARD_INTENT_TO_PERSONAL, - () -> getString(com.android.internal.R.string.forward_intent_to_owner)); + () -> getString(R.string.forward_intent_to_owner)); } private String getForwardToWorkMsg() { return getSystemService(DevicePolicyManager.class).getResources().getString( FORWARD_INTENT_TO_WORK, - () -> getString(com.android.internal.R.string.forward_intent_to_work)); + () -> getString(R.string.forward_intent_to_work)); } /** @@ -1544,7 +1540,7 @@ public class ResolverActivity extends FragmentActivity implements return getSystemService(DevicePolicyManager.class).getResources().getString( RESOLVER_WORK_PROFILE_NOT_SUPPORTED, () -> getString( - com.android.internal.R.string.activity_resolver_work_profiles_support, + R.string.activity_resolver_work_profiles_support, launcherName), launcherName); } diff --git a/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java b/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java index 0333039b..2f3dfbd5 100644 --- a/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java @@ -29,7 +29,6 @@ import android.stats.devicepolicy.nano.DevicePolicyEnums; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; -import com.android.internal.R; /** * Chooser/ResolverActivity empty state provider that returns empty state which is shown when diff --git a/java/tests/src/com/android/intentresolver/ResolverActivityTest.java b/java/tests/src/com/android/intentresolver/ResolverActivityTest.java index ae1b99f8..e2772423 100644 --- a/java/tests/src/com/android/intentresolver/ResolverActivityTest.java +++ b/java/tests/src/com/android/intentresolver/ResolverActivityTest.java @@ -55,7 +55,6 @@ import androidx.test.rule.ActivityTestRule; import androidx.test.runner.AndroidJUnit4; import com.android.intentresolver.widget.ResolverDrawerLayout; -import com.android.internal.R; import org.junit.Before; import org.junit.Ignore; @@ -117,7 +116,7 @@ public class ResolverActivityTest { ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); onView(withText(toChoose.activityInfo.name)) .perform(click()); - onView(withId(R.id.button_once)) + onView(withId(com.android.internal.R.id.button_once)) .perform(click()); waitForIdle(); assertThat(chosen[0], is(toChoose)); @@ -133,13 +132,13 @@ public class ResolverActivityTest { waitForIdle(); final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - final View viewPager = activity.findViewById(R.id.profile_pager); + final View viewPager = activity.findViewById(com.android.internal.R.id.profile_pager); final int initialResolverHeight = viewPager.getHeight(); activity.runOnUiThread(() -> { ResolverDrawerLayout layout = (ResolverDrawerLayout) activity.findViewById( - R.id.contentPanel); + com.android.internal.R.id.contentPanel); ((ResolverDrawerLayout.LayoutParams) viewPager.getLayoutParams()).maxHeight = initialResolverHeight - 1; // Force a relayout @@ -153,7 +152,7 @@ public class ResolverActivityTest { activity.runOnUiThread(() -> { ResolverDrawerLayout layout = (ResolverDrawerLayout) activity.findViewById( - R.id.contentPanel); + com.android.internal.R.id.contentPanel); ((ResolverDrawerLayout.LayoutParams) viewPager.getLayoutParams()).maxHeight = initialResolverHeight + 1; // Force a relayout @@ -175,10 +174,11 @@ public class ResolverActivityTest { waitForIdle(); final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - final View viewPager = activity.findViewById(R.id.profile_pager); - final View divider = activity.findViewById(R.id.divider); + final View viewPager = activity.findViewById(com.android.internal.R.id.profile_pager); + final View divider = activity.findViewById(com.android.internal.R.id.divider); final RelativeLayout profileView = - (RelativeLayout) activity.findViewById(R.id.profile_button).getParent(); + (RelativeLayout) activity.findViewById(com.android.internal.R.id.profile_button) + .getParent(); assertThat("Drawer should show at bottom by default", profileView.getBottom() + divider.getHeight() == viewPager.getTop() && profileView.getTop() > 0); @@ -186,7 +186,7 @@ public class ResolverActivityTest { activity.runOnUiThread(() -> { ResolverDrawerLayout layout = (ResolverDrawerLayout) activity.findViewById( - R.id.contentPanel); + com.android.internal.R.id.contentPanel); layout.setShowAtTop(true); }); waitForIdle(); @@ -218,7 +218,7 @@ public class ResolverActivityTest { return true; }; - onView(withId(R.id.button_once)).perform(click()); + onView(withId(com.android.internal.R.id.button_once)).perform(click()); waitForIdle(); assertThat(chosen[0], is(toChoose)); } @@ -251,7 +251,7 @@ public class ResolverActivityTest { // We pick the first one as there is another one in the work profile side onView(first(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name))) .perform(click()); - onView(withId(R.id.button_once)) + onView(withId(com.android.internal.R.id.button_once)) .perform(click()); waitForIdle(); assertThat(chosen[0], is(toChoose)); @@ -280,7 +280,7 @@ public class ResolverActivityTest { }; // Confirm that the button bar is disabled by default - onView(withId(R.id.button_once)).check(matches(not(isEnabled()))); + onView(withId(com.android.internal.R.id.button_once)).check(matches(not(isEnabled()))); // Make a stable copy of the components as the original list may be modified List stableCopy = @@ -288,7 +288,7 @@ public class ResolverActivityTest { onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) .perform(click()); - onView(withId(R.id.button_once)).perform(click()); + onView(withId(com.android.internal.R.id.button_once)).perform(click()); waitForIdle(); assertThat(chosen[0], is(toChoose)); } @@ -321,7 +321,7 @@ public class ResolverActivityTest { }; // Confirm that the button bar is disabled by default - onView(withId(R.id.button_once)).check(matches(not(isEnabled()))); + onView(withId(com.android.internal.R.id.button_once)).check(matches(not(isEnabled()))); // Make a stable copy of the components as the original list may be modified List stableCopy = @@ -329,7 +329,7 @@ public class ResolverActivityTest { onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) .perform(click()); - onView(withId(R.id.button_once)).perform(click()); + onView(withId(com.android.internal.R.id.button_once)).perform(click()); waitForIdle(); assertThat(chosen[0], is(toChoose)); } @@ -342,7 +342,7 @@ public class ResolverActivityTest { mActivityRule.launchActivity(sendIntent); waitForIdle(); - onView(withId(R.id.tabs)).check(matches(isDisplayed())); + onView(withId(com.android.internal.R.id.tabs)).check(matches(isDisplayed())); } @Test @@ -352,7 +352,7 @@ public class ResolverActivityTest { mActivityRule.launchActivity(sendIntent); waitForIdle(); - onView(withId(R.id.tabs)).check(matches(not(isDisplayed()))); + onView(withId(com.android.internal.R.id.tabs)).check(matches(not(isDisplayed()))); } @Test @@ -447,7 +447,7 @@ public class ResolverActivityTest { onView(first(allOf(withText(workResolvedComponentInfos.get(0) .getResolveInfoAt(0).activityInfo.applicationInfo.name), isCompletelyDisplayed()))) .perform(click()); - onView(withId(R.id.button_once)) + onView(withId(com.android.internal.R.id.button_once)) .perform(click()); waitForIdle(); @@ -484,7 +484,7 @@ public class ResolverActivityTest { final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); waitForIdle(); - TextView headerText = activity.findViewById(R.id.title); + TextView headerText = activity.findViewById(com.android.internal.R.id.title); String initialText = headerText.getText().toString(); assertFalse(initialText.isEmpty(), "Header text is empty."); assertThat(headerText.getVisibility(), is(View.VISIBLE)); @@ -501,7 +501,7 @@ public class ResolverActivityTest { final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); waitForIdle(); - TextView headerText = activity.findViewById(R.id.title); + TextView headerText = activity.findViewById(com.android.internal.R.id.title); String initialText = headerText.getText().toString(); onView(withText(R.string.resolver_work_tab)) .perform(click()); @@ -539,7 +539,7 @@ public class ResolverActivityTest { .getResolveInfoAt(0).activityInfo.applicationInfo.name), isDisplayed()))) .perform(click()); - onView(withId(R.id.button_once)) + onView(withId(com.android.internal.R.id.button_once)) .perform(click()); waitForIdle(); @@ -563,7 +563,7 @@ public class ResolverActivityTest { waitForIdle(); onView(withText(R.string.resolver_work_tab)).perform(click()); waitForIdle(); - onView(withId(R.id.contentPanel)) + onView(withId(com.android.internal.R.id.contentPanel)) .perform(swipeUp()); onView(withText(R.string.resolver_cross_profile_blocked)) @@ -585,7 +585,7 @@ public class ResolverActivityTest { mActivityRule.launchActivity(sendIntent); waitForIdle(); - onView(withId(R.id.contentPanel)) + onView(withId(com.android.internal.R.id.contentPanel)) .perform(swipeUp()); onView(withText(R.string.resolver_work_tab)).perform(click()); waitForIdle(); @@ -607,7 +607,7 @@ public class ResolverActivityTest { mActivityRule.launchActivity(sendIntent); waitForIdle(); - onView(withId(R.id.contentPanel)) + onView(withId(com.android.internal.R.id.contentPanel)) .perform(swipeUp()); onView(withText(R.string.resolver_work_tab)).perform(click()); waitForIdle(); @@ -631,7 +631,7 @@ public class ResolverActivityTest { mActivityRule.launchActivity(sendIntent); waitForIdle(); - onView(withId(R.id.contentPanel)) + onView(withId(com.android.internal.R.id.contentPanel)) .perform(swipeUp()); onView(withText(R.string.resolver_work_tab)).perform(click()); waitForIdle(); @@ -655,7 +655,7 @@ public class ResolverActivityTest { mActivityRule.launchActivity(sendIntent); waitForIdle(); - onView(withId(R.id.open_cross_profile)).check(matches(isDisplayed())); + onView(withId(com.android.internal.R.id.open_cross_profile)).check(matches(isDisplayed())); } @Test @@ -678,7 +678,8 @@ public class ResolverActivityTest { private void assertNotMiniResolver() { try { - onView(withId(R.id.open_cross_profile)).check(matches(isDisplayed())); + onView(withId(com.android.internal.R.id.open_cross_profile)) + .check(matches(isDisplayed())); } catch (NoMatchingViewException e) { return; } @@ -699,7 +700,7 @@ public class ResolverActivityTest { mActivityRule.launchActivity(sendIntent); waitForIdle(); - onView(withId(R.id.contentPanel)) + onView(withId(com.android.internal.R.id.contentPanel)) .perform(swipeUp()); onView(withText(R.string.resolver_work_tab)).perform(click()); waitForIdle(); diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 0aab0536..3bf9f1d8 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -350,7 +350,7 @@ public class UnbundledChooserActivityTest { mActivityRule.launchActivity(Intent.createChooser(sendIntent, "chooser test")); waitForIdle(); onView(withId(android.R.id.title)) - .check(matches(withText(com.android.internal.R.string.whichSendApplication))); + .check(matches(withText(R.string.whichSendApplication))); } @Test @@ -362,7 +362,7 @@ public class UnbundledChooserActivityTest { mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); onView(withId(android.R.id.title)) - .check(matches(withText(com.android.internal.R.string.whichSendApplication))); + .check(matches(withText(R.string.whichSendApplication))); } @Test diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java index 87dc1b9d..6c1edfbc 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java @@ -49,7 +49,6 @@ import androidx.test.espresso.NoMatchingViewException; import androidx.test.rule.ActivityTestRule; import com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.Tab; -import com.android.internal.R; import junit.framework.AssertionFailedError; @@ -356,7 +355,7 @@ public class UnbundledChooserActivityWorkProfileTest { } }); - onView(withId(R.id.contentPanel)) + onView(withId(com.android.internal.R.id.contentPanel)) .perform(swipeUp()); waitForIdle(); } -- cgit v1.2.3-59-g8ed1b From 4649ef1769d53727d59423f184cb3ee068ce40db Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Mon, 13 Mar 2023 16:39:23 -0700 Subject: Add unified preview UI ChooserContentPreviewUi applies various heuristic to determine if each shared URI has a preview and, if any, displays a scrollable preview list. Each preview item in the list is badge accroding ot its type: no badge for images, a video-file badge for videos and a generic-file badge for all others. All URIs without a previwe are groupped under a single item at list end (+N files). Collateral changes: * FileContentPreviewUi$FileInfo is moved into the package level; * ChooserContentPreviewUi$ImageMimeTypeClassifier internface is moved into the package level, renamted to MimeTypeClassifier and defines a new method, isVideoType(); * ScrollableImagePreviewView is modified to support badges and the "+N" item; * A new class, UnfifiedContentPreviewUi is clonned from ImageContentPreviewUi class, and is reponsible for drawing the new unified preview ui, ImageContentPreviewUi is used only with the legacy image content preview. Bug: 271613784 Test: manual testing Change-Id: Ia25f5a1565226ac679cc8ecefd58acb95cb60142 --- java/res/drawable/content_preview_badge_bg.xml | 27 ++ java/res/drawable/ic_file_video.xml | 27 ++ java/res/layout/image_preview_image_item.xml | 36 ++- java/res/layout/image_preview_other_item.xml | 31 +++ java/res/values/attrs.xml | 4 + java/res/values/dimens.xml | 1 + java/res/values/strings.xml | 7 + .../android/intentresolver/ChooserActivity.java | 2 +- .../intentresolver/ImagePreviewImageLoader.kt | 10 +- .../contentpreview/ChooserContentPreviewUi.java | 306 +++++++++++++-------- .../contentpreview/FileContentPreviewUi.java | 101 +------ .../intentresolver/contentpreview/FileInfo.kt | 48 ++++ .../contentpreview/ImageContentPreviewUi.java | 19 +- .../contentpreview/MimeTypeClassifier.java | 38 +++ .../contentpreview/UnifiedContentPreviewUi.java | 202 ++++++++++++++ .../widget/ChooserImagePreviewView.kt | 2 +- .../intentresolver/widget/ImagePreviewView.kt | 1 - .../widget/RoundedRectImageView.java | 13 +- .../widget/ScrollableImagePreviewView.kt | 125 ++++++--- .../contentpreview/ChooserContentPreviewUiTest.kt | 91 +++++- 20 files changed, 826 insertions(+), 265 deletions(-) create mode 100644 java/res/drawable/content_preview_badge_bg.xml create mode 100644 java/res/drawable/ic_file_video.xml create mode 100644 java/res/layout/image_preview_other_item.xml create mode 100644 java/src/com/android/intentresolver/contentpreview/FileInfo.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/MimeTypeClassifier.java create mode 100644 java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java (limited to 'java/tests') diff --git a/java/res/drawable/content_preview_badge_bg.xml b/java/res/drawable/content_preview_badge_bg.xml new file mode 100644 index 00000000..087247e7 --- /dev/null +++ b/java/res/drawable/content_preview_badge_bg.xml @@ -0,0 +1,27 @@ + + + + + + diff --git a/java/res/drawable/ic_file_video.xml b/java/res/drawable/ic_file_video.xml new file mode 100644 index 00000000..8c7a3650 --- /dev/null +++ b/java/res/drawable/ic_file_video.xml @@ -0,0 +1,27 @@ + + + + + + diff --git a/java/res/layout/image_preview_image_item.xml b/java/res/layout/image_preview_image_item.xml index c18cc279..81fa5c8e 100644 --- a/java/res/layout/image_preview_image_item.xml +++ b/java/res/layout/image_preview_image_item.xml @@ -14,11 +14,35 @@ ~ limitations under the License. --> - + android:layout_height="@dimen/chooser_preview_image_height" > + + + + + + + + diff --git a/java/res/layout/image_preview_other_item.xml b/java/res/layout/image_preview_other_item.xml new file mode 100644 index 00000000..b7cc4350 --- /dev/null +++ b/java/res/layout/image_preview_other_item.xml @@ -0,0 +1,31 @@ + + + + + + + diff --git a/java/res/values/attrs.xml b/java/res/values/attrs.xml index 2f2bbda2..eba6b9b7 100644 --- a/java/res/values/attrs.xml +++ b/java/res/values/attrs.xml @@ -41,4 +41,8 @@ + + + + diff --git a/java/res/values/dimens.xml b/java/res/values/dimens.xml index 87eec7fb..af90c4ef 100644 --- a/java/res/values/dimens.xml +++ b/java/res/values/dimens.xml @@ -19,6 +19,7 @@ 412dp 28dp + 14dp 25dp 18dp 16dp diff --git a/java/res/values/strings.xml b/java/res/values/strings.xml index 24604ed3..11f8bc59 100644 --- a/java/res/values/strings.xml +++ b/java/res/values/strings.xml @@ -48,6 +48,13 @@ } + + {count, plural, + =1 {+ # file} + other {+ # files} + } + + No recommended people to share with diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index ae5be26d..37a17e79 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -714,7 +714,7 @@ public class ChooserActivity extends ResolverActivity implements } @VisibleForTesting - protected boolean isImageType(String mimeType) { + protected boolean isImageType(@Nullable String mimeType) { return mimeType != null && mimeType.startsWith("image/"); } diff --git a/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt index 7b6651a2..9650403e 100644 --- a/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt +++ b/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt @@ -19,6 +19,7 @@ package com.android.intentresolver import android.content.Context import android.graphics.Bitmap import android.net.Uri +import android.util.Log import android.util.Size import androidx.annotation.GuardedBy import androidx.annotation.VisibleForTesting @@ -32,6 +33,8 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.util.function.Consumer +private const val TAG = "ImagePreviewImageLoader" + @VisibleForTesting class ImagePreviewImageLoader @JvmOverloads constructor( private val context: Context, @@ -79,9 +82,12 @@ class ImagePreviewImageLoader @JvmOverloads constructor( } private fun CompletableDeferred.loadBitmap(uri: Uri) { - val bitmap = runCatching { + val bitmap = try { context.contentResolver.loadThumbnail(uri, thumbnailSize, null) - }.getOrNull() + } catch (t: Throwable) { + Log.d(TAG, "failed to load $uri preview", t) + null + } complete(bitmap) } } diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index 205be444..526b15ae 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -16,17 +16,23 @@ package com.android.intentresolver.contentpreview; -import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_FILE; +import static android.provider.DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL; + import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_IMAGE; -import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_TEXT; import android.content.ClipData; import android.content.ClipDescription; import android.content.ContentInterface; import android.content.Intent; import android.content.res.Resources; +import android.database.Cursor; +import android.media.MediaMetadata; import android.net.Uri; import android.os.RemoteException; +import android.provider.DocumentsContract; +import android.provider.Downloads; +import android.provider.OpenableColumns; +import android.text.TextUtils; import android.view.LayoutInflater; import android.view.ViewGroup; @@ -34,14 +40,14 @@ import androidx.annotation.Nullable; import com.android.intentresolver.ImageLoader; import com.android.intentresolver.flags.FeatureFlagRepository; +import com.android.intentresolver.flags.Flags; import com.android.intentresolver.widget.ActionRow; -import com.android.intentresolver.widget.ImagePreviewView; import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.function.Consumer; -import java.util.stream.Collectors; /** * Collection of helpers for building the content preview UI displayed in @@ -88,24 +94,12 @@ public final class ChooserContentPreviewUi { Consumer getExcludeSharedTextAction(); } - /** - * Testing shim to specify whether a given mime type is considered to be an "image." - * - * TODO: move away from {@link ChooserActivityOverrideData} as a model to configure our tests, - * then migrate {@link com.android.intentresolver.ChooserActivity#isImageType(String)} into this - * class. - */ - public interface ImageMimeTypeClassifier { - /** @return whether the specified {@code mimeType} is classified as an "image" type. */ - boolean isImageType(String mimeType); - } - private final ContentPreviewUi mContentPreviewUi; public ChooserContentPreviewUi( Intent targetIntent, ContentInterface contentResolver, - ImageMimeTypeClassifier imageClassifier, + MimeTypeClassifier imageClassifier, ImageLoader imageLoader, ActionFactory actionFactory, TransitionElementStatusCallback transitionElementStatusCallback, @@ -127,37 +121,78 @@ public final class ChooserContentPreviewUi { private ContentPreviewUi createContentPreview( Intent targetIntent, ContentInterface contentResolver, - ImageMimeTypeClassifier imageClassifier, + MimeTypeClassifier typeClassifier, ImageLoader imageLoader, ActionFactory actionFactory, TransitionElementStatusCallback transitionElementStatusCallback, FeatureFlagRepository featureFlagRepository) { - int type = findPreferredContentPreview(targetIntent, contentResolver, imageClassifier); - switch (type) { - case CONTENT_PREVIEW_TEXT: - return createTextPreview( - targetIntent, actionFactory, imageLoader, featureFlagRepository); - case CONTENT_PREVIEW_FILE: - return new FileContentPreviewUi( - extractContentUris(targetIntent), - actionFactory, - imageLoader, - contentResolver, - featureFlagRepository); + /* In {@link android.content.Intent#getType}, the app may specify a very general mime type + * that broadly covers all data being shared, such as {@literal *}/* when sending an image + * and text. We therefore should inspect each item for the preferred type, in order: IMAGE, + * FILE, TEXT. */ + final String action = targetIntent.getAction(); + final String type = targetIntent.getType(); + final boolean isSend = Intent.ACTION_SEND.equals(action); + final boolean isSendMultiple = Intent.ACTION_SEND_MULTIPLE.equals(action); - case CONTENT_PREVIEW_IMAGE: - return createImagePreview( - targetIntent, + if (!(isSend || isSendMultiple) + || (type != null && ClipDescription.compareMimeTypes(type, "text/*"))) { + return createTextPreview( + targetIntent, actionFactory, imageLoader, featureFlagRepository); + } + List uris = extractContentUris(targetIntent); + if (uris.isEmpty()) { + return createTextPreview( + targetIntent, actionFactory, imageLoader, featureFlagRepository); + } + ArrayList files = new ArrayList<>(uris.size()); + int previewCount = readFileInfo(contentResolver, typeClassifier, uris, files); + if (previewCount == 0) { + return new FileContentPreviewUi( + files, + actionFactory, + imageLoader, + featureFlagRepository); + } + if (featureFlagRepository.isEnabled(Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW)) { + return new UnifiedContentPreviewUi( + files, + targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT), + actionFactory, + imageLoader, + typeClassifier, + transitionElementStatusCallback, + featureFlagRepository); + } + if (previewCount < uris.size()) { + return new FileContentPreviewUi( + files, + actionFactory, + imageLoader, + featureFlagRepository); + } + // The legacy (3-image) image preview is on it's way out and it's unlikely that we'd end up + // here. To preserve the legacy behavior, before using it, check that all uris are images. + for (FileInfo fileInfo: files) { + if (!typeClassifier.isImageType(fileInfo.getMimeType())) { + return new FileContentPreviewUi( + files, actionFactory, - contentResolver, - imageClassifier, imageLoader, - transitionElementStatusCallback, featureFlagRepository); + } } - - return new NoContextPreviewUi(type); + return new ImageContentPreviewUi( + files.stream() + .map(FileInfo::getPreviewUri) + .filter(Objects::nonNull) + .toList(), + targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT), + actionFactory, + imageLoader, + transitionElementStatusCallback, + featureFlagRepository); } public int getPreferredContentPreview() { @@ -174,61 +209,98 @@ public final class ChooserContentPreviewUi { return mContentPreviewUi.display(resources, layoutInflater, parent); } - /** Determine the most appropriate type of preview to show for the provided {@link Intent}. */ - @ContentPreviewType - private static int findPreferredContentPreview( - Intent targetIntent, - ContentInterface resolver, - ImageMimeTypeClassifier imageClassifier) { - /* In {@link android.content.Intent#getType}, the app may specify a very general mime type - * that broadly covers all data being shared, such as {@literal *}/* when sending an image - * and text. We therefore should inspect each item for the preferred type, in order: IMAGE, - * FILE, TEXT. */ - final String action = targetIntent.getAction(); - final String type = targetIntent.getType(); - final boolean isSend = Intent.ACTION_SEND.equals(action); - final boolean isSendMultiple = Intent.ACTION_SEND_MULTIPLE.equals(action); - - if (!(isSend || isSendMultiple) - || (type != null && ClipDescription.compareMimeTypes(type, "text/*"))) { - return CONTENT_PREVIEW_TEXT; + private static int readFileInfo( + ContentInterface contentResolver, + MimeTypeClassifier typeClassifier, + List uris, + List fileInfos) { + int previewCount = 0; + for (Uri uri: uris) { + FileInfo fileInfo = getFileInfo(contentResolver, typeClassifier, uri); + if (fileInfo.getPreviewUri() != null) { + previewCount++; + } + fileInfos.add(fileInfo); } + return previewCount; + } - if (isSend) { - Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); - return findPreferredContentPreview(uri, resolver, imageClassifier); + private static FileInfo getFileInfo( + ContentInterface resolver, MimeTypeClassifier typeClassifier, Uri uri) { + FileInfo.Builder builder = new FileInfo.Builder(uri) + .withName(getFileName(uri)); + String mimeType = getType(resolver, uri); + builder.withMimeType(mimeType); + if (typeClassifier.isImageType(mimeType)) { + return builder.withPreviewUri(uri).build(); } - - List uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); - if (uris == null || uris.isEmpty()) { - return CONTENT_PREVIEW_TEXT; + readFileMetadata(resolver, uri, builder); + if (builder.getPreviewUri() == null) { + readOtherFileTypes(resolver, uri, typeClassifier, builder); } + return builder.build(); + } - for (Uri uri : uris) { - // Defaulting to file preview when there are mixed image/file types is - // preferable, as it shows the user the correct number of items being shared - int uriPreviewType = findPreferredContentPreview(uri, resolver, imageClassifier); - if (uriPreviewType == CONTENT_PREVIEW_FILE) { - return CONTENT_PREVIEW_FILE; + private static void readFileMetadata( + ContentInterface resolver, Uri uri, FileInfo.Builder builder) { + Cursor cursor = query(resolver, uri); + if (cursor == null || !cursor.moveToFirst()) { + return; + } + int flagColIdx = -1; + int displayIconUriColIdx = -1; + int nameColIndex = -1; + int titleColIndex = -1; + String[] columns = cursor.getColumnNames(); + // TODO: double-check why Cursor#getColumnInded didn't work + for (int i = 0; i < columns.length; i++) { + String columnName = columns[i]; + if (DocumentsContract.Document.COLUMN_FLAGS.equals(columnName)) { + flagColIdx = i; + } else if (MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI.equals(columnName)) { + displayIconUriColIdx = i; + } else if (OpenableColumns.DISPLAY_NAME.equals(columnName)) { + nameColIndex = i; + } else if (Downloads.Impl.COLUMN_TITLE.equals(columnName)) { + titleColIndex = i; } } + String fileName = ""; + if (nameColIndex >= 0) { + fileName = cursor.getString(nameColIndex); + } else if (titleColIndex >= 0) { + fileName = cursor.getString(titleColIndex); + } + if (!TextUtils.isEmpty(fileName)) { + builder.withName(fileName); + } - return CONTENT_PREVIEW_IMAGE; - } - - @ContentPreviewType - private static int findPreferredContentPreview( - Uri uri, ContentInterface resolver, ImageMimeTypeClassifier imageClassifier) { - if (uri == null) { - return CONTENT_PREVIEW_TEXT; + Uri previewUri = null; + if (flagColIdx >= 0 && ((cursor.getInt(flagColIdx) & FLAG_SUPPORTS_THUMBNAIL) != 0)) { + previewUri = uri; + } else if (displayIconUriColIdx >= 0) { + String uriStr = cursor.getString(displayIconUriColIdx); + previewUri = uriStr == null ? null : Uri.parse(uriStr); } + if (previewUri != null) { + builder.withPreviewUri(previewUri); + } + } - String mimeType = null; - try { - mimeType = resolver.getType(uri); - } catch (RemoteException ignored) { + private static void readOtherFileTypes( + ContentInterface resolver, + Uri uri, + MimeTypeClassifier typeClassifier, + FileInfo.Builder builder) { + String[] otherTypes = getStreamTypes(resolver, uri); + if (otherTypes != null && otherTypes.length > 0) { + for (String mimeType : otherTypes) { + if (typeClassifier.isImageType(mimeType)) { + builder.withPreviewUri(uri); + break; + } + } } - return imageClassifier.isImageType(mimeType) ? CONTENT_PREVIEW_IMAGE : CONTENT_PREVIEW_FILE; } private static TextContentPreviewUi createTextPreview( @@ -255,39 +327,6 @@ public final class ChooserContentPreviewUi { featureFlagRepository); } - static ImageContentPreviewUi createImagePreview( - Intent targetIntent, - ChooserContentPreviewUi.ActionFactory actionFactory, - ContentInterface contentResolver, - ChooserContentPreviewUi.ImageMimeTypeClassifier imageClassifier, - ImageLoader imageLoader, - ImagePreviewView.TransitionElementStatusCallback transitionElementStatusCallback, - FeatureFlagRepository featureFlagRepository) { - CharSequence text = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); - String action = targetIntent.getAction(); - // TODO: why don't we use image classifier for single-element ACTION_SEND? - final List imageUris = Intent.ACTION_SEND.equals(action) - ? extractContentUris(targetIntent) - : extractContentUris(targetIntent) - .stream() - .filter(uri -> { - String type = null; - try { - type = contentResolver.getType(uri); - } catch (RemoteException ignored) { - } - return imageClassifier.isImageType(type); - }) - .collect(Collectors.toList()); - return new ImageContentPreviewUi( - imageUris, - text, - actionFactory, - imageLoader, - transitionElementStatusCallback, - featureFlagRepository); - } - private static List extractContentUris(Intent targetIntent) { List uris = new ArrayList<>(); if (Intent.ACTION_SEND.equals(targetIntent.getAction())) { @@ -307,4 +346,41 @@ public final class ChooserContentPreviewUi { } return uris; } + + @Nullable + private static String getType(ContentInterface resolver, Uri uri) { + try { + return resolver.getType(uri); + } catch (RemoteException e) { + return null; + } + } + + @Nullable + private static Cursor query(ContentInterface resolver, Uri uri) { + try { + return resolver.query(uri, null, null, null); + } catch (RemoteException e) { + return null; + } + } + + @Nullable + private static String[] getStreamTypes(ContentInterface resolver, Uri uri) { + try { + return resolver.getStreamTypes(uri, "*/*"); + } catch (RemoteException e) { + return null; + } + } + + private static String getFileName(Uri uri) { + String fileName = uri.getPath(); + fileName = fileName == null ? "" : fileName; + int index = fileName.lastIndexOf('/'); + if (index != -1) { + fileName = fileName.substring(index + 1); + } + return fileName; + } } diff --git a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java index 7cd71475..2c5def8b 100644 --- a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java @@ -16,15 +16,7 @@ package com.android.intentresolver.contentpreview; -import android.content.ContentInterface; import android.content.res.Resources; -import android.database.Cursor; -import android.net.Uri; -import android.os.RemoteException; -import android.provider.DocumentsContract; -import android.provider.Downloads; -import android.provider.OpenableColumns; -import android.text.TextUtils; import android.util.Log; import android.util.PluralsMessageFormatter; import android.view.LayoutInflater; @@ -49,21 +41,19 @@ class FileContentPreviewUi extends ContentPreviewUi { private static final String PLURALS_COUNT = "count"; private static final String PLURALS_FILE_NAME = "file_name"; - private final List mUris; + private final List mFiles; private final ChooserContentPreviewUi.ActionFactory mActionFactory; private final ImageLoader mImageLoader; - private final ContentInterface mContentResolver; private final FeatureFlagRepository mFeatureFlagRepository; - FileContentPreviewUi(List uris, + FileContentPreviewUi( + List files, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, - ContentInterface contentResolver, FeatureFlagRepository featureFlagRepository) { - mUris = uris; + mFiles = files; mActionFactory = actionFactory; mImageLoader = imageLoader; - mContentResolver = contentResolver; mFeatureFlagRepository = featureFlagRepository; } @@ -85,7 +75,7 @@ class FileContentPreviewUi extends ContentPreviewUi { ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_file, parent, false); - final int uriCount = mUris.size(); + final int uriCount = mFiles.size(); if (uriCount == 0) { contentPreviewLayout.setVisibility(View.GONE); @@ -95,13 +85,13 @@ class FileContentPreviewUi extends ContentPreviewUi { } if (uriCount == 1) { - loadFileUriIntoView(mUris.get(0), contentPreviewLayout, mImageLoader, mContentResolver); + loadFileUriIntoView(mFiles.get(0), contentPreviewLayout, mImageLoader); } else { - FileInfo fileInfo = extractFileInfo(mUris.get(0), mContentResolver); + FileInfo fileInfo = mFiles.get(0); int remUriCount = uriCount - 1; Map arguments = new HashMap<>(); arguments.put(PLURALS_COUNT, remUriCount); - arguments.put(PLURALS_FILE_NAME, fileInfo.name); + arguments.put(PLURALS_FILE_NAME, fileInfo.getName()); String fileName = PluralsMessageFormatter.format(resources, arguments, R.string.file_count); @@ -143,19 +133,16 @@ class FileContentPreviewUi extends ContentPreviewUi { } private static void loadFileUriIntoView( - final Uri uri, + final FileInfo fileInfo, final View parent, - final ImageLoader imageLoader, - final ContentInterface contentResolver) { - FileInfo fileInfo = extractFileInfo(uri, contentResolver); - + final ImageLoader imageLoader) { TextView fileNameView = parent.findViewById( com.android.internal.R.id.content_preview_filename); - fileNameView.setText(fileInfo.name); + fileNameView.setText(fileInfo.getName()); - if (fileInfo.hasThumbnail) { + if (fileInfo.getPreviewUri() != null) { imageLoader.loadImage( - uri, + fileInfo.getPreviewUri(), (bitmap) -> updateViewWithImage( parent.findViewById( com.android.internal.R.id.content_preview_file_thumbnail), @@ -171,66 +158,4 @@ class FileContentPreviewUi extends ContentPreviewUi { fileIconView.setImageResource(R.drawable.chooser_file_generic); } } - - private static FileInfo extractFileInfo(Uri uri, ContentInterface resolver) { - String fileName = null; - boolean hasThumbnail = false; - - try (Cursor cursor = queryResolver(resolver, uri)) { - if (cursor != null && cursor.getCount() > 0) { - int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); - int titleIndex = cursor.getColumnIndex(Downloads.Impl.COLUMN_TITLE); - int flagsIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_FLAGS); - - cursor.moveToFirst(); - if (nameIndex != -1) { - fileName = cursor.getString(nameIndex); - } else if (titleIndex != -1) { - fileName = cursor.getString(titleIndex); - } - - if (flagsIndex != -1) { - hasThumbnail = (cursor.getInt(flagsIndex) - & DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL) != 0; - } - } - } catch (SecurityException | NullPointerException e) { - // The ContentResolver already logs the exception. Log something more informative. - Log.w( - TAG, - "Could not load (" + uri.toString() + ") thumbnail/name for preview. If " - + "desired, consider using Intent#createChooser to launch the ChooserActivity, " - + "and set your Intent's clipData and flags in accordance with that method's " - + "documentation"); - } - - if (TextUtils.isEmpty(fileName)) { - fileName = uri.getPath(); - fileName = fileName == null ? "" : fileName; - int index = fileName.lastIndexOf('/'); - if (index != -1) { - fileName = fileName.substring(index + 1); - } - } - - return new FileInfo(fileName, hasThumbnail); - } - - private static Cursor queryResolver(ContentInterface resolver, Uri uri) { - try { - return resolver.query(uri, null, null, null); - } catch (RemoteException e) { - return null; - } - } - - private static class FileInfo { - public final String name; - public final boolean hasThumbnail; - - FileInfo(String name, boolean hasThumbnail) { - this.name = name; - this.hasThumbnail = hasThumbnail; - } - } } diff --git a/java/src/com/android/intentresolver/contentpreview/FileInfo.kt b/java/src/com/android/intentresolver/contentpreview/FileInfo.kt new file mode 100644 index 00000000..527bfc8e --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/FileInfo.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.contentpreview + +import android.net.Uri + +internal class FileInfo private constructor( + val uri: Uri, + val name: String?, + val previewUri: Uri?, + val mimeType: String? +) { + class Builder(val uri: Uri) { + var name: String = "" + private set + var previewUri: Uri? = null + private set + var mimeType: String? = null + private set + + fun withName(name: String): Builder = apply { + this.name = name + } + + fun withPreviewUri(uri: Uri?): Builder = apply { + previewUri = uri + } + + fun withMimeType(mimeType: String?): Builder = apply { + this.mimeType = mimeType + } + + fun build(): FileInfo = FileInfo(uri, name, previewUri, mimeType) + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java index db26ab1b..5f3bdf40 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java @@ -39,7 +39,8 @@ import com.android.intentresolver.R; import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.flags.Flags; import com.android.intentresolver.widget.ActionRow; -import com.android.intentresolver.widget.ImagePreviewView; +import com.android.intentresolver.widget.ChooserImagePreviewView; +import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; import java.util.ArrayList; import java.util.List; @@ -51,7 +52,7 @@ class ImageContentPreviewUi extends ContentPreviewUi { private final CharSequence mText; private final ChooserContentPreviewUi.ActionFactory mActionFactory; private final ImageLoader mImageLoader; - private final ImagePreviewView.TransitionElementStatusCallback mTransitionElementStatusCallback; + private final TransitionElementStatusCallback mTransitionElementStatusCallback; private final FeatureFlagRepository mFeatureFlagRepository; ImageContentPreviewUi( @@ -59,7 +60,7 @@ class ImageContentPreviewUi extends ContentPreviewUi { @Nullable CharSequence text, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, - ImagePreviewView.TransitionElementStatusCallback transitionElementStatusCallback, + TransitionElementStatusCallback transitionElementStatusCallback, FeatureFlagRepository featureFlagRepository) { mImageUris = imageUris; mText = text; @@ -87,7 +88,7 @@ class ImageContentPreviewUi extends ContentPreviewUi { @LayoutRes int actionRowLayout = getActionRowLayout(mFeatureFlagRepository); ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_image, parent, false); - ImagePreviewView imagePreview = inflateImagePreviewView(contentPreviewLayout); + ChooserImagePreviewView imagePreview = inflateImagePreviewView(contentPreviewLayout); final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout); if (actionRow != null) { @@ -103,7 +104,7 @@ class ImageContentPreviewUi extends ContentPreviewUi { TAG, "Attempted to display image preview area with zero" + " available images detected in EXTRA_STREAM list"); - ((View) imagePreview).setVisibility(View.GONE); + imagePreview.setVisibility(View.GONE); mTransitionElementStatusCallback.onAllTransitionElementsReady(); return contentPreviewLayout; } @@ -129,14 +130,10 @@ class ImageContentPreviewUi extends ContentPreviewUi { return actions; } - private ImagePreviewView inflateImagePreviewView(ViewGroup previewLayout) { + private ChooserImagePreviewView inflateImagePreviewView(ViewGroup previewLayout) { ViewStub stub = previewLayout.findViewById(R.id.image_preview_stub); if (stub != null) { - int layoutId = - mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW) - ? R.layout.scrollable_image_preview_view - : R.layout.chooser_image_preview_view; - stub.setLayoutResource(layoutId); + stub.setLayoutResource(R.layout.chooser_image_preview_view); stub.inflate(); } return previewLayout.findViewById( diff --git a/java/src/com/android/intentresolver/contentpreview/MimeTypeClassifier.java b/java/src/com/android/intentresolver/contentpreview/MimeTypeClassifier.java new file mode 100644 index 00000000..5172dd29 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/MimeTypeClassifier.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview; + +import android.content.ClipDescription; + +import androidx.annotation.Nullable; + +/** + * Testing shim to specify whether a given mime type is considered to be an "image." + * + * TODO: move away from {@link ChooserActivityOverrideData} as a model to configure our tests, + * then migrate {@link com.android.intentresolver.ChooserActivity#isImageType(String)} into this + * class. + */ +public interface MimeTypeClassifier { + /** @return whether the specified {@code mimeType} is classified as an "image" type. */ + boolean isImageType(@Nullable String mimeType); + + /** @return whether the specified {@code mimeType} is classified as an "video" type */ + default boolean isVideoType(@Nullable String mimeType) { + return ClipDescription.compareMimeTypes(mimeType, "video/*"); + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java new file mode 100644 index 00000000..ee24d18f --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -0,0 +1,202 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview; + +import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_IMAGE; + +import android.content.res.Resources; +import android.text.TextUtils; +import android.text.util.Linkify; +import android.transition.TransitionManager; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewStub; +import android.widget.CheckBox; +import android.widget.TextView; + +import androidx.annotation.LayoutRes; +import androidx.annotation.Nullable; + +import com.android.intentresolver.ImageLoader; +import com.android.intentresolver.R; +import com.android.intentresolver.flags.FeatureFlagRepository; +import com.android.intentresolver.flags.Flags; +import com.android.intentresolver.widget.ActionRow; +import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; +import com.android.intentresolver.widget.ScrollableImagePreviewView; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; + +class UnifiedContentPreviewUi extends ContentPreviewUi { + private final List mFiles; + @Nullable + private final CharSequence mText; + private final ChooserContentPreviewUi.ActionFactory mActionFactory; + private final ImageLoader mImageLoader; + private final MimeTypeClassifier mTypeClassifier; + private final TransitionElementStatusCallback mTransitionElementStatusCallback; + private final FeatureFlagRepository mFeatureFlagRepository; + + UnifiedContentPreviewUi( + List files, + @Nullable CharSequence text, + ChooserContentPreviewUi.ActionFactory actionFactory, + ImageLoader imageLoader, + MimeTypeClassifier typeClassifier, + TransitionElementStatusCallback transitionElementStatusCallback, + FeatureFlagRepository featureFlagRepository) { + mFiles = files; + mText = text; + mActionFactory = actionFactory; + mImageLoader = imageLoader; + mTypeClassifier = typeClassifier; + mTransitionElementStatusCallback = transitionElementStatusCallback; + mFeatureFlagRepository = featureFlagRepository; + + mImageLoader.prePopulate(mFiles.stream() + .map(FileInfo::getPreviewUri) + .filter(Objects::nonNull) + .toList()); + } + + @Override + public int getType() { + return CONTENT_PREVIEW_IMAGE; + } + + @Override + public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { + ViewGroup layout = displayInternal(layoutInflater, parent); + displayPayloadReselectionAction(layout, mActionFactory, mFeatureFlagRepository); + return layout; + } + + private ViewGroup displayInternal(LayoutInflater layoutInflater, ViewGroup parent) { + @LayoutRes int actionRowLayout = getActionRowLayout(mFeatureFlagRepository); + ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( + R.layout.chooser_grid_preview_image, parent, false); + ScrollableImagePreviewView imagePreview = inflateImagePreviewView(contentPreviewLayout); + + final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout); + if (actionRow != null) { + actionRow.setActions( + createActions( + createImagePreviewActions(), + mActionFactory.createCustomActions(), + mFeatureFlagRepository)); + } + + if (mFiles.size() == 0) { + Log.i( + TAG, + "Attempted to display image preview area with zero" + + " available images detected in EXTRA_STREAM list"); + imagePreview.setVisibility(View.GONE); + mTransitionElementStatusCallback.onAllTransitionElementsReady(); + return contentPreviewLayout; + } + + setTextInImagePreviewVisibility(contentPreviewLayout, mActionFactory); + imagePreview.setTransitionElementStatusCallback(mTransitionElementStatusCallback); + List previews = mFiles.stream() + .filter(fileInfo -> fileInfo.getPreviewUri() != null) + .map(fileInfo -> + new ScrollableImagePreviewView.Preview( + getPreviewType(fileInfo.getMimeType()), + fileInfo.getPreviewUri())) + .toList(); + imagePreview.setPreviews( + previews, + mFiles.size() - previews.size(), + mImageLoader); + + return contentPreviewLayout; + } + + private List createImagePreviewActions() { + ArrayList actions = new ArrayList<>(2); + //TODO: add copy action; + ActionRow.Action action = mActionFactory.createNearbyButton(); + if (action != null) { + actions.add(action); + } + action = mActionFactory.createEditButton(); + if (action != null) { + actions.add(action); + } + return actions; + } + + private ScrollableImagePreviewView inflateImagePreviewView(ViewGroup previewLayout) { + ViewStub stub = previewLayout.findViewById(R.id.image_preview_stub); + if (stub != null) { + stub.setLayoutResource(R.layout.scrollable_image_preview_view); + stub.inflate(); + } + return previewLayout.findViewById( + com.android.internal.R.id.content_preview_image_area); + } + + private void setTextInImagePreviewVisibility( + ViewGroup contentPreview, ChooserContentPreviewUi.ActionFactory actionFactory) { + int visibility = mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW) + && !TextUtils.isEmpty(mText) + ? View.VISIBLE + : View.GONE; + + final TextView textView = contentPreview + .requireViewById(com.android.internal.R.id.content_preview_text); + CheckBox actionView = contentPreview + .requireViewById(R.id.include_text_action); + textView.setVisibility(visibility); + boolean isLink = visibility == View.VISIBLE && HttpUriMatcher.isHttpUri(mText.toString()); + textView.setAutoLinkMask(isLink ? Linkify.WEB_URLS : 0); + textView.setText(mText); + + if (visibility == View.VISIBLE) { + final int[] actionLabels = isLink + ? new int[] { R.string.include_link, R.string.exclude_link } + : new int[] { R.string.include_text, R.string.exclude_text }; + final Consumer shareTextAction = actionFactory.getExcludeSharedTextAction(); + actionView.setChecked(true); + actionView.setText(actionLabels[1]); + shareTextAction.accept(false); + actionView.setOnCheckedChangeListener((view, isChecked) -> { + view.setText(actionLabels[isChecked ? 1 : 0]); + TransitionManager.beginDelayedTransition((ViewGroup) textView.getParent()); + textView.setVisibility(isChecked ? View.VISIBLE : View.GONE); + shareTextAction.accept(!isChecked); + }); + } + actionView.setVisibility(visibility); + } + + private ScrollableImagePreviewView.PreviewType getPreviewType(String mimeType) { + if (mTypeClassifier.isImageType(mimeType)) { + return ScrollableImagePreviewView.PreviewType.Image; + } + if (mTypeClassifier.isVideoType(mimeType)) { + return ScrollableImagePreviewView.PreviewType.Video; + } + return ScrollableImagePreviewView.PreviewType.File; + } +} diff --git a/java/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt index ca94a95d..6273296d 100644 --- a/java/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt +++ b/java/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt @@ -74,7 +74,7 @@ class ChooserImagePreviewView : RelativeLayout, ImagePreviewView { transitionStatusElementCallback = callback } - override fun setImages(uris: List, imageLoader: ImageLoader) { + fun setImages(uris: List, imageLoader: ImageLoader) { loadImageJob?.cancel() loadImageJob = coroutineScope.launch { when (uris.size) { diff --git a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt index a166ef27..8813adca 100644 --- a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt +++ b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt @@ -23,7 +23,6 @@ internal typealias ImageLoader = suspend (Uri) -> Bitmap? interface ImagePreviewView { fun setTransitionElementStatusCallback(callback: TransitionElementStatusCallback?) - fun setImages(uris: List, imageLoader: ImageLoader) /** * [ImagePreviewView] progressively prepares views for shared element transition and reports diff --git a/java/src/com/android/intentresolver/widget/RoundedRectImageView.java b/java/src/com/android/intentresolver/widget/RoundedRectImageView.java index 8538041b..8ca6ed14 100644 --- a/java/src/com/android/intentresolver/widget/RoundedRectImageView.java +++ b/java/src/com/android/intentresolver/widget/RoundedRectImageView.java @@ -17,6 +17,7 @@ package com.android.intentresolver.widget; import android.content.Context; +import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; @@ -52,7 +53,17 @@ public class RoundedRectImageView extends ImageView { public RoundedRectImageView( Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); - mRadius = context.getResources().getDimensionPixelSize(R.dimen.chooser_corner_radius); + + final TypedArray a = context.obtainStyledAttributes( + attrs, + R.styleable.RoundedRectImageView, + defStyleAttr, + 0); + mRadius = a.getDimensionPixelSize(R.styleable.RoundedRectImageView_radius, -1); + if (mRadius < 0) { + mRadius = context.getResources().getDimensionPixelSize(R.dimen.chooser_corner_radius); + } + a.recycle(); mOverlayPaint.setColor(0x99000000); mOverlayPaint.setStyle(Paint.Style.FILL); diff --git a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt index 467c404a..c02a10a2 100644 --- a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt +++ b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt @@ -20,11 +20,13 @@ import android.content.Context import android.graphics.Rect import android.net.Uri import android.util.AttributeSet +import android.util.PluralsMessageFormatter import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ImageView +import android.widget.TextView import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.android.intentresolver.R @@ -33,11 +35,12 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import kotlinx.coroutines.cancel -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.plus +import kotlin.math.sign private const val TRANSITION_NAME = "screenshot_preview_image" +private const val PLURALS_COUNT = "count" class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { constructor(context: Context) : this(context, null) @@ -66,41 +69,64 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { previewAdapter.transitionStatusElementCallback = callback } - override fun setImages(uris: List, imageLoader: ImageLoader) { - previewAdapter.setImages(uris, imageLoader) + fun setPreviews(previews: List, otherItemCount: Int, imageLoader: ImageLoader) = + previewAdapter.setPreviews(previews, otherItemCount, imageLoader) + + class Preview(val type: PreviewType, val uri: Uri) + enum class PreviewType { + Image, Video, File } private class Adapter(private val context: Context) : RecyclerView.Adapter() { - private val uris = ArrayList() + private val previews = ArrayList() private var imageLoader: ImageLoader? = null + private var firstImagePos = -1 var transitionStatusElementCallback: TransitionElementStatusCallback? = null + private var otherItemCount = 0 - fun setImages(uris: List, imageLoader: ImageLoader) { - this.uris.clear() - this.uris.addAll(uris) + fun setPreviews( + previews: List, otherItemCount: Int, imageLoader: ImageLoader + ) { + this.previews.clear() + this.previews.addAll(previews) this.imageLoader = imageLoader + firstImagePos = previews.indexOfFirst { it.type == PreviewType.Image } + this.otherItemCount = maxOf(0, otherItemCount) notifyDataSetChanged() } override fun onCreateViewHolder(parent: ViewGroup, itemType: Int): ViewHolder { - return ViewHolder( - LayoutInflater.from(context) - .inflate(R.layout.image_preview_image_item, parent, false) - ) + val view = LayoutInflater.from(context).inflate(itemType, parent, false); + return if (itemType == R.layout.image_preview_other_item) { + OtherItemViewHolder(view) + } else { + PreviewViewHolder(view) + } } - override fun getItemCount(): Int = uris.size + override fun getItemCount(): Int = previews.size + otherItemCount.sign + + override fun getItemViewType(position: Int): Int { + return if (position == previews.size) { + R.layout.image_preview_other_item + } else { + R.layout.image_preview_image_item + } + } override fun onBindViewHolder(vh: ViewHolder, position: Int) { - vh.bind( - uris[position], - imageLoader ?: error("ImageLoader is missing"), - if (position == 0 && transitionStatusElementCallback != null) { - this::onTransitionElementReady - } else { - null - } - ) + when (vh) { + is OtherItemViewHolder -> vh.bind(otherItemCount) + is PreviewViewHolder -> vh.bind( + previews[position], + imageLoader ?: error("ImageLoader is missing"), + if (position == firstImagePos && transitionStatusElementCallback != null) { + this::onTransitionElementReady + } else { + null + } + ) + } } override fun onViewRecycled(vh: ViewHolder) { @@ -121,12 +147,18 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { } } - private class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + private sealed class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + abstract fun unbind() + } + + private class PreviewViewHolder(view: View) : ViewHolder(view) { private val image = view.requireViewById(R.id.image) + private val badgeFrame = view.requireViewById(R.id.badge_frame) + private val badge = view.requireViewById(R.id.badge) private var scope: CoroutineScope? = null fun bind( - uri: Uri, + preview: Preview, imageLoader: ImageLoader, previewReadyCallback: ((String) -> Unit)? ) { @@ -136,26 +168,35 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { } else { null } + badgeFrame.visibility = when (preview.type) { + PreviewType.Image -> View.GONE + PreviewType.Video -> { + badge.setImageResource(R.drawable.ic_file_video) + View.VISIBLE + } + else -> { + badge.setImageResource(R.drawable.chooser_file_generic) + View.VISIBLE + } + } resetScope().launch { - loadImage(uri, imageLoader, previewReadyCallback) + loadImage(preview.uri, imageLoader) + if (preview.type == PreviewType.Image) { + previewReadyCallback?.let { callback -> + image.waitForPreDraw() + callback(TRANSITION_NAME) + } + } } } - private suspend fun loadImage( - uri: Uri, - imageLoader: ImageLoader, - previewReadyCallback: ((String) -> Unit)? - ) { + private suspend fun loadImage(uri: Uri, imageLoader: ImageLoader) { val bitmap = runCatching { // it's expected for all loading/caching optimizations to be implemented by the // loader imageLoader(uri) }.getOrNull() image.setImageBitmap(bitmap) - previewReadyCallback?.let { callback -> - image.waitForPreDraw() - callback(TRANSITION_NAME) - } } private fun resetScope(): CoroutineScope = @@ -164,13 +205,27 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { scope = it } - fun unbind() { + override fun unbind() { scope?.cancel() scope = null } } - private class SpacingDecoration(private val margin: Int) : RecyclerView.ItemDecoration() { + private class OtherItemViewHolder(view: View) : ViewHolder(view) { + private val label = view.requireViewById(R.id.label) + + fun bind(count: Int) { + label.text = PluralsMessageFormatter.format( + itemView.context.resources, + mapOf(PLURALS_COUNT to count), + R.string.other_files + ) + } + + override fun unbind() = Unit + } + + private class SpacingDecoration(private val margin: Int) : ItemDecoration() { override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: State) { outRect.set(margin, 0, margin, 0) } diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt index d870a8c2..ba89c367 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt @@ -39,7 +39,7 @@ import java.util.function.Consumer private const val PROVIDER_NAME = "org.pkg.app" class ChooserContentPreviewUiTest { private val contentResolver = mock() - private val imageClassifier = ChooserContentPreviewUi.ImageMimeTypeClassifier { mimeType -> + private val imageClassifier = MimeTypeClassifier { mimeType -> mimeType != null && ClipDescription.compareMimeTypes(mimeType, "image/*") } private val imageLoader = object : ImageLoader { @@ -123,7 +123,7 @@ class ChooserContentPreviewUiTest { } @Test - fun test_ChooserContentPreview_single_non_image_uri_to_file_preview() { + fun test_ChooserContentPreview_single_uri_without_preview_to_file_preview() { val uri = Uri.parse("content://$PROVIDER_NAME/test.pdf") val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) @@ -143,6 +143,29 @@ class ChooserContentPreviewUiTest { verify(transitionCallback, times(1)).onAllTransitionElementsReady() } + @Test + fun test_ChooserContentPreview_single_uri_with_preview_to_image_preview() { + val uri = Uri.parse("content://$PROVIDER_NAME/test.pdf") + val targetIntent = Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_STREAM, uri) + } + whenever(contentResolver.getType(uri)).thenReturn("application/pdf") + whenever(contentResolver.getStreamTypes(uri, "*/*")) + .thenReturn(arrayOf("application/pdf", "image/png")) + val testSubject = ChooserContentPreviewUi( + targetIntent, + contentResolver, + imageClassifier, + imageLoader, + actionFactory, + transitionCallback, + featureFlagRepository + ) + assertThat(testSubject.preferredContentPreview) + .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) + verify(transitionCallback, never()).onAllTransitionElementsReady() + } + @Test fun test_ChooserContentPreview_multiple_image_uri_to_image_preview() { val uri1 = Uri.parse("content://$PROVIDER_NAME/test.png") @@ -173,7 +196,7 @@ class ChooserContentPreviewUiTest { } @Test - fun test_ChooserContentPreview_some_non_image_uri_to_file_preview() { + fun test_ChooserContentPreview_some_non_image_uri_to_image_preview() { val uri1 = Uri.parse("content://$PROVIDER_NAME/test.png") val uri2 = Uri.parse("content://$PROVIDER_NAME/test.pdf") val targetIntent = Intent(Intent.ACTION_SEND_MULTIPLE).apply { @@ -197,7 +220,67 @@ class ChooserContentPreviewUiTest { featureFlagRepository ) assertThat(testSubject.preferredContentPreview) - .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) + .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) + verify(transitionCallback, never()).onAllTransitionElementsReady() + } + + @Test + fun test_ChooserContentPreview_some_non_image_uri_with_preview_to_image_preview() { + val uri1 = Uri.parse("content://$PROVIDER_NAME/test.mp4") + val uri2 = Uri.parse("content://$PROVIDER_NAME/test.pdf") + val targetIntent = Intent(Intent.ACTION_SEND_MULTIPLE).apply { + putExtra( + Intent.EXTRA_STREAM, + ArrayList().apply { + add(uri1) + add(uri2) + } + ) + } + whenever(contentResolver.getType(uri1)).thenReturn("video/mpeg4") + whenever(contentResolver.getStreamTypes(uri1, "*/*")) + .thenReturn(arrayOf("image/png")) + whenever(contentResolver.getType(uri2)).thenReturn("application/pdf") + val testSubject = ChooserContentPreviewUi( + targetIntent, + contentResolver, + imageClassifier, + imageLoader, + actionFactory, + transitionCallback, + featureFlagRepository + ) + assertThat(testSubject.preferredContentPreview) + .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) + verify(transitionCallback, never()).onAllTransitionElementsReady() + } + + @Test + fun test_ChooserContentPreview_all_non_image_uris_without_preview_to_file_preview() { + val uri1 = Uri.parse("content://$PROVIDER_NAME/test.html") + val uri2 = Uri.parse("content://$PROVIDER_NAME/test.pdf") + val targetIntent = Intent(Intent.ACTION_SEND_MULTIPLE).apply { + putExtra( + Intent.EXTRA_STREAM, + ArrayList().apply { + add(uri1) + add(uri2) + } + ) + } + whenever(contentResolver.getType(uri1)).thenReturn("text/html") + whenever(contentResolver.getType(uri2)).thenReturn("application/pdf") + val testSubject = ChooserContentPreviewUi( + targetIntent, + contentResolver, + imageClassifier, + imageLoader, + actionFactory, + transitionCallback, + featureFlagRepository + ) + assertThat(testSubject.preferredContentPreview) + .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) verify(transitionCallback, times(1)).onAllTransitionElementsReady() } } -- cgit v1.2.3-59-g8ed1b From 9f90b598713528b5a216c771acee7753ab10d953 Mon Sep 17 00:00:00 2001 From: Govinda Wasserman Date: Thu, 16 Mar 2023 13:22:09 -0400 Subject: Adds a reciever for pin migration data The receiver can only be contacted by a sender with the ADD_CHOOSER_PINS permission to prevent a 3rd party from abusing the feature to inject a pin. Test: atest ChooserPinMigrationReceiverTest BUG: 223249318 Change-Id: I3d796b40c50b64848f86398e532c3e18b0937e61 --- AndroidManifest.xml | 10 ++- .../intentresolver/ChooserPinMigrationReceiver.kt | 55 +++++++++++++++ .../ChooserPinMigrationReceiverTest.kt | 78 ++++++++++++++++++++++ 3 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 java/src/com/android/intentresolver/ChooserPinMigrationReceiver.kt create mode 100644 java/tests/src/com/android/intentresolver/ChooserPinMigrationReceiverTest.kt (limited to 'java/tests') diff --git a/AndroidManifest.xml b/AndroidManifest.xml index fc99c0b2..a3acc8a6 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -22,7 +22,6 @@ android:versionName="2021-11" coreApp="true"> - @@ -32,6 +31,7 @@ + @@ -71,6 +71,14 @@ + + + + + + diff --git a/java/src/com/android/intentresolver/ChooserPinMigrationReceiver.kt b/java/src/com/android/intentresolver/ChooserPinMigrationReceiver.kt new file mode 100644 index 00000000..a3ba2192 --- /dev/null +++ b/java/src/com/android/intentresolver/ChooserPinMigrationReceiver.kt @@ -0,0 +1,55 @@ +/* + * 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 + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.util.Log + +/** + * Broadcast receiver for receiving Chooser pin data from the legacy chooser. + * + * Unions the legacy pins with any existing pins. This receiver is protected by the ADD_CHOOSER_PINS + * permission. The receiver is required to have the RECEIVE_CHOOSER_PIN_MIGRATION to receive the + * broadcast. + */ +class ChooserPinMigrationReceiver( + private val pinnedSharedPrefsProvider: (Context) -> SharedPreferences = + { context -> ChooserActivity.getPinnedSharedPrefs(context) }, +) : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + val bundle = intent.extras ?: return + Log.i(TAG, "Starting migration") + + val prefsEditor = pinnedSharedPrefsProvider.invoke(context).edit() + bundle.keySet().forEach { key -> + if(bundle.getBoolean(key)) { + prefsEditor.putBoolean(key, true) + } + } + prefsEditor.apply() + + Log.i(TAG, "Migration complete") + } + + companion object { + private const val TAG = "ChooserPinMigrationReceiver" + } +} \ No newline at end of file diff --git a/java/tests/src/com/android/intentresolver/ChooserPinMigrationReceiverTest.kt b/java/tests/src/com/android/intentresolver/ChooserPinMigrationReceiverTest.kt new file mode 100644 index 00000000..1daee137 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/ChooserPinMigrationReceiverTest.kt @@ -0,0 +1,78 @@ +/* + * 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 + +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoMoreInteractions +import org.mockito.MockitoAnnotations + +@RunWith(AndroidJUnit4::class) +class ChooserPinMigrationReceiverTest { + + private lateinit var chooserPinMigrationReceiver: ChooserPinMigrationReceiver + + @Mock private lateinit var mockContext: Context + @Mock private lateinit var mockSharedPreferences: SharedPreferences + @Mock private lateinit var mockSharedPreferencesEditor: SharedPreferences.Editor + + @Before + fun setup() { + MockitoAnnotations.initMocks(this) + + whenever(mockSharedPreferences.edit()).thenReturn(mockSharedPreferencesEditor) + + chooserPinMigrationReceiver = ChooserPinMigrationReceiver { mockSharedPreferences } + } + + @Test + fun onReceive_addsReceivedPins() { + // Arrange + val intent = Intent().apply { + putExtra("TestPackage/TestClass", true) + } + + // Act + chooserPinMigrationReceiver.onReceive(mockContext, intent) + + // Assert + verify(mockSharedPreferencesEditor).putBoolean(eq("TestPackage/TestClass"), eq(true)) + verify(mockSharedPreferencesEditor).apply() + } + + @Test + fun onReceive_ignoresUnpinnedEntries() { + // Arrange + val intent = Intent().apply { + putExtra("TestPackage/TestClass", false) + } + + // Act + chooserPinMigrationReceiver.onReceive(mockContext, intent) + + // Assert + verify(mockSharedPreferencesEditor).apply() + verifyNoMoreInteractions(mockSharedPreferencesEditor) + } +} \ No newline at end of file -- cgit v1.2.3-59-g8ed1b From 19b5edd72865206184068604804d5eebf83f896d Mon Sep 17 00:00:00 2001 From: 1 Date: Sat, 18 Mar 2023 14:45:17 +0000 Subject: Add headline to sharesheet. Move modify share UI to upper right. Bug: 273788379 Bug: 271159515 Test: atest IntentResolverUnitTests Change-Id: Ia1099c60a4bf9034b6a34b48a948ca1b5280a9aa --- java/res/layout/chooser_grid_preview_file.xml | 10 +-- java/res/layout/chooser_grid_preview_image.xml | 16 +--- java/res/layout/chooser_grid_preview_text.xml | 10 +-- java/res/layout/chooser_headline_row.xml | 57 ++++++++++++ java/res/values/strings.xml | 30 +++++++ .../android/intentresolver/ChooserActivity.java | 4 +- .../contentpreview/ChooserContentPreviewUi.java | 42 ++++++--- .../contentpreview/ContentPreviewUi.java | 17 +++- .../contentpreview/FileContentPreviewUi.java | 9 +- .../contentpreview/HeadlineGenerator.kt | 35 ++++++++ .../contentpreview/HeadlineGeneratorImpl.kt | 67 ++++++++++++++ .../contentpreview/ImageContentPreviewUi.java | 21 ++++- .../contentpreview/TextContentPreviewUi.java | 9 +- .../contentpreview/UnifiedContentPreviewUi.java | 100 ++++++++++++++------- .../UnbundledChooserActivityTest.java | 39 ++++++++ .../contentpreview/ChooserContentPreviewUiTest.kt | 28 ++++-- .../contentpreview/HeadlineGeneratorImplTest.kt | 49 ++++++++++ 17 files changed, 450 insertions(+), 93 deletions(-) create mode 100644 java/res/layout/chooser_headline_row.xml create mode 100644 java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt create mode 100644 java/tests/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt (limited to 'java/tests') diff --git a/java/res/layout/chooser_grid_preview_file.xml b/java/res/layout/chooser_grid_preview_file.xml index 6ba06b3d..036c5318 100644 --- a/java/res/layout/chooser_grid_preview_file.xml +++ b/java/res/layout/chooser_grid_preview_file.xml @@ -27,6 +27,8 @@ android:paddingBottom="@dimen/chooser_view_spacing" android:background="?android:attr/colorBackground"> + + - - - + - - + + - - + + + + + + + + + + + diff --git a/java/res/values/strings.xml b/java/res/values/strings.xml index d1c97c7e..f38666e4 100644 --- a/java/res/values/strings.xml +++ b/java/res/values/strings.xml @@ -137,6 +137,36 @@ } + + Sharing text + + Sharing link + + {count, plural, + =1 {Sharing image} + other {Sharing # images} + } + + + {count, plural, + =1 {Sharing video} + other {Sharing # videos} + } + + + {count, plural, + =1 {Sharing # item} + other {Sharing # items} + } + + + Sharing image with text + + Sharing image with link + No recommended people to share with diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 2a73c42a..1419bca7 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -84,6 +84,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.contentpreview.HeadlineGeneratorImpl; import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.flags.FeatureFlagRepositoryFactory; import com.android.intentresolver.flags.Flags; @@ -291,7 +292,8 @@ public class ChooserActivity extends ResolverActivity implements createPreviewImageLoader(), createChooserActionFactory(), mEnterTransitionAnimationDelegate, - mFeatureFlagRepository); + mFeatureFlagRepository, + new HeadlineGeneratorImpl(this)); setAdditionalTargets(mChooserRequest.getAdditionalTargets()); diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index 08cebf68..047a10fd 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -103,7 +103,8 @@ public final class ChooserContentPreviewUi { ImageLoader imageLoader, ActionFactory actionFactory, TransitionElementStatusCallback transitionElementStatusCallback, - FeatureFlagRepository featureFlagRepository) { + FeatureFlagRepository featureFlagRepository, + HeadlineGenerator headlineGenerator) { mContentPreviewUi = createContentPreview( targetIntent, @@ -112,7 +113,8 @@ public final class ChooserContentPreviewUi { imageLoader, actionFactory, transitionElementStatusCallback, - featureFlagRepository); + featureFlagRepository, + headlineGenerator); if (mContentPreviewUi.getType() != CONTENT_PREVIEW_IMAGE) { transitionElementStatusCallback.onAllTransitionElementsReady(); } @@ -125,7 +127,8 @@ public final class ChooserContentPreviewUi { ImageLoader imageLoader, ActionFactory actionFactory, TransitionElementStatusCallback transitionElementStatusCallback, - FeatureFlagRepository featureFlagRepository) { + FeatureFlagRepository featureFlagRepository, + HeadlineGenerator headlineGenerator) { /* 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 @@ -139,12 +142,20 @@ public final class ChooserContentPreviewUi { if (!(isSend || isSendMultiple) || (type != null && ClipDescription.compareMimeTypes(type, "text/*"))) { return createTextPreview( - targetIntent, actionFactory, imageLoader, featureFlagRepository); + targetIntent, + actionFactory, + imageLoader, + featureFlagRepository, + headlineGenerator); } List uris = extractContentUris(targetIntent); if (uris.isEmpty()) { return createTextPreview( - targetIntent, actionFactory, imageLoader, featureFlagRepository); + targetIntent, + actionFactory, + imageLoader, + featureFlagRepository, + headlineGenerator); } ArrayList files = new ArrayList<>(uris.size()); int previewCount = readFileInfo(contentResolver, typeClassifier, uris, files); @@ -153,7 +164,8 @@ public final class ChooserContentPreviewUi { files, actionFactory, imageLoader, - featureFlagRepository); + featureFlagRepository, + headlineGenerator); } if (featureFlagRepository.isEnabled(Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW)) { return new UnifiedContentPreviewUi( @@ -163,14 +175,16 @@ public final class ChooserContentPreviewUi { imageLoader, typeClassifier, transitionElementStatusCallback, - featureFlagRepository); + featureFlagRepository, + headlineGenerator); } if (previewCount < uris.size()) { return new FileContentPreviewUi( files, actionFactory, imageLoader, - featureFlagRepository); + featureFlagRepository, + headlineGenerator); } // The legacy (3-image) image preview is on it's way out and it's unlikely that we'd end up // here. To preserve the legacy behavior, before using it, check that all uris are images. @@ -180,7 +194,8 @@ public final class ChooserContentPreviewUi { files, actionFactory, imageLoader, - featureFlagRepository); + featureFlagRepository, + headlineGenerator); } } return new ImageContentPreviewUi( @@ -192,7 +207,8 @@ public final class ChooserContentPreviewUi { actionFactory, imageLoader, transitionElementStatusCallback, - featureFlagRepository); + featureFlagRepository, + headlineGenerator); } public int getPreferredContentPreview() { @@ -307,7 +323,8 @@ public final class ChooserContentPreviewUi { Intent targetIntent, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, - FeatureFlagRepository featureFlagRepository) { + FeatureFlagRepository featureFlagRepository, + HeadlineGenerator headlineGenerator) { CharSequence sharingText = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); String previewTitle = targetIntent.getStringExtra(Intent.EXTRA_TITLE); ClipData previewData = targetIntent.getClipData(); @@ -324,7 +341,8 @@ public final class ChooserContentPreviewUi { previewThumbnail, actionFactory, imageLoader, - featureFlagRepository); + featureFlagRepository, + headlineGenerator); } private static List extractContentUris(Intent targetIntent) { diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java index 79444b4e..a6bc2164 100644 --- a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java @@ -24,6 +24,7 @@ import android.content.res.Resources; import android.graphics.Bitmap; import android.net.Uri; import android.os.UserHandle; +import android.text.TextUtils; import android.util.Log; import android.view.LayoutInflater; import android.view.View; @@ -114,7 +115,21 @@ abstract class ContentPreviewUi { fadeAnim.start(); } - protected static void displayPayloadReselectionAction( + protected static void displayHeadline(ViewGroup layout, String headline) { + if (layout != null) { + TextView titleView = layout.findViewById(R.id.headline); + if (titleView != null) { + if (!TextUtils.isEmpty(headline)) { + titleView.setText(headline); + titleView.setVisibility(View.VISIBLE); + } else { + titleView.setVisibility(View.GONE); + } + } + } + } + + protected static void displayModifyShareAction( ViewGroup layout, ChooserContentPreviewUi.ActionFactory actionFactory, FeatureFlagRepository featureFlagRepository) { diff --git a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java index 2c5def8b..52e20cf0 100644 --- a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java @@ -45,16 +45,19 @@ class FileContentPreviewUi extends ContentPreviewUi { private final ChooserContentPreviewUi.ActionFactory mActionFactory; private final ImageLoader mImageLoader; private final FeatureFlagRepository mFeatureFlagRepository; + private final HeadlineGenerator mHeadlineGenerator; FileContentPreviewUi( List files, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, - FeatureFlagRepository featureFlagRepository) { + FeatureFlagRepository featureFlagRepository, + HeadlineGenerator headlineGenerator) { mFiles = files; mActionFactory = actionFactory; mImageLoader = imageLoader; mFeatureFlagRepository = featureFlagRepository; + mHeadlineGenerator = headlineGenerator; } @Override @@ -65,7 +68,7 @@ class FileContentPreviewUi extends ContentPreviewUi { @Override public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { ViewGroup layout = displayInternal(resources, layoutInflater, parent); - displayPayloadReselectionAction(layout, mActionFactory, mFeatureFlagRepository); + displayModifyShareAction(layout, mActionFactory, mFeatureFlagRepository); return layout; } @@ -77,6 +80,8 @@ class FileContentPreviewUi extends ContentPreviewUi { final int uriCount = mFiles.size(); + displayHeadline(contentPreviewLayout, mHeadlineGenerator.getItemsHeadline(mFiles.size())); + if (uriCount == 0) { contentPreviewLayout.setVisibility(View.GONE); Log.i(TAG, "Appears to be no uris available in EXTRA_STREAM," diff --git a/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt b/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt new file mode 100644 index 00000000..e32bb5c4 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt @@ -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 + +private const val PLURALS_COUNT = "count" + +/** + * HeadlineGenerator generates the text to show at the top of the sharesheet as a brief + * description of the content being shared. + */ +interface HeadlineGenerator { + fun getTextHeadline(text: CharSequence): String + + fun getImageWithTextHeadline(text: CharSequence): String + + fun getImagesHeadline(count: Int): String + + fun getVideosHeadline(count: Int): String + + fun getItemsHeadline(count: Int): String +} diff --git a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt new file mode 100644 index 00000000..ae44294c --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt @@ -0,0 +1,67 @@ +/* + * 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.Context +import com.android.intentresolver.R +import android.util.PluralsMessageFormatter + +private const val PLURALS_COUNT = "count" + +/** + * HeadlineGenerator generates the text to show at the top of the sharesheet as a brief + * description of the content being shared. + */ +class HeadlineGeneratorImpl(private val context: Context) : HeadlineGenerator { + override fun getTextHeadline(text: CharSequence): String { + if (text.toString().isHttpUri()) { + return context.getString(R.string.sharing_link) + } + return context.getString(R.string.sharing_text) + } + + override fun getImageWithTextHeadline(text: CharSequence): String { + if (text.toString().isHttpUri()) { + return context.getString(R.string.sharing_image_with_link) + } + return context.getString(R.string.sharing_image_with_text) + } + + override fun getImagesHeadline(count: Int): String { + return PluralsMessageFormatter.format( + context.resources, + mapOf(PLURALS_COUNT to count), + R.string.sharing_images + ) + } + + override fun getVideosHeadline(count: Int): String { + return PluralsMessageFormatter.format( + context.resources, + mapOf(PLURALS_COUNT to count), + R.string.sharing_videos + ) + } + + override fun getItemsHeadline(count: Int): String { + return PluralsMessageFormatter.format( + context.resources, + mapOf(PLURALS_COUNT to count), + R.string.sharing_items + ) + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java index 5f3bdf40..f2c0564a 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java @@ -54,6 +54,7 @@ class ImageContentPreviewUi extends ContentPreviewUi { private final ImageLoader mImageLoader; private final TransitionElementStatusCallback mTransitionElementStatusCallback; private final FeatureFlagRepository mFeatureFlagRepository; + private final HeadlineGenerator mHeadlineGenerator; ImageContentPreviewUi( List imageUris, @@ -61,13 +62,15 @@ class ImageContentPreviewUi extends ContentPreviewUi { ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, TransitionElementStatusCallback transitionElementStatusCallback, - FeatureFlagRepository featureFlagRepository) { + FeatureFlagRepository featureFlagRepository, + HeadlineGenerator headlineGenerator) { mImageUris = imageUris; mText = text; mActionFactory = actionFactory; mImageLoader = imageLoader; mTransitionElementStatusCallback = transitionElementStatusCallback; mFeatureFlagRepository = featureFlagRepository; + mHeadlineGenerator = headlineGenerator; mImageLoader.prePopulate(mImageUris); } @@ -80,7 +83,7 @@ class ImageContentPreviewUi extends ContentPreviewUi { @Override public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { ViewGroup layout = displayInternal(layoutInflater, parent); - displayPayloadReselectionAction(layout, mActionFactory, mFeatureFlagRepository); + displayModifyShareAction(layout, mActionFactory, mFeatureFlagRepository); return layout; } @@ -113,6 +116,8 @@ class ImageContentPreviewUi extends ContentPreviewUi { imagePreview.setTransitionElementStatusCallback(mTransitionElementStatusCallback); imagePreview.setImages(mImageUris, mImageLoader); + updateHeadline(contentPreviewLayout); + return contentPreviewLayout; } @@ -140,6 +145,17 @@ class ImageContentPreviewUi extends ContentPreviewUi { com.android.internal.R.id.content_preview_image_area); } + private void updateHeadline(ViewGroup contentPreview) { + CheckBox includeTextCheckbox = contentPreview.requireViewById(R.id.include_text_action); + if (includeTextCheckbox.getVisibility() == View.VISIBLE + && includeTextCheckbox.isChecked()) { + displayHeadline(contentPreview, mHeadlineGenerator.getImageWithTextHeadline(mText)); + } else { + displayHeadline( + contentPreview, mHeadlineGenerator.getImagesHeadline(mImageUris.size())); + } + } + private void setTextInImagePreviewVisibility( ViewGroup contentPreview, ChooserContentPreviewUi.ActionFactory actionFactory) { int visibility = mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW) @@ -169,6 +185,7 @@ class ImageContentPreviewUi extends ContentPreviewUi { TransitionManager.beginDelayedTransition((ViewGroup) textView.getParent()); textView.setVisibility(isChecked ? View.VISIBLE : View.GONE); shareTextAction.accept(!isChecked); + updateHeadline(contentPreview); }); } actionView.setVisibility(visibility); diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java index e143954e..d0cba5bb 100644 --- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java @@ -46,6 +46,7 @@ class TextContentPreviewUi extends ContentPreviewUi { private final ImageLoader mImageLoader; private final ChooserContentPreviewUi.ActionFactory mActionFactory; private final FeatureFlagRepository mFeatureFlagRepository; + private final HeadlineGenerator mHeadlineGenerator; TextContentPreviewUi( @Nullable CharSequence sharingText, @@ -53,13 +54,15 @@ class TextContentPreviewUi extends ContentPreviewUi { @Nullable Uri previewThumbnail, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, - FeatureFlagRepository featureFlagRepository) { + FeatureFlagRepository featureFlagRepository, + HeadlineGenerator headlineGenerator) { mSharingText = sharingText; mPreviewTitle = previewTitle; mPreviewThumbnail = previewThumbnail; mImageLoader = imageLoader; mActionFactory = actionFactory; mFeatureFlagRepository = featureFlagRepository; + mHeadlineGenerator = headlineGenerator; } @Override @@ -70,7 +73,7 @@ class TextContentPreviewUi extends ContentPreviewUi { @Override public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { ViewGroup layout = displayInternal(layoutInflater, parent); - displayPayloadReselectionAction(layout, mActionFactory, mFeatureFlagRepository); + displayModifyShareAction(layout, mActionFactory, mFeatureFlagRepository); return layout; } @@ -122,6 +125,8 @@ class TextContentPreviewUi extends ContentPreviewUi { bitmap)); } + displayHeadline(contentPreviewLayout, mHeadlineGenerator.getTextHeadline(mSharingText)); + return contentPreviewLayout; } diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java index ee24d18f..b9c64c6f 100644 --- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -55,6 +55,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { private final MimeTypeClassifier mTypeClassifier; private final TransitionElementStatusCallback mTransitionElementStatusCallback; private final FeatureFlagRepository mFeatureFlagRepository; + private final HeadlineGenerator mHeadlineGenerator; UnifiedContentPreviewUi( List files, @@ -63,7 +64,8 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { ImageLoader imageLoader, MimeTypeClassifier typeClassifier, TransitionElementStatusCallback transitionElementStatusCallback, - FeatureFlagRepository featureFlagRepository) { + FeatureFlagRepository featureFlagRepository, + HeadlineGenerator headlineGenerator) { mFiles = files; mText = text; mActionFactory = actionFactory; @@ -71,6 +73,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { mTypeClassifier = typeClassifier; mTransitionElementStatusCallback = transitionElementStatusCallback; mFeatureFlagRepository = featureFlagRepository; + mHeadlineGenerator = headlineGenerator; mImageLoader.prePopulate(mFiles.stream() .map(FileInfo::getPreviewUri) @@ -86,7 +89,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { @Override public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { ViewGroup layout = displayInternal(layoutInflater, parent); - displayPayloadReselectionAction(layout, mActionFactory, mFeatureFlagRepository); + displayModifyShareAction(layout, mActionFactory, mFeatureFlagRepository); return layout; } @@ -115,20 +118,47 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { return contentPreviewLayout; } - setTextInImagePreviewVisibility(contentPreviewLayout, mActionFactory); imagePreview.setTransitionElementStatusCallback(mTransitionElementStatusCallback); - List previews = mFiles.stream() - .filter(fileInfo -> fileInfo.getPreviewUri() != null) - .map(fileInfo -> - new ScrollableImagePreviewView.Preview( - getPreviewType(fileInfo.getMimeType()), - fileInfo.getPreviewUri())) - .toList(); + + List previews = new ArrayList<>(); + boolean allImages = !mFiles.isEmpty(); + boolean allVideos = !mFiles.isEmpty(); + for (FileInfo fileInfo : mFiles) { + ScrollableImagePreviewView.PreviewType previewType = + getPreviewType(fileInfo.getMimeType()); + allImages = allImages && previewType == ScrollableImagePreviewView.PreviewType.Image; + allVideos = allVideos && previewType == ScrollableImagePreviewView.PreviewType.Video; + + if (fileInfo.getPreviewUri() != null) { + previews.add(new ScrollableImagePreviewView.Preview( + previewType, + fileInfo.getPreviewUri())); + } + } imagePreview.setPreviews( previews, mFiles.size() - previews.size(), mImageLoader); + if (mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW) + && !TextUtils.isEmpty(mText) + && mFiles.size() == 1 + && allImages) { + setTextInImagePreviewVisibility(contentPreviewLayout, mActionFactory); + updateTextWithImageHeadline(contentPreviewLayout); + } else { + if (allImages) { + displayHeadline( + contentPreviewLayout, mHeadlineGenerator.getImagesHeadline(mFiles.size())); + } else if (allVideos) { + displayHeadline( + contentPreviewLayout, mHeadlineGenerator.getVideosHeadline(mFiles.size())); + } else { + displayHeadline( + contentPreviewLayout, mHeadlineGenerator.getItemsHeadline(mFiles.size())); + } + } + return contentPreviewLayout; } @@ -156,38 +186,42 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { com.android.internal.R.id.content_preview_image_area); } + private void updateTextWithImageHeadline(ViewGroup contentPreview) { + CheckBox actionView = contentPreview.requireViewById(R.id.include_text_action); + if (actionView.getVisibility() == View.VISIBLE && actionView.isChecked()) { + displayHeadline(contentPreview, mHeadlineGenerator.getImageWithTextHeadline(mText)); + } else { + displayHeadline( + contentPreview, mHeadlineGenerator.getImagesHeadline(mFiles.size())); + } + } + 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.setVisibility(View.VISIBLE); + boolean isLink = HttpUriMatcher.isHttpUri(mText.toString()); textView.setAutoLinkMask(isLink ? Linkify.WEB_URLS : 0); textView.setText(mText); - if (visibility == View.VISIBLE) { - final int[] actionLabels = isLink - ? new int[] { R.string.include_link, R.string.exclude_link } - : new int[] { R.string.include_text, R.string.exclude_text }; - final Consumer shareTextAction = actionFactory.getExcludeSharedTextAction(); - actionView.setChecked(true); - actionView.setText(actionLabels[1]); - shareTextAction.accept(false); - actionView.setOnCheckedChangeListener((view, isChecked) -> { - view.setText(actionLabels[isChecked ? 1 : 0]); - TransitionManager.beginDelayedTransition((ViewGroup) textView.getParent()); - textView.setVisibility(isChecked ? View.VISIBLE : View.GONE); - shareTextAction.accept(!isChecked); - }); - } - actionView.setVisibility(visibility); + final int[] actionLabels = isLink + ? new int[] { R.string.include_link, R.string.exclude_link } + : new int[] { R.string.include_text, R.string.exclude_text }; + final Consumer shareTextAction = actionFactory.getExcludeSharedTextAction(); + actionView.setChecked(true); + actionView.setText(actionLabels[1]); + shareTextAction.accept(false); + actionView.setOnCheckedChangeListener((view, isChecked) -> { + view.setText(actionLabels[isChecked ? 1 : 0]); + TransitionManager.beginDelayedTransition((ViewGroup) textView.getParent()); + textView.setVisibility(isChecked ? View.VISIBLE : View.GONE); + shareTextAction.accept(!isChecked); + updateTextWithImageHeadline(contentPreview); + }); + actionView.setVisibility(View.VISIBLE); } private ScrollableImagePreviewView.PreviewType getPreviewType(String mimeType) { diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 596b546e..943584a2 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -26,6 +26,7 @@ import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist; import static androidx.test.espresso.assertion.ViewAssertions.matches; import static androidx.test.espresso.matcher.ViewMatchers.hasSibling; import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withText; @@ -98,6 +99,7 @@ import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.test.espresso.contrib.RecyclerViewActions; import androidx.test.espresso.matcher.BoundedDiagnosingMatcher; +import androidx.test.espresso.matcher.ViewMatchers; import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.rule.ActivityTestRule; @@ -1067,6 +1069,43 @@ public class UnbundledChooserActivityTest { .check(matches(isDisplayed())); } + @Test + @RequireFeatureFlags( + flags = { Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME }, + values = { true }) + public void testNoTextPreviewWhenTextIsSharedWithMultipleImages() { + final Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" + + R.drawable.test320x240); + final String sharedText = "text-" + System.currentTimeMillis(); + + ArrayList uris = new ArrayList<>(); + uris.add(uri); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); + ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); + ChooserActivityOverrideData.getInstance().isImageType = true; + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntentAsUser( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class), + Mockito.any(UserHandle.class))) + .thenReturn(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(com.android.internal.R.id.content_preview_text)) + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))); + } + @Test public void testOnCreateLogging() { Intent sendIntent = createSendTextIntent(); diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt index 04a136b4..3a00f1f3 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt @@ -42,6 +42,7 @@ class ChooserContentPreviewUiTest { private val imageClassifier = MimeTypeClassifier { mimeType -> mimeType != null && ClipDescription.compareMimeTypes(mimeType, "image/*") } + private val headlineGenerator = mock() private val imageLoader = object : ImageLoader { override fun loadImage(uri: Uri, callback: Consumer) { callback.accept(null) @@ -74,7 +75,8 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository + featureFlagRepository, + headlineGenerator ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) @@ -94,7 +96,8 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository + featureFlagRepository, + headlineGenerator ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) @@ -115,7 +118,8 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository + featureFlagRepository, + headlineGenerator ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) @@ -136,7 +140,8 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository + featureFlagRepository, + headlineGenerator ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) @@ -159,7 +164,8 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository + featureFlagRepository, + headlineGenerator ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) @@ -188,7 +194,8 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository + featureFlagRepository, + headlineGenerator ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) @@ -217,7 +224,8 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository + featureFlagRepository, + headlineGenerator ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) @@ -248,7 +256,8 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository + featureFlagRepository, + headlineGenerator ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) @@ -277,7 +286,8 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository + featureFlagRepository, + headlineGenerator ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) diff --git a/java/tests/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt new file mode 100644 index 00000000..9becce99 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt @@ -0,0 +1,49 @@ +/* + * 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 androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Test +import org.junit.runner.RunWith +import com.google.common.truth.Truth.assertThat + +@RunWith(AndroidJUnit4::class) +class HeadlineGeneratorImplTest { + @Test + fun testHeadlineGeneration() { + val generator = HeadlineGeneratorImpl( + InstrumentationRegistry.getInstrumentation().getTargetContext()) + val str = "Some sting" + val url = "http://www.google.com" + + assertThat(generator.getTextHeadline(str)).isEqualTo("Sharing text") + assertThat(generator.getTextHeadline(url)).isEqualTo("Sharing link") + + assertThat(generator.getImageWithTextHeadline(str)).isEqualTo("Sharing image with text") + assertThat(generator.getImageWithTextHeadline(url)).isEqualTo("Sharing image with link") + + assertThat(generator.getImagesHeadline(1)).isEqualTo("Sharing image") + assertThat(generator.getImagesHeadline(4)).isEqualTo("Sharing 4 images") + + assertThat(generator.getVideosHeadline(1)).isEqualTo("Sharing video") + assertThat(generator.getVideosHeadline(4)).isEqualTo("Sharing 4 videos") + + assertThat(generator.getItemsHeadline(1)).isEqualTo("Sharing 1 item") + assertThat(generator.getItemsHeadline(4)).isEqualTo("Sharing 4 items") + } +} -- cgit v1.2.3-59-g8ed1b From f1870096ee8ad86d45887b1de5aee70a7f93dea3 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Fri, 17 Mar 2023 02:31:04 -0700 Subject: Maintain previews aspect ratio Content preview preserves previews apspect raio (limiting between 0.4 and 2.5). ScrollableImagePreviewView now makes an initial pass over provided URIs to determine preview aspect ratio; previews that successfully loaded are progressively added to the dislay list. Previews that failed to load counted in the "+N" item at the list end. Previews are displayed in the order they are loaded (concurrently) to facilitate the fastest preview display. Preivew item height and spacings are change accroding to the latest specs. Bug: 271613784 Test: manual testing Change-Id: I430f1e7fb39c97da91bdc25914a0cb804a2b6ffa --- Android.bp | 1 + java/res/layout/image_preview_image_item.xml | 33 +-- java/res/layout/image_preview_other_item.xml | 2 +- java/res/layout/scrollable_image_preview_view.xml | 25 +- java/res/values/attrs.xml | 6 + java/res/values/dimens.xml | 1 + .../android/intentresolver/ChooserActivity.java | 4 +- .../contentpreview/UnifiedContentPreviewUi.java | 3 +- java/src/com/android/intentresolver/util/Flow.kt | 84 +++++++ .../widget/ScrollableImagePreviewView.kt | 259 +++++++++++++++++++-- .../UnbundledChooserActivityTest.java | 2 +- 11 files changed, 368 insertions(+), 52 deletions(-) create mode 100644 java/src/com/android/intentresolver/util/Flow.kt (limited to 'java/tests') diff --git a/Android.bp b/Android.bp index 9238a7e2..bab33509 100644 --- a/Android.bp +++ b/Android.bp @@ -68,6 +68,7 @@ android_library { static_libs: [ "androidx.annotation_annotation", "androidx.concurrent_concurrent-futures", + "androidx-constraintlayout_constraintlayout", "androidx.recyclerview_recyclerview", "androidx.viewpager_viewpager", "androidx.lifecycle_lifecycle-common-java8", diff --git a/java/res/layout/image_preview_image_item.xml b/java/res/layout/image_preview_image_item.xml index 81fa5c8e..a8a2c754 100644 --- a/java/res/layout/image_preview_image_item.xml +++ b/java/res/layout/image_preview_image_item.xml @@ -14,25 +14,30 @@ ~ limitations under the License. --> - + android:layout_width="wrap_content" + android:layout_height="@dimen/chooser_preview_image_height_tall"> - + - + diff --git a/java/res/layout/image_preview_other_item.xml b/java/res/layout/image_preview_other_item.xml index b7cc4350..07f87e3a 100644 --- a/java/res/layout/image_preview_other_item.xml +++ b/java/res/layout/image_preview_other_item.xml @@ -17,7 +17,7 @@ + android:layout_height="@dimen/chooser_preview_image_height_tall"> - + + android:layout_height="wrap_content"> + + + diff --git a/java/res/values/attrs.xml b/java/res/values/attrs.xml index eba6b9b7..67acb3ae 100644 --- a/java/res/values/attrs.xml +++ b/java/res/values/attrs.xml @@ -45,4 +45,10 @@ + + + + + + diff --git a/java/res/values/dimens.xml b/java/res/values/dimens.xml index af90c4ef..7daa9206 100644 --- a/java/res/values/dimens.xml +++ b/java/res/values/dimens.xml @@ -28,6 +28,7 @@ 1dp 120dp 104dp + 192dp 200dp -1px 4dp diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 37a17e79..44000bef 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -1299,7 +1299,9 @@ public class ChooserActivity extends ResolverActivity implements final int cacheSize; if (mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW)) { float chooserWidth = getResources().getDimension(R.dimen.chooser_width); - float imageWidth = getResources().getDimension(R.dimen.chooser_preview_image_width); + // imageWidth = imagePreviewHeight / minAspectRatio (see ScrollableImagePreviewView) + float imageWidth = + getResources().getDimension(R.dimen.chooser_preview_image_height_tall) * 5 / 2; cacheSize = (int) (Math.ceil(chooserWidth / imageWidth) + 2); } else { cacheSize = 3; diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java index ee24d18f..c4e6feb7 100644 --- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -152,8 +152,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { stub.setLayoutResource(R.layout.scrollable_image_preview_view); stub.inflate(); } - return previewLayout.findViewById( - com.android.internal.R.id.content_preview_image_area); + return previewLayout.findViewById(R.id.scrollable_image_preview); } private void setTextInImagePreviewVisibility( diff --git a/java/src/com/android/intentresolver/util/Flow.kt b/java/src/com/android/intentresolver/util/Flow.kt new file mode 100644 index 00000000..1155b9fe --- /dev/null +++ b/java/src/com/android/intentresolver/util/Flow.kt @@ -0,0 +1,84 @@ +/* + * 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.util + +import android.os.SystemClock +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.launch + +/** + * Returns a flow that mirrors the original flow, but delays values following emitted values for the + * given [periodMs]. If the original flow emits more than one value during this period, only the + * latest value is emitted. + * + * Example: + * + * ```kotlin + * flow { + * emit(1) // t=0ms + * delay(90) + * emit(2) // t=90ms + * delay(90) + * emit(3) // t=180ms + * delay(1010) + * emit(4) // t=1190ms + * delay(1010) + * emit(5) // t=2200ms + * }.throttle(1000) + * ``` + * + * produces the following emissions at the following times + * + * ```text + * 1 (t=0ms), 3 (t=1000ms), 4 (t=2000ms), 5 (t=3000ms) + * ``` + */ +// A SystemUI com.android.systemui.util.kotlin.throttle copy. +fun Flow.throttle(periodMs: Long): Flow = channelFlow { + coroutineScope { + var previousEmitTimeMs = 0L + var delayJob: Job? = null + var sendJob: Job? = null + val outerScope = this + + collect { + delayJob?.cancel() + sendJob?.join() + val currentTimeMs = SystemClock.elapsedRealtime() + val timeSinceLastEmit = currentTimeMs - previousEmitTimeMs + val timeUntilNextEmit = maxOf(0L, periodMs - timeSinceLastEmit) + if (timeUntilNextEmit > 0L) { + // We create delayJob to allow cancellation during the delay period + delayJob = launch { + delay(timeUntilNextEmit) + sendJob = outerScope.launch(start = CoroutineStart.UNDISPATCHED) { + send(it) + previousEmitTimeMs = SystemClock.elapsedRealtime() + } + } + } else { + send(it) + previousEmitTimeMs = currentTimeMs + } + } + } +} diff --git a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt index c02a10a2..d1b0f5b4 100644 --- a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt +++ b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt @@ -17,6 +17,7 @@ package com.android.intentresolver.widget import android.content.Context +import android.graphics.Bitmap import android.graphics.Rect import android.net.Uri import android.util.AttributeSet @@ -27,20 +28,32 @@ import android.view.View import android.view.ViewGroup import android.widget.ImageView import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.android.intentresolver.R +import com.android.intentresolver.util.throttle import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.MainScope import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.takeWhile +import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.plus -import kotlin.math.sign +import java.util.ArrayDeque +import kotlin.math.roundToInt private const val TRANSITION_NAME = "screenshot_preview_image" private const val PLURALS_COUNT = "count" +private const val ADAPTER_UPDATE_INTERVAL_MS = 150L +private const val MIN_ASPECT_RATIO = 0.4f +private const val MIN_ASPECT_RATIO_STRING = "2:5" +private const val MAX_ASPECT_RATIO = 2.5f +private const val MAX_ASPECT_RATIO_STRING = "5:2" class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { constructor(context: Context) : this(context, null) @@ -50,14 +63,53 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { ) : super(context, attrs, defStyleAttr) { layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) adapter = Adapter(context) - val spacing = TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, 5f, context.resources.displayMetrics - ).toInt() - addItemDecoration(SpacingDecoration(spacing)) + + context.obtainStyledAttributes( + attrs, R.styleable.ScrollableImagePreviewView, defStyleAttr, 0 + ).use { a -> + var innerSpacing = a.getDimensionPixelSize( + R.styleable.ScrollableImagePreviewView_itemInnerSpacing, -1 + ) + if (innerSpacing < 0) { + innerSpacing = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, 3f, context.resources.displayMetrics + ).toInt() + } + var outerSpacing = a.getDimensionPixelSize( + R.styleable.ScrollableImagePreviewView_itemOuterSpacing, -1 + ) + if (outerSpacing < 0) { + outerSpacing = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, 16f, context.resources.displayMetrics + ).toInt() + } + addItemDecoration(SpacingDecoration(innerSpacing, outerSpacing)) + + maxWidthHint = a.getDimensionPixelSize( + R.styleable.ScrollableImagePreviewView_maxWidthHint, -1 + ) + } } + private var batchLoader: BatchPreviewLoader? = null private val previewAdapter get() = adapter as Adapter + /** + * A hint about the maximum width this view can grow to, this helps to optimize preview + * loading. + */ + var maxWidthHint: Int = -1 + private var requestedHeight: Int = 0 + private var isMeasured = false + + override fun onMeasure(widthSpec: Int, heightSpec: Int) { + super.onMeasure(widthSpec, heightSpec) + if (!isMeasured) { + isMeasured = true + batchLoader?.loadAspectRatios(getMaxWidth(), this::calcPreviewWidth) + } + } + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { super.onLayout(changed, l, t, r, b) setOverScrollMode( @@ -69,32 +121,103 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { previewAdapter.transitionStatusElementCallback = callback } - fun setPreviews(previews: List, otherItemCount: Int, imageLoader: ImageLoader) = - previewAdapter.setPreviews(previews, otherItemCount, imageLoader) + fun setPreviews(previews: List, otherItemCount: Int, imageLoader: ImageLoader) { + previewAdapter.reset(0, imageLoader) + batchLoader?.cancel() + batchLoader = BatchPreviewLoader( + previewAdapter, + imageLoader, + previews, + otherItemCount, + ).apply { + if (isMeasured) { + loadAspectRatios(getMaxWidth(), this@ScrollableImagePreviewView::calcPreviewWidth) + } + } + } + + private fun getMaxWidth(): Int = + when { + maxWidthHint > 0 -> maxWidthHint + isLaidOut -> width + else -> measuredWidth + } + + private fun calcPreviewWidth(bitmap: Bitmap): Int { + val effectiveHeight = if (isLaidOut) height else measuredHeight + return if (bitmap.width <= 0 || bitmap.height <= 0) { + effectiveHeight + } else { + val ar = (bitmap.width.toFloat() / bitmap.height.toFloat()) + .coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO) + (effectiveHeight * ar).roundToInt() + } + } + + class Preview internal constructor( + val type: PreviewType, + val uri: Uri, + internal var aspectRatioString: String + ) { + constructor(type: PreviewType, uri: Uri) : this(type, uri, "1:1") + + internal var bitmap: Bitmap? = null + + internal fun updateAspectRatio(width: Int, height: Int) { + if (width <= 0 || height <= 0) return + val aspectRatio = width.toFloat() / height.toFloat() + aspectRatioString = when { + aspectRatio <= MIN_ASPECT_RATIO -> MIN_ASPECT_RATIO_STRING + aspectRatio >= MAX_ASPECT_RATIO -> MAX_ASPECT_RATIO_STRING + else -> "$width:$height" + } + } + } - class Preview(val type: PreviewType, val uri: Uri) enum class PreviewType { Image, Video, File } - private class Adapter(private val context: Context) : RecyclerView.Adapter() { + private class Adapter( + private val context: Context + ) : RecyclerView.Adapter() { private val previews = ArrayList() private var imageLoader: ImageLoader? = null private var firstImagePos = -1 + private var totalItemCount: Int = 0 + + private val hasOtherItem get() = previews.size < totalItemCount + var transitionStatusElementCallback: TransitionElementStatusCallback? = null - private var otherItemCount = 0 - fun setPreviews( - previews: List, otherItemCount: Int, imageLoader: ImageLoader - ) { - this.previews.clear() - this.previews.addAll(previews) + fun reset(totalItemCount: Int, imageLoader: ImageLoader) { this.imageLoader = imageLoader - firstImagePos = previews.indexOfFirst { it.type == PreviewType.Image } - this.otherItemCount = maxOf(0, otherItemCount) + firstImagePos = -1 + previews.clear() + this.totalItemCount = maxOf(0, totalItemCount) notifyDataSetChanged() } + fun addPreviews(newPreviews: Collection) { + if (newPreviews.isEmpty()) return + val insertPos = previews.size + val hadOtherItem = hasOtherItem + previews.addAll(newPreviews) + if (firstImagePos < 0) { + val pos = newPreviews.indexOfFirst { it.type == PreviewType.Image } + if (pos >= 0) firstImagePos = insertPos + pos + } + notifyItemRangeInserted(insertPos, newPreviews.size) + when { + hadOtherItem && previews.size >= totalItemCount -> { + notifyItemRemoved(previews.size) + } + !hadOtherItem && previews.size < totalItemCount -> { + notifyItemInserted(previews.size) + } + } + } + override fun onCreateViewHolder(parent: ViewGroup, itemType: Int): ViewHolder { val view = LayoutInflater.from(context).inflate(itemType, parent, false); return if (itemType == R.layout.image_preview_other_item) { @@ -104,7 +227,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { } } - override fun getItemCount(): Int = previews.size + otherItemCount.sign + override fun getItemCount(): Int = previews.size + if (hasOtherItem) 1 else 0 override fun getItemViewType(position: Int): Int { return if (position == previews.size) { @@ -116,7 +239,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { override fun onBindViewHolder(vh: ViewHolder, position: Int) { when (vh) { - is OtherItemViewHolder -> vh.bind(otherItemCount) + is OtherItemViewHolder -> vh.bind(totalItemCount - previews.size) is PreviewViewHolder -> vh.bind( previews[position], imageLoader ?: error("ImageLoader is missing"), @@ -163,6 +286,9 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { previewReadyCallback: ((String) -> Unit)? ) { image.setImageDrawable(null) + (image.layoutParams as? ConstraintLayout.LayoutParams)?.let { params -> + params.dimensionRatio = preview.aspectRatioString + } image.transitionName = if (previewReadyCallback != null) { TRANSITION_NAME } else { @@ -180,7 +306,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { } } resetScope().launch { - loadImage(preview.uri, imageLoader) + loadImage(preview, imageLoader) if (preview.type == PreviewType.Image) { previewReadyCallback?.let { callback -> image.waitForPreDraw() @@ -190,11 +316,11 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { } } - private suspend fun loadImage(uri: Uri, imageLoader: ImageLoader) { - val bitmap = runCatching { + private suspend fun loadImage(preview: Preview, imageLoader: ImageLoader) { + val bitmap = preview.bitmap ?: runCatching { // it's expected for all loading/caching optimizations to be implemented by the // loader - imageLoader(uri) + imageLoader(preview.uri) }.getOrNull() image.setImageBitmap(bitmap) } @@ -225,9 +351,92 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { override fun unbind() = Unit } - private class SpacingDecoration(private val margin: Int) : ItemDecoration() { + private class SpacingDecoration( + private val innerSpacing: Int, + private val outerSpacing: Int + ) : ItemDecoration() { override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: State) { - outRect.set(margin, 0, margin, 0) + val itemCount = parent.adapter?.itemCount ?: return + val pos = parent.getChildAdapterPosition(view) + var leftMargin = if (pos == 0) outerSpacing else innerSpacing + var rightMargin = if (pos == itemCount - 1) outerSpacing else 0 + outRect.set(leftMargin, 0, rightMargin, 0) + } + } + + private class BatchPreviewLoader( + private val adapter: Adapter, + private val imageLoader: ImageLoader, + previews: List, + otherItemCount: Int, + ) { + private val pendingPreviews = ArrayDeque(previews) + private val totalItemCount = previews.size + otherItemCount + private var scope: CoroutineScope? = MainScope() + Dispatchers.Main.immediate + + fun cancel() { + scope?.cancel() + scope = null + } + + fun loadAspectRatios(maxWidth: Int, previewWidthCalculator: (Bitmap) -> Int) { + val scope = this.scope ?: return + val updates = ArrayDeque(pendingPreviews.size) + // replay 2 items to guarantee that we'd get at least one update + val reportFlow = MutableSharedFlow(replay = 2) + var isFirstUpdate = true + val updateEvent = Any() + val completedEvent = Any() + // throttle adapter updates by waiting on the channel, the channel first notified + // when enough preview elements is loaded and then periodically with a delay + scope.launch(Dispatchers.Main) { + reportFlow + .takeWhile { it !== completedEvent } + .throttle(ADAPTER_UPDATE_INTERVAL_MS) + .collect { + if (isFirstUpdate) { + isFirstUpdate = false + adapter.reset(totalItemCount, imageLoader) + } + if (updates.isNotEmpty()) { + adapter.addPreviews(updates) + updates.clear() + } + } + } + + scope.launch { + var loadedPreviewWidth = 0 + List(4) { + launch { + while (pendingPreviews.isNotEmpty()) { + val preview = pendingPreviews.poll() ?: continue + val bitmap = runCatching { + // TODO: decide on adding a timeout + imageLoader(preview.uri) + }.getOrNull() ?: continue + preview.updateAspectRatio(bitmap.width, bitmap.height) + updates.add(preview) + if (loadedPreviewWidth < maxWidth) { + loadedPreviewWidth += previewWidthCalculator(bitmap) + // cache bitmaps for the first preview items to aovid potential + // double-loading (in case those values are evicted from the image + // loader's cache) + preview.bitmap = bitmap + if (loadedPreviewWidth >= maxWidth) { + // notify that the preview now can be displayed + reportFlow.emit(updateEvent) + } + } else { + reportFlow.emit(updateEvent) + } + } + } + }.joinAll() + // in case all previews have failed to load + reportFlow.emit(updateEvent) + reportFlow.emit(completedEvent) + } } } } diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 596b546e..8b9aaa60 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -1030,7 +1030,7 @@ public class UnbundledChooserActivityTest { setupResolverControllers(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); - onView(withId(com.android.internal.R.id.content_preview_image_area)) + onView(withId(R.id.scrollable_image_preview)) .perform(RecyclerViewActions.scrollToLastPosition()) .check((view, exception) -> { if (exception != null) { -- cgit v1.2.3-59-g8ed1b From 4fc75124fe90daff88f213ba204a157e40ba2b96 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Tue, 21 Mar 2023 10:56:58 -0700 Subject: Do not crash on reading URI metadata Do not crash if content resolver throws an exception while trying to read an URI metadata. Bug: 273890881 Test: check that the Chooser is not failing Change-Id: I161e29246bfcb1081a500b90c88c8c06c1d0df72 --- .../contentpreview/ChooserContentPreviewUi.java | 11 +++-- .../contentpreview/ChooserContentPreviewUiTest.kt | 50 ++++++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) (limited to 'java/tests') diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index 08cebf68..de454cfd 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -28,11 +28,11 @@ import android.content.res.Resources; import android.database.Cursor; import android.media.MediaMetadata; import android.net.Uri; -import android.os.RemoteException; import android.provider.DocumentsContract; import android.provider.Downloads; import android.provider.OpenableColumns; import android.text.TextUtils; +import android.util.Log; import android.view.LayoutInflater; import android.view.ViewGroup; @@ -351,7 +351,8 @@ public final class ChooserContentPreviewUi { private static String getType(ContentInterface resolver, Uri uri) { try { return resolver.getType(uri); - } catch (RemoteException e) { + } catch (Throwable t) { + Log.e(ContentPreviewUi.TAG, "Failed to read content type, uri: " + uri, t); return null; } } @@ -360,7 +361,8 @@ public final class ChooserContentPreviewUi { private static Cursor query(ContentInterface resolver, Uri uri) { try { return resolver.query(uri, null, null, null); - } catch (RemoteException e) { + } catch (Throwable t) { + Log.e(ContentPreviewUi.TAG, "Failed to read metadata, uri: " + uri, t); return null; } } @@ -369,7 +371,8 @@ public final class ChooserContentPreviewUi { private static String[] getStreamTypes(ContentInterface resolver, Uri uri) { try { return resolver.getStreamTypes(uri, "*/*"); - } catch (RemoteException e) { + } catch (Throwable t) { + Log.e(ContentPreviewUi.TAG, "Failed to read stream types, uri: " + uri, t); return null; } } diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt index 04a136b4..58b8a21d 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt @@ -23,6 +23,8 @@ import android.graphics.Bitmap import android.net.Uri import com.android.intentresolver.ImageLoader import com.android.intentresolver.TestFeatureFlagRepository +import com.android.intentresolver.any +import com.android.intentresolver.anyOrNull import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory import com.android.intentresolver.flags.Flags import com.android.intentresolver.mock @@ -143,6 +145,53 @@ class ChooserContentPreviewUiTest { verify(transitionCallback, times(1)).onAllTransitionElementsReady() } + @Test + fun test_ChooserContentPreview_single_uri_crashing_getType_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(any())) + .thenThrow(SecurityException("Test getType() exception")) + 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_single_uri_crashing_metadata_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(any())).thenReturn("application/pdf") + whenever(contentResolver.query(any(), anyOrNull(), anyOrNull(), anyOrNull())) + .thenThrow(SecurityException("Test query() exception")) + whenever(contentResolver.getStreamTypes(any(), any())) + .thenThrow(SecurityException("Test getStreamType() exception")) + 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_single_uri_with_preview_to_image_preview() { val uri = Uri.parse("content://$PROVIDER_NAME/test.pdf") @@ -283,4 +332,5 @@ class ChooserContentPreviewUiTest { .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) verify(transitionCallback, times(1)).onAllTransitionElementsReady() } + } -- cgit v1.2.3-59-g8ed1b From 6aaafb277958144a39f9ff4c8328a29eb2db6e38 Mon Sep 17 00:00:00 2001 From: Remi NGUYEN VAN Date: Wed, 22 Mar 2023 07:25:59 +0000 Subject: Revert "Add headline to sharesheet." This reverts commit 19b5edd72865206184068604804d5eebf83f896d. Reason for revert: Broken build: b/274711262 Change-Id: I91e8cd61c1268d3e4e2fc2d48a7f1cedda444a4f --- java/res/layout/chooser_grid_preview_file.xml | 10 ++- java/res/layout/chooser_grid_preview_image.xml | 16 +++- java/res/layout/chooser_grid_preview_text.xml | 10 ++- java/res/layout/chooser_headline_row.xml | 57 ------------ java/res/values/strings.xml | 30 ------- .../android/intentresolver/ChooserActivity.java | 4 +- .../contentpreview/ChooserContentPreviewUi.java | 42 +++------ .../contentpreview/ContentPreviewUi.java | 17 +--- .../contentpreview/FileContentPreviewUi.java | 9 +- .../contentpreview/HeadlineGenerator.kt | 35 -------- .../contentpreview/HeadlineGeneratorImpl.kt | 67 -------------- .../contentpreview/ImageContentPreviewUi.java | 21 +---- .../contentpreview/TextContentPreviewUi.java | 9 +- .../contentpreview/UnifiedContentPreviewUi.java | 100 +++++++-------------- .../UnbundledChooserActivityTest.java | 39 -------- .../contentpreview/ChooserContentPreviewUiTest.kt | 28 ++---- .../contentpreview/HeadlineGeneratorImplTest.kt | 49 ---------- 17 files changed, 93 insertions(+), 450 deletions(-) delete mode 100644 java/res/layout/chooser_headline_row.xml delete mode 100644 java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt delete mode 100644 java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt delete mode 100644 java/tests/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt (limited to 'java/tests') diff --git a/java/res/layout/chooser_grid_preview_file.xml b/java/res/layout/chooser_grid_preview_file.xml index 036c5318..6ba06b3d 100644 --- a/java/res/layout/chooser_grid_preview_file.xml +++ b/java/res/layout/chooser_grid_preview_file.xml @@ -27,8 +27,6 @@ android:paddingBottom="@dimen/chooser_view_spacing" android:background="?android:attr/colorBackground"> - - + + - + + + - - + + - - - - - - - - - - - diff --git a/java/res/values/strings.xml b/java/res/values/strings.xml index f38666e4..d1c97c7e 100644 --- a/java/res/values/strings.xml +++ b/java/res/values/strings.xml @@ -137,36 +137,6 @@ } - - Sharing text - - Sharing link - - {count, plural, - =1 {Sharing image} - other {Sharing # images} - } - - - {count, plural, - =1 {Sharing video} - other {Sharing # videos} - } - - - {count, plural, - =1 {Sharing # item} - other {Sharing # items} - } - - - Sharing image with text - - Sharing image with link - No recommended people to share with diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 1419bca7..2a73c42a 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -84,7 +84,6 @@ 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.contentpreview.HeadlineGeneratorImpl; import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.flags.FeatureFlagRepositoryFactory; import com.android.intentresolver.flags.Flags; @@ -292,8 +291,7 @@ public class ChooserActivity extends ResolverActivity implements createPreviewImageLoader(), createChooserActionFactory(), mEnterTransitionAnimationDelegate, - mFeatureFlagRepository, - new HeadlineGeneratorImpl(this)); + mFeatureFlagRepository); setAdditionalTargets(mChooserRequest.getAdditionalTargets()); diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index 047a10fd..08cebf68 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -103,8 +103,7 @@ public final class ChooserContentPreviewUi { ImageLoader imageLoader, ActionFactory actionFactory, TransitionElementStatusCallback transitionElementStatusCallback, - FeatureFlagRepository featureFlagRepository, - HeadlineGenerator headlineGenerator) { + FeatureFlagRepository featureFlagRepository) { mContentPreviewUi = createContentPreview( targetIntent, @@ -113,8 +112,7 @@ public final class ChooserContentPreviewUi { imageLoader, actionFactory, transitionElementStatusCallback, - featureFlagRepository, - headlineGenerator); + featureFlagRepository); if (mContentPreviewUi.getType() != CONTENT_PREVIEW_IMAGE) { transitionElementStatusCallback.onAllTransitionElementsReady(); } @@ -127,8 +125,7 @@ public final class ChooserContentPreviewUi { ImageLoader imageLoader, ActionFactory actionFactory, TransitionElementStatusCallback transitionElementStatusCallback, - FeatureFlagRepository featureFlagRepository, - HeadlineGenerator headlineGenerator) { + FeatureFlagRepository featureFlagRepository) { /* In {@link android.content.Intent#getType}, the app may specify a very general mime type * that broadly covers all data being shared, such as {@literal *}/* when sending an image @@ -142,20 +139,12 @@ public final class ChooserContentPreviewUi { if (!(isSend || isSendMultiple) || (type != null && ClipDescription.compareMimeTypes(type, "text/*"))) { return createTextPreview( - targetIntent, - actionFactory, - imageLoader, - featureFlagRepository, - headlineGenerator); + targetIntent, actionFactory, imageLoader, featureFlagRepository); } List uris = extractContentUris(targetIntent); if (uris.isEmpty()) { return createTextPreview( - targetIntent, - actionFactory, - imageLoader, - featureFlagRepository, - headlineGenerator); + targetIntent, actionFactory, imageLoader, featureFlagRepository); } ArrayList files = new ArrayList<>(uris.size()); int previewCount = readFileInfo(contentResolver, typeClassifier, uris, files); @@ -164,8 +153,7 @@ public final class ChooserContentPreviewUi { files, actionFactory, imageLoader, - featureFlagRepository, - headlineGenerator); + featureFlagRepository); } if (featureFlagRepository.isEnabled(Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW)) { return new UnifiedContentPreviewUi( @@ -175,16 +163,14 @@ public final class ChooserContentPreviewUi { imageLoader, typeClassifier, transitionElementStatusCallback, - featureFlagRepository, - headlineGenerator); + featureFlagRepository); } if (previewCount < uris.size()) { return new FileContentPreviewUi( files, actionFactory, imageLoader, - featureFlagRepository, - headlineGenerator); + featureFlagRepository); } // The legacy (3-image) image preview is on it's way out and it's unlikely that we'd end up // here. To preserve the legacy behavior, before using it, check that all uris are images. @@ -194,8 +180,7 @@ public final class ChooserContentPreviewUi { files, actionFactory, imageLoader, - featureFlagRepository, - headlineGenerator); + featureFlagRepository); } } return new ImageContentPreviewUi( @@ -207,8 +192,7 @@ public final class ChooserContentPreviewUi { actionFactory, imageLoader, transitionElementStatusCallback, - featureFlagRepository, - headlineGenerator); + featureFlagRepository); } public int getPreferredContentPreview() { @@ -323,8 +307,7 @@ public final class ChooserContentPreviewUi { Intent targetIntent, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, - FeatureFlagRepository featureFlagRepository, - HeadlineGenerator headlineGenerator) { + FeatureFlagRepository featureFlagRepository) { CharSequence sharingText = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); String previewTitle = targetIntent.getStringExtra(Intent.EXTRA_TITLE); ClipData previewData = targetIntent.getClipData(); @@ -341,8 +324,7 @@ public final class ChooserContentPreviewUi { previewThumbnail, actionFactory, imageLoader, - featureFlagRepository, - headlineGenerator); + featureFlagRepository); } private static List extractContentUris(Intent targetIntent) { diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java index a6bc2164..79444b4e 100644 --- a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java @@ -24,7 +24,6 @@ import android.content.res.Resources; import android.graphics.Bitmap; import android.net.Uri; import android.os.UserHandle; -import android.text.TextUtils; import android.util.Log; import android.view.LayoutInflater; import android.view.View; @@ -115,21 +114,7 @@ abstract class ContentPreviewUi { fadeAnim.start(); } - protected static void displayHeadline(ViewGroup layout, String headline) { - if (layout != null) { - TextView titleView = layout.findViewById(R.id.headline); - if (titleView != null) { - if (!TextUtils.isEmpty(headline)) { - titleView.setText(headline); - titleView.setVisibility(View.VISIBLE); - } else { - titleView.setVisibility(View.GONE); - } - } - } - } - - protected static void displayModifyShareAction( + protected static void displayPayloadReselectionAction( ViewGroup layout, ChooserContentPreviewUi.ActionFactory actionFactory, FeatureFlagRepository featureFlagRepository) { diff --git a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java index 52e20cf0..2c5def8b 100644 --- a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java @@ -45,19 +45,16 @@ class FileContentPreviewUi extends ContentPreviewUi { private final ChooserContentPreviewUi.ActionFactory mActionFactory; private final ImageLoader mImageLoader; private final FeatureFlagRepository mFeatureFlagRepository; - private final HeadlineGenerator mHeadlineGenerator; FileContentPreviewUi( List files, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, - FeatureFlagRepository featureFlagRepository, - HeadlineGenerator headlineGenerator) { + FeatureFlagRepository featureFlagRepository) { mFiles = files; mActionFactory = actionFactory; mImageLoader = imageLoader; mFeatureFlagRepository = featureFlagRepository; - mHeadlineGenerator = headlineGenerator; } @Override @@ -68,7 +65,7 @@ class FileContentPreviewUi extends ContentPreviewUi { @Override public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { ViewGroup layout = displayInternal(resources, layoutInflater, parent); - displayModifyShareAction(layout, mActionFactory, mFeatureFlagRepository); + displayPayloadReselectionAction(layout, mActionFactory, mFeatureFlagRepository); return layout; } @@ -80,8 +77,6 @@ class FileContentPreviewUi extends ContentPreviewUi { final int uriCount = mFiles.size(); - displayHeadline(contentPreviewLayout, mHeadlineGenerator.getItemsHeadline(mFiles.size())); - if (uriCount == 0) { contentPreviewLayout.setVisibility(View.GONE); Log.i(TAG, "Appears to be no uris available in EXTRA_STREAM," diff --git a/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt b/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt deleted file mode 100644 index e32bb5c4..00000000 --- a/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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 - -private const val PLURALS_COUNT = "count" - -/** - * HeadlineGenerator generates the text to show at the top of the sharesheet as a brief - * description of the content being shared. - */ -interface HeadlineGenerator { - fun getTextHeadline(text: CharSequence): String - - fun getImageWithTextHeadline(text: CharSequence): String - - fun getImagesHeadline(count: Int): String - - fun getVideosHeadline(count: Int): String - - fun getItemsHeadline(count: Int): String -} diff --git a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt deleted file mode 100644 index ae44294c..00000000 --- a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * 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.Context -import com.android.intentresolver.R -import android.util.PluralsMessageFormatter - -private const val PLURALS_COUNT = "count" - -/** - * HeadlineGenerator generates the text to show at the top of the sharesheet as a brief - * description of the content being shared. - */ -class HeadlineGeneratorImpl(private val context: Context) : HeadlineGenerator { - override fun getTextHeadline(text: CharSequence): String { - if (text.toString().isHttpUri()) { - return context.getString(R.string.sharing_link) - } - return context.getString(R.string.sharing_text) - } - - override fun getImageWithTextHeadline(text: CharSequence): String { - if (text.toString().isHttpUri()) { - return context.getString(R.string.sharing_image_with_link) - } - return context.getString(R.string.sharing_image_with_text) - } - - override fun getImagesHeadline(count: Int): String { - return PluralsMessageFormatter.format( - context.resources, - mapOf(PLURALS_COUNT to count), - R.string.sharing_images - ) - } - - override fun getVideosHeadline(count: Int): String { - return PluralsMessageFormatter.format( - context.resources, - mapOf(PLURALS_COUNT to count), - R.string.sharing_videos - ) - } - - override fun getItemsHeadline(count: Int): String { - return PluralsMessageFormatter.format( - context.resources, - mapOf(PLURALS_COUNT to count), - R.string.sharing_items - ) - } -} diff --git a/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java index f2c0564a..5f3bdf40 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java @@ -54,7 +54,6 @@ class ImageContentPreviewUi extends ContentPreviewUi { private final ImageLoader mImageLoader; private final TransitionElementStatusCallback mTransitionElementStatusCallback; private final FeatureFlagRepository mFeatureFlagRepository; - private final HeadlineGenerator mHeadlineGenerator; ImageContentPreviewUi( List imageUris, @@ -62,15 +61,13 @@ class ImageContentPreviewUi extends ContentPreviewUi { ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, TransitionElementStatusCallback transitionElementStatusCallback, - FeatureFlagRepository featureFlagRepository, - HeadlineGenerator headlineGenerator) { + FeatureFlagRepository featureFlagRepository) { mImageUris = imageUris; mText = text; mActionFactory = actionFactory; mImageLoader = imageLoader; mTransitionElementStatusCallback = transitionElementStatusCallback; mFeatureFlagRepository = featureFlagRepository; - mHeadlineGenerator = headlineGenerator; mImageLoader.prePopulate(mImageUris); } @@ -83,7 +80,7 @@ class ImageContentPreviewUi extends ContentPreviewUi { @Override public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { ViewGroup layout = displayInternal(layoutInflater, parent); - displayModifyShareAction(layout, mActionFactory, mFeatureFlagRepository); + displayPayloadReselectionAction(layout, mActionFactory, mFeatureFlagRepository); return layout; } @@ -116,8 +113,6 @@ class ImageContentPreviewUi extends ContentPreviewUi { imagePreview.setTransitionElementStatusCallback(mTransitionElementStatusCallback); imagePreview.setImages(mImageUris, mImageLoader); - updateHeadline(contentPreviewLayout); - return contentPreviewLayout; } @@ -145,17 +140,6 @@ class ImageContentPreviewUi extends ContentPreviewUi { com.android.internal.R.id.content_preview_image_area); } - private void updateHeadline(ViewGroup contentPreview) { - CheckBox includeTextCheckbox = contentPreview.requireViewById(R.id.include_text_action); - if (includeTextCheckbox.getVisibility() == View.VISIBLE - && includeTextCheckbox.isChecked()) { - displayHeadline(contentPreview, mHeadlineGenerator.getImageWithTextHeadline(mText)); - } else { - displayHeadline( - contentPreview, mHeadlineGenerator.getImagesHeadline(mImageUris.size())); - } - } - private void setTextInImagePreviewVisibility( ViewGroup contentPreview, ChooserContentPreviewUi.ActionFactory actionFactory) { int visibility = mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW) @@ -185,7 +169,6 @@ class ImageContentPreviewUi extends ContentPreviewUi { TransitionManager.beginDelayedTransition((ViewGroup) textView.getParent()); textView.setVisibility(isChecked ? View.VISIBLE : View.GONE); shareTextAction.accept(!isChecked); - updateHeadline(contentPreview); }); } actionView.setVisibility(visibility); diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java index d0cba5bb..e143954e 100644 --- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java @@ -46,7 +46,6 @@ class TextContentPreviewUi extends ContentPreviewUi { private final ImageLoader mImageLoader; private final ChooserContentPreviewUi.ActionFactory mActionFactory; private final FeatureFlagRepository mFeatureFlagRepository; - private final HeadlineGenerator mHeadlineGenerator; TextContentPreviewUi( @Nullable CharSequence sharingText, @@ -54,15 +53,13 @@ class TextContentPreviewUi extends ContentPreviewUi { @Nullable Uri previewThumbnail, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, - FeatureFlagRepository featureFlagRepository, - HeadlineGenerator headlineGenerator) { + FeatureFlagRepository featureFlagRepository) { mSharingText = sharingText; mPreviewTitle = previewTitle; mPreviewThumbnail = previewThumbnail; mImageLoader = imageLoader; mActionFactory = actionFactory; mFeatureFlagRepository = featureFlagRepository; - mHeadlineGenerator = headlineGenerator; } @Override @@ -73,7 +70,7 @@ class TextContentPreviewUi extends ContentPreviewUi { @Override public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { ViewGroup layout = displayInternal(layoutInflater, parent); - displayModifyShareAction(layout, mActionFactory, mFeatureFlagRepository); + displayPayloadReselectionAction(layout, mActionFactory, mFeatureFlagRepository); return layout; } @@ -125,8 +122,6 @@ class TextContentPreviewUi extends ContentPreviewUi { bitmap)); } - displayHeadline(contentPreviewLayout, mHeadlineGenerator.getTextHeadline(mSharingText)); - return contentPreviewLayout; } diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java index b9c64c6f..ee24d18f 100644 --- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -55,7 +55,6 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { private final MimeTypeClassifier mTypeClassifier; private final TransitionElementStatusCallback mTransitionElementStatusCallback; private final FeatureFlagRepository mFeatureFlagRepository; - private final HeadlineGenerator mHeadlineGenerator; UnifiedContentPreviewUi( List files, @@ -64,8 +63,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { ImageLoader imageLoader, MimeTypeClassifier typeClassifier, TransitionElementStatusCallback transitionElementStatusCallback, - FeatureFlagRepository featureFlagRepository, - HeadlineGenerator headlineGenerator) { + FeatureFlagRepository featureFlagRepository) { mFiles = files; mText = text; mActionFactory = actionFactory; @@ -73,7 +71,6 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { mTypeClassifier = typeClassifier; mTransitionElementStatusCallback = transitionElementStatusCallback; mFeatureFlagRepository = featureFlagRepository; - mHeadlineGenerator = headlineGenerator; mImageLoader.prePopulate(mFiles.stream() .map(FileInfo::getPreviewUri) @@ -89,7 +86,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { @Override public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { ViewGroup layout = displayInternal(layoutInflater, parent); - displayModifyShareAction(layout, mActionFactory, mFeatureFlagRepository); + displayPayloadReselectionAction(layout, mActionFactory, mFeatureFlagRepository); return layout; } @@ -118,47 +115,20 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { return contentPreviewLayout; } + setTextInImagePreviewVisibility(contentPreviewLayout, mActionFactory); imagePreview.setTransitionElementStatusCallback(mTransitionElementStatusCallback); - - List previews = new ArrayList<>(); - boolean allImages = !mFiles.isEmpty(); - boolean allVideos = !mFiles.isEmpty(); - for (FileInfo fileInfo : mFiles) { - ScrollableImagePreviewView.PreviewType previewType = - getPreviewType(fileInfo.getMimeType()); - allImages = allImages && previewType == ScrollableImagePreviewView.PreviewType.Image; - allVideos = allVideos && previewType == ScrollableImagePreviewView.PreviewType.Video; - - if (fileInfo.getPreviewUri() != null) { - previews.add(new ScrollableImagePreviewView.Preview( - previewType, - fileInfo.getPreviewUri())); - } - } + List previews = mFiles.stream() + .filter(fileInfo -> fileInfo.getPreviewUri() != null) + .map(fileInfo -> + new ScrollableImagePreviewView.Preview( + getPreviewType(fileInfo.getMimeType()), + fileInfo.getPreviewUri())) + .toList(); imagePreview.setPreviews( previews, mFiles.size() - previews.size(), mImageLoader); - if (mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW) - && !TextUtils.isEmpty(mText) - && mFiles.size() == 1 - && allImages) { - setTextInImagePreviewVisibility(contentPreviewLayout, mActionFactory); - updateTextWithImageHeadline(contentPreviewLayout); - } else { - if (allImages) { - displayHeadline( - contentPreviewLayout, mHeadlineGenerator.getImagesHeadline(mFiles.size())); - } else if (allVideos) { - displayHeadline( - contentPreviewLayout, mHeadlineGenerator.getVideosHeadline(mFiles.size())); - } else { - displayHeadline( - contentPreviewLayout, mHeadlineGenerator.getItemsHeadline(mFiles.size())); - } - } - return contentPreviewLayout; } @@ -186,42 +156,38 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { com.android.internal.R.id.content_preview_image_area); } - private void updateTextWithImageHeadline(ViewGroup contentPreview) { - CheckBox actionView = contentPreview.requireViewById(R.id.include_text_action); - if (actionView.getVisibility() == View.VISIBLE && actionView.isChecked()) { - displayHeadline(contentPreview, mHeadlineGenerator.getImageWithTextHeadline(mText)); - } else { - displayHeadline( - contentPreview, mHeadlineGenerator.getImagesHeadline(mFiles.size())); - } - } - 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(View.VISIBLE); - boolean isLink = HttpUriMatcher.isHttpUri(mText.toString()); + textView.setVisibility(visibility); + boolean isLink = visibility == View.VISIBLE && HttpUriMatcher.isHttpUri(mText.toString()); textView.setAutoLinkMask(isLink ? Linkify.WEB_URLS : 0); textView.setText(mText); - final int[] actionLabels = isLink - ? new int[] { R.string.include_link, R.string.exclude_link } - : new int[] { R.string.include_text, R.string.exclude_text }; - final Consumer shareTextAction = actionFactory.getExcludeSharedTextAction(); - actionView.setChecked(true); - actionView.setText(actionLabels[1]); - shareTextAction.accept(false); - actionView.setOnCheckedChangeListener((view, isChecked) -> { - view.setText(actionLabels[isChecked ? 1 : 0]); - TransitionManager.beginDelayedTransition((ViewGroup) textView.getParent()); - textView.setVisibility(isChecked ? View.VISIBLE : View.GONE); - shareTextAction.accept(!isChecked); - updateTextWithImageHeadline(contentPreview); - }); - actionView.setVisibility(View.VISIBLE); + if (visibility == View.VISIBLE) { + final int[] actionLabels = isLink + ? new int[] { R.string.include_link, R.string.exclude_link } + : new int[] { R.string.include_text, R.string.exclude_text }; + final Consumer shareTextAction = actionFactory.getExcludeSharedTextAction(); + actionView.setChecked(true); + actionView.setText(actionLabels[1]); + shareTextAction.accept(false); + actionView.setOnCheckedChangeListener((view, isChecked) -> { + view.setText(actionLabels[isChecked ? 1 : 0]); + TransitionManager.beginDelayedTransition((ViewGroup) textView.getParent()); + textView.setVisibility(isChecked ? View.VISIBLE : View.GONE); + shareTextAction.accept(!isChecked); + }); + } + actionView.setVisibility(visibility); } private ScrollableImagePreviewView.PreviewType getPreviewType(String mimeType) { diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 943584a2..596b546e 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -26,7 +26,6 @@ import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist; import static androidx.test.espresso.assertion.ViewAssertions.matches; import static androidx.test.espresso.matcher.ViewMatchers.hasSibling; import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; -import static androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withText; @@ -99,7 +98,6 @@ import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.test.espresso.contrib.RecyclerViewActions; import androidx.test.espresso.matcher.BoundedDiagnosingMatcher; -import androidx.test.espresso.matcher.ViewMatchers; import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.rule.ActivityTestRule; @@ -1069,43 +1067,6 @@ public class UnbundledChooserActivityTest { .check(matches(isDisplayed())); } - @Test - @RequireFeatureFlags( - flags = { Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME }, - values = { true }) - public void testNoTextPreviewWhenTextIsSharedWithMultipleImages() { - final Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" - + R.drawable.test320x240); - final String sharedText = "text-" + System.currentTimeMillis(); - - ArrayList uris = new ArrayList<>(); - uris.add(uri); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); - ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); - ChooserActivityOverrideData.getInstance().isImageType = true; - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - Mockito.any(UserHandle.class))) - .thenReturn(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(com.android.internal.R.id.content_preview_text)) - .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))); - } - @Test public void testOnCreateLogging() { Intent sendIntent = createSendTextIntent(); diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt index 3a00f1f3..04a136b4 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt @@ -42,7 +42,6 @@ class ChooserContentPreviewUiTest { private val imageClassifier = MimeTypeClassifier { mimeType -> mimeType != null && ClipDescription.compareMimeTypes(mimeType, "image/*") } - private val headlineGenerator = mock() private val imageLoader = object : ImageLoader { override fun loadImage(uri: Uri, callback: Consumer) { callback.accept(null) @@ -75,8 +74,7 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository, - headlineGenerator + featureFlagRepository ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) @@ -96,8 +94,7 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository, - headlineGenerator + featureFlagRepository ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) @@ -118,8 +115,7 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository, - headlineGenerator + featureFlagRepository ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) @@ -140,8 +136,7 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository, - headlineGenerator + featureFlagRepository ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) @@ -164,8 +159,7 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository, - headlineGenerator + featureFlagRepository ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) @@ -194,8 +188,7 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository, - headlineGenerator + featureFlagRepository ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) @@ -224,8 +217,7 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository, - headlineGenerator + featureFlagRepository ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) @@ -256,8 +248,7 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository, - headlineGenerator + featureFlagRepository ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) @@ -286,8 +277,7 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository, - headlineGenerator + featureFlagRepository ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) diff --git a/java/tests/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt deleted file mode 100644 index 9becce99..00000000 --- a/java/tests/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * 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 androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.Test -import org.junit.runner.RunWith -import com.google.common.truth.Truth.assertThat - -@RunWith(AndroidJUnit4::class) -class HeadlineGeneratorImplTest { - @Test - fun testHeadlineGeneration() { - val generator = HeadlineGeneratorImpl( - InstrumentationRegistry.getInstrumentation().getTargetContext()) - val str = "Some sting" - val url = "http://www.google.com" - - assertThat(generator.getTextHeadline(str)).isEqualTo("Sharing text") - assertThat(generator.getTextHeadline(url)).isEqualTo("Sharing link") - - assertThat(generator.getImageWithTextHeadline(str)).isEqualTo("Sharing image with text") - assertThat(generator.getImageWithTextHeadline(url)).isEqualTo("Sharing image with link") - - assertThat(generator.getImagesHeadline(1)).isEqualTo("Sharing image") - assertThat(generator.getImagesHeadline(4)).isEqualTo("Sharing 4 images") - - assertThat(generator.getVideosHeadline(1)).isEqualTo("Sharing video") - assertThat(generator.getVideosHeadline(4)).isEqualTo("Sharing 4 videos") - - assertThat(generator.getItemsHeadline(1)).isEqualTo("Sharing 1 item") - assertThat(generator.getItemsHeadline(4)).isEqualTo("Sharing 4 items") - } -} -- cgit v1.2.3-59-g8ed1b From f016e5ff5b3d4d1b515aff469e9e0c18531d0de5 Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Wed, 22 Mar 2023 10:49:49 +0000 Subject: Revert "Revert "Add headline to sharesheet."" This reverts commit 6aaafb277958144a39f9ff4c8328a29eb2db6e38. Reason for revert: Fixing test to submit Change-Id: I99e4f091d5f8812d18415993c07c78e739926c40 --- java/res/layout/chooser_grid_preview_file.xml | 10 +-- java/res/layout/chooser_grid_preview_image.xml | 16 +--- java/res/layout/chooser_grid_preview_text.xml | 10 +-- java/res/layout/chooser_headline_row.xml | 57 ++++++++++++ java/res/values/strings.xml | 30 +++++++ .../android/intentresolver/ChooserActivity.java | 4 +- .../contentpreview/ChooserContentPreviewUi.java | 42 ++++++--- .../contentpreview/ContentPreviewUi.java | 17 +++- .../contentpreview/FileContentPreviewUi.java | 9 +- .../contentpreview/HeadlineGenerator.kt | 35 ++++++++ .../contentpreview/HeadlineGeneratorImpl.kt | 67 ++++++++++++++ .../contentpreview/ImageContentPreviewUi.java | 21 ++++- .../contentpreview/TextContentPreviewUi.java | 9 +- .../contentpreview/UnifiedContentPreviewUi.java | 100 ++++++++++++++------- .../UnbundledChooserActivityTest.java | 39 ++++++++ .../contentpreview/ChooserContentPreviewUiTest.kt | 34 ++++--- .../contentpreview/HeadlineGeneratorImplTest.kt | 49 ++++++++++ 17 files changed, 454 insertions(+), 95 deletions(-) create mode 100644 java/res/layout/chooser_headline_row.xml create mode 100644 java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt create mode 100644 java/tests/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt (limited to 'java/tests') diff --git a/java/res/layout/chooser_grid_preview_file.xml b/java/res/layout/chooser_grid_preview_file.xml index 6ba06b3d..036c5318 100644 --- a/java/res/layout/chooser_grid_preview_file.xml +++ b/java/res/layout/chooser_grid_preview_file.xml @@ -27,6 +27,8 @@ android:paddingBottom="@dimen/chooser_view_spacing" android:background="?android:attr/colorBackground"> + + - - - + - - + + - - + + + + + + + + + + + diff --git a/java/res/values/strings.xml b/java/res/values/strings.xml index d1c97c7e..f38666e4 100644 --- a/java/res/values/strings.xml +++ b/java/res/values/strings.xml @@ -137,6 +137,36 @@ } + + Sharing text + + Sharing link + + {count, plural, + =1 {Sharing image} + other {Sharing # images} + } + + + {count, plural, + =1 {Sharing video} + other {Sharing # videos} + } + + + {count, plural, + =1 {Sharing # item} + other {Sharing # items} + } + + + Sharing image with text + + Sharing image with link + No recommended people to share with diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 341e1d52..1dce3b97 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -84,6 +84,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.contentpreview.HeadlineGeneratorImpl; import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.flags.FeatureFlagRepositoryFactory; import com.android.intentresolver.flags.Flags; @@ -291,7 +292,8 @@ public class ChooserActivity extends ResolverActivity implements createPreviewImageLoader(), createChooserActionFactory(), mEnterTransitionAnimationDelegate, - mFeatureFlagRepository); + mFeatureFlagRepository, + new HeadlineGeneratorImpl(this)); setAdditionalTargets(mChooserRequest.getAdditionalTargets()); diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index de454cfd..6892b32c 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -103,7 +103,8 @@ public final class ChooserContentPreviewUi { ImageLoader imageLoader, ActionFactory actionFactory, TransitionElementStatusCallback transitionElementStatusCallback, - FeatureFlagRepository featureFlagRepository) { + FeatureFlagRepository featureFlagRepository, + HeadlineGenerator headlineGenerator) { mContentPreviewUi = createContentPreview( targetIntent, @@ -112,7 +113,8 @@ public final class ChooserContentPreviewUi { imageLoader, actionFactory, transitionElementStatusCallback, - featureFlagRepository); + featureFlagRepository, + headlineGenerator); if (mContentPreviewUi.getType() != CONTENT_PREVIEW_IMAGE) { transitionElementStatusCallback.onAllTransitionElementsReady(); } @@ -125,7 +127,8 @@ public final class ChooserContentPreviewUi { ImageLoader imageLoader, ActionFactory actionFactory, TransitionElementStatusCallback transitionElementStatusCallback, - FeatureFlagRepository featureFlagRepository) { + FeatureFlagRepository featureFlagRepository, + HeadlineGenerator headlineGenerator) { /* 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 @@ -139,12 +142,20 @@ public final class ChooserContentPreviewUi { if (!(isSend || isSendMultiple) || (type != null && ClipDescription.compareMimeTypes(type, "text/*"))) { return createTextPreview( - targetIntent, actionFactory, imageLoader, featureFlagRepository); + targetIntent, + actionFactory, + imageLoader, + featureFlagRepository, + headlineGenerator); } List uris = extractContentUris(targetIntent); if (uris.isEmpty()) { return createTextPreview( - targetIntent, actionFactory, imageLoader, featureFlagRepository); + targetIntent, + actionFactory, + imageLoader, + featureFlagRepository, + headlineGenerator); } ArrayList files = new ArrayList<>(uris.size()); int previewCount = readFileInfo(contentResolver, typeClassifier, uris, files); @@ -153,7 +164,8 @@ public final class ChooserContentPreviewUi { files, actionFactory, imageLoader, - featureFlagRepository); + featureFlagRepository, + headlineGenerator); } if (featureFlagRepository.isEnabled(Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW)) { return new UnifiedContentPreviewUi( @@ -163,14 +175,16 @@ public final class ChooserContentPreviewUi { imageLoader, typeClassifier, transitionElementStatusCallback, - featureFlagRepository); + featureFlagRepository, + headlineGenerator); } if (previewCount < uris.size()) { return new FileContentPreviewUi( files, actionFactory, imageLoader, - featureFlagRepository); + featureFlagRepository, + headlineGenerator); } // The legacy (3-image) image preview is on it's way out and it's unlikely that we'd end up // here. To preserve the legacy behavior, before using it, check that all uris are images. @@ -180,7 +194,8 @@ public final class ChooserContentPreviewUi { files, actionFactory, imageLoader, - featureFlagRepository); + featureFlagRepository, + headlineGenerator); } } return new ImageContentPreviewUi( @@ -192,7 +207,8 @@ public final class ChooserContentPreviewUi { actionFactory, imageLoader, transitionElementStatusCallback, - featureFlagRepository); + featureFlagRepository, + headlineGenerator); } public int getPreferredContentPreview() { @@ -307,7 +323,8 @@ public final class ChooserContentPreviewUi { Intent targetIntent, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, - FeatureFlagRepository featureFlagRepository) { + FeatureFlagRepository featureFlagRepository, + HeadlineGenerator headlineGenerator) { CharSequence sharingText = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); String previewTitle = targetIntent.getStringExtra(Intent.EXTRA_TITLE); ClipData previewData = targetIntent.getClipData(); @@ -324,7 +341,8 @@ public final class ChooserContentPreviewUi { previewThumbnail, actionFactory, imageLoader, - featureFlagRepository); + featureFlagRepository, + headlineGenerator); } private static List extractContentUris(Intent targetIntent) { diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java index 79444b4e..a6bc2164 100644 --- a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java @@ -24,6 +24,7 @@ import android.content.res.Resources; import android.graphics.Bitmap; import android.net.Uri; import android.os.UserHandle; +import android.text.TextUtils; import android.util.Log; import android.view.LayoutInflater; import android.view.View; @@ -114,7 +115,21 @@ abstract class ContentPreviewUi { fadeAnim.start(); } - protected static void displayPayloadReselectionAction( + protected static void displayHeadline(ViewGroup layout, String headline) { + if (layout != null) { + TextView titleView = layout.findViewById(R.id.headline); + if (titleView != null) { + if (!TextUtils.isEmpty(headline)) { + titleView.setText(headline); + titleView.setVisibility(View.VISIBLE); + } else { + titleView.setVisibility(View.GONE); + } + } + } + } + + protected static void displayModifyShareAction( ViewGroup layout, ChooserContentPreviewUi.ActionFactory actionFactory, FeatureFlagRepository featureFlagRepository) { diff --git a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java index 2c5def8b..52e20cf0 100644 --- a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java @@ -45,16 +45,19 @@ class FileContentPreviewUi extends ContentPreviewUi { private final ChooserContentPreviewUi.ActionFactory mActionFactory; private final ImageLoader mImageLoader; private final FeatureFlagRepository mFeatureFlagRepository; + private final HeadlineGenerator mHeadlineGenerator; FileContentPreviewUi( List files, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, - FeatureFlagRepository featureFlagRepository) { + FeatureFlagRepository featureFlagRepository, + HeadlineGenerator headlineGenerator) { mFiles = files; mActionFactory = actionFactory; mImageLoader = imageLoader; mFeatureFlagRepository = featureFlagRepository; + mHeadlineGenerator = headlineGenerator; } @Override @@ -65,7 +68,7 @@ class FileContentPreviewUi extends ContentPreviewUi { @Override public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { ViewGroup layout = displayInternal(resources, layoutInflater, parent); - displayPayloadReselectionAction(layout, mActionFactory, mFeatureFlagRepository); + displayModifyShareAction(layout, mActionFactory, mFeatureFlagRepository); return layout; } @@ -77,6 +80,8 @@ class FileContentPreviewUi extends ContentPreviewUi { final int uriCount = mFiles.size(); + displayHeadline(contentPreviewLayout, mHeadlineGenerator.getItemsHeadline(mFiles.size())); + if (uriCount == 0) { contentPreviewLayout.setVisibility(View.GONE); Log.i(TAG, "Appears to be no uris available in EXTRA_STREAM," diff --git a/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt b/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt new file mode 100644 index 00000000..e32bb5c4 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt @@ -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 + +private const val PLURALS_COUNT = "count" + +/** + * HeadlineGenerator generates the text to show at the top of the sharesheet as a brief + * description of the content being shared. + */ +interface HeadlineGenerator { + fun getTextHeadline(text: CharSequence): String + + fun getImageWithTextHeadline(text: CharSequence): String + + fun getImagesHeadline(count: Int): String + + fun getVideosHeadline(count: Int): String + + fun getItemsHeadline(count: Int): String +} diff --git a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt new file mode 100644 index 00000000..ae44294c --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt @@ -0,0 +1,67 @@ +/* + * 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.Context +import com.android.intentresolver.R +import android.util.PluralsMessageFormatter + +private const val PLURALS_COUNT = "count" + +/** + * HeadlineGenerator generates the text to show at the top of the sharesheet as a brief + * description of the content being shared. + */ +class HeadlineGeneratorImpl(private val context: Context) : HeadlineGenerator { + override fun getTextHeadline(text: CharSequence): String { + if (text.toString().isHttpUri()) { + return context.getString(R.string.sharing_link) + } + return context.getString(R.string.sharing_text) + } + + override fun getImageWithTextHeadline(text: CharSequence): String { + if (text.toString().isHttpUri()) { + return context.getString(R.string.sharing_image_with_link) + } + return context.getString(R.string.sharing_image_with_text) + } + + override fun getImagesHeadline(count: Int): String { + return PluralsMessageFormatter.format( + context.resources, + mapOf(PLURALS_COUNT to count), + R.string.sharing_images + ) + } + + override fun getVideosHeadline(count: Int): String { + return PluralsMessageFormatter.format( + context.resources, + mapOf(PLURALS_COUNT to count), + R.string.sharing_videos + ) + } + + override fun getItemsHeadline(count: Int): String { + return PluralsMessageFormatter.format( + context.resources, + mapOf(PLURALS_COUNT to count), + R.string.sharing_items + ) + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java index 5f3bdf40..f2c0564a 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java @@ -54,6 +54,7 @@ class ImageContentPreviewUi extends ContentPreviewUi { private final ImageLoader mImageLoader; private final TransitionElementStatusCallback mTransitionElementStatusCallback; private final FeatureFlagRepository mFeatureFlagRepository; + private final HeadlineGenerator mHeadlineGenerator; ImageContentPreviewUi( List imageUris, @@ -61,13 +62,15 @@ class ImageContentPreviewUi extends ContentPreviewUi { ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, TransitionElementStatusCallback transitionElementStatusCallback, - FeatureFlagRepository featureFlagRepository) { + FeatureFlagRepository featureFlagRepository, + HeadlineGenerator headlineGenerator) { mImageUris = imageUris; mText = text; mActionFactory = actionFactory; mImageLoader = imageLoader; mTransitionElementStatusCallback = transitionElementStatusCallback; mFeatureFlagRepository = featureFlagRepository; + mHeadlineGenerator = headlineGenerator; mImageLoader.prePopulate(mImageUris); } @@ -80,7 +83,7 @@ class ImageContentPreviewUi extends ContentPreviewUi { @Override public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { ViewGroup layout = displayInternal(layoutInflater, parent); - displayPayloadReselectionAction(layout, mActionFactory, mFeatureFlagRepository); + displayModifyShareAction(layout, mActionFactory, mFeatureFlagRepository); return layout; } @@ -113,6 +116,8 @@ class ImageContentPreviewUi extends ContentPreviewUi { imagePreview.setTransitionElementStatusCallback(mTransitionElementStatusCallback); imagePreview.setImages(mImageUris, mImageLoader); + updateHeadline(contentPreviewLayout); + return contentPreviewLayout; } @@ -140,6 +145,17 @@ class ImageContentPreviewUi extends ContentPreviewUi { com.android.internal.R.id.content_preview_image_area); } + private void updateHeadline(ViewGroup contentPreview) { + CheckBox includeTextCheckbox = contentPreview.requireViewById(R.id.include_text_action); + if (includeTextCheckbox.getVisibility() == View.VISIBLE + && includeTextCheckbox.isChecked()) { + displayHeadline(contentPreview, mHeadlineGenerator.getImageWithTextHeadline(mText)); + } else { + displayHeadline( + contentPreview, mHeadlineGenerator.getImagesHeadline(mImageUris.size())); + } + } + private void setTextInImagePreviewVisibility( ViewGroup contentPreview, ChooserContentPreviewUi.ActionFactory actionFactory) { int visibility = mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW) @@ -169,6 +185,7 @@ class ImageContentPreviewUi extends ContentPreviewUi { TransitionManager.beginDelayedTransition((ViewGroup) textView.getParent()); textView.setVisibility(isChecked ? View.VISIBLE : View.GONE); shareTextAction.accept(!isChecked); + updateHeadline(contentPreview); }); } actionView.setVisibility(visibility); diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java index e143954e..d0cba5bb 100644 --- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java @@ -46,6 +46,7 @@ class TextContentPreviewUi extends ContentPreviewUi { private final ImageLoader mImageLoader; private final ChooserContentPreviewUi.ActionFactory mActionFactory; private final FeatureFlagRepository mFeatureFlagRepository; + private final HeadlineGenerator mHeadlineGenerator; TextContentPreviewUi( @Nullable CharSequence sharingText, @@ -53,13 +54,15 @@ class TextContentPreviewUi extends ContentPreviewUi { @Nullable Uri previewThumbnail, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, - FeatureFlagRepository featureFlagRepository) { + FeatureFlagRepository featureFlagRepository, + HeadlineGenerator headlineGenerator) { mSharingText = sharingText; mPreviewTitle = previewTitle; mPreviewThumbnail = previewThumbnail; mImageLoader = imageLoader; mActionFactory = actionFactory; mFeatureFlagRepository = featureFlagRepository; + mHeadlineGenerator = headlineGenerator; } @Override @@ -70,7 +73,7 @@ class TextContentPreviewUi extends ContentPreviewUi { @Override public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { ViewGroup layout = displayInternal(layoutInflater, parent); - displayPayloadReselectionAction(layout, mActionFactory, mFeatureFlagRepository); + displayModifyShareAction(layout, mActionFactory, mFeatureFlagRepository); return layout; } @@ -122,6 +125,8 @@ class TextContentPreviewUi extends ContentPreviewUi { bitmap)); } + displayHeadline(contentPreviewLayout, mHeadlineGenerator.getTextHeadline(mSharingText)); + return contentPreviewLayout; } diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java index c4e6feb7..2d2ae52b 100644 --- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -55,6 +55,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { private final MimeTypeClassifier mTypeClassifier; private final TransitionElementStatusCallback mTransitionElementStatusCallback; private final FeatureFlagRepository mFeatureFlagRepository; + private final HeadlineGenerator mHeadlineGenerator; UnifiedContentPreviewUi( List files, @@ -63,7 +64,8 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { ImageLoader imageLoader, MimeTypeClassifier typeClassifier, TransitionElementStatusCallback transitionElementStatusCallback, - FeatureFlagRepository featureFlagRepository) { + FeatureFlagRepository featureFlagRepository, + HeadlineGenerator headlineGenerator) { mFiles = files; mText = text; mActionFactory = actionFactory; @@ -71,6 +73,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { mTypeClassifier = typeClassifier; mTransitionElementStatusCallback = transitionElementStatusCallback; mFeatureFlagRepository = featureFlagRepository; + mHeadlineGenerator = headlineGenerator; mImageLoader.prePopulate(mFiles.stream() .map(FileInfo::getPreviewUri) @@ -86,7 +89,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { @Override public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { ViewGroup layout = displayInternal(layoutInflater, parent); - displayPayloadReselectionAction(layout, mActionFactory, mFeatureFlagRepository); + displayModifyShareAction(layout, mActionFactory, mFeatureFlagRepository); return layout; } @@ -115,20 +118,47 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { return contentPreviewLayout; } - setTextInImagePreviewVisibility(contentPreviewLayout, mActionFactory); imagePreview.setTransitionElementStatusCallback(mTransitionElementStatusCallback); - List previews = mFiles.stream() - .filter(fileInfo -> fileInfo.getPreviewUri() != null) - .map(fileInfo -> - new ScrollableImagePreviewView.Preview( - getPreviewType(fileInfo.getMimeType()), - fileInfo.getPreviewUri())) - .toList(); + + List previews = new ArrayList<>(); + boolean allImages = !mFiles.isEmpty(); + boolean allVideos = !mFiles.isEmpty(); + for (FileInfo fileInfo : mFiles) { + ScrollableImagePreviewView.PreviewType previewType = + getPreviewType(fileInfo.getMimeType()); + allImages = allImages && previewType == ScrollableImagePreviewView.PreviewType.Image; + allVideos = allVideos && previewType == ScrollableImagePreviewView.PreviewType.Video; + + if (fileInfo.getPreviewUri() != null) { + previews.add(new ScrollableImagePreviewView.Preview( + previewType, + fileInfo.getPreviewUri())); + } + } imagePreview.setPreviews( previews, mFiles.size() - previews.size(), mImageLoader); + if (mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW) + && !TextUtils.isEmpty(mText) + && mFiles.size() == 1 + && allImages) { + setTextInImagePreviewVisibility(contentPreviewLayout, mActionFactory); + updateTextWithImageHeadline(contentPreviewLayout); + } else { + if (allImages) { + displayHeadline( + contentPreviewLayout, mHeadlineGenerator.getImagesHeadline(mFiles.size())); + } else if (allVideos) { + displayHeadline( + contentPreviewLayout, mHeadlineGenerator.getVideosHeadline(mFiles.size())); + } else { + displayHeadline( + contentPreviewLayout, mHeadlineGenerator.getItemsHeadline(mFiles.size())); + } + } + return contentPreviewLayout; } @@ -155,38 +185,42 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { return previewLayout.findViewById(R.id.scrollable_image_preview); } + private void updateTextWithImageHeadline(ViewGroup contentPreview) { + CheckBox actionView = contentPreview.requireViewById(R.id.include_text_action); + if (actionView.getVisibility() == View.VISIBLE && actionView.isChecked()) { + displayHeadline(contentPreview, mHeadlineGenerator.getImageWithTextHeadline(mText)); + } else { + displayHeadline( + contentPreview, mHeadlineGenerator.getImagesHeadline(mFiles.size())); + } + } + 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.setVisibility(View.VISIBLE); + boolean isLink = HttpUriMatcher.isHttpUri(mText.toString()); textView.setAutoLinkMask(isLink ? Linkify.WEB_URLS : 0); textView.setText(mText); - if (visibility == View.VISIBLE) { - final int[] actionLabels = isLink - ? new int[] { R.string.include_link, R.string.exclude_link } - : new int[] { R.string.include_text, R.string.exclude_text }; - final Consumer shareTextAction = actionFactory.getExcludeSharedTextAction(); - actionView.setChecked(true); - actionView.setText(actionLabels[1]); - shareTextAction.accept(false); - actionView.setOnCheckedChangeListener((view, isChecked) -> { - view.setText(actionLabels[isChecked ? 1 : 0]); - TransitionManager.beginDelayedTransition((ViewGroup) textView.getParent()); - textView.setVisibility(isChecked ? View.VISIBLE : View.GONE); - shareTextAction.accept(!isChecked); - }); - } - actionView.setVisibility(visibility); + final int[] actionLabels = isLink + ? new int[] { R.string.include_link, R.string.exclude_link } + : new int[] { R.string.include_text, R.string.exclude_text }; + final Consumer shareTextAction = actionFactory.getExcludeSharedTextAction(); + actionView.setChecked(true); + actionView.setText(actionLabels[1]); + shareTextAction.accept(false); + actionView.setOnCheckedChangeListener((view, isChecked) -> { + view.setText(actionLabels[isChecked ? 1 : 0]); + TransitionManager.beginDelayedTransition((ViewGroup) textView.getParent()); + textView.setVisibility(isChecked ? View.VISIBLE : View.GONE); + shareTextAction.accept(!isChecked); + updateTextWithImageHeadline(contentPreview); + }); + actionView.setVisibility(View.VISIBLE); } private ScrollableImagePreviewView.PreviewType getPreviewType(String mimeType) { diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 8b9aaa60..9139a4c6 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -26,6 +26,7 @@ import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist; import static androidx.test.espresso.assertion.ViewAssertions.matches; import static androidx.test.espresso.matcher.ViewMatchers.hasSibling; import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withText; @@ -98,6 +99,7 @@ import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.test.espresso.contrib.RecyclerViewActions; import androidx.test.espresso.matcher.BoundedDiagnosingMatcher; +import androidx.test.espresso.matcher.ViewMatchers; import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.rule.ActivityTestRule; @@ -1067,6 +1069,43 @@ public class UnbundledChooserActivityTest { .check(matches(isDisplayed())); } + @Test + @RequireFeatureFlags( + flags = { Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME }, + values = { true }) + public void testNoTextPreviewWhenTextIsSharedWithMultipleImages() { + final Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" + + R.drawable.test320x240); + final String sharedText = "text-" + System.currentTimeMillis(); + + ArrayList uris = new ArrayList<>(); + uris.add(uri); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); + ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); + ChooserActivityOverrideData.getInstance().isImageType = true; + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntentAsUser( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class), + Mockito.any(UserHandle.class))) + .thenReturn(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(com.android.internal.R.id.content_preview_text)) + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))); + } + @Test public void testOnCreateLogging() { Intent sendIntent = createSendTextIntent(); diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt index 58b8a21d..82bf94c4 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt @@ -44,6 +44,7 @@ class ChooserContentPreviewUiTest { private val imageClassifier = MimeTypeClassifier { mimeType -> mimeType != null && ClipDescription.compareMimeTypes(mimeType, "image/*") } + private val headlineGenerator = mock() private val imageLoader = object : ImageLoader { override fun loadImage(uri: Uri, callback: Consumer) { callback.accept(null) @@ -76,7 +77,8 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository + featureFlagRepository, + headlineGenerator ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) @@ -96,7 +98,8 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository + featureFlagRepository, + headlineGenerator ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) @@ -117,7 +120,8 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository + featureFlagRepository, + headlineGenerator ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) @@ -138,7 +142,8 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository + featureFlagRepository, + headlineGenerator ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) @@ -160,7 +165,8 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository + featureFlagRepository, + headlineGenerator ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) @@ -185,7 +191,8 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository + featureFlagRepository, + headlineGenerator ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) @@ -208,7 +215,8 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository + featureFlagRepository, + headlineGenerator ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) @@ -237,7 +245,8 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository + featureFlagRepository, + headlineGenerator ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) @@ -266,7 +275,8 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository + featureFlagRepository, + headlineGenerator ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) @@ -297,7 +307,8 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository + featureFlagRepository, + headlineGenerator ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) @@ -326,7 +337,8 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository + featureFlagRepository, + headlineGenerator ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) diff --git a/java/tests/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt new file mode 100644 index 00000000..9becce99 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt @@ -0,0 +1,49 @@ +/* + * 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 androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Test +import org.junit.runner.RunWith +import com.google.common.truth.Truth.assertThat + +@RunWith(AndroidJUnit4::class) +class HeadlineGeneratorImplTest { + @Test + fun testHeadlineGeneration() { + val generator = HeadlineGeneratorImpl( + InstrumentationRegistry.getInstrumentation().getTargetContext()) + val str = "Some sting" + val url = "http://www.google.com" + + assertThat(generator.getTextHeadline(str)).isEqualTo("Sharing text") + assertThat(generator.getTextHeadline(url)).isEqualTo("Sharing link") + + assertThat(generator.getImageWithTextHeadline(str)).isEqualTo("Sharing image with text") + assertThat(generator.getImageWithTextHeadline(url)).isEqualTo("Sharing image with link") + + assertThat(generator.getImagesHeadline(1)).isEqualTo("Sharing image") + assertThat(generator.getImagesHeadline(4)).isEqualTo("Sharing 4 images") + + assertThat(generator.getVideosHeadline(1)).isEqualTo("Sharing video") + assertThat(generator.getVideosHeadline(4)).isEqualTo("Sharing 4 videos") + + assertThat(generator.getItemsHeadline(1)).isEqualTo("Sharing 1 item") + assertThat(generator.getItemsHeadline(4)).isEqualTo("Sharing 4 items") + } +} -- cgit v1.2.3-59-g8ed1b From 10f22425705485803cc723bcdc61623926a402c9 Mon Sep 17 00:00:00 2001 From: Himanshu Gupta Date: Sat, 25 Feb 2023 14:39:01 +0000 Subject: Migrating AppCloning code to unbundled sharesheet. This CL encapsulates the work done in previous CLs for the frameworks sharesheet: 1. ag/20982903 2. ag/20980382 3. ag/20480246 4. ag/21072117 5. ag/21223364 6. ag/21117396 7. ag/21465730 With this CL, Cloned Apps can be shown in unbundled sharesheet, at par with frameworks. Bug: 273294251 Test: atest com.android.intentresolver Change-Id: Ic01a93f7279c8beb998b3e98f53c459c5ed2b1bf --- AndroidManifest.xml | 1 + .../AbstractMultiProfilePagerAdapter.java | 9 +- .../intentresolver/AnnotatedUserHandles.java | 42 ++- .../android/intentresolver/ChooserActivity.java | 85 +++-- .../android/intentresolver/ChooserListAdapter.java | 9 +- .../ChooserMultiProfilePagerAdapter.java | 6 + .../GenericMultiProfilePagerAdapter.java | 24 +- .../NoAppsAvailableEmptyStateProvider.java | 12 +- .../NoCrossProfileEmptyStateProvider.java | 11 +- .../android/intentresolver/ResolverActivity.java | 184 +++++++--- .../intentresolver/ResolverListAdapter.java | 33 +- .../intentresolver/ResolverListController.java | 45 +-- .../ResolverMultiProfilePagerAdapter.java | 10 +- .../model/AbstractResolverComparator.java | 72 +++- .../AppPredictionServiceResolverComparator.java | 43 ++- .../model/ResolverComparatorModel.java | 7 +- .../ResolverRankerServiceResolverComparator.java | 281 +++++++++------ .../ChooserActivityOverrideData.java | 16 +- .../intentresolver/ChooserListAdapterTest.kt | 9 +- .../intentresolver/ChooserWrapperActivity.java | 32 +- .../intentresolver/ResolverActivityTest.java | 397 +++++++++++++++++---- .../intentresolver/ResolverDataProvider.java | 48 +++ .../intentresolver/ResolverWrapperActivity.java | 50 +-- .../intentresolver/ResolverWrapperAdapter.java | 6 +- .../intentresolver/ShortcutSelectionLogicTest.kt | 9 +- .../UnbundledChooserActivityTest.java | 142 ++++++-- .../UnbundledChooserActivityWorkProfileTest.java | 16 +- .../chooser/ImmutableTargetInfoTest.kt | 12 +- .../intentresolver/chooser/TargetInfoTest.kt | 9 +- .../model/AbstractResolverComparatorTest.java | 8 +- 30 files changed, 1186 insertions(+), 442 deletions(-) (limited to 'java/tests') diff --git a/AndroidManifest.xml b/AndroidManifest.xml index fc99c0b2..5228827d 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -35,6 +35,7 @@ + mLoadedPages; private final EmptyStateProvider mEmptyStateProvider; private final UserHandle mWorkProfileUserHandle; + private final UserHandle mCloneProfileUserHandle; private final Supplier mWorkProfileQuietModeChecker; // True when work is quiet. AbstractMultiProfilePagerAdapter( @@ -69,11 +70,13 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { int currentPage, EmptyStateProvider emptyStateProvider, Supplier workProfileQuietModeChecker, - UserHandle workProfileUserHandle) { + UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle) { mContext = Objects.requireNonNull(context); mCurrentPage = currentPage; mLoadedPages = new HashSet<>(); mWorkProfileUserHandle = workProfileUserHandle; + mCloneProfileUserHandle = cloneProfileUserHandle; mEmptyStateProvider = emptyStateProvider; mWorkProfileQuietModeChecker = workProfileQuietModeChecker; } @@ -160,6 +163,10 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { return null; } + public UserHandle getCloneUserHandle() { + return mCloneProfileUserHandle; + } + /** * Returns the {@link ProfileDescriptor} relevant to the given pageIndex. *

    diff --git a/java/src/com/android/intentresolver/AnnotatedUserHandles.java b/java/src/com/android/intentresolver/AnnotatedUserHandles.java index b4365b84..769195ed 100644 --- a/java/src/com/android/intentresolver/AnnotatedUserHandles.java +++ b/java/src/com/android/intentresolver/AnnotatedUserHandles.java @@ -87,6 +87,11 @@ public final class AnnotatedUserHandles { // TODO: integrate logic for `ResolverActivity.EXTRA_CALLING_USER`. userHandleSharesheetLaunchedAs = UserHandle.of(UserHandle.myUserId()); + // ActivityManager.getCurrentUser() refers to the current Foreground user. When clone/work + // profile is active, we always make the personal tab from the foreground user. + // Outside profiles, current foreground user is potentially the same as the sharesheet + // process's user (UserHandle.myUserId()), so we continue to create personal tab with the + // current foreground user. personalProfileUserHandle = UserHandle.of(ActivityManager.getCurrentUser()); UserManager userManager = forShareActivity.getSystemService(UserManager.class); @@ -100,14 +105,43 @@ public final class AnnotatedUserHandles { @Nullable private static UserHandle getWorkProfileForUser( UserManager userManager, UserHandle profileOwnerUserHandle) { - return userManager.getProfiles(profileOwnerUserHandle.getIdentifier()).stream() - .filter(info -> info.isManagedProfile()).findFirst() - .map(info -> info.getUserHandle()).orElse(null); + return userManager.getProfiles(profileOwnerUserHandle.getIdentifier()) + .stream() + .filter(info -> info.isManagedProfile()) + .findFirst() + .map(info -> info.getUserHandle()) + .orElse(null); } @Nullable private static UserHandle getCloneProfileForUser( UserManager userManager, UserHandle profileOwnerUserHandle) { - return null; // Not yet supported in framework. + return userManager.getProfiles(profileOwnerUserHandle.getIdentifier()) + .stream() + .filter(info -> info.isCloneProfile()) + .findFirst() + .map(info -> info.getUserHandle()) + .orElse(null); + } + + /** + * Returns the {@link UserHandle} to use when querying resolutions for intents in a + * {@link ResolverListController} configured for the provided {@code userHandle}. + */ + public UserHandle getQueryIntentsUser(UserHandle userHandle) { + // In case launching app is in clonedProfile, and we are building the personal tab, intent + // resolution will be attempted as clonedUser instead of user 0. This is because intent + // resolution from user 0 and clonedUser is not guaranteed to return same results. + // We do not care about the case when personal adapter is started with non-root user + // (secondary user case), as clone profile is guaranteed to be non-active in that case. + UserHandle queryIntentsUser = userHandle; + if (isLaunchedAsCloneProfile() && userHandle.equals(personalProfileUserHandle)) { + queryIntentsUser = cloneProfileUserHandle; + } + return queryIntentsUser; + } + + private Boolean isLaunchedAsCloneProfile() { + return userHandleSharesheetLaunchedAs.equals(cloneProfileUserHandle); } } diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index ae5be26d..bae1feb2 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -495,7 +495,7 @@ public class ChooserActivity extends ResolverActivity implements return new NoCrossProfileEmptyStateProvider(getPersonalProfileUserHandle(), noWorkToPersonalEmptyState, noPersonalToWorkEmptyState, - createCrossProfileIntentsChecker(), createMyUserIdProvider()); + createCrossProfileIntentsChecker(), getTabOwnerUserHandleForLaunch()); } private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForOneProfile( @@ -508,13 +508,14 @@ public class ChooserActivity extends ResolverActivity implements initialIntents, rList, filterLastUsed, - /* userHandle */ UserHandle.of(UserHandle.myUserId())); + /* userHandle */ getPersonalProfileUserHandle()); return new ChooserMultiProfilePagerAdapter( /* context */ this, adapter, createEmptyStateProvider(/* workProfileUserHandle= */ null), /* workProfileQuietModeChecker= */ () -> false, /* workProfileUserHandle= */ null, + getCloneProfileUserHandle(), mMaxTargetsPerRow); } @@ -545,13 +546,14 @@ public class ChooserActivity extends ResolverActivity implements () -> mWorkProfileAvailability.isQuietModeEnabled(), selectedProfile, getWorkProfileUserHandle(), + getCloneProfileUserHandle(), mMaxTargetsPerRow); } private int findSelectedProfile() { int selectedProfile = getSelectedProfileExtra(); if (selectedProfile == -1) { - selectedProfile = getProfileForUser(getUser()); + selectedProfile = getProfileForUser(getTabOwnerUserHandleForLaunch()); } return selectedProfile; } @@ -860,7 +862,11 @@ public class ChooserActivity extends ResolverActivity implements ChooserTargetActionsDialogFragment.show( getSupportFragmentManager(), targetList, - mChooserMultiProfilePagerAdapter.getCurrentUserHandle(), + // Adding userHandle from ResolveInfo allows the app icon in Dialog Box to be + // resolved correctly within the same tab. + getResolveInfoUserHandle( + targetInfo.getResolveInfo(), + mChooserMultiProfilePagerAdapter.getCurrentUserHandle()), shortcutIdKey, shortcutTitle, isShortcutPinned, @@ -892,11 +898,14 @@ public class ChooserActivity extends ResolverActivity implements if (targetInfo.isMultiDisplayResolveInfo()) { MultiDisplayResolveInfo mti = (MultiDisplayResolveInfo) targetInfo; if (!mti.hasSelected()) { + // Add userHandle based badge to the stackedAppDialogBox. ChooserStackedAppDialogFragment.show( getSupportFragmentManager(), mti, which, - mChooserMultiProfilePagerAdapter.getCurrentUserHandle()); + getResolveInfoUserHandle( + targetInfo.getResolveInfo(), + mChooserMultiProfilePagerAdapter.getCurrentUserHandle())); return; } } @@ -1008,9 +1017,11 @@ public class ChooserActivity extends ResolverActivity implements mChooserMultiProfilePagerAdapter.getActiveListAdapter(); if (currentListAdapter != null) { sendImpressionToAppPredictor(info, currentListAdapter); - currentListAdapter.updateModel(info.getResolvedComponentName()); - currentListAdapter.updateChooserCounts(ri.activityInfo.packageName, - targetIntent.getAction()); + currentListAdapter.updateModel(info); + currentListAdapter.updateChooserCounts( + ri.activityInfo.packageName, + targetIntent.getAction(), + ri.userHandle); } if (DEBUG) { Log.d(TAG, "ResolveInfo Package is " + ri.activityInfo.packageName); @@ -1096,22 +1107,33 @@ public class ChooserActivity extends ResolverActivity implements @Nullable private AppPredictor getAppPredictor(UserHandle userHandle) { ProfileRecord record = getProfileRecord(userHandle); - return (record == null) ? null : record.appPredictor; + // We cannot use APS service when clone profile is present as APS service cannot sort + // cross profile targets as of now. + return (record == null || getCloneProfileUserHandle() != null) ? null : record.appPredictor; } /** * Sort intents alphabetically based on display label. */ static class AzInfoComparator implements Comparator { - Collator mCollator; + Comparator mComparator; AzInfoComparator(Context context) { - mCollator = Collator.getInstance(context.getResources().getConfiguration().locale); + Collator collator = Collator + .getInstance(context.getResources().getConfiguration().locale); + // Adding two stage comparator, first stage compares using displayLabel, next stage + // compares using resolveInfo.userHandle + mComparator = Comparator.comparing(DisplayResolveInfo::getDisplayLabel, collator) + .thenComparingInt(displayResolveInfo -> + getResolveInfoUserHandle( + displayResolveInfo.getResolveInfo(), + // TODO: User resolveInfo.userHandle, once its available. + UserHandle.SYSTEM).getIdentifier()); } @Override public int compare( DisplayResolveInfo lhsp, DisplayResolveInfo rhsp) { - return mCollator.compare(lhsp.getDisplayLabel(), rhsp.getDisplayLabel()); + return mComparator.compare(lhsp, rhsp); } } @@ -1129,14 +1151,16 @@ public class ChooserActivity extends ResolverActivity implements Intent targetIntent, String referrerPackageName, int launchedFromUid, - AbstractResolverComparator resolverComparator) { + AbstractResolverComparator resolverComparator, + UserHandle queryIntentsAsUser) { super( context, pm, targetIntent, referrerPackageName, launchedFromUid, - resolverComparator); + resolverComparator, + queryIntentsAsUser); } @Override @@ -1255,20 +1279,24 @@ public class ChooserActivity extends ResolverActivity implements Intent targetIntent, ChooserRequestParameters chooserRequest, int maxTargetsPerRow) { + UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile() + && userHandle.equals(getPersonalProfileUserHandle()) + ? getCloneProfileUserHandle() : userHandle; return new ChooserListAdapter( context, payloadIntents, initialIntents, rList, filterLastUsed, - resolverListController, + createListController(userHandle), userHandle, targetIntent, this, context.getPackageManager(), getChooserActivityLogger(), chooserRequest, - maxTargetsPerRow); + maxTargetsPerRow, + initialIntentsUserSpace); } @Override @@ -1281,8 +1309,13 @@ public class ChooserActivity extends ResolverActivity implements getReferrerPackageName(), appPredictor, userHandle, getChooserActivityLogger()); } else { resolverComparator = - new ResolverRankerServiceResolverComparator(this, getTargetIntent(), - getReferrerPackageName(), null, getChooserActivityLogger()); + new ResolverRankerServiceResolverComparator( + this, + getTargetIntent(), + getReferrerPackageName(), + null, + getChooserActivityLogger(), + getResolverRankerServiceUserHandleList(userHandle)); } return new ChooserListController( @@ -1291,7 +1324,8 @@ public class ChooserActivity extends ResolverActivity implements getTargetIntent(), getReferrerPackageName(), getAnnotatedUserHandles().userIdOfCallingApp, - resolverComparator); + resolverComparator, + getQueryIntentsUser(userHandle)); } @VisibleForTesting @@ -1508,17 +1542,16 @@ public class ChooserActivity extends ResolverActivity implements } /** - * Returns {@link #PROFILE_PERSONAL}, {@link #PROFILE_WORK}, or -1 if the given user handle - * does not match either the personal or work user handle. + * Returns {@link #PROFILE_WORK}, if the given user handle matches work user handle. + * Returns {@link #PROFILE_PERSONAL}, otherwise. **/ private int getProfileForUser(UserHandle currentUserHandle) { - if (currentUserHandle.equals(getPersonalProfileUserHandle())) { - return PROFILE_PERSONAL; - } else if (currentUserHandle.equals(getWorkProfileUserHandle())) { + if (currentUserHandle.equals(getWorkProfileUserHandle())) { return PROFILE_WORK; } - Log.e(TAG, "User " + currentUserHandle + " does not belong to a personal or work profile."); - return -1; + // We return personal profile, as it is the default when there is no work profile, personal + // profile represents rootUser, clonedUser & secondaryUser, covering all use cases. + return PROFILE_PERSONAL; } private ViewGroup getActiveEmptyStateView() { diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index f0651360..dab44577 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -142,7 +142,8 @@ public class ChooserListAdapter extends ResolverListAdapter { PackageManager packageManager, ChooserActivityLogger chooserActivityLogger, ChooserRequestParameters chooserRequest, - int maxRankedTargets) { + int maxRankedTargets, + UserHandle initialIntentsUserSpace) { // Don't send the initial intents through the shared ResolverActivity path, // we want to separate them into a different section. super( @@ -155,7 +156,8 @@ public class ChooserListAdapter extends ResolverListAdapter { userHandle, targetIntent, resolverListCommunicator, - false); + false, + initialIntentsUserSpace); mChooserRequest = chooserRequest; mMaxRankedTargets = maxRankedTargets; @@ -222,6 +224,7 @@ public class ChooserListAdapter extends ResolverListAdapter { ri.noResourceId = true; ri.icon = 0; } + ri.userHandle = initialIntentsUserSpace; DisplayResolveInfo displayResolveInfo = DisplayResolveInfo.newDisplayResolveInfo( ii, ri, ii, mPresentationFactory.makePresentationGetter(ri)); mCallerTargets.add(displayResolveInfo); @@ -351,6 +354,8 @@ public class ChooserListAdapter extends ResolverListAdapter { .collect(Collectors.groupingBy(target -> target.getResolvedComponentName().getPackageName() + "#" + target.getDisplayLabel() + + '#' + ResolverActivity.getResolveInfoUserHandle( + target.getResolveInfo(), getUserHandle()).getIdentifier() )) .values() .stream() diff --git a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java index 3e2ea473..9c096fd2 100644 --- a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java @@ -50,6 +50,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda EmptyStateProvider emptyStateProvider, Supplier workProfileQuietModeChecker, UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle, int maxTargetsPerRow) { this( context, @@ -59,6 +60,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda workProfileQuietModeChecker, /* defaultProfile= */ 0, workProfileUserHandle, + cloneProfileUserHandle, new BottomPaddingOverrideSupplier(context)); } @@ -70,6 +72,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda Supplier workProfileQuietModeChecker, @Profile int defaultProfile, UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle, int maxTargetsPerRow) { this( context, @@ -79,6 +82,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda workProfileQuietModeChecker, defaultProfile, workProfileUserHandle, + cloneProfileUserHandle, new BottomPaddingOverrideSupplier(context)); } @@ -90,6 +94,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda Supplier workProfileQuietModeChecker, @Profile int defaultProfile, UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle, BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) { super( context, @@ -100,6 +105,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda workProfileQuietModeChecker, defaultProfile, workProfileUserHandle, + cloneProfileUserHandle, () -> makeProfileView(context), bottomPaddingOverrideSupplier); mAdapterBinder = adapterBinder; diff --git a/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java index 7613f35f..a1c53402 100644 --- a/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java @@ -19,6 +19,7 @@ package com.android.intentresolver; import android.annotation.Nullable; import android.content.Context; import android.os.UserHandle; +import android.util.Log; import android.view.View; import android.view.ViewGroup; @@ -84,6 +85,7 @@ class GenericMultiProfilePagerAdapter< Supplier workProfileQuietModeChecker, @Profile int defaultProfile, UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle, Supplier pageViewInflater, Supplier> containerBottomPaddingOverrideSupplier) { super( @@ -91,7 +93,8 @@ class GenericMultiProfilePagerAdapter< /* currentPage= */ defaultProfile, emptyStateProvider, workProfileQuietModeChecker, - workProfileUserHandle); + workProfileUserHandle, + cloneProfileUserHandle); mListAdapterExtractor = listAdapterExtractor; mAdapterBinder = adapterBinder; @@ -145,12 +148,12 @@ class GenericMultiProfilePagerAdapter< @Override @Nullable protected ListAdapterT getListAdapterForUserHandle(UserHandle userHandle) { - if (getActiveListAdapter().getUserHandle().equals(userHandle)) { - return getActiveListAdapter(); - } - if ((getInactiveListAdapter() != null) && getInactiveListAdapter().getUserHandle().equals( - userHandle)) { - return getInactiveListAdapter(); + if (getPersonalListAdapter().getUserHandle().equals(userHandle) + || userHandle.equals(getCloneUserHandle())) { + return getPersonalListAdapter(); + } else if (getWorkListAdapter() != null + && getWorkListAdapter().getUserHandle().equals(userHandle)) { + return getWorkListAdapter(); } return null; } @@ -177,6 +180,9 @@ class GenericMultiProfilePagerAdapter< @Override public ListAdapterT getWorkListAdapter() { + if (!hasAdapterForIndex(PROFILE_WORK)) { + return null; + } return mListAdapterExtractor.apply(getAdapterForIndex(PROFILE_WORK)); } @@ -209,6 +215,10 @@ class GenericMultiProfilePagerAdapter< paddingBottom)); } + private boolean hasAdapterForIndex(int pageIndex) { + return (pageIndex < getCount()); + } + // TODO: `ChooserActivity` also has a per-profile record type. Maybe the "multi-profile pager" // should be the owner of all per-profile data (especially now that the API is generic)? private static class GenericProfileDescriptor extends diff --git a/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java b/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java index d424f295..a7b50f38 100644 --- a/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java @@ -30,7 +30,7 @@ import android.stats.devicepolicy.nano.DevicePolicyEnums; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider; +import com.android.internal.R; import java.util.List; @@ -49,16 +49,16 @@ public class NoAppsAvailableEmptyStateProvider implements EmptyStateProvider { @NonNull private final String mMetricsCategory; @NonNull - private final MyUserIdProvider mMyUserIdProvider; + private final UserHandle mTabOwnerUserHandleForLaunch; public NoAppsAvailableEmptyStateProvider(Context context, UserHandle workProfileUserHandle, UserHandle personalProfileUserHandle, String metricsCategory, - MyUserIdProvider myUserIdProvider) { + UserHandle tabOwnerUserHandleForLaunch) { mContext = context; mWorkProfileUserHandle = workProfileUserHandle; mPersonalProfileUserHandle = personalProfileUserHandle; mMetricsCategory = metricsCategory; - mMyUserIdProvider = myUserIdProvider; + mTabOwnerUserHandleForLaunch = tabOwnerUserHandleForLaunch; } @Nullable @@ -68,7 +68,7 @@ public class NoAppsAvailableEmptyStateProvider implements EmptyStateProvider { UserHandle listUserHandle = resolverListAdapter.getUserHandle(); if (mWorkProfileUserHandle != null - && (mMyUserIdProvider.getMyUserId() == listUserHandle.getIdentifier() + && (mTabOwnerUserHandleForLaunch.equals(listUserHandle) || !hasAppsInOtherProfile(resolverListAdapter))) { String title; @@ -101,7 +101,7 @@ public class NoAppsAvailableEmptyStateProvider implements EmptyStateProvider { return false; } List resolversForIntent = - adapter.getResolversForUser(UserHandle.of(mMyUserIdProvider.getMyUserId())); + adapter.getResolversForUser(mTabOwnerUserHandleForLaunch); for (ResolvedComponentInfo info : resolversForIntent) { ResolveInfo resolveInfo = info.getResolveInfoAt(0); if (resolveInfo.targetUserId != UserHandle.USER_CURRENT) { diff --git a/java/src/com/android/intentresolver/NoCrossProfileEmptyStateProvider.java b/java/src/com/android/intentresolver/NoCrossProfileEmptyStateProvider.java index 420d26c5..6f72bb00 100644 --- a/java/src/com/android/intentresolver/NoCrossProfileEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/NoCrossProfileEmptyStateProvider.java @@ -27,7 +27,6 @@ import android.os.UserHandle; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider; /** * Empty state provider that does not allow cross profile sharing, it will return a blocker @@ -39,28 +38,28 @@ public class NoCrossProfileEmptyStateProvider implements EmptyStateProvider { private final EmptyState mNoWorkToPersonalEmptyState; private final EmptyState mNoPersonalToWorkEmptyState; private final CrossProfileIntentsChecker mCrossProfileIntentsChecker; - private final MyUserIdProvider mUserIdProvider; + private final UserHandle mTabOwnerUserHandleForLaunch; public NoCrossProfileEmptyStateProvider(UserHandle personalUserHandle, EmptyState noWorkToPersonalEmptyState, EmptyState noPersonalToWorkEmptyState, CrossProfileIntentsChecker crossProfileIntentsChecker, - MyUserIdProvider myUserIdProvider) { + UserHandle tabOwnerUserHandleForLaunch) { mPersonalProfileUserHandle = personalUserHandle; mNoWorkToPersonalEmptyState = noWorkToPersonalEmptyState; mNoPersonalToWorkEmptyState = noPersonalToWorkEmptyState; mCrossProfileIntentsChecker = crossProfileIntentsChecker; - mUserIdProvider = myUserIdProvider; + mTabOwnerUserHandleForLaunch = tabOwnerUserHandleForLaunch; } @Nullable @Override public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { boolean shouldShowBlocker = - mUserIdProvider.getMyUserId() != resolverListAdapter.getUserHandle().getIdentifier() + !mTabOwnerUserHandleForLaunch.equals(resolverListAdapter.getUserHandle()) && !mCrossProfileIntentsChecker .hasCrossProfileIntents(resolverListAdapter.getIntents(), - mUserIdProvider.getMyUserId(), + mTabOwnerUserHandleForLaunch.getIdentifier(), resolverListAdapter.getUserHandle().getIdentifier()); if (!shouldShowBlocker) { diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index a240968b..3b9d2a53 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -33,6 +33,8 @@ import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_S import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK; import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; +import static com.android.internal.annotations.VisibleForTesting.Visibility.PROTECTED; + import android.annotation.Nullable; import android.annotation.StringRes; import android.annotation.UiThread; @@ -106,6 +108,7 @@ import com.android.intentresolver.AbstractMultiProfilePagerAdapter.Profile; import com.android.intentresolver.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.content.PackageMonitor; @@ -375,8 +378,11 @@ public class ResolverActivity extends FragmentActivity implements // turn this off when running under voice interaction, since it results in // a more complicated UI that the current voice interaction flow is not able // to handle. We also turn it off when the work tab is shown to simplify the UX. + // We also turn it off when clonedProfile is present on the device, because we might have + // different "last chosen" activities in the different profiles, and PackageManager doesn't + // provide any more information to help us select between them. boolean filterLastUsed = mSupportsAlwaysUseOption && !isVoiceInteraction() - && !shouldShowTabs(); + && !shouldShowTabs() && !hasCloneProfile(); mMultiProfilePagerAdapter = createMultiProfilePagerAdapter(initialIntents, rList, filterLastUsed); if (configureContentView()) { return; @@ -480,7 +486,7 @@ public class ResolverActivity extends FragmentActivity implements return new NoCrossProfileEmptyStateProvider(getPersonalProfileUserHandle(), noWorkToPersonalEmptyState, noPersonalToWorkEmptyState, - createCrossProfileIntentsChecker(), createMyUserIdProvider()); + createCrossProfileIntentsChecker(), getTabOwnerUserHandleForLaunch()); } protected int appliedThemeResId() { @@ -857,13 +863,23 @@ public class ResolverActivity extends FragmentActivity implements // the future if resolver *were* to make any (non-overridden) calls to a version that used a // different signature (and thus didn't return the subclass type). @VisibleForTesting - protected ResolverListController createListController(UserHandle unused) { + protected ResolverListController createListController(UserHandle userHandle) { + ResolverRankerServiceResolverComparator resolverComparator = + new ResolverRankerServiceResolverComparator( + this, + getTargetIntent(), + getReferrerPackageName(), + null, + null, + getResolverRankerServiceUserHandleList(userHandle)); return new ResolverListController( this, mPm, getTargetIntent(), getReferrerPackageName(), - getAnnotatedUserHandles().userIdOfCallingApp); + getAnnotatedUserHandles().userIdOfCallingApp, + resolverComparator, + getQueryIntentsUser(userHandle)); } /** @@ -1003,27 +1019,6 @@ public class ResolverActivity extends FragmentActivity implements }); } - // TODO: have tests override `getAnnotatedUserHandles()`, and make this method `final`. - // @NonFinalForTesting - @Nullable - protected UserHandle getWorkProfileUserHandle() { - return getAnnotatedUserHandles().workProfileUserHandle; - } - - // @NonFinalForTesting - @VisibleForTesting - public void safelyStartActivity(TargetInfo cti) { - // We're dispatching intents that might be coming from legacy apps, so - // don't kill ourselves. - StrictMode.disableDeathOnFileUriExposure(); - try { - UserHandle currentUserHandle = mMultiProfilePagerAdapter.getCurrentUserHandle(); - safelyStartActivityInternal(cti, currentUserHandle, null); - } finally { - StrictMode.enableDeathOnFileUriExposure(); - } - } - // @NonFinalForTesting @VisibleForTesting protected ResolverListAdapter createResolverListAdapter(Context context, @@ -1032,6 +1027,9 @@ public class ResolverActivity extends FragmentActivity implements Intent startIntent = getIntent(); boolean isAudioCaptureDevice = startIntent.getBooleanExtra(EXTRA_IS_AUDIO_CAPTURE_DEVICE, false); + UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile() + && userHandle.equals(getPersonalProfileUserHandle()) + ? getCloneProfileUserHandle() : userHandle; return new ResolverListAdapter( context, payloadIntents, @@ -1042,7 +1040,8 @@ public class ResolverActivity extends FragmentActivity implements userHandle, getTargetIntent(), this, - isAudioCaptureDevice); + isAudioCaptureDevice, + initialIntentsUserSpace); } private LatencyTracker getLatencyTracker() { @@ -1081,7 +1080,7 @@ public class ResolverActivity extends FragmentActivity implements workProfileUserHandle, getPersonalProfileUserHandle(), getMetricsCategory(), - createMyUserIdProvider() + getTabOwnerUserHandleForLaunch() ); // Return composite provider, the order matters (the higher, the more priority) @@ -1124,19 +1123,20 @@ public class ResolverActivity extends FragmentActivity implements initialIntents, rList, filterLastUsed, - /* userHandle */ UserHandle.of(UserHandle.myUserId())); + /* userHandle */ getPersonalProfileUserHandle()); return new ResolverMultiProfilePagerAdapter( /* context */ this, adapter, createEmptyStateProvider(/* workProfileUserHandle= */ null), /* workProfileQuietModeChecker= */ () -> false, - /* workProfileUserHandle= */ null); + /* workProfileUserHandle= */ null, + getCloneProfileUserHandle()); } private UserHandle getIntentUser() { return getIntent().hasExtra(EXTRA_CALLING_USER) ? getIntent().getParcelableExtra(EXTRA_CALLING_USER) - : getUser(); + : getTabOwnerUserHandleForLaunch(); } private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForTwoProfiles( @@ -1148,7 +1148,7 @@ public class ResolverActivity extends FragmentActivity implements // this happens, we check for it here and set the current profile's tab. int selectedProfile = getCurrentProfile(); UserHandle intentUser = getIntentUser(); - if (!getUser().equals(intentUser)) { + if (!getTabOwnerUserHandleForLaunch().equals(intentUser)) { if (getPersonalProfileUserHandle().equals(intentUser)) { selectedProfile = PROFILE_PERSONAL; } else if (getWorkProfileUserHandle().equals(intentUser)) { @@ -1187,7 +1187,8 @@ public class ResolverActivity extends FragmentActivity implements createEmptyStateProvider(getWorkProfileUserHandle()), () -> mWorkProfileAvailability.isQuietModeEnabled(), selectedProfile, - getWorkProfileUserHandle()); + getWorkProfileUserHandle(), + getCloneProfileUserHandle()); } /** @@ -1210,7 +1211,8 @@ public class ResolverActivity extends FragmentActivity implements } protected final @Profile int getCurrentProfile() { - return (UserHandle.myUserId() == UserHandle.USER_SYSTEM ? PROFILE_PERSONAL : PROFILE_WORK); + return (getTabOwnerUserHandleForLaunch().equals(getPersonalProfileUserHandle()) + ? PROFILE_PERSONAL : PROFILE_WORK); } protected final AnnotatedUserHandles getAnnotatedUserHandles() { @@ -1221,10 +1223,43 @@ public class ResolverActivity extends FragmentActivity implements return getAnnotatedUserHandles().personalProfileUserHandle; } + // TODO: have tests override `getAnnotatedUserHandles()`, and make this method `final`. + // @NonFinalForTesting + @Nullable + protected UserHandle getWorkProfileUserHandle() { + return getAnnotatedUserHandles().workProfileUserHandle; + } + + // TODO: have tests override `getAnnotatedUserHandles()`, and make this method `final`. + @Nullable + protected UserHandle getCloneProfileUserHandle() { + return getAnnotatedUserHandles().cloneProfileUserHandle; + } + + // TODO: have tests override `getAnnotatedUserHandles()`, and make this method `final`. + protected UserHandle getTabOwnerUserHandleForLaunch() { + return getAnnotatedUserHandles().tabOwnerUserHandleForLaunch; + } + + protected UserHandle getUserHandleSharesheetLaunchedAs() { + return getAnnotatedUserHandles().userHandleSharesheetLaunchedAs; + } + + private boolean hasWorkProfile() { return getWorkProfileUserHandle() != null; } + private boolean hasCloneProfile() { + return getCloneProfileUserHandle() != null; + } + + protected final boolean isLaunchedAsCloneProfile() { + return hasCloneProfile() + && getUserHandleSharesheetLaunchedAs().equals(getCloneProfileUserHandle()); + } + + protected final boolean shouldShowTabs() { return hasWorkProfile(); } @@ -1498,6 +1533,13 @@ public class ResolverActivity extends FragmentActivity implements mAlwaysButton.setEnabled(false); return; } + // In case of clonedProfile being active, we do not allow the 'Always' option in the + // disambiguation dialog of Personal Profile as the package manager cannot distinguish + // between cross-profile preferred activities. + if (hasCloneProfile() && (mMultiProfilePagerAdapter.getCurrentPage() == PROFILE_PERSONAL)) { + mAlwaysButton.setEnabled(false); + return; + } boolean enabled = false; ResolveInfo ri = null; if (hasValidSelection) { @@ -1572,6 +1614,16 @@ public class ResolverActivity extends FragmentActivity implements } } + /** Start the activity specified by the {@link TargetInfo}.*/ + public final void safelyStartActivity(TargetInfo cti) { + // In case cloned apps are present, we would want to start those apps in cloned user + // space, which will not be same as adaptor's userHandle. resolveInfo.userHandle + // identifies the correct user space in such cases. + UserHandle activityUserHandle = getResolveInfoUserHandle( + cti.getResolveInfo(), mMultiProfilePagerAdapter.getCurrentUserHandle()); + safelyStartActivityAsUser(cti, activityUserHandle, null); + } + /** * Start activity as a fixed user handle. * @param cti TargetInfo to be launched. @@ -1594,7 +1646,8 @@ public class ResolverActivity extends FragmentActivity implements } } - private void safelyStartActivityInternal( + @VisibleForTesting + protected void safelyStartActivityInternal( TargetInfo cti, UserHandle user, @Nullable Bundle options) { // If the target is suspended, the activity will not be successfully launched. // Do not unregister from package manager updates in this case @@ -2172,16 +2225,10 @@ public class ResolverActivity extends FragmentActivity implements public final boolean useLayoutWithDefault() { // We only use the default app layout when the profile of the active user has a // filtered item. We always show the same default app even in the inactive user profile. - boolean currentUserAdapterHasFilteredItem; - if (mMultiProfilePagerAdapter.getCurrentUserHandle().getIdentifier() - == UserHandle.myUserId()) { - currentUserAdapterHasFilteredItem = - mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem(); - } else { - currentUserAdapterHasFilteredItem = - mMultiProfilePagerAdapter.getInactiveListAdapter().hasFilteredItem(); - } - return mSupportsAlwaysUseOption && currentUserAdapterHasFilteredItem; + boolean adapterForCurrentUserHasFilteredItem = + mMultiProfilePagerAdapter.getListAdapterForUserHandle( + getTabOwnerUserHandleForLaunch()).hasFilteredItem(); + return mSupportsAlwaysUseOption && adapterForCurrentUserHasFilteredItem; } /** @@ -2200,7 +2247,14 @@ public class ResolverActivity extends FragmentActivity implements return lhs == null ? rhs == null : lhs.activityInfo == null ? rhs.activityInfo == null : Objects.equals(lhs.activityInfo.name, rhs.activityInfo.name) - && Objects.equals(lhs.activityInfo.packageName, rhs.activityInfo.packageName); + && Objects.equals(lhs.activityInfo.packageName, rhs.activityInfo.packageName) + // Comparing against resolveInfo.userHandle in case cloned apps are present, + // as they will have the same activityInfo. + && Objects.equals( + getResolveInfoUserHandle(lhs, + mMultiProfilePagerAdapter.getActiveListAdapter().getUserHandle()), + getResolveInfoUserHandle(rhs, + mMultiProfilePagerAdapter.getActiveListAdapter().getUserHandle())); } private boolean inactiveListAdapterHasItems() { @@ -2307,4 +2361,44 @@ public class ResolverActivity extends FragmentActivity implements } } } + /** + * Returns the {@link UserHandle} to use when querying resolutions for intents in a + * {@link ResolverListController} configured for the provided {@code userHandle}. + */ + protected final UserHandle getQueryIntentsUser(UserHandle userHandle) { + return mLazyAnnotatedUserHandles.get().getQueryIntentsUser(userHandle); + } + + /** + * Returns the {@link List} of {@link UserHandle} to pass on to the + * {@link ResolverRankerServiceResolverComparator} as per the provided {@code userHandle}. + */ + @VisibleForTesting(visibility = PROTECTED) + public final List getResolverRankerServiceUserHandleList(UserHandle userHandle) { + return getResolverRankerServiceUserHandleListInternal(userHandle); + } + + @VisibleForTesting + protected List getResolverRankerServiceUserHandleListInternal( + UserHandle userHandle) { + List userList = new ArrayList<>(); + userList.add(userHandle); + // Add clonedProfileUserHandle to the list only if we are: + // a. Building the Personal Tab. + // b. CloneProfile exists on the device. + if (userHandle.equals(getPersonalProfileUserHandle()) + && getCloneProfileUserHandle() != null) { + userList.add(getCloneProfileUserHandle()); + } + return userList; + } + + /** + * This function is temporary in nature, and its usages will be replaced with just + * resolveInfo.userHandle, once it is available, once sharesheet is stable. + */ + public static UserHandle getResolveInfoUserHandle(ResolveInfo resolveInfo, + UserHandle predictedHandle) { + return resolveInfo.userHandle; + } } diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index eac275cc..b0586f2d 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -97,6 +97,8 @@ public class ResolverListAdapter extends BaseAdapter { private boolean mFilterLastUsed; private Runnable mPostListReadyRunnable; private boolean mIsTabLoaded; + // Represents the UserSpace in which the Initial Intents should be resolved. + private final UserHandle mInitialIntentsUserSpace; public ResolverListAdapter( Context context, @@ -108,7 +110,8 @@ public class ResolverListAdapter extends BaseAdapter { UserHandle userHandle, Intent targetIntent, ResolverListCommunicator resolverListCommunicator, - boolean isAudioCaptureDevice) { + boolean isAudioCaptureDevice, + UserHandle initialIntentsUserSpace) { mContext = context; mIntents = payloadIntents; mInitialIntents = initialIntents; @@ -125,6 +128,7 @@ public class ResolverListAdapter extends BaseAdapter { final ActivityManager am = (ActivityManager) mContext.getSystemService(ACTIVITY_SERVICE); mIconDpi = am.getLauncherLargeIconDensity(); mPresentationFactory = new TargetPresentationGetter.Factory(mContext, mIconDpi); + mInitialIntentsUserSpace = initialIntentsUserSpace; } public final DisplayResolveInfo getFirstDisplayResolveInfo() { @@ -176,19 +180,25 @@ public class ResolverListAdapter extends BaseAdapter { } /** - * Returns the app share score of the given {@code componentName}. + * Returns the app share score of the given {@code targetInfo}. */ - public float getScore(ComponentName componentName) { - return mResolverListController.getScore(componentName); + public float getScore(TargetInfo targetInfo) { + return mResolverListController.getScore(targetInfo); } - public void updateModel(ComponentName componentName) { - mResolverListController.updateModel(componentName); + /** + * Updates the model about the chosen {@code targetInfo}. + */ + public void updateModel(TargetInfo targetInfo) { + mResolverListController.updateModel(targetInfo); } - public void updateChooserCounts(String packageName, String action) { + /** + * Updates the model about Chooser Activity selection. + */ + public void updateChooserCounts(String packageName, String action, UserHandle userHandle) { mResolverListController.updateChooserCounts( - packageName, getUserHandle().getIdentifier(), action); + packageName, userHandle, action); } List getUnfilteredResolveList() { @@ -468,6 +478,7 @@ public class ResolverListAdapter extends BaseAdapter { ri.icon = 0; } + ri.userHandle = mInitialIntentsUserSpace; addResolveInfo(DisplayResolveInfo.newDisplayResolveInfo( ii, ri, @@ -761,8 +772,10 @@ public class ResolverListAdapter extends BaseAdapter { } Drawable loadIconForResolveInfo(ResolveInfo ri) { - // Load icons based on the current process. If in work profile icons should be badged. - return mPresentationFactory.makePresentationGetter(ri).getIcon(getUserHandle()); + // Load icons based on userHandle from ResolveInfo. If in work profile/clone profile, icons + // should be badged. + return mPresentationFactory.makePresentationGetter(ri) + .getIcon(ResolverActivity.getResolveInfoUserHandle(ri, getUserHandle())); } protected final Drawable loadIconPlaceholder() { diff --git a/java/src/com/android/intentresolver/ResolverListController.java b/java/src/com/android/intentresolver/ResolverListController.java index b4544c43..d5a5fedf 100644 --- a/java/src/com/android/intentresolver/ResolverListController.java +++ b/java/src/com/android/intentresolver/ResolverListController.java @@ -32,8 +32,8 @@ import android.os.UserHandle; import android.util.Log; import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.model.AbstractResolverComparator; -import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; import com.android.internal.annotations.VisibleForTesting; import java.util.ArrayList; @@ -58,34 +58,26 @@ public class ResolverListController { private static final String TAG = "ResolverListController"; private static final boolean DEBUG = false; + private final UserHandle mQueryIntentsAsUser; private AbstractResolverComparator mResolverComparator; private boolean isComputed = false; - public ResolverListController( - Context context, - PackageManager pm, - Intent targetIntent, - String referrerPackage, - int launchedFromUid) { - this(context, pm, targetIntent, referrerPackage, launchedFromUid, - new ResolverRankerServiceResolverComparator( - context, targetIntent, referrerPackage, null, null)); - } - public ResolverListController( Context context, PackageManager pm, Intent targetIntent, String referrerPackage, int launchedFromUid, - AbstractResolverComparator resolverComparator) { + AbstractResolverComparator resolverComparator, + UserHandle queryIntentsAsUser) { mContext = context; mpm = pm; mLaunchedFromUid = launchedFromUid; mTargetIntent = targetIntent; mReferrerPackage = referrerPackage; mResolverComparator = resolverComparator; + mQueryIntentsAsUser = queryIntentsAsUser; } @VisibleForTesting @@ -118,7 +110,8 @@ public class ResolverListController { | PackageManager.MATCH_DIRECT_BOOT_AWARE | PackageManager.MATCH_DIRECT_BOOT_UNAWARE | (shouldGetResolvedFilter ? PackageManager.GET_RESOLVED_FILTER : 0) - | (shouldGetActivityMetadata ? PackageManager.GET_META_DATA : 0); + | (shouldGetActivityMetadata ? PackageManager.GET_META_DATA : 0) + | PackageManager.MATCH_CLONE_PROFILE; return getResolversForIntentAsUserInternal(intents, userHandle, baseFlags); } @@ -154,6 +147,10 @@ public class ResolverListController { final int intoCount = into.size(); for (int i = 0; i < fromCount; i++) { final ResolveInfo newInfo = from.get(i); + if (newInfo.userHandle == null) { + Log.w(TAG, "Skipping ResolveInfo with no userHandle: " + newInfo); + continue; + } boolean found = false; // Only loop to the end of into as it was before we started; no dupes in from. for (int j = 0; j < intoCount; j++) { @@ -344,22 +341,28 @@ public class ResolverListController { @VisibleForTesting public float getScore(DisplayResolveInfo target) { - return mResolverComparator.getScore(target.getResolvedComponentName()); + return mResolverComparator.getScore(target); } /** * Returns the app share score of the given {@code componentName}. */ - public float getScore(ComponentName componentName) { - return mResolverComparator.getScore(componentName); + public float getScore(TargetInfo targetInfo) { + return mResolverComparator.getScore(targetInfo); } - public void updateModel(ComponentName componentName) { - mResolverComparator.updateModel(componentName); + /** + * Updates the model about the chosen {@code targetInfo}. + */ + public void updateModel(TargetInfo targetInfo) { + mResolverComparator.updateModel(targetInfo); } - public void updateChooserCounts(String packageName, int userId, String action) { - mResolverComparator.updateChooserCounts(packageName, userId, action); + /** + * Updates the model about Chooser Activity selection. + */ + public void updateChooserCounts(String packageName, UserHandle user, String action) { + mResolverComparator.updateChooserCounts(packageName, user, action); } public void destroy() { diff --git a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java index 48e3b62d..85d97ad5 100644 --- a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java @@ -44,7 +44,8 @@ public class ResolverMultiProfilePagerAdapter extends ResolverListAdapter adapter, EmptyStateProvider emptyStateProvider, Supplier workProfileQuietModeChecker, - UserHandle workProfileUserHandle) { + UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle) { this( context, ImmutableList.of(adapter), @@ -52,6 +53,7 @@ public class ResolverMultiProfilePagerAdapter extends workProfileQuietModeChecker, /* defaultProfile= */ 0, workProfileUserHandle, + cloneProfileUserHandle, new BottomPaddingOverrideSupplier()); } @@ -61,7 +63,8 @@ public class ResolverMultiProfilePagerAdapter extends EmptyStateProvider emptyStateProvider, Supplier workProfileQuietModeChecker, @Profile int defaultProfile, - UserHandle workProfileUserHandle) { + UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle) { this( context, ImmutableList.of(personalAdapter, workAdapter), @@ -69,6 +72,7 @@ public class ResolverMultiProfilePagerAdapter extends workProfileQuietModeChecker, defaultProfile, workProfileUserHandle, + cloneProfileUserHandle, new BottomPaddingOverrideSupplier()); } @@ -79,6 +83,7 @@ public class ResolverMultiProfilePagerAdapter extends Supplier workProfileQuietModeChecker, @Profile int defaultProfile, UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle, BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) { super( context, @@ -89,6 +94,7 @@ public class ResolverMultiProfilePagerAdapter extends workProfileQuietModeChecker, defaultProfile, workProfileUserHandle, + cloneProfileUserHandle, () -> (ViewGroup) LayoutInflater.from(context).inflate( R.layout.resolver_list_per_profile, null, false), bottomPaddingOverrideSupplier); diff --git a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java index ea767568..7357fde9 100644 --- a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java +++ b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java @@ -32,11 +32,16 @@ import android.util.Log; import com.android.intentresolver.ChooserActivityLogger; import com.android.intentresolver.ResolvedComponentInfo; import com.android.intentresolver.ResolverActivity; +import com.android.intentresolver.chooser.TargetInfo; + +import com.google.android.collect.Lists; import java.text.Collator; import java.util.ArrayList; import java.util.Comparator; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** * Used to sort resolved activities in {@link ResolverListController}. @@ -50,8 +55,8 @@ public abstract class AbstractResolverComparator implements Comparator mPmMap = new HashMap<>(); + protected final Map mUsmMap = new HashMap<>(); protected String[] mAnnotations; protected String mContentType; @@ -100,14 +105,48 @@ public abstract class AbstractResolverComparator implements Comparator resolvedActivityUserSpaceList) { String scheme = intent.getScheme(); mHttp = "http".equals(scheme) || "https".equals(scheme); mContentType = intent.getType(); getContentAnnotations(intent); - mPm = context.getPackageManager(); - mUsm = (UsageStatsManager) context.getSystemService(Context.USAGE_STATS_SERVICE); - mAzComparator = new AzInfoComparator(context); + for (UserHandle user : resolvedActivityUserSpaceList) { + Context userContext = launchedFromContext.createContextAsUser(user, 0); + mPmMap.put(user, userContext.getPackageManager()); + mUsmMap.put( + user, + (UsageStatsManager) userContext.getSystemService(Context.USAGE_STATS_SERVICE)); + } + mAzComparator = new AzInfoComparator(launchedFromContext); } // get annotations of content from intent. @@ -197,8 +236,8 @@ public abstract class AbstractResolverComparator implements ComparatorDefault implementation does nothing, as we could have simple model that does not train * online. * - * @param componentName the component that the user clicked + * * @param targetInfo the target that the user clicked. */ - public void updateModel(ComponentName componentName) { + public void updateModel(TargetInfo targetInfo) { } /** Called before {@link #doCompute(List)}. Sets up 500ms timeout. */ diff --git a/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java b/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java index c986ef15..84dca3ff 100644 --- a/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java +++ b/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java @@ -33,6 +33,9 @@ import android.util.Log; import com.android.intentresolver.ChooserActivityLogger; import com.android.intentresolver.ResolvedComponentInfo; +import com.android.intentresolver.chooser.TargetInfo; + +import com.google.android.collect.Lists; import java.util.ArrayList; import java.util.Comparator; @@ -70,7 +73,7 @@ public class AppPredictionServiceResolverComparator extends AbstractResolverComp AppPredictor appPredictor, UserHandle user, ChooserActivityLogger chooserActivityLogger) { - super(context, intent); + super(context, intent, Lists.newArrayList(user)); mContext = context; mIntent = intent; mAppPredictor = appPredictor; @@ -108,9 +111,12 @@ public class AppPredictionServiceResolverComparator extends AbstractResolverComp // APS for chooser is disabled. Fallback to resolver. mResolverRankerService = new ResolverRankerServiceResolverComparator( - mContext, mIntent, mReferrerPackage, + mContext, + mIntent, + mReferrerPackage, () -> mHandler.sendEmptyMessage(RANKER_SERVICE_RESULT), - getChooserActivityLogger()); + getChooserActivityLogger(), + mUser); mComparatorModel = buildUpdatedModel(); mResolverRankerService.compute(targets); } else { @@ -167,13 +173,13 @@ public class AppPredictionServiceResolverComparator extends AbstractResolverComp } @Override - public float getScore(ComponentName name) { - return mComparatorModel.getScore(name); + public float getScore(TargetInfo targetInfo) { + return mComparatorModel.getScore(targetInfo); } @Override - public void updateModel(ComponentName componentName) { - mComparatorModel.notifyOnTargetSelected(componentName); + public void updateModel(TargetInfo targetInfo) { + mComparatorModel.notifyOnTargetSelected(targetInfo); } @Override @@ -246,11 +252,11 @@ public class AppPredictionServiceResolverComparator extends AbstractResolverComp } @Override - public float getScore(ComponentName name) { + public float getScore(TargetInfo targetInfo) { if (mResolverRankerService != null) { - return mResolverRankerService.getScore(name); + return mResolverRankerService.getScore(targetInfo); } - Integer rank = mTargetRanks.get(name); + Integer rank = mTargetRanks.get(targetInfo.getResolvedComponentName()); if (rank == null) { Log.w(TAG, "Score requested for unknown component. Did you call compute yet?"); return 0f; @@ -260,18 +266,19 @@ public class AppPredictionServiceResolverComparator extends AbstractResolverComp } @Override - public void notifyOnTargetSelected(ComponentName componentName) { + public void notifyOnTargetSelected(TargetInfo targetInfo) { if (mResolverRankerService != null) { - mResolverRankerService.updateModel(componentName); + mResolverRankerService.updateModel(targetInfo); return; } + ComponentName targetComponent = targetInfo.getResolvedComponentName(); + AppTargetId targetId = new AppTargetId(targetComponent.toString()); + AppTarget appTarget = + new AppTarget.Builder(targetId, targetComponent.getPackageName(), mUser) + .setClassName(targetComponent.getClassName()) + .build(); mAppPredictor.notifyAppTargetEvent( - new AppTargetEvent.Builder( - new AppTarget.Builder( - new AppTargetId(componentName.toString()), - componentName.getPackageName(), mUser) - .setClassName(componentName.getClassName()).build(), - ACTION_LAUNCH).build()); + new AppTargetEvent.Builder(appTarget, ACTION_LAUNCH).build()); } } } diff --git a/java/src/com/android/intentresolver/model/ResolverComparatorModel.java b/java/src/com/android/intentresolver/model/ResolverComparatorModel.java index 3616a853..4835ea17 100644 --- a/java/src/com/android/intentresolver/model/ResolverComparatorModel.java +++ b/java/src/com/android/intentresolver/model/ResolverComparatorModel.java @@ -16,9 +16,10 @@ package com.android.intentresolver.model; -import android.content.ComponentName; import android.content.pm.ResolveInfo; +import com.android.intentresolver.chooser.TargetInfo; + import java.util.Comparator; /** @@ -44,7 +45,7 @@ interface ResolverComparatorModel { * likelihood that the user will select that component as the target. Implementations that don't * assign numerical scores are recommended to return a value of 0 for all components. */ - float getScore(ComponentName name); + float getScore(TargetInfo targetInfo); /** * Notify the model that the user selected a target. (Models may log this information, use it as @@ -52,5 +53,5 @@ interface ResolverComparatorModel { * {@code ResolverComparatorModel} instance is immutable, clients will need to get an up-to-date * instance in order to see any changes in the ranking that might result from this feedback. */ - void notifyOnTargetSelected(ComponentName componentName); + void notifyOnTargetSelected(TargetInfo targetInfo); } diff --git a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java index 0431078c..725212e4 100644 --- a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java +++ b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java @@ -17,11 +17,13 @@ package com.android.intentresolver.model; +import android.annotation.Nullable; import android.app.usage.UsageStats; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; +import android.content.pm.ActivityInfo; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; import android.content.pm.PackageManager.NameNotFoundException; @@ -39,12 +41,16 @@ import android.util.Log; import com.android.intentresolver.ChooserActivityLogger; import com.android.intentresolver.ResolvedComponentInfo; +import com.android.intentresolver.chooser.TargetInfo; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; +import com.google.android.collect.Lists; + import java.text.Collator; import java.util.ArrayList; import java.util.Comparator; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -70,10 +76,10 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom private static final int CONNECTION_COST_TIMEOUT_MILLIS = 200; private final Collator mCollator; - private final Map mStats; + private final Map> mStatsPerUser; private final long mCurrentTime; private final long mSinceTime; - private final LinkedHashMap mTargetsDict = new LinkedHashMap<>(); + private final Map> mTargetsDictPerUser; private final String mReferrerPackage; private final Object mLock = new Object(); private ArrayList mTargets; @@ -86,17 +92,48 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom private CountDownLatch mConnectSignal; private ResolverRankerServiceComparatorModel mComparatorModel; - public ResolverRankerServiceResolverComparator(Context context, Intent intent, - String referrerPackage, Runnable afterCompute, - ChooserActivityLogger chooserActivityLogger) { - super(context, intent); - mCollator = Collator.getInstance(context.getResources().getConfiguration().locale); + /** + * Constructor to initialize the comparator. + * @param launchedFromContext the activity calling this comparator + * @param intent original intent + * @param targetUserSpace the userSpace(s) used by the comparator for fetching activity stats + * and recording activity selection. The latter could be different from + * the userSpace provided by context. + */ + public ResolverRankerServiceResolverComparator(Context launchedFromContext, Intent intent, + String referrerPackage, Runnable afterCompute, + ChooserActivityLogger chooserActivityLogger, UserHandle targetUserSpace) { + this(launchedFromContext, intent, referrerPackage, afterCompute, chooserActivityLogger, + Lists.newArrayList(targetUserSpace)); + } + + /** + * Constructor to initialize the comparator. + * @param launchedFromContext the activity calling this comparator + * @param intent original intent + * @param targetUserSpaceList the userSpace(s) used by the comparator for fetching activity + * stats and recording activity selection. The latter could be + * different from the userSpace provided by context. + */ + public ResolverRankerServiceResolverComparator(Context launchedFromContext, Intent intent, + String referrerPackage, Runnable afterCompute, + ChooserActivityLogger chooserActivityLogger, List targetUserSpaceList) { + super(launchedFromContext, intent, targetUserSpaceList); + mCollator = Collator.getInstance( + launchedFromContext.getResources().getConfiguration().locale); mReferrerPackage = referrerPackage; - mContext = context; + mContext = launchedFromContext; mCurrentTime = System.currentTimeMillis(); mSinceTime = mCurrentTime - USAGE_STATS_PERIOD; - mStats = mUsm.queryAndAggregateUsageStats(mSinceTime, mCurrentTime); + mStatsPerUser = new HashMap<>(); + mTargetsDictPerUser = new HashMap<>(); + for (UserHandle user : targetUserSpaceList) { + mStatsPerUser.put( + user, + mUsmMap.get(user).queryAndAggregateUsageStats(mSinceTime, mCurrentTime)); + mTargetsDictPerUser.put(user, new LinkedHashMap<>()); + } mAction = intent.getAction(); mRankerServiceName = new ComponentName(mContext, this.getClass()); setCallBack(afterCompute); @@ -147,58 +184,68 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom float mostChooserScore = 1.0f; for (ResolvedComponentInfo target : targets) { + if (target.getResolveInfoAt(0) == null) { + continue; + } final ResolverTarget resolverTarget = new ResolverTarget(); - mTargetsDict.put(target.name, resolverTarget); - final UsageStats pkStats = mStats.get(target.name.getPackageName()); - if (pkStats != null) { - // Only count recency for apps that weren't the caller - // since the caller is always the most recent. - // Persistent processes muck this up, so omit them too. - if (!target.name.getPackageName().equals(mReferrerPackage) - && !isPersistentProcess(target)) { - final float recencyScore = - (float) Math.max(pkStats.getLastTimeUsed() - recentSinceTime, 0); - resolverTarget.setRecencyScore(recencyScore); - if (recencyScore > mostRecencyScore) { - mostRecencyScore = recencyScore; + final UserHandle resolvedComponentUserSpace = + target.getResolveInfoAt(0).userHandle; + final Map targetsDict = + mTargetsDictPerUser.get(resolvedComponentUserSpace); + final Map stats = mStatsPerUser.get(resolvedComponentUserSpace); + if (targetsDict != null && stats != null) { + targetsDict.put(target.name, resolverTarget); + final UsageStats pkStats = stats.get(target.name.getPackageName()); + if (pkStats != null) { + // Only count recency for apps that weren't the caller + // since the caller is always the most recent. + // Persistent processes muck this up, so omit them too. + if (!target.name.getPackageName().equals(mReferrerPackage) + && !isPersistentProcess(target)) { + final float recencyScore = + (float) Math.max(pkStats.getLastTimeUsed() - recentSinceTime, 0); + resolverTarget.setRecencyScore(recencyScore); + if (recencyScore > mostRecencyScore) { + mostRecencyScore = recencyScore; + } + } + final float timeSpentScore = (float) pkStats.getTotalTimeInForeground(); + resolverTarget.setTimeSpentScore(timeSpentScore); + if (timeSpentScore > mostTimeSpentScore) { + mostTimeSpentScore = timeSpentScore; + } + final float launchScore = (float) pkStats.mLaunchCount; + resolverTarget.setLaunchScore(launchScore); + if (launchScore > mostLaunchScore) { + mostLaunchScore = launchScore; } - } - final float timeSpentScore = (float) pkStats.getTotalTimeInForeground(); - resolverTarget.setTimeSpentScore(timeSpentScore); - if (timeSpentScore > mostTimeSpentScore) { - mostTimeSpentScore = timeSpentScore; - } - final float launchScore = (float) pkStats.mLaunchCount; - resolverTarget.setLaunchScore(launchScore); - if (launchScore > mostLaunchScore) { - mostLaunchScore = launchScore; - } - float chooserScore = 0.0f; - if (pkStats.mChooserCounts != null && mAction != null - && pkStats.mChooserCounts.get(mAction) != null) { - chooserScore = (float) pkStats.mChooserCounts.get(mAction) - .getOrDefault(mContentType, 0); - if (mAnnotations != null) { - final int size = mAnnotations.length; - for (int i = 0; i < size; i++) { - chooserScore += (float) pkStats.mChooserCounts.get(mAction) - .getOrDefault(mAnnotations[i], 0); + float chooserScore = 0.0f; + if (pkStats.mChooserCounts != null && mAction != null + && pkStats.mChooserCounts.get(mAction) != null) { + chooserScore = (float) pkStats.mChooserCounts.get(mAction) + .getOrDefault(mContentType, 0); + if (mAnnotations != null) { + final int size = mAnnotations.length; + for (int i = 0; i < size; i++) { + chooserScore += (float) pkStats.mChooserCounts.get(mAction) + .getOrDefault(mAnnotations[i], 0); + } } } - } - if (DEBUG) { - if (mAction == null) { - Log.d(TAG, "Action type is null"); - } else { - Log.d(TAG, "Chooser Count of " + mAction + ":" - + target.name.getPackageName() + " is " - + Float.toString(chooserScore)); + if (DEBUG) { + if (mAction == null) { + Log.d(TAG, "Action type is null"); + } else { + Log.d(TAG, "Chooser Count of " + mAction + ":" + + target.name.getPackageName() + " is " + + Float.toString(chooserScore)); + } + } + resolverTarget.setChooserScore(chooserScore); + if (chooserScore > mostChooserScore) { + mostChooserScore = chooserScore; } - } - resolverTarget.setChooserScore(chooserScore); - if (chooserScore > mostChooserScore) { - mostChooserScore = chooserScore; } } } @@ -210,7 +257,10 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom + " mostChooserScore: " + mostChooserScore); } - mTargets = new ArrayList<>(mTargetsDict.values()); + mTargets = new ArrayList<>(); + for (UserHandle u : mTargetsDictPerUser.keySet()) { + mTargets.addAll(mTargetsDictPerUser.get(u).values()); + } for (ResolverTarget target : mTargets) { final float recency = target.getRecencyScore() / mostRecencyScore; setFeatures(target, recency * recency * RECENCY_MULTIPLIER, @@ -233,15 +283,15 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom } @Override - public float getScore(ComponentName name) { - return mComparatorModel.getScore(name); + public float getScore(TargetInfo targetInfo) { + return mComparatorModel.getScore(targetInfo); } // update ranking model when the connection to it is valid. @Override - public void updateModel(ComponentName componentName) { + public void updateModel(TargetInfo targetInfo) { synchronized (mLock) { - mComparatorModel.notifyOnTargetSelected(componentName); + mComparatorModel.notifyOnTargetSelected(targetInfo); } } @@ -282,7 +332,8 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom // resolve the service for ranking. private Intent resolveRankerService() { Intent intent = new Intent(ResolverRankerService.SERVICE_INTERFACE); - final List resolveInfos = mPm.queryIntentServices(intent, 0); + final List resolveInfos = mContext.getPackageManager() + .queryIntentServices(intent, 0); for (ResolveInfo resolveInfo : resolveInfos) { if (resolveInfo == null || resolveInfo.serviceInfo == null || resolveInfo.serviceInfo.applicationInfo == null) { @@ -295,7 +346,8 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom resolveInfo.serviceInfo.applicationInfo.packageName, resolveInfo.serviceInfo.name); try { - final String perm = mPm.getServiceInfo(componentName, 0).permission; + final String perm = + mContext.getPackageManager().getServiceInfo(componentName, 0).permission; if (!ResolverRankerService.BIND_PERMISSION.equals(perm)) { Log.w(TAG, "ResolverRankerService " + componentName + " does not require" + " permission " + ResolverRankerService.BIND_PERMISSION @@ -306,9 +358,9 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom + " in the manifest."); continue; } - if (PackageManager.PERMISSION_GRANTED != mPm.checkPermission( - ResolverRankerService.HOLD_PERMISSION, - resolveInfo.serviceInfo.packageName)) { + if (PackageManager.PERMISSION_GRANTED != mContext.getPackageManager() + .checkPermission(ResolverRankerService.HOLD_PERMISSION, + resolveInfo.serviceInfo.packageName)) { Log.w(TAG, "ResolverRankerService " + componentName + " does not hold" + " permission " + ResolverRankerService.HOLD_PERMISSION + " - this service will not be queried for " @@ -386,7 +438,9 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom @Override void beforeCompute() { super.beforeCompute(); - mTargetsDict.clear(); + for (UserHandle userHandle : mTargetsDictPerUser.keySet()) { + mTargetsDictPerUser.get(userHandle).clear(); + } mTargets = null; mRankerServiceName = new ComponentName(mContext, this.getClass()); mComparatorModel = buildUpdatedModel(); @@ -468,14 +522,14 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom // so the ResolverComparatorModel may provide inconsistent results. We should make immutable // copies of the data (waiting for any necessary remaining data before creating the model). return new ResolverRankerServiceComparatorModel( - mStats, - mTargetsDict, + mStatsPerUser, + mTargetsDictPerUser, mTargets, mCollator, mRanker, mRankerServiceName, (mAnnotations != null), - mPm); + mPmMap); } /** @@ -484,35 +538,36 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom * removing the complex legacy API. */ static class ResolverRankerServiceComparatorModel implements ResolverComparatorModel { - private final Map mStats; // Treat as immutable. - private final Map mTargetsDict; // Treat as immutable. + private final Map> mStatsPerUser; // Treat as immutable. + // Treat as immutable. + private final Map> mTargetsDictPerUser; private final List mTargets; // Treat as immutable. private final Collator mCollator; private final IResolverRankerService mRanker; private final ComponentName mRankerServiceName; private final boolean mAnnotationsUsed; - private final PackageManager mPm; + private final Map mPmMap; // TODO: it doesn't look like we should have to pass both targets and targetsDict, but it's // not written in a way that makes it clear whether we can derive one from the other (at // least in this constructor). ResolverRankerServiceComparatorModel( - Map stats, - Map targetsDict, + Map> statsPerUser, + Map> targetsDictPerUser, List targets, Collator collator, IResolverRankerService ranker, ComponentName rankerServiceName, boolean annotationsUsed, - PackageManager pm) { - mStats = stats; - mTargetsDict = targetsDict; + Map pmMap) { + mStatsPerUser = statsPerUser; + mTargetsDictPerUser = targetsDictPerUser; mTargets = targets; mCollator = collator; mRanker = ranker; mRankerServiceName = rankerServiceName; mAnnotationsUsed = annotationsUsed; - mPm = pm; + mPmMap = pmMap; } @Override @@ -521,25 +576,29 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom // a bug there, or do we have a way of knowing it will be non-null under certain // conditions? return (lhs, rhs) -> { - if (mStats != null) { - final ResolverTarget lhsTarget = mTargetsDict.get(new ComponentName( - lhs.activityInfo.packageName, lhs.activityInfo.name)); - final ResolverTarget rhsTarget = mTargetsDict.get(new ComponentName( - rhs.activityInfo.packageName, rhs.activityInfo.name)); - - if (lhsTarget != null && rhsTarget != null) { - final int selectProbabilityDiff = Float.compare( - rhsTarget.getSelectProbability(), lhsTarget.getSelectProbability()); - - if (selectProbabilityDiff != 0) { - return selectProbabilityDiff > 0 ? 1 : -1; - } + final ResolverTarget lhsTarget = + getActivityResolverTargetForUser(lhs.activityInfo, lhs.userHandle); + final ResolverTarget rhsTarget = + getActivityResolverTargetForUser(rhs.activityInfo, rhs.userHandle); + + if (lhsTarget != null && rhsTarget != null) { + final int selectProbabilityDiff = Float.compare( + rhsTarget.getSelectProbability(), lhsTarget.getSelectProbability()); + + if (selectProbabilityDiff != 0) { + return selectProbabilityDiff > 0 ? 1 : -1; } } - CharSequence sa = lhs.loadLabel(mPm); + CharSequence sa = null; + if (mPmMap.containsKey(lhs.userHandle)) { + sa = lhs.loadLabel(mPmMap.get(lhs.userHandle)); + } if (sa == null) sa = lhs.activityInfo.name; - CharSequence sb = rhs.loadLabel(mPm); + CharSequence sb = null; + if (mPmMap.containsKey(rhs.userHandle)) { + sb = rhs.loadLabel(mPmMap.get(rhs.userHandle)); + } if (sb == null) sb = rhs.activityInfo.name; return mCollator.compare(sa.toString().trim(), sb.toString().trim()); @@ -547,8 +606,9 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom } @Override - public float getScore(ComponentName name) { - final ResolverTarget target = mTargetsDict.get(name); + public float getScore(TargetInfo targetInfo) { + ResolverTarget target = getResolverTargetForUserAndComponent( + targetInfo.getResolvedComponentName(), targetInfo.getResolveInfo().userHandle); if (target != null) { return target.getSelectProbability(); } @@ -556,13 +616,17 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom } @Override - public void notifyOnTargetSelected(ComponentName componentName) { + public void notifyOnTargetSelected(TargetInfo targetInfo) { if (mRanker != null) { try { - int selectedPos = new ArrayList(mTargetsDict.keySet()) - .indexOf(componentName); + int selectedPos = -1; + if (mTargetsDictPerUser.containsKey(targetInfo.getResolveInfo().userHandle)) { + selectedPos = new ArrayList<>(mTargetsDictPerUser + .get(targetInfo.getResolveInfo().userHandle).keySet()) + .indexOf(targetInfo.getResolvedComponentName()); + } if (selectedPos >= 0 && mTargets != null) { - final float selectedProbability = getScore(componentName); + final float selectedProbability = getScore(targetInfo); int order = 0; for (ResolverTarget target : mTargets) { if (target.getSelectProbability() > selectedProbability) { @@ -573,7 +637,8 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom mRanker.train(mTargets, selectedPos); } else { if (DEBUG) { - Log.d(TAG, "Selected a unknown component: " + componentName); + Log.d(TAG, "Selected a unknown component: " + targetInfo + .getResolvedComponentName()); } } } catch (RemoteException e) { @@ -597,5 +662,21 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom metricsLogger.write(log); } } + + @Nullable + private ResolverTarget getActivityResolverTargetForUser( + ActivityInfo activity, UserHandle user) { + return getResolverTargetForUserAndComponent( + new ComponentName(activity.packageName, activity.name), user); + } + + @Nullable + private ResolverTarget getResolverTargetForUserAndComponent( + ComponentName targetComponentName, UserHandle user) { + if ((mStatsPerUser == null) || !mTargetsDictPerUser.containsKey(user)) { + return null; + } + return mTargetsDictPerUser.get(user).get(targetComponentName); + } } } diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java index f0c459e5..f9093b8f 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java +++ b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java @@ -28,7 +28,6 @@ import android.graphics.Bitmap; import android.os.UserHandle; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.shortcuts.ShortcutLoader; @@ -55,6 +54,7 @@ public class ChooserActivityOverrideData { @SuppressWarnings("Since15") public Function createPackageManager; + public Function onSafelyStartInternalCallback; public Function onSafelyStartCallback; public Function2, ShortcutLoader> shortcutLoaderFactory = (userHandle, callback) -> null; @@ -69,17 +69,18 @@ public class ChooserActivityOverrideData { public int alternateProfileSetting; public Resources resources; public UserHandle workProfileUserHandle; + public UserHandle cloneProfileUserHandle; + public UserHandle tabOwnerUserHandleForLaunch; public boolean hasCrossProfileIntents; public boolean isQuietModeEnabled; public Integer myUserId; public WorkProfileAvailabilityManager mWorkProfileAvailability; - public MyUserIdProvider mMyUserIdProvider; public CrossProfileIntentsChecker mCrossProfileIntentsChecker; public PackageManager packageManager; public FeatureFlagRepository featureFlagRepository; public void reset() { - onSafelyStartCallback = null; + onSafelyStartInternalCallback = null; isVoiceInteraction = null; createPackageManager = null; previewThumbnail = null; @@ -92,6 +93,8 @@ public class ChooserActivityOverrideData { alternateProfileSetting = 0; resources = null; workProfileUserHandle = null; + cloneProfileUserHandle = null; + tabOwnerUserHandleForLaunch = null; hasCrossProfileIntents = true; isQuietModeEnabled = false; myUserId = null; @@ -122,13 +125,6 @@ public class ChooserActivityOverrideData { }; shortcutLoaderFactory = ((userHandle, resultConsumer) -> null); - mMyUserIdProvider = new MyUserIdProvider() { - @Override - public int getMyUserId() { - return myUserId != null ? myUserId : UserHandle.myUserId(); - } - }; - mCrossProfileIntentsChecker = mock(CrossProfileIntentsChecker.class); when(mCrossProfileIntentsChecker.hasCrossProfileIntents(any(), anyInt(), anyInt())) .thenAnswer(invocation -> hasCrossProfileIntents); diff --git a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt index 58f6b733..9504f377 100644 --- a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt +++ b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt @@ -20,6 +20,7 @@ import android.content.ComponentName import android.content.Intent import android.content.pm.PackageManager import android.content.pm.PackageManager.ResolveInfoFlags +import android.os.UserHandle import android.view.View import android.widget.FrameLayout import android.widget.ImageView @@ -39,6 +40,9 @@ import org.mockito.Mockito.verify @RunWith(AndroidJUnit4::class) class ChooserListAdapterTest { + private val PERSONAL_USER_HANDLE: UserHandle = InstrumentationRegistry + .getInstrumentation().getTargetContext().getUser() + private val packageManager = mock { whenever( resolveActivity(any(), any()) @@ -63,7 +67,8 @@ class ChooserListAdapterTest { packageManager, chooserActivityLogger, mock(), - 0 + 0, + null ) { override fun createLoadDirectShareIconTask( info: SelectableTargetInfo @@ -119,7 +124,7 @@ class ChooserListAdapterTest { SelectableTargetInfo.newSelectableTargetInfo( /* sourceInfo = */ DisplayResolveInfo.newDisplayResolveInfo( Intent(), - ResolverDataProvider.createResolveInfo(2, 0), + ResolverDataProvider.createResolveInfo(2, 0, PERSONAL_USER_HANDLE), "label", "extended info", Intent(), diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index d4ae666b..8886892f 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -29,10 +29,10 @@ import android.content.pm.ResolveInfo; import android.content.res.Resources; import android.database.Cursor; import android.net.Uri; +import android.os.Bundle; import android.os.UserHandle; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.flags.FeatureFlagRepository; @@ -80,14 +80,15 @@ public class ChooserWrapperActivity initialIntents, rList, filterLastUsed, - resolverListController, + createListController(userHandle), userHandle, targetIntent, this, packageManager, getChooserActivityLogger(), chooserRequest, - maxTargetsPerRow); + maxTargetsPerRow, + userHandle); } @Override @@ -141,14 +142,6 @@ public class ChooserWrapperActivity return super.isVoiceInteraction(); } - @Override - protected MyUserIdProvider createMyUserIdProvider() { - if (sOverrides.mMyUserIdProvider != null) { - return sOverrides.mMyUserIdProvider; - } - return super.createMyUserIdProvider(); - } - @Override protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { if (sOverrides.mCrossProfileIntentsChecker != null) { @@ -166,12 +159,13 @@ public class ChooserWrapperActivity } @Override - public void safelyStartActivity(TargetInfo cti) { - if (sOverrides.onSafelyStartCallback != null - && sOverrides.onSafelyStartCallback.apply(cti)) { + public void safelyStartActivityInternal(TargetInfo cti, UserHandle user, + @Nullable Bundle options) { + if (sOverrides.onSafelyStartInternalCallback != null + && sOverrides.onSafelyStartInternalCallback.apply(cti)) { return; } - super.safelyStartActivity(cti); + super.safelyStartActivityInternal(cti, user, options); } @Override @@ -259,6 +253,14 @@ public class ChooserWrapperActivity return mMultiProfilePagerAdapter.getCurrentUserHandle(); } + @Override + protected UserHandle getTabOwnerUserHandleForLaunch() { + if (sOverrides.tabOwnerUserHandleForLaunch == null) { + return super.getTabOwnerUserHandleForLaunch(); + } + return sOverrides.tabOwnerUserHandleForLaunch; + } + @Override public Context createContextAsUser(UserHandle user, int flags) { // return the current context as a work profile doesn't really exist in these tests diff --git a/java/tests/src/com/android/intentresolver/ResolverActivityTest.java b/java/tests/src/com/android/intentresolver/ResolverActivityTest.java index e2772423..12b71970 100644 --- a/java/tests/src/com/android/intentresolver/ResolverActivityTest.java +++ b/java/tests/src/com/android/intentresolver/ResolverActivityTest.java @@ -56,6 +56,8 @@ import androidx.test.runner.AndroidJUnit4; import com.android.intentresolver.widget.ResolverDrawerLayout; +import com.google.android.collect.Lists; + import org.junit.Before; import org.junit.Ignore; import org.junit.Rule; @@ -71,6 +73,9 @@ import java.util.List; */ @RunWith(AndroidJUnit4.class) public class ResolverActivityTest { + + private static final UserHandle PERSONAL_USER_HANDLE = androidx.test.platform.app + .InstrumentationRegistry.getInstrumentation().getTargetContext().getUser(); protected Intent getConcreteIntentForLaunch(Intent clientIntent) { clientIntent.setClass( androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().getTargetContext(), @@ -97,7 +102,8 @@ public class ResolverActivityTest { @Test public void twoOptionsAndUserSelectsOne() throws InterruptedException { Intent sendIntent = createSendImageIntent(); - List resolvedComponentInfos = createResolvedComponentsForTest(2); + List resolvedComponentInfos = createResolvedComponentsForTest(2, + PERSONAL_USER_HANDLE); setupResolverControllers(resolvedComponentInfos); @@ -108,8 +114,8 @@ public class ResolverActivityTest { assertThat(activity.getAdapter().getCount(), is(2)); ResolveInfo[] chosen = new ResolveInfo[1]; - sOverrides.onSafelyStartCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); + sOverrides.onSafelyStartInternalCallback = result -> { + chosen[0] = result.first.getResolveInfo(); return true; }; @@ -126,7 +132,8 @@ public class ResolverActivityTest { @Test public void setMaxHeight() throws Exception { Intent sendIntent = createSendImageIntent(); - List resolvedComponentInfos = createResolvedComponentsForTest(2); + List resolvedComponentInfos = createResolvedComponentsForTest(2, + PERSONAL_USER_HANDLE); setupResolverControllers(resolvedComponentInfos); waitForIdle(); @@ -168,7 +175,8 @@ public class ResolverActivityTest { @Test public void setShowAtTopToTrue() throws Exception { Intent sendIntent = createSendImageIntent(); - List resolvedComponentInfos = createResolvedComponentsForTest(2); + List resolvedComponentInfos = createResolvedComponentsForTest(2, + PERSONAL_USER_HANDLE); setupResolverControllers(resolvedComponentInfos); waitForIdle(); @@ -198,7 +206,8 @@ public class ResolverActivityTest { @Test public void hasLastChosenActivity() throws Exception { Intent sendIntent = createSendImageIntent(); - List resolvedComponentInfos = createResolvedComponentsForTest(2); + List resolvedComponentInfos = createResolvedComponentsForTest(2, + PERSONAL_USER_HANDLE); ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); setupResolverControllers(resolvedComponentInfos); @@ -213,8 +222,8 @@ public class ResolverActivityTest { assertThat(activity.getAdapter().getPlaceholderCount(), is(1)); ResolveInfo[] chosen = new ResolveInfo[1]; - sOverrides.onSafelyStartCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); + sOverrides.onSafelyStartInternalCallback = result -> { + chosen[0] = result.first.getResolveInfo(); return true; }; @@ -226,10 +235,12 @@ public class ResolverActivityTest { @Test public void hasOtherProfileOneOption() throws Exception { List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10); - List workResolvedComponentInfos = createResolvedComponentsForTest(4); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10, + PERSONAL_USER_HANDLE); markWorkProfileUserAvailable(); + List workResolvedComponentInfos = createResolvedComponentsForTest(4, + sOverrides.workProfileUserHandle); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); ResolveInfo toChoose = personalResolvedComponentInfos.get(1).getResolveInfoAt(0); Intent sendIntent = createSendImageIntent(); @@ -241,13 +252,14 @@ public class ResolverActivityTest { assertThat(activity.getAdapter().getCount(), is(1)); ResolveInfo[] chosen = new ResolveInfo[1]; - sOverrides.onSafelyStartCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); + sOverrides.onSafelyStartInternalCallback = result -> { + chosen[0] = result.first.getResolveInfo(); return true; }; // Make a stable copy of the components as the original list may be modified List stableCopy = - createResolvedComponentsForTestWithOtherProfile(2, /* userId= */ 10); + createResolvedComponentsForTestWithOtherProfile(2, /* userId= */ 10, + PERSONAL_USER_HANDLE); // We pick the first one as there is another one in the work profile side onView(first(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name))) .perform(click()); @@ -261,7 +273,7 @@ public class ResolverActivityTest { public void hasOtherProfileTwoOptionsAndUserSelectsOne() throws Exception { Intent sendIntent = createSendImageIntent(); List resolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3); + createResolvedComponentsForTestWithOtherProfile(3, PERSONAL_USER_HANDLE); ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); setupResolverControllers(resolvedComponentInfos); @@ -274,8 +286,8 @@ public class ResolverActivityTest { assertThat(activity.getAdapter().getCount(), is(2)); ResolveInfo[] chosen = new ResolveInfo[1]; - sOverrides.onSafelyStartCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); + sOverrides.onSafelyStartInternalCallback = result -> { + chosen[0] = result.first.getResolveInfo(); return true; }; @@ -284,7 +296,7 @@ public class ResolverActivityTest { // Make a stable copy of the components as the original list may be modified List stableCopy = - createResolvedComponentsForTestWithOtherProfile(2); + createResolvedComponentsForTestWithOtherProfile(2, PERSONAL_USER_HANDLE); onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) .perform(click()); @@ -300,7 +312,7 @@ public class ResolverActivityTest { // chosen activity. Intent sendIntent = createSendImageIntent(); List resolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3); + createResolvedComponentsForTestWithOtherProfile(3, PERSONAL_USER_HANDLE); ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); setupResolverControllers(resolvedComponentInfos); @@ -315,8 +327,8 @@ public class ResolverActivityTest { assertThat(activity.getAdapter().getCount(), is(2)); ResolveInfo[] chosen = new ResolveInfo[1]; - sOverrides.onSafelyStartCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); + sOverrides.onSafelyStartInternalCallback = result -> { + chosen[0] = result.first.getResolveInfo(); return true; }; @@ -325,7 +337,7 @@ public class ResolverActivityTest { // Make a stable copy of the components as the original list may be modified List stableCopy = - createResolvedComponentsForTestWithOtherProfile(2); + createResolvedComponentsForTestWithOtherProfile(2, PERSONAL_USER_HANDLE); onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) .perform(click()); @@ -358,12 +370,14 @@ public class ResolverActivityTest { @Test public void testWorkTab_workTabListPopulatedBeforeGoingToTab() throws InterruptedException { List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId = */ 10); - List workResolvedComponentInfos = createResolvedComponentsForTest(4); + createResolvedComponentsForTestWithOtherProfile(3, /* userId = */ 10, + PERSONAL_USER_HANDLE); + markWorkProfileUserAvailable(); + List workResolvedComponentInfos = createResolvedComponentsForTest(4, + sOverrides.workProfileUserHandle); setupResolverControllers(personalResolvedComponentInfos, new ArrayList<>(workResolvedComponentInfos)); Intent sendIntent = createSendImageIntent(); - markWorkProfileUserAvailable(); final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); waitForIdle(); @@ -376,8 +390,11 @@ public class ResolverActivityTest { @Test public void testWorkTab_workTabUsesExpectedAdapter() { List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List workResolvedComponentInfos = createResolvedComponentsForTest(4); + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, + PERSONAL_USER_HANDLE); + markWorkProfileUserAvailable(); + List workResolvedComponentInfos = createResolvedComponentsForTest(4, + sOverrides.workProfileUserHandle); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); markWorkProfileUserAvailable(); @@ -393,11 +410,12 @@ public class ResolverActivityTest { @Test public void testWorkTab_personalTabUsesExpectedAdapter() { List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3); - List workResolvedComponentInfos = createResolvedComponentsForTest(4); + createResolvedComponentsForTestWithOtherProfile(3, PERSONAL_USER_HANDLE); + markWorkProfileUserAvailable(); + List workResolvedComponentInfos = createResolvedComponentsForTest(4, + sOverrides.workProfileUserHandle); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); - markWorkProfileUserAvailable(); final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); waitForIdle(); @@ -411,8 +429,10 @@ public class ResolverActivityTest { public void testWorkTab_workProfileHasExpectedNumberOfTargets() throws InterruptedException { markWorkProfileUserAvailable(); List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List workResolvedComponentInfos = createResolvedComponentsForTest(4); + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, + PERSONAL_USER_HANDLE); + List workResolvedComponentInfos = createResolvedComponentsForTest(4, + sOverrides.workProfileUserHandle); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); @@ -429,13 +449,15 @@ public class ResolverActivityTest { public void testWorkTab_selectingWorkTabAppOpensAppInWorkProfile() throws InterruptedException { markWorkProfileUserAvailable(); List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List workResolvedComponentInfos = createResolvedComponentsForTest(4); + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, + PERSONAL_USER_HANDLE); + List workResolvedComponentInfos = createResolvedComponentsForTest(4, + sOverrides.workProfileUserHandle); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); ResolveInfo[] chosen = new ResolveInfo[1]; - sOverrides.onSafelyStartCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); + sOverrides.onSafelyStartInternalCallback = result -> { + chosen[0] = result.first.getResolveInfo(); return true; }; @@ -459,8 +481,9 @@ public class ResolverActivityTest { throws InterruptedException { markWorkProfileUserAvailable(); List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(1); - List workResolvedComponentInfos = createResolvedComponentsForTest(4); + createResolvedComponentsForTestWithOtherProfile(1, PERSONAL_USER_HANDLE); + List workResolvedComponentInfos = createResolvedComponentsForTest(4, + sOverrides.workProfileUserHandle); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); @@ -477,8 +500,9 @@ public class ResolverActivityTest { public void testWorkTab_headerIsVisibleInPersonalTab() { markWorkProfileUserAvailable(); List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(1); - List workResolvedComponentInfos = createResolvedComponentsForTest(4); + createResolvedComponentsForTestWithOtherProfile(1, PERSONAL_USER_HANDLE); + List workResolvedComponentInfos = createResolvedComponentsForTest(4, + sOverrides.workProfileUserHandle); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createOpenWebsiteIntent(); @@ -494,8 +518,9 @@ public class ResolverActivityTest { public void testWorkTab_switchTabs_headerStaysSame() { markWorkProfileUserAvailable(); List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(1); - List workResolvedComponentInfos = createResolvedComponentsForTest(4); + createResolvedComponentsForTestWithOtherProfile(1, PERSONAL_USER_HANDLE); + List workResolvedComponentInfos = createResolvedComponentsForTest(4, + sOverrides.workProfileUserHandle); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createOpenWebsiteIntent(); @@ -519,13 +544,15 @@ public class ResolverActivityTest { throws InterruptedException { markWorkProfileUserAvailable(); List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId= */ 10); - List workResolvedComponentInfos = createResolvedComponentsForTest(4); + createResolvedComponentsForTestWithOtherProfile(3, /* userId= */ 10, + PERSONAL_USER_HANDLE); + List workResolvedComponentInfos = createResolvedComponentsForTest(4, + sOverrides.workProfileUserHandle); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); ResolveInfo[] chosen = new ResolveInfo[1]; - sOverrides.onSafelyStartCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); + sOverrides.onSafelyStartInternalCallback = result -> { + chosen[0] = result.first.getResolveInfo(); return true; }; @@ -551,9 +578,11 @@ public class ResolverActivityTest { markWorkProfileUserAvailable(); int workProfileTargets = 4; List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, + PERSONAL_USER_HANDLE); List workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); + createResolvedComponentsForTest(workProfileTargets, + sOverrides.workProfileUserHandle); sOverrides.hasCrossProfileIntents = false; setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); @@ -575,9 +604,11 @@ public class ResolverActivityTest { markWorkProfileUserAvailable(); int workProfileTargets = 4; List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, + PERSONAL_USER_HANDLE); List workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); + createResolvedComponentsForTest(workProfileTargets, + sOverrides.workProfileUserHandle); sOverrides.isQuietModeEnabled = true; setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); @@ -598,9 +629,9 @@ public class ResolverActivityTest { public void testWorkTab_noWorkAppsAvailable_emptyStateShown() { markWorkProfileUserAvailable(); List personalResolvedComponentInfos = - createResolvedComponentsForTest(3); + createResolvedComponentsForTest(3, PERSONAL_USER_HANDLE); List workResolvedComponentInfos = - createResolvedComponentsForTest(0); + createResolvedComponentsForTest(0, sOverrides.workProfileUserHandle); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); sendIntent.setType("TestType"); @@ -620,9 +651,9 @@ public class ResolverActivityTest { public void testWorkTab_xProfileOff_noAppsAvailable_workOff_xProfileOffEmptyStateShown() { markWorkProfileUserAvailable(); List personalResolvedComponentInfos = - createResolvedComponentsForTest(3); + createResolvedComponentsForTest(3, PERSONAL_USER_HANDLE); List workResolvedComponentInfos = - createResolvedComponentsForTest(0); + createResolvedComponentsForTest(0, sOverrides.workProfileUserHandle); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); sendIntent.setType("TestType"); @@ -644,9 +675,9 @@ public class ResolverActivityTest { public void testMiniResolver() { markWorkProfileUserAvailable(); List personalResolvedComponentInfos = - createResolvedComponentsForTest(1); + createResolvedComponentsForTest(1, PERSONAL_USER_HANDLE); List workResolvedComponentInfos = - createResolvedComponentsForTest(1); + createResolvedComponentsForTest(1, sOverrides.workProfileUserHandle); // Personal profile only has a browser personalResolvedComponentInfos.get(0).getResolveInfoAt(0).handleAllWebDataURI = true; setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); @@ -662,9 +693,9 @@ public class ResolverActivityTest { public void testMiniResolver_noCurrentProfileTarget() { markWorkProfileUserAvailable(); List personalResolvedComponentInfos = - createResolvedComponentsForTest(0); + createResolvedComponentsForTest(0, PERSONAL_USER_HANDLE); List workResolvedComponentInfos = - createResolvedComponentsForTest(1); + createResolvedComponentsForTest(1, sOverrides.workProfileUserHandle); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); sendIntent.setType("TestType"); @@ -690,9 +721,9 @@ public class ResolverActivityTest { public void testWorkTab_noAppsAvailable_workOff_noAppsAvailableEmptyStateShown() { markWorkProfileUserAvailable(); List personalResolvedComponentInfos = - createResolvedComponentsForTest(3); + createResolvedComponentsForTest(3, PERSONAL_USER_HANDLE); List workResolvedComponentInfos = - createResolvedComponentsForTest(0); + createResolvedComponentsForTest(0, sOverrides.workProfileUserHandle); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); sendIntent.setType("TestType"); @@ -714,16 +745,18 @@ public class ResolverActivityTest { markWorkProfileUserAvailable(); int workProfileTargets = 4; List personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10); + createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10, + PERSONAL_USER_HANDLE); List workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); + createResolvedComponentsForTest(workProfileTargets, + sOverrides.workProfileUserHandle); sOverrides.hasCrossProfileIntents = false; setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); sendIntent.setType("TestType"); ResolveInfo[] chosen = new ResolveInfo[1]; - sOverrides.onSafelyStartCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); + sOverrides.onSafelyStartInternalCallback = result -> { + chosen[0] = result.first.getResolveInfo(); return true; }; @@ -741,7 +774,7 @@ public class ResolverActivityTest { // chosen activity. Intent sendIntent = createSendImageIntent(); List resolvedComponentInfos = - createResolvedComponentsForTest(2); + createResolvedComponentsForTest(2, PERSONAL_USER_HANDLE); setupResolverControllers(resolvedComponentInfos); when(sOverrides.resolverListController.getLastChosen()) @@ -757,6 +790,200 @@ public class ResolverActivityTest { assertThat(activity.getAdapter().getPlaceholderCount(), is(2)); } + @Test + public void testClonedProfilePresent_personalAdapterIsSetWithPersonalProfile() { + // enable cloneProfile + markCloneProfileUserAvailable(); + List resolvedComponentInfos = + createResolvedComponentsWithCloneProfileForTest( + 3, + PERSONAL_USER_HANDLE, + sOverrides.cloneProfileUserHandle); + setupResolverControllers(resolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + + assertThat(activity.getCurrentUserHandle(), is(activity.getPersonalProfileUserHandle())); + assertThat(activity.getAdapter().getCount(), is(3)); + } + + @Test + public void testClonedProfilePresent_personalTabUsesExpectedAdapter() { + markWorkProfileUserAvailable(); + // enable cloneProfile + markCloneProfileUserAvailable(); + List personalResolvedComponentInfos = + createResolvedComponentsWithCloneProfileForTest( + 3, + PERSONAL_USER_HANDLE, + sOverrides.cloneProfileUserHandle); + List workResolvedComponentInfos = createResolvedComponentsForTest(4, + sOverrides.workProfileUserHandle); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + + assertThat(activity.getCurrentUserHandle(), is(activity.getPersonalProfileUserHandle())); + assertThat(activity.getAdapter().getCount(), is(3)); + } + + @Test + public void testClonedProfilePresent_layoutWithDefault_neverShown() throws Exception { + // enable cloneProfile + markCloneProfileUserAvailable(); + Intent sendIntent = createSendImageIntent(); + List resolvedComponentInfos = + createResolvedComponentsWithCloneProfileForTest( + 2, + PERSONAL_USER_HANDLE, + sOverrides.cloneProfileUserHandle); + + setupResolverControllers(resolvedComponentInfos); + when(sOverrides.resolverListController.getLastChosen()) + .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0)); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + Espresso.registerIdlingResources(activity.getAdapter().getLabelIdlingResource()); + waitForIdle(); + + assertThat(activity.getAdapter().hasFilteredItem(), is(false)); + assertThat(activity.getAdapter().getCount(), is(2)); + assertThat(activity.getAdapter().getPlaceholderCount(), is(2)); + } + + @Test + public void testClonedProfilePresent_alwaysButtonDisabled() throws Exception { + // enable cloneProfile + markCloneProfileUserAvailable(); + Intent sendIntent = createSendImageIntent(); + List resolvedComponentInfos = + createResolvedComponentsWithCloneProfileForTest( + 3, + PERSONAL_USER_HANDLE, + sOverrides.cloneProfileUserHandle); + + setupResolverControllers(resolvedComponentInfos); + when(sOverrides.resolverListController.getLastChosen()) + .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0)); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + + // Confirm that the button bar is disabled by default + onView(withId(com.android.internal.R.id.button_once)).check(matches(not(isEnabled()))); + onView(withId(com.android.internal.R.id.button_always)).check(matches(not(isEnabled()))); + + // Make a stable copy of the components as the original list may be modified + List stableCopy = + createResolvedComponentsForTestWithOtherProfile(2, PERSONAL_USER_HANDLE); + + onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) + .perform(click()); + + onView(withId(com.android.internal.R.id.button_once)).check(matches(isEnabled())); + onView(withId(com.android.internal.R.id.button_always)).check(matches(not(isEnabled()))); + } + + @Test + public void testClonedProfilePresent_personalProfileActivityIsStartedInCorrectUser() + throws Exception { + markWorkProfileUserAvailable(); + // enable cloneProfile + markCloneProfileUserAvailable(); + + List personalResolvedComponentInfos = + createResolvedComponentsWithCloneProfileForTest( + 3, + PERSONAL_USER_HANDLE, + sOverrides.cloneProfileUserHandle); + List workResolvedComponentInfos = + createResolvedComponentsForTest(3, sOverrides.workProfileUserHandle); + sOverrides.hasCrossProfileIntents = false; + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + sendIntent.setType("TestType"); + final UserHandle[] selectedActivityUserHandle = new UserHandle[1]; + sOverrides.onSafelyStartInternalCallback = result -> { + selectedActivityUserHandle[0] = result.second; + return true; + }; + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + onView(first(allOf(withText(personalResolvedComponentInfos.get(0) + .getResolveInfoAt(0).activityInfo.applicationInfo.name), isCompletelyDisplayed()))) + .perform(click()); + onView(withId(com.android.internal.R.id.button_once)) + .perform(click()); + waitForIdle(); + + assertThat(selectedActivityUserHandle[0], is(activity.getAdapter().getUserHandle())); + } + + @Test + public void testClonedProfilePresent_workProfileActivityIsStartedInCorrectUser() + throws Exception { + markWorkProfileUserAvailable(); + // enable cloneProfile + markCloneProfileUserAvailable(); + + List personalResolvedComponentInfos = + createResolvedComponentsWithCloneProfileForTest( + 3, + PERSONAL_USER_HANDLE, + sOverrides.cloneProfileUserHandle); + List workResolvedComponentInfos = + createResolvedComponentsForTest(3, sOverrides.workProfileUserHandle); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + sendIntent.setType("TestType"); + final UserHandle[] selectedActivityUserHandle = new UserHandle[1]; + sOverrides.onSafelyStartInternalCallback = result -> { + selectedActivityUserHandle[0] = result.second; + return true; + }; + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + onView(withText(R.string.resolver_work_tab)) + .perform(click()); + waitForIdle(); + onView(first(allOf(withText(workResolvedComponentInfos.get(0) + .getResolveInfoAt(0).activityInfo.applicationInfo.name), isCompletelyDisplayed()))) + .perform(click()); + onView(withId(com.android.internal.R.id.button_once)) + .perform(click()); + waitForIdle(); + + assertThat(selectedActivityUserHandle[0], is(activity.getAdapter().getUserHandle())); + } + + @Test + public void testClonedProfilePresent_personalProfileResolverComparatorHasCorrectUsers() + throws Exception { + // enable cloneProfile + markCloneProfileUserAvailable(); + List resolvedComponentInfos = + createResolvedComponentsWithCloneProfileForTest( + 3, + PERSONAL_USER_HANDLE, + sOverrides.cloneProfileUserHandle); + setupResolverControllers(resolvedComponentInfos); + Intent sendIntent = createSendImageIntent(); + + final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); + waitForIdle(); + List result = activity + .getResolverRankerServiceUserHandleList(PERSONAL_USER_HANDLE); + + assertThat(result.containsAll(Lists.newArrayList(PERSONAL_USER_HANDLE, + sOverrides.cloneProfileUserHandle)), is(true)); + } + private Intent createSendImageIntent() { Intent sendIntent = new Intent(); sendIntent.setAction(Intent.ACTION_SEND); @@ -772,36 +999,56 @@ public class ResolverActivityTest { return sendIntent; } - private List createResolvedComponentsForTest(int numberOfResults) { + private List createResolvedComponentsForTest(int numberOfResults, + UserHandle resolvedForUser) { List infoList = new ArrayList<>(numberOfResults); for (int i = 0; i < numberOfResults; i++) { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i)); + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, resolvedForUser)); + } + return infoList; + } + + private List createResolvedComponentsWithCloneProfileForTest( + int numberOfResults, + UserHandle resolvedForPersonalUser, + UserHandle resolvedForClonedUser) { + List infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < 1; i++) { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, + resolvedForPersonalUser)); + } + for (int i = 1; i < numberOfResults; i++) { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, + resolvedForClonedUser)); } return infoList; } private List createResolvedComponentsForTestWithOtherProfile( - int numberOfResults) { + int numberOfResults, + UserHandle resolvedForUser) { List infoList = new ArrayList<>(numberOfResults); for (int i = 0; i < numberOfResults; i++) { if (i == 0) { - infoList.add(ResolverDataProvider.createResolvedComponentInfoWithOtherId(i)); + infoList.add(ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, + resolvedForUser)); } else { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i)); + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, resolvedForUser)); } } return infoList; } private List createResolvedComponentsForTestWithOtherProfile( - int numberOfResults, int userId) { + int numberOfResults, int userId, UserHandle resolvedForUser) { List infoList = new ArrayList<>(numberOfResults); for (int i = 0; i < numberOfResults; i++) { if (i == 0) { infoList.add( - ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, userId)); + ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, userId, + resolvedForUser)); } else { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i)); + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, resolvedForUser)); } } return infoList; @@ -820,6 +1067,10 @@ public class ResolverActivityTest { setupResolverControllers(personalResolvedComponentInfos, new ArrayList<>()); } + private void markCloneProfileUserAvailable() { + ResolverWrapperActivity.sOverrides.cloneProfileUserHandle = UserHandle.of(11); + } + private void setupResolverControllers( List personalResolvedComponentInfos, List workResolvedComponentInfos) { diff --git a/java/tests/src/com/android/intentresolver/ResolverDataProvider.java b/java/tests/src/com/android/intentresolver/ResolverDataProvider.java index b6b32b5a..688dd867 100644 --- a/java/tests/src/com/android/intentresolver/ResolverDataProvider.java +++ b/java/tests/src/com/android/intentresolver/ResolverDataProvider.java @@ -43,6 +43,14 @@ public class ResolverDataProvider { createResolveInfo(i, UserHandle.USER_CURRENT)); } + static ResolvedComponentInfo createResolvedComponentInfo(int i, + UserHandle resolvedForUser) { + return new ResolvedComponentInfo( + createComponentName(i), + createResolverIntent(i), + createResolveInfo(i, UserHandle.USER_CURRENT, resolvedForUser)); + } + static ResolvedComponentInfo createResolvedComponentInfo( ComponentName componentName, Intent intent) { return new ResolvedComponentInfo( @@ -51,6 +59,14 @@ public class ResolverDataProvider { createResolveInfo(componentName, UserHandle.USER_CURRENT)); } + static ResolvedComponentInfo createResolvedComponentInfo( + ComponentName componentName, Intent intent, UserHandle resolvedForUser) { + return new ResolvedComponentInfo( + componentName, + intent, + createResolveInfo(componentName, UserHandle.USER_CURRENT, resolvedForUser)); + } + static ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i) { return new ResolvedComponentInfo( createComponentName(i), @@ -58,6 +74,14 @@ public class ResolverDataProvider { createResolveInfo(i, USER_SOMEONE_ELSE)); } + static ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i, + UserHandle resolvedForUser) { + return new ResolvedComponentInfo( + createComponentName(i), + createResolverIntent(i), + createResolveInfo(i, USER_SOMEONE_ELSE, resolvedForUser)); + } + static ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i, int userId) { return new ResolvedComponentInfo( createComponentName(i), @@ -65,6 +89,14 @@ public class ResolverDataProvider { createResolveInfo(i, userId)); } + static ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i, + int userId, UserHandle resolvedForUser) { + return new ResolvedComponentInfo( + createComponentName(i), + createResolverIntent(i), + createResolveInfo(i, userId, resolvedForUser)); + } + public static ComponentName createComponentName(int i) { final String name = "component" + i; return new ComponentName("foo.bar." + name, name); @@ -76,6 +108,13 @@ public class ResolverDataProvider { resolveInfo.targetUserId = userId; return resolveInfo; } + public static ResolveInfo createResolveInfo(int i, int userId, UserHandle resolvedForUser) { + final ResolveInfo resolveInfo = new ResolveInfo(); + resolveInfo.activityInfo = createActivityInfo(i); + resolveInfo.targetUserId = userId; + resolveInfo.userHandle = resolvedForUser; + return resolveInfo; + } public static ResolveInfo createResolveInfo(ComponentName componentName, int userId) { final ResolveInfo resolveInfo = new ResolveInfo(); @@ -84,6 +123,15 @@ public class ResolverDataProvider { return resolveInfo; } + public static ResolveInfo createResolveInfo(ComponentName componentName, int userId, + UserHandle resolvedForUser) { + final ResolveInfo resolveInfo = new ResolveInfo(); + resolveInfo.activityInfo = createActivityInfo(componentName); + resolveInfo.targetUserId = userId; + resolveInfo.userHandle = resolvedForUser; + return resolveInfo; + } + static ActivityInfo createActivityInfo(int i) { ActivityInfo ai = new ActivityInfo(); ai.name = "activity_name" + i; diff --git a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java index d67b73af..645e8c72 100644 --- a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java @@ -21,6 +21,7 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import android.annotation.Nullable; import android.app.usage.UsageStatsManager; import android.content.Context; import android.content.Intent; @@ -28,9 +29,9 @@ import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.os.Bundle; import android.os.UserHandle; +import android.util.Pair; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider; import com.android.intentresolver.chooser.TargetInfo; import java.util.List; @@ -67,15 +68,8 @@ public class ResolverWrapperActivity extends ResolverActivity { createListController(userHandle), userHandle, payloadIntents.get(0), // TODO: extract upstream - this); - } - - @Override - protected MyUserIdProvider createMyUserIdProvider() { - if (sOverrides.mMyUserIdProvider != null) { - return sOverrides.mMyUserIdProvider; - } - return super.createMyUserIdProvider(); + this, + userHandle); } @Override @@ -118,12 +112,13 @@ public class ResolverWrapperActivity extends ResolverActivity { } @Override - public void safelyStartActivity(TargetInfo cti) { - if (sOverrides.onSafelyStartCallback != null && - sOverrides.onSafelyStartCallback.apply(cti)) { + public void safelyStartActivityInternal(TargetInfo cti, UserHandle user, + @Nullable Bundle options) { + if (sOverrides.onSafelyStartInternalCallback != null + && sOverrides.onSafelyStartInternalCallback.apply(new Pair<>(cti, user))) { return; } - super.safelyStartActivity(cti); + super.safelyStartActivityInternal(cti, user, options); } @Override @@ -151,11 +146,22 @@ public class ResolverWrapperActivity extends ResolverActivity { return sOverrides.workProfileUserHandle; } + @Override + protected UserHandle getCloneProfileUserHandle() { + return sOverrides.cloneProfileUserHandle; + } + @Override public void startActivityAsUser(Intent intent, Bundle options, UserHandle user) { super.startActivityAsUser(intent, options, user); } + @Override + protected List getResolverRankerServiceUserHandleListInternal(UserHandle + userHandle) { + return super.getResolverRankerServiceUserHandleListInternal(userHandle); + } + /** * We cannot directly mock the activity created since instrumentation creates it. *

    @@ -164,25 +170,28 @@ public class ResolverWrapperActivity extends ResolverActivity { static class OverrideData { @SuppressWarnings("Since15") public Function createPackageManager; - public Function onSafelyStartCallback; + public Function, Boolean> onSafelyStartInternalCallback; public ResolverListController resolverListController; public ResolverListController workResolverListController; public Boolean isVoiceInteraction; public UserHandle workProfileUserHandle; + public UserHandle cloneProfileUserHandle; + public UserHandle tabOwnerUserHandleForLaunch; public Integer myUserId; public boolean hasCrossProfileIntents; public boolean isQuietModeEnabled; public WorkProfileAvailabilityManager mWorkProfileAvailability; - public MyUserIdProvider mMyUserIdProvider; public CrossProfileIntentsChecker mCrossProfileIntentsChecker; public void reset() { - onSafelyStartCallback = null; + onSafelyStartInternalCallback = null; isVoiceInteraction = null; createPackageManager = null; resolverListController = mock(ResolverListController.class); workResolverListController = mock(ResolverListController.class); workProfileUserHandle = null; + cloneProfileUserHandle = null; + tabOwnerUserHandleForLaunch = null; myUserId = null; hasCrossProfileIntents = true; isQuietModeEnabled = false; @@ -212,13 +221,6 @@ public class ResolverWrapperActivity extends ResolverActivity { } }; - mMyUserIdProvider = new MyUserIdProvider() { - @Override - public int getMyUserId() { - return myUserId != null ? myUserId : UserHandle.myUserId(); - } - }; - mCrossProfileIntentsChecker = mock(CrossProfileIntentsChecker.class); when(mCrossProfileIntentsChecker.hasCrossProfileIntents(any(), anyInt(), anyInt())) .thenAnswer(invocation -> hasCrossProfileIntents); diff --git a/java/tests/src/com/android/intentresolver/ResolverWrapperAdapter.java b/java/tests/src/com/android/intentresolver/ResolverWrapperAdapter.java index a53b41d1..fd310fd8 100644 --- a/java/tests/src/com/android/intentresolver/ResolverWrapperAdapter.java +++ b/java/tests/src/com/android/intentresolver/ResolverWrapperAdapter.java @@ -41,7 +41,8 @@ public class ResolverWrapperAdapter extends ResolverListAdapter { ResolverListController resolverListController, UserHandle userHandle, Intent targetIntent, - ResolverListCommunicator resolverListCommunicator) { + ResolverListCommunicator resolverListCommunicator, + UserHandle initialIntentsUserHandle) { super( context, payloadIntents, @@ -52,7 +53,8 @@ public class ResolverWrapperAdapter extends ResolverListAdapter { userHandle, targetIntent, resolverListCommunicator, - false); + false, + initialIntentsUserHandle); } public CountingIdlingResource getLabelIdlingResource() { diff --git a/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt b/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt index a8d6f978..9ddeed84 100644 --- a/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt +++ b/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt @@ -21,10 +21,12 @@ import android.content.Context import android.content.Intent import android.content.pm.ResolveInfo import android.content.pm.ShortcutInfo +import android.os.UserHandle import android.service.chooser.ChooserTarget import com.android.intentresolver.chooser.DisplayResolveInfo import com.android.intentresolver.chooser.TargetInfo import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -35,6 +37,9 @@ private const val CLASS_NAME = "./MainActivity" @SmallTest class ShortcutSelectionLogicTest { + private val PERSONAL_USER_HANDLE: UserHandle = InstrumentationRegistry + .getInstrumentation().getTargetContext().getUser() + private val packageTargets = HashMap>().apply { arrayOf(PACKAGE_A, PACKAGE_B).forEach { pkg -> // shortcuts in reverse priority order @@ -52,7 +57,7 @@ class ShortcutSelectionLogicTest { private val baseDisplayInfo = DisplayResolveInfo.newDisplayResolveInfo( Intent(), - ResolverDataProvider.createResolveInfo(3, 0), + ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE), "label", "extended info", Intent(), @@ -60,7 +65,7 @@ class ShortcutSelectionLogicTest { private val otherBaseDisplayInfo = DisplayResolveInfo.newDisplayResolveInfo( Intent(), - ResolverDataProvider.createResolveInfo(4, 0), + ResolverDataProvider.createResolveInfo(4, 0, PERSONAL_USER_HANDLE), "label 2", "extended info 2", Intent(), diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 3bf9f1d8..4a0eb92d 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -155,6 +155,8 @@ public class UnbundledChooserActivityTest { * -------- */ + private static final UserHandle PERSONAL_USER_HANDLE = InstrumentationRegistry + .getInstrumentation().getTargetContext().getUser(); private static final Function DEFAULT_PM = pm -> pm; private static final Function NO_APP_PREDICTION_SERVICE_PM = pm -> { @@ -445,7 +447,7 @@ public class UnbundledChooserActivityTest { onView(withId(com.android.internal.R.id.profile_button)).check(doesNotExist()); ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> { + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { chosen[0] = targetInfo.getResolveInfo(); return true; }; @@ -472,7 +474,7 @@ public class UnbundledChooserActivityTest { List infosToStack = new ArrayList<>(); for (int i = 0; i < 4; i++) { ResolveInfo resolveInfo = ResolverDataProvider.createResolveInfo(i, - UserHandle.USER_CURRENT); + UserHandle.USER_CURRENT, PERSONAL_USER_HANDLE); resolveInfo.activityInfo.applicationInfo.name = appName; resolveInfo.activityInfo.applicationInfo.packageName = packageName; resolveInfo.activityInfo.packageName = packageName; @@ -491,7 +493,7 @@ public class UnbundledChooserActivityTest { assertThat(activity.getAdapter().getCount(), is(6)); ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> { + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { chosen[0] = targetInfo.getResolveInfo(); return true; }; @@ -522,17 +524,21 @@ public class UnbundledChooserActivityTest { verify(ChooserActivityOverrideData.getInstance().resolverListController, times(1)) .topK(any(List.class), anyInt()); assertThat(activity.getIsSelected(), is(false)); - ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> { + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { return true; }; ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); + DisplayResolveInfo testDri = + activity.createTestDisplayResolveInfo(sendIntent, toChoose, "testLabel", "testInfo", + sendIntent, /* resolveInfoPresentationGetter */ null); onView(withText(toChoose.activityInfo.name)) .perform(click()); waitForIdle(); verify(ChooserActivityOverrideData.getInstance().resolverListController, times(1)) - .updateChooserCounts(Mockito.anyString(), anyInt(), Mockito.anyString()); + .updateChooserCounts(Mockito.anyString(), any(UserHandle.class), + Mockito.anyString()); verify(ChooserActivityOverrideData.getInstance().resolverListController, times(1)) - .updateModel(toChoose.activityInfo.getComponentName()); + .updateModel(testDri); assertThat(activity.getIsSelected(), is(true)); } @@ -560,7 +566,7 @@ public class UnbundledChooserActivityTest { @Test public void autoLaunchSingleResult() throws InterruptedException { ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> { + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { chosen[0] = targetInfo.getResolveInfo(); return true; }; @@ -595,7 +601,7 @@ public class UnbundledChooserActivityTest { assertThat(activity.getAdapter().getCount(), is(1)); ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> { + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { chosen[0] = targetInfo.getResolveInfo(); return true; }; @@ -630,7 +636,7 @@ public class UnbundledChooserActivityTest { assertThat(activity.getAdapter().getCount(), is(2)); ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> { + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { chosen[0] = targetInfo.getResolveInfo(); return true; }; @@ -661,7 +667,7 @@ public class UnbundledChooserActivityTest { assertThat(activity.getAdapter().getCount(), is(2)); ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> { + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { chosen[0] = targetInfo.getResolveInfo(); return true; }; @@ -690,10 +696,10 @@ public class UnbundledChooserActivityTest { List resolvedComponentInfos = Arrays.asList( ResolverDataProvider.createResolvedComponentInfo( new ComponentName("org.imageviewer", "ImageTarget"), - sendIntent), + sendIntent, PERSONAL_USER_HANDLE), ResolverDataProvider.createResolvedComponentInfo( new ComponentName("org.textviewer", "UriTarget"), - new Intent("VIEW_TEXT")) + new Intent("VIEW_TEXT"), PERSONAL_USER_HANDLE) ); setupResolverControllers(resolvedComponentInfos); @@ -707,7 +713,7 @@ public class UnbundledChooserActivityTest { waitForIdle(); AtomicReference launchedIntentRef = new AtomicReference<>(); - ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> { + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { launchedIntentRef.set(targetInfo.getTargetIntent()); return true; }; @@ -734,10 +740,10 @@ public class UnbundledChooserActivityTest { List resolvedComponentInfos = Arrays.asList( ResolverDataProvider.createResolvedComponentInfo( new ComponentName("org.imageviewer", "ImageTarget"), - sendIntent), + sendIntent, PERSONAL_USER_HANDLE), ResolverDataProvider.createResolvedComponentInfo( new ComponentName("org.textviewer", "UriTarget"), - new Intent("VIEW_TEXT")) + new Intent("VIEW_TEXT"), PERSONAL_USER_HANDLE) ); setupResolverControllers(resolvedComponentInfos); @@ -754,7 +760,7 @@ public class UnbundledChooserActivityTest { waitForIdle(); AtomicReference launchedIntentRef = new AtomicReference<>(); - ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> { + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { launchedIntentRef.set(targetInfo.getTargetIntent()); return true; }; @@ -784,10 +790,10 @@ public class UnbundledChooserActivityTest { List resolvedComponentInfos = Arrays.asList( ResolverDataProvider.createResolvedComponentInfo( new ComponentName("org.imageviewer", "ImageTarget"), - sendIntent), + sendIntent, PERSONAL_USER_HANDLE), ResolverDataProvider.createResolvedComponentInfo( new ComponentName("org.textviewer", "UriTarget"), - alternativeIntent) + alternativeIntent, PERSONAL_USER_HANDLE) ); setupResolverControllers(resolvedComponentInfos); @@ -801,7 +807,7 @@ public class UnbundledChooserActivityTest { waitForIdle(); AtomicReference launchedIntentRef = new AtomicReference<>(); - ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> { + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { launchedIntentRef.set(targetInfo.getTargetIntent()); return true; }; @@ -1271,8 +1277,12 @@ public class UnbundledChooserActivityTest { waitForIdle(); final DisplayResolveInfo testDri = - activity.createTestDisplayResolveInfo(sendIntent, - ResolverDataProvider.createResolveInfo(3, 0), "testLabel", "testInfo", sendIntent, + activity.createTestDisplayResolveInfo( + sendIntent, + ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE), + "testLabel", + "testInfo", + sendIntent, /* resolveInfoPresentationGetter */ null); final ChooserListAdapter adapter = activity.getAdapter(); @@ -1810,7 +1820,7 @@ public class UnbundledChooserActivityTest { // Create direct share target List serviceTargets = createDirectShareTargets(1, resolvedComponentInfos.get(14).getResolveInfoAt(0).activityInfo.packageName); - ResolveInfo ri = ResolverDataProvider.createResolveInfo(16, 0); + ResolveInfo ri = ResolverDataProvider.createResolveInfo(16, 0, PERSONAL_USER_HANDLE); // Start activity final IChooserWrapper wrapper = (IChooserWrapper) @@ -1943,7 +1953,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); sendIntent.setType(TEST_MIME_TYPE); ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> { + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { chosen[0] = targetInfo.getResolveInfo(); return true; }; @@ -2099,7 +2109,7 @@ public class UnbundledChooserActivityTest { onView(withId(com.android.internal.R.id.profile_button)).check(doesNotExist()); ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> { + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { chosen[0] = targetInfo.getResolveInfo(); return true; }; @@ -2326,7 +2336,7 @@ public class UnbundledChooserActivityTest { setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendTextIntent(); ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> { + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { chosen[0] = targetInfo.getResolveInfo(); return true; }; @@ -2345,7 +2355,7 @@ public class UnbundledChooserActivityTest { Intent chooserIntent = createChooserIntent(createSendTextIntent(), new Intent[] {new Intent("action.fake")}); ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> { + ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { chosen[0] = targetInfo.getResolveInfo(); return true; }; @@ -2473,7 +2483,7 @@ public class UnbundledChooserActivityTest { new Intent[] {new Intent("action.fake")}); ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class); ResolveInfo ri = ResolverDataProvider.createResolveInfo(0, - UserHandle.USER_CURRENT); + UserHandle.USER_CURRENT, PERSONAL_USER_HANDLE); when( ChooserActivityOverrideData .getInstance() @@ -2523,6 +2533,46 @@ public class UnbundledChooserActivityTest { verify(workProfileShortcutLoader, times(1)).queryShortcuts(any()); } + @Test + public void testClonedProfilePresent_personalAdapterIsSetWithPersonalProfile() { + // enable cloneProfile + markCloneProfileUserAvailable(); + List resolvedComponentInfos = + createResolvedComponentsWithCloneProfileForTest( + 3, + PERSONAL_USER_HANDLE, + ChooserActivityOverrideData.getInstance().cloneProfileUserHandle); + setupResolverControllers(resolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + + final IChooserWrapper activity = (IChooserWrapper) mActivityRule + .launchActivity(Intent.createChooser(sendIntent, "personalProfileTest")); + waitForIdle(); + + assertThat(activity.getPersonalListAdapter().getUserHandle(), is(PERSONAL_USER_HANDLE)); + assertThat(activity.getAdapter().getCount(), is(3)); + } + + @Test + public void testClonedProfilePresent_personalTabUsesExpectedAdapter() { + markWorkProfileUserAvailable(); + markCloneProfileUserAvailable(); + List personalResolvedComponentInfos = + createResolvedComponentsForTest(3); + List workResolvedComponentInfos = createResolvedComponentsForTest( + 4); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "multi tab test")); + waitForIdle(); + + assertThat(activity.getCurrentUserHandle(), is(PERSONAL_USER_HANDLE)); + } + private Intent createChooserIntent(Intent intent, Intent[] initialIntents) { Intent chooserIntent = new Intent(); chooserIntent.setAction(Intent.ACTION_CHOOSER); @@ -2557,6 +2607,7 @@ public class UnbundledChooserActivityTest { ri.activityInfo.packageName = "fake.package.name"; ri.activityInfo.applicationInfo = new ApplicationInfo(); ri.activityInfo.applicationInfo.packageName = "fake.package.name"; + ri.userHandle = UserHandle.CURRENT; return ri; } @@ -2618,7 +2669,23 @@ public class UnbundledChooserActivityTest { private List createResolvedComponentsForTest(int numberOfResults) { List infoList = new ArrayList<>(numberOfResults); for (int i = 0; i < numberOfResults; i++) { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i)); + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, PERSONAL_USER_HANDLE)); + } + return infoList; + } + + private List createResolvedComponentsWithCloneProfileForTest( + int numberOfResults, + UserHandle resolvedForPersonalUser, + UserHandle resolvedForClonedUser) { + List infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < 1; i++) { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, + resolvedForPersonalUser)); + } + for (int i = 1; i < numberOfResults; i++) { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, + resolvedForClonedUser)); } return infoList; } @@ -2628,9 +2695,11 @@ public class UnbundledChooserActivityTest { List infoList = new ArrayList<>(numberOfResults); for (int i = 0; i < numberOfResults; i++) { if (i == 0) { - infoList.add(ResolverDataProvider.createResolvedComponentInfoWithOtherId(i)); + infoList.add(ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, + PERSONAL_USER_HANDLE)); } else { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i)); + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, + PERSONAL_USER_HANDLE)); } } return infoList; @@ -2642,9 +2711,11 @@ public class UnbundledChooserActivityTest { for (int i = 0; i < numberOfResults; i++) { if (i == 0) { infoList.add( - ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, userId)); + ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, userId, + PERSONAL_USER_HANDLE)); } else { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i)); + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, + PERSONAL_USER_HANDLE)); } } return infoList; @@ -2654,7 +2725,8 @@ public class UnbundledChooserActivityTest { int numberOfResults, int userId) { List infoList = new ArrayList<>(numberOfResults); for (int i = 0; i < numberOfResults; i++) { - infoList.add(ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, userId)); + infoList.add(ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, userId, + PERSONAL_USER_HANDLE)); } return infoList; } @@ -2733,6 +2805,10 @@ public class UnbundledChooserActivityTest { ChooserActivityOverrideData.getInstance().workProfileUserHandle = UserHandle.of(10); } + private void markCloneProfileUserAvailable() { + ChooserActivityOverrideData.getInstance().cloneProfileUserHandle = UserHandle.of(11); + } + private void setupResolverControllers( List personalResolvedComponentInfos) { setupResolverControllers(personalResolvedComponentInfos, new ArrayList<>()); diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java index 6c1edfbc..92bccb7d 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java @@ -98,7 +98,7 @@ public class UnbundledChooserActivityWorkProfileTest { public void testBlocker() { setUpPersonalAndWorkComponentInfos(); sOverrides.hasCrossProfileIntents = mTestCase.hasCrossProfileIntents(); - sOverrides.myUserId = mTestCase.getMyUserHandle().getIdentifier(); + sOverrides.tabOwnerUserHandleForLaunch = mTestCase.getMyUserHandle(); launchActivity(mTestCase.getIsSendAction()); switchToTab(mTestCase.getTab()); @@ -241,19 +241,21 @@ public class UnbundledChooserActivityWorkProfileTest { } private List createResolvedComponentsForTestWithOtherProfile( - int numberOfResults, int userId) { + int numberOfResults, int userId, UserHandle resolvedForUser) { List infoList = new ArrayList<>(numberOfResults); for (int i = 0; i < numberOfResults; i++) { infoList.add( - ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, userId)); + ResolverDataProvider + .createResolvedComponentInfoWithOtherId(i, userId, resolvedForUser)); } return infoList; } - private List createResolvedComponentsForTest(int numberOfResults) { + private List createResolvedComponentsForTest(int numberOfResults, + UserHandle resolvedForUser) { List infoList = new ArrayList<>(numberOfResults); for (int i = 0; i < numberOfResults; i++) { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i)); + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, resolvedForUser)); } return infoList; } @@ -263,9 +265,9 @@ public class UnbundledChooserActivityWorkProfileTest { int workProfileTargets = 4; List personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, - /* userId */ WORK_USER_HANDLE.getIdentifier()); + /* userId */ WORK_USER_HANDLE.getIdentifier(), PERSONAL_USER_HANDLE); List workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); + createResolvedComponentsForTest(workProfileTargets, WORK_USER_HANDLE); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); } diff --git a/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt b/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt index e9c755d3..cebccaae 100644 --- a/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt +++ b/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt @@ -30,14 +30,18 @@ import com.android.intentresolver.ResolverActivity import com.android.intentresolver.ResolverDataProvider import com.google.common.truth.Truth.assertThat import org.junit.Test +import androidx.test.platform.app.InstrumentationRegistry class ImmutableTargetInfoTest { + private val PERSONAL_USER_HANDLE: UserHandle = InstrumentationRegistry + .getInstrumentation().getTargetContext().getUser() + private val resolvedIntent = Intent("resolved") private val targetIntent = Intent("target") private val referrerFillInIntent = Intent("referrer_fillin") private val resolvedComponentName = ComponentName("resolved", "component") private val chooserTargetComponentName = ComponentName("chooser", "target") - private val resolveInfo = ResolverDataProvider.createResolveInfo(1, 0) + private val resolveInfo = ResolverDataProvider.createResolveInfo(1, 0, PERSONAL_USER_HANDLE) private val displayLabel: CharSequence = "Display Label" private val extendedInfo: CharSequence = "Extended Info" private val displayIconHolder: TargetInfo.IconHolder = mock() @@ -45,14 +49,14 @@ class ImmutableTargetInfoTest { private val sourceIntent2 = Intent("source2") private val displayTarget1 = DisplayResolveInfo.newDisplayResolveInfo( Intent("display1"), - ResolverDataProvider.createResolveInfo(2, 0), + ResolverDataProvider.createResolveInfo(2, 0, PERSONAL_USER_HANDLE), "display1 label", "display1 extended info", Intent("display1_resolved"), /* resolveInfoPresentationGetter= */ null) private val displayTarget2 = DisplayResolveInfo.newDisplayResolveInfo( Intent("display2"), - ResolverDataProvider.createResolveInfo(3, 0), + ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE), "display2 label", "display2 extended info", Intent("display2_resolved"), @@ -66,7 +70,7 @@ class ImmutableTargetInfoTest { UserHandle.CURRENT) private val displayResolveInfo = DisplayResolveInfo.newDisplayResolveInfo( Intent("displayresolve"), - ResolverDataProvider.createResolveInfo(5, 0), + ResolverDataProvider.createResolveInfo(5, 0, PERSONAL_USER_HANDLE), "displayresolve label", "displayresolve extended info", Intent("display_resolved"), diff --git a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt index dddbcccb..886e32df 100644 --- a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt +++ b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt @@ -41,6 +41,9 @@ import org.mockito.Mockito.times import org.mockito.Mockito.verify class TargetInfoTest { + private val PERSONAL_USER_HANDLE: UserHandle = InstrumentationRegistry + .getInstrumentation().getTargetContext().getUser() + private val context = InstrumentationRegistry.getInstrumentation().getContext() @Before @@ -81,7 +84,7 @@ class TargetInfoTest { val resolvedIntent = Intent() val baseDisplayInfo = DisplayResolveInfo.newDisplayResolveInfo( resolvedIntent, - ResolverDataProvider.createResolveInfo(1, 0), + ResolverDataProvider.createResolveInfo(1, 0, PERSONAL_USER_HANDLE), "label", "extended info", resolvedIntent, @@ -190,7 +193,7 @@ class TargetInfoTest { intent.putExtra(Intent.EXTRA_TEXT, "testing intent sending") intent.setType("text/plain") - val resolveInfo = ResolverDataProvider.createResolveInfo(3, 0) + val resolveInfo = ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE) val targetInfo = DisplayResolveInfo.newDisplayResolveInfo( intent, @@ -268,7 +271,7 @@ class TargetInfoTest { intent.putExtra(Intent.EXTRA_TEXT, "testing intent sending") intent.setType("text/plain") - val resolveInfo = ResolverDataProvider.createResolveInfo(3, 0) + val resolveInfo = ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE) val firstTargetInfo = DisplayResolveInfo.newDisplayResolveInfo( intent, resolveInfo, diff --git a/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java b/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java index 006f3b2d..892a2e28 100644 --- a/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java +++ b/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java @@ -28,6 +28,9 @@ import android.os.Message; import androidx.test.InstrumentationRegistry; import com.android.intentresolver.ResolvedComponentInfo; +import com.android.intentresolver.chooser.TargetInfo; + +import com.google.android.collect.Lists; import org.junit.Test; @@ -81,7 +84,8 @@ public class AbstractResolverComparatorTest { Intent intent = new Intent(); AbstractResolverComparator testComparator = - new AbstractResolverComparator(context, intent) { + new AbstractResolverComparator(context, intent, + Lists.newArrayList(context.getUser())) { @Override int compare(ResolveInfo lhs, ResolveInfo rhs) { @@ -94,7 +98,7 @@ public class AbstractResolverComparatorTest { void doCompute(List targets) {} @Override - public float getScore(ComponentName name) { + public float getScore(TargetInfo targetInfo) { return 0; } -- cgit v1.2.3-59-g8ed1b From bf2977684f6518a140e44035e01b8873a4c76453 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Sun, 26 Mar 2023 23:06:37 -0700 Subject: Fallback to ShortcutManager if AppPredictor has crashed Bug: 269230501 Test: unit tests Change-Id: If5ac83d0f301bda8d00721f56ed8df88332a147f --- .../intentresolver/shortcuts/ShortcutLoader.kt | 11 +++- .../intentresolver/shortcuts/ShortcutLoaderTest.kt | 75 ++++++++++++++++------ 2 files changed, 65 insertions(+), 21 deletions(-) (limited to 'java/tests') diff --git a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt index 6f7542f1..29e706d4 100644 --- a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt +++ b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt @@ -77,6 +77,7 @@ open class ShortcutLoader @VisibleForTesting constructor( private val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager private val activeRequest = AtomicReference(NO_REQUEST) private val appPredictorCallback = AppPredictor.Callback { onAppPredictorCallback(it) } + @Volatile private var isDestroyed = false @MainThread @@ -134,8 +135,14 @@ open class ShortcutLoader @VisibleForTesting constructor( @WorkerThread private fun queryDirectShareTargets(skipAppPredictionService: Boolean) { if (!skipAppPredictionService && appPredictor != null) { - appPredictor.requestPredictionUpdate() - return + try { + appPredictor.requestPredictionUpdate() + return + } catch (e: Throwable) { + // we might have been destroyed concurrently, nothing left to do + if (isDestroyed) return + Log.e(TAG, "Failed to query AppPredictor", e) + } } // Default to just querying ShortcutManager if AppPredictor not present. if (targetIntentFilter == null) return diff --git a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt index 0c817cb2..e8e2f862 100644 --- a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt +++ b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt @@ -58,7 +58,7 @@ class ShortcutLoaderTest { private val pm = mock { whenever(getApplicationInfo(any(), any())).thenReturn(appInfo) } - val userManager = mock { + private val userManager = mock { whenever(isUserRunning(any())).thenReturn(true) whenever(isUserUnlocked(any())).thenReturn(true) whenever(isQuietModeEnabled(any())).thenReturn(false) @@ -72,14 +72,15 @@ class ShortcutLoaderTest { private val intentFilter = mock() private val appPredictor = mock() private val callback = mock>() + private val componentName = ComponentName("pkg", "Class") + private val appTarget = mock { + whenever(resolvedComponentName).thenReturn(componentName) + } + private val appTargets = arrayOf(appTarget) + private val matchingShortcutInfo = createShortcutInfo("id-0", componentName, 1) @Test fun test_queryShortcuts_result_consistency_with_AppPredictor() { - val componentName = ComponentName("pkg", "Class") - val appTarget = mock { - whenever(resolvedComponentName).thenReturn(componentName) - } - val appTargets = arrayOf(appTarget) val testSubject = ShortcutLoader( context, appPredictor, @@ -93,7 +94,6 @@ class ShortcutLoaderTest { testSubject.queryShortcuts(appTargets) - val matchingShortcutInfo = createShortcutInfo("id-0", componentName, 1) val matchingAppTarget = createAppTarget(matchingShortcutInfo) val shortcuts = listOf( matchingAppTarget, @@ -131,12 +131,6 @@ class ShortcutLoaderTest { @Test fun test_queryShortcuts_result_consistency_with_ShortcutManager() { - val componentName = ComponentName("pkg", "Class") - val appTarget = mock { - whenever(resolvedComponentName).thenReturn(componentName) - } - val appTargets = arrayOf(appTarget) - val matchingShortcutInfo = createShortcutInfo("id-0", componentName, 1) val shortcutManagerResult = listOf( ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), // mismatching shortcut @@ -182,12 +176,6 @@ class ShortcutLoaderTest { @Test fun test_queryShortcuts_falls_back_to_ShortcutManager_on_empty_reply() { - val componentName = ComponentName("pkg", "Class") - val appTarget = mock { - whenever(resolvedComponentName).thenReturn(componentName) - } - val appTargets = arrayOf(appTarget) - val matchingShortcutInfo = createShortcutInfo("id-0", componentName, 1) val shortcutManagerResult = listOf( ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), // mismatching shortcut @@ -237,6 +225,55 @@ class ShortcutLoaderTest { } } + @Test + fun test_queryShortcuts_onAppPredictorFailure_fallbackToShortcutManager() { + val shortcutManagerResult = listOf( + ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), + // mismatching shortcut + createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) + ) + val shortcutManager = mock { + whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult) + } + whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) + whenever(appPredictor.requestPredictionUpdate()) + .thenThrow(IllegalStateException("Test exception")) + val testSubject = ShortcutLoader( + context, + appPredictor, + UserHandle.of(0), + true, + intentFilter, + executor, + executor, + callback + ) + + testSubject.queryShortcuts(appTargets) + + verify(appPredictor, times(1)).requestPredictionUpdate() + + val resultCaptor = argumentCaptor() + verify(callback, times(1)).accept(capture(resultCaptor)) + + val result = resultCaptor.value + assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor) + assertArrayEquals("Wrong input app targets in the result", appTargets, result.appTargets) + assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size) + assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget) + for (shortcut in result.shortcutsByApp[0].shortcuts) { + assertTrue( + "AppTargets are not expected the cache of a ShortcutManager result", + result.directShareAppTargetCache.isEmpty() + ) + assertEquals( + "Wrong ShortcutInfo in the cache", + matchingShortcutInfo, + result.directShareShortcutInfoCache[shortcut] + ) + } + } + @Test fun test_queryShortcuts_do_not_call_services_for_not_running_work_profile() { testDisabledWorkProfileDoNotCallSystem(isUserRunning = false) -- cgit v1.2.3-59-g8ed1b From df5ea9d3683947c09c53c632d1e4822b31b88a88 Mon Sep 17 00:00:00 2001 From: 1 Date: Tue, 28 Mar 2023 15:28:00 +0000 Subject: Hide all system actions if there are custom actions. Nearby will have a separate home for launch, the others are intentionally hidden per product feedback. Bug: 274645844 Test: atest ContentPreviewUiTest Test: atest CtsSharesheetDeviceTest Change-Id: I5cd9157bc3ab6fd38a9993e470604633fd41c7e2 --- .../contentpreview/ContentPreviewUi.java | 6 +- .../contentpreview/ContentPreviewUiTest.kt | 80 ++++++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 java/tests/src/com/android/intentresolver/contentpreview/ContentPreviewUiTest.kt (limited to 'java/tests') diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java index a6bc2164..2a6bff5c 100644 --- a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java @@ -75,9 +75,11 @@ abstract class ContentPreviewUi { FeatureFlagRepository featureFlagRepository) { ArrayList actions = new ArrayList<>(systemActions.size() + customActions.size()); - actions.addAll(systemActions); - if (featureFlagRepository.isEnabled(Flags.SHARESHEET_CUSTOM_ACTIONS)) { + if (featureFlagRepository.isEnabled(Flags.SHARESHEET_CUSTOM_ACTIONS) + && customActions != null && !customActions.isEmpty()) { actions.addAll(customActions); + } else { + actions.addAll(systemActions); } return actions; } diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ContentPreviewUiTest.kt new file mode 100644 index 00000000..2b78a262 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/contentpreview/ContentPreviewUiTest.kt @@ -0,0 +1,80 @@ +/* + * 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.view.LayoutInflater +import android.view.ViewGroup +import com.android.intentresolver.TestFeatureFlagRepository +import com.android.intentresolver.flags.FeatureFlagRepository +import com.android.intentresolver.flags.Flags +import com.android.intentresolver.widget.ActionRow +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class ContentPreviewUiTest { + private class TestablePreview(private val flags: FeatureFlagRepository) : ContentPreviewUi() { + override fun getType() = 0 + + override fun display( + resources: Resources?, + layoutInflater: LayoutInflater?, + parent: ViewGroup? + ): ViewGroup { + throw IllegalStateException() + } + + // exposing for testing + fun makeActions( + system: List, + custom: List + ): List { + return createActions(system, custom, flags) + } + } + + @Test + fun testCreateActions() { + val featureFlagRepository = TestFeatureFlagRepository( + mapOf( + Flags.SHARESHEET_CUSTOM_ACTIONS to true + ) + ) + val preview = TestablePreview(featureFlagRepository) + + val system = listOf(ActionRow.Action(label="system", icon=null) {}) + val custom = listOf(ActionRow.Action(label="custom", icon=null) {}) + + assertThat(preview.makeActions(system, custom)).isEqualTo(custom) + assertThat(preview.makeActions(system, listOf())).isEqualTo(system) + } + + @Test + fun testCreateActions_flagDisabled() { + val featureFlagRepository = TestFeatureFlagRepository( + mapOf( + Flags.SHARESHEET_CUSTOM_ACTIONS to false + ) + ) + val preview = TestablePreview(featureFlagRepository) + + val system = listOf(ActionRow.Action(label="system", icon=null) {}) + val custom = listOf(ActionRow.Action(label="custom", icon=null) {}) + + assertThat(preview.makeActions(system, custom)).isEqualTo(system) + } +} -- cgit v1.2.3-59-g8ed1b From 7a9decf0189c8674d2580cc9a117eebfeee0a9d2 Mon Sep 17 00:00:00 2001 From: 1 Date: Tue, 4 Apr 2023 16:28:03 +0000 Subject: Remove SHARESHEET_CUSTOM_ACTIONS flag usage Has been released for a while without issue. Everyone loves it. Bug: 266983432 Test: atest IntentResolverUnitTests Change-Id: I8fdf7014415d53df2dabd55ec7bbdfb1426e093e --- java/res/layout/chooser_action_row.xml | 32 ---------------------- .../intentresolver/ChooserRequestParameters.java | 4 +-- .../contentpreview/ContentPreviewUi.java | 12 +++----- .../contentpreview/FileContentPreviewUi.java | 5 ++-- .../contentpreview/ImageContentPreviewUi.java | 5 ++-- .../contentpreview/TextContentPreviewUi.java | 5 ++-- .../contentpreview/UnifiedContentPreviewUi.java | 5 ++-- java/src/com/android/intentresolver/flags/Flags.kt | 5 ---- .../UnbundledChooserActivityTest.java | 4 --- .../contentpreview/ContentPreviewUiTest.kt | 29 ++------------------ 10 files changed, 16 insertions(+), 90 deletions(-) delete mode 100644 java/res/layout/chooser_action_row.xml (limited to 'java/tests') diff --git a/java/res/layout/chooser_action_row.xml b/java/res/layout/chooser_action_row.xml deleted file mode 100644 index 620ff704..00000000 --- a/java/res/layout/chooser_action_row.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java index 8e0014d6..d11561df 100644 --- a/java/src/com/android/intentresolver/ChooserRequestParameters.java +++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java @@ -146,9 +146,7 @@ public class ChooserRequestParameters { mTargetIntentFilter = getTargetIntentFilter(mTarget); - mChooserActions = featureFlags.isEnabled(Flags.SHARESHEET_CUSTOM_ACTIONS) - ? getChooserActions(clientIntent) - : ImmutableList.of(); + mChooserActions = getChooserActions(clientIntent); mModifyShareAction = featureFlags.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION) ? getModifyShareAction(clientIntent) : null; diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java index 2a6bff5c..05d0ee66 100644 --- a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java @@ -54,10 +54,8 @@ abstract class ContentPreviewUi { 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 int getActionRowLayout() { + return R.layout.scrollable_chooser_action_row; } protected static ActionRow inflateActionRow(ViewGroup parent, @LayoutRes int actionRowLayout) { @@ -71,12 +69,10 @@ abstract class ContentPreviewUi { protected static List createActions( List systemActions, - List customActions, - FeatureFlagRepository featureFlagRepository) { + List customActions) { ArrayList actions = new ArrayList<>(systemActions.size() + customActions.size()); - if (featureFlagRepository.isEnabled(Flags.SHARESHEET_CUSTOM_ACTIONS) - && customActions != null && !customActions.isEmpty()) { + if (customActions != null && !customActions.isEmpty()) { actions.addAll(customActions); } else { actions.addAll(systemActions); diff --git a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java index 52e20cf0..cc087a63 100644 --- a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java @@ -74,7 +74,7 @@ class FileContentPreviewUi extends ContentPreviewUi { private ViewGroup displayInternal( Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { - @LayoutRes int actionRowLayout = getActionRowLayout(mFeatureFlagRepository); + @LayoutRes int actionRowLayout = getActionRowLayout(); ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_file, parent, false); @@ -119,8 +119,7 @@ class FileContentPreviewUi extends ContentPreviewUi { actionRow.setActions( createActions( createFilePreviewActions(), - mActionFactory.createCustomActions(), - mFeatureFlagRepository)); + mActionFactory.createCustomActions())); } return contentPreviewLayout; diff --git a/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java index f2c0564a..1ca2ba61 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java @@ -88,7 +88,7 @@ class ImageContentPreviewUi extends ContentPreviewUi { } private ViewGroup displayInternal(LayoutInflater layoutInflater, ViewGroup parent) { - @LayoutRes int actionRowLayout = getActionRowLayout(mFeatureFlagRepository); + @LayoutRes int actionRowLayout = getActionRowLayout(); ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_image, parent, false); ChooserImagePreviewView imagePreview = inflateImagePreviewView(contentPreviewLayout); @@ -98,8 +98,7 @@ class ImageContentPreviewUi extends ContentPreviewUi { actionRow.setActions( createActions( createImagePreviewActions(), - mActionFactory.createCustomActions(), - mFeatureFlagRepository)); + mActionFactory.createCustomActions())); } if (mImageUris.size() == 0) { diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java index d0cba5bb..8a3c2259 100644 --- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java @@ -80,7 +80,7 @@ class TextContentPreviewUi extends ContentPreviewUi { private ViewGroup displayInternal( LayoutInflater layoutInflater, ViewGroup parent) { - @LayoutRes int actionRowLayout = getActionRowLayout(mFeatureFlagRepository); + @LayoutRes int actionRowLayout = getActionRowLayout(); ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_text, parent, false); @@ -89,8 +89,7 @@ class TextContentPreviewUi extends ContentPreviewUi { actionRow.setActions( createActions( createTextPreviewActions(), - mActionFactory.createCustomActions(), - mFeatureFlagRepository)); + mActionFactory.createCustomActions())); } if (mSharingText == null) { diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java index 2d2ae52b..dfc171d2 100644 --- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -94,7 +94,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { } private ViewGroup displayInternal(LayoutInflater layoutInflater, ViewGroup parent) { - @LayoutRes int actionRowLayout = getActionRowLayout(mFeatureFlagRepository); + @LayoutRes int actionRowLayout = getActionRowLayout(); ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_image, parent, false); ScrollableImagePreviewView imagePreview = inflateImagePreviewView(contentPreviewLayout); @@ -104,8 +104,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { actionRow.setActions( createActions( createImagePreviewActions(), - mActionFactory.createCustomActions(), - mFeatureFlagRepository)); + mActionFactory.createCustomActions())); } if (mFiles.size() == 0) { diff --git a/java/src/com/android/intentresolver/flags/Flags.kt b/java/src/com/android/intentresolver/flags/Flags.kt index 40f32bf3..846f8e64 100644 --- a/java/src/com/android/intentresolver/flags/Flags.kt +++ b/java/src/com/android/intentresolver/flags/Flags.kt @@ -22,15 +22,10 @@ import com.android.systemui.flags.UnreleasedFlag // Flag id, name and namespace should be kept in sync with [com.android.systemui.flags.Flags] to // make the flags available in the flag flipper app (see go/sysui-flags). object Flags { - const val SHARESHEET_CUSTOM_ACTIONS_NAME = "sharesheet_custom_actions" const val SHARESHEET_RESELECTION_ACTION_NAME = "sharesheet_reselection_action" const val SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME = "sharesheet_image_text_preview" const val SHARESHEET_SCROLLABLE_IMAGE_PREVIEW_NAME = "sharesheet_scrollable_image_preview" - // TODO(b/266983432) Tracking Bug - @JvmField - val SHARESHEET_CUSTOM_ACTIONS = releasedFlag(1501, SHARESHEET_CUSTOM_ACTIONS_NAME) - // TODO(b/266982749) Tracking Bug @JvmField val SHARESHEET_RESELECTION_ACTION = releasedFlag(1502, SHARESHEET_RESELECTION_ACTION_NAME) diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 6744d625..088e8923 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -169,7 +169,6 @@ public class UnbundledChooserActivityTest { private static final List ALL_FLAGS = Arrays.asList( - Flags.SHARESHEET_CUSTOM_ACTIONS, Flags.SHARESHEET_RESELECTION_ACTION, Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW, Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW); @@ -1719,9 +1718,6 @@ public class UnbundledChooserActivityTest { } @Test - @RequireFeatureFlags( - flags = { Flags.SHARESHEET_CUSTOM_ACTIONS_NAME }, - values = { true }) public void testLaunchWithCustomAction() throws InterruptedException { List resolvedComponentInfos = createResolvedComponentsForTest(2); setupResolverControllers(resolvedComponentInfos); diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ContentPreviewUiTest.kt index 2b78a262..c6a47515 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/ContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/ContentPreviewUiTest.kt @@ -19,15 +19,12 @@ package com.android.intentresolver.contentpreview import android.content.res.Resources import android.view.LayoutInflater import android.view.ViewGroup -import com.android.intentresolver.TestFeatureFlagRepository -import com.android.intentresolver.flags.FeatureFlagRepository -import com.android.intentresolver.flags.Flags import com.android.intentresolver.widget.ActionRow import com.google.common.truth.Truth.assertThat import org.junit.Test class ContentPreviewUiTest { - private class TestablePreview(private val flags: FeatureFlagRepository) : ContentPreviewUi() { + private class TestablePreview() : ContentPreviewUi() { override fun getType() = 0 override fun display( @@ -43,18 +40,13 @@ class ContentPreviewUiTest { system: List, custom: List ): List { - return createActions(system, custom, flags) + return createActions(system, custom) } } @Test fun testCreateActions() { - val featureFlagRepository = TestFeatureFlagRepository( - mapOf( - Flags.SHARESHEET_CUSTOM_ACTIONS to true - ) - ) - val preview = TestablePreview(featureFlagRepository) + val preview = TestablePreview() val system = listOf(ActionRow.Action(label="system", icon=null) {}) val custom = listOf(ActionRow.Action(label="custom", icon=null) {}) @@ -62,19 +54,4 @@ class ContentPreviewUiTest { assertThat(preview.makeActions(system, custom)).isEqualTo(custom) assertThat(preview.makeActions(system, listOf())).isEqualTo(system) } - - @Test - fun testCreateActions_flagDisabled() { - val featureFlagRepository = TestFeatureFlagRepository( - mapOf( - Flags.SHARESHEET_CUSTOM_ACTIONS to false - ) - ) - val preview = TestablePreview(featureFlagRepository) - - val system = listOf(ActionRow.Action(label="system", icon=null) {}) - val custom = listOf(ActionRow.Action(label="custom", icon=null) {}) - - assertThat(preview.makeActions(system, custom)).isEqualTo(system) - } } -- cgit v1.2.3-59-g8ed1b From 0a8bcadca1a463d8854e944fbb6cc96c08828bf6 Mon Sep 17 00:00:00 2001 From: 1 Date: Tue, 4 Apr 2023 19:58:31 +0000 Subject: Remove flag SHARESHEET_RESELECTION_ACTION Bug: 266982749 Test: Build and run with modify share. Change-Id: Id36a84c82e05c9fc946543c3eaa1271f786a5068 --- java/src/com/android/intentresolver/ChooserActionFactory.java | 5 +---- .../src/com/android/intentresolver/ChooserRequestParameters.java | 5 +---- .../android/intentresolver/contentpreview/ContentPreviewUi.java | 8 ++------ .../intentresolver/contentpreview/FileContentPreviewUi.java | 2 +- .../intentresolver/contentpreview/ImageContentPreviewUi.java | 2 +- .../intentresolver/contentpreview/TextContentPreviewUi.java | 2 +- .../intentresolver/contentpreview/UnifiedContentPreviewUi.java | 2 +- java/src/com/android/intentresolver/flags/Flags.kt | 5 ----- .../src/com/android/intentresolver/ChooserActionFactoryTest.kt | 9 --------- .../com/android/intentresolver/UnbundledChooserActivityTest.java | 4 ---- 10 files changed, 8 insertions(+), 36 deletions(-) (limited to 'java/tests') diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java index d0e6e53f..8dafae86 100644 --- a/java/src/com/android/intentresolver/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/ChooserActionFactory.java @@ -41,7 +41,6 @@ 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; import com.android.internal.annotations.VisibleForTesting; @@ -161,9 +160,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio finishCallback, logger), chooserRequest.getChooserActions(), - (featureFlagRepository.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION) - ? chooserRequest.getModifyShareAction() - : null), + chooserRequest.getModifyShareAction(), onUpdateSharedTextIsExcluded, logger, finishCallback); diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java index d11561df..f9004a9b 100644 --- a/java/src/com/android/intentresolver/ChooserRequestParameters.java +++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java @@ -33,7 +33,6 @@ import android.util.Log; import android.util.Pair; import com.android.intentresolver.flags.FeatureFlagRepository; -import com.android.intentresolver.flags.Flags; import com.google.common.collect.ImmutableList; @@ -147,9 +146,7 @@ public class ChooserRequestParameters { mTargetIntentFilter = getTargetIntentFilter(mTarget); mChooserActions = getChooserActions(clientIntent); - mModifyShareAction = featureFlags.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION) - ? getModifyShareAction(clientIntent) - : null; + mModifyShareAction = getModifyShareAction(clientIntent); } public Intent getTargetIntent() { diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java index 05d0ee66..15ba96c0 100644 --- a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java @@ -37,8 +37,6 @@ import android.widget.TextView; 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 java.util.ArrayList; @@ -129,11 +127,9 @@ abstract class ContentPreviewUi { protected static void displayModifyShareAction( ViewGroup layout, - ChooserContentPreviewUi.ActionFactory actionFactory, - FeatureFlagRepository featureFlagRepository) { + ChooserContentPreviewUi.ActionFactory actionFactory) { ActionRow.Action modifyShareAction = actionFactory.getModifyShareAction(); - if (modifyShareAction != null && layout != null - && featureFlagRepository.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION)) { + if (modifyShareAction != null && layout != null) { TextView modifyShareView = layout.findViewById(R.id.reselection_action); if (modifyShareView != null) { modifyShareView.setText(modifyShareAction.getLabel()); diff --git a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java index cc087a63..e65538ae 100644 --- a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java @@ -68,7 +68,7 @@ class FileContentPreviewUi extends ContentPreviewUi { @Override public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { ViewGroup layout = displayInternal(resources, layoutInflater, parent); - displayModifyShareAction(layout, mActionFactory, mFeatureFlagRepository); + displayModifyShareAction(layout, mActionFactory); return layout; } diff --git a/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java index 1ca2ba61..cf7c5525 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java @@ -83,7 +83,7 @@ class ImageContentPreviewUi extends ContentPreviewUi { @Override public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { ViewGroup layout = displayInternal(layoutInflater, parent); - displayModifyShareAction(layout, mActionFactory, mFeatureFlagRepository); + displayModifyShareAction(layout, mActionFactory); return layout; } diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java index 8a3c2259..6b5676cc 100644 --- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java @@ -73,7 +73,7 @@ class TextContentPreviewUi extends ContentPreviewUi { @Override public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { ViewGroup layout = displayInternal(layoutInflater, parent); - displayModifyShareAction(layout, mActionFactory, mFeatureFlagRepository); + displayModifyShareAction(layout, mActionFactory); return layout; } diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java index dfc171d2..55e8058b 100644 --- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -89,7 +89,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { @Override public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { ViewGroup layout = displayInternal(layoutInflater, parent); - displayModifyShareAction(layout, mActionFactory, mFeatureFlagRepository); + displayModifyShareAction(layout, mActionFactory); return layout; } diff --git a/java/src/com/android/intentresolver/flags/Flags.kt b/java/src/com/android/intentresolver/flags/Flags.kt index 846f8e64..2b3da725 100644 --- a/java/src/com/android/intentresolver/flags/Flags.kt +++ b/java/src/com/android/intentresolver/flags/Flags.kt @@ -22,14 +22,9 @@ import com.android.systemui.flags.UnreleasedFlag // Flag id, name and namespace should be kept in sync with [com.android.systemui.flags.Flags] to // make the flags available in the flag flipper app (see go/sysui-flags). object Flags { - const val SHARESHEET_RESELECTION_ACTION_NAME = "sharesheet_reselection_action" const val SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME = "sharesheet_image_text_preview" const val SHARESHEET_SCROLLABLE_IMAGE_PREVIEW_NAME = "sharesheet_scrollable_image_preview" - // TODO(b/266982749) Tracking Bug - @JvmField - val SHARESHEET_RESELECTION_ACTION = releasedFlag(1502, SHARESHEET_RESELECTION_ACTION_NAME) - // TODO(b/266983474) Tracking Bug @JvmField val SHARESHEET_IMAGE_AND_TEXT_PREVIEW = releasedFlag( diff --git a/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt b/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt index 98c7d5ee..0a8c22b7 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt +++ b/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt @@ -71,7 +71,6 @@ class ChooserActionFactoryTest { @Before fun setup() { - whenever(flags.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION)).thenReturn(true) context.registerReceiver(testReceiver, IntentFilter(testAction)) } @@ -105,14 +104,6 @@ class ChooserActionFactoryTest { assertThat(factory.modifyShareAction).isNull() } - @Test - fun testNoModifyShareAction_flagDisabled() { - whenever(flags.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION)).thenReturn(false) - val factory = createFactory(includeModifyShare = true) - - assertThat(factory.modifyShareAction).isNull() - } - @Test fun testModifyShareAction() { val factory = createFactory(includeModifyShare = true) diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 088e8923..b6a3631b 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -169,7 +169,6 @@ public class UnbundledChooserActivityTest { private static final List ALL_FLAGS = Arrays.asList( - Flags.SHARESHEET_RESELECTION_ACTION, Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW, Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW); @@ -1761,9 +1760,6 @@ public class UnbundledChooserActivityTest { } @Test - @RequireFeatureFlags( - flags = { Flags.SHARESHEET_RESELECTION_ACTION_NAME }, - values = { true }) public void testLaunchWithShareModification() throws InterruptedException { List resolvedComponentInfos = createResolvedComponentsForTest(2); setupResolverControllers(resolvedComponentInfos); -- cgit v1.2.3-59-g8ed1b From c75013c73c79fac3c9f64661d0e09825c5bde345 Mon Sep 17 00:00:00 2001 From: 1 Date: Tue, 4 Apr 2023 20:14:55 +0000 Subject: Remove usage of SHARESHEET_IMAGE_AND_TEXT_PREVIEW Bug: 266983474 Test: atest IntentResolverUnitTests Change-Id: I5571fde968fb1bc0280ccdfe43f26d2843bec7ae --- .../contentpreview/ImageContentPreviewUi.java | 6 +----- .../contentpreview/UnifiedContentPreviewUi.java | 6 +----- java/src/com/android/intentresolver/flags/Flags.kt | 7 ------- .../intentresolver/UnbundledChooserActivityTest.java | 16 ---------------- 4 files changed, 2 insertions(+), 33 deletions(-) (limited to 'java/tests') diff --git a/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java index cf7c5525..85ae2adb 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java @@ -37,7 +37,6 @@ 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.ChooserImagePreviewView; import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; @@ -157,10 +156,7 @@ class ImageContentPreviewUi extends ContentPreviewUi { 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; + int visibility = !TextUtils.isEmpty(mText) ? View.VISIBLE : View.GONE; final TextView textView = contentPreview .requireViewById(com.android.internal.R.id.content_preview_text); diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java index 55e8058b..a849ddb9 100644 --- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -36,7 +36,6 @@ import androidx.annotation.Nullable; import com.android.intentresolver.ImageLoader; import com.android.intentresolver.R; import com.android.intentresolver.flags.FeatureFlagRepository; -import com.android.intentresolver.flags.Flags; import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; import com.android.intentresolver.widget.ScrollableImagePreviewView; @@ -139,10 +138,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { mFiles.size() - previews.size(), mImageLoader); - if (mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW) - && !TextUtils.isEmpty(mText) - && mFiles.size() == 1 - && allImages) { + if (!TextUtils.isEmpty(mText) && mFiles.size() == 1 && allImages) { setTextInImagePreviewVisibility(contentPreviewLayout, mActionFactory); updateTextWithImageHeadline(contentPreviewLayout); } else { diff --git a/java/src/com/android/intentresolver/flags/Flags.kt b/java/src/com/android/intentresolver/flags/Flags.kt index 2b3da725..0440b3d6 100644 --- a/java/src/com/android/intentresolver/flags/Flags.kt +++ b/java/src/com/android/intentresolver/flags/Flags.kt @@ -22,15 +22,8 @@ import com.android.systemui.flags.UnreleasedFlag // Flag id, name and namespace should be kept in sync with [com.android.systemui.flags.Flags] to // make the flags available in the flag flipper app (see go/sysui-flags). object Flags { - const val SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME = "sharesheet_image_text_preview" const val SHARESHEET_SCROLLABLE_IMAGE_PREVIEW_NAME = "sharesheet_scrollable_image_preview" - // TODO(b/266983474) Tracking Bug - @JvmField - val SHARESHEET_IMAGE_AND_TEXT_PREVIEW = releasedFlag( - id = 1503, name = SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME - ) - // TODO(b/267355521) Tracking Bug @JvmField val SHARESHEET_SCROLLABLE_IMAGE_PREVIEW = releasedFlag( diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index b6a3631b..d074e978 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -169,7 +169,6 @@ public class UnbundledChooserActivityTest { private static final List ALL_FLAGS = Arrays.asList( - Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW, Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW); private static final Map ALL_FLAGS_OFF = @@ -682,9 +681,6 @@ public class UnbundledChooserActivityTest { } @Test - @RequireFeatureFlags( - flags = { Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME }, - values = { true }) public void testImagePlusTextSharing_ExcludeText() { Intent sendIntent = createSendImageIntent( Uri.parse("android.resource://com.android.frameworks.coretests/" @@ -725,9 +721,6 @@ public class UnbundledChooserActivityTest { } @Test - @RequireFeatureFlags( - flags = { Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME }, - values = { true }) public void testImagePlusTextSharing_RemoveAndAddBackText() { Intent sendIntent = createSendImageIntent( Uri.parse("android.resource://com.android.frameworks.coretests/" @@ -772,9 +765,6 @@ public class UnbundledChooserActivityTest { } @Test - @RequireFeatureFlags( - flags = { Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME }, - values = { true }) public void testImagePlusTextSharing_TextExclusionDoesNotAffectAlternativeIntent() { Intent sendIntent = createSendImageIntent( Uri.parse("android.resource://com.android.frameworks.coretests/" @@ -1048,9 +1038,6 @@ public class UnbundledChooserActivityTest { } @Test - @RequireFeatureFlags( - flags = { Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME }, - values = { true }) public void testImageAndTextPreview() { final Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" + R.drawable.test320x240); @@ -1074,9 +1061,6 @@ public class UnbundledChooserActivityTest { } @Test - @RequireFeatureFlags( - flags = { Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME }, - values = { true }) public void testNoTextPreviewWhenTextIsSharedWithMultipleImages() { final Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" + R.drawable.test320x240); -- cgit v1.2.3-59-g8ed1b From 964676869a6edcacccae658ba6ab4290f90166c2 Mon Sep 17 00:00:00 2001 From: 1 Date: Tue, 4 Apr 2023 21:00:37 +0000 Subject: Remove SHARESHEET_SCROLLABLE_IMAGE_PREVIEW Also simplify some flag testing stuff when there are no flags being tested. Bug: 267355521 Test: atest IntentResolverUnitTests Change-Id: I2ec39d38605d4e1b9df5bb22542623cdeb1e51da --- java/res/layout/chooser_image_preview_view.xml | 26 --- .../chooser_image_preview_view_internals.xml | 73 -------- .../android/intentresolver/ChooserActivity.java | 16 +- .../contentpreview/ChooserContentPreviewUi.java | 51 +----- .../contentpreview/FileContentPreviewUi.java | 4 - .../contentpreview/ImageContentPreviewUi.java | 188 --------------------- .../contentpreview/TextContentPreviewUi.java | 4 - .../contentpreview/UnifiedContentPreviewUi.java | 4 - java/src/com/android/intentresolver/flags/Flags.kt | 9 +- .../widget/ChooserImagePreviewView.kt | 163 ------------------ .../UnbundledChooserActivityTest.java | 81 ++------- .../contentpreview/ChooserContentPreviewUiTest.kt | 18 -- 12 files changed, 19 insertions(+), 618 deletions(-) delete mode 100644 java/res/layout/chooser_image_preview_view.xml delete mode 100644 java/res/layout/chooser_image_preview_view_internals.xml delete mode 100644 java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java delete mode 100644 java/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt (limited to 'java/tests') diff --git a/java/res/layout/chooser_image_preview_view.xml b/java/res/layout/chooser_image_preview_view.xml deleted file mode 100644 index e81349c7..00000000 --- a/java/res/layout/chooser_image_preview_view.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - diff --git a/java/res/layout/chooser_image_preview_view_internals.xml b/java/res/layout/chooser_image_preview_view_internals.xml deleted file mode 100644 index 2b93edf8..00000000 --- a/java/res/layout/chooser_image_preview_view_internals.xml +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - - - - - - - - diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 270fc299..dd0be4f0 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -87,7 +87,6 @@ import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; import com.android.intentresolver.contentpreview.HeadlineGeneratorImpl; import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.flags.FeatureFlagRepositoryFactory; -import com.android.intentresolver.flags.Flags; import com.android.intentresolver.grid.ChooserGridAdapter; import com.android.intentresolver.grid.DirectShareViewHolder; import com.android.intentresolver.model.AbstractResolverComparator; @@ -293,7 +292,6 @@ public class ChooserActivity extends ResolverActivity implements createPreviewImageLoader(), createChooserActionFactory(), mEnterTransitionAnimationDelegate, - mFeatureFlagRepository, new HeadlineGeneratorImpl(this)); setAdditionalTargets(mChooserRequest.getAdditionalTargets()); @@ -1341,15 +1339,11 @@ public class ChooserActivity extends ResolverActivity implements @VisibleForTesting protected ImageLoader createPreviewImageLoader() { final int cacheSize; - if (mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW)) { - float chooserWidth = getResources().getDimension(R.dimen.chooser_width); - // imageWidth = imagePreviewHeight / minAspectRatio (see ScrollableImagePreviewView) - float imageWidth = - getResources().getDimension(R.dimen.chooser_preview_image_height_tall) * 5 / 2; - cacheSize = (int) (Math.ceil(chooserWidth / imageWidth) + 2); - } else { - cacheSize = 3; - } + float chooserWidth = getResources().getDimension(R.dimen.chooser_width); + // imageWidth = imagePreviewHeight / minAspectRatio (see ScrollableImagePreviewView) + float imageWidth = + getResources().getDimension(R.dimen.chooser_preview_image_height_tall) * 5 / 2; + cacheSize = (int) (Math.ceil(chooserWidth / imageWidth) + 2); return new ImagePreviewImageLoader(this, getLifecycle(), cacheSize); } diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index 3509c67d..318aa627 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -39,14 +39,11 @@ import android.view.ViewGroup; import androidx.annotation.Nullable; import com.android.intentresolver.ImageLoader; -import com.android.intentresolver.flags.FeatureFlagRepository; -import com.android.intentresolver.flags.Flags; import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; import java.util.ArrayList; import java.util.List; -import java.util.Objects; import java.util.function.Consumer; /** @@ -103,7 +100,6 @@ public final class ChooserContentPreviewUi { ImageLoader imageLoader, ActionFactory actionFactory, TransitionElementStatusCallback transitionElementStatusCallback, - FeatureFlagRepository featureFlagRepository, HeadlineGenerator headlineGenerator) { mContentPreviewUi = createContentPreview( @@ -113,7 +109,6 @@ public final class ChooserContentPreviewUi { imageLoader, actionFactory, transitionElementStatusCallback, - featureFlagRepository, headlineGenerator); if (mContentPreviewUi.getType() != CONTENT_PREVIEW_IMAGE) { transitionElementStatusCallback.onAllTransitionElementsReady(); @@ -127,7 +122,6 @@ public final class ChooserContentPreviewUi { ImageLoader imageLoader, ActionFactory actionFactory, TransitionElementStatusCallback transitionElementStatusCallback, - FeatureFlagRepository featureFlagRepository, HeadlineGenerator headlineGenerator) { /* In {@link android.content.Intent#getType}, the app may specify a very general mime type @@ -145,7 +139,6 @@ public final class ChooserContentPreviewUi { targetIntent, actionFactory, imageLoader, - featureFlagRepository, headlineGenerator); } List uris = extractContentUris(targetIntent); @@ -154,7 +147,6 @@ public final class ChooserContentPreviewUi { targetIntent, actionFactory, imageLoader, - featureFlagRepository, headlineGenerator); } ArrayList files = new ArrayList<>(uris.size()); @@ -164,50 +156,15 @@ public final class ChooserContentPreviewUi { files, actionFactory, imageLoader, - featureFlagRepository, headlineGenerator); } - if (featureFlagRepository.isEnabled(Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW)) { - return new UnifiedContentPreviewUi( - files, - targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT), - actionFactory, - imageLoader, - typeClassifier, - transitionElementStatusCallback, - featureFlagRepository, - headlineGenerator); - } - if (previewCount < uris.size()) { - return new FileContentPreviewUi( - files, - actionFactory, - imageLoader, - featureFlagRepository, - headlineGenerator); - } - // The legacy (3-image) image preview is on it's way out and it's unlikely that we'd end up - // here. To preserve the legacy behavior, before using it, check that all uris are images. - for (FileInfo fileInfo: files) { - if (!typeClassifier.isImageType(fileInfo.getMimeType())) { - return new FileContentPreviewUi( - files, - actionFactory, - imageLoader, - featureFlagRepository, - headlineGenerator); - } - } - return new ImageContentPreviewUi( - files.stream() - .map(FileInfo::getPreviewUri) - .filter(Objects::nonNull) - .toList(), + return new UnifiedContentPreviewUi( + files, targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT), actionFactory, imageLoader, + typeClassifier, transitionElementStatusCallback, - featureFlagRepository, headlineGenerator); } @@ -323,7 +280,6 @@ public final class ChooserContentPreviewUi { Intent targetIntent, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, - FeatureFlagRepository featureFlagRepository, HeadlineGenerator headlineGenerator) { CharSequence sharingText = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); String previewTitle = targetIntent.getStringExtra(Intent.EXTRA_TITLE); @@ -341,7 +297,6 @@ public final class ChooserContentPreviewUi { previewThumbnail, actionFactory, imageLoader, - featureFlagRepository, headlineGenerator); } diff --git a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java index e65538ae..3012eec2 100644 --- a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java @@ -29,7 +29,6 @@ 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; @@ -44,19 +43,16 @@ class FileContentPreviewUi extends ContentPreviewUi { private final List mFiles; private final ChooserContentPreviewUi.ActionFactory mActionFactory; private final ImageLoader mImageLoader; - private final FeatureFlagRepository mFeatureFlagRepository; private final HeadlineGenerator mHeadlineGenerator; FileContentPreviewUi( List files, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, - FeatureFlagRepository featureFlagRepository, HeadlineGenerator headlineGenerator) { mFiles = files; mActionFactory = actionFactory; mImageLoader = imageLoader; - mFeatureFlagRepository = featureFlagRepository; mHeadlineGenerator = headlineGenerator; } diff --git a/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java deleted file mode 100644 index 85ae2adb..00000000 --- a/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java +++ /dev/null @@ -1,188 +0,0 @@ -/* - * 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.widget.ActionRow; -import com.android.intentresolver.widget.ChooserImagePreviewView; -import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; - -import java.util.ArrayList; -import java.util.List; -import java.util.function.Consumer; - -class ImageContentPreviewUi extends ContentPreviewUi { - private final List mImageUris; - @Nullable - private final CharSequence mText; - private final ChooserContentPreviewUi.ActionFactory mActionFactory; - private final ImageLoader mImageLoader; - private final TransitionElementStatusCallback mTransitionElementStatusCallback; - private final FeatureFlagRepository mFeatureFlagRepository; - private final HeadlineGenerator mHeadlineGenerator; - - ImageContentPreviewUi( - List imageUris, - @Nullable CharSequence text, - ChooserContentPreviewUi.ActionFactory actionFactory, - ImageLoader imageLoader, - TransitionElementStatusCallback transitionElementStatusCallback, - FeatureFlagRepository featureFlagRepository, - HeadlineGenerator headlineGenerator) { - mImageUris = imageUris; - mText = text; - mActionFactory = actionFactory; - mImageLoader = imageLoader; - mTransitionElementStatusCallback = transitionElementStatusCallback; - mFeatureFlagRepository = featureFlagRepository; - mHeadlineGenerator = headlineGenerator; - - 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); - displayModifyShareAction(layout, mActionFactory); - return layout; - } - - private ViewGroup displayInternal(LayoutInflater layoutInflater, ViewGroup parent) { - @LayoutRes int actionRowLayout = getActionRowLayout(); - ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( - R.layout.chooser_grid_preview_image, parent, false); - ChooserImagePreviewView imagePreview = inflateImagePreviewView(contentPreviewLayout); - - final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout); - if (actionRow != null) { - actionRow.setActions( - createActions( - createImagePreviewActions(), - mActionFactory.createCustomActions())); - } - - if (mImageUris.size() == 0) { - Log.i( - TAG, - "Attempted to display image preview area with zero" - + " available images detected in EXTRA_STREAM list"); - imagePreview.setVisibility(View.GONE); - mTransitionElementStatusCallback.onAllTransitionElementsReady(); - return contentPreviewLayout; - } - - setTextInImagePreviewVisibility(contentPreviewLayout, mActionFactory); - imagePreview.setTransitionElementStatusCallback(mTransitionElementStatusCallback); - imagePreview.setImages(mImageUris, mImageLoader); - - updateHeadline(contentPreviewLayout); - - return contentPreviewLayout; - } - - private List createImagePreviewActions() { - ArrayList actions = new ArrayList<>(2); - //TODO: add copy action; - ActionRow.Action action = mActionFactory.createNearbyButton(); - if (action != null) { - actions.add(action); - } - action = mActionFactory.createEditButton(); - if (action != null) { - actions.add(action); - } - return actions; - } - - private ChooserImagePreviewView inflateImagePreviewView(ViewGroup previewLayout) { - ViewStub stub = previewLayout.findViewById(R.id.image_preview_stub); - if (stub != null) { - stub.setLayoutResource(R.layout.chooser_image_preview_view); - stub.inflate(); - } - return previewLayout.findViewById( - com.android.internal.R.id.content_preview_image_area); - } - - private void updateHeadline(ViewGroup contentPreview) { - CheckBox includeTextCheckbox = contentPreview.requireViewById(R.id.include_text_action); - if (includeTextCheckbox.getVisibility() == View.VISIBLE - && includeTextCheckbox.isChecked()) { - displayHeadline(contentPreview, mHeadlineGenerator.getImageWithTextHeadline(mText)); - } else { - displayHeadline( - contentPreview, mHeadlineGenerator.getImagesHeadline(mImageUris.size())); - } - } - - private void setTextInImagePreviewVisibility( - ViewGroup contentPreview, ChooserContentPreviewUi.ActionFactory actionFactory) { - int visibility = !TextUtils.isEmpty(mText) ? View.VISIBLE : View.GONE; - - final TextView textView = contentPreview - .requireViewById(com.android.internal.R.id.content_preview_text); - CheckBox actionView = contentPreview - .requireViewById(R.id.include_text_action); - textView.setVisibility(visibility); - boolean isLink = visibility == View.VISIBLE && HttpUriMatcher.isHttpUri(mText.toString()); - textView.setAutoLinkMask(isLink ? Linkify.WEB_URLS : 0); - textView.setText(mText); - - if (visibility == View.VISIBLE) { - final int[] actionLabels = isLink - ? new int[] { R.string.include_link, R.string.exclude_link } - : new int[] { R.string.include_text, R.string.exclude_text }; - final Consumer shareTextAction = actionFactory.getExcludeSharedTextAction(); - actionView.setChecked(true); - actionView.setText(actionLabels[1]); - shareTextAction.accept(false); - actionView.setOnCheckedChangeListener((view, isChecked) -> { - view.setText(actionLabels[isChecked ? 1 : 0]); - TransitionManager.beginDelayedTransition((ViewGroup) textView.getParent()); - textView.setVisibility(isChecked ? View.VISIBLE : View.GONE); - shareTextAction.accept(!isChecked); - updateHeadline(contentPreview); - }); - } - actionView.setVisibility(visibility); - } -} diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java index 6b5676cc..70df6479 100644 --- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java @@ -30,7 +30,6 @@ 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; @@ -45,7 +44,6 @@ class TextContentPreviewUi extends ContentPreviewUi { private final Uri mPreviewThumbnail; private final ImageLoader mImageLoader; private final ChooserContentPreviewUi.ActionFactory mActionFactory; - private final FeatureFlagRepository mFeatureFlagRepository; private final HeadlineGenerator mHeadlineGenerator; TextContentPreviewUi( @@ -54,14 +52,12 @@ class TextContentPreviewUi extends ContentPreviewUi { @Nullable Uri previewThumbnail, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, - FeatureFlagRepository featureFlagRepository, HeadlineGenerator headlineGenerator) { mSharingText = sharingText; mPreviewTitle = previewTitle; mPreviewThumbnail = previewThumbnail; mImageLoader = imageLoader; mActionFactory = actionFactory; - mFeatureFlagRepository = featureFlagRepository; mHeadlineGenerator = headlineGenerator; } diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java index a849ddb9..00a11e30 100644 --- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -35,7 +35,6 @@ 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 com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; import com.android.intentresolver.widget.ScrollableImagePreviewView; @@ -53,7 +52,6 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { private final ImageLoader mImageLoader; private final MimeTypeClassifier mTypeClassifier; private final TransitionElementStatusCallback mTransitionElementStatusCallback; - private final FeatureFlagRepository mFeatureFlagRepository; private final HeadlineGenerator mHeadlineGenerator; UnifiedContentPreviewUi( @@ -63,7 +61,6 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { ImageLoader imageLoader, MimeTypeClassifier typeClassifier, TransitionElementStatusCallback transitionElementStatusCallback, - FeatureFlagRepository featureFlagRepository, HeadlineGenerator headlineGenerator) { mFiles = files; mText = text; @@ -71,7 +68,6 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { mImageLoader = imageLoader; mTypeClassifier = typeClassifier; mTransitionElementStatusCallback = transitionElementStatusCallback; - mFeatureFlagRepository = featureFlagRepository; mHeadlineGenerator = headlineGenerator; mImageLoader.prePopulate(mFiles.stream() diff --git a/java/src/com/android/intentresolver/flags/Flags.kt b/java/src/com/android/intentresolver/flags/Flags.kt index 0440b3d6..b303dd1a 100644 --- a/java/src/com/android/intentresolver/flags/Flags.kt +++ b/java/src/com/android/intentresolver/flags/Flags.kt @@ -21,15 +21,8 @@ import com.android.systemui.flags.UnreleasedFlag // Flag id, name and namespace should be kept in sync with [com.android.systemui.flags.Flags] to // make the flags available in the flag flipper app (see go/sysui-flags). +// All flags added should be included in UnbundledChooserActivityTest.ALL_FLAGS. object Flags { - const val SHARESHEET_SCROLLABLE_IMAGE_PREVIEW_NAME = "sharesheet_scrollable_image_preview" - - // TODO(b/267355521) Tracking Bug - @JvmField - val SHARESHEET_SCROLLABLE_IMAGE_PREVIEW = releasedFlag( - 1504, SHARESHEET_SCROLLABLE_IMAGE_PREVIEW_NAME - ) - private fun releasedFlag(id: Int, name: String) = ReleasedFlag(id, name, "systemui") diff --git a/java/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt deleted file mode 100644 index 6273296d..00000000 --- a/java/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt +++ /dev/null @@ -1,163 +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.widget - -import android.animation.ObjectAnimator -import android.content.Context -import android.net.Uri -import android.util.AttributeSet -import android.view.LayoutInflater -import android.view.animation.DecelerateInterpolator -import android.widget.RelativeLayout -import androidx.core.view.isVisible -import com.android.intentresolver.R -import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback -import kotlinx.coroutines.Job -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import com.android.internal.R as IntR - -private const val IMAGE_FADE_IN_MILLIS = 150L - -class ChooserImagePreviewView : RelativeLayout, ImagePreviewView { - - constructor(context: Context) : this(context, null) - constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) - - constructor( - context: Context, attrs: AttributeSet?, defStyleAttr: Int - ) : this(context, attrs, defStyleAttr, 0) - - constructor( - context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int - ) : super(context, attrs, defStyleAttr, defStyleRes) - - private val coroutineScope = MainScope() - private lateinit var mainImage: RoundedRectImageView - private lateinit var secondLargeImage: RoundedRectImageView - private lateinit var secondSmallImage: RoundedRectImageView - private lateinit var thirdImage: RoundedRectImageView - - private var loadImageJob: Job? = null - private var transitionStatusElementCallback: TransitionElementStatusCallback? = null - - override fun onFinishInflate() { - LayoutInflater.from(context) - .inflate(R.layout.chooser_image_preview_view_internals, this, true) - mainImage = requireViewById(IntR.id.content_preview_image_1_large) - secondLargeImage = requireViewById(IntR.id.content_preview_image_2_large) - secondSmallImage = requireViewById(IntR.id.content_preview_image_2_small) - thirdImage = requireViewById(IntR.id.content_preview_image_3_small) - } - - /** - * Specifies a transition animation target readiness callback. The callback will be - * invoked once when views preparation is done. - * Should be called before [setImages]. - */ - override fun setTransitionElementStatusCallback(callback: TransitionElementStatusCallback?) { - transitionStatusElementCallback = callback - } - - fun setImages(uris: List, imageLoader: ImageLoader) { - loadImageJob?.cancel() - loadImageJob = coroutineScope.launch { - when (uris.size) { - 0 -> hideAllViews() - 1 -> showOneImage(uris, imageLoader) - 2 -> showTwoImages(uris, imageLoader) - else -> showThreeImages(uris, imageLoader) - } - } - } - - private fun hideAllViews() { - mainImage.isVisible = false - secondLargeImage.isVisible = false - secondSmallImage.isVisible = false - thirdImage.isVisible = false - invokeTransitionViewReadyCallback() - } - - private suspend fun showOneImage(uris: List, imageLoader: ImageLoader) { - secondLargeImage.isVisible = false - secondSmallImage.isVisible = false - thirdImage.isVisible = false - showImages(uris, imageLoader, mainImage) - } - - private suspend fun showTwoImages(uris: List, imageLoader: ImageLoader) { - secondSmallImage.isVisible = false - thirdImage.isVisible = false - showImages(uris, imageLoader, mainImage, secondLargeImage) - } - - private suspend fun showThreeImages(uris: List, imageLoader: ImageLoader) { - secondLargeImage.isVisible = false - showImages(uris, imageLoader, mainImage, secondSmallImage, thirdImage) - thirdImage.setExtraImageCount(uris.size - 3) - } - - private suspend fun showImages( - uris: List, imageLoader: ImageLoader, vararg views: RoundedRectImageView - ) = coroutineScope { - for (i in views.indices) { - launch { - loadImageIntoView(views[i], uris[i], imageLoader) - } - } - } - - private suspend fun loadImageIntoView( - view: RoundedRectImageView, uri: Uri, imageLoader: ImageLoader - ) { - val bitmap = runCatching { - imageLoader(uri) - }.getOrDefault(null) - if (bitmap == null) { - view.isVisible = false - if (view === mainImage) { - invokeTransitionViewReadyCallback() - } - } else { - view.isVisible = true - view.setImageBitmap(bitmap) - - view.alpha = 0f - ObjectAnimator.ofFloat(view, "alpha", 0.0f, 1.0f).apply { - interpolator = DecelerateInterpolator(1.0f) - duration = IMAGE_FADE_IN_MILLIS - start() - } - if (view === mainImage && transitionStatusElementCallback != null) { - view.waitForPreDraw() - invokeTransitionViewReadyCallback() - } - } - } - - private fun invokeTransitionViewReadyCallback() { - transitionStatusElementCallback?.apply { - if (mainImage.isVisible && mainImage.drawable != null) { - mainImage.transitionName?.let { onTransitionElementReady(it) } - } - onAllTransitionElementsReady() - } - transitionStatusElementCallback = null - } -} diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index d074e978..eb340224 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -104,7 +104,6 @@ import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.rule.ActivityTestRule; import com.android.intentresolver.chooser.DisplayResolveInfo; -import com.android.intentresolver.flags.Flags; import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; @@ -168,8 +167,7 @@ public class UnbundledChooserActivityTest { }; private static final List ALL_FLAGS = - Arrays.asList( - Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW); + Arrays.asList(); private static final Map ALL_FLAGS_OFF = createAllFlagsOverride(false); @@ -178,6 +176,15 @@ public class UnbundledChooserActivityTest { @Parameterized.Parameters public static Collection packageManagers() { + if (ALL_FLAGS.isEmpty()) { + // No flags to toggle between, so just two configurations. + return Arrays.asList(new Object[][] { + // Default PackageManager and all flags off + { DEFAULT_PM, ALL_FLAGS_OFF}, + // No App Prediction Service and all flags off + { NO_APP_PREDICTION_SERVICE_PM, ALL_FLAGS_OFF }, + }); + } return Arrays.asList(new Object[][] { // Default PackageManager and all flags off { DEFAULT_PM, ALL_FLAGS_OFF}, @@ -933,74 +940,6 @@ public class UnbundledChooserActivityTest { } @Test - @RequireFeatureFlags( - flags = { Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW_NAME }, - values = { false }) - public void twoVisibleImagePreview() { - Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" - + R.drawable.test320x240); - - ArrayList uris = new ArrayList<>(); - uris.add(uri); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); - ChooserActivityOverrideData.getInstance().isImageType = true; - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(com.android.internal.R.id.content_preview_image_1_large)) - .check(matches(isDisplayed())); - onView(withId(com.android.internal.R.id.content_preview_image_2_large)) - .check(matches(isDisplayed())); - onView(withId(com.android.internal.R.id.content_preview_image_2_small)) - .check(matches(not(isDisplayed()))); - onView(withId(com.android.internal.R.id.content_preview_image_3_small)) - .check(matches(not(isDisplayed()))); - } - - @Test - @RequireFeatureFlags( - flags = { Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW_NAME }, - values = { false }) - public void threeOrMoreVisibleImagePreview() { - Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" - + R.drawable.test320x240); - - ArrayList uris = new ArrayList<>(); - uris.add(uri); - uris.add(uri); - uris.add(uri); - uris.add(uri); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); - ChooserActivityOverrideData.getInstance().isImageType = true; - - List resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(com.android.internal.R.id.content_preview_image_1_large)) - .check(matches(isDisplayed())); - onView(withId(com.android.internal.R.id.content_preview_image_2_large)) - .check(matches(not(isDisplayed()))); - onView(withId(com.android.internal.R.id.content_preview_image_2_small)) - .check(matches(isDisplayed())); - onView(withId(com.android.internal.R.id.content_preview_image_3_small)) - .check(matches(isDisplayed())); - } - - @Test - @RequireFeatureFlags( - flags = { Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW_NAME }, - values = { true }) public void testManyVisibleImagePreview_ScrollableImagePreview() { Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" + R.drawable.test320x240); diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt index 82bf94c4..7b9a0ce6 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt @@ -22,11 +22,9 @@ 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.any import com.android.intentresolver.anyOrNull 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 @@ -61,11 +59,6 @@ class ChooserContentPreviewUiTest { override fun getExcludeSharedTextAction(): Consumer = Consumer {} } private val transitionCallback = mock() - private val featureFlagRepository = TestFeatureFlagRepository( - mapOf( - Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW to true - ) - ) @Test fun test_ChooserContentPreview_non_send_intent_action_to_text_preview() { @@ -77,7 +70,6 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository, headlineGenerator ) assertThat(testSubject.preferredContentPreview) @@ -98,7 +90,6 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository, headlineGenerator ) assertThat(testSubject.preferredContentPreview) @@ -120,7 +111,6 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository, headlineGenerator ) assertThat(testSubject.preferredContentPreview) @@ -142,7 +132,6 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository, headlineGenerator ) assertThat(testSubject.preferredContentPreview) @@ -165,7 +154,6 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository, headlineGenerator ) assertThat(testSubject.preferredContentPreview) @@ -191,7 +179,6 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository, headlineGenerator ) assertThat(testSubject.preferredContentPreview) @@ -215,7 +202,6 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository, headlineGenerator ) assertThat(testSubject.preferredContentPreview) @@ -245,7 +231,6 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository, headlineGenerator ) assertThat(testSubject.preferredContentPreview) @@ -275,7 +260,6 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository, headlineGenerator ) assertThat(testSubject.preferredContentPreview) @@ -307,7 +291,6 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository, headlineGenerator ) assertThat(testSubject.preferredContentPreview) @@ -337,7 +320,6 @@ class ChooserContentPreviewUiTest { imageLoader, actionFactory, transitionCallback, - featureFlagRepository, headlineGenerator ) assertThat(testSubject.preferredContentPreview) -- cgit v1.2.3-59-g8ed1b From 35ea80ec66daa4df6e77766571c29a651c4db8f0 Mon Sep 17 00:00:00 2001 From: Liana Kazanova Date: Thu, 6 Apr 2023 23:01:18 +0000 Subject: Revert "Adds a reciever for pin migration data" Revert submission 22119004-b223249318-chooser-pin-migrate Reason for revert: Reverted changes: /q/submissionid:22119004-b223249318-chooser-pin-migrate Change-Id: I17b20318a401e9942f941b1be2ce5b842237b487 --- AndroidManifest.xml | 10 +-- .../intentresolver/ChooserPinMigrationReceiver.kt | 55 --------------- .../ChooserPinMigrationReceiverTest.kt | 78 ---------------------- 3 files changed, 1 insertion(+), 142 deletions(-) delete mode 100644 java/src/com/android/intentresolver/ChooserPinMigrationReceiver.kt delete mode 100644 java/tests/src/com/android/intentresolver/ChooserPinMigrationReceiverTest.kt (limited to 'java/tests') diff --git a/AndroidManifest.xml b/AndroidManifest.xml index a3acc8a6..fc99c0b2 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -22,6 +22,7 @@ android:versionName="2021-11" coreApp="true"> + @@ -31,7 +32,6 @@ - @@ -71,14 +71,6 @@ - - - - - - diff --git a/java/src/com/android/intentresolver/ChooserPinMigrationReceiver.kt b/java/src/com/android/intentresolver/ChooserPinMigrationReceiver.kt deleted file mode 100644 index a3ba2192..00000000 --- a/java/src/com/android/intentresolver/ChooserPinMigrationReceiver.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * 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 - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.SharedPreferences -import android.util.Log - -/** - * Broadcast receiver for receiving Chooser pin data from the legacy chooser. - * - * Unions the legacy pins with any existing pins. This receiver is protected by the ADD_CHOOSER_PINS - * permission. The receiver is required to have the RECEIVE_CHOOSER_PIN_MIGRATION to receive the - * broadcast. - */ -class ChooserPinMigrationReceiver( - private val pinnedSharedPrefsProvider: (Context) -> SharedPreferences = - { context -> ChooserActivity.getPinnedSharedPrefs(context) }, -) : BroadcastReceiver() { - - override fun onReceive(context: Context, intent: Intent) { - val bundle = intent.extras ?: return - Log.i(TAG, "Starting migration") - - val prefsEditor = pinnedSharedPrefsProvider.invoke(context).edit() - bundle.keySet().forEach { key -> - if(bundle.getBoolean(key)) { - prefsEditor.putBoolean(key, true) - } - } - prefsEditor.apply() - - Log.i(TAG, "Migration complete") - } - - companion object { - private const val TAG = "ChooserPinMigrationReceiver" - } -} \ No newline at end of file diff --git a/java/tests/src/com/android/intentresolver/ChooserPinMigrationReceiverTest.kt b/java/tests/src/com/android/intentresolver/ChooserPinMigrationReceiverTest.kt deleted file mode 100644 index 1daee137..00000000 --- a/java/tests/src/com/android/intentresolver/ChooserPinMigrationReceiverTest.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * 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 - -import android.content.Context -import android.content.Intent -import android.content.SharedPreferences -import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.Mock -import org.mockito.Mockito.verify -import org.mockito.Mockito.verifyNoMoreInteractions -import org.mockito.MockitoAnnotations - -@RunWith(AndroidJUnit4::class) -class ChooserPinMigrationReceiverTest { - - private lateinit var chooserPinMigrationReceiver: ChooserPinMigrationReceiver - - @Mock private lateinit var mockContext: Context - @Mock private lateinit var mockSharedPreferences: SharedPreferences - @Mock private lateinit var mockSharedPreferencesEditor: SharedPreferences.Editor - - @Before - fun setup() { - MockitoAnnotations.initMocks(this) - - whenever(mockSharedPreferences.edit()).thenReturn(mockSharedPreferencesEditor) - - chooserPinMigrationReceiver = ChooserPinMigrationReceiver { mockSharedPreferences } - } - - @Test - fun onReceive_addsReceivedPins() { - // Arrange - val intent = Intent().apply { - putExtra("TestPackage/TestClass", true) - } - - // Act - chooserPinMigrationReceiver.onReceive(mockContext, intent) - - // Assert - verify(mockSharedPreferencesEditor).putBoolean(eq("TestPackage/TestClass"), eq(true)) - verify(mockSharedPreferencesEditor).apply() - } - - @Test - fun onReceive_ignoresUnpinnedEntries() { - // Arrange - val intent = Intent().apply { - putExtra("TestPackage/TestClass", false) - } - - // Act - chooserPinMigrationReceiver.onReceive(mockContext, intent) - - // Assert - verify(mockSharedPreferencesEditor).apply() - verifyNoMoreInteractions(mockSharedPreferencesEditor) - } -} \ No newline at end of file -- cgit v1.2.3-59-g8ed1b From a36c4deb98e45aa0994b3f5fe2937fb112d03451 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Wed, 5 Apr 2023 21:34:47 -0700 Subject: Replace view stubs with actual views With the feature flags removed, there are no layout variations for action row and image preivew anymore thus ViewStubs can be replaced with the actual views. Bug: 267355521 Bug: 266983432 Test: manual testing Change-Id: I96a3a3560bfa5f1fbf064bd6080c618467c7aca1 --- java/res/layout/chooser_grid_preview_file.xml | 10 ++++--- java/res/layout/chooser_grid_preview_image.xml | 24 ++++++++++----- java/res/layout/chooser_grid_preview_text.xml | 11 ++++--- java/res/layout/scrollable_chooser_action_row.xml | 30 ------------------- java/res/layout/scrollable_image_preview_view.xml | 35 ---------------------- .../contentpreview/ContentPreviewUi.java | 16 ---------- .../contentpreview/FileContentPreviewUi.java | 16 ++++------ .../contentpreview/TextContentPreviewUi.java | 15 ++++------ .../contentpreview/UnifiedContentPreviewUi.java | 30 ++++++------------- .../UnbundledChooserActivityTest.java | 17 ++++------- 10 files changed, 55 insertions(+), 149 deletions(-) delete mode 100644 java/res/layout/scrollable_chooser_action_row.xml delete mode 100644 java/res/layout/scrollable_image_preview_view.xml (limited to 'java/tests') diff --git a/java/res/layout/chooser_grid_preview_file.xml b/java/res/layout/chooser_grid_preview_file.xml index 036c5318..bcc320d3 100644 --- a/java/res/layout/chooser_grid_preview_file.xml +++ b/java/res/layout/chooser_grid_preview_file.xml @@ -71,10 +71,12 @@ android:textAppearance="@style/TextAppearance.ChooserDefault" /> - + diff --git a/java/res/layout/chooser_grid_preview_image.xml b/java/res/layout/chooser_grid_preview_image.xml index 9ad594e8..43f6f4d1 100644 --- a/java/res/layout/chooser_grid_preview_image.xml +++ b/java/res/layout/chooser_grid_preview_image.xml @@ -19,6 +19,7 @@ - + android:layout_height="@dimen/chooser_preview_image_height_tall" + android:layout_gravity="center_horizontal" + android:layout_marginBottom="@dimen/chooser_view_spacing" + android:background="?android:attr/colorBackground" + app:itemInnerSpacing="3dp" + app:itemOuterSpacing="@dimen/chooser_edge_margin_normal" + app:maxWidthHint="@dimen/chooser_width" /> - + diff --git a/java/res/layout/chooser_grid_preview_text.xml b/java/res/layout/chooser_grid_preview_text.xml index 47beaa5a..5e7afa46 100644 --- a/java/res/layout/chooser_grid_preview_text.xml +++ b/java/res/layout/chooser_grid_preview_text.xml @@ -84,9 +84,12 @@ android:focusable="true"/> - + + diff --git a/java/res/layout/scrollable_chooser_action_row.xml b/java/res/layout/scrollable_chooser_action_row.xml deleted file mode 100644 index cb5dabf0..00000000 --- a/java/res/layout/scrollable_chooser_action_row.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - diff --git a/java/res/layout/scrollable_image_preview_view.xml b/java/res/layout/scrollable_image_preview_view.xml deleted file mode 100644 index 0d41f1ae..00000000 --- a/java/res/layout/scrollable_image_preview_view.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java index 15ba96c0..fcafe752 100644 --- a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java @@ -29,13 +29,10 @@ 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 android.widget.ImageView; import android.widget.TextView; -import androidx.annotation.LayoutRes; - import com.android.intentresolver.R; import com.android.intentresolver.widget.ActionRow; @@ -52,19 +49,6 @@ abstract class ContentPreviewUi { public abstract ViewGroup display( Resources resources, LayoutInflater layoutInflater, ViewGroup parent); - protected static int getActionRowLayout() { - return R.layout.scrollable_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 createActions( List systemActions, List customActions) { diff --git a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java index 3012eec2..e814eb12 100644 --- a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java @@ -25,8 +25,6 @@ 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.widget.ActionRow; @@ -70,7 +68,6 @@ class FileContentPreviewUi extends ContentPreviewUi { private ViewGroup displayInternal( Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { - @LayoutRes int actionRowLayout = getActionRowLayout(); ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_file, parent, false); @@ -110,13 +107,12 @@ class FileContentPreviewUi extends ContentPreviewUi { fileIconView.setImageResource(R.drawable.ic_file_copy); } - final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout); - if (actionRow != null) { - actionRow.setActions( - createActions( - createFilePreviewActions(), - mActionFactory.createCustomActions())); - } + final ActionRow actionRow = + contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); + actionRow.setActions( + createActions( + createFilePreviewActions(), + mActionFactory.createCustomActions())); return contentPreviewLayout; } diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java index 70df6479..ece0c312 100644 --- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java @@ -25,7 +25,6 @@ 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; @@ -76,17 +75,15 @@ class TextContentPreviewUi extends ContentPreviewUi { private ViewGroup displayInternal( LayoutInflater layoutInflater, ViewGroup parent) { - @LayoutRes int actionRowLayout = getActionRowLayout(); 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())); - } + final ActionRow actionRow = + contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); + actionRow.setActions( + createActions( + createTextPreviewActions(), + mActionFactory.createCustomActions())); if (mSharingText == null) { contentPreviewLayout diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java index 00a11e30..748f7421 100644 --- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -26,11 +26,9 @@ 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; @@ -89,18 +87,17 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { } private ViewGroup displayInternal(LayoutInflater layoutInflater, ViewGroup parent) { - @LayoutRes int actionRowLayout = getActionRowLayout(); ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_image, parent, false); - ScrollableImagePreviewView imagePreview = inflateImagePreviewView(contentPreviewLayout); - - final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout); - if (actionRow != null) { - actionRow.setActions( - createActions( - createImagePreviewActions(), - mActionFactory.createCustomActions())); - } + ScrollableImagePreviewView imagePreview = + contentPreviewLayout.findViewById(R.id.scrollable_image_preview); + + final ActionRow actionRow = + contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); + actionRow.setActions( + createActions( + createImagePreviewActions(), + mActionFactory.createCustomActions())); if (mFiles.size() == 0) { Log.i( @@ -167,15 +164,6 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { return actions; } - private ScrollableImagePreviewView inflateImagePreviewView(ViewGroup previewLayout) { - ViewStub stub = previewLayout.findViewById(R.id.image_preview_stub); - if (stub != null) { - stub.setLayoutResource(R.layout.scrollable_image_preview_view); - stub.inflate(); - } - return previewLayout.findViewById(R.id.scrollable_image_preview); - } - private void updateTextWithImageHeadline(ViewGroup contentPreview) { CheckBox actionView = contentPreview.requireViewById(R.id.include_text_action); if (actionView.getVisibility() == View.VISIBLE && actionView.isChecked()) { diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index eb340224..39357a4d 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -91,7 +91,6 @@ import android.util.HashedStringCache; import android.util.Pair; import android.util.SparseArray; import android.view.View; -import android.view.ViewGroup; import androidx.annotation.CallSuper; import androidx.annotation.NonNull; @@ -919,23 +918,17 @@ public class UnbundledChooserActivityTest { setupResolverControllers(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); - onView(withId(com.android.internal.R.id.content_preview_image_area)) + onView(withId(R.id.scrollable_image_preview)) .check((view, exception) -> { if (exception != null) { throw exception; } - ViewGroup parent = (ViewGroup) view; - ArrayList visibleViews = new ArrayList<>(); - for (int i = 0, count = parent.getChildCount(); i < count; i++) { - View child = parent.getChildAt(i); - if (child.getVisibility() == View.VISIBLE) { - visibleViews.add(child); - } - } - assertThat(visibleViews.size(), is(1)); + RecyclerView recyclerView = (RecyclerView) view; + assertThat(recyclerView.getAdapter().getItemCount(), is(1)); + assertThat(recyclerView.getChildCount(), is(1)); assertThat( "image preview view is fully visible", - isDisplayed().matches(visibleViews.get(0))); + isDisplayed().matches(recyclerView.getChildAt(0))); }); } -- cgit v1.2.3-59-g8ed1b From 8ae8c4a3f25cc5c0da3196fa0a3f0f07dfe87066 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Fri, 7 Apr 2023 17:54:25 -0700 Subject: Hide thumbnail prview if no preview has loaded In a case when a text is shared along with an image and image preview has failed to load, text exclusion from sharing does not hides the text but just greys it out (TextView marked disabled). This is to avoid unpleasant collapsing animation, something we'd need to address separately. Bug: 274643406 Test: manual testing Change-Id: I9fb9ce530afc1b31b2e075e15392bba256f36048 --- java/res/layout/chooser_grid_preview_image.xml | 9 +- java/res/values/dimens.xml | 1 + .../contentpreview/UnifiedContentPreviewUi.java | 15 ++- .../widget/ScrollableImagePreviewView.kt | 15 ++- .../ChooserActivityOverrideData.java | 5 +- .../intentresolver/ChooserWrapperActivity.java | 6 +- .../intentresolver/TestPreviewImageLoader.kt | 12 +- .../UnbundledChooserActivityTest.java | 131 +++++++++++++++++---- 8 files changed, 145 insertions(+), 49 deletions(-) (limited to 'java/tests') diff --git a/java/res/layout/chooser_grid_preview_image.xml b/java/res/layout/chooser_grid_preview_image.xml index 43f6f4d1..2cfab2a8 100644 --- a/java/res/layout/chooser_grid_preview_image.xml +++ b/java/res/layout/chooser_grid_preview_image.xml @@ -32,7 +32,9 @@ android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_horizontal" - android:layout_marginBottom="@dimen/chooser_view_spacing"> + android:layout_marginBottom="@dimen/chooser_view_spacing" + android:paddingStart="@dimen/chooser_edge_margin_normal_half" + android:paddingEnd="@dimen/chooser_edge_margin_normal_half"> 18dp 8dp 24dp + 12dp 20sp 1dp 120dp diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java index 748f7421..2e6ecb0e 100644 --- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -91,6 +91,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { R.layout.chooser_grid_preview_image, parent, false); ScrollableImagePreviewView imagePreview = contentPreviewLayout.findViewById(R.id.scrollable_image_preview); + imagePreview.setOnNoPreviewCallback(() -> imagePreview.setVisibility(View.GONE)); final ActionRow actionRow = contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); @@ -132,7 +133,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { mImageLoader); if (!TextUtils.isEmpty(mText) && mFiles.size() == 1 && allImages) { - setTextInImagePreviewVisibility(contentPreviewLayout, mActionFactory); + setTextInImagePreviewVisibility(contentPreviewLayout, imagePreview, mActionFactory); updateTextWithImageHeadline(contentPreviewLayout); } else { if (allImages) { @@ -175,7 +176,9 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { } private void setTextInImagePreviewVisibility( - ViewGroup contentPreview, ChooserContentPreviewUi.ActionFactory actionFactory) { + ViewGroup contentPreview, + ScrollableImagePreviewView imagePreview, + ChooserContentPreviewUi.ActionFactory actionFactory) { final TextView textView = contentPreview .requireViewById(com.android.internal.R.id.content_preview_text); CheckBox actionView = contentPreview @@ -194,8 +197,12 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { 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); + textView.setEnabled(isChecked); + if (imagePreview.getVisibility() == View.VISIBLE) { + // animate only only if we have preview + TransitionManager.beginDelayedTransition((ViewGroup) textView.getParent()); + textView.setVisibility(isChecked ? View.VISIBLE : View.GONE); + } shareTextAction.accept(!isChecked); updateTextWithImageHeadline(contentPreview); }); diff --git a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt index d1b0f5b4..8dcaacb8 100644 --- a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt +++ b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt @@ -40,6 +40,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.MainScope import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch @@ -129,13 +130,18 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { imageLoader, previews, otherItemCount, - ).apply { + ) { + onNoPreviewCallback?.run() + } + .apply { if (isMeasured) { loadAspectRatios(getMaxWidth(), this@ScrollableImagePreviewView::calcPreviewWidth) } } } + var onNoPreviewCallback: Runnable? = null + private fun getMaxWidth(): Int = when { maxWidthHint > 0 -> maxWidthHint @@ -187,6 +193,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { private var totalItemCount: Int = 0 private val hasOtherItem get() = previews.size < totalItemCount + val hasPreviews: Boolean get() = previews.isNotEmpty() var transitionStatusElementCallback: TransitionElementStatusCallback? = null @@ -369,6 +376,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { private val imageLoader: ImageLoader, previews: List, otherItemCount: Int, + private val onNoPreviewCallback: (() -> Unit) ) { private val pendingPreviews = ArrayDeque(previews) private val totalItemCount = previews.size + otherItemCount @@ -393,6 +401,11 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { reportFlow .takeWhile { it !== completedEvent } .throttle(ADAPTER_UPDATE_INTERVAL_MS) + .onCompletion { cause -> + if (cause == null && !adapter.hasPreviews) { + onNoPreviewCallback() + } + } .collect { if (isFirstUpdate) { isFirstUpdate = false diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java index f9093b8f..2a4d654a 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java +++ b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java @@ -24,7 +24,6 @@ import static org.mockito.Mockito.when; import android.content.pm.PackageManager; import android.content.res.Resources; import android.database.Cursor; -import android.graphics.Bitmap; import android.os.UserHandle; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; @@ -64,7 +63,7 @@ public class ChooserActivityOverrideData { public boolean isImageType; public Cursor resolverCursor; public boolean resolverForceException; - public Bitmap previewThumbnail; + public ImageLoader imageLoader; public ChooserActivityLogger chooserActivityLogger; public int alternateProfileSetting; public Resources resources; @@ -83,7 +82,7 @@ public class ChooserActivityOverrideData { onSafelyStartInternalCallback = null; isVoiceInteraction = null; createPackageManager = null; - previewThumbnail = null; + imageLoader = null; isImageType = false; resolverCursor = null; resolverForceException = false; diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index 8886892f..dc9baade 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -194,9 +194,9 @@ public class ChooserWrapperActivity @Override protected ImageLoader createPreviewImageLoader() { - return new TestPreviewImageLoader( - super.createPreviewImageLoader(), - () -> sOverrides.previewThumbnail); + return sOverrides.imageLoader == null + ? super.createPreviewImageLoader() + : sOverrides.imageLoader; } @Override diff --git a/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt b/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt index cfe041dd..2f240d58 100644 --- a/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt +++ b/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt @@ -21,18 +21,12 @@ import android.net.Uri import java.util.function.Consumer internal class TestPreviewImageLoader( - private val imageLoader: ImageLoader, - private val imageOverride: () -> Bitmap? + private val bitmaps: Map ) : ImageLoader { override fun loadImage(uri: Uri, callback: Consumer) { - val override = imageOverride() - if (override != null) { - callback.accept(override) - } else { - imageLoader.loadImage(uri, callback) - } + callback.accept(bitmaps[uri]) } - override suspend fun invoke(uri: Uri): Bitmap? = imageOverride() ?: imageLoader(uri) + override suspend fun invoke(uri: Uri): Bitmap? = bitmaps[uri] override fun prePopulate(uris: List) = Unit } diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 39357a4d..0a60b8c7 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -26,6 +26,7 @@ import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist; import static androidx.test.espresso.assertion.ViewAssertions.matches; import static androidx.test.espresso.matcher.ViewMatchers.hasSibling; import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.isEnabled; import static androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withText; @@ -125,6 +126,7 @@ import org.mockito.Mockito; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -422,10 +424,12 @@ public class UnbundledChooserActivityTest { @Test public void visiblePreviewTitleAndThumbnail() throws InterruptedException { String previewTitle = "My Content Preview Title"; - Intent sendIntent = createSendTextIntentWithPreview(previewTitle, - Uri.parse("android.resource://com.android.frameworks.coretests/" - + R.drawable.test320x240)); - ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); + Uri uri = Uri.parse( + "android.resource://com.android.frameworks.coretests/" + + R.drawable.test320x240); + Intent sendIntent = createSendTextIntentWithPreview(previewTitle, uri); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createBitmap()); List resolvedComponentInfos = createResolvedComponentsForTest(2); setupResolverControllers(resolvedComponentInfos); @@ -688,10 +692,12 @@ public class UnbundledChooserActivityTest { @Test public void testImagePlusTextSharing_ExcludeText() { - Intent sendIntent = createSendImageIntent( - Uri.parse("android.resource://com.android.frameworks.coretests/" - + R.drawable.test320x240)); - ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); + Uri uri = Uri.parse( + "android.resource://com.android.frameworks.coretests/" + + R.drawable.test320x240); + Intent sendIntent = createSendImageIntent(uri); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createBitmap()); ChooserActivityOverrideData.getInstance().isImageType = true; sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); @@ -728,10 +734,12 @@ public class UnbundledChooserActivityTest { @Test public void testImagePlusTextSharing_RemoveAndAddBackText() { - Intent sendIntent = createSendImageIntent( - Uri.parse("android.resource://com.android.frameworks.coretests/" - + R.drawable.test320x240)); - ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); + Uri uri = Uri.parse( + "android.resource://com.android.frameworks.coretests/" + + R.drawable.test320x240); + Intent sendIntent = createSendImageIntent(uri); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createBitmap()); ChooserActivityOverrideData.getInstance().isImageType = true; final String text = "https://google.com/search?q=google"; sendIntent.putExtra(Intent.EXTRA_TEXT, text); @@ -772,10 +780,12 @@ public class UnbundledChooserActivityTest { @Test public void testImagePlusTextSharing_TextExclusionDoesNotAffectAlternativeIntent() { - Intent sendIntent = createSendImageIntent( - Uri.parse("android.resource://com.android.frameworks.coretests/" - + R.drawable.test320x240)); - ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); + Uri uri = Uri.parse( + "android.resource://com.android.frameworks.coretests/" + + R.drawable.test320x240); + Intent sendIntent = createSendImageIntent(uri); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createBitmap()); ChooserActivityOverrideData.getInstance().isImageType = true; sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); @@ -814,6 +824,42 @@ public class UnbundledChooserActivityTest { assertThat(launchedIntentRef.get().getStringExtra(Intent.EXTRA_TEXT)).isEqualTo(text); } + @Test + public void testImagePlusTextSharing_failedThumbnailAndExcludedText_textRemainsVisible() { + Uri uri = Uri.parse( + "android.resource://com.android.frameworks.coretests/" + + R.drawable.test320x240); + Intent sendIntent = createSendImageIntent(uri); + ChooserActivityOverrideData.getInstance().imageLoader = + new TestPreviewImageLoader(Collections.emptyMap()); + 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, PERSONAL_USER_HANDLE), + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.textviewer", "UriTarget"), + new Intent("VIEW_TEXT"), PERSONAL_USER_HANDLE) + ); + + setupResolverControllers(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.scrollable_image_preview)) + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))); + onView(withId(com.android.internal.R.id.content_preview_text)) + .check(matches(allOf(isDisplayed(), not(isEnabled())))); + } + @Test public void copyTextToClipboard() throws Exception { Intent sendIntent = createSendTextIntent(); @@ -879,11 +925,12 @@ public class UnbundledChooserActivityTest { @Test @Ignore public void testEditImageLogs() throws Exception { - Intent sendIntent = createSendImageIntent( - Uri.parse("android.resource://com.android.frameworks.coretests/" - + R.drawable.test320x240)); - - ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); + Uri uri = Uri.parse( + "android.resource://com.android.frameworks.coretests/" + + R.drawable.test320x240); + Intent sendIntent = createSendImageIntent(uri); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createBitmap()); ChooserActivityOverrideData.getInstance().isImageType = true; List resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -910,7 +957,8 @@ public class UnbundledChooserActivityTest { uris.add(uri); Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createBitmap()); ChooserActivityOverrideData.getInstance().isImageType = true; List resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -932,6 +980,29 @@ public class UnbundledChooserActivityTest { }); } + @Test + public void allThumbnailsFailedToLoad_hidePreview() { + Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" + + R.drawable.test320x240); + + ArrayList uris = new ArrayList<>(); + uris.add(uri); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + ChooserActivityOverrideData.getInstance().imageLoader = + new TestPreviewImageLoader(Collections.emptyMap()); + ChooserActivityOverrideData.getInstance().isImageType = true; + + List resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(R.id.scrollable_image_preview)) + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))); + } + @Test public void testManyVisibleImagePreview_ScrollableImagePreview() { Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" @@ -950,7 +1021,8 @@ public class UnbundledChooserActivityTest { uris.add(uri); Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createBitmap()); ChooserActivityOverrideData.getInstance().isImageType = true; List resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -980,7 +1052,8 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendUriIntentWithPreview(uris); sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); - ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createBitmap()); ChooserActivityOverrideData.getInstance().isImageType = true; List resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1004,7 +1077,8 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendUriIntentWithPreview(uris); sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); - ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createBitmap()); ChooserActivityOverrideData.getInstance().isImageType = true; List resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1093,7 +1167,8 @@ public class UnbundledChooserActivityTest { uris.add(uri); Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createBitmap()); ChooserActivityOverrideData.getInstance().isImageType = true; List resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -2866,4 +2941,8 @@ public class UnbundledChooserActivityTest { }; return shortcutLoaders; } + + private static ImageLoader createImageLoader(Uri uri, Bitmap bitmap) { + return new TestPreviewImageLoader(Collections.singletonMap(uri, bitmap)); + } } -- cgit v1.2.3-59-g8ed1b From f0b4a9f9c194618c0eb87edf7ecad48f3583e8f9 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Fri, 14 Apr 2023 12:37:50 -0700 Subject: Fix image edit transition animation Bug: 278282157 Test: manual testing Change-Id: Id5d5d91d01c209bf94850d4c8e72d98cf6a9fa5e --- .../android/intentresolver/ChooserActionFactory.java | 3 --- .../src/com/android/intentresolver/ChooserActivity.java | 8 +++++--- .../android/intentresolver/widget/ImagePreviewView.kt | 2 ++ .../intentresolver/widget/ScrollableImagePreviewView.kt | 17 ++++++++++++++--- .../android/intentresolver/ChooserActionFactoryTest.kt | 3 --- 5 files changed, 21 insertions(+), 12 deletions(-) (limited to 'java/tests') diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java index 8dafae86..23e04560 100644 --- a/java/src/com/android/intentresolver/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/ChooserActionFactory.java @@ -40,7 +40,6 @@ import android.view.View; 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.widget.ActionRow; import com.android.internal.annotations.VisibleForTesting; @@ -104,7 +103,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio /** * @param context * @param chooserRequest data about the invocation of the current Sharesheet session. - * @param featureFlagRepository feature flags that may control the eligibility of some actions. * @param integratedDeviceComponents info about other components that are available on this * device to implement the supported action types. * @param onUpdateSharedTextIsExcluded a delegate to be invoked when the "exclude shared text" @@ -118,7 +116,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio public ChooserActionFactory( Context context, ChooserRequestParameters chooserRequest, - FeatureFlagRepository featureFlagRepository, ChooserIntegratedDeviceComponents integratedDeviceComponents, ChooserActivityLogger logger, Consumer onUpdateSharedTextIsExcluded, diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 75ee0648..404d6da3 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -92,6 +92,7 @@ import com.android.intentresolver.model.AppPredictionServiceResolverComparator; import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; import com.android.intentresolver.shortcuts.AppPredictorFactory; import com.android.intentresolver.shortcuts.ShortcutLoader; +import com.android.intentresolver.widget.ImagePreviewView; import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.content.PackageMonitor; @@ -682,8 +683,10 @@ public class ChooserActivity extends ResolverActivity implements @Nullable private View getFirstVisibleImgPreviewView() { - View firstImage = findViewById(com.android.internal.R.id.content_preview_image_1_large); - return firstImage != null && firstImage.isVisibleToUser() ? firstImage : null; + View imagePreview = findViewById(R.id.scrollable_image_preview); + return imagePreview instanceof ImagePreviewView + ? ((ImagePreviewView) imagePreview).getTransitionView() + : null; } /** @@ -1295,7 +1298,6 @@ public class ChooserActivity extends ResolverActivity implements return new ChooserActionFactory( this, mChooserRequest, - mFeatureFlagRepository, mIntegratedDeviceComponents, getChooserActivityLogger(), (isExcluded) -> mExcludeSharedText = isExcluded, diff --git a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt index 8813adca..5f92b149 100644 --- a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt +++ b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt @@ -18,11 +18,13 @@ package com.android.intentresolver.widget import android.graphics.Bitmap import android.net.Uri +import android.view.View internal typealias ImageLoader = suspend (Uri) -> Bitmap? interface ImagePreviewView { fun setTransitionElementStatusCallback(callback: TransitionElementStatusCallback?) + fun getTransitionView(): View? /** * [ImagePreviewView] progressively prepares views for shared element transition and reports diff --git a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt index 8dcaacb8..7755610d 100644 --- a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt +++ b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt @@ -122,6 +122,15 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { previewAdapter.transitionStatusElementCallback = callback } + override fun getTransitionView(): View? { + for (i in 0 until childCount) { + val child = getChildAt(i) + val vh = getChildViewHolder(child) + if (vh is PreviewViewHolder && vh.image.transitionName != null) return child + } + return null + } + fun setPreviews(previews: List, otherItemCount: Int, imageLoader: ImageLoader) { previewAdapter.reset(0, imageLoader) batchLoader?.cancel() @@ -250,7 +259,8 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { is PreviewViewHolder -> vh.bind( previews[position], imageLoader ?: error("ImageLoader is missing"), - if (position == firstImagePos && transitionStatusElementCallback != null) { + isSharedTransitionElement = position == firstImagePos, + previewReadyCallback = if (position == firstImagePos && transitionStatusElementCallback != null) { this::onTransitionElementReady } else { null @@ -282,7 +292,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { } private class PreviewViewHolder(view: View) : ViewHolder(view) { - private val image = view.requireViewById(R.id.image) + val image = view.requireViewById(R.id.image) private val badgeFrame = view.requireViewById(R.id.badge_frame) private val badge = view.requireViewById(R.id.badge) private var scope: CoroutineScope? = null @@ -290,13 +300,14 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { fun bind( preview: Preview, imageLoader: ImageLoader, + isSharedTransitionElement: Boolean, previewReadyCallback: ((String) -> Unit)? ) { image.setImageDrawable(null) (image.layoutParams as? ConstraintLayout.LayoutParams)?.let { params -> params.dimensionRatio = preview.aspectRatioString } - image.transitionName = if (previewReadyCallback != null) { + image.transitionName = if (isSharedTransitionElement) { TRANSITION_NAME } else { null diff --git a/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt b/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt index 0a8c22b7..d72c9aa6 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt +++ b/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt @@ -29,11 +29,9 @@ import android.view.View import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.android.intentresolver.flags.FeatureFlagRepository -import com.android.intentresolver.flags.Flags import com.google.common.collect.ImmutableList import com.google.common.truth.Truth.assertThat import org.junit.After -import org.junit.Assert import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test @@ -142,7 +140,6 @@ class ChooserActionFactoryTest { return ChooserActionFactory( context, chooserRequest, - flags, mock(), logger, Consumer{}, -- cgit v1.2.3-59-g8ed1b From 5c05904fbda5a7d8b0b3a1d474ab7224adb686ee Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Wed, 22 Mar 2023 15:12:43 -0700 Subject: Remove bitmap caching from ScrollableImagePreview Remove bitmap caching from ScrollableImagePreview; instead, extend the image loader interface to proivde a cache-control option. ScrollableImagePreview$BatchPreviewLoader requests caching only for the first visible items as we'd like them to be displayed as fast as possible and not be evicted by any of the remaining items. Minor fixes inside ImagePreviewImageLoader. ImageLoader is moved into contentpreview package. Bug: 271613784 Test: unit tests Test: Manual testing with injected extensive logging. Change-Id: Ic07572e1fb73d589d98207af8fe58ae52698802a --- .../android/intentresolver/ChooserActivity.java | 5 +- java/src/com/android/intentresolver/ImageLoader.kt | 26 ------ .../intentresolver/ImagePreviewImageLoader.kt | 80 ++++++++++++++++--- .../contentpreview/ChooserContentPreviewUi.java | 1 - .../intentresolver/contentpreview/ImageLoader.kt | 51 ++++++++++++ .../contentpreview/TextContentPreviewUi.java | 1 - .../contentpreview/UnifiedContentPreviewUi.java | 1 - .../intentresolver/widget/ImagePreviewView.kt | 4 - .../widget/ScrollableImagePreviewView.kt | 29 +++---- .../ChooserActivityOverrideData.java | 1 + .../intentresolver/ChooserWrapperActivity.java | 1 + .../intentresolver/ImagePreviewImageLoaderTest.kt | 93 +++++++++++++++++++++- .../intentresolver/TestPreviewImageLoader.kt | 4 +- .../UnbundledChooserActivityTest.java | 1 + .../contentpreview/ChooserContentPreviewUiTest.kt | 3 +- 15 files changed, 230 insertions(+), 71 deletions(-) delete mode 100644 java/src/com/android/intentresolver/ImageLoader.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/ImageLoader.kt (limited to 'java/tests') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 404d6da3..917a4e5d 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -84,6 +84,7 @@ import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; import com.android.intentresolver.contentpreview.HeadlineGeneratorImpl; +import com.android.intentresolver.contentpreview.ImageLoader; import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.flags.FeatureFlagRepositoryFactory; import com.android.intentresolver.grid.ChooserGridAdapter; @@ -1287,9 +1288,9 @@ public class ChooserActivity extends ResolverActivity implements protected ImageLoader createPreviewImageLoader() { final int cacheSize; float chooserWidth = getResources().getDimension(R.dimen.chooser_width); - // imageWidth = imagePreviewHeight / minAspectRatio (see ScrollableImagePreviewView) + // imageWidth = imagePreviewHeight * minAspectRatio (see ScrollableImagePreviewView) float imageWidth = - getResources().getDimension(R.dimen.chooser_preview_image_height_tall) * 5 / 2; + getResources().getDimension(R.dimen.chooser_preview_image_height_tall) * 2 / 5; cacheSize = (int) (Math.ceil(chooserWidth / imageWidth) + 2); return new ImagePreviewImageLoader(this, getLifecycle(), cacheSize); } diff --git a/java/src/com/android/intentresolver/ImageLoader.kt b/java/src/com/android/intentresolver/ImageLoader.kt deleted file mode 100644 index 0ed8b122..00000000 --- a/java/src/com/android/intentresolver/ImageLoader.kt +++ /dev/null @@ -1,26 +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 android.graphics.Bitmap -import android.net.Uri -import java.util.function.Consumer - -interface ImageLoader : suspend (Uri) -> Bitmap? { - fun loadImage(uri: Uri, callback: Consumer) - fun prePopulate(uris: List) -} diff --git a/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt index 9650403e..c97efdd1 100644 --- a/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt +++ b/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt @@ -26,8 +26,11 @@ import androidx.annotation.VisibleForTesting import androidx.collection.LruCache import androidx.lifecycle.Lifecycle import androidx.lifecycle.coroutineScope +import com.android.intentresolver.contentpreview.ImageLoader +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -35,6 +38,10 @@ import java.util.function.Consumer private const val TAG = "ImagePreviewImageLoader" +/** + * Implements preview image loading for the content preview UI. Provides requests deduplication and + * image caching. + */ @VisibleForTesting class ImagePreviewImageLoader @JvmOverloads constructor( private val context: Context, @@ -48,14 +55,17 @@ class ImagePreviewImageLoader @JvmOverloads constructor( Size(it, it) } - @GuardedBy("self") - private val cache = LruCache>(cacheSize) + private val lock = Any() + @GuardedBy("lock") + private val cache = LruCache(cacheSize) + @GuardedBy("lock") + private val runningRequests = HashMap() - override suspend fun invoke(uri: Uri): Bitmap? = loadImageAsync(uri) + override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? = loadImageAsync(uri, caching) override fun loadImage(uri: Uri, callback: Consumer) { lifecycle.coroutineScope.launch { - val image = loadImageAsync(uri) + val image = loadImageAsync(uri, caching = true) if (isActive) { callback.accept(image) } @@ -65,23 +75,44 @@ class ImagePreviewImageLoader @JvmOverloads constructor( override fun prePopulate(uris: List) { uris.asSequence().take(cache.maxSize()).forEach { uri -> lifecycle.coroutineScope.launch { - loadImageAsync(uri) + loadImageAsync(uri, caching = true) } } } - private suspend fun loadImageAsync(uri: Uri): Bitmap? { - return synchronized(cache) { - cache.get(uri) ?: CompletableDeferred().also { result -> - cache.put(uri, result) - lifecycle.coroutineScope.launch(dispatcher) { - result.loadBitmap(uri) + private suspend fun loadImageAsync(uri: Uri, caching: Boolean): Bitmap? { + return getRequestDeferred(uri, caching) + .await() + } + + private fun getRequestDeferred(uri: Uri, caching: Boolean): Deferred { + var shouldLaunchImageLoading = false + val request = synchronized(lock) { + cache[uri] + ?: runningRequests.getOrPut(uri) { + shouldLaunchImageLoading = true + RequestRecord(uri, CompletableDeferred(), caching) + }.apply { + this.caching = this.caching || caching } + } + if (shouldLaunchImageLoading) { + request.loadBitmapAsync() + } + return request.deferred + } + + private fun RequestRecord.loadBitmapAsync() { + lifecycle.coroutineScope.launch(dispatcher) { + loadBitmap() + }.invokeOnCompletion { cause -> + if (cause is CancellationException) { + cancel() } - }.await() + } } - private fun CompletableDeferred.loadBitmap(uri: Uri) { + private fun RequestRecord.loadBitmap() { val bitmap = try { context.contentResolver.loadThumbnail(uri, thumbnailSize, null) } catch (t: Throwable) { @@ -90,4 +121,27 @@ class ImagePreviewImageLoader @JvmOverloads constructor( } complete(bitmap) } + + private fun RequestRecord.cancel() { + synchronized(lock) { + runningRequests.remove(uri) + deferred.cancel() + } + } + + private fun RequestRecord.complete(bitmap: Bitmap?) { + deferred.complete(bitmap) + synchronized(lock) { + runningRequests.remove(uri) + if (bitmap != null && caching) { + cache.put(uri, this) + } + } + } + + private class RequestRecord( + val uri: Uri, + val deferred: CompletableDeferred, + @GuardedBy("lock") var caching: Boolean + ) } diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index 56027a16..181fe117 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -38,7 +38,6 @@ import android.view.ViewGroup; import androidx.annotation.Nullable; -import com.android.intentresolver.ImageLoader; import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; diff --git a/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt new file mode 100644 index 00000000..225807ee --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt @@ -0,0 +1,51 @@ +/* + * 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.graphics.Bitmap +import android.net.Uri +import java.util.function.Consumer + +/** + * A content preview image loader. + */ +interface ImageLoader : suspend (Uri) -> Bitmap?, suspend (Uri, Boolean) -> Bitmap? { + /** + * Load preview image asynchronously; caching is allowed. + * @param uri content URI + * @param callback a callback that will be invoked with the loaded image or null if loading has + * failed. + */ + fun loadImage(uri: Uri, callback: Consumer) + + /** + * Prepopulate the image loader cache. + */ + fun prePopulate(uris: List) + + /** + * Load preview image; caching is allowed. + */ + override suspend fun invoke(uri: Uri) = invoke(uri, true) + + /** + * Load preview image. + * @param uri content URI + * @param caching indicates if the loaded image could be cached. + */ + override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? +} diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java index ece0c312..6bf9a1cc 100644 --- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java @@ -27,7 +27,6 @@ import android.widget.TextView; import androidx.annotation.Nullable; -import com.android.intentresolver.ImageLoader; import com.android.intentresolver.R; import com.android.intentresolver.widget.ActionRow; diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java index 9ce875c8..709ec566 100644 --- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -31,7 +31,6 @@ import android.widget.TextView; import androidx.annotation.Nullable; -import com.android.intentresolver.ImageLoader; import com.android.intentresolver.R; import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; diff --git a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt index 5f92b149..3f0458ee 100644 --- a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt +++ b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt @@ -16,12 +16,8 @@ package com.android.intentresolver.widget -import android.graphics.Bitmap -import android.net.Uri import android.view.View -internal typealias ImageLoader = suspend (Uri) -> Bitmap? - interface ImagePreviewView { fun setTransitionElementStatusCallback(callback: TransitionElementStatusCallback?) fun getTransitionView(): View? diff --git a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt index 7755610d..524b4f81 100644 --- a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt +++ b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt @@ -56,6 +56,8 @@ private const val MIN_ASPECT_RATIO_STRING = "2:5" private const val MAX_ASPECT_RATIO = 2.5f private const val MAX_ASPECT_RATIO_STRING = "5:2" +private typealias CachingImageLoader = suspend (Uri, Boolean) -> Bitmap? + class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { constructor(context: Context) : this(context, null) constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) @@ -131,7 +133,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { return null } - fun setPreviews(previews: List, otherItemCount: Int, imageLoader: ImageLoader) { + fun setPreviews(previews: List, otherItemCount: Int, imageLoader: CachingImageLoader) { previewAdapter.reset(0, imageLoader) batchLoader?.cancel() batchLoader = BatchPreviewLoader( @@ -176,8 +178,6 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { ) { constructor(type: PreviewType, uri: Uri) : this(type, uri, "1:1") - internal var bitmap: Bitmap? = null - internal fun updateAspectRatio(width: Int, height: Int) { if (width <= 0 || height <= 0) return val aspectRatio = width.toFloat() / height.toFloat() @@ -197,7 +197,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { private val context: Context ) : RecyclerView.Adapter() { private val previews = ArrayList() - private var imageLoader: ImageLoader? = null + private var imageLoader: CachingImageLoader? = null private var firstImagePos = -1 private var totalItemCount: Int = 0 @@ -206,7 +206,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { var transitionStatusElementCallback: TransitionElementStatusCallback? = null - fun reset(totalItemCount: Int, imageLoader: ImageLoader) { + fun reset(totalItemCount: Int, imageLoader: CachingImageLoader) { this.imageLoader = imageLoader firstImagePos = -1 previews.clear() @@ -299,7 +299,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { fun bind( preview: Preview, - imageLoader: ImageLoader, + imageLoader: CachingImageLoader, isSharedTransitionElement: Boolean, previewReadyCallback: ((String) -> Unit)? ) { @@ -334,11 +334,11 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { } } - private suspend fun loadImage(preview: Preview, imageLoader: ImageLoader) { - val bitmap = preview.bitmap ?: runCatching { + private suspend fun loadImage(preview: Preview, imageLoader: CachingImageLoader) { + val bitmap = runCatching { // it's expected for all loading/caching optimizations to be implemented by the // loader - imageLoader(preview.uri) + imageLoader(preview.uri, true) }.getOrNull() image.setImageBitmap(bitmap) } @@ -384,7 +384,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { private class BatchPreviewLoader( private val adapter: Adapter, - private val imageLoader: ImageLoader, + private val imageLoader: CachingImageLoader, previews: List, otherItemCount: Int, private val onNoPreviewCallback: (() -> Unit) @@ -435,18 +435,15 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { launch { while (pendingPreviews.isNotEmpty()) { val preview = pendingPreviews.poll() ?: continue + val isVisible = loadedPreviewWidth < maxWidth val bitmap = runCatching { // TODO: decide on adding a timeout - imageLoader(preview.uri) + imageLoader(preview.uri, isVisible) }.getOrNull() ?: continue preview.updateAspectRatio(bitmap.width, bitmap.height) updates.add(preview) - if (loadedPreviewWidth < maxWidth) { + if (isVisible) { loadedPreviewWidth += previewWidthCalculator(bitmap) - // cache bitmaps for the first preview items to aovid potential - // double-loading (in case those values are evicted from the image - // loader's cache) - preview.bitmap = bitmap if (loadedPreviewWidth >= maxWidth) { // notify that the preview now can be displayed reportFlow.emit(updateEvent) diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java index 2a4d654a..9ebeb79d 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java +++ b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java @@ -28,6 +28,7 @@ import android.os.UserHandle; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.contentpreview.ImageLoader; import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.shortcuts.ShortcutLoader; diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index dc9baade..d23e4a66 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -35,6 +35,7 @@ import android.os.UserHandle; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.contentpreview.ImageLoader; import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.grid.ChooserGridAdapter; import com.android.intentresolver.shortcuts.ShortcutLoader; diff --git a/java/tests/src/com/android/intentresolver/ImagePreviewImageLoaderTest.kt b/java/tests/src/com/android/intentresolver/ImagePreviewImageLoaderTest.kt index f327e19e..3c399cc4 100644 --- a/java/tests/src/com/android/intentresolver/ImagePreviewImageLoaderTest.kt +++ b/java/tests/src/com/android/intentresolver/ImagePreviewImageLoaderTest.kt @@ -19,11 +19,18 @@ package com.android.intentresolver import android.content.ContentResolver import android.content.Context import android.content.res.Resources +import android.graphics.Bitmap import android.net.Uri import android.util.Size import androidx.lifecycle.Lifecycle +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart.UNDISPATCHED import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestCoroutineScheduler import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain @@ -41,7 +48,10 @@ class ImagePreviewImageLoaderTest { private val imageSize = Size(300, 300) private val uriOne = Uri.parse("content://org.package.app/image-1.png") private val uriTwo = Uri.parse("content://org.package.app/image-2.png") - private val contentResolver = mock() + private val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) + private val contentResolver = mock { + whenever(loadThumbnail(any(), any(), anyOrNull())).thenReturn(bitmap) + } private val resources = mock { whenever(getDimensionPixelSize(R.dimen.chooser_preview_image_max_dimen)) .thenReturn(imageSize.width) @@ -70,7 +80,7 @@ class ImagePreviewImageLoaderTest { } @Test - fun test_prePopulate() = runTest { + fun prePopulate_cachesImagesUpToTheCacheSize() = runTest { testSubject.prePopulate(listOf(uriOne, uriTwo)) verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) @@ -81,7 +91,7 @@ class ImagePreviewImageLoaderTest { } @Test - fun test_invoke_return_cached_image() = runTest { + fun invoke_returnCachedImageWhenCalledTwice() = runTest { testSubject(uriOne) testSubject(uriOne) @@ -89,7 +99,33 @@ class ImagePreviewImageLoaderTest { } @Test - fun test_invoke_old_records_evicted_from_the_cache() = runTest { + fun invoke_whenInstructed_doesNotCache() = runTest { + testSubject(uriOne, false) + testSubject(uriOne, false) + + verify(contentResolver, times(2)).loadThumbnail(any(), any(), anyOrNull()) + } + + @Test + fun invoke_overlappedRequests_Deduplicate() = runTest { + val scheduler = TestCoroutineScheduler() + val dispatcher = StandardTestDispatcher(scheduler) + val testSubject = ImagePreviewImageLoader(context, lifecycleOwner.lifecycle, 1, dispatcher) + coroutineScope { + launch(start = UNDISPATCHED) { + testSubject(uriOne, false) + } + launch(start = UNDISPATCHED) { + testSubject(uriOne, false) + } + scheduler.advanceUntilIdle() + } + + verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull()) + } + + @Test + fun invoke_oldRecordsEvictedFromTheCache() = runTest { testSubject(uriOne) testSubject(uriTwo) testSubject(uriTwo) @@ -98,4 +134,53 @@ class ImagePreviewImageLoaderTest { verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null) verify(contentResolver, times(1)).loadThumbnail(uriTwo, imageSize, null) } + + @Test + fun invoke_doNotCacheNulls() = runTest { + whenever(contentResolver.loadThumbnail(any(), any(), anyOrNull())).thenReturn(null) + testSubject(uriOne) + testSubject(uriOne) + + verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null) + } + + @Test(expected = CancellationException::class) + fun invoke_onClosedImageLoaderScope_throwsCancellationException() = runTest { + lifecycleOwner.state = Lifecycle.State.DESTROYED + testSubject(uriOne) + } + + @Test(expected = CancellationException::class) + fun invoke_imageLoaderScopeClosedMidflight_throwsCancellationException() = runTest { + val scheduler = TestCoroutineScheduler() + val dispatcher = StandardTestDispatcher(scheduler) + val testSubject = ImagePreviewImageLoader(context, lifecycleOwner.lifecycle, 1, dispatcher) + coroutineScope { + val deferred = async(start = UNDISPATCHED) { + testSubject(uriOne, false) + } + lifecycleOwner.state = Lifecycle.State.DESTROYED + scheduler.advanceUntilIdle() + deferred.await() + } + } + + @Test + fun invoke_multipleCallsWithDifferentCacheInstructions_cachingPrevails() = runTest { + val scheduler = TestCoroutineScheduler() + val dispatcher = StandardTestDispatcher(scheduler) + val testSubject = ImagePreviewImageLoader(context, lifecycleOwner.lifecycle, 1, dispatcher) + coroutineScope { + launch(start = UNDISPATCHED) { + testSubject(uriOne, false) + } + launch(start = UNDISPATCHED) { + testSubject(uriOne, true) + } + scheduler.advanceUntilIdle() + } + testSubject(uriOne, true) + + verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) + } } diff --git a/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt b/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt index 2f240d58..74a253b8 100644 --- a/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt +++ b/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt @@ -18,6 +18,7 @@ package com.android.intentresolver import android.graphics.Bitmap import android.net.Uri +import com.android.intentresolver.contentpreview.ImageLoader import java.util.function.Consumer internal class TestPreviewImageLoader( @@ -27,6 +28,7 @@ internal class TestPreviewImageLoader( callback.accept(bitmaps[uri]) } - override suspend fun invoke(uri: Uri): Bitmap? = bitmaps[uri] + override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? = bitmaps[uri] + override fun prePopulate(uris: List) = Unit } diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 0a60b8c7..de5498db 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -104,6 +104,7 @@ import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.rule.ActivityTestRule; import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.contentpreview.ImageLoader; import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt index 7b9a0ce6..8eec289e 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt @@ -21,7 +21,6 @@ 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.any import com.android.intentresolver.anyOrNull import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory @@ -48,7 +47,7 @@ class ChooserContentPreviewUiTest { callback.accept(null) } override fun prePopulate(uris: List) = Unit - override suspend fun invoke(uri: Uri): Bitmap? = null + override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? = null } private val actionFactory = object : ActionFactory { override fun createCopyButton() = ActionRow.Action(label = "Copy", icon = null) {} -- cgit v1.2.3-59-g8ed1b From cd42caeb00aa0bdd33f565816bdbfb46ccbbad24 Mon Sep 17 00:00:00 2001 From: 1 Date: Fri, 14 Apr 2023 03:04:40 +0000 Subject: Move Nearby to the front of the app list. Introduce a concept of a "cemented" component that always goes to the front of the list. Just allowing up to one of these since we really don't expect to ever have more than one. Remove the nearby action code as well, along with some associated cleanup. Not in this CL: Prevent this target from being pinned. Bug: 275787600 Test: atest com.android.intentresolver Change-Id: I55c551f930de2d642cd0e5a42c6cdbd6e1918f0b --- .../intentresolver/ChooserActionFactory.java | 94 ---------------------- .../android/intentresolver/ChooserActivity.java | 7 +- .../intentresolver/ChooserRequestParameters.java | 32 ++------ .../android/intentresolver/ResolverActivity.java | 3 +- .../contentpreview/ChooserContentPreviewUi.java | 4 - .../contentpreview/FileContentPreviewUi.java | 15 +--- .../contentpreview/TextContentPreviewUi.java | 4 - .../contentpreview/UnifiedContentPreviewUi.java | 8 +- .../model/AbstractResolverComparator.java | 37 ++++----- .../AppPredictionServiceResolverComparator.java | 8 +- .../ResolverRankerServiceResolverComparator.java | 10 ++- .../contentpreview/ChooserContentPreviewUiTest.kt | 1 - .../model/AbstractResolverComparatorTest.java | 74 ++++++++++++----- 13 files changed, 94 insertions(+), 203 deletions(-) (limited to 'java/tests') diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java index 23e04560..f355d9d4 100644 --- a/java/src/com/android/intentresolver/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/ChooserActionFactory.java @@ -26,12 +26,9 @@ import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; -import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.ResolveInfo; -import android.content.res.Resources; import android.graphics.drawable.Drawable; import android.net.Uri; -import android.os.Bundle; import android.service.chooser.ChooserAction; import android.text.TextUtils; import android.util.Log; @@ -92,8 +89,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio private final Runnable mOnCopyButtonClicked; private final TargetInfo mEditSharingTarget; private final Runnable mOnEditButtonClicked; - private final TargetInfo mNearbySharingTarget; - private final Runnable mOnNearbyButtonClicked; private final ImmutableList mCustomActions; private final @Nullable ChooserAction mModifyShareAction; private final Consumer mExcludeSharedTextAction; @@ -144,18 +139,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio firstVisibleImageQuery, activityStarter, logger), - getNearbySharingTarget( - context, - chooserRequest.getTargetIntent(), - integratedDeviceComponents), - makeOnNearbyShareRunnable( - getNearbySharingTarget( - context, - chooserRequest.getTargetIntent(), - integratedDeviceComponents), - activityStarter, - finishCallback, - logger), chooserRequest.getChooserActions(), chooserRequest.getModifyShareAction(), onUpdateSharedTextIsExcluded, @@ -171,8 +154,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio Runnable onCopyButtonClicked, TargetInfo editSharingTarget, Runnable onEditButtonClicked, - TargetInfo nearbySharingTarget, - Runnable onNearbyButtonClicked, List customActions, @Nullable ChooserAction modifyShareAction, Consumer onUpdateSharedTextIsExcluded, @@ -184,8 +165,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio mOnCopyButtonClicked = onCopyButtonClicked; mEditSharingTarget = editSharingTarget; mOnEditButtonClicked = onEditButtonClicked; - mNearbySharingTarget = nearbySharingTarget; - mOnNearbyButtonClicked = onNearbyButtonClicked; mCustomActions = ImmutableList.copyOf(customActions); mModifyShareAction = modifyShareAction; mExcludeSharedTextAction = onUpdateSharedTextIsExcluded; @@ -218,21 +197,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio mOnEditButtonClicked); } - /** Create a "Share to Nearby" action. */ - @Override - @Nullable - public ActionRow.Action createNearbyButton() { - if (mNearbySharingTarget == null) { - return null; - } - - return new ActionRow.Action( - com.android.internal.R.id.chooser_nearby_button, - mNearbySharingTarget.getDisplayLabel(), - mNearbySharingTarget.getDisplayIconHolder().getDisplayIcon(), - mOnNearbyButtonClicked); - } - /** Create custom actions */ @Override public List createCustomActions() { @@ -403,64 +367,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio }; } - private static TargetInfo getNearbySharingTarget( - Context context, - Intent originalIntent, - ChooserIntegratedDeviceComponents integratedComponents) { - final ComponentName cn = integratedComponents.getNearbySharingComponent(); - if (cn == null) { - return null; - } - - final Intent resolveIntent = new Intent(originalIntent); - resolveIntent.setComponent(cn); - final ResolveInfo ri = context.getPackageManager().resolveActivity( - resolveIntent, PackageManager.GET_META_DATA); - if (ri == null || ri.activityInfo == null) { - Log.e(TAG, "Device-specified nearby sharing component (" + cn - + ") not available"); - return null; - } - - // Allow the nearby sharing component to provide a more appropriate icon and label - // for the chip. - CharSequence name = null; - Drawable icon = null; - final Bundle metaData = ri.activityInfo.metaData; - if (metaData != null) { - try { - final Resources pkgRes = context.getPackageManager().getResourcesForActivity(cn); - final int nameResId = metaData.getInt(CHIP_LABEL_METADATA_KEY); - name = pkgRes.getString(nameResId); - final int resId = metaData.getInt(CHIP_ICON_METADATA_KEY); - icon = pkgRes.getDrawable(resId); - } catch (NameNotFoundException | Resources.NotFoundException ex) { /* ignore */ } - } - if (TextUtils.isEmpty(name)) { - name = ri.loadLabel(context.getPackageManager()); - } - if (icon == null) { - icon = ri.loadIcon(context.getPackageManager()); - } - - final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( - originalIntent, ri, name, "", resolveIntent, null); - dri.getDisplayIconHolder().setDisplayIcon(icon); - return dri; - } - - private static Runnable makeOnNearbyShareRunnable( - TargetInfo nearbyShareTarget, - ActionActivityStarter activityStarter, - Consumer finishCallback, - ChooserActivityLogger logger) { - return () -> { - logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_NEARBY); - // Action bar is user-independent; always start as primary. - activityStarter.safelyStartActivityAsPersonalProfileUser(nearbyShareTarget); - }; - } - @Nullable private static ActionRow.Action createCustomAction( Context context, diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 404d6da3..ab2ba91e 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -239,7 +239,6 @@ public class ChooserActivity extends ResolverActivity implements getIntent(), getReferrerPackageName(), getReferrer(), - mIntegratedDeviceComponents, mFeatureFlagRepository); } catch (IllegalArgumentException e) { Log.e(TAG, "Caller provided invalid Chooser request parameters", e); @@ -1261,7 +1260,8 @@ public class ChooserActivity extends ResolverActivity implements AbstractResolverComparator resolverComparator; if (appPredictor != null) { resolverComparator = new AppPredictionServiceResolverComparator(this, getTargetIntent(), - getReferrerPackageName(), appPredictor, userHandle, getChooserActivityLogger()); + getReferrerPackageName(), appPredictor, userHandle, getChooserActivityLogger(), + getIntegratedDeviceComponents().getNearbySharingComponent()); } else { resolverComparator = new ResolverRankerServiceResolverComparator( @@ -1270,7 +1270,8 @@ public class ChooserActivity extends ResolverActivity implements getReferrerPackageName(), null, getChooserActivityLogger(), - getResolverRankerServiceUserHandleList(userHandle)); + getResolverRankerServiceUserHandleList(userHandle), + getIntegratedDeviceComponents().getNearbySharingComponent()); } return new ChooserListController( diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java index f9004a9b..039f50e9 100644 --- a/java/src/com/android/intentresolver/ChooserRequestParameters.java +++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java @@ -69,7 +69,6 @@ public class ChooserRequestParameters { Intent.FLAG_ACTIVITY_NEW_DOCUMENT | Intent.FLAG_ACTIVITY_MULTIPLE_TASK; private final Intent mTarget; - private final ChooserIntegratedDeviceComponents mIntegratedDeviceComponents; private final String mReferrerPackageName; private final Pair mTitleSpec; private final Intent mReferrerFillInIntent; @@ -104,14 +103,11 @@ public class ChooserRequestParameters { final Intent clientIntent, String referrerPackageName, final Uri referrer, - ChooserIntegratedDeviceComponents integratedDeviceComponents, FeatureFlagRepository featureFlags) { final Intent requestedTarget = parseTargetIntentExtra( clientIntent.getParcelableExtra(Intent.EXTRA_INTENT)); mTarget = intentWithModifiedLaunchFlags(requestedTarget); - mIntegratedDeviceComponents = integratedDeviceComponents; - mReferrerPackageName = referrerPackageName; mAdditionalTargets = intentsWithModifiedLaunchFlagsFromExtraIfPresent( @@ -133,8 +129,11 @@ public class ChooserRequestParameters { mRefinementIntentSender = clientIntent.getParcelableExtra( Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER); - mFilteredComponentNames = getFilteredComponentNames( - clientIntent, mIntegratedDeviceComponents.getNearbySharingComponent()); + ComponentName[] filteredComponents = clientIntent.getParcelableArrayExtra( + Intent.EXTRA_EXCLUDE_COMPONENTS, ComponentName.class); + mFilteredComponentNames = filteredComponents != null + ? ImmutableList.copyOf(filteredComponents) + : ImmutableList.of(); mCallerChooserTargets = parseCallerTargetsFromClientIntent(clientIntent); @@ -252,10 +251,6 @@ public class ChooserRequestParameters { return mTargetIntentFilter; } - public ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() { - return mIntegratedDeviceComponents; - } - private static boolean isSendAction(@Nullable String action) { return (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action)); } @@ -309,23 +304,6 @@ public class ChooserRequestParameters { return Pair.create(requestedTitle, defaultTitleRes); } - private static ImmutableList getFilteredComponentNames( - Intent clientIntent, @Nullable ComponentName nearbySharingComponent) { - Stream filteredComponents = streamParcelableArrayExtra( - clientIntent, Intent.EXTRA_EXCLUDE_COMPONENTS, ComponentName.class, true, true); - - if (nearbySharingComponent != null) { - // Exclude Nearby from main list if chip is present, to avoid duplication. - // TODO: we don't have an explicit guarantee that the chip will be displayed just - // because we have a non-null component; that's ultimately determined by the preview - // layout. Maybe we can make that decision further upstream? - filteredComponents = Stream.concat( - filteredComponents, Stream.of(nearbySharingComponent)); - } - - return filteredComponents.collect(toImmutableList()); - } - private static ImmutableList parseCallerTargetsFromClientIntent( Intent clientIntent) { return diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 3b9d2a53..66eae92d 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -871,7 +871,8 @@ public class ResolverActivity extends FragmentActivity implements getReferrerPackageName(), null, null, - getResolverRankerServiceUserHandleList(userHandle)); + getResolverRankerServiceUserHandleList(userHandle), + null); return new ResolverListController( this, mPm, diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index 56027a16..3c2ee343 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -66,10 +66,6 @@ public final class ChooserContentPreviewUi { @Nullable ActionRow.Action createEditButton(); - /** Create an "Share to Nearby" action. */ - @Nullable - ActionRow.Action createNearbyButton(); - /** Create custom actions */ List createCustomActions(); diff --git a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java index e9d65eed..cae9403a 100644 --- a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java @@ -103,21 +103,8 @@ class FileContentPreviewUi extends ContentPreviewUi { final ActionRow actionRow = contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); actionRow.setActions( - createActions( - createFilePreviewActions(), - mActionFactory.createCustomActions())); + createActions(new ArrayList<>(), mActionFactory.createCustomActions())); return contentPreviewLayout; } - - private List createFilePreviewActions() { - List actions = new ArrayList<>(1); - //TODO(b/120417119): - // add action buttonFactory.createCopyButton() - ActionRow.Action action = mActionFactory.createNearbyButton(); - if (action != null) { - actions.add(action); - } - return actions; - } } diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java index ece0c312..44a9e654 100644 --- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java @@ -125,10 +125,6 @@ class TextContentPreviewUi extends ContentPreviewUi { private List createTextPreviewActions() { ArrayList 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/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java index 9ce875c8..ebf9bf11 100644 --- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -152,14 +152,10 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { } private List createImagePreviewActions() { - ArrayList actions = new ArrayList<>(2); + ArrayList actions = new ArrayList<>(1); //TODO: add copy action; - ActionRow.Action action = mActionFactory.createNearbyButton(); - if (action != null) { - actions.add(action); - } if (mFiles.size() == 1 && mTypeClassifier.isImageType(mFiles.get(0).getMimeType())) { - action = mActionFactory.createEditButton(); + ActionRow.Action action = mActionFactory.createEditButton(); if (action != null) { actions.add(action); } diff --git a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java index 7357fde9..bc54e01e 100644 --- a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java +++ b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java @@ -16,6 +16,7 @@ package com.android.intentresolver.model; +import android.annotation.Nullable; import android.app.usage.UsageStatsManager; import android.content.ComponentName; import android.content.Context; @@ -34,8 +35,6 @@ import com.android.intentresolver.ResolvedComponentInfo; import com.android.intentresolver.ResolverActivity; import com.android.intentresolver.chooser.TargetInfo; -import com.google.android.collect.Lists; - import java.text.Collator; import java.util.ArrayList; import java.util.Comparator; @@ -59,6 +58,7 @@ public abstract class AbstractResolverComparator implements Comparator mUsmMap = new HashMap<>(); protected String[] mAnnotations; protected String mContentType; + protected final ComponentName mPromoteToFirst; // True if the current share is a link. private final boolean mHttp; @@ -105,23 +105,6 @@ public abstract class AbstractResolverComparator implements Comparator resolvedActivityUserSpaceList) { + List resolvedActivityUserSpaceList, + @Nullable ComponentName promoteToFirst) { String scheme = intent.getScheme(); mHttp = "http".equals(scheme) || "https".equals(scheme); mContentType = intent.getType(); @@ -147,6 +133,7 @@ public abstract class AbstractResolverComparator implements Comparator mHandler.sendEmptyMessage(RANKER_SERVICE_RESULT), getChooserActivityLogger(), - mUser); + mUser, + mPromoteToFirst); mComparatorModel = buildUpdatedModel(); mResolverRankerService.compute(targets); } else { diff --git a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java index 725212e4..ebaffc36 100644 --- a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java +++ b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java @@ -102,9 +102,10 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom */ public ResolverRankerServiceResolverComparator(Context launchedFromContext, Intent intent, String referrerPackage, Runnable afterCompute, - ChooserActivityLogger chooserActivityLogger, UserHandle targetUserSpace) { + ChooserActivityLogger chooserActivityLogger, UserHandle targetUserSpace, + ComponentName promoteToFirst) { this(launchedFromContext, intent, referrerPackage, afterCompute, chooserActivityLogger, - Lists.newArrayList(targetUserSpace)); + Lists.newArrayList(targetUserSpace), promoteToFirst); } /** @@ -117,8 +118,9 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom */ public ResolverRankerServiceResolverComparator(Context launchedFromContext, Intent intent, String referrerPackage, Runnable afterCompute, - ChooserActivityLogger chooserActivityLogger, List targetUserSpaceList) { - super(launchedFromContext, intent, targetUserSpaceList); + ChooserActivityLogger chooserActivityLogger, List targetUserSpaceList, + @Nullable ComponentName promoteToFirst) { + super(launchedFromContext, intent, targetUserSpaceList, promoteToFirst); mCollator = Collator.getInstance( launchedFromContext.getResources().getConfiguration().locale); mReferrerPackage = referrerPackage; diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt index 7b9a0ce6..a7273a86 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt @@ -53,7 +53,6 @@ class ChooserContentPreviewUiTest { 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 = emptyList() override fun getModifyShareAction(): ActionRow.Action? = null override fun getExcludeSharedTextAction(): Consumer = Consumer {} diff --git a/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java b/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java index 892a2e28..5f0ead7b 100644 --- a/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java +++ b/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java @@ -40,52 +40,82 @@ public class AbstractResolverComparatorTest { @Test public void testPinned() { - ResolvedComponentInfo r1 = new ResolvedComponentInfo( - new ComponentName("package", "class"), new Intent(), new ResolveInfo() - ); + ResolvedComponentInfo r1 = createResolvedComponentInfo( + new ComponentName("package", "class")); r1.setPinned(true); - ResolvedComponentInfo r2 = new ResolvedComponentInfo( - new ComponentName("zackage", "zlass"), new Intent(), new ResolveInfo() - ); + ResolvedComponentInfo r2 = createResolvedComponentInfo( + new ComponentName("zackage", "zlass")); Context context = InstrumentationRegistry.getTargetContext(); - AbstractResolverComparator comparator = getTestComparator(context); + AbstractResolverComparator comparator = getTestComparator(context, null); assertEquals("Pinned ranks over unpinned", -1, comparator.compare(r1, r2)); assertEquals("Unpinned ranks under pinned", 1, comparator.compare(r2, r1)); } - @Test public void testBothPinned() { - ResolveInfo pmInfo1 = new ResolveInfo(); - pmInfo1.activityInfo = new ActivityInfo(); - pmInfo1.activityInfo.packageName = "aaa"; - - ResolvedComponentInfo r1 = new ResolvedComponentInfo( - new ComponentName("package", "class"), new Intent(), pmInfo1); + ResolvedComponentInfo r1 = createResolvedComponentInfo( + new ComponentName("package", "class")); r1.setPinned(true); - ResolveInfo pmInfo2 = new ResolveInfo(); - pmInfo2.activityInfo = new ActivityInfo(); - pmInfo2.activityInfo.packageName = "zzz"; - ResolvedComponentInfo r2 = new ResolvedComponentInfo( - new ComponentName("zackage", "zlass"), new Intent(), pmInfo2); + ResolvedComponentInfo r2 = createResolvedComponentInfo( + new ComponentName("zackage", "zlass")); r2.setPinned(true); Context context = InstrumentationRegistry.getTargetContext(); - AbstractResolverComparator comparator = getTestComparator(context); + AbstractResolverComparator comparator = getTestComparator(context, null); assertEquals("Both pinned should rank alphabetically", -1, comparator.compare(r1, r2)); } - private AbstractResolverComparator getTestComparator(Context context) { + @Test + public void testPromoteToFirst() { + ComponentName promoteToFirst = new ComponentName("promoted-package", "class"); + ResolvedComponentInfo r1 = createResolvedComponentInfo(promoteToFirst); + + ResolvedComponentInfo r2 = createResolvedComponentInfo( + new ComponentName("package", "class")); + + Context context = InstrumentationRegistry.getTargetContext(); + AbstractResolverComparator comparator = getTestComparator(context, promoteToFirst); + + assertEquals("PromoteToFirst ranks over non-cemented", -1, comparator.compare(r1, r2)); + assertEquals("Non-cemented ranks under PromoteToFirst", 1, comparator.compare(r2, r1)); + } + + @Test + public void testPromoteToFirstOverPinned() { + ComponentName cementedComponent = new ComponentName("promoted-package", "class"); + ResolvedComponentInfo r1 = createResolvedComponentInfo(cementedComponent); + + ResolvedComponentInfo r2 = createResolvedComponentInfo( + new ComponentName("package", "class")); + r2.setPinned(true); + + Context context = InstrumentationRegistry.getTargetContext(); + AbstractResolverComparator comparator = getTestComparator(context, cementedComponent); + + assertEquals("PromoteToFirst ranks over pinned", -1, comparator.compare(r1, r2)); + assertEquals("Pinned ranks under PromoteToFirst", 1, comparator.compare(r2, r1)); + } + + private ResolvedComponentInfo createResolvedComponentInfo(ComponentName component) { + ResolveInfo info = new ResolveInfo(); + info.activityInfo = new ActivityInfo(); + info.activityInfo.packageName = component.getPackageName(); + info.activityInfo.name = component.getClassName(); + return new ResolvedComponentInfo(component, new Intent(), info); + } + + private AbstractResolverComparator getTestComparator( + Context context, ComponentName promoteToFirst) { Intent intent = new Intent(); AbstractResolverComparator testComparator = new AbstractResolverComparator(context, intent, - Lists.newArrayList(context.getUser())) { + Lists.newArrayList(context.getUser()), promoteToFirst) { @Override int compare(ResolveInfo lhs, ResolveInfo rhs) { -- cgit v1.2.3-59-g8ed1b From 2783dd19f19bcbb523fa15e2d8154174f0978479 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Thu, 13 Apr 2023 18:45:10 -0700 Subject: Constraint image preivew width by Chooser width Limit preivew aspect ratio so they are never wider than Sharesheet. Bug: 277629860 Test: manual testing Test: integration test Change-Id: I501ffae9287eb524b466d6f654d4d604f97e1883 --- java/res/layout/chooser_grid_preview_image.xml | 3 +- .../widget/ScrollableImagePreviewView.kt | 70 +++++++++++++++------- .../UnbundledChooserActivityTest.java | 31 ++++++++-- 3 files changed, 76 insertions(+), 28 deletions(-) (limited to 'java/tests') diff --git a/java/res/layout/chooser_grid_preview_image.xml b/java/res/layout/chooser_grid_preview_image.xml index 68b07846..b87cecda 100644 --- a/java/res/layout/chooser_grid_preview_image.xml +++ b/java/res/layout/chooser_grid_preview_image.xml @@ -43,8 +43,7 @@ android:layout_gravity="center_horizontal" android:background="?android:attr/colorBackground" app:itemInnerSpacing="3dp" - app:itemOuterSpacing="@dimen/chooser_edge_margin_normal_half" - app:maxWidthHint="@dimen/chooser_width" /> + app:itemOuterSpacing="@dimen/chooser_edge_margin_normal_half" /> 0) return + if (View.MeasureSpec.getMode(widthSpec) != View.MeasureSpec.UNSPECIFIED) { + maxWidthHint = View.MeasureSpec.getSize(widthSpec) } } @@ -146,7 +158,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { } .apply { if (isMeasured) { - loadAspectRatios(getMaxWidth(), this@ScrollableImagePreviewView::calcPreviewWidth) + loadAspectRatios(getMaxWidth(), this@ScrollableImagePreviewView::updatePreviewSize) } } } @@ -160,14 +172,39 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { else -> measuredWidth } - private fun calcPreviewWidth(bitmap: Bitmap): Int { + private fun updateMaxAspectRatio() { + val padding = outerSpacing * 2 + val w = maxOf(padding, getMaxWidth() - padding) + val h = if (isLaidOut) height else measuredHeight + if (w > 0 && h > 0) { + maxAspectRatio = (w.toFloat() / h.toFloat()) + .coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO) + maxAspectRatioString = when { + maxAspectRatio <= MIN_ASPECT_RATIO -> MIN_ASPECT_RATIO_STRING + maxAspectRatio >= MAX_ASPECT_RATIO -> MAX_ASPECT_RATIO_STRING + else -> "$w:$h" + } + } + } + + /** + * Sets [preview]'s aspect ratio based on the preview image size. + * @return adjusted preview width + */ + private fun updatePreviewSize(preview: Preview, width: Int, height: Int): Int { val effectiveHeight = if (isLaidOut) height else measuredHeight - return if (bitmap.width <= 0 || bitmap.height <= 0) { + return if (width <= 0 || height <= 0) { + preview.aspectRatioString = "1:1" effectiveHeight } else { - val ar = (bitmap.width.toFloat() / bitmap.height.toFloat()) - .coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO) - (effectiveHeight * ar).roundToInt() + val aspectRatio = (width.toFloat() / height.toFloat()) + .coerceIn(MIN_ASPECT_RATIO, maxAspectRatio) + preview.aspectRatioString = when { + aspectRatio <= MIN_ASPECT_RATIO -> MIN_ASPECT_RATIO_STRING + aspectRatio >= maxAspectRatio -> maxAspectRatioString + else -> "$width:$height" + } + (effectiveHeight * aspectRatio).toInt() } } @@ -177,16 +214,6 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { internal var aspectRatioString: String ) { constructor(type: PreviewType, uri: Uri) : this(type, uri, "1:1") - - internal fun updateAspectRatio(width: Int, height: Int) { - if (width <= 0 || height <= 0) return - val aspectRatio = width.toFloat() / height.toFloat() - aspectRatioString = when { - aspectRatio <= MIN_ASPECT_RATIO -> MIN_ASPECT_RATIO_STRING - aspectRatio >= MAX_ASPECT_RATIO -> MAX_ASPECT_RATIO_STRING - else -> "$width:$height" - } - } } enum class PreviewType { @@ -398,7 +425,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { scope = null } - fun loadAspectRatios(maxWidth: Int, previewWidthCalculator: (Bitmap) -> Int) { + fun loadAspectRatios(maxWidth: Int, previewSizeUpdater: (Preview, Int, Int) -> Int) { val scope = this.scope ?: return val updates = ArrayDeque(pendingPreviews.size) // replay 2 items to guarantee that we'd get at least one update @@ -440,10 +467,11 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { // TODO: decide on adding a timeout imageLoader(preview.uri, isVisible) }.getOrNull() ?: continue - preview.updateAspectRatio(bitmap.width, bitmap.height) + val previewWidth = + previewSizeUpdater(preview, bitmap.width, bitmap.height) updates.add(preview) if (isVisible) { - loadedPreviewWidth += previewWidthCalculator(bitmap) + loadedPreviewWidth += previewWidth if (loadedPreviewWidth >= maxWidth) { // notify that the preview now can be displayed reportFlow.emit(updateEvent) diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index de5498db..acced89f 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -81,6 +81,7 @@ import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; +import android.graphics.Rect; import android.graphics.drawable.Icon; import android.net.Uri; import android.os.Bundle; @@ -92,6 +93,7 @@ import android.util.HashedStringCache; import android.util.Pair; import android.util.SparseArray; import android.view.View; +import android.view.WindowManager; import androidx.annotation.CallSuper; import androidx.annotation.NonNull; @@ -959,7 +961,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendUriIntentWithPreview(uris); ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); + createImageLoader(uri, createWideBitmap()); ChooserActivityOverrideData.getInstance().isImageType = true; List resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -975,9 +977,14 @@ public class UnbundledChooserActivityTest { RecyclerView recyclerView = (RecyclerView) view; assertThat(recyclerView.getAdapter().getItemCount(), is(1)); assertThat(recyclerView.getChildCount(), is(1)); + View imageView = recyclerView.getChildAt(0); + Rect rect = new Rect(); + boolean isPartiallyVisible = imageView.getGlobalVisibleRect(rect); assertThat( - "image preview view is fully visible", - isDisplayed().matches(recyclerView.getChildAt(0))); + "image preview view is not fully visible", + isPartiallyVisible + && rect.width() == imageView.getWidth() + && rect.height() == imageView.getHeight()); }); } @@ -2785,8 +2792,22 @@ public class UnbundledChooserActivityTest { } private Bitmap createBitmap() { - int width = 200; - int height = 200; + return createBitmap(200, 200); + } + + private Bitmap createWideBitmap() { + WindowManager windowManager = InstrumentationRegistry.getInstrumentation() + .getTargetContext() + .getSystemService(WindowManager.class); + int width = 3000; + if (windowManager != null) { + Rect bounds = windowManager.getMaximumWindowMetrics().getBounds(); + width = bounds.width() + 200; + } + return createBitmap(width, 100); + } + + private Bitmap createBitmap(int width, int height) { Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); Canvas canvas = new Canvas(bitmap); -- cgit v1.2.3-59-g8ed1b From 7de412dce7e98131740b281e87641ef0f792d6b1 Mon Sep 17 00:00:00 2001 From: Ravindra Nallabilli Date: Wed, 19 Apr 2023 02:31:52 +0000 Subject: Revert "Move Nearby to the front of the app list." This reverts commit cd42caeb00aa0bdd33f565816bdbfb46ccbbad24. Reason for revert: ACA session : https://android-build.googleplex.com/builds/culprit-assistant/run/4189ac3b-b57c-43f8-acb6-3467d8140574 Bug id: b/278790605 Change-Id: I3398de7f7699a129f47cb047ce206493931aa00e --- .../intentresolver/ChooserActionFactory.java | 94 ++++++++++++++++++++++ .../android/intentresolver/ChooserActivity.java | 7 +- .../intentresolver/ChooserRequestParameters.java | 32 ++++++-- .../android/intentresolver/ResolverActivity.java | 3 +- .../contentpreview/ChooserContentPreviewUi.java | 4 + .../contentpreview/FileContentPreviewUi.java | 15 +++- .../contentpreview/TextContentPreviewUi.java | 4 + .../contentpreview/UnifiedContentPreviewUi.java | 8 +- .../model/AbstractResolverComparator.java | 37 +++++---- .../AppPredictionServiceResolverComparator.java | 8 +- .../ResolverRankerServiceResolverComparator.java | 10 +-- .../contentpreview/ChooserContentPreviewUiTest.kt | 1 + .../model/AbstractResolverComparatorTest.java | 74 +++++------------ 13 files changed, 203 insertions(+), 94 deletions(-) (limited to 'java/tests') diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java index f355d9d4..23e04560 100644 --- a/java/src/com/android/intentresolver/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/ChooserActionFactory.java @@ -26,9 +26,12 @@ import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.ResolveInfo; +import android.content.res.Resources; import android.graphics.drawable.Drawable; import android.net.Uri; +import android.os.Bundle; import android.service.chooser.ChooserAction; import android.text.TextUtils; import android.util.Log; @@ -89,6 +92,8 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio private final Runnable mOnCopyButtonClicked; private final TargetInfo mEditSharingTarget; private final Runnable mOnEditButtonClicked; + private final TargetInfo mNearbySharingTarget; + private final Runnable mOnNearbyButtonClicked; private final ImmutableList mCustomActions; private final @Nullable ChooserAction mModifyShareAction; private final Consumer mExcludeSharedTextAction; @@ -139,6 +144,18 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio firstVisibleImageQuery, activityStarter, logger), + getNearbySharingTarget( + context, + chooserRequest.getTargetIntent(), + integratedDeviceComponents), + makeOnNearbyShareRunnable( + getNearbySharingTarget( + context, + chooserRequest.getTargetIntent(), + integratedDeviceComponents), + activityStarter, + finishCallback, + logger), chooserRequest.getChooserActions(), chooserRequest.getModifyShareAction(), onUpdateSharedTextIsExcluded, @@ -154,6 +171,8 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio Runnable onCopyButtonClicked, TargetInfo editSharingTarget, Runnable onEditButtonClicked, + TargetInfo nearbySharingTarget, + Runnable onNearbyButtonClicked, List customActions, @Nullable ChooserAction modifyShareAction, Consumer onUpdateSharedTextIsExcluded, @@ -165,6 +184,8 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio mOnCopyButtonClicked = onCopyButtonClicked; mEditSharingTarget = editSharingTarget; mOnEditButtonClicked = onEditButtonClicked; + mNearbySharingTarget = nearbySharingTarget; + mOnNearbyButtonClicked = onNearbyButtonClicked; mCustomActions = ImmutableList.copyOf(customActions); mModifyShareAction = modifyShareAction; mExcludeSharedTextAction = onUpdateSharedTextIsExcluded; @@ -197,6 +218,21 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio mOnEditButtonClicked); } + /** Create a "Share to Nearby" action. */ + @Override + @Nullable + public ActionRow.Action createNearbyButton() { + if (mNearbySharingTarget == null) { + return null; + } + + return new ActionRow.Action( + com.android.internal.R.id.chooser_nearby_button, + mNearbySharingTarget.getDisplayLabel(), + mNearbySharingTarget.getDisplayIconHolder().getDisplayIcon(), + mOnNearbyButtonClicked); + } + /** Create custom actions */ @Override public List createCustomActions() { @@ -367,6 +403,64 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio }; } + private static TargetInfo getNearbySharingTarget( + Context context, + Intent originalIntent, + ChooserIntegratedDeviceComponents integratedComponents) { + final ComponentName cn = integratedComponents.getNearbySharingComponent(); + if (cn == null) { + return null; + } + + final Intent resolveIntent = new Intent(originalIntent); + resolveIntent.setComponent(cn); + final ResolveInfo ri = context.getPackageManager().resolveActivity( + resolveIntent, PackageManager.GET_META_DATA); + if (ri == null || ri.activityInfo == null) { + Log.e(TAG, "Device-specified nearby sharing component (" + cn + + ") not available"); + return null; + } + + // Allow the nearby sharing component to provide a more appropriate icon and label + // for the chip. + CharSequence name = null; + Drawable icon = null; + final Bundle metaData = ri.activityInfo.metaData; + if (metaData != null) { + try { + final Resources pkgRes = context.getPackageManager().getResourcesForActivity(cn); + final int nameResId = metaData.getInt(CHIP_LABEL_METADATA_KEY); + name = pkgRes.getString(nameResId); + final int resId = metaData.getInt(CHIP_ICON_METADATA_KEY); + icon = pkgRes.getDrawable(resId); + } catch (NameNotFoundException | Resources.NotFoundException ex) { /* ignore */ } + } + if (TextUtils.isEmpty(name)) { + name = ri.loadLabel(context.getPackageManager()); + } + if (icon == null) { + icon = ri.loadIcon(context.getPackageManager()); + } + + final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( + originalIntent, ri, name, "", resolveIntent, null); + dri.getDisplayIconHolder().setDisplayIcon(icon); + return dri; + } + + private static Runnable makeOnNearbyShareRunnable( + TargetInfo nearbyShareTarget, + ActionActivityStarter activityStarter, + Consumer finishCallback, + ChooserActivityLogger logger) { + return () -> { + logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_NEARBY); + // Action bar is user-independent; always start as primary. + activityStarter.safelyStartActivityAsPersonalProfileUser(nearbyShareTarget); + }; + } + @Nullable private static ActionRow.Action createCustomAction( Context context, diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index ab2ba91e..404d6da3 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -239,6 +239,7 @@ public class ChooserActivity extends ResolverActivity implements getIntent(), getReferrerPackageName(), getReferrer(), + mIntegratedDeviceComponents, mFeatureFlagRepository); } catch (IllegalArgumentException e) { Log.e(TAG, "Caller provided invalid Chooser request parameters", e); @@ -1260,8 +1261,7 @@ public class ChooserActivity extends ResolverActivity implements AbstractResolverComparator resolverComparator; if (appPredictor != null) { resolverComparator = new AppPredictionServiceResolverComparator(this, getTargetIntent(), - getReferrerPackageName(), appPredictor, userHandle, getChooserActivityLogger(), - getIntegratedDeviceComponents().getNearbySharingComponent()); + getReferrerPackageName(), appPredictor, userHandle, getChooserActivityLogger()); } else { resolverComparator = new ResolverRankerServiceResolverComparator( @@ -1270,8 +1270,7 @@ public class ChooserActivity extends ResolverActivity implements getReferrerPackageName(), null, getChooserActivityLogger(), - getResolverRankerServiceUserHandleList(userHandle), - getIntegratedDeviceComponents().getNearbySharingComponent()); + getResolverRankerServiceUserHandleList(userHandle)); } return new ChooserListController( diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java index 039f50e9..f9004a9b 100644 --- a/java/src/com/android/intentresolver/ChooserRequestParameters.java +++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java @@ -69,6 +69,7 @@ public class ChooserRequestParameters { Intent.FLAG_ACTIVITY_NEW_DOCUMENT | Intent.FLAG_ACTIVITY_MULTIPLE_TASK; private final Intent mTarget; + private final ChooserIntegratedDeviceComponents mIntegratedDeviceComponents; private final String mReferrerPackageName; private final Pair mTitleSpec; private final Intent mReferrerFillInIntent; @@ -103,11 +104,14 @@ public class ChooserRequestParameters { final Intent clientIntent, String referrerPackageName, final Uri referrer, + ChooserIntegratedDeviceComponents integratedDeviceComponents, FeatureFlagRepository featureFlags) { final Intent requestedTarget = parseTargetIntentExtra( clientIntent.getParcelableExtra(Intent.EXTRA_INTENT)); mTarget = intentWithModifiedLaunchFlags(requestedTarget); + mIntegratedDeviceComponents = integratedDeviceComponents; + mReferrerPackageName = referrerPackageName; mAdditionalTargets = intentsWithModifiedLaunchFlagsFromExtraIfPresent( @@ -129,11 +133,8 @@ public class ChooserRequestParameters { mRefinementIntentSender = clientIntent.getParcelableExtra( Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER); - ComponentName[] filteredComponents = clientIntent.getParcelableArrayExtra( - Intent.EXTRA_EXCLUDE_COMPONENTS, ComponentName.class); - mFilteredComponentNames = filteredComponents != null - ? ImmutableList.copyOf(filteredComponents) - : ImmutableList.of(); + mFilteredComponentNames = getFilteredComponentNames( + clientIntent, mIntegratedDeviceComponents.getNearbySharingComponent()); mCallerChooserTargets = parseCallerTargetsFromClientIntent(clientIntent); @@ -251,6 +252,10 @@ public class ChooserRequestParameters { return mTargetIntentFilter; } + public ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() { + return mIntegratedDeviceComponents; + } + private static boolean isSendAction(@Nullable String action) { return (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action)); } @@ -304,6 +309,23 @@ public class ChooserRequestParameters { return Pair.create(requestedTitle, defaultTitleRes); } + private static ImmutableList getFilteredComponentNames( + Intent clientIntent, @Nullable ComponentName nearbySharingComponent) { + Stream filteredComponents = streamParcelableArrayExtra( + clientIntent, Intent.EXTRA_EXCLUDE_COMPONENTS, ComponentName.class, true, true); + + if (nearbySharingComponent != null) { + // Exclude Nearby from main list if chip is present, to avoid duplication. + // TODO: we don't have an explicit guarantee that the chip will be displayed just + // because we have a non-null component; that's ultimately determined by the preview + // layout. Maybe we can make that decision further upstream? + filteredComponents = Stream.concat( + filteredComponents, Stream.of(nearbySharingComponent)); + } + + return filteredComponents.collect(toImmutableList()); + } + private static ImmutableList parseCallerTargetsFromClientIntent( Intent clientIntent) { return diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 66eae92d..3b9d2a53 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -871,8 +871,7 @@ public class ResolverActivity extends FragmentActivity implements getReferrerPackageName(), null, null, - getResolverRankerServiceUserHandleList(userHandle), - null); + getResolverRankerServiceUserHandleList(userHandle)); return new ResolverListController( this, mPm, diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index 3c2ee343..56027a16 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -66,6 +66,10 @@ public final class ChooserContentPreviewUi { @Nullable ActionRow.Action createEditButton(); + /** Create an "Share to Nearby" action. */ + @Nullable + ActionRow.Action createNearbyButton(); + /** Create custom actions */ List createCustomActions(); diff --git a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java index cae9403a..e9d65eed 100644 --- a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java @@ -103,8 +103,21 @@ class FileContentPreviewUi extends ContentPreviewUi { final ActionRow actionRow = contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); actionRow.setActions( - createActions(new ArrayList<>(), mActionFactory.createCustomActions())); + createActions( + createFilePreviewActions(), + mActionFactory.createCustomActions())); return contentPreviewLayout; } + + private List createFilePreviewActions() { + List actions = new ArrayList<>(1); + //TODO(b/120417119): + // add action buttonFactory.createCopyButton() + ActionRow.Action action = mActionFactory.createNearbyButton(); + if (action != null) { + actions.add(action); + } + return actions; + } } diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java index 44a9e654..ece0c312 100644 --- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java @@ -125,6 +125,10 @@ class TextContentPreviewUi extends ContentPreviewUi { private List createTextPreviewActions() { ArrayList 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/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java index ebf9bf11..9ce875c8 100644 --- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -152,10 +152,14 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { } private List createImagePreviewActions() { - ArrayList actions = new ArrayList<>(1); + ArrayList actions = new ArrayList<>(2); //TODO: add copy action; + ActionRow.Action action = mActionFactory.createNearbyButton(); + if (action != null) { + actions.add(action); + } if (mFiles.size() == 1 && mTypeClassifier.isImageType(mFiles.get(0).getMimeType())) { - ActionRow.Action action = mActionFactory.createEditButton(); + action = mActionFactory.createEditButton(); if (action != null) { actions.add(action); } diff --git a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java index bc54e01e..7357fde9 100644 --- a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java +++ b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java @@ -16,7 +16,6 @@ package com.android.intentresolver.model; -import android.annotation.Nullable; import android.app.usage.UsageStatsManager; import android.content.ComponentName; import android.content.Context; @@ -35,6 +34,8 @@ import com.android.intentresolver.ResolvedComponentInfo; import com.android.intentresolver.ResolverActivity; import com.android.intentresolver.chooser.TargetInfo; +import com.google.android.collect.Lists; + import java.text.Collator; import java.util.ArrayList; import java.util.Comparator; @@ -58,7 +59,6 @@ public abstract class AbstractResolverComparator implements Comparator mUsmMap = new HashMap<>(); protected String[] mAnnotations; protected String mContentType; - protected final ComponentName mPromoteToFirst; // True if the current share is a link. private final boolean mHttp; @@ -105,6 +105,23 @@ public abstract class AbstractResolverComparator implements Comparator resolvedActivityUserSpaceList, - @Nullable ComponentName promoteToFirst) { + List resolvedActivityUserSpaceList) { String scheme = intent.getScheme(); mHttp = "http".equals(scheme) || "https".equals(scheme); mContentType = intent.getType(); @@ -133,7 +147,6 @@ public abstract class AbstractResolverComparator implements Comparator mHandler.sendEmptyMessage(RANKER_SERVICE_RESULT), getChooserActivityLogger(), - mUser, - mPromoteToFirst); + mUser); mComparatorModel = buildUpdatedModel(); mResolverRankerService.compute(targets); } else { diff --git a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java index ebaffc36..725212e4 100644 --- a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java +++ b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java @@ -102,10 +102,9 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom */ public ResolverRankerServiceResolverComparator(Context launchedFromContext, Intent intent, String referrerPackage, Runnable afterCompute, - ChooserActivityLogger chooserActivityLogger, UserHandle targetUserSpace, - ComponentName promoteToFirst) { + ChooserActivityLogger chooserActivityLogger, UserHandle targetUserSpace) { this(launchedFromContext, intent, referrerPackage, afterCompute, chooserActivityLogger, - Lists.newArrayList(targetUserSpace), promoteToFirst); + Lists.newArrayList(targetUserSpace)); } /** @@ -118,9 +117,8 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom */ public ResolverRankerServiceResolverComparator(Context launchedFromContext, Intent intent, String referrerPackage, Runnable afterCompute, - ChooserActivityLogger chooserActivityLogger, List targetUserSpaceList, - @Nullable ComponentName promoteToFirst) { - super(launchedFromContext, intent, targetUserSpaceList, promoteToFirst); + ChooserActivityLogger chooserActivityLogger, List targetUserSpaceList) { + super(launchedFromContext, intent, targetUserSpaceList); mCollator = Collator.getInstance( launchedFromContext.getResources().getConfiguration().locale); mReferrerPackage = referrerPackage; diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt index a7273a86..7b9a0ce6 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt @@ -53,6 +53,7 @@ class ChooserContentPreviewUiTest { 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 = emptyList() override fun getModifyShareAction(): ActionRow.Action? = null override fun getExcludeSharedTextAction(): Consumer = Consumer {} diff --git a/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java b/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java index 5f0ead7b..892a2e28 100644 --- a/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java +++ b/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java @@ -40,82 +40,52 @@ public class AbstractResolverComparatorTest { @Test public void testPinned() { - ResolvedComponentInfo r1 = createResolvedComponentInfo( - new ComponentName("package", "class")); + ResolvedComponentInfo r1 = new ResolvedComponentInfo( + new ComponentName("package", "class"), new Intent(), new ResolveInfo() + ); r1.setPinned(true); - ResolvedComponentInfo r2 = createResolvedComponentInfo( - new ComponentName("zackage", "zlass")); + ResolvedComponentInfo r2 = new ResolvedComponentInfo( + new ComponentName("zackage", "zlass"), new Intent(), new ResolveInfo() + ); Context context = InstrumentationRegistry.getTargetContext(); - AbstractResolverComparator comparator = getTestComparator(context, null); + AbstractResolverComparator comparator = getTestComparator(context); assertEquals("Pinned ranks over unpinned", -1, comparator.compare(r1, r2)); assertEquals("Unpinned ranks under pinned", 1, comparator.compare(r2, r1)); } + @Test public void testBothPinned() { - ResolvedComponentInfo r1 = createResolvedComponentInfo( - new ComponentName("package", "class")); + ResolveInfo pmInfo1 = new ResolveInfo(); + pmInfo1.activityInfo = new ActivityInfo(); + pmInfo1.activityInfo.packageName = "aaa"; + + ResolvedComponentInfo r1 = new ResolvedComponentInfo( + new ComponentName("package", "class"), new Intent(), pmInfo1); r1.setPinned(true); - ResolvedComponentInfo r2 = createResolvedComponentInfo( - new ComponentName("zackage", "zlass")); + ResolveInfo pmInfo2 = new ResolveInfo(); + pmInfo2.activityInfo = new ActivityInfo(); + pmInfo2.activityInfo.packageName = "zzz"; + ResolvedComponentInfo r2 = new ResolvedComponentInfo( + new ComponentName("zackage", "zlass"), new Intent(), pmInfo2); r2.setPinned(true); Context context = InstrumentationRegistry.getTargetContext(); - AbstractResolverComparator comparator = getTestComparator(context, null); + AbstractResolverComparator comparator = getTestComparator(context); assertEquals("Both pinned should rank alphabetically", -1, comparator.compare(r1, r2)); } - @Test - public void testPromoteToFirst() { - ComponentName promoteToFirst = new ComponentName("promoted-package", "class"); - ResolvedComponentInfo r1 = createResolvedComponentInfo(promoteToFirst); - - ResolvedComponentInfo r2 = createResolvedComponentInfo( - new ComponentName("package", "class")); - - Context context = InstrumentationRegistry.getTargetContext(); - AbstractResolverComparator comparator = getTestComparator(context, promoteToFirst); - - assertEquals("PromoteToFirst ranks over non-cemented", -1, comparator.compare(r1, r2)); - assertEquals("Non-cemented ranks under PromoteToFirst", 1, comparator.compare(r2, r1)); - } - - @Test - public void testPromoteToFirstOverPinned() { - ComponentName cementedComponent = new ComponentName("promoted-package", "class"); - ResolvedComponentInfo r1 = createResolvedComponentInfo(cementedComponent); - - ResolvedComponentInfo r2 = createResolvedComponentInfo( - new ComponentName("package", "class")); - r2.setPinned(true); - - Context context = InstrumentationRegistry.getTargetContext(); - AbstractResolverComparator comparator = getTestComparator(context, cementedComponent); - - assertEquals("PromoteToFirst ranks over pinned", -1, comparator.compare(r1, r2)); - assertEquals("Pinned ranks under PromoteToFirst", 1, comparator.compare(r2, r1)); - } - - private ResolvedComponentInfo createResolvedComponentInfo(ComponentName component) { - ResolveInfo info = new ResolveInfo(); - info.activityInfo = new ActivityInfo(); - info.activityInfo.packageName = component.getPackageName(); - info.activityInfo.name = component.getClassName(); - return new ResolvedComponentInfo(component, new Intent(), info); - } - - private AbstractResolverComparator getTestComparator( - Context context, ComponentName promoteToFirst) { + private AbstractResolverComparator getTestComparator(Context context) { Intent intent = new Intent(); AbstractResolverComparator testComparator = new AbstractResolverComparator(context, intent, - Lists.newArrayList(context.getUser()), promoteToFirst) { + Lists.newArrayList(context.getUser())) { @Override int compare(ResolveInfo lhs, ResolveInfo rhs) { -- cgit v1.2.3-59-g8ed1b From 7da4cd780a8cad1c84426efdd470f25ba73908ce Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Thu, 20 Apr 2023 03:57:19 +0000 Subject: Revert "Revert "Move Nearby to the front of the app list."" This reverts commit 7de412dce7e98131740b281e87641ef0f792d6b1. Reason for revert: Reinstating change with test fix in ag/22747737 Change-Id: I9b60fc1a4a74e0ad8dcc687bded5e1b27650ef93 --- .../intentresolver/ChooserActionFactory.java | 94 ---------------------- .../android/intentresolver/ChooserActivity.java | 7 +- .../intentresolver/ChooserRequestParameters.java | 32 ++------ .../android/intentresolver/ResolverActivity.java | 3 +- .../contentpreview/ChooserContentPreviewUi.java | 4 - .../contentpreview/FileContentPreviewUi.java | 15 +--- .../contentpreview/TextContentPreviewUi.java | 4 - .../contentpreview/UnifiedContentPreviewUi.java | 8 +- .../model/AbstractResolverComparator.java | 37 ++++----- .../AppPredictionServiceResolverComparator.java | 8 +- .../ResolverRankerServiceResolverComparator.java | 10 ++- .../contentpreview/ChooserContentPreviewUiTest.kt | 1 - .../model/AbstractResolverComparatorTest.java | 74 ++++++++++++----- 13 files changed, 94 insertions(+), 203 deletions(-) (limited to 'java/tests') diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java index 23e04560..f355d9d4 100644 --- a/java/src/com/android/intentresolver/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/ChooserActionFactory.java @@ -26,12 +26,9 @@ import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; -import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.ResolveInfo; -import android.content.res.Resources; import android.graphics.drawable.Drawable; import android.net.Uri; -import android.os.Bundle; import android.service.chooser.ChooserAction; import android.text.TextUtils; import android.util.Log; @@ -92,8 +89,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio private final Runnable mOnCopyButtonClicked; private final TargetInfo mEditSharingTarget; private final Runnable mOnEditButtonClicked; - private final TargetInfo mNearbySharingTarget; - private final Runnable mOnNearbyButtonClicked; private final ImmutableList mCustomActions; private final @Nullable ChooserAction mModifyShareAction; private final Consumer mExcludeSharedTextAction; @@ -144,18 +139,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio firstVisibleImageQuery, activityStarter, logger), - getNearbySharingTarget( - context, - chooserRequest.getTargetIntent(), - integratedDeviceComponents), - makeOnNearbyShareRunnable( - getNearbySharingTarget( - context, - chooserRequest.getTargetIntent(), - integratedDeviceComponents), - activityStarter, - finishCallback, - logger), chooserRequest.getChooserActions(), chooserRequest.getModifyShareAction(), onUpdateSharedTextIsExcluded, @@ -171,8 +154,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio Runnable onCopyButtonClicked, TargetInfo editSharingTarget, Runnable onEditButtonClicked, - TargetInfo nearbySharingTarget, - Runnable onNearbyButtonClicked, List customActions, @Nullable ChooserAction modifyShareAction, Consumer onUpdateSharedTextIsExcluded, @@ -184,8 +165,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio mOnCopyButtonClicked = onCopyButtonClicked; mEditSharingTarget = editSharingTarget; mOnEditButtonClicked = onEditButtonClicked; - mNearbySharingTarget = nearbySharingTarget; - mOnNearbyButtonClicked = onNearbyButtonClicked; mCustomActions = ImmutableList.copyOf(customActions); mModifyShareAction = modifyShareAction; mExcludeSharedTextAction = onUpdateSharedTextIsExcluded; @@ -218,21 +197,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio mOnEditButtonClicked); } - /** Create a "Share to Nearby" action. */ - @Override - @Nullable - public ActionRow.Action createNearbyButton() { - if (mNearbySharingTarget == null) { - return null; - } - - return new ActionRow.Action( - com.android.internal.R.id.chooser_nearby_button, - mNearbySharingTarget.getDisplayLabel(), - mNearbySharingTarget.getDisplayIconHolder().getDisplayIcon(), - mOnNearbyButtonClicked); - } - /** Create custom actions */ @Override public List createCustomActions() { @@ -403,64 +367,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio }; } - private static TargetInfo getNearbySharingTarget( - Context context, - Intent originalIntent, - ChooserIntegratedDeviceComponents integratedComponents) { - final ComponentName cn = integratedComponents.getNearbySharingComponent(); - if (cn == null) { - return null; - } - - final Intent resolveIntent = new Intent(originalIntent); - resolveIntent.setComponent(cn); - final ResolveInfo ri = context.getPackageManager().resolveActivity( - resolveIntent, PackageManager.GET_META_DATA); - if (ri == null || ri.activityInfo == null) { - Log.e(TAG, "Device-specified nearby sharing component (" + cn - + ") not available"); - return null; - } - - // Allow the nearby sharing component to provide a more appropriate icon and label - // for the chip. - CharSequence name = null; - Drawable icon = null; - final Bundle metaData = ri.activityInfo.metaData; - if (metaData != null) { - try { - final Resources pkgRes = context.getPackageManager().getResourcesForActivity(cn); - final int nameResId = metaData.getInt(CHIP_LABEL_METADATA_KEY); - name = pkgRes.getString(nameResId); - final int resId = metaData.getInt(CHIP_ICON_METADATA_KEY); - icon = pkgRes.getDrawable(resId); - } catch (NameNotFoundException | Resources.NotFoundException ex) { /* ignore */ } - } - if (TextUtils.isEmpty(name)) { - name = ri.loadLabel(context.getPackageManager()); - } - if (icon == null) { - icon = ri.loadIcon(context.getPackageManager()); - } - - final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( - originalIntent, ri, name, "", resolveIntent, null); - dri.getDisplayIconHolder().setDisplayIcon(icon); - return dri; - } - - private static Runnable makeOnNearbyShareRunnable( - TargetInfo nearbyShareTarget, - ActionActivityStarter activityStarter, - Consumer finishCallback, - ChooserActivityLogger logger) { - return () -> { - logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_NEARBY); - // Action bar is user-independent; always start as primary. - activityStarter.safelyStartActivityAsPersonalProfileUser(nearbyShareTarget); - }; - } - @Nullable private static ActionRow.Action createCustomAction( Context context, diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 404d6da3..ab2ba91e 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -239,7 +239,6 @@ public class ChooserActivity extends ResolverActivity implements getIntent(), getReferrerPackageName(), getReferrer(), - mIntegratedDeviceComponents, mFeatureFlagRepository); } catch (IllegalArgumentException e) { Log.e(TAG, "Caller provided invalid Chooser request parameters", e); @@ -1261,7 +1260,8 @@ public class ChooserActivity extends ResolverActivity implements AbstractResolverComparator resolverComparator; if (appPredictor != null) { resolverComparator = new AppPredictionServiceResolverComparator(this, getTargetIntent(), - getReferrerPackageName(), appPredictor, userHandle, getChooserActivityLogger()); + getReferrerPackageName(), appPredictor, userHandle, getChooserActivityLogger(), + getIntegratedDeviceComponents().getNearbySharingComponent()); } else { resolverComparator = new ResolverRankerServiceResolverComparator( @@ -1270,7 +1270,8 @@ public class ChooserActivity extends ResolverActivity implements getReferrerPackageName(), null, getChooserActivityLogger(), - getResolverRankerServiceUserHandleList(userHandle)); + getResolverRankerServiceUserHandleList(userHandle), + getIntegratedDeviceComponents().getNearbySharingComponent()); } return new ChooserListController( diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java index f9004a9b..039f50e9 100644 --- a/java/src/com/android/intentresolver/ChooserRequestParameters.java +++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java @@ -69,7 +69,6 @@ public class ChooserRequestParameters { Intent.FLAG_ACTIVITY_NEW_DOCUMENT | Intent.FLAG_ACTIVITY_MULTIPLE_TASK; private final Intent mTarget; - private final ChooserIntegratedDeviceComponents mIntegratedDeviceComponents; private final String mReferrerPackageName; private final Pair mTitleSpec; private final Intent mReferrerFillInIntent; @@ -104,14 +103,11 @@ public class ChooserRequestParameters { final Intent clientIntent, String referrerPackageName, final Uri referrer, - ChooserIntegratedDeviceComponents integratedDeviceComponents, FeatureFlagRepository featureFlags) { final Intent requestedTarget = parseTargetIntentExtra( clientIntent.getParcelableExtra(Intent.EXTRA_INTENT)); mTarget = intentWithModifiedLaunchFlags(requestedTarget); - mIntegratedDeviceComponents = integratedDeviceComponents; - mReferrerPackageName = referrerPackageName; mAdditionalTargets = intentsWithModifiedLaunchFlagsFromExtraIfPresent( @@ -133,8 +129,11 @@ public class ChooserRequestParameters { mRefinementIntentSender = clientIntent.getParcelableExtra( Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER); - mFilteredComponentNames = getFilteredComponentNames( - clientIntent, mIntegratedDeviceComponents.getNearbySharingComponent()); + ComponentName[] filteredComponents = clientIntent.getParcelableArrayExtra( + Intent.EXTRA_EXCLUDE_COMPONENTS, ComponentName.class); + mFilteredComponentNames = filteredComponents != null + ? ImmutableList.copyOf(filteredComponents) + : ImmutableList.of(); mCallerChooserTargets = parseCallerTargetsFromClientIntent(clientIntent); @@ -252,10 +251,6 @@ public class ChooserRequestParameters { return mTargetIntentFilter; } - public ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() { - return mIntegratedDeviceComponents; - } - private static boolean isSendAction(@Nullable String action) { return (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action)); } @@ -309,23 +304,6 @@ public class ChooserRequestParameters { return Pair.create(requestedTitle, defaultTitleRes); } - private static ImmutableList getFilteredComponentNames( - Intent clientIntent, @Nullable ComponentName nearbySharingComponent) { - Stream filteredComponents = streamParcelableArrayExtra( - clientIntent, Intent.EXTRA_EXCLUDE_COMPONENTS, ComponentName.class, true, true); - - if (nearbySharingComponent != null) { - // Exclude Nearby from main list if chip is present, to avoid duplication. - // TODO: we don't have an explicit guarantee that the chip will be displayed just - // because we have a non-null component; that's ultimately determined by the preview - // layout. Maybe we can make that decision further upstream? - filteredComponents = Stream.concat( - filteredComponents, Stream.of(nearbySharingComponent)); - } - - return filteredComponents.collect(toImmutableList()); - } - private static ImmutableList parseCallerTargetsFromClientIntent( Intent clientIntent) { return diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 3b9d2a53..66eae92d 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -871,7 +871,8 @@ public class ResolverActivity extends FragmentActivity implements getReferrerPackageName(), null, null, - getResolverRankerServiceUserHandleList(userHandle)); + getResolverRankerServiceUserHandleList(userHandle), + null); return new ResolverListController( this, mPm, diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index 56027a16..3c2ee343 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -66,10 +66,6 @@ public final class ChooserContentPreviewUi { @Nullable ActionRow.Action createEditButton(); - /** Create an "Share to Nearby" action. */ - @Nullable - ActionRow.Action createNearbyButton(); - /** Create custom actions */ List createCustomActions(); diff --git a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java index e9d65eed..cae9403a 100644 --- a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java @@ -103,21 +103,8 @@ class FileContentPreviewUi extends ContentPreviewUi { final ActionRow actionRow = contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); actionRow.setActions( - createActions( - createFilePreviewActions(), - mActionFactory.createCustomActions())); + createActions(new ArrayList<>(), mActionFactory.createCustomActions())); return contentPreviewLayout; } - - private List createFilePreviewActions() { - List actions = new ArrayList<>(1); - //TODO(b/120417119): - // add action buttonFactory.createCopyButton() - ActionRow.Action action = mActionFactory.createNearbyButton(); - if (action != null) { - actions.add(action); - } - return actions; - } } diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java index ece0c312..44a9e654 100644 --- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java @@ -125,10 +125,6 @@ class TextContentPreviewUi extends ContentPreviewUi { private List createTextPreviewActions() { ArrayList 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/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java index 9ce875c8..ebf9bf11 100644 --- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -152,14 +152,10 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { } private List createImagePreviewActions() { - ArrayList actions = new ArrayList<>(2); + ArrayList actions = new ArrayList<>(1); //TODO: add copy action; - ActionRow.Action action = mActionFactory.createNearbyButton(); - if (action != null) { - actions.add(action); - } if (mFiles.size() == 1 && mTypeClassifier.isImageType(mFiles.get(0).getMimeType())) { - action = mActionFactory.createEditButton(); + ActionRow.Action action = mActionFactory.createEditButton(); if (action != null) { actions.add(action); } diff --git a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java index 7357fde9..bc54e01e 100644 --- a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java +++ b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java @@ -16,6 +16,7 @@ package com.android.intentresolver.model; +import android.annotation.Nullable; import android.app.usage.UsageStatsManager; import android.content.ComponentName; import android.content.Context; @@ -34,8 +35,6 @@ import com.android.intentresolver.ResolvedComponentInfo; import com.android.intentresolver.ResolverActivity; import com.android.intentresolver.chooser.TargetInfo; -import com.google.android.collect.Lists; - import java.text.Collator; import java.util.ArrayList; import java.util.Comparator; @@ -59,6 +58,7 @@ public abstract class AbstractResolverComparator implements Comparator mUsmMap = new HashMap<>(); protected String[] mAnnotations; protected String mContentType; + protected final ComponentName mPromoteToFirst; // True if the current share is a link. private final boolean mHttp; @@ -105,23 +105,6 @@ public abstract class AbstractResolverComparator implements Comparator resolvedActivityUserSpaceList) { + List resolvedActivityUserSpaceList, + @Nullable ComponentName promoteToFirst) { String scheme = intent.getScheme(); mHttp = "http".equals(scheme) || "https".equals(scheme); mContentType = intent.getType(); @@ -147,6 +133,7 @@ public abstract class AbstractResolverComparator implements Comparator mHandler.sendEmptyMessage(RANKER_SERVICE_RESULT), getChooserActivityLogger(), - mUser); + mUser, + mPromoteToFirst); mComparatorModel = buildUpdatedModel(); mResolverRankerService.compute(targets); } else { diff --git a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java index 725212e4..ebaffc36 100644 --- a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java +++ b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java @@ -102,9 +102,10 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom */ public ResolverRankerServiceResolverComparator(Context launchedFromContext, Intent intent, String referrerPackage, Runnable afterCompute, - ChooserActivityLogger chooserActivityLogger, UserHandle targetUserSpace) { + ChooserActivityLogger chooserActivityLogger, UserHandle targetUserSpace, + ComponentName promoteToFirst) { this(launchedFromContext, intent, referrerPackage, afterCompute, chooserActivityLogger, - Lists.newArrayList(targetUserSpace)); + Lists.newArrayList(targetUserSpace), promoteToFirst); } /** @@ -117,8 +118,9 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom */ public ResolverRankerServiceResolverComparator(Context launchedFromContext, Intent intent, String referrerPackage, Runnable afterCompute, - ChooserActivityLogger chooserActivityLogger, List targetUserSpaceList) { - super(launchedFromContext, intent, targetUserSpaceList); + ChooserActivityLogger chooserActivityLogger, List targetUserSpaceList, + @Nullable ComponentName promoteToFirst) { + super(launchedFromContext, intent, targetUserSpaceList, promoteToFirst); mCollator = Collator.getInstance( launchedFromContext.getResources().getConfiguration().locale); mReferrerPackage = referrerPackage; diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt index 7b9a0ce6..a7273a86 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt @@ -53,7 +53,6 @@ class ChooserContentPreviewUiTest { 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 = emptyList() override fun getModifyShareAction(): ActionRow.Action? = null override fun getExcludeSharedTextAction(): Consumer = Consumer {} diff --git a/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java b/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java index 892a2e28..5f0ead7b 100644 --- a/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java +++ b/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java @@ -40,52 +40,82 @@ public class AbstractResolverComparatorTest { @Test public void testPinned() { - ResolvedComponentInfo r1 = new ResolvedComponentInfo( - new ComponentName("package", "class"), new Intent(), new ResolveInfo() - ); + ResolvedComponentInfo r1 = createResolvedComponentInfo( + new ComponentName("package", "class")); r1.setPinned(true); - ResolvedComponentInfo r2 = new ResolvedComponentInfo( - new ComponentName("zackage", "zlass"), new Intent(), new ResolveInfo() - ); + ResolvedComponentInfo r2 = createResolvedComponentInfo( + new ComponentName("zackage", "zlass")); Context context = InstrumentationRegistry.getTargetContext(); - AbstractResolverComparator comparator = getTestComparator(context); + AbstractResolverComparator comparator = getTestComparator(context, null); assertEquals("Pinned ranks over unpinned", -1, comparator.compare(r1, r2)); assertEquals("Unpinned ranks under pinned", 1, comparator.compare(r2, r1)); } - @Test public void testBothPinned() { - ResolveInfo pmInfo1 = new ResolveInfo(); - pmInfo1.activityInfo = new ActivityInfo(); - pmInfo1.activityInfo.packageName = "aaa"; - - ResolvedComponentInfo r1 = new ResolvedComponentInfo( - new ComponentName("package", "class"), new Intent(), pmInfo1); + ResolvedComponentInfo r1 = createResolvedComponentInfo( + new ComponentName("package", "class")); r1.setPinned(true); - ResolveInfo pmInfo2 = new ResolveInfo(); - pmInfo2.activityInfo = new ActivityInfo(); - pmInfo2.activityInfo.packageName = "zzz"; - ResolvedComponentInfo r2 = new ResolvedComponentInfo( - new ComponentName("zackage", "zlass"), new Intent(), pmInfo2); + ResolvedComponentInfo r2 = createResolvedComponentInfo( + new ComponentName("zackage", "zlass")); r2.setPinned(true); Context context = InstrumentationRegistry.getTargetContext(); - AbstractResolverComparator comparator = getTestComparator(context); + AbstractResolverComparator comparator = getTestComparator(context, null); assertEquals("Both pinned should rank alphabetically", -1, comparator.compare(r1, r2)); } - private AbstractResolverComparator getTestComparator(Context context) { + @Test + public void testPromoteToFirst() { + ComponentName promoteToFirst = new ComponentName("promoted-package", "class"); + ResolvedComponentInfo r1 = createResolvedComponentInfo(promoteToFirst); + + ResolvedComponentInfo r2 = createResolvedComponentInfo( + new ComponentName("package", "class")); + + Context context = InstrumentationRegistry.getTargetContext(); + AbstractResolverComparator comparator = getTestComparator(context, promoteToFirst); + + assertEquals("PromoteToFirst ranks over non-cemented", -1, comparator.compare(r1, r2)); + assertEquals("Non-cemented ranks under PromoteToFirst", 1, comparator.compare(r2, r1)); + } + + @Test + public void testPromoteToFirstOverPinned() { + ComponentName cementedComponent = new ComponentName("promoted-package", "class"); + ResolvedComponentInfo r1 = createResolvedComponentInfo(cementedComponent); + + ResolvedComponentInfo r2 = createResolvedComponentInfo( + new ComponentName("package", "class")); + r2.setPinned(true); + + Context context = InstrumentationRegistry.getTargetContext(); + AbstractResolverComparator comparator = getTestComparator(context, cementedComponent); + + assertEquals("PromoteToFirst ranks over pinned", -1, comparator.compare(r1, r2)); + assertEquals("Pinned ranks under PromoteToFirst", 1, comparator.compare(r2, r1)); + } + + private ResolvedComponentInfo createResolvedComponentInfo(ComponentName component) { + ResolveInfo info = new ResolveInfo(); + info.activityInfo = new ActivityInfo(); + info.activityInfo.packageName = component.getPackageName(); + info.activityInfo.name = component.getClassName(); + return new ResolvedComponentInfo(component, new Intent(), info); + } + + private AbstractResolverComparator getTestComparator( + Context context, ComponentName promoteToFirst) { Intent intent = new Intent(); AbstractResolverComparator testComparator = new AbstractResolverComparator(context, intent, - Lists.newArrayList(context.getUser())) { + Lists.newArrayList(context.getUser()), promoteToFirst) { @Override int compare(ResolveInfo lhs, ResolveInfo rhs) { -- cgit v1.2.3-59-g8ed1b From 21cee78c93503c016f6d5cd16e8f5e24abf4910c Mon Sep 17 00:00:00 2001 From: 1 Date: Thu, 13 Apr 2023 13:59:22 +0000 Subject: Files + text in a separate preview layout. Expand the image+text flow to allow arbitrary files+text, but only keep preview code for the single-image use case. Remove some of the relevant logic from UnifiedContentPreview. Update headlines to give more clarity in the files+text cases. Bug: 277958508 Test: Manual testing with ShareTest. Test: atest IntentResolverUnitTests Change-Id: Ibdc0688acf43c228ed5cbc0bfd73026bcfada1f2 --- .../res/layout/chooser_grid_preview_files_text.xml | 62 +++++++ java/res/layout/chooser_grid_preview_image.xml | 47 ++--- java/res/values/strings.xml | 41 ++++- .../contentpreview/ChooserContentPreviewUi.java | 10 +- .../FilesPlusTextContentPreviewUi.java | 193 +++++++++++++++++++++ .../contentpreview/HeadlineGenerator.kt | 6 +- .../contentpreview/HeadlineGeneratorImpl.kt | 50 +++--- .../contentpreview/UnifiedContentPreviewUi.java | 74 +------- .../UnbundledChooserActivityTest.java | 9 +- .../contentpreview/HeadlineGeneratorImplTest.kt | 18 +- 10 files changed, 373 insertions(+), 137 deletions(-) create mode 100644 java/res/layout/chooser_grid_preview_files_text.xml create mode 100644 java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java (limited to 'java/tests') diff --git a/java/res/layout/chooser_grid_preview_files_text.xml b/java/res/layout/chooser_grid_preview_files_text.xml new file mode 100644 index 00000000..d46da2c0 --- /dev/null +++ b/java/res/layout/chooser_grid_preview_files_text.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + diff --git a/java/res/layout/chooser_grid_preview_image.xml b/java/res/layout/chooser_grid_preview_image.xml index afb0db2a..12848a50 100644 --- a/java/res/layout/chooser_grid_preview_image.xml +++ b/java/res/layout/chooser_grid_preview_image.xml @@ -18,47 +18,24 @@ --> - + - + - - - - - - + diff --git a/java/res/values/strings.xml b/java/res/values/strings.xml index ce367d0a..1648831c 100644 --- a/java/res/values/strings.xml +++ b/java/res/values/strings.xml @@ -160,12 +160,41 @@ other {Sharing # items} } - - Sharing image with text - - Sharing image with link + {count, plural, + =1 {Sharing image with text} + other {Sharing # images with text} + } + + + {count, plural, + =1 {Sharing image with link} + other {Sharing # images with link} + } + + + {count, plural, + =1 {Sharing video with text} + other {Sharing # videos with text} + } + + + {count, plural, + =1 {Sharing video with link} + other {Sharing # videos with link} + } + + + {count, plural, + =1 {Sharing file with text} + other {Sharing # files with text} + } + + + {count, plural, + =1 {Sharing file with link} + other {Sharing # files with link} + } + No recommended people to share with diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index 181fe117..21930fdb 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -150,6 +150,15 @@ public final class ChooserContentPreviewUi { } ArrayList files = new ArrayList<>(uris.size()); int previewCount = readFileInfo(contentResolver, typeClassifier, uris, files); + CharSequence text = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); + if (!TextUtils.isEmpty(text)) { + return new FilesPlusTextContentPreviewUi(files, + targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT), + actionFactory, + imageLoader, + typeClassifier, + headlineGenerator); + } if (previewCount == 0) { return new FileContentPreviewUi( files, @@ -158,7 +167,6 @@ public final class ChooserContentPreviewUi { } return new UnifiedContentPreviewUi( files, - targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT), actionFactory, imageLoader, typeClassifier, diff --git a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java new file mode 100644 index 00000000..5174234a --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java @@ -0,0 +1,193 @@ +/* + * 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_FILE; +import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_IMAGE; + +import android.content.res.Resources; +import android.text.util.Linkify; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CheckBox; +import android.widget.ImageView; +import android.widget.TextView; + +import com.android.intentresolver.R; +import com.android.intentresolver.widget.ActionRow; +import com.android.intentresolver.widget.ScrollableImagePreviewView; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +/** + * FilesPlusTextContentPreviewUi is shown when the user is sending 1 or more files along with + * non-empty EXTRA_TEXT. The text can be toggled with a checkbox. If a single image file is being + * shared, it is shown in a preview (otherwise the headline summary is the sole indication of the + * file content). + */ +class FilesPlusTextContentPreviewUi extends ContentPreviewUi { + private final List mFiles; + private final CharSequence mText; + private final ChooserContentPreviewUi.ActionFactory mActionFactory; + private final ImageLoader mImageLoader; + private final MimeTypeClassifier mTypeClassifier; + private final HeadlineGenerator mHeadlineGenerator; + private final boolean mAllImages; + private final boolean mAllVideos; + + FilesPlusTextContentPreviewUi( + List files, + CharSequence text, + ChooserContentPreviewUi.ActionFactory actionFactory, + ImageLoader imageLoader, + MimeTypeClassifier typeClassifier, + HeadlineGenerator headlineGenerator) { + mFiles = files; + mText = text; + mActionFactory = actionFactory; + mImageLoader = imageLoader; + mTypeClassifier = typeClassifier; + mHeadlineGenerator = headlineGenerator; + + boolean allImages = true; + boolean allVideos = true; + for (FileInfo fileInfo : mFiles) { + ScrollableImagePreviewView.PreviewType previewType = + getPreviewType(fileInfo.getMimeType()); + allImages = allImages && previewType == ScrollableImagePreviewView.PreviewType.Image; + allVideos = allVideos && previewType == ScrollableImagePreviewView.PreviewType.Video; + } + mAllImages = allImages; + mAllVideos = allVideos; + } + + @Override + public int getType() { + return shouldShowPreview() ? CONTENT_PREVIEW_IMAGE : CONTENT_PREVIEW_FILE; + } + + @Override + public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { + ViewGroup layout = displayInternal(layoutInflater, parent); + displayModifyShareAction(layout, mActionFactory); + return layout; + } + + private ViewGroup displayInternal(LayoutInflater layoutInflater, ViewGroup parent) { + ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( + R.layout.chooser_grid_preview_files_text, parent, false); + ImageView imagePreview = + contentPreviewLayout.findViewById(R.id.image_view); + + final ActionRow actionRow = + contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); + actionRow.setActions(createActions( + createImagePreviewActions(), + mActionFactory.createCustomActions())); + + if (shouldShowPreview()) { + mImageLoader.loadImage(mFiles.get(0).getPreviewUri(), bitmap -> { + if (bitmap == null) { + imagePreview.setVisibility(View.GONE); + } else { + imagePreview.setImageBitmap(bitmap); + } + }); + } else { + imagePreview.setVisibility(View.GONE); + } + + prepareTextPreview(contentPreviewLayout, mActionFactory); + updateHeadline(contentPreviewLayout); + + return contentPreviewLayout; + } + + private boolean shouldShowPreview() { + return mAllImages && mFiles.size() == 1 && mFiles.get(0).getPreviewUri() != null; + } + + private List createImagePreviewActions() { + ArrayList actions = new ArrayList<>(2); + //TODO: add copy action; + if (mFiles.size() == 1 && mAllImages) { + ActionRow.Action action = mActionFactory.createEditButton(); + if (action != null) { + actions.add(action); + } + } + return actions; + } + + private void updateHeadline(ViewGroup contentPreview) { + CheckBox includeText = contentPreview.requireViewById(R.id.include_text_action); + String headline; + if (includeText.getVisibility() == View.VISIBLE && includeText.isChecked()) { + if (mAllImages) { + headline = mHeadlineGenerator.getImagesWithTextHeadline(mText, mFiles.size()); + } else if (mAllVideos) { + headline = mHeadlineGenerator.getVideosWithTextHeadline(mText, mFiles.size()); + } else { + headline = mHeadlineGenerator.getFilesWithTextHeadline(mText, mFiles.size()); + } + } else { + if (mAllImages) { + headline = mHeadlineGenerator.getImagesHeadline(mFiles.size()); + } else if (mAllVideos) { + headline = mHeadlineGenerator.getVideosHeadline(mFiles.size()); + } else { + headline = mHeadlineGenerator.getItemsHeadline(mFiles.size()); + } + } + + displayHeadline(contentPreview, headline); + } + + private void prepareTextPreview( + ViewGroup contentPreview, + ChooserContentPreviewUi.ActionFactory actionFactory) { + final TextView textView = contentPreview.requireViewById(R.id.content_preview_text); + CheckBox includeText = contentPreview.requireViewById(R.id.include_text_action); + boolean isLink = HttpUriMatcher.isHttpUri(mText.toString()); + textView.setAutoLinkMask(isLink ? Linkify.WEB_URLS : 0); + textView.setText(mText); + + final Consumer shareTextAction = actionFactory.getExcludeSharedTextAction(); + includeText.setChecked(true); + includeText.setText(isLink ? R.string.include_link : R.string.include_text); + shareTextAction.accept(false); + includeText.setOnCheckedChangeListener((view, isChecked) -> { + textView.setEnabled(isChecked); + shareTextAction.accept(!isChecked); + updateHeadline(contentPreview); + }); + includeText.setVisibility(View.VISIBLE); + } + + private ScrollableImagePreviewView.PreviewType getPreviewType(String mimeType) { + if (mTypeClassifier.isImageType(mimeType)) { + return ScrollableImagePreviewView.PreviewType.Image; + } + if (mTypeClassifier.isVideoType(mimeType)) { + return ScrollableImagePreviewView.PreviewType.Video; + } + return ScrollableImagePreviewView.PreviewType.File; + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt b/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt index e32bb5c4..ad2a7ada 100644 --- a/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt +++ b/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt @@ -25,7 +25,11 @@ private const val PLURALS_COUNT = "count" interface HeadlineGenerator { fun getTextHeadline(text: CharSequence): String - fun getImageWithTextHeadline(text: CharSequence): String + fun getImagesWithTextHeadline(text: CharSequence, count: Int): String + + fun getVideosWithTextHeadline(text: CharSequence, count: Int): String + + fun getFilesWithTextHeadline(text: CharSequence, count: Int): String fun getImagesHeadline(count: Int): String diff --git a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt index ae44294c..a6b782ad 100644 --- a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt +++ b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt @@ -16,6 +16,7 @@ package com.android.intentresolver.contentpreview +import android.annotation.StringRes import android.content.Context import com.android.intentresolver.R import android.util.PluralsMessageFormatter @@ -28,40 +29,49 @@ private const val PLURALS_COUNT = "count" */ class HeadlineGeneratorImpl(private val context: Context) : HeadlineGenerator { override fun getTextHeadline(text: CharSequence): String { - if (text.toString().isHttpUri()) { - return context.getString(R.string.sharing_link) - } - return context.getString(R.string.sharing_text) + return context.getString( + getTemplateResource(text, R.string.sharing_link, R.string.sharing_text)) } - override fun getImageWithTextHeadline(text: CharSequence): String { - if (text.toString().isHttpUri()) { - return context.getString(R.string.sharing_image_with_link) - } - return context.getString(R.string.sharing_image_with_text) + override fun getImagesWithTextHeadline(text: CharSequence, count: Int): String { + return getPluralString(getTemplateResource( + text, R.string.sharing_images_with_link, R.string.sharing_images_with_text), count) + } + + override fun getVideosWithTextHeadline(text: CharSequence, count: Int): String { + return getPluralString(getTemplateResource( + text, R.string.sharing_videos_with_link, R.string.sharing_videos_with_text), count) + } + + override fun getFilesWithTextHeadline(text: CharSequence, count: Int): String { + return getPluralString(getTemplateResource( + text, R.string.sharing_files_with_link, R.string.sharing_files_with_text), count) } override fun getImagesHeadline(count: Int): String { - return PluralsMessageFormatter.format( - context.resources, - mapOf(PLURALS_COUNT to count), - R.string.sharing_images - ) + return getPluralString(R.string.sharing_images, count) } override fun getVideosHeadline(count: Int): String { - return PluralsMessageFormatter.format( - context.resources, - mapOf(PLURALS_COUNT to count), - R.string.sharing_videos - ) + return getPluralString(R.string.sharing_videos, count) } override fun getItemsHeadline(count: Int): String { + return getPluralString(R.string.sharing_items, count) + } + + private fun getPluralString(@StringRes templateResource: Int, count: Int): String { return PluralsMessageFormatter.format( context.resources, mapOf(PLURALS_COUNT to count), - R.string.sharing_items + templateResource ) } + + @StringRes + private fun getTemplateResource( + text: CharSequence, @StringRes linkResource: Int, @StringRes nonLinkResource: Int + ): Int { + return if (text.toString().isHttpUri()) linkResource else nonLinkResource + } } diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java index 709ec566..6f1be116 100644 --- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -19,15 +19,10 @@ package com.android.intentresolver.contentpreview; import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_IMAGE; import android.content.res.Resources; -import android.text.TextUtils; -import android.text.util.Linkify; -import android.transition.TransitionManager; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.CheckBox; -import android.widget.TextView; import androidx.annotation.Nullable; @@ -39,12 +34,10 @@ import com.android.intentresolver.widget.ScrollableImagePreviewView; import java.util.ArrayList; import java.util.List; import java.util.Objects; -import java.util.function.Consumer; class UnifiedContentPreviewUi extends ContentPreviewUi { private final List mFiles; @Nullable - private final CharSequence mText; private final ChooserContentPreviewUi.ActionFactory mActionFactory; private final ImageLoader mImageLoader; private final MimeTypeClassifier mTypeClassifier; @@ -53,14 +46,12 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { UnifiedContentPreviewUi( List files, - @Nullable CharSequence text, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, MimeTypeClassifier typeClassifier, TransitionElementStatusCallback transitionElementStatusCallback, HeadlineGenerator headlineGenerator) { mFiles = files; - mText = text; mActionFactory = actionFactory; mImageLoader = imageLoader; mTypeClassifier = typeClassifier; @@ -131,20 +122,15 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { mFiles.size() - previews.size(), mImageLoader); - if (!TextUtils.isEmpty(mText) && mFiles.size() == 1 && allImages) { - setTextInImagePreviewVisibility(contentPreviewLayout, imagePreview, mActionFactory); - updateTextWithImageHeadline(contentPreviewLayout); + if (allImages) { + displayHeadline( + contentPreviewLayout, mHeadlineGenerator.getImagesHeadline(mFiles.size())); + } else if (allVideos) { + displayHeadline( + contentPreviewLayout, mHeadlineGenerator.getVideosHeadline(mFiles.size())); } else { - if (allImages) { - displayHeadline( - contentPreviewLayout, mHeadlineGenerator.getImagesHeadline(mFiles.size())); - } else if (allVideos) { - displayHeadline( - contentPreviewLayout, mHeadlineGenerator.getVideosHeadline(mFiles.size())); - } else { - displayHeadline( - contentPreviewLayout, mHeadlineGenerator.getItemsHeadline(mFiles.size())); - } + displayHeadline( + contentPreviewLayout, mHeadlineGenerator.getItemsHeadline(mFiles.size())); } return contentPreviewLayout; @@ -166,50 +152,6 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { return actions; } - private void updateTextWithImageHeadline(ViewGroup contentPreview) { - CheckBox actionView = contentPreview.requireViewById(R.id.include_text_action); - if (actionView.getVisibility() == View.VISIBLE && actionView.isChecked()) { - displayHeadline(contentPreview, mHeadlineGenerator.getImageWithTextHeadline(mText)); - } else { - displayHeadline( - contentPreview, mHeadlineGenerator.getImagesHeadline(mFiles.size())); - } - } - - private void setTextInImagePreviewVisibility( - ViewGroup contentPreview, - ScrollableImagePreviewView imagePreview, - ChooserContentPreviewUi.ActionFactory actionFactory) { - final TextView textView = contentPreview - .requireViewById(com.android.internal.R.id.content_preview_text); - CheckBox actionView = contentPreview - .requireViewById(R.id.include_text_action); - textView.setVisibility(View.VISIBLE); - boolean isLink = HttpUriMatcher.isHttpUri(mText.toString()); - textView.setAutoLinkMask(isLink ? Linkify.WEB_URLS : 0); - textView.setText(mText); - - 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.setEnabled(isChecked); - if (imagePreview.getVisibility() == View.VISIBLE) { - // animate only only if we have preview - TransitionManager.beginDelayedTransition((ViewGroup) textView.getParent()); - textView.setVisibility(isChecked ? View.VISIBLE : View.GONE); - } - shareTextAction.accept(!isChecked); - updateTextWithImageHeadline(contentPreview); - }); - actionView.setVisibility(View.VISIBLE); - } - private ScrollableImagePreviewView.PreviewType getPreviewType(String mimeType) { if (mTypeClassifier.isImageType(mimeType)) { return ScrollableImagePreviewView.PreviewType.Image; diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index acced89f..385f9fd8 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -857,9 +857,9 @@ public class UnbundledChooserActivityTest { .perform(click()); waitForIdle(); - onView(withId(R.id.scrollable_image_preview)) + onView(withId(R.id.image_view)) .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))); - onView(withId(com.android.internal.R.id.content_preview_text)) + onView(withId(R.id.content_preview_text)) .check(matches(allOf(isDisplayed(), not(isEnabled())))); } @@ -1074,7 +1074,7 @@ public class UnbundledChooserActivityTest { } @Test - public void testNoTextPreviewWhenTextIsSharedWithMultipleImages() { + public void testTextPreviewWhenTextIsSharedWithMultipleImages() { final Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" + R.drawable.test320x240); final String sharedText = "text-" + System.currentTimeMillis(); @@ -1104,8 +1104,7 @@ public class UnbundledChooserActivityTest { .thenReturn(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); - onView(withId(com.android.internal.R.id.content_preview_text)) - .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))); + onView(withText(sharedText)).check(matches(isDisplayed())); } @Test diff --git a/java/tests/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt index 9becce99..aac6caa7 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt @@ -28,14 +28,26 @@ class HeadlineGeneratorImplTest { fun testHeadlineGeneration() { val generator = HeadlineGeneratorImpl( InstrumentationRegistry.getInstrumentation().getTargetContext()) - val str = "Some sting" + val str = "Some string" val url = "http://www.google.com" assertThat(generator.getTextHeadline(str)).isEqualTo("Sharing text") assertThat(generator.getTextHeadline(url)).isEqualTo("Sharing link") - assertThat(generator.getImageWithTextHeadline(str)).isEqualTo("Sharing image with text") - assertThat(generator.getImageWithTextHeadline(url)).isEqualTo("Sharing image with link") + assertThat(generator.getImagesWithTextHeadline(str, 1)).isEqualTo("Sharing image with text") + assertThat(generator.getImagesWithTextHeadline(url, 1)).isEqualTo("Sharing image with link") + assertThat(generator.getImagesWithTextHeadline(str, 5)).isEqualTo("Sharing 5 images with text") + assertThat(generator.getImagesWithTextHeadline(url, 5)).isEqualTo("Sharing 5 images with link") + + assertThat(generator.getVideosWithTextHeadline(str, 1)).isEqualTo("Sharing video with text") + assertThat(generator.getVideosWithTextHeadline(url, 1)).isEqualTo("Sharing video with link") + assertThat(generator.getVideosWithTextHeadline(str, 5)).isEqualTo("Sharing 5 videos with text") + assertThat(generator.getVideosWithTextHeadline(url, 5)).isEqualTo("Sharing 5 videos with link") + + assertThat(generator.getFilesWithTextHeadline(str, 1)).isEqualTo("Sharing file with text") + assertThat(generator.getFilesWithTextHeadline(url, 1)).isEqualTo("Sharing file with link") + assertThat(generator.getFilesWithTextHeadline(str, 5)).isEqualTo("Sharing 5 files with text") + assertThat(generator.getFilesWithTextHeadline(url, 5)).isEqualTo("Sharing 5 files with link") assertThat(generator.getImagesHeadline(1)).isEqualTo("Sharing image") assertThat(generator.getImagesHeadline(4)).isEqualTo("Sharing 4 images") -- cgit v1.2.3-59-g8ed1b From ffc1dc5709c794effc8b2e7b0761a80ecc8abb77 Mon Sep 17 00:00:00 2001 From: Ravindra Nallabilli Date: Fri, 21 Apr 2023 11:34:56 +0000 Subject: Revert^2 "Revert "Move Nearby to the front of the app list."" 7da4cd780a8cad1c84426efdd470f25ba73908ce Change-Id: Icbd5b5e0fb00a3a8a08eb9c432ab2b1f27c747e8 --- .../intentresolver/ChooserActionFactory.java | 94 ++++++++++++++++++++++ .../android/intentresolver/ChooserActivity.java | 7 +- .../intentresolver/ChooserRequestParameters.java | 32 ++++++-- .../android/intentresolver/ResolverActivity.java | 3 +- .../contentpreview/ChooserContentPreviewUi.java | 4 + .../contentpreview/FileContentPreviewUi.java | 15 +++- .../contentpreview/TextContentPreviewUi.java | 4 + .../contentpreview/UnifiedContentPreviewUi.java | 8 +- .../model/AbstractResolverComparator.java | 37 +++++---- .../AppPredictionServiceResolverComparator.java | 8 +- .../ResolverRankerServiceResolverComparator.java | 10 +-- .../contentpreview/ChooserContentPreviewUiTest.kt | 1 + .../model/AbstractResolverComparatorTest.java | 74 +++++------------ 13 files changed, 203 insertions(+), 94 deletions(-) (limited to 'java/tests') diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java index f355d9d4..23e04560 100644 --- a/java/src/com/android/intentresolver/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/ChooserActionFactory.java @@ -26,9 +26,12 @@ import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.ResolveInfo; +import android.content.res.Resources; import android.graphics.drawable.Drawable; import android.net.Uri; +import android.os.Bundle; import android.service.chooser.ChooserAction; import android.text.TextUtils; import android.util.Log; @@ -89,6 +92,8 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio private final Runnable mOnCopyButtonClicked; private final TargetInfo mEditSharingTarget; private final Runnable mOnEditButtonClicked; + private final TargetInfo mNearbySharingTarget; + private final Runnable mOnNearbyButtonClicked; private final ImmutableList mCustomActions; private final @Nullable ChooserAction mModifyShareAction; private final Consumer mExcludeSharedTextAction; @@ -139,6 +144,18 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio firstVisibleImageQuery, activityStarter, logger), + getNearbySharingTarget( + context, + chooserRequest.getTargetIntent(), + integratedDeviceComponents), + makeOnNearbyShareRunnable( + getNearbySharingTarget( + context, + chooserRequest.getTargetIntent(), + integratedDeviceComponents), + activityStarter, + finishCallback, + logger), chooserRequest.getChooserActions(), chooserRequest.getModifyShareAction(), onUpdateSharedTextIsExcluded, @@ -154,6 +171,8 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio Runnable onCopyButtonClicked, TargetInfo editSharingTarget, Runnable onEditButtonClicked, + TargetInfo nearbySharingTarget, + Runnable onNearbyButtonClicked, List customActions, @Nullable ChooserAction modifyShareAction, Consumer onUpdateSharedTextIsExcluded, @@ -165,6 +184,8 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio mOnCopyButtonClicked = onCopyButtonClicked; mEditSharingTarget = editSharingTarget; mOnEditButtonClicked = onEditButtonClicked; + mNearbySharingTarget = nearbySharingTarget; + mOnNearbyButtonClicked = onNearbyButtonClicked; mCustomActions = ImmutableList.copyOf(customActions); mModifyShareAction = modifyShareAction; mExcludeSharedTextAction = onUpdateSharedTextIsExcluded; @@ -197,6 +218,21 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio mOnEditButtonClicked); } + /** Create a "Share to Nearby" action. */ + @Override + @Nullable + public ActionRow.Action createNearbyButton() { + if (mNearbySharingTarget == null) { + return null; + } + + return new ActionRow.Action( + com.android.internal.R.id.chooser_nearby_button, + mNearbySharingTarget.getDisplayLabel(), + mNearbySharingTarget.getDisplayIconHolder().getDisplayIcon(), + mOnNearbyButtonClicked); + } + /** Create custom actions */ @Override public List createCustomActions() { @@ -367,6 +403,64 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio }; } + private static TargetInfo getNearbySharingTarget( + Context context, + Intent originalIntent, + ChooserIntegratedDeviceComponents integratedComponents) { + final ComponentName cn = integratedComponents.getNearbySharingComponent(); + if (cn == null) { + return null; + } + + final Intent resolveIntent = new Intent(originalIntent); + resolveIntent.setComponent(cn); + final ResolveInfo ri = context.getPackageManager().resolveActivity( + resolveIntent, PackageManager.GET_META_DATA); + if (ri == null || ri.activityInfo == null) { + Log.e(TAG, "Device-specified nearby sharing component (" + cn + + ") not available"); + return null; + } + + // Allow the nearby sharing component to provide a more appropriate icon and label + // for the chip. + CharSequence name = null; + Drawable icon = null; + final Bundle metaData = ri.activityInfo.metaData; + if (metaData != null) { + try { + final Resources pkgRes = context.getPackageManager().getResourcesForActivity(cn); + final int nameResId = metaData.getInt(CHIP_LABEL_METADATA_KEY); + name = pkgRes.getString(nameResId); + final int resId = metaData.getInt(CHIP_ICON_METADATA_KEY); + icon = pkgRes.getDrawable(resId); + } catch (NameNotFoundException | Resources.NotFoundException ex) { /* ignore */ } + } + if (TextUtils.isEmpty(name)) { + name = ri.loadLabel(context.getPackageManager()); + } + if (icon == null) { + icon = ri.loadIcon(context.getPackageManager()); + } + + final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( + originalIntent, ri, name, "", resolveIntent, null); + dri.getDisplayIconHolder().setDisplayIcon(icon); + return dri; + } + + private static Runnable makeOnNearbyShareRunnable( + TargetInfo nearbyShareTarget, + ActionActivityStarter activityStarter, + Consumer finishCallback, + ChooserActivityLogger logger) { + return () -> { + logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_NEARBY); + // Action bar is user-independent; always start as primary. + activityStarter.safelyStartActivityAsPersonalProfileUser(nearbyShareTarget); + }; + } + @Nullable private static ActionRow.Action createCustomAction( Context context, diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index ab2ba91e..404d6da3 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -239,6 +239,7 @@ public class ChooserActivity extends ResolverActivity implements getIntent(), getReferrerPackageName(), getReferrer(), + mIntegratedDeviceComponents, mFeatureFlagRepository); } catch (IllegalArgumentException e) { Log.e(TAG, "Caller provided invalid Chooser request parameters", e); @@ -1260,8 +1261,7 @@ public class ChooserActivity extends ResolverActivity implements AbstractResolverComparator resolverComparator; if (appPredictor != null) { resolverComparator = new AppPredictionServiceResolverComparator(this, getTargetIntent(), - getReferrerPackageName(), appPredictor, userHandle, getChooserActivityLogger(), - getIntegratedDeviceComponents().getNearbySharingComponent()); + getReferrerPackageName(), appPredictor, userHandle, getChooserActivityLogger()); } else { resolverComparator = new ResolverRankerServiceResolverComparator( @@ -1270,8 +1270,7 @@ public class ChooserActivity extends ResolverActivity implements getReferrerPackageName(), null, getChooserActivityLogger(), - getResolverRankerServiceUserHandleList(userHandle), - getIntegratedDeviceComponents().getNearbySharingComponent()); + getResolverRankerServiceUserHandleList(userHandle)); } return new ChooserListController( diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java index 039f50e9..f9004a9b 100644 --- a/java/src/com/android/intentresolver/ChooserRequestParameters.java +++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java @@ -69,6 +69,7 @@ public class ChooserRequestParameters { Intent.FLAG_ACTIVITY_NEW_DOCUMENT | Intent.FLAG_ACTIVITY_MULTIPLE_TASK; private final Intent mTarget; + private final ChooserIntegratedDeviceComponents mIntegratedDeviceComponents; private final String mReferrerPackageName; private final Pair mTitleSpec; private final Intent mReferrerFillInIntent; @@ -103,11 +104,14 @@ public class ChooserRequestParameters { final Intent clientIntent, String referrerPackageName, final Uri referrer, + ChooserIntegratedDeviceComponents integratedDeviceComponents, FeatureFlagRepository featureFlags) { final Intent requestedTarget = parseTargetIntentExtra( clientIntent.getParcelableExtra(Intent.EXTRA_INTENT)); mTarget = intentWithModifiedLaunchFlags(requestedTarget); + mIntegratedDeviceComponents = integratedDeviceComponents; + mReferrerPackageName = referrerPackageName; mAdditionalTargets = intentsWithModifiedLaunchFlagsFromExtraIfPresent( @@ -129,11 +133,8 @@ public class ChooserRequestParameters { mRefinementIntentSender = clientIntent.getParcelableExtra( Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER); - ComponentName[] filteredComponents = clientIntent.getParcelableArrayExtra( - Intent.EXTRA_EXCLUDE_COMPONENTS, ComponentName.class); - mFilteredComponentNames = filteredComponents != null - ? ImmutableList.copyOf(filteredComponents) - : ImmutableList.of(); + mFilteredComponentNames = getFilteredComponentNames( + clientIntent, mIntegratedDeviceComponents.getNearbySharingComponent()); mCallerChooserTargets = parseCallerTargetsFromClientIntent(clientIntent); @@ -251,6 +252,10 @@ public class ChooserRequestParameters { return mTargetIntentFilter; } + public ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() { + return mIntegratedDeviceComponents; + } + private static boolean isSendAction(@Nullable String action) { return (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action)); } @@ -304,6 +309,23 @@ public class ChooserRequestParameters { return Pair.create(requestedTitle, defaultTitleRes); } + private static ImmutableList getFilteredComponentNames( + Intent clientIntent, @Nullable ComponentName nearbySharingComponent) { + Stream filteredComponents = streamParcelableArrayExtra( + clientIntent, Intent.EXTRA_EXCLUDE_COMPONENTS, ComponentName.class, true, true); + + if (nearbySharingComponent != null) { + // Exclude Nearby from main list if chip is present, to avoid duplication. + // TODO: we don't have an explicit guarantee that the chip will be displayed just + // because we have a non-null component; that's ultimately determined by the preview + // layout. Maybe we can make that decision further upstream? + filteredComponents = Stream.concat( + filteredComponents, Stream.of(nearbySharingComponent)); + } + + return filteredComponents.collect(toImmutableList()); + } + private static ImmutableList parseCallerTargetsFromClientIntent( Intent clientIntent) { return diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 66eae92d..3b9d2a53 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -871,8 +871,7 @@ public class ResolverActivity extends FragmentActivity implements getReferrerPackageName(), null, null, - getResolverRankerServiceUserHandleList(userHandle), - null); + getResolverRankerServiceUserHandleList(userHandle)); return new ResolverListController( this, mPm, diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index 3c2ee343..56027a16 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -66,6 +66,10 @@ public final class ChooserContentPreviewUi { @Nullable ActionRow.Action createEditButton(); + /** Create an "Share to Nearby" action. */ + @Nullable + ActionRow.Action createNearbyButton(); + /** Create custom actions */ List createCustomActions(); diff --git a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java index cae9403a..e9d65eed 100644 --- a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java @@ -103,8 +103,21 @@ class FileContentPreviewUi extends ContentPreviewUi { final ActionRow actionRow = contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); actionRow.setActions( - createActions(new ArrayList<>(), mActionFactory.createCustomActions())); + createActions( + createFilePreviewActions(), + mActionFactory.createCustomActions())); return contentPreviewLayout; } + + private List createFilePreviewActions() { + List actions = new ArrayList<>(1); + //TODO(b/120417119): + // add action buttonFactory.createCopyButton() + ActionRow.Action action = mActionFactory.createNearbyButton(); + if (action != null) { + actions.add(action); + } + return actions; + } } diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java index 44a9e654..ece0c312 100644 --- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java @@ -125,6 +125,10 @@ class TextContentPreviewUi extends ContentPreviewUi { private List createTextPreviewActions() { ArrayList 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/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java index ebf9bf11..9ce875c8 100644 --- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -152,10 +152,14 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { } private List createImagePreviewActions() { - ArrayList actions = new ArrayList<>(1); + ArrayList actions = new ArrayList<>(2); //TODO: add copy action; + ActionRow.Action action = mActionFactory.createNearbyButton(); + if (action != null) { + actions.add(action); + } if (mFiles.size() == 1 && mTypeClassifier.isImageType(mFiles.get(0).getMimeType())) { - ActionRow.Action action = mActionFactory.createEditButton(); + action = mActionFactory.createEditButton(); if (action != null) { actions.add(action); } diff --git a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java index bc54e01e..7357fde9 100644 --- a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java +++ b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java @@ -16,7 +16,6 @@ package com.android.intentresolver.model; -import android.annotation.Nullable; import android.app.usage.UsageStatsManager; import android.content.ComponentName; import android.content.Context; @@ -35,6 +34,8 @@ import com.android.intentresolver.ResolvedComponentInfo; import com.android.intentresolver.ResolverActivity; import com.android.intentresolver.chooser.TargetInfo; +import com.google.android.collect.Lists; + import java.text.Collator; import java.util.ArrayList; import java.util.Comparator; @@ -58,7 +59,6 @@ public abstract class AbstractResolverComparator implements Comparator mUsmMap = new HashMap<>(); protected String[] mAnnotations; protected String mContentType; - protected final ComponentName mPromoteToFirst; // True if the current share is a link. private final boolean mHttp; @@ -105,6 +105,23 @@ public abstract class AbstractResolverComparator implements Comparator resolvedActivityUserSpaceList, - @Nullable ComponentName promoteToFirst) { + List resolvedActivityUserSpaceList) { String scheme = intent.getScheme(); mHttp = "http".equals(scheme) || "https".equals(scheme); mContentType = intent.getType(); @@ -133,7 +147,6 @@ public abstract class AbstractResolverComparator implements Comparator mHandler.sendEmptyMessage(RANKER_SERVICE_RESULT), getChooserActivityLogger(), - mUser, - mPromoteToFirst); + mUser); mComparatorModel = buildUpdatedModel(); mResolverRankerService.compute(targets); } else { diff --git a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java index ebaffc36..725212e4 100644 --- a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java +++ b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java @@ -102,10 +102,9 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom */ public ResolverRankerServiceResolverComparator(Context launchedFromContext, Intent intent, String referrerPackage, Runnable afterCompute, - ChooserActivityLogger chooserActivityLogger, UserHandle targetUserSpace, - ComponentName promoteToFirst) { + ChooserActivityLogger chooserActivityLogger, UserHandle targetUserSpace) { this(launchedFromContext, intent, referrerPackage, afterCompute, chooserActivityLogger, - Lists.newArrayList(targetUserSpace), promoteToFirst); + Lists.newArrayList(targetUserSpace)); } /** @@ -118,9 +117,8 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom */ public ResolverRankerServiceResolverComparator(Context launchedFromContext, Intent intent, String referrerPackage, Runnable afterCompute, - ChooserActivityLogger chooserActivityLogger, List targetUserSpaceList, - @Nullable ComponentName promoteToFirst) { - super(launchedFromContext, intent, targetUserSpaceList, promoteToFirst); + ChooserActivityLogger chooserActivityLogger, List targetUserSpaceList) { + super(launchedFromContext, intent, targetUserSpaceList); mCollator = Collator.getInstance( launchedFromContext.getResources().getConfiguration().locale); mReferrerPackage = referrerPackage; diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt index a7273a86..7b9a0ce6 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt @@ -53,6 +53,7 @@ class ChooserContentPreviewUiTest { 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 = emptyList() override fun getModifyShareAction(): ActionRow.Action? = null override fun getExcludeSharedTextAction(): Consumer = Consumer {} diff --git a/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java b/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java index 5f0ead7b..892a2e28 100644 --- a/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java +++ b/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java @@ -40,82 +40,52 @@ public class AbstractResolverComparatorTest { @Test public void testPinned() { - ResolvedComponentInfo r1 = createResolvedComponentInfo( - new ComponentName("package", "class")); + ResolvedComponentInfo r1 = new ResolvedComponentInfo( + new ComponentName("package", "class"), new Intent(), new ResolveInfo() + ); r1.setPinned(true); - ResolvedComponentInfo r2 = createResolvedComponentInfo( - new ComponentName("zackage", "zlass")); + ResolvedComponentInfo r2 = new ResolvedComponentInfo( + new ComponentName("zackage", "zlass"), new Intent(), new ResolveInfo() + ); Context context = InstrumentationRegistry.getTargetContext(); - AbstractResolverComparator comparator = getTestComparator(context, null); + AbstractResolverComparator comparator = getTestComparator(context); assertEquals("Pinned ranks over unpinned", -1, comparator.compare(r1, r2)); assertEquals("Unpinned ranks under pinned", 1, comparator.compare(r2, r1)); } + @Test public void testBothPinned() { - ResolvedComponentInfo r1 = createResolvedComponentInfo( - new ComponentName("package", "class")); + ResolveInfo pmInfo1 = new ResolveInfo(); + pmInfo1.activityInfo = new ActivityInfo(); + pmInfo1.activityInfo.packageName = "aaa"; + + ResolvedComponentInfo r1 = new ResolvedComponentInfo( + new ComponentName("package", "class"), new Intent(), pmInfo1); r1.setPinned(true); - ResolvedComponentInfo r2 = createResolvedComponentInfo( - new ComponentName("zackage", "zlass")); + ResolveInfo pmInfo2 = new ResolveInfo(); + pmInfo2.activityInfo = new ActivityInfo(); + pmInfo2.activityInfo.packageName = "zzz"; + ResolvedComponentInfo r2 = new ResolvedComponentInfo( + new ComponentName("zackage", "zlass"), new Intent(), pmInfo2); r2.setPinned(true); Context context = InstrumentationRegistry.getTargetContext(); - AbstractResolverComparator comparator = getTestComparator(context, null); + AbstractResolverComparator comparator = getTestComparator(context); assertEquals("Both pinned should rank alphabetically", -1, comparator.compare(r1, r2)); } - @Test - public void testPromoteToFirst() { - ComponentName promoteToFirst = new ComponentName("promoted-package", "class"); - ResolvedComponentInfo r1 = createResolvedComponentInfo(promoteToFirst); - - ResolvedComponentInfo r2 = createResolvedComponentInfo( - new ComponentName("package", "class")); - - Context context = InstrumentationRegistry.getTargetContext(); - AbstractResolverComparator comparator = getTestComparator(context, promoteToFirst); - - assertEquals("PromoteToFirst ranks over non-cemented", -1, comparator.compare(r1, r2)); - assertEquals("Non-cemented ranks under PromoteToFirst", 1, comparator.compare(r2, r1)); - } - - @Test - public void testPromoteToFirstOverPinned() { - ComponentName cementedComponent = new ComponentName("promoted-package", "class"); - ResolvedComponentInfo r1 = createResolvedComponentInfo(cementedComponent); - - ResolvedComponentInfo r2 = createResolvedComponentInfo( - new ComponentName("package", "class")); - r2.setPinned(true); - - Context context = InstrumentationRegistry.getTargetContext(); - AbstractResolverComparator comparator = getTestComparator(context, cementedComponent); - - assertEquals("PromoteToFirst ranks over pinned", -1, comparator.compare(r1, r2)); - assertEquals("Pinned ranks under PromoteToFirst", 1, comparator.compare(r2, r1)); - } - - private ResolvedComponentInfo createResolvedComponentInfo(ComponentName component) { - ResolveInfo info = new ResolveInfo(); - info.activityInfo = new ActivityInfo(); - info.activityInfo.packageName = component.getPackageName(); - info.activityInfo.name = component.getClassName(); - return new ResolvedComponentInfo(component, new Intent(), info); - } - - private AbstractResolverComparator getTestComparator( - Context context, ComponentName promoteToFirst) { + private AbstractResolverComparator getTestComparator(Context context) { Intent intent = new Intent(); AbstractResolverComparator testComparator = new AbstractResolverComparator(context, intent, - Lists.newArrayList(context.getUser()), promoteToFirst) { + Lists.newArrayList(context.getUser())) { @Override int compare(ResolveInfo lhs, ResolveInfo rhs) { -- cgit v1.2.3-59-g8ed1b From c393f91bcf5976d2db378b1da5a82a37ddc8e6c1 Mon Sep 17 00:00:00 2001 From: 1 Date: Fri, 21 Apr 2023 02:23:11 +0000 Subject: Align file sharing UI with mocks. Mocks: https://www.figma.com/file/JVIcmg4OhWuO5Vc1UdPVxe/Sharesheet-(U)?node-id=66-5067&t=GK4LwE6SDaQyVyov-0 - Split text into two lines (when needed) - Change headline to "files" instead of "items". - Shaded background around content. - Just use one icon for now (this will be updated later). Bug: 279070016 Test: atest UnbundledChooserActivityTest Test: atest HeadlineGeneratorImplTest Change-Id: I696b4bc21250290253b78b56c6fc20278e939a8a --- java/res/layout/chooser_grid_preview_file.xml | 76 +++++++++++++--------- java/res/layout/chooser_grid_preview_text.xml | 1 - java/res/values/strings.xml | 21 +++--- .../contentpreview/FileContentPreviewUi.java | 30 ++++----- .../FilesPlusTextContentPreviewUi.java | 2 +- .../contentpreview/HeadlineGenerator.kt | 4 +- .../contentpreview/HeadlineGeneratorImpl.kt | 4 +- .../contentpreview/UnifiedContentPreviewUi.java | 2 +- .../UnbundledChooserActivityTest.java | 40 +++++------- .../contentpreview/HeadlineGeneratorImplTest.kt | 4 +- 10 files changed, 90 insertions(+), 94 deletions(-) (limited to 'java/tests') diff --git a/java/res/layout/chooser_grid_preview_file.xml b/java/res/layout/chooser_grid_preview_file.xml index 40ab2f0b..9fd90f10 100644 --- a/java/res/layout/chooser_grid_preview_file.xml +++ b/java/res/layout/chooser_grid_preview_file.xml @@ -27,41 +27,53 @@ android:paddingBottom="@dimen/chooser_view_spacing" android:background="?android:attr/colorBackground"> - + - - - - - + android:layout_gravity="center" + android:layout_marginHorizontal="@dimen/chooser_edge_margin_normal" + android:layout_marginBottom="@dimen/chooser_view_spacing" + android:padding="@dimen/chooser_edge_margin_normal" + android:background="@drawable/chooser_content_preview_rounded" + android:id="@androidprv:id/content_preview_file_layout"> + + + + + + - + diff --git a/java/res/layout/chooser_grid_preview_text.xml b/java/res/layout/chooser_grid_preview_text.xml index e22cfbd2..3a390025 100644 --- a/java/res/layout/chooser_grid_preview_text.xml +++ b/java/res/layout/chooser_grid_preview_text.xml @@ -32,7 +32,6 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center" - android:orientation="horizontal" android:layout_marginLeft="@dimen/chooser_edge_margin_normal" android:layout_marginRight="@dimen/chooser_edge_margin_normal" android:layout_marginBottom="@dimen/chooser_view_spacing" diff --git a/java/res/values/strings.xml b/java/res/values/strings.xml index 1648831c..8a24b349 100644 --- a/java/res/values/strings.xml +++ b/java/res/values/strings.xml @@ -124,12 +124,6 @@ Edit - {count, plural, - =1 {{file_name} + # file} - other {{file_name} + # files} - } - - {count, plural, =1 {+ # file} @@ -137,6 +131,13 @@ } + + {count, plural, + =1 {+ # more file} + other {+ # more files} + } + + Sharing text @@ -153,11 +154,11 @@ other {Sharing # videos} } - - {count, plural, - =1 {Sharing # item} - other {Sharing # items} + {count, plural, + =1 {Sharing # file} + other {Sharing # files} } {count, plural, diff --git a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java index e9d65eed..d7d35100 100644 --- a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java @@ -22,7 +22,6 @@ 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 com.android.intentresolver.R; @@ -69,7 +68,7 @@ class FileContentPreviewUi extends ContentPreviewUi { final int uriCount = mFiles.size(); - displayHeadline(contentPreviewLayout, mHeadlineGenerator.getItemsHeadline(mFiles.size())); + displayHeadline(contentPreviewLayout, mHeadlineGenerator.getFilesHeadline(mFiles.size())); if (uriCount == 0) { contentPreviewLayout.setVisibility(View.GONE); @@ -79,26 +78,21 @@ class FileContentPreviewUi extends ContentPreviewUi { } FileInfo fileInfo = mFiles.get(0); - final CharSequence fileName; - final int iconId; - if (uriCount == 1) { - fileName = fileInfo.getName(); - iconId = R.drawable.chooser_file_generic; - } else { + TextView fileNameView = contentPreviewLayout.findViewById( + R.id.content_preview_filename); + fileNameView.setText(fileInfo.getName()); + + TextView secondLine = contentPreviewLayout.findViewById( + R.id.content_preview_more_files); + if (uriCount > 1) { int remUriCount = uriCount - 1; Map arguments = new HashMap<>(); arguments.put(PLURALS_COUNT, remUriCount); - arguments.put(PLURALS_FILE_NAME, fileInfo.getName()); - fileName = PluralsMessageFormatter.format(resources, arguments, R.string.file_count); - iconId = R.drawable.ic_file_copy; + secondLine.setText( + PluralsMessageFormatter.format(resources, arguments, R.string.more_files)); + } else { + secondLine.setVisibility(View.GONE); } - TextView fileNameView = contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_filename); - fileNameView.setText(fileName); - - ImageView fileIconView = contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_file_icon); - fileIconView.setImageResource(iconId); final ActionRow actionRow = contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); diff --git a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java index 5174234a..12843e0a 100644 --- a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java @@ -153,7 +153,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { } else if (mAllVideos) { headline = mHeadlineGenerator.getVideosHeadline(mFiles.size()); } else { - headline = mHeadlineGenerator.getItemsHeadline(mFiles.size()); + headline = mHeadlineGenerator.getFilesHeadline(mFiles.size()); } } diff --git a/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt b/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt index ad2a7ada..5f87c924 100644 --- a/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt +++ b/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt @@ -16,8 +16,6 @@ package com.android.intentresolver.contentpreview -private const val PLURALS_COUNT = "count" - /** * HeadlineGenerator generates the text to show at the top of the sharesheet as a brief * description of the content being shared. @@ -35,5 +33,5 @@ interface HeadlineGenerator { fun getVideosHeadline(count: Int): String - fun getItemsHeadline(count: Int): String + fun getFilesHeadline(count: Int): String } diff --git a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt index a6b782ad..1aace8c3 100644 --- a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt +++ b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt @@ -56,8 +56,8 @@ class HeadlineGeneratorImpl(private val context: Context) : HeadlineGenerator { return getPluralString(R.string.sharing_videos, count) } - override fun getItemsHeadline(count: Int): String { - return getPluralString(R.string.sharing_items, count) + override fun getFilesHeadline(count: Int): String { + return getPluralString(R.string.sharing_files, count) } private fun getPluralString(@StringRes templateResource: Int, count: Int): String { diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java index 6f1be116..8aa8026c 100644 --- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -130,7 +130,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { contentPreviewLayout, mHeadlineGenerator.getVideosHeadline(mFiles.size())); } else { displayHeadline( - contentPreviewLayout, mHeadlineGenerator.getItemsHeadline(mFiles.size())); + contentPreviewLayout, mHeadlineGenerator.getFilesHeadline(mFiles.size())); } return contentPreviewLayout; diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 385f9fd8..cbc8d53e 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -1203,12 +1203,9 @@ public class UnbundledChooserActivityTest { setupResolverControllers(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); - onView(withId(com.android.internal.R.id.content_preview_filename)) - .check(matches(isDisplayed())); - onView(withId(com.android.internal.R.id.content_preview_filename)) - .check(matches(withText("app.pdf"))); - onView(withId(com.android.internal.R.id.content_preview_file_icon)) - .check(matches(isDisplayed())); + onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); + onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf"))); + onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); } @@ -1228,12 +1225,11 @@ public class UnbundledChooserActivityTest { setupResolverControllers(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); - onView(withId(com.android.internal.R.id.content_preview_filename)) - .check(matches(isDisplayed())); - onView(withId(com.android.internal.R.id.content_preview_filename)) - .check(matches(withText("app.pdf + 2 files"))); - onView(withId(com.android.internal.R.id.content_preview_file_icon)) - .check(matches(isDisplayed())); + onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); + onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf"))); + onView(withId(R.id.content_preview_more_files)).check(matches(isDisplayed())); + onView(withId(R.id.content_preview_more_files)).check(matches(withText("+ 2 more files"))); + onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); } @Test @@ -1252,12 +1248,9 @@ public class UnbundledChooserActivityTest { mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); - onView(withId(com.android.internal.R.id.content_preview_filename)) - .check(matches(isDisplayed())); - onView(withId(com.android.internal.R.id.content_preview_filename)) - .check(matches(withText("app.pdf"))); - onView(withId(com.android.internal.R.id.content_preview_file_icon)) - .check(matches(isDisplayed())); + onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); + onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf"))); + onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); } @Test @@ -1283,12 +1276,11 @@ public class UnbundledChooserActivityTest { mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); - onView(withId(com.android.internal.R.id.content_preview_filename)) - .check(matches(isDisplayed())); - onView(withId(com.android.internal.R.id.content_preview_filename)) - .check(matches(withText("app.pdf + 1 file"))); - onView(withId(com.android.internal.R.id.content_preview_file_icon)) - .check(matches(isDisplayed())); + onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); + onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf"))); + onView(withId(R.id.content_preview_more_files)).check(matches(isDisplayed())); + onView(withId(R.id.content_preview_more_files)).check(matches(withText("+ 1 more file"))); + onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); } @Test diff --git a/java/tests/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt index aac6caa7..a65280e5 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt @@ -55,7 +55,7 @@ class HeadlineGeneratorImplTest { assertThat(generator.getVideosHeadline(1)).isEqualTo("Sharing video") assertThat(generator.getVideosHeadline(4)).isEqualTo("Sharing 4 videos") - assertThat(generator.getItemsHeadline(1)).isEqualTo("Sharing 1 item") - assertThat(generator.getItemsHeadline(4)).isEqualTo("Sharing 4 items") + assertThat(generator.getFilesHeadline(1)).isEqualTo("Sharing 1 file") + assertThat(generator.getFilesHeadline(4)).isEqualTo("Sharing 4 files") } } -- cgit v1.2.3-59-g8ed1b From 2bc18fb62994d7c1b9b6dfd57974cd7a62e0cb3e Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Mon, 24 Apr 2023 15:12:41 +0000 Subject: Revert "Revert^2 "Revert "Move Nearby to the front of the app list.""" This reverts commit ffc1dc5709c794effc8b2e7b0761a80ecc8abb77. Reason for revert: Removing the offending portion of the test in ag/22815196 Change-Id: I8787f57e570e7fe2fe8bcb38aef33d14ae694d8d --- .../intentresolver/ChooserActionFactory.java | 94 ---------------------- .../android/intentresolver/ChooserActivity.java | 7 +- .../intentresolver/ChooserRequestParameters.java | 32 ++------ .../android/intentresolver/ResolverActivity.java | 3 +- .../contentpreview/ChooserContentPreviewUi.java | 4 - .../contentpreview/FileContentPreviewUi.java | 15 +--- .../contentpreview/TextContentPreviewUi.java | 4 - .../contentpreview/UnifiedContentPreviewUi.java | 8 +- .../model/AbstractResolverComparator.java | 37 ++++----- .../AppPredictionServiceResolverComparator.java | 8 +- .../ResolverRankerServiceResolverComparator.java | 10 ++- .../contentpreview/ChooserContentPreviewUiTest.kt | 1 - .../model/AbstractResolverComparatorTest.java | 74 ++++++++++++----- 13 files changed, 94 insertions(+), 203 deletions(-) (limited to 'java/tests') diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java index 23e04560..f355d9d4 100644 --- a/java/src/com/android/intentresolver/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/ChooserActionFactory.java @@ -26,12 +26,9 @@ import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; -import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.ResolveInfo; -import android.content.res.Resources; import android.graphics.drawable.Drawable; import android.net.Uri; -import android.os.Bundle; import android.service.chooser.ChooserAction; import android.text.TextUtils; import android.util.Log; @@ -92,8 +89,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio private final Runnable mOnCopyButtonClicked; private final TargetInfo mEditSharingTarget; private final Runnable mOnEditButtonClicked; - private final TargetInfo mNearbySharingTarget; - private final Runnable mOnNearbyButtonClicked; private final ImmutableList mCustomActions; private final @Nullable ChooserAction mModifyShareAction; private final Consumer mExcludeSharedTextAction; @@ -144,18 +139,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio firstVisibleImageQuery, activityStarter, logger), - getNearbySharingTarget( - context, - chooserRequest.getTargetIntent(), - integratedDeviceComponents), - makeOnNearbyShareRunnable( - getNearbySharingTarget( - context, - chooserRequest.getTargetIntent(), - integratedDeviceComponents), - activityStarter, - finishCallback, - logger), chooserRequest.getChooserActions(), chooserRequest.getModifyShareAction(), onUpdateSharedTextIsExcluded, @@ -171,8 +154,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio Runnable onCopyButtonClicked, TargetInfo editSharingTarget, Runnable onEditButtonClicked, - TargetInfo nearbySharingTarget, - Runnable onNearbyButtonClicked, List customActions, @Nullable ChooserAction modifyShareAction, Consumer onUpdateSharedTextIsExcluded, @@ -184,8 +165,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio mOnCopyButtonClicked = onCopyButtonClicked; mEditSharingTarget = editSharingTarget; mOnEditButtonClicked = onEditButtonClicked; - mNearbySharingTarget = nearbySharingTarget; - mOnNearbyButtonClicked = onNearbyButtonClicked; mCustomActions = ImmutableList.copyOf(customActions); mModifyShareAction = modifyShareAction; mExcludeSharedTextAction = onUpdateSharedTextIsExcluded; @@ -218,21 +197,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio mOnEditButtonClicked); } - /** Create a "Share to Nearby" action. */ - @Override - @Nullable - public ActionRow.Action createNearbyButton() { - if (mNearbySharingTarget == null) { - return null; - } - - return new ActionRow.Action( - com.android.internal.R.id.chooser_nearby_button, - mNearbySharingTarget.getDisplayLabel(), - mNearbySharingTarget.getDisplayIconHolder().getDisplayIcon(), - mOnNearbyButtonClicked); - } - /** Create custom actions */ @Override public List createCustomActions() { @@ -403,64 +367,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio }; } - private static TargetInfo getNearbySharingTarget( - Context context, - Intent originalIntent, - ChooserIntegratedDeviceComponents integratedComponents) { - final ComponentName cn = integratedComponents.getNearbySharingComponent(); - if (cn == null) { - return null; - } - - final Intent resolveIntent = new Intent(originalIntent); - resolveIntent.setComponent(cn); - final ResolveInfo ri = context.getPackageManager().resolveActivity( - resolveIntent, PackageManager.GET_META_DATA); - if (ri == null || ri.activityInfo == null) { - Log.e(TAG, "Device-specified nearby sharing component (" + cn - + ") not available"); - return null; - } - - // Allow the nearby sharing component to provide a more appropriate icon and label - // for the chip. - CharSequence name = null; - Drawable icon = null; - final Bundle metaData = ri.activityInfo.metaData; - if (metaData != null) { - try { - final Resources pkgRes = context.getPackageManager().getResourcesForActivity(cn); - final int nameResId = metaData.getInt(CHIP_LABEL_METADATA_KEY); - name = pkgRes.getString(nameResId); - final int resId = metaData.getInt(CHIP_ICON_METADATA_KEY); - icon = pkgRes.getDrawable(resId); - } catch (NameNotFoundException | Resources.NotFoundException ex) { /* ignore */ } - } - if (TextUtils.isEmpty(name)) { - name = ri.loadLabel(context.getPackageManager()); - } - if (icon == null) { - icon = ri.loadIcon(context.getPackageManager()); - } - - final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( - originalIntent, ri, name, "", resolveIntent, null); - dri.getDisplayIconHolder().setDisplayIcon(icon); - return dri; - } - - private static Runnable makeOnNearbyShareRunnable( - TargetInfo nearbyShareTarget, - ActionActivityStarter activityStarter, - Consumer finishCallback, - ChooserActivityLogger logger) { - return () -> { - logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_NEARBY); - // Action bar is user-independent; always start as primary. - activityStarter.safelyStartActivityAsPersonalProfileUser(nearbyShareTarget); - }; - } - @Nullable private static ActionRow.Action createCustomAction( Context context, diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 404d6da3..ab2ba91e 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -239,7 +239,6 @@ public class ChooserActivity extends ResolverActivity implements getIntent(), getReferrerPackageName(), getReferrer(), - mIntegratedDeviceComponents, mFeatureFlagRepository); } catch (IllegalArgumentException e) { Log.e(TAG, "Caller provided invalid Chooser request parameters", e); @@ -1261,7 +1260,8 @@ public class ChooserActivity extends ResolverActivity implements AbstractResolverComparator resolverComparator; if (appPredictor != null) { resolverComparator = new AppPredictionServiceResolverComparator(this, getTargetIntent(), - getReferrerPackageName(), appPredictor, userHandle, getChooserActivityLogger()); + getReferrerPackageName(), appPredictor, userHandle, getChooserActivityLogger(), + getIntegratedDeviceComponents().getNearbySharingComponent()); } else { resolverComparator = new ResolverRankerServiceResolverComparator( @@ -1270,7 +1270,8 @@ public class ChooserActivity extends ResolverActivity implements getReferrerPackageName(), null, getChooserActivityLogger(), - getResolverRankerServiceUserHandleList(userHandle)); + getResolverRankerServiceUserHandleList(userHandle), + getIntegratedDeviceComponents().getNearbySharingComponent()); } return new ChooserListController( diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java index f9004a9b..039f50e9 100644 --- a/java/src/com/android/intentresolver/ChooserRequestParameters.java +++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java @@ -69,7 +69,6 @@ public class ChooserRequestParameters { Intent.FLAG_ACTIVITY_NEW_DOCUMENT | Intent.FLAG_ACTIVITY_MULTIPLE_TASK; private final Intent mTarget; - private final ChooserIntegratedDeviceComponents mIntegratedDeviceComponents; private final String mReferrerPackageName; private final Pair mTitleSpec; private final Intent mReferrerFillInIntent; @@ -104,14 +103,11 @@ public class ChooserRequestParameters { final Intent clientIntent, String referrerPackageName, final Uri referrer, - ChooserIntegratedDeviceComponents integratedDeviceComponents, FeatureFlagRepository featureFlags) { final Intent requestedTarget = parseTargetIntentExtra( clientIntent.getParcelableExtra(Intent.EXTRA_INTENT)); mTarget = intentWithModifiedLaunchFlags(requestedTarget); - mIntegratedDeviceComponents = integratedDeviceComponents; - mReferrerPackageName = referrerPackageName; mAdditionalTargets = intentsWithModifiedLaunchFlagsFromExtraIfPresent( @@ -133,8 +129,11 @@ public class ChooserRequestParameters { mRefinementIntentSender = clientIntent.getParcelableExtra( Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER); - mFilteredComponentNames = getFilteredComponentNames( - clientIntent, mIntegratedDeviceComponents.getNearbySharingComponent()); + ComponentName[] filteredComponents = clientIntent.getParcelableArrayExtra( + Intent.EXTRA_EXCLUDE_COMPONENTS, ComponentName.class); + mFilteredComponentNames = filteredComponents != null + ? ImmutableList.copyOf(filteredComponents) + : ImmutableList.of(); mCallerChooserTargets = parseCallerTargetsFromClientIntent(clientIntent); @@ -252,10 +251,6 @@ public class ChooserRequestParameters { return mTargetIntentFilter; } - public ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() { - return mIntegratedDeviceComponents; - } - private static boolean isSendAction(@Nullable String action) { return (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action)); } @@ -309,23 +304,6 @@ public class ChooserRequestParameters { return Pair.create(requestedTitle, defaultTitleRes); } - private static ImmutableList getFilteredComponentNames( - Intent clientIntent, @Nullable ComponentName nearbySharingComponent) { - Stream filteredComponents = streamParcelableArrayExtra( - clientIntent, Intent.EXTRA_EXCLUDE_COMPONENTS, ComponentName.class, true, true); - - if (nearbySharingComponent != null) { - // Exclude Nearby from main list if chip is present, to avoid duplication. - // TODO: we don't have an explicit guarantee that the chip will be displayed just - // because we have a non-null component; that's ultimately determined by the preview - // layout. Maybe we can make that decision further upstream? - filteredComponents = Stream.concat( - filteredComponents, Stream.of(nearbySharingComponent)); - } - - return filteredComponents.collect(toImmutableList()); - } - private static ImmutableList parseCallerTargetsFromClientIntent( Intent clientIntent) { return diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 3b9d2a53..66eae92d 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -871,7 +871,8 @@ public class ResolverActivity extends FragmentActivity implements getReferrerPackageName(), null, null, - getResolverRankerServiceUserHandleList(userHandle)); + getResolverRankerServiceUserHandleList(userHandle), + null); return new ResolverListController( this, mPm, diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index 56027a16..3c2ee343 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -66,10 +66,6 @@ public final class ChooserContentPreviewUi { @Nullable ActionRow.Action createEditButton(); - /** Create an "Share to Nearby" action. */ - @Nullable - ActionRow.Action createNearbyButton(); - /** Create custom actions */ List createCustomActions(); diff --git a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java index e9d65eed..cae9403a 100644 --- a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java @@ -103,21 +103,8 @@ class FileContentPreviewUi extends ContentPreviewUi { final ActionRow actionRow = contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); actionRow.setActions( - createActions( - createFilePreviewActions(), - mActionFactory.createCustomActions())); + createActions(new ArrayList<>(), mActionFactory.createCustomActions())); return contentPreviewLayout; } - - private List createFilePreviewActions() { - List actions = new ArrayList<>(1); - //TODO(b/120417119): - // add action buttonFactory.createCopyButton() - ActionRow.Action action = mActionFactory.createNearbyButton(); - if (action != null) { - actions.add(action); - } - return actions; - } } diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java index ece0c312..44a9e654 100644 --- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java @@ -125,10 +125,6 @@ class TextContentPreviewUi extends ContentPreviewUi { private List createTextPreviewActions() { ArrayList 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/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java index 9ce875c8..ebf9bf11 100644 --- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -152,14 +152,10 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { } private List createImagePreviewActions() { - ArrayList actions = new ArrayList<>(2); + ArrayList actions = new ArrayList<>(1); //TODO: add copy action; - ActionRow.Action action = mActionFactory.createNearbyButton(); - if (action != null) { - actions.add(action); - } if (mFiles.size() == 1 && mTypeClassifier.isImageType(mFiles.get(0).getMimeType())) { - action = mActionFactory.createEditButton(); + ActionRow.Action action = mActionFactory.createEditButton(); if (action != null) { actions.add(action); } diff --git a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java index 7357fde9..bc54e01e 100644 --- a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java +++ b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java @@ -16,6 +16,7 @@ package com.android.intentresolver.model; +import android.annotation.Nullable; import android.app.usage.UsageStatsManager; import android.content.ComponentName; import android.content.Context; @@ -34,8 +35,6 @@ import com.android.intentresolver.ResolvedComponentInfo; import com.android.intentresolver.ResolverActivity; import com.android.intentresolver.chooser.TargetInfo; -import com.google.android.collect.Lists; - import java.text.Collator; import java.util.ArrayList; import java.util.Comparator; @@ -59,6 +58,7 @@ public abstract class AbstractResolverComparator implements Comparator mUsmMap = new HashMap<>(); protected String[] mAnnotations; protected String mContentType; + protected final ComponentName mPromoteToFirst; // True if the current share is a link. private final boolean mHttp; @@ -105,23 +105,6 @@ public abstract class AbstractResolverComparator implements Comparator resolvedActivityUserSpaceList) { + List resolvedActivityUserSpaceList, + @Nullable ComponentName promoteToFirst) { String scheme = intent.getScheme(); mHttp = "http".equals(scheme) || "https".equals(scheme); mContentType = intent.getType(); @@ -147,6 +133,7 @@ public abstract class AbstractResolverComparator implements Comparator mHandler.sendEmptyMessage(RANKER_SERVICE_RESULT), getChooserActivityLogger(), - mUser); + mUser, + mPromoteToFirst); mComparatorModel = buildUpdatedModel(); mResolverRankerService.compute(targets); } else { diff --git a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java index 725212e4..ebaffc36 100644 --- a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java +++ b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java @@ -102,9 +102,10 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom */ public ResolverRankerServiceResolverComparator(Context launchedFromContext, Intent intent, String referrerPackage, Runnable afterCompute, - ChooserActivityLogger chooserActivityLogger, UserHandle targetUserSpace) { + ChooserActivityLogger chooserActivityLogger, UserHandle targetUserSpace, + ComponentName promoteToFirst) { this(launchedFromContext, intent, referrerPackage, afterCompute, chooserActivityLogger, - Lists.newArrayList(targetUserSpace)); + Lists.newArrayList(targetUserSpace), promoteToFirst); } /** @@ -117,8 +118,9 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom */ public ResolverRankerServiceResolverComparator(Context launchedFromContext, Intent intent, String referrerPackage, Runnable afterCompute, - ChooserActivityLogger chooserActivityLogger, List targetUserSpaceList) { - super(launchedFromContext, intent, targetUserSpaceList); + ChooserActivityLogger chooserActivityLogger, List targetUserSpaceList, + @Nullable ComponentName promoteToFirst) { + super(launchedFromContext, intent, targetUserSpaceList, promoteToFirst); mCollator = Collator.getInstance( launchedFromContext.getResources().getConfiguration().locale); mReferrerPackage = referrerPackage; diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt index 7b9a0ce6..a7273a86 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt @@ -53,7 +53,6 @@ class ChooserContentPreviewUiTest { 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 = emptyList() override fun getModifyShareAction(): ActionRow.Action? = null override fun getExcludeSharedTextAction(): Consumer = Consumer {} diff --git a/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java b/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java index 892a2e28..5f0ead7b 100644 --- a/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java +++ b/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java @@ -40,52 +40,82 @@ public class AbstractResolverComparatorTest { @Test public void testPinned() { - ResolvedComponentInfo r1 = new ResolvedComponentInfo( - new ComponentName("package", "class"), new Intent(), new ResolveInfo() - ); + ResolvedComponentInfo r1 = createResolvedComponentInfo( + new ComponentName("package", "class")); r1.setPinned(true); - ResolvedComponentInfo r2 = new ResolvedComponentInfo( - new ComponentName("zackage", "zlass"), new Intent(), new ResolveInfo() - ); + ResolvedComponentInfo r2 = createResolvedComponentInfo( + new ComponentName("zackage", "zlass")); Context context = InstrumentationRegistry.getTargetContext(); - AbstractResolverComparator comparator = getTestComparator(context); + AbstractResolverComparator comparator = getTestComparator(context, null); assertEquals("Pinned ranks over unpinned", -1, comparator.compare(r1, r2)); assertEquals("Unpinned ranks under pinned", 1, comparator.compare(r2, r1)); } - @Test public void testBothPinned() { - ResolveInfo pmInfo1 = new ResolveInfo(); - pmInfo1.activityInfo = new ActivityInfo(); - pmInfo1.activityInfo.packageName = "aaa"; - - ResolvedComponentInfo r1 = new ResolvedComponentInfo( - new ComponentName("package", "class"), new Intent(), pmInfo1); + ResolvedComponentInfo r1 = createResolvedComponentInfo( + new ComponentName("package", "class")); r1.setPinned(true); - ResolveInfo pmInfo2 = new ResolveInfo(); - pmInfo2.activityInfo = new ActivityInfo(); - pmInfo2.activityInfo.packageName = "zzz"; - ResolvedComponentInfo r2 = new ResolvedComponentInfo( - new ComponentName("zackage", "zlass"), new Intent(), pmInfo2); + ResolvedComponentInfo r2 = createResolvedComponentInfo( + new ComponentName("zackage", "zlass")); r2.setPinned(true); Context context = InstrumentationRegistry.getTargetContext(); - AbstractResolverComparator comparator = getTestComparator(context); + AbstractResolverComparator comparator = getTestComparator(context, null); assertEquals("Both pinned should rank alphabetically", -1, comparator.compare(r1, r2)); } - private AbstractResolverComparator getTestComparator(Context context) { + @Test + public void testPromoteToFirst() { + ComponentName promoteToFirst = new ComponentName("promoted-package", "class"); + ResolvedComponentInfo r1 = createResolvedComponentInfo(promoteToFirst); + + ResolvedComponentInfo r2 = createResolvedComponentInfo( + new ComponentName("package", "class")); + + Context context = InstrumentationRegistry.getTargetContext(); + AbstractResolverComparator comparator = getTestComparator(context, promoteToFirst); + + assertEquals("PromoteToFirst ranks over non-cemented", -1, comparator.compare(r1, r2)); + assertEquals("Non-cemented ranks under PromoteToFirst", 1, comparator.compare(r2, r1)); + } + + @Test + public void testPromoteToFirstOverPinned() { + ComponentName cementedComponent = new ComponentName("promoted-package", "class"); + ResolvedComponentInfo r1 = createResolvedComponentInfo(cementedComponent); + + ResolvedComponentInfo r2 = createResolvedComponentInfo( + new ComponentName("package", "class")); + r2.setPinned(true); + + Context context = InstrumentationRegistry.getTargetContext(); + AbstractResolverComparator comparator = getTestComparator(context, cementedComponent); + + assertEquals("PromoteToFirst ranks over pinned", -1, comparator.compare(r1, r2)); + assertEquals("Pinned ranks under PromoteToFirst", 1, comparator.compare(r2, r1)); + } + + private ResolvedComponentInfo createResolvedComponentInfo(ComponentName component) { + ResolveInfo info = new ResolveInfo(); + info.activityInfo = new ActivityInfo(); + info.activityInfo.packageName = component.getPackageName(); + info.activityInfo.name = component.getClassName(); + return new ResolvedComponentInfo(component, new Intent(), info); + } + + private AbstractResolverComparator getTestComparator( + Context context, ComponentName promoteToFirst) { Intent intent = new Intent(); AbstractResolverComparator testComparator = new AbstractResolverComparator(context, intent, - Lists.newArrayList(context.getUser())) { + Lists.newArrayList(context.getUser()), promoteToFirst) { @Override int compare(ResolveInfo lhs, ResolveInfo rhs) { -- cgit v1.2.3-59-g8ed1b From 20e622725e112c85edd3fc7f52592782bf63b9a2 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Wed, 19 Apr 2023 09:18:56 -0700 Subject: Load shortucts concurrently with app target resolution Reload shortcuts whenever we rebuild the target list: * in ChooserActivity#onHandlePackageChange(), called by system package change broadcast, target pinning, and ResolverActivity#onRestart; * in work profile status broadcast receiver. Tie ShorctuLoader to a Lifecycle (instead of exposing lifecycle methods i.e. destroy) and switch to coroutines from executors. Add a startup-to-first-shortcut tracing and a debug log. Bug: 262927266 Test: manual testing, unit tests. Change-Id: Iaa4bd9a88f29378d75d88b2ea8fc3698cbd3be8f --- .../android/intentresolver/ChooserActivity.java | 54 +++++-- .../android/intentresolver/ResolverActivity.java | 18 +-- .../android/intentresolver/measurements/Tracer.kt | 46 ++++++ .../intentresolver/shortcuts/ShortcutLoader.kt | 150 +++++++++++-------- .../UnbundledChooserActivityTest.java | 18 +-- .../intentresolver/shortcuts/ShortcutLoaderTest.kt | 163 ++++++++++++++++----- 6 files changed, 318 insertions(+), 131 deletions(-) create mode 100644 java/src/com/android/intentresolver/measurements/Tracer.kt (limited to 'java/tests') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 97161452..7f55f78f 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -88,6 +88,7 @@ import com.android.intentresolver.contentpreview.ImageLoader; import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.flags.FeatureFlagRepositoryFactory; import com.android.intentresolver.grid.ChooserGridAdapter; +import com.android.intentresolver.measurements.Tracer; import com.android.intentresolver.model.AbstractResolverComparator; import com.android.intentresolver.model.AppPredictionServiceResolverComparator; import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; @@ -227,6 +228,7 @@ public class ChooserActivity extends ResolverActivity implements @Override protected void onCreate(Bundle savedInstanceState) { + Tracer.INSTANCE.markLaunched(); final long intentReceivedTime = System.currentTimeMillis(); mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET); @@ -362,7 +364,10 @@ public class ChooserActivity extends ResolverActivity implements private void createProfileRecords( AppPredictorFactory factory, IntentFilter targetIntentFilter) { UserHandle mainUserHandle = getPersonalProfileUserHandle(); - createProfileRecord(mainUserHandle, targetIntentFilter, factory); + ProfileRecord record = createProfileRecord(mainUserHandle, targetIntentFilter, factory); + if (record.shortcutLoader == null) { + Tracer.INSTANCE.endLaunchToShortcutTrace(); + } UserHandle workUserHandle = getWorkProfileUserHandle(); if (workUserHandle != null) { @@ -370,7 +375,7 @@ public class ChooserActivity extends ResolverActivity implements } } - private void createProfileRecord( + private ProfileRecord createProfileRecord( UserHandle userHandle, IntentFilter targetIntentFilter, AppPredictorFactory factory) { AppPredictor appPredictor = factory.create(userHandle); ShortcutLoader shortcutLoader = ActivityManager.isLowRamDeviceStatic() @@ -381,9 +386,9 @@ public class ChooserActivity extends ResolverActivity implements userHandle, targetIntentFilter, shortcutsResult -> onShortcutsLoaded(userHandle, shortcutsResult)); - mProfileRecords.put( - userHandle.getIdentifier(), - new ProfileRecord(appPredictor, shortcutLoader)); + ProfileRecord record = new ProfileRecord(appPredictor, shortcutLoader); + mProfileRecords.put(userHandle.getIdentifier(), record); + return record; } @Nullable @@ -400,6 +405,7 @@ public class ChooserActivity extends ResolverActivity implements Consumer callback) { return new ShortcutLoader( context, + getLifecycle(), appPredictor, userHandle, targetIntentFilter, @@ -580,16 +586,25 @@ public class ChooserActivity extends ResolverActivity implements // Refresh pinned items mPinnedSharedPrefs = getPinnedSharedPrefs(this); if (listAdapter == null) { - mChooserMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); + handlePackageChangePerProfile(mChooserMultiProfilePagerAdapter.getActiveListAdapter()); if (mChooserMultiProfilePagerAdapter.getCount() > 1) { - mChooserMultiProfilePagerAdapter.getInactiveListAdapter().handlePackagesChanged(); + handlePackageChangePerProfile( + mChooserMultiProfilePagerAdapter.getInactiveListAdapter()); } } else { - listAdapter.handlePackagesChanged(); + handlePackageChangePerProfile(listAdapter); } updateProfileViewButton(); } + private void handlePackageChangePerProfile(ResolverListAdapter adapter) { + ProfileRecord record = getProfileRecord(adapter.getUserHandle()); + if (record != null && record.shortcutLoader != null) { + record.shortcutLoader.reset(); + } + adapter.handlePackagesChanged(); + } + @Override protected void onResume() { super.onResume(); @@ -1254,6 +1269,16 @@ public class ChooserActivity extends ResolverActivity implements initialIntentsUserSpace); } + @Override + protected void onWorkProfileStatusUpdated() { + UserHandle workUser = getWorkProfileUserHandle(); + ProfileRecord record = workUser == null ? null : getProfileRecord(workUser); + if (record != null && record.shortcutLoader != null) { + record.shortcutLoader.reset(); + } + super.onWorkProfileStatusUpdated(); + } + @Override @VisibleForTesting protected ChooserListController createListController(UserHandle userHandle) { @@ -1520,14 +1545,11 @@ public class ChooserActivity extends ResolverActivity implements private void maybeQueryAdditionalPostProcessingTargets(ChooserListAdapter chooserListAdapter) { UserHandle userHandle = chooserListAdapter.getUserHandle(); ProfileRecord record = getProfileRecord(userHandle); - if (record == null) { - return; - } - if (record.shortcutLoader == null) { + if (record == null || record.shortcutLoader == null) { return; } record.loadingStartTime = SystemClock.elapsedRealtime(); - record.shortcutLoader.queryShortcuts(chooserListAdapter.getDisplayResolveInfos()); + record.shortcutLoader.updateAppTargets(chooserListAdapter.getDisplayResolveInfos()); } @MainThread @@ -1553,6 +1575,9 @@ public class ChooserActivity extends ResolverActivity implements adapter.completeServiceTargetLoading(); } + if (mMultiProfilePagerAdapter.getActiveListAdapter() == adapter) { + Tracer.INSTANCE.endLaunchToShortcutTrace(); + } logDirectShareTargetReceived(userHandle); sendVoiceChoicesIfNeeded(); getChooserActivityLogger().logSharesheetDirectLoadComplete(); @@ -1883,9 +1908,6 @@ public class ChooserActivity extends ResolverActivity implements } public void destroy() { - if (shortcutLoader != null) { - shortcutLoader.destroy(); - } if (appPredictor != null) { appPredictor.destroy(); } diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 66eae92d..aea6c2c9 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -1003,21 +1003,21 @@ public class ResolverActivity extends FragmentActivity implements return new CrossProfileIntentsChecker(getContentResolver()); } - // @NonFinalForTesting - @VisibleForTesting protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() { final UserHandle workUser = getWorkProfileUserHandle(); return new WorkProfileAvailabilityManager( getSystemService(UserManager.class), workUser, - () -> { - if (mMultiProfilePagerAdapter.getCurrentUserHandle().equals(workUser)) { - mMultiProfilePagerAdapter.rebuildActiveTab(true); - } else { - mMultiProfilePagerAdapter.clearInactiveProfileCache(); - } - }); + this::onWorkProfileStatusUpdated); + } + + protected void onWorkProfileStatusUpdated() { + if (mMultiProfilePagerAdapter.getCurrentUserHandle().equals(getWorkProfileUserHandle())) { + mMultiProfilePagerAdapter.rebuildActiveTab(true); + } else { + mMultiProfilePagerAdapter.clearInactiveProfileCache(); + } } // @NonFinalForTesting diff --git a/java/src/com/android/intentresolver/measurements/Tracer.kt b/java/src/com/android/intentresolver/measurements/Tracer.kt new file mode 100644 index 00000000..168bda0e --- /dev/null +++ b/java/src/com/android/intentresolver/measurements/Tracer.kt @@ -0,0 +1,46 @@ +/* + * 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.measurements + +import android.os.Trace +import android.os.SystemClock +import android.util.Log +import java.util.concurrent.atomic.AtomicLong + +private const val TAG = "Tracer" +private const val SECTION_LAUNCH_TO_SHORTCUT = "launch-to-shortcut" + +object Tracer { + private val launchToFirstShortcut = AtomicLong(-1L) + + fun markLaunched() { + if (launchToFirstShortcut.compareAndSet(-1, elapsedTimeNow())) { + Trace.beginAsyncSection(SECTION_LAUNCH_TO_SHORTCUT, 1) + } + } + + fun endLaunchToShortcutTrace() { + val time = elapsedTimeNow() + val startTime = launchToFirstShortcut.get() + if (startTime >= 0 && launchToFirstShortcut.compareAndSet(startTime, -1L)) { + Trace.endAsyncSection(SECTION_LAUNCH_TO_SHORTCUT, 1) + Log.d(TAG, "stat to first shortcut time: ${time - startTime} ms") + } + } + + private fun elapsedTimeNow() = SystemClock.elapsedRealtime() +} diff --git a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt index 29e706d4..ee6893d0 100644 --- a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt +++ b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt @@ -26,7 +26,6 @@ import android.content.pm.PackageManager import android.content.pm.ShortcutInfo import android.content.pm.ShortcutManager import android.content.pm.ShortcutManager.ShareShortcutInfo -import android.os.AsyncTask import android.os.UserHandle import android.os.UserManager import android.service.chooser.ChooserTarget @@ -36,12 +35,19 @@ import androidx.annotation.MainThread import androidx.annotation.OpenForTesting import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.coroutineScope import com.android.intentresolver.chooser.DisplayResolveInfo -import java.lang.RuntimeException -import java.util.ArrayList -import java.util.HashMap +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.launch import java.util.concurrent.Executor -import java.util.concurrent.atomic.AtomicReference import java.util.function.Consumer /** @@ -49,86 +55,107 @@ import java.util.function.Consumer * * * A ShortcutLoader instance can be viewed as a per-profile singleton hot stream of shortcut - * updates. The shortcut loading is triggered by the [queryShortcuts], - * the processing will happen on the [backgroundExecutor] and the result is delivered - * through the [callback] on the [callbackExecutor], the main thread. - * - * - * The current version does not improve on the legacy in a way that it does not guarantee that - * each invocation of the [queryShortcuts] will be matched by an - * invocation of the callback (there are early terminations of the flow). Also, the fetched - * shortcuts would be matched against the last known input, i.e. two invocations of - * [queryShortcuts] may result in two callbacks where shortcuts are - * processed against the latest input. - * + * updates. The shortcut loading is triggered in the constructor or by the [reset] method, + * the processing happens on the [dispatcher] and the result is delivered + * through the [callback] on the default [lifecycle]'s dispatcher, the main thread. */ @OpenForTesting open class ShortcutLoader @VisibleForTesting constructor( private val context: Context, + private val lifecycle: Lifecycle, private val appPredictor: AppPredictorProxy?, private val userHandle: UserHandle, private val isPersonalProfile: Boolean, private val targetIntentFilter: IntentFilter?, - private val backgroundExecutor: Executor, - private val callbackExecutor: Executor, + private val dispatcher: CoroutineDispatcher, private val callback: Consumer ) { private val shortcutToChooserTargetConverter = ShortcutToChooserTargetConverter() private val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager - private val activeRequest = AtomicReference(NO_REQUEST) private val appPredictorCallback = AppPredictor.Callback { onAppPredictorCallback(it) } - @Volatile - private var isDestroyed = false + private val appTargetSource = MutableSharedFlow?>( + replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + private val shortcutSource = MutableSharedFlow( + replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + private val isDestroyed get() = !lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED) @MainThread constructor( context: Context, + lifecycle: Lifecycle, appPredictor: AppPredictor?, userHandle: UserHandle, targetIntentFilter: IntentFilter?, callback: Consumer ) : this( context, + lifecycle, appPredictor?.let { AppPredictorProxy(it) }, userHandle, userHandle == UserHandle.of(ActivityManager.getCurrentUser()), targetIntentFilter, - AsyncTask.SERIAL_EXECUTOR, - context.mainExecutor, + Dispatchers.IO, callback ) init { - appPredictor?.registerPredictionUpdates(callbackExecutor, appPredictorCallback) + appPredictor?.registerPredictionUpdates(dispatcher.asExecutor(), appPredictorCallback) + lifecycle.coroutineScope + .launch { + appTargetSource.combine(shortcutSource) { appTargets, shortcutData -> + if (appTargets == null || shortcutData == null) { + null + } else { + filterShortcuts( + appTargets, + shortcutData.shortcuts, + shortcutData.isFromAppPredictor, + shortcutData.appPredictorTargets + ) + } + } + .filter { it != null } + .flowOn(dispatcher) + .collect { + callback.accept(it ?: error("can not be null")) + } + } + .invokeOnCompletion { + runCatching { + appPredictor?.unregisterPredictionUpdates(appPredictorCallback) + } + Log.d(TAG, "destroyed, user: $userHandle") + } + reset() } /** - * Unsubscribe from app predictor if one was provided. + * Clear application targets (see [updateAppTargets] and initiate shrtcuts loading. */ - @OpenForTesting - @MainThread - open fun destroy() { - isDestroyed = true - appPredictor?.unregisterPredictionUpdates(appPredictorCallback) + fun reset() { + Log.d(TAG, "reset shortcut loader for user $userHandle") + appTargetSource.tryEmit(null) + shortcutSource.tryEmit(null) + lifecycle.coroutineScope.launch(dispatcher) { + loadShortcuts() + } } /** - * Set new resolved targets. This will trigger shortcut loading. - * @param appTargets a collection of application targets a loaded set of shortcuts will be - * grouped against + * Update resolved application targets; as soon as shortcuts are loaded, they will be filtered + * against the targets and the is delivered to the client through the [callback]. */ @OpenForTesting - @MainThread - open fun queryShortcuts(appTargets: Array) { - if (isDestroyed) return - activeRequest.set(Request(appTargets)) - backgroundExecutor.execute { loadShortcuts() } + open fun updateAppTargets(appTargets: Array) { + appTargetSource.tryEmit(appTargets) } @WorkerThread private fun loadShortcuts() { // no need to query direct share for work profile when its locked or disabled if (!shouldQueryDirectShareTargets()) return - Log.d(TAG, "querying direct share targets") + Log.d(TAG, "querying direct share targets for user $userHandle") queryDirectShareTargets(false) } @@ -141,7 +168,7 @@ open class ShortcutLoader @VisibleForTesting constructor( } catch (e: Throwable) { // we might have been destroyed concurrently, nothing left to do if (isDestroyed) return - Log.e(TAG, "Failed to query AppPredictor", e) + Log.e(TAG, "Failed to query AppPredictor for user $userHandle", e) } } // Default to just querying ShortcutManager if AppPredictor not present. @@ -196,6 +223,15 @@ open class ShortcutLoader @VisibleForTesting constructor( isFromAppPredictor: Boolean, appPredictorTargets: List? ) { + shortcutSource.tryEmit(ShortcutData(shortcuts, isFromAppPredictor, appPredictorTargets)) + } + + private fun filterShortcuts( + appTargets: Array, + shortcuts: List, + isFromAppPredictor: Boolean, + appPredictorTargets: List? + ): Result { if (appPredictorTargets != null && appPredictorTargets.size != shortcuts.size) { throw RuntimeException( "resultList and appTargets must have the same size." @@ -208,7 +244,6 @@ open class ShortcutLoader @VisibleForTesting constructor( // Match ShareShortcutInfos with DisplayResolveInfos to be able to use the old code path // for direct share targets. After ShareSheet is refactored we should use the // ShareShortcutInfos directly. - val appTargets = activeRequest.get().appTargets val resultRecords: MutableList = ArrayList() for (displayResolveInfo in appTargets) { val matchingShortcuts = shortcuts.filter { @@ -225,25 +260,15 @@ open class ShortcutLoader @VisibleForTesting constructor( val resultRecord = ShortcutResultInfo(displayResolveInfo, chooserTargets) resultRecords.add(resultRecord) } - postReport( - Result( - isFromAppPredictor, - appTargets, - resultRecords.toTypedArray(), - directShareAppTargetCache, - directShareShortcutInfoCache - ) + return Result( + isFromAppPredictor, + appTargets, + resultRecords.toTypedArray(), + directShareAppTargetCache, + directShareShortcutInfoCache ) } - private fun postReport(result: Result) = callbackExecutor.execute { report(result) } - - @MainThread - private fun report(result: Result) { - if (isDestroyed) return - callback.accept(result) - } - /** * Returns `false` if `userHandle` is the work profile and it's either * in quiet mode or not running. @@ -256,7 +281,11 @@ open class ShortcutLoader @VisibleForTesting constructor( && userManager.isUserUnlocked(userHandle) && !userManager.isQuietModeEnabled(userHandle) - private class Request(val appTargets: Array) + private class ShortcutData( + val shortcuts: List, + val isFromAppPredictor: Boolean, + val appPredictorTargets: List? + ) /** * Resolved shortcuts with corresponding app targets. @@ -264,7 +293,7 @@ open class ShortcutLoader @VisibleForTesting constructor( class Result( val isFromAppPredictor: Boolean, /** - * Input app targets (see [ShortcutLoader.queryShortcuts] the + * Input app targets (see [ShortcutLoader.updateAppTargets] the * shortcuts were process against. */ val appTargets: Array, @@ -315,7 +344,6 @@ open class ShortcutLoader @VisibleForTesting constructor( companion object { private const val TAG = "ShortcutLoader" - private val NO_REQUEST = Request(arrayOf()) private fun PackageManager.isPackageEnabled(packageName: String): Boolean { if (TextUtils.isEmpty(packageName)) { diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index cbc8d53e..6c659133 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -1342,7 +1342,7 @@ public class UnbundledChooserActivityTest { // verify that ShortcutLoader was queried ArgumentCaptor appTargets = ArgumentCaptor.forClass(DisplayResolveInfo[].class); - verify(shortcutLoaders.get(0).first, times(1)).queryShortcuts(appTargets.capture()); + verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture()); // send shortcuts assertThat( @@ -1423,7 +1423,7 @@ public class UnbundledChooserActivityTest { // verify that ShortcutLoader was queried ArgumentCaptor appTargets = ArgumentCaptor.forClass(DisplayResolveInfo[].class); - verify(shortcutLoaders.get(0).first, times(1)).queryShortcuts(appTargets.capture()); + verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture()); // send shortcuts assertThat( @@ -1508,7 +1508,7 @@ public class UnbundledChooserActivityTest { // verify that ShortcutLoader was queried ArgumentCaptor appTargets = ArgumentCaptor.forClass(DisplayResolveInfo[].class); - verify(shortcutLoaders.get(0).first, times(1)).queryShortcuts(appTargets.capture()); + verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture()); // send shortcuts assertThat( @@ -1583,7 +1583,7 @@ public class UnbundledChooserActivityTest { // verify that ShortcutLoader was queried ArgumentCaptor appTargets = ArgumentCaptor.forClass(DisplayResolveInfo[].class); - verify(shortcutLoaders.get(0).first, times(1)).queryShortcuts(appTargets.capture()); + verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture()); // send shortcuts assertThat( @@ -1675,7 +1675,7 @@ public class UnbundledChooserActivityTest { // verify that ShortcutLoader was queried ArgumentCaptor appTargets = ArgumentCaptor.forClass(DisplayResolveInfo[].class); - verify(shortcutLoaders.get(0).first, times(1)).queryShortcuts(appTargets.capture()); + verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture()); // send shortcuts assertThat( @@ -2174,7 +2174,7 @@ public class UnbundledChooserActivityTest { ArgumentCaptor appTargets = ArgumentCaptor.forClass(DisplayResolveInfo[].class); verify(shortcutLoaders.get(0).first, times(1)) - .queryShortcuts(appTargets.capture()); + .updateAppTargets(appTargets.capture()); // send shortcuts assertThat( @@ -2255,7 +2255,7 @@ public class UnbundledChooserActivityTest { ArgumentCaptor appTargets = ArgumentCaptor.forClass(DisplayResolveInfo[].class); verify(shortcutLoaders.get(0).first, times(1)) - .queryShortcuts(appTargets.capture()); + .updateAppTargets(appTargets.capture()); // send shortcuts List serviceTargets = createDirectShareTargets( @@ -2550,12 +2550,12 @@ public class UnbundledChooserActivityTest { .perform(swipeUp()); waitForIdle(); - verify(personalProfileShortcutLoader, times(1)).queryShortcuts(any()); + verify(personalProfileShortcutLoader, times(1)).updateAppTargets(any()); onView(withText(R.string.resolver_work_tab)).perform(click()); waitForIdle(); - verify(workProfileShortcutLoader, times(1)).queryShortcuts(any()); + verify(workProfileShortcutLoader, times(1)).updateAppTargets(any()); } @Test diff --git a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt index e8e2f862..742aac71 100644 --- a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt +++ b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt @@ -26,7 +26,9 @@ import android.content.pm.PackageManager.ApplicationInfoFlags import android.content.pm.ShortcutManager import android.os.UserHandle import android.os.UserManager +import androidx.lifecycle.Lifecycle import androidx.test.filters.SmallTest +import com.android.intentresolver.TestLifecycleOwner import com.android.intentresolver.any import com.android.intentresolver.argumentCaptor import com.android.intentresolver.capture @@ -36,19 +38,27 @@ import com.android.intentresolver.createShareShortcutInfo import com.android.intentresolver.createShortcutInfo import com.android.intentresolver.mock import com.android.intentresolver.whenever +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Test import org.mockito.Mockito.anyInt import org.mockito.Mockito.atLeastOnce import org.mockito.Mockito.never import org.mockito.Mockito.times import org.mockito.Mockito.verify -import java.util.concurrent.Executor import java.util.function.Consumer +@OptIn(ExperimentalCoroutinesApi::class) @SmallTest class ShortcutLoaderTest { private val appInfo = ApplicationInfo().apply { @@ -68,7 +78,9 @@ class ShortcutLoaderTest { whenever(createContextAsUser(any(), anyInt())).thenReturn(this) whenever(getSystemService(Context.USER_SERVICE)).thenReturn(userManager) } - private val executor = ImmediateExecutor() + private val scheduler = TestCoroutineScheduler() + private val dispatcher = UnconfinedTestDispatcher(scheduler) + private val lifecycleOwner = TestLifecycleOwner() private val intentFilter = mock() private val appPredictor = mock() private val callback = mock>() @@ -79,20 +91,32 @@ class ShortcutLoaderTest { private val appTargets = arrayOf(appTarget) private val matchingShortcutInfo = createShortcutInfo("id-0", componentName, 1) + @Before + fun setup() { + Dispatchers.setMain(dispatcher) + lifecycleOwner.state = Lifecycle.State.CREATED + } + + @After + fun cleanup() { + lifecycleOwner.state = Lifecycle.State.DESTROYED + Dispatchers.resetMain() + } + @Test - fun test_queryShortcuts_result_consistency_with_AppPredictor() { + fun test_loadShortcutsWithAppPredictor_resultIntegrity() { val testSubject = ShortcutLoader( context, + lifecycleOwner.lifecycle, appPredictor, UserHandle.of(0), true, intentFilter, - executor, - executor, + dispatcher, callback ) - testSubject.queryShortcuts(appTargets) + testSubject.updateAppTargets(appTargets) val matchingAppTarget = createAppTarget(matchingShortcutInfo) val shortcuts = listOf( @@ -130,7 +154,7 @@ class ShortcutLoaderTest { } @Test - fun test_queryShortcuts_result_consistency_with_ShortcutManager() { + fun test_loadShortcutsWithShortcutManager_resultIntegrity() { val shortcutManagerResult = listOf( ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), // mismatching shortcut @@ -142,16 +166,16 @@ class ShortcutLoaderTest { whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) val testSubject = ShortcutLoader( context, + lifecycleOwner.lifecycle, null, UserHandle.of(0), true, intentFilter, - executor, - executor, + dispatcher, callback ) - testSubject.queryShortcuts(appTargets) + testSubject.updateAppTargets(appTargets) val resultCaptor = argumentCaptor() verify(callback, times(1)).accept(capture(resultCaptor)) @@ -175,7 +199,7 @@ class ShortcutLoaderTest { } @Test - fun test_queryShortcuts_falls_back_to_ShortcutManager_on_empty_reply() { + fun test_appPredictorReturnsEmptyList_fallbackToShortcutManager() { val shortcutManagerResult = listOf( ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), // mismatching shortcut @@ -187,16 +211,16 @@ class ShortcutLoaderTest { whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) val testSubject = ShortcutLoader( context, + lifecycleOwner.lifecycle, appPredictor, UserHandle.of(0), true, intentFilter, - executor, - executor, + dispatcher, callback ) - testSubject.queryShortcuts(appTargets) + testSubject.updateAppTargets(appTargets) verify(appPredictor, times(1)).requestPredictionUpdate() val appPredictorCallbackCaptor = argumentCaptor() @@ -226,7 +250,7 @@ class ShortcutLoaderTest { } @Test - fun test_queryShortcuts_onAppPredictorFailure_fallbackToShortcutManager() { + fun test_appPredictor_requestPredictionUpdateFailure_fallbackToShortcutManager() { val shortcutManagerResult = listOf( ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), // mismatching shortcut @@ -240,16 +264,16 @@ class ShortcutLoaderTest { .thenThrow(IllegalStateException("Test exception")) val testSubject = ShortcutLoader( context, + lifecycleOwner.lifecycle, appPredictor, UserHandle.of(0), true, intentFilter, - executor, - executor, + dispatcher, callback ) - testSubject.queryShortcuts(appTargets) + testSubject.updateAppTargets(appTargets) verify(appPredictor, times(1)).requestPredictionUpdate() @@ -275,32 +299,105 @@ class ShortcutLoaderTest { } @Test - fun test_queryShortcuts_do_not_call_services_for_not_running_work_profile() { + fun test_ShortcutLoader_shortcutsRequestedIndependentlyFromAppTargets() { + ShortcutLoader( + context, + lifecycleOwner.lifecycle, + appPredictor, + UserHandle.of(0), + true, + intentFilter, + dispatcher, + callback + ) + + verify(appPredictor, times(1)).requestPredictionUpdate() + verify(callback, never()).accept(any()) + } + + @Test + fun test_ShortcutLoader_noResultsWithoutAppTargets() { + val shortcutManagerResult = listOf( + ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), + // mismatching shortcut + createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) + ) + val shortcutManager = mock { + whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult) + } + whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) + val testSubject = ShortcutLoader( + context, + lifecycleOwner.lifecycle, + null, + UserHandle.of(0), + true, + intentFilter, + dispatcher, + callback + ) + + verify(shortcutManager, times(1)).getShareTargets(any()) + verify(callback, never()).accept(any()) + + testSubject.reset() + + verify(shortcutManager, times(2)).getShareTargets(any()) + verify(callback, never()).accept(any()) + + testSubject.updateAppTargets(appTargets) + + verify(shortcutManager, times(2)).getShareTargets(any()) + verify(callback, times(1)).accept(any()) + } + + @Test + fun test_OnLifecycleDestroyed_unsubscribeFromAppPredictor() { + ShortcutLoader( + context, + lifecycleOwner.lifecycle, + appPredictor, + UserHandle.of(0), + true, + intentFilter, + dispatcher, + callback + ) + + verify(appPredictor, never()).unregisterPredictionUpdates(any()) + + lifecycleOwner.state = Lifecycle.State.DESTROYED + + verify(appPredictor, times(1)).unregisterPredictionUpdates(any()) + } + + @Test + fun test_workProfileNotRunning_doNotCallServices() { testDisabledWorkProfileDoNotCallSystem(isUserRunning = false) } @Test - fun test_queryShortcuts_do_not_call_services_for_locked_work_profile() { + fun test_workProfileLocked_doNotCallServices() { testDisabledWorkProfileDoNotCallSystem(isUserUnlocked = false) } @Test - fun test_queryShortcuts_do_not_call_services_if_quite_mode_is_enabled_for_work_profile() { + fun test_workProfileQuiteModeEnabled_doNotCallServices() { testDisabledWorkProfileDoNotCallSystem(isQuietModeEnabled = true) } @Test - fun test_queryShortcuts_call_services_for_not_running_main_profile() { + fun test_mainProfileNotRunning_callServicesAnyway() { testAlwaysCallSystemForMainProfile(isUserRunning = false) } @Test - fun test_queryShortcuts_call_services_for_locked_main_profile() { + fun test_mainProfileLocked_callServicesAnyway() { testAlwaysCallSystemForMainProfile(isUserUnlocked = false) } @Test - fun test_queryShortcuts_call_services_if_quite_mode_is_enabled_for_main_profile() { + fun test_mainProfileQuiteModeEnabled_callServicesAnyway() { testAlwaysCallSystemForMainProfile(isQuietModeEnabled = true) } @@ -320,16 +417,16 @@ class ShortcutLoaderTest { val callback = mock>() val testSubject = ShortcutLoader( context, + lifecycleOwner.lifecycle, appPredictor, userHandle, false, intentFilter, - executor, - executor, + dispatcher, callback ) - testSubject.queryShortcuts(arrayOf(mock())) + testSubject.updateAppTargets(arrayOf(mock())) verify(appPredictor, never()).requestPredictionUpdate() } @@ -350,23 +447,17 @@ class ShortcutLoaderTest { val callback = mock>() val testSubject = ShortcutLoader( context, + lifecycleOwner.lifecycle, appPredictor, userHandle, true, intentFilter, - executor, - executor, + dispatcher, callback ) - testSubject.queryShortcuts(arrayOf(mock())) + testSubject.updateAppTargets(arrayOf(mock())) verify(appPredictor, times(1)).requestPredictionUpdate() } } - -private class ImmediateExecutor : Executor { - override fun execute(r: Runnable) { - r.run() - } -} -- cgit v1.2.3-59-g8ed1b From fb81ae4c3caacdd3ffc06200513a44dda0ab3745 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Tue, 25 Apr 2023 19:05:22 +0000 Subject: Null-check mime types in content preview handling Test: atest CtsSharesheetDeviceTest Bug: 279447957 Change-Id: I44bb350ddf2c9eb79ad51b485cc06d9b8532edf5 --- .../contentpreview/ContentPreviewUi.java | 15 +++++++++++++++ .../contentpreview/FilesPlusTextContentPreviewUi.java | 12 +----------- .../contentpreview/MimeTypeClassifier.java | 2 +- .../contentpreview/UnifiedContentPreviewUi.java | 12 +----------- .../contentpreview/ContentPreviewUiTest.kt | 18 ++++++++++++++++++ 5 files changed, 36 insertions(+), 23 deletions(-) (limited to 'java/tests') diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java index fcafe752..4e343a17 100644 --- a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java @@ -35,6 +35,7 @@ import android.widget.TextView; import com.android.intentresolver.R; import com.android.intentresolver.widget.ActionRow; +import com.android.intentresolver.widget.ScrollableImagePreviewView; import java.util.ArrayList; import java.util.List; @@ -122,4 +123,18 @@ abstract class ContentPreviewUi { } } } + + protected static ScrollableImagePreviewView.PreviewType getPreviewType( + MimeTypeClassifier typeClassifier, String mimeType) { + if (mimeType == null) { + return ScrollableImagePreviewView.PreviewType.File; + } + if (typeClassifier.isImageType(mimeType)) { + return ScrollableImagePreviewView.PreviewType.Image; + } + if (typeClassifier.isVideoType(mimeType)) { + return ScrollableImagePreviewView.PreviewType.Video; + } + return ScrollableImagePreviewView.PreviewType.File; + } } diff --git a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java index 12843e0a..e15d53c4 100644 --- a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java @@ -70,7 +70,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { boolean allVideos = true; for (FileInfo fileInfo : mFiles) { ScrollableImagePreviewView.PreviewType previewType = - getPreviewType(fileInfo.getMimeType()); + getPreviewType(mTypeClassifier, fileInfo.getMimeType()); allImages = allImages && previewType == ScrollableImagePreviewView.PreviewType.Image; allVideos = allVideos && previewType == ScrollableImagePreviewView.PreviewType.Video; } @@ -180,14 +180,4 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { }); includeText.setVisibility(View.VISIBLE); } - - private ScrollableImagePreviewView.PreviewType getPreviewType(String mimeType) { - if (mTypeClassifier.isImageType(mimeType)) { - return ScrollableImagePreviewView.PreviewType.Image; - } - if (mTypeClassifier.isVideoType(mimeType)) { - return ScrollableImagePreviewView.PreviewType.Video; - } - return ScrollableImagePreviewView.PreviewType.File; - } } diff --git a/java/src/com/android/intentresolver/contentpreview/MimeTypeClassifier.java b/java/src/com/android/intentresolver/contentpreview/MimeTypeClassifier.java index 5172dd29..44cbd52e 100644 --- a/java/src/com/android/intentresolver/contentpreview/MimeTypeClassifier.java +++ b/java/src/com/android/intentresolver/contentpreview/MimeTypeClassifier.java @@ -33,6 +33,6 @@ public interface MimeTypeClassifier { /** @return whether the specified {@code mimeType} is classified as an "video" type */ default boolean isVideoType(@Nullable String mimeType) { - return ClipDescription.compareMimeTypes(mimeType, "video/*"); + return (mimeType != null) && ClipDescription.compareMimeTypes(mimeType, "video/*"); } } diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java index 22e98373..8bc0fb71 100644 --- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -105,7 +105,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { boolean allVideos = !mFiles.isEmpty(); for (FileInfo fileInfo : mFiles) { ScrollableImagePreviewView.PreviewType previewType = - getPreviewType(fileInfo.getMimeType()); + getPreviewType(mTypeClassifier, fileInfo.getMimeType()); allImages = allImages && previewType == ScrollableImagePreviewView.PreviewType.Image; allVideos = allVideos && previewType == ScrollableImagePreviewView.PreviewType.Video; @@ -157,14 +157,4 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { } return actions; } - - private ScrollableImagePreviewView.PreviewType getPreviewType(String mimeType) { - if (mTypeClassifier.isImageType(mimeType)) { - return ScrollableImagePreviewView.PreviewType.Image; - } - if (mTypeClassifier.isVideoType(mimeType)) { - return ScrollableImagePreviewView.PreviewType.Video; - } - return ScrollableImagePreviewView.PreviewType.File; - } } diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ContentPreviewUiTest.kt index c6a47515..6c30fc9e 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/ContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/ContentPreviewUiTest.kt @@ -20,6 +20,7 @@ import android.content.res.Resources import android.view.LayoutInflater import android.view.ViewGroup import com.android.intentresolver.widget.ActionRow +import com.android.intentresolver.widget.ScrollableImagePreviewView.PreviewType import com.google.common.truth.Truth.assertThat import org.junit.Test @@ -44,6 +45,23 @@ class ContentPreviewUiTest { } } + @Test + fun testPreviewTypes() { + val typeClassifier = object : MimeTypeClassifier { + override fun isImageType(type: String?) = (type == "image") + override fun isVideoType(type: String?) = (type == "video") + } + + assertThat(ContentPreviewUi.getPreviewType(typeClassifier, "image")) + .isEqualTo(PreviewType.Image) + assertThat(ContentPreviewUi.getPreviewType(typeClassifier, "video")) + .isEqualTo(PreviewType.Video) + assertThat(ContentPreviewUi.getPreviewType(typeClassifier, "other")) + .isEqualTo(PreviewType.File) + assertThat(ContentPreviewUi.getPreviewType(typeClassifier, null)) + .isEqualTo(PreviewType.File) + } + @Test fun testCreateActions() { val preview = TestablePreview() -- cgit v1.2.3-59-g8ed1b From c74648831207df9495dcb13bf08afdecdf30fa93 Mon Sep 17 00:00:00 2001 From: 1 Date: Tue, 25 Apr 2023 19:17:58 +0000 Subject: Update file+share to align toggled text with mock. When text is toggled off, it should say e.g. "Image only". Mock: https://www.figma.com/file/JVIcmg4OhWuO5Vc1UdPVxe/Sharesheet-(U)?node-id=66-5067&t=Wj5ulJXOpvpOPlYY-0 Bug: 279599021 Test: atest UnbundledChooserActivityTest Test: Manual test with ShareTest Change-Id: I85eecae3c24f348e355717b78b2cf23aa748f91a --- java/res/values/strings.xml | 39 +++++++++++++++++++++- .../FilesPlusTextContentPreviewUi.java | 28 ++++++++++++++++ .../UnbundledChooserActivityTest.java | 19 ++++++----- 3 files changed, 77 insertions(+), 9 deletions(-) (limited to 'java/tests') diff --git a/java/res/values/strings.xml b/java/res/values/strings.xml index 8a24b349..360e2bc6 100644 --- a/java/res/values/strings.xml +++ b/java/res/values/strings.xml @@ -155,48 +155,85 @@ } + (for example: sharing a mixture of photos and videos) [CHAR_LIMIT=50] --> {count, plural, =1 {Sharing # file} other {Sharing # files} } + + {count, plural, =1 {Sharing image with text} other {Sharing # images with text} } + {count, plural, =1 {Sharing image with link} other {Sharing # images with link} } + {count, plural, =1 {Sharing video with text} other {Sharing # videos with text} } + {count, plural, =1 {Sharing video with link} other {Sharing # videos with link} } + {count, plural, =1 {Sharing file with text} other {Sharing # files with text} } + {count, plural, =1 {Sharing file with link} other {Sharing # files with link} } + + {count, plural, + =1 {Image only} + other {Images only} + } + + + + {count, plural, + =1 {Video only} + other {Videos only} + } + + + + {count, plural, + =1 {File only} + other {Files only} + } + + No recommended people to share with diff --git a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java index e15d53c4..5c42b4b7 100644 --- a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java @@ -21,6 +21,7 @@ import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTE import android.content.res.Resources; import android.text.util.Linkify; +import android.util.PluralsMessageFormatter; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -33,6 +34,7 @@ import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ScrollableImagePreviewView; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.function.Consumer; @@ -175,9 +177,35 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { shareTextAction.accept(false); includeText.setOnCheckedChangeListener((view, isChecked) -> { textView.setEnabled(isChecked); + if (isChecked) { + textView.setText(mText); + } else { + textView.setText(getNoTextString(contentPreview.getResources())); + } shareTextAction.accept(!isChecked); updateHeadline(contentPreview); }); includeText.setVisibility(View.VISIBLE); } + + private String getNoTextString(Resources resources) { + int stringResource; + + if (mAllImages) { + stringResource = R.string.sharing_images_only; + } else if (mAllVideos) { + stringResource = R.string.sharing_videos_only; + } else { + stringResource = R.string.sharing_files_only; + } + + HashMap params = new HashMap<>(); + params.put("count", mFiles.size()); + + return PluralsMessageFormatter.format( + resources, + params, + stringResource + ); + } } diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 6c659133..7c4838a2 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -694,14 +694,13 @@ public class UnbundledChooserActivityTest { } @Test - public void testImagePlusTextSharing_ExcludeText() { + public void testFilePlusTextSharing_ExcludeText() { Uri uri = Uri.parse( "android.resource://com.android.frameworks.coretests/" + R.drawable.test320x240); Intent sendIntent = createSendImageIntent(uri); ChooserActivityOverrideData.getInstance().imageLoader = createImageLoader(uri, createBitmap()); - ChooserActivityOverrideData.getInstance().isImageType = true; sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); List resolvedComponentInfos = Arrays.asList( @@ -723,6 +722,8 @@ public class UnbundledChooserActivityTest { .perform(click()); waitForIdle(); + onView(withId(R.id.content_preview_text)).check(matches(withText("File only"))); + AtomicReference launchedIntentRef = new AtomicReference<>(); ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { launchedIntentRef.set(targetInfo.getTargetIntent()); @@ -736,14 +737,13 @@ public class UnbundledChooserActivityTest { } @Test - public void testImagePlusTextSharing_RemoveAndAddBackText() { + public void testFilePlusTextSharing_RemoveAndAddBackText() { Uri uri = Uri.parse( "android.resource://com.android.frameworks.coretests/" + R.drawable.test320x240); Intent sendIntent = createSendImageIntent(uri); ChooserActivityOverrideData.getInstance().imageLoader = createImageLoader(uri, createBitmap()); - ChooserActivityOverrideData.getInstance().isImageType = true; final String text = "https://google.com/search?q=google"; sendIntent.putExtra(Intent.EXTRA_TEXT, text); @@ -765,10 +765,14 @@ public class UnbundledChooserActivityTest { .check(matches(isDisplayed())) .perform(click()); waitForIdle(); + onView(withId(R.id.content_preview_text)).check(matches(withText("File only"))); + onView(withId(R.id.include_text_action)) .perform(click()); waitForIdle(); + onView(withId(R.id.content_preview_text)).check(matches(withText(text))); + AtomicReference launchedIntentRef = new AtomicReference<>(); ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { launchedIntentRef.set(targetInfo.getTargetIntent()); @@ -782,14 +786,13 @@ public class UnbundledChooserActivityTest { } @Test - public void testImagePlusTextSharing_TextExclusionDoesNotAffectAlternativeIntent() { + public void testFilePlusTextSharing_TextExclusionDoesNotAffectAlternativeIntent() { Uri uri = Uri.parse( "android.resource://com.android.frameworks.coretests/" + R.drawable.test320x240); Intent sendIntent = createSendImageIntent(uri); ChooserActivityOverrideData.getInstance().imageLoader = createImageLoader(uri, createBitmap()); - ChooserActivityOverrideData.getInstance().isImageType = true; sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); Intent alternativeIntent = createSendTextIntent(); @@ -828,7 +831,7 @@ public class UnbundledChooserActivityTest { } @Test - public void testImagePlusTextSharing_failedThumbnailAndExcludedText_textRemainsVisible() { + public void testImagePlusTextSharing_failedThumbnailAndExcludedText_textChanges() { Uri uri = Uri.parse( "android.resource://com.android.frameworks.coretests/" + R.drawable.test320x240); @@ -860,7 +863,7 @@ public class UnbundledChooserActivityTest { onView(withId(R.id.image_view)) .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))); onView(withId(R.id.content_preview_text)) - .check(matches(allOf(isDisplayed(), not(isEnabled())))); + .check(matches(allOf(isDisplayed(), not(isEnabled()), withText("File only")))); } @Test -- cgit v1.2.3-59-g8ed1b From 0bd94de6891a259cfabce5c5ccbac588de950642 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Wed, 26 Apr 2023 21:31:11 -0700 Subject: Preview UI: specify metadata columns, change metada reading order Specify columns passed to ContentResolver#query. Read ContentResolver#getStreamTypes before ContentResolver#query because if the former returns, among others, an image mime types we can avoid reading the latter. Bug: 279674836 Test: unit tests, manual testing wiht Drive and a Photos prototype. Change-Id: Ide31f5c9aae21c9e0ce93bec9a8d829851532f4a --- .../contentpreview/ChooserContentPreviewUi.java | 21 +++++-- .../contentpreview/ChooserContentPreviewUiTest.kt | 72 ++++++++++++++++++---- 2 files changed, 77 insertions(+), 16 deletions(-) (limited to 'java/tests') diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index 69d8c49f..8173d542 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -37,6 +37,7 @@ import android.view.LayoutInflater; import android.view.ViewGroup; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; @@ -52,6 +53,18 @@ import java.util.function.Consumer; * A content preview façade. */ public final class ChooserContentPreviewUi { + + /** + * A set of metadata columns we read for a content URI (see [readFileMetadata] method). + */ + @VisibleForTesting + static final String[] METADATA_COLUMNS = new String[] { + DocumentsContract.Document.COLUMN_FLAGS, + MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI, + OpenableColumns.DISPLAY_NAME, + Downloads.Impl.COLUMN_TITLE + }; + /** * 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. @@ -209,9 +222,9 @@ public final class ChooserContentPreviewUi { if (typeClassifier.isImageType(mimeType)) { return builder.withPreviewUri(uri).build(); } - readFileMetadata(resolver, uri, builder); + readOtherFileTypes(resolver, uri, typeClassifier, builder); if (builder.getPreviewUri() == null) { - readOtherFileTypes(resolver, uri, typeClassifier, builder); + readFileMetadata(resolver, uri, builder); } return builder.build(); } @@ -329,7 +342,7 @@ public final class ChooserContentPreviewUi { } catch (SecurityException e) { logProviderPermissionWarning(uri, "mime type"); } catch (Throwable t) { - Log.e(ContentPreviewUi.TAG, "Failed to read content type, uri: " + uri, t); + Log.e(ContentPreviewUi.TAG, "Failed to read content type, uri: " + uri, t); } return null; } @@ -337,7 +350,7 @@ public final class ChooserContentPreviewUi { @Nullable private static Cursor query(ContentInterface resolver, Uri uri) { try { - return resolver.query(uri, null, null, null); + return resolver.query(uri, METADATA_COLUMNS, null, null); } catch (SecurityException e) { logProviderPermissionWarning(uri, "metadata"); } catch (Throwable t) { diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt index f29fac84..63fa8766 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt @@ -19,11 +19,15 @@ package com.android.intentresolver.contentpreview import android.content.ClipDescription import android.content.ContentInterface import android.content.Intent +import android.database.MatrixCursor import android.graphics.Bitmap +import android.media.MediaMetadata import android.net.Uri +import android.provider.DocumentsContract import com.android.intentresolver.any import com.android.intentresolver.anyOrNull import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory +import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.METADATA_COLUMNS import com.android.intentresolver.mock import com.android.intentresolver.whenever import com.android.intentresolver.widget.ActionRow @@ -59,7 +63,7 @@ class ChooserContentPreviewUiTest { private val transitionCallback = mock() @Test - fun test_ChooserContentPreview_non_send_intent_action_to_text_preview() { + fun test_nonSendIntentAction_useTextPreviewUi() { val targetIntent = Intent(Intent.ACTION_VIEW) val testSubject = ChooserContentPreviewUi( targetIntent, @@ -76,7 +80,7 @@ class ChooserContentPreviewUiTest { } @Test - fun test_ChooserContentPreview_text_mime_type_to_text_preview() { + fun test_textMimeType_useTextPreviewUi() { val targetIntent = Intent(Intent.ACTION_SEND).apply { type = "text/plain" putExtra(Intent.EXTRA_TEXT, "Text Extra") @@ -96,7 +100,7 @@ class ChooserContentPreviewUiTest { } @Test - fun test_ChooserContentPreview_single_image_uri_to_image_preview() { + fun test_singleImageUri_useImagePreviewUi() { val uri = Uri.parse("content://$PROVIDER_NAME/test.png") val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) @@ -117,7 +121,7 @@ class ChooserContentPreviewUiTest { } @Test - fun test_ChooserContentPreview_single_uri_without_preview_to_file_preview() { + fun test_singleNonImageUriWithoutPreview_useFilePreviewUi() { val uri = Uri.parse("content://$PROVIDER_NAME/test.pdf") val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) @@ -138,7 +142,7 @@ class ChooserContentPreviewUiTest { } @Test - fun test_ChooserContentPreview_single_uri_crashing_getType_to_file_preview() { + fun test_singleUriWithFailingGetType_useFilePreviewUi() { val uri = Uri.parse("content://$PROVIDER_NAME/test.pdf") val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) @@ -160,7 +164,7 @@ class ChooserContentPreviewUiTest { } @Test - fun test_ChooserContentPreview_single_uri_crashing_metadata_to_file_preview() { + fun test_singleNonImageUriWithFailingMetadata_useFilePreviewUi() { val uri = Uri.parse("content://$PROVIDER_NAME/test.pdf") val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) @@ -185,7 +189,7 @@ class ChooserContentPreviewUiTest { } @Test - fun test_ChooserContentPreview_single_uri_with_preview_to_image_preview() { + fun test_SingleNonImageUriWithImageTypeInGetStreamTypes_useImagePreviewUi() { val uri = Uri.parse("content://$PROVIDER_NAME/test.pdf") val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) @@ -208,7 +212,52 @@ class ChooserContentPreviewUiTest { } @Test - fun test_ChooserContentPreview_multiple_image_uri_to_image_preview() { + fun test_SingleNonImageUriWithThumbnailFlag_useImagePreviewUi() { + testMetadataToImagePreview( + columns = arrayOf(DocumentsContract.Document.COLUMN_FLAGS), + values = arrayOf( + DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL or + DocumentsContract.Document.FLAG_SUPPORTS_METADATA + ) + ) + } + + @Test + fun test_SingleNonImageUriWithMetadataIconUri_useImagePreviewUi() { + testMetadataToImagePreview( + columns = arrayOf(MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI), + values = arrayOf("content://$PROVIDER_NAME/test.pdf?thumbnail"), + ) + } + + private fun testMetadataToImagePreview(columns: Array, values: Array) { + val uri = Uri.parse("content://$PROVIDER_NAME/test.pdf") + val targetIntent = Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_STREAM, uri) + } + whenever(contentResolver.getType(uri)).thenReturn("application/pdf") + whenever(contentResolver.query(uri, METADATA_COLUMNS, null, null)) + .thenReturn( + MatrixCursor(columns).apply { + addRow(values) + } + ) + val testSubject = ChooserContentPreviewUi( + targetIntent, + contentResolver, + imageClassifier, + imageLoader, + actionFactory, + transitionCallback, + headlineGenerator + ) + assertThat(testSubject.preferredContentPreview) + .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) + verify(transitionCallback, never()).onAllTransitionElementsReady() + } + + @Test + fun test_multipleImageUri_useImagePreviewUi() { 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 { @@ -237,7 +286,7 @@ class ChooserContentPreviewUiTest { } @Test - fun test_ChooserContentPreview_some_non_image_uri_to_image_preview() { + fun test_SomeImageUri_useImagePreviewUi() { 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 { @@ -266,7 +315,7 @@ class ChooserContentPreviewUiTest { } @Test - fun test_ChooserContentPreview_some_non_image_uri_with_preview_to_image_preview() { + fun test_someNonImageUriWithPreview_useImagePreviewUi() { val uri1 = Uri.parse("content://$PROVIDER_NAME/test.mp4") val uri2 = Uri.parse("content://$PROVIDER_NAME/test.pdf") val targetIntent = Intent(Intent.ACTION_SEND_MULTIPLE).apply { @@ -297,7 +346,7 @@ class ChooserContentPreviewUiTest { } @Test - fun test_ChooserContentPreview_all_non_image_uris_without_preview_to_file_preview() { + fun test_allNonImageUrisWithoutPreview_useFilePreviewUi() { val uri1 = Uri.parse("content://$PROVIDER_NAME/test.html") val uri2 = Uri.parse("content://$PROVIDER_NAME/test.pdf") val targetIntent = Intent(Intent.ACTION_SEND_MULTIPLE).apply { @@ -324,5 +373,4 @@ class ChooserContentPreviewUiTest { .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) verify(transitionCallback, times(1)).onAllTransitionElementsReady() } - } -- cgit v1.2.3-59-g8ed1b From ee377b925768b0da7d64dfa7b919b23583639a33 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Thu, 27 Apr 2023 22:05:57 -0400 Subject: Filter custom chooser actions for invalid Uris Test: atest UriFilterTests; researcher supplied PoC Bug: 277915880 Change-Id: I62458dd4b23a8972b25e0005ccfa48a40c7d5e2b --- .../intentresolver/ChooserRequestParameters.java | 4 +- .../contentpreview/ChooserContentPreviewUi.java | 5 +- .../contentpreview/ContentPreviewUi.java | 23 ------ .../contentpreview/TextContentPreviewUi.java | 4 +- .../com/android/intentresolver/util/UriFilters.kt | 66 +++++++++++++++ .../android/intentresolver/util/UriFiltersTest.kt | 95 ++++++++++++++++++++++ 6 files changed, 170 insertions(+), 27 deletions(-) create mode 100644 java/src/com/android/intentresolver/util/UriFilters.kt create mode 100644 java/tests/src/com/android/intentresolver/util/UriFiltersTest.kt (limited to 'java/tests') diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java index 039f50e9..b3f5a722 100644 --- a/java/src/com/android/intentresolver/ChooserRequestParameters.java +++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java @@ -33,6 +33,7 @@ import android.util.Log; import android.util.Pair; import com.android.intentresolver.flags.FeatureFlagRepository; +import com.android.intentresolver.util.UriFilters; import com.google.common.collect.ImmutableList; @@ -320,7 +321,8 @@ public class ChooserRequestParameters { ChooserAction.class, true, true) - .collect(toImmutableList()); + .filter(UriFilters::hasValidIcon) + .collect(toImmutableList()); } @Nullable diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index 69d8c49f..b61f01b3 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -19,6 +19,7 @@ package com.android.intentresolver.contentpreview; import static android.provider.DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL; import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_IMAGE; +import static com.android.intentresolver.util.UriFilters.isOwnedByCurrentUser; import android.content.ClipData; import android.content.ClipDescription; @@ -306,14 +307,14 @@ public final class ChooserContentPreviewUi { List uris = new ArrayList<>(); if (Intent.ACTION_SEND.equals(targetIntent.getAction())) { Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); - if (ContentPreviewUi.validForContentPreview(uri)) { + if (isOwnedByCurrentUser(uri)) { uris.add(uri); } } else { List receivedUris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); if (receivedUris != null) { for (Uri uri : receivedUris) { - if (ContentPreviewUi.validForContentPreview(uri)) { + if (isOwnedByCurrentUser(uri)) { uris.add(uri); } } diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java index 4e343a17..c0859e53 100644 --- a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java @@ -16,16 +16,11 @@ 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.text.TextUtils; -import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -63,24 +58,6 @@ abstract class ContentPreviewUi { 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(ImageView imageView, Bitmap image) { if (image == null) { imageView.setVisibility(View.GONE); diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java index 3f662ce3..dc7a68b1 100644 --- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java @@ -16,6 +16,8 @@ package com.android.intentresolver.contentpreview; +import static com.android.intentresolver.util.UriFilters.isOwnedByCurrentUser; + import android.content.res.Resources; import android.net.Uri; import android.text.TextUtils; @@ -108,7 +110,7 @@ class TextContentPreviewUi extends ContentPreviewUi { ImageView previewThumbnailView = contentPreviewLayout.findViewById( com.android.internal.R.id.content_preview_thumbnail); - if (!validForContentPreview(mPreviewThumbnail) || minimalPreview) { + if (!isOwnedByCurrentUser(mPreviewThumbnail) || minimalPreview) { previewThumbnailView.setVisibility(View.GONE); } else { mImageLoader.loadImage( diff --git a/java/src/com/android/intentresolver/util/UriFilters.kt b/java/src/com/android/intentresolver/util/UriFilters.kt new file mode 100644 index 00000000..8714c314 --- /dev/null +++ b/java/src/com/android/intentresolver/util/UriFilters.kt @@ -0,0 +1,66 @@ +/* + * 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. + */ +@file:JvmName("UriFilters") + +package com.android.intentresolver.util + +import android.content.ContentProvider.getUserIdFromUri +import android.content.ContentResolver.SCHEME_CONTENT +import android.graphics.drawable.Icon.TYPE_URI +import android.graphics.drawable.Icon.TYPE_URI_ADAPTIVE_BITMAP +import android.net.Uri +import android.os.UserHandle +import android.service.chooser.ChooserAction + +/** + * Checks if the [Uri] is a `content://` uri which references the current user (from process uid). + * + * MediaStore interprets the user field of a content:// URI as a UserId and applies it if the caller + * holds INTERACT_ACROSS_USERS permission. (Example: `content://10@media/images/1234`) + * + * No URI content should be loaded unless it passes this check since the caller would not have + * permission to read it. + * + * @return false if this is a content:// [Uri] which references another user + */ +val Uri?.ownedByCurrentUser: Boolean + @JvmName("isOwnedByCurrentUser") + get() = this?.let { + when (getUserIdFromUri(this, UserHandle.USER_CURRENT)) { + UserHandle.USER_CURRENT, + UserHandle.myUserId() -> true + else -> false + } + } == true + +/** Does the [Uri] reference a content provider ('content://')? */ +internal val Uri.contentScheme: Boolean + get() = scheme == SCHEME_CONTENT + +/** + * Checks if the Icon of a [ChooserAction] backed by content:// [Uri] is safe for display. + * + * @param action the chooser action + * @see [Uri.ownedByCurrentUser] + */ +fun hasValidIcon(action: ChooserAction) = + with(action.icon) { + when (type) { + TYPE_URI, + TYPE_URI_ADAPTIVE_BITMAP -> !uri.contentScheme || uri.ownedByCurrentUser + else -> true + } + } diff --git a/java/tests/src/com/android/intentresolver/util/UriFiltersTest.kt b/java/tests/src/com/android/intentresolver/util/UriFiltersTest.kt new file mode 100644 index 00000000..18218064 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/util/UriFiltersTest.kt @@ -0,0 +1,95 @@ +package com.android.intentresolver.util + +import android.app.PendingIntent +import android.content.IIntentReceiver +import android.content.IIntentSender +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.drawable.Icon +import android.net.Uri +import android.os.Binder +import android.os.Bundle +import android.os.IBinder +import android.os.UserHandle +import android.service.chooser.ChooserAction +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class UriFiltersTest { + + @Test + fun uri_ownedByCurrentUser_noUserId() { + val uri = Uri.parse("content://media/images/12345") + assertTrue("Uri without userId should always return true", uri.ownedByCurrentUser) + } + + @Test + fun uri_ownedByCurrentUser_selfUserId() { + val uri = Uri.parse("content://${UserHandle.myUserId()}@media/images/12345") + assertTrue("Uri with own userId should return true", uri.ownedByCurrentUser) + } + + @Test + fun uri_ownedByCurrentUser_otherUserId() { + val otherUserId = UserHandle.myUserId() + 10 + val uri = Uri.parse("content://${otherUserId}@media/images/12345") + assertFalse("Uri with other userId should return false", uri.ownedByCurrentUser) + } + + @Test + fun chooserAction_hasValidIcon_bitmap() = + smallBitmap().use { + val icon = Icon.createWithBitmap(it) + val action = actionWithIcon(icon) + assertTrue("No uri, assumed valid", hasValidIcon(action)) + } + + @Test + fun chooserAction_hasValidIcon_uri() { + val icon = Icon.createWithContentUri("content://provider/content/12345") + assertTrue("No userId in uri, uri is valid", hasValidIcon(actionWithIcon(icon))) + } + @Test + fun chooserAction_hasValidIcon_uri_unowned() { + val userId = UserHandle.myUserId() + 10 + val icon = Icon.createWithContentUri("content://${userId}@provider/content/12345") + assertFalse("uri userId references a different user", hasValidIcon(actionWithIcon(icon))) + } + + private fun smallBitmap() = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + + private fun mockAction(): PendingIntent { + return PendingIntent( + object : IIntentSender { + override fun asBinder(): IBinder = Binder() + override fun send( + code: Int, + intent: Intent?, + resolvedType: String?, + whitelistToken: IBinder?, + finishedReceiver: IIntentReceiver?, + requiredPermission: String?, + options: Bundle? + ) { + /* empty */ + } + } + ) + } + + private fun actionWithIcon(icon: Icon): ChooserAction { + return ChooserAction.Builder(icon, "", mockAction()).build() + } + + /** Unconditionally recycles the [Bitmap] after running the given block */ + private fun Bitmap.use(block: (Bitmap) -> Unit) = + try { + block(this) + } finally { + recycle() + } +} -- cgit v1.2.3-59-g8ed1b From 061c69f6beea61b1dd8c12935d53ccabcdf35e0e Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Tue, 2 May 2023 13:48:44 +0000 Subject: Scaffolding for AnnotatedUserHandles testing. This provides (test-only) capacities to inject different UserHandle values than the ones we'd normally determine, so that we can test under artificially-constructed scenarios just by injecting the appropriate fake AnnotatedUserHandles value. Test: `atest AnnotatedUserHandlesTest` (& presubmits/etc) Bug: 280237072 Change-Id: Idc2f7a5a46f49f9e4c11d361e01e6943404262b2 --- .../intentresolver/AnnotatedUserHandles.java | 132 ++++++++++++++++----- .../android/intentresolver/ResolverActivity.java | 2 +- .../intentresolver/AnnotatedUserHandlesTest.kt | 79 ++++++++++++ 3 files changed, 181 insertions(+), 32 deletions(-) create mode 100644 java/tests/src/com/android/intentresolver/AnnotatedUserHandlesTest.kt (limited to 'java/tests') diff --git a/java/src/com/android/intentresolver/AnnotatedUserHandles.java b/java/src/com/android/intentresolver/AnnotatedUserHandles.java index 769195ed..168f36d6 100644 --- a/java/src/com/android/intentresolver/AnnotatedUserHandles.java +++ b/java/src/com/android/intentresolver/AnnotatedUserHandles.java @@ -22,6 +22,8 @@ import android.app.ActivityManager; import android.os.UserHandle; import android.os.UserManager; +import androidx.annotation.VisibleForTesting; + /** * Helper class to precompute the (immutable) designations of various user handles in the system * that may contribute to the current Sharesheet session. @@ -78,28 +80,74 @@ public final class AnnotatedUserHandles { */ public final UserHandle tabOwnerUserHandleForLaunch; - public AnnotatedUserHandles(Activity forShareActivity) { - userIdOfCallingApp = forShareActivity.getLaunchedFromUid(); - if ((userIdOfCallingApp < 0) || UserHandle.isIsolated(userIdOfCallingApp)) { - throw new SecurityException("Can't start a resolver from uid " + userIdOfCallingApp); - } - - // TODO: integrate logic for `ResolverActivity.EXTRA_CALLING_USER`. - userHandleSharesheetLaunchedAs = UserHandle.of(UserHandle.myUserId()); + /** Compute all handle designations for a new Sharesheet session in the specified activity. */ + public static AnnotatedUserHandles forShareActivity(Activity shareActivity) { + // TODO: consider integrating logic for `ResolverActivity.EXTRA_CALLING_USER`? + UserHandle userHandleSharesheetLaunchedAs = UserHandle.of(UserHandle.myUserId()); // ActivityManager.getCurrentUser() refers to the current Foreground user. When clone/work // profile is active, we always make the personal tab from the foreground user. // Outside profiles, current foreground user is potentially the same as the sharesheet // process's user (UserHandle.myUserId()), so we continue to create personal tab with the // current foreground user. - personalProfileUserHandle = UserHandle.of(ActivityManager.getCurrentUser()); + UserHandle personalProfileUserHandle = UserHandle.of(ActivityManager.getCurrentUser()); + + UserManager userManager = shareActivity.getSystemService(UserManager.class); + + return newBuilder() + .setUserIdOfCallingApp(shareActivity.getLaunchedFromUid()) + .setUserHandleSharesheetLaunchedAs(userHandleSharesheetLaunchedAs) + .setPersonalProfileUserHandle(personalProfileUserHandle) + .setWorkProfileUserHandle( + getWorkProfileForUser(userManager, personalProfileUserHandle)) + .setCloneProfileUserHandle( + getCloneProfileForUser(userManager, personalProfileUserHandle)) + .build(); + } + + @VisibleForTesting static Builder newBuilder() { + return new Builder(); + } + + /** + * Returns the {@link UserHandle} to use when querying resolutions for intents in a + * {@link ResolverListController} configured for the provided {@code userHandle}. + */ + public UserHandle getQueryIntentsUser(UserHandle userHandle) { + // In case launching app is in clonedProfile, and we are building the personal tab, intent + // resolution will be attempted as clonedUser instead of user 0. This is because intent + // resolution from user 0 and clonedUser is not guaranteed to return same results. + // We do not care about the case when personal adapter is started with non-root user + // (secondary user case), as clone profile is guaranteed to be non-active in that case. + UserHandle queryIntentsUser = userHandle; + if (isLaunchedAsCloneProfile() && userHandle.equals(personalProfileUserHandle)) { + queryIntentsUser = cloneProfileUserHandle; + } + return queryIntentsUser; + } + + private Boolean isLaunchedAsCloneProfile() { + return userHandleSharesheetLaunchedAs.equals(cloneProfileUserHandle); + } - UserManager userManager = forShareActivity.getSystemService(UserManager.class); - workProfileUserHandle = getWorkProfileForUser(userManager, personalProfileUserHandle); - cloneProfileUserHandle = getCloneProfileForUser(userManager, personalProfileUserHandle); + private AnnotatedUserHandles( + int userIdOfCallingApp, + UserHandle userHandleSharesheetLaunchedAs, + UserHandle personalProfileUserHandle, + @Nullable UserHandle workProfileUserHandle, + @Nullable UserHandle cloneProfileUserHandle) { + if ((userIdOfCallingApp < 0) || UserHandle.isIsolated(userIdOfCallingApp)) { + throw new SecurityException("Can't start a resolver from uid " + userIdOfCallingApp); + } - tabOwnerUserHandleForLaunch = (userHandleSharesheetLaunchedAs == workProfileUserHandle) - ? workProfileUserHandle : personalProfileUserHandle; + this.userIdOfCallingApp = userIdOfCallingApp; + this.userHandleSharesheetLaunchedAs = userHandleSharesheetLaunchedAs; + this.personalProfileUserHandle = personalProfileUserHandle; + this.workProfileUserHandle = workProfileUserHandle; + this.cloneProfileUserHandle = cloneProfileUserHandle; + this.tabOwnerUserHandleForLaunch = + (userHandleSharesheetLaunchedAs == workProfileUserHandle) + ? workProfileUserHandle : personalProfileUserHandle; } @Nullable @@ -124,24 +172,46 @@ public final class AnnotatedUserHandles { .orElse(null); } - /** - * Returns the {@link UserHandle} to use when querying resolutions for intents in a - * {@link ResolverListController} configured for the provided {@code userHandle}. - */ - public UserHandle getQueryIntentsUser(UserHandle userHandle) { - // In case launching app is in clonedProfile, and we are building the personal tab, intent - // resolution will be attempted as clonedUser instead of user 0. This is because intent - // resolution from user 0 and clonedUser is not guaranteed to return same results. - // We do not care about the case when personal adapter is started with non-root user - // (secondary user case), as clone profile is guaranteed to be non-active in that case. - UserHandle queryIntentsUser = userHandle; - if (isLaunchedAsCloneProfile() && userHandle.equals(personalProfileUserHandle)) { - queryIntentsUser = cloneProfileUserHandle; + @VisibleForTesting + static class Builder { + private int mUserIdOfCallingApp; + private UserHandle mUserHandleSharesheetLaunchedAs; + private UserHandle mPersonalProfileUserHandle; + private UserHandle mWorkProfileUserHandle; + private UserHandle mCloneProfileUserHandle; + + public Builder setUserIdOfCallingApp(int id) { + mUserIdOfCallingApp = id; + return this; } - return queryIntentsUser; - } - private Boolean isLaunchedAsCloneProfile() { - return userHandleSharesheetLaunchedAs.equals(cloneProfileUserHandle); + public Builder setUserHandleSharesheetLaunchedAs(UserHandle user) { + mUserHandleSharesheetLaunchedAs = user; + return this; + } + + public Builder setPersonalProfileUserHandle(UserHandle user) { + mPersonalProfileUserHandle = user; + return this; + } + + public Builder setWorkProfileUserHandle(UserHandle user) { + mWorkProfileUserHandle = user; + return this; + } + + public Builder setCloneProfileUserHandle(UserHandle user) { + mCloneProfileUserHandle = user; + return this; + } + + public AnnotatedUserHandles build() { + return new AnnotatedUserHandles( + mUserIdOfCallingApp, + mUserHandleSharesheetLaunchedAs, + mPersonalProfileUserHandle, + mWorkProfileUserHandle, + mCloneProfileUserHandle); + } } } diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index aea6c2c9..ced7bf58 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -228,7 +228,7 @@ public class ResolverActivity extends FragmentActivity implements // new component whose lifecycle is limited to the "created" Activity (so that we can just hold // the annotations as a `final` ivar, which is a better way to show immutability). private Supplier mLazyAnnotatedUserHandles = () -> { - final AnnotatedUserHandles result = new AnnotatedUserHandles(this); + final AnnotatedUserHandles result = AnnotatedUserHandles.forShareActivity(this); mLazyAnnotatedUserHandles = () -> result; return result; }; diff --git a/java/tests/src/com/android/intentresolver/AnnotatedUserHandlesTest.kt b/java/tests/src/com/android/intentresolver/AnnotatedUserHandlesTest.kt new file mode 100644 index 00000000..a17a560c --- /dev/null +++ b/java/tests/src/com/android/intentresolver/AnnotatedUserHandlesTest.kt @@ -0,0 +1,79 @@ +/* + * 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 + +import android.os.UserHandle + +import com.google.common.truth.Truth.assertThat + +import org.junit.Test + +class AnnotatedUserHandlesTest { + + @Test + fun testBasicProperties() { // Fields that are reflected back w/o logic. + val info = AnnotatedUserHandles.newBuilder() + .setUserIdOfCallingApp(42) + .setUserHandleSharesheetLaunchedAs(UserHandle.of(116)) + .setPersonalProfileUserHandle(UserHandle.of(117)) + .setWorkProfileUserHandle(UserHandle.of(118)) + .setCloneProfileUserHandle(UserHandle.of(119)) + .build() + + assertThat(info.userIdOfCallingApp).isEqualTo(42) + assertThat(info.userHandleSharesheetLaunchedAs.identifier).isEqualTo(116) + assertThat(info.personalProfileUserHandle.identifier).isEqualTo(117) + assertThat(info.workProfileUserHandle.identifier).isEqualTo(118) + assertThat(info.cloneProfileUserHandle.identifier).isEqualTo(119) + } + + @Test + fun testWorkTabInitiallySelectedWhenLaunchedFromWorkProfile() { + val info = AnnotatedUserHandles.newBuilder() + .setUserIdOfCallingApp(42) + .setPersonalProfileUserHandle(UserHandle.of(101)) + .setWorkProfileUserHandle(UserHandle.of(202)) + .setUserHandleSharesheetLaunchedAs(UserHandle.of(202)) + .build() + + assertThat(info.tabOwnerUserHandleForLaunch.identifier).isEqualTo(202) + } + + @Test + fun testPersonalTabInitiallySelectedWhenLaunchedFromPersonalProfile() { + val info = AnnotatedUserHandles.newBuilder() + .setUserIdOfCallingApp(42) + .setPersonalProfileUserHandle(UserHandle.of(101)) + .setWorkProfileUserHandle(UserHandle.of(202)) + .setUserHandleSharesheetLaunchedAs(UserHandle.of(101)) + .build() + + assertThat(info.tabOwnerUserHandleForLaunch.identifier).isEqualTo(101) + } + + @Test + fun testPersonalTabInitiallySelectedWhenLaunchedFromOtherProfile() { + val info = AnnotatedUserHandles.newBuilder() + .setUserIdOfCallingApp(42) + .setPersonalProfileUserHandle(UserHandle.of(101)) + .setWorkProfileUserHandle(UserHandle.of(202)) + .setUserHandleSharesheetLaunchedAs(UserHandle.of(303)) + .build() + + assertThat(info.tabOwnerUserHandleForLaunch.identifier).isEqualTo(101) + } +} -- cgit v1.2.3-59-g8ed1b From 4d84286ea7d571d16869c3907de308f0a8d2b5a4 Mon Sep 17 00:00:00 2001 From: Govinda Wasserman Date: Wed, 5 Apr 2023 08:25:00 -0400 Subject: Removes dependency on TestNG library. This library does not play well with some parts of Android including Android Studio support. This change replaces the usages with supported versions. BUG:278726409 Change-Id: I970e6af31853382454f35eeff70a7f92e2f28e5a --- java/tests/Android.bp | 1 - java/tests/src/com/android/intentresolver/ResolverActivityTest.java | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) (limited to 'java/tests') diff --git a/java/tests/Android.bp b/java/tests/Android.bp index 12cb390e..c381d0a8 100644 --- a/java/tests/Android.bp +++ b/java/tests/Android.bp @@ -29,7 +29,6 @@ android_test { "androidx.lifecycle_lifecycle-runtime-ktx", "truth-prebuilt", "testables", - "testng", "kotlinx_coroutines_test", ], test_suites: ["general-tests"], diff --git a/java/tests/src/com/android/intentresolver/ResolverActivityTest.java b/java/tests/src/com/android/intentresolver/ResolverActivityTest.java index 12b71970..5cece092 100644 --- a/java/tests/src/com/android/intentresolver/ResolverActivityTest.java +++ b/java/tests/src/com/android/intentresolver/ResolverActivityTest.java @@ -33,10 +33,10 @@ import static org.hamcrest.CoreMatchers.allOf; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.fail; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; -import static org.testng.Assert.assertFalse; -import static org.testng.Assert.fail; import android.content.Intent; import android.content.pm.ResolveInfo; @@ -510,7 +510,7 @@ public class ResolverActivityTest { waitForIdle(); TextView headerText = activity.findViewById(com.android.internal.R.id.title); String initialText = headerText.getText().toString(); - assertFalse(initialText.isEmpty(), "Header text is empty."); + assertFalse("Header text is empty.", initialText.isEmpty()); assertThat(headerText.getVisibility(), is(View.VISIBLE)); } -- cgit v1.2.3-59-g8ed1b From 12b3755d0b4d30f79f5f2ca657940e7366b77dd3 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Wed, 3 May 2023 22:16:22 -0700 Subject: Preserve order of shared items in preview Fix: 280480016 Test: manual testing, unit tests Change-Id: I39ed4a6db6bd68f2d89d53b03698d76c966a8001 --- .../widget/ScrollableImagePreviewView.kt | 98 ++++++--- .../widget/BatchPreviewLoaderTest.kt | 239 +++++++++++++++++++++ 2 files changed, 302 insertions(+), 35 deletions(-) create mode 100644 java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt (limited to 'java/tests') diff --git a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt index e760e6d0..1f5be601 100644 --- a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt +++ b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt @@ -28,6 +28,7 @@ import android.view.View import android.view.ViewGroup import android.widget.ImageView import android.widget.TextView +import androidx.annotation.VisibleForTesting import androidx.constraintlayout.widget.ConstraintLayout import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -45,8 +46,6 @@ import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.plus -import java.util.ArrayDeque -import kotlin.math.roundToInt private const val TRANSITION_NAME = "screenshot_preview_image" private const val PLURALS_COUNT = "count" @@ -149,14 +148,17 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { previewAdapter.reset(0, imageLoader) batchLoader?.cancel() batchLoader = BatchPreviewLoader( - previewAdapter, imageLoader, previews, otherItemCount, - ) { - onNoPreviewCallback?.run() - } - .apply { + onReset = { totalItemCount -> previewAdapter.reset(totalItemCount, imageLoader) }, + onUpdate = previewAdapter::addPreviews, + onCompletion = { + if (!previewAdapter.hasPreviews) { + onNoPreviewCallback?.run() + } + } + ).apply { if (isMeasured) { loadAspectRatios(getMaxWidth(), this@ScrollableImagePreviewView::updatePreviewSize) } @@ -409,14 +411,17 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { } } - private class BatchPreviewLoader( - private val adapter: Adapter, + @VisibleForTesting + class BatchPreviewLoader( private val imageLoader: CachingImageLoader, previews: List, otherItemCount: Int, - private val onNoPreviewCallback: (() -> Unit) + private val onReset: (Int) -> Unit, + private val onUpdate: (List) -> Unit, + private val onCompletion: () -> Unit, ) { - private val pendingPreviews = ArrayDeque(previews) + private val previews: List = + if (previews is RandomAccess) previews else ArrayList(previews) private val totalItemCount = previews.size + otherItemCount private var scope: CoroutineScope? = MainScope() + Dispatchers.Main.immediate @@ -427,52 +432,75 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { fun loadAspectRatios(maxWidth: Int, previewSizeUpdater: (Preview, Int, Int) -> Int) { val scope = this.scope ?: return - val updates = ArrayDeque(pendingPreviews.size) + // -1 encodes that the preview has not been processed, + // 0 means failed, > 0 is a preview width + val previewWidths = IntArray(previews.size) { -1 } + var blockStart = 0 // inclusive + var blockEnd = 0 // exclusive + // replay 2 items to guarantee that we'd get at least one update val reportFlow = MutableSharedFlow(replay = 2) - var isFirstUpdate = true val updateEvent = Any() val completedEvent = Any() - // throttle adapter updates by waiting on the channel, the channel first notified - // when enough preview elements is loaded and then periodically with a delay + + // throttle adapter updates using flow; the flow first emits when enough preview + // elements is loaded to fill the viewport and then each time a subsequent block of + // previews is loaded scope.launch(Dispatchers.Main) { reportFlow .takeWhile { it !== completedEvent } .throttle(ADAPTER_UPDATE_INTERVAL_MS) .onCompletion { cause -> - if (cause == null && !adapter.hasPreviews) { - onNoPreviewCallback() + if (cause == null) { + onCompletion() } } .collect { - if (isFirstUpdate) { - isFirstUpdate = false - adapter.reset(totalItemCount, imageLoader) + if (blockStart == 0) { + onReset(totalItemCount) + } + val updates = ArrayList(blockEnd - blockStart) + while (blockStart < blockEnd) { + if (previewWidths[blockStart] > 0) { + updates.add(previews[blockStart]) + } + blockStart++ } if (updates.isNotEmpty()) { - adapter.addPreviews(updates) - updates.clear() + onUpdate(updates) } } } scope.launch { - var loadedPreviewWidth = 0 + var blockWidth = 0 + var isFirstBlock = true + var nextIdx = 0 List(4) { launch { - while (pendingPreviews.isNotEmpty()) { - val preview = pendingPreviews.poll() ?: continue - val isVisible = loadedPreviewWidth < maxWidth - val bitmap = runCatching { + while (true) { + val i = nextIdx++ + if (i >= previews.size) break + val preview = previews[i] + + previewWidths[i] = runCatching { // TODO: decide on adding a timeout - imageLoader(preview.uri, isVisible) - }.getOrNull() ?: continue - val previewWidth = - previewSizeUpdater(preview, bitmap.width, bitmap.height) - updates.add(preview) - if (isVisible) { - loadedPreviewWidth += previewWidth - if (loadedPreviewWidth >= maxWidth) { + imageLoader(preview.uri, isFirstBlock)?.let { bitmap -> + previewSizeUpdater(preview, bitmap.width, bitmap.height) + } ?: 0 + }.getOrDefault(0) + + if (blockEnd != i) continue + while ( + blockEnd < previewWidths.size + && previewWidths[blockEnd] >= 0 + ) { + blockWidth += previewWidths[blockEnd] + blockEnd++ + } + if (isFirstBlock) { + if (blockWidth >= maxWidth) { + isFirstBlock = false // notify that the preview now can be displayed reportFlow.emit(updateEvent) } diff --git a/java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt b/java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt new file mode 100644 index 00000000..c1d7451f --- /dev/null +++ b/java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt @@ -0,0 +1,239 @@ +/* + * 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.widget + +import android.graphics.Bitmap +import android.net.Uri +import com.android.intentresolver.widget.ScrollableImagePreviewView.BatchPreviewLoader +import com.android.intentresolver.widget.ScrollableImagePreviewView.Preview +import com.android.intentresolver.widget.ScrollableImagePreviewView.PreviewType +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.Test +import com.android.intentresolver.mock +import com.android.intentresolver.captureMany +import com.android.intentresolver.withArgCaptor +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.mockito.Mockito.atLeast +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import com.google.common.truth.Truth.assertThat + +@OptIn(ExperimentalCoroutinesApi::class) +class BatchPreviewLoaderTest { + private val dispatcher = UnconfinedTestDispatcher() + private val testScope = CoroutineScope(dispatcher) + private val onCompletion = mock<() -> Unit>() + private val onReset = mock<(Int) -> Unit>() + private val onUpdate = mock<(List) -> Unit>() + + @Before + fun setup() { + Dispatchers.setMain(dispatcher) + } + + @After + fun cleanup() { + testScope.cancel() + Dispatchers.resetMain() + } + + @Test + fun test_allImagesWithinViewPort_oneUpdate() { + val imageLoader = TestImageLoader(testScope) + val uriOne = createUri(1) + val uriTwo = createUri(2) + imageLoader.setUriLoadingOrder(succeed(uriTwo), succeed(uriOne)) + val testSubject = BatchPreviewLoader( + imageLoader, + previews(uriOne, uriTwo), + 0, + onReset, + onUpdate, + onCompletion + ) + testSubject.loadAspectRatios(200) { _, _, _ -> 100 } + dispatcher.scheduler.advanceUntilIdle() + + verify(onCompletion, times(1)).invoke() + verify(onReset, times(1)).invoke(2) + val list = withArgCaptor { + verify(onUpdate, times(1)).invoke(capture()) + }.map { it.uri } + assertThat(list).containsExactly(uriOne, uriTwo).inOrder() + } + + @Test + fun test_allImagesWithinViewPortOneFailed_failedPreviewIsNotUpdated() { + val imageLoader = TestImageLoader(testScope) + val uriOne = createUri(1) + val uriTwo = createUri(2) + val uriThree = createUri(3) + imageLoader.setUriLoadingOrder(succeed(uriThree), fail(uriTwo), succeed(uriOne)) + val testSubject = BatchPreviewLoader( + imageLoader, + previews(uriOne, uriTwo, uriThree), + 0, + onReset, + onUpdate, + onCompletion + ) + testSubject.loadAspectRatios(200) { _, _, _ -> 100 } + dispatcher.scheduler.advanceUntilIdle() + + verify(onCompletion, times(1)).invoke() + verify(onReset, times(1)).invoke(3) + val list = withArgCaptor { + verify(onUpdate, times(1)).invoke(capture()) + }.map { it.uri } + assertThat(list).containsExactly(uriOne, uriThree).inOrder() + } + + @Test + fun test_imagesLoadedNotInOrder_updatedInOrder() { + val imageLoader = TestImageLoader(testScope) + val uris = Array(10) { createUri(it) } + val loadingOrder = Array(uris.size) { i -> + val uriIdx = when { + i % 2 == 1 -> i - 1 + i % 2 == 0 && i < uris.size - 1 -> i + 1 + else -> i + } + succeed(uris[uriIdx]) + } + imageLoader.setUriLoadingOrder(*loadingOrder) + val testSubject = BatchPreviewLoader( + imageLoader, + previews(*uris), + 0, + onReset, + onUpdate, + onCompletion + ) + testSubject.loadAspectRatios(200) { _, _, _ -> 100 } + dispatcher.scheduler.advanceUntilIdle() + + verify(onCompletion, times(1)).invoke() + verify(onReset, times(1)).invoke(uris.size) + val list = captureMany { + verify(onUpdate, atLeast(1)).invoke(capture()) + }.fold(ArrayList()) { acc, update -> + acc.apply { + addAll(update) + } + }.map { it.uri } + assertThat(list).containsExactly(*uris).inOrder() + } + + @Test + fun test_imagesLoadedNotInOrderSomeFailed_updatedInOrder() { + val imageLoader = TestImageLoader(testScope) + val uris = Array(10) { createUri(it) } + val loadingOrder = Array(uris.size) { i -> + val uriIdx = when { + i % 2 == 1 -> i - 1 + i % 2 == 0 && i < uris.size - 1 -> i + 1 + else -> i + } + if (uriIdx % 2 == 0) fail(uris[uriIdx]) else succeed(uris[uriIdx]) + } + val expectedUris = Array(uris.size / 2) { createUri(it * 2 + 1) } + imageLoader.setUriLoadingOrder(*loadingOrder) + val testSubject = BatchPreviewLoader( + imageLoader, + previews(*uris), + 0, + onReset, + onUpdate, + onCompletion + ) + testSubject.loadAspectRatios(200) { _, _, _ -> 100 } + dispatcher.scheduler.advanceUntilIdle() + + verify(onCompletion, times(1)).invoke() + verify(onReset, times(1)).invoke(uris.size) + val list = captureMany { + verify(onUpdate, atLeast(1)).invoke(capture()) + }.fold(ArrayList()) { acc, update -> + acc.apply { + addAll(update) + } + }.map { it.uri } + assertThat(list).containsExactly(*expectedUris).inOrder() + } + + private fun createUri(idx: Int): Uri = Uri.parse("content://org.pkg.app/image-$idx.png") + + private fun fail(uri: Uri) = uri to false + private fun succeed(uri: Uri) = uri to true + private fun previews(vararg uris: Uri) = + uris.fold(ArrayList(uris.size)) { acc, uri -> + acc.apply { + add(Preview(PreviewType.Image, uri)) + } + } +} + +private class TestImageLoader( + scope: CoroutineScope +) : suspend (Uri, Boolean) -> Bitmap? { + private val loadingOrder = ArrayDeque>() + private val pendingRequests = LinkedHashMap>() + private val flow = MutableSharedFlow(replay = 1) + private val bitmap by lazy { + Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + } + + init { + scope.launch { + flow.collect { + while (true) { + val (nextUri, isLoaded) = loadingOrder.firstOrNull() ?: break + val deferred = pendingRequests.remove(nextUri) ?: break + loadingOrder.removeFirst() + deferred.complete(if (isLoaded) bitmap else null) + } + if (loadingOrder.isEmpty()) { + pendingRequests.forEach { (uri, deferred) -> + deferred.complete(bitmap) + } + pendingRequests.clear() + } + } + } + } + + fun setUriLoadingOrder(vararg uris: Pair) { + loadingOrder.clear() + loadingOrder.addAll(uris) + } + + override suspend fun invoke(uri: Uri, cache: Boolean): Bitmap? { + val deferred = pendingRequests.getOrPut(uri) { CompletableDeferred() } + flow.tryEmit(Unit) + return deferred.await() + } +} -- cgit v1.2.3-59-g8ed1b From 7e34b05b23683d154f8743fd1b4e64c3c165d46b Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Fri, 5 May 2023 12:06:26 -0700 Subject: Add test content provider to mock uri mime types Add a content provider that returns getType and getStreamTypes based on the URI query parameters. That allows us not to rely on the debug hook to override URI mime types, mock files with previes, and makes our integration tests more integral. Bug: 280237072 Test: tests themselves Change-Id: I70835439e8b8b41f4dc633f218d662c2bc42c3ae --- .../android/intentresolver/ChooserActivity.java | 3 +- java/tests/AndroidManifest.xml | 4 ++ .../ChooserActivityOverrideData.java | 2 - .../intentresolver/ChooserWrapperActivity.java | 5 -- .../android/intentresolver/TestContentProvider.kt | 55 ++++++++++++++++++ .../UnbundledChooserActivityTest.java | 66 ++++++++++------------ 6 files changed, 89 insertions(+), 46 deletions(-) create mode 100644 java/tests/src/com/android/intentresolver/TestContentProvider.kt (limited to 'java/tests') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 7f55f78f..ecfaf0e2 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -713,8 +713,7 @@ public class ChooserActivity extends ResolverActivity implements return resolver.query(uri, null, null, null, null); } - @VisibleForTesting - protected boolean isImageType(@Nullable String mimeType) { + private boolean isImageType(@Nullable String mimeType) { return mimeType != null && mimeType.startsWith("image/"); } diff --git a/java/tests/AndroidManifest.xml b/java/tests/AndroidManifest.xml index 306eccb9..05830c4c 100644 --- a/java/tests/AndroidManifest.xml +++ b/java/tests/AndroidManifest.xml @@ -29,6 +29,10 @@ + ?, + selection: String?, + selectionArgs: Array?, + sortOrder: String? + ): Cursor? = null + + override fun getType(uri: Uri): String? + = runCatching { + uri.getQueryParameter("mimeType") + }.getOrNull() + + override fun getStreamTypes(uri: Uri, mimeTypeFilter: String): Array? + = runCatching { + uri.getQueryParameter("streamType")?.let { arrayOf(it) } + }.getOrNull() + + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0 + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array? + ): Int = 0 + + override fun onCreate(): Boolean = true +} \ No newline at end of file diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 7c4838a2..5ea0b47d 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -97,6 +97,7 @@ import android.view.WindowManager; import androidx.annotation.CallSuper; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.test.espresso.contrib.RecyclerViewActions; @@ -695,9 +696,7 @@ public class UnbundledChooserActivityTest { @Test public void testFilePlusTextSharing_ExcludeText() { - Uri uri = Uri.parse( - "android.resource://com.android.frameworks.coretests/" - + R.drawable.test320x240); + Uri uri = createTestContentProviderUri(null, "image/png"); Intent sendIntent = createSendImageIntent(uri); ChooserActivityOverrideData.getInstance().imageLoader = createImageLoader(uri, createBitmap()); @@ -738,9 +737,7 @@ public class UnbundledChooserActivityTest { @Test public void testFilePlusTextSharing_RemoveAndAddBackText() { - Uri uri = Uri.parse( - "android.resource://com.android.frameworks.coretests/" - + R.drawable.test320x240); + Uri uri = createTestContentProviderUri("application/pdf", "image/png"); Intent sendIntent = createSendImageIntent(uri); ChooserActivityOverrideData.getInstance().imageLoader = createImageLoader(uri, createBitmap()); @@ -787,9 +784,7 @@ public class UnbundledChooserActivityTest { @Test public void testFilePlusTextSharing_TextExclusionDoesNotAffectAlternativeIntent() { - Uri uri = Uri.parse( - "android.resource://com.android.frameworks.coretests/" - + R.drawable.test320x240); + Uri uri = createTestContentProviderUri("image/png", null); Intent sendIntent = createSendImageIntent(uri); ChooserActivityOverrideData.getInstance().imageLoader = createImageLoader(uri, createBitmap()); @@ -832,13 +827,10 @@ public class UnbundledChooserActivityTest { @Test public void testImagePlusTextSharing_failedThumbnailAndExcludedText_textChanges() { - Uri uri = Uri.parse( - "android.resource://com.android.frameworks.coretests/" - + R.drawable.test320x240); + Uri uri = createTestContentProviderUri("image/png", null); Intent sendIntent = createSendImageIntent(uri); ChooserActivityOverrideData.getInstance().imageLoader = new TestPreviewImageLoader(Collections.emptyMap()); - ChooserActivityOverrideData.getInstance().isImageType = true; sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); List resolvedComponentInfos = Arrays.asList( @@ -863,7 +855,7 @@ public class UnbundledChooserActivityTest { onView(withId(R.id.image_view)) .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))); onView(withId(R.id.content_preview_text)) - .check(matches(allOf(isDisplayed(), not(isEnabled()), withText("File only")))); + .check(matches(allOf(isDisplayed(), not(isEnabled()), withText("Image only")))); } @Test @@ -930,14 +922,11 @@ public class UnbundledChooserActivityTest { @Test @Ignore - public void testEditImageLogs() throws Exception { - Uri uri = Uri.parse( - "android.resource://com.android.frameworks.coretests/" - + R.drawable.test320x240); + public void testEditImageLogs() { + Uri uri = createTestContentProviderUri("image/png", null); Intent sendIntent = createSendImageIntent(uri); ChooserActivityOverrideData.getInstance().imageLoader = createImageLoader(uri, createBitmap()); - ChooserActivityOverrideData.getInstance().isImageType = true; List resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -956,8 +945,7 @@ public class UnbundledChooserActivityTest { @Test public void oneVisibleImagePreview() { - Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" - + R.drawable.test320x240); + Uri uri = createTestContentProviderUri("image/png", null); ArrayList uris = new ArrayList<>(); uris.add(uri); @@ -965,7 +953,6 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendUriIntentWithPreview(uris); ChooserActivityOverrideData.getInstance().imageLoader = createImageLoader(uri, createWideBitmap()); - ChooserActivityOverrideData.getInstance().isImageType = true; List resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -993,8 +980,7 @@ public class UnbundledChooserActivityTest { @Test public void allThumbnailsFailedToLoad_hidePreview() { - Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" - + R.drawable.test320x240); + Uri uri = createTestContentProviderUri("image/jpg", null); ArrayList uris = new ArrayList<>(); uris.add(uri); @@ -1003,7 +989,6 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendUriIntentWithPreview(uris); ChooserActivityOverrideData.getInstance().imageLoader = new TestPreviewImageLoader(Collections.emptyMap()); - ChooserActivityOverrideData.getInstance().isImageType = true; List resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1016,8 +1001,7 @@ public class UnbundledChooserActivityTest { @Test public void testManyVisibleImagePreview_ScrollableImagePreview() { - Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" - + R.drawable.test320x240); + Uri uri = createTestContentProviderUri("image/png", null); ArrayList uris = new ArrayList<>(); uris.add(uri); @@ -1034,7 +1018,6 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendUriIntentWithPreview(uris); ChooserActivityOverrideData.getInstance().imageLoader = createImageLoader(uri, createBitmap()); - ChooserActivityOverrideData.getInstance().isImageType = true; List resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1054,8 +1037,7 @@ public class UnbundledChooserActivityTest { @Test public void testImageAndTextPreview() { - final Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" - + R.drawable.test320x240); + final Uri uri = createTestContentProviderUri("image/png", null); final String sharedText = "text-" + System.currentTimeMillis(); ArrayList uris = new ArrayList<>(); @@ -1065,7 +1047,6 @@ public class UnbundledChooserActivityTest { sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); ChooserActivityOverrideData.getInstance().imageLoader = createImageLoader(uri, createBitmap()); - ChooserActivityOverrideData.getInstance().isImageType = true; List resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1078,8 +1059,7 @@ public class UnbundledChooserActivityTest { @Test public void testTextPreviewWhenTextIsSharedWithMultipleImages() { - final Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" - + R.drawable.test320x240); + final Uri uri = createTestContentProviderUri("image/png", null); final String sharedText = "text-" + System.currentTimeMillis(); ArrayList uris = new ArrayList<>(); @@ -1090,7 +1070,6 @@ public class UnbundledChooserActivityTest { sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); ChooserActivityOverrideData.getInstance().imageLoader = createImageLoader(uri, createBitmap()); - ChooserActivityOverrideData.getInstance().isImageType = true; List resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1170,8 +1149,7 @@ public class UnbundledChooserActivityTest { @Test public void testImagePreviewLogging() { - Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" - + R.drawable.test320x240); + Uri uri = createTestContentProviderUri("image/png", null); ArrayList uris = new ArrayList<>(); uris.add(uri); @@ -1179,7 +1157,6 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendUriIntentWithPreview(uris); ChooserActivityOverrideData.getInstance().imageLoader = createImageLoader(uri, createBitmap()); - ChooserActivityOverrideData.getInstance().isImageType = true; List resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -2660,6 +2637,21 @@ public class UnbundledChooserActivityTest { return sendIntent; } + private Uri createTestContentProviderUri( + @Nullable String mimeType, @Nullable String streamType) { + String packageName = + InstrumentationRegistry.getInstrumentation().getContext().getPackageName(); + Uri.Builder builder = Uri.parse("content://" + packageName + "/image.png") + .buildUpon(); + if (mimeType != null) { + builder.appendQueryParameter("mimeType", mimeType); + } + if (streamType != null) { + builder.appendQueryParameter("streamType", streamType); + } + return builder.build(); + } + private Intent createSendTextIntentWithPreview(String title, Uri imageThumbnail) { Intent sendIntent = new Intent(); sendIntent.setAction(Intent.ACTION_SEND); -- cgit v1.2.3-59-g8ed1b From 7cc1eb3e660decca55d42105835d7935ef444613 Mon Sep 17 00:00:00 2001 From: 1 Date: Mon, 1 May 2023 21:09:50 +0000 Subject: Use a ViewState to preserve refinement state Refinement manager keeps its own state across config changes so that it can receive callbacks on the ResultReceiver. ChooserActivity can watch for the state of refinement completion and launch the refined TargetInfo if refinement succeeds, otherwise just finish(). If onResume happens when refinement is in progress, ChooserActivity will finish, as this is expected to be abandonment of the refinement process without a result. But, onResume also happens during a config change during refinement, and the activity must not finish in those cases. The logic for this is contained within ChooserRefinementManager, with ChooserActivity only needing to pass along a couple of lifecycle events. Add a bunch of test coverage to new and old functionality. Also fix some kotlin nullability errors in TargetInfo tests that sysui studio was complaining about. Bug: 279514914 Test: atest ChooserRefinementManagerTest Test: manually test the refinement flow in Photos, both completing and cancelling the process, rotating at various points. Change-Id: I2b22155894e29b062c78b4af20e8fe0683d40bea --- .../android/intentresolver/ChooserActivity.java | 46 ++--- .../intentresolver/ChooserRefinementManager.java | 117 +++++++---- .../intentresolver/ChooserRefinementManagerTest.kt | 225 +++++++++++++++++++-- .../chooser/ImmutableTargetInfoTest.kt | 34 ++-- .../intentresolver/chooser/TargetInfoTest.kt | 10 +- 5 files changed, 329 insertions(+), 103 deletions(-) (limited to 'java/tests') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 7f55f78f..8067876f 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -72,6 +72,7 @@ import android.view.animation.LinearInterpolator; import android.widget.TextView; import androidx.annotation.MainThread; +import androidx.lifecycle.ViewModelProvider; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.viewpager.widget.ViewPager; @@ -250,20 +251,25 @@ public class ChooserActivity extends ResolverActivity implements return; } - mRefinementManager = new ChooserRefinementManager( - this, - mChooserRequest.getRefinementIntentSender(), - (validatedRefinedTarget) -> { - maybeRemoveSharedText(validatedRefinedTarget); + mRefinementManager = new ViewModelProvider(this).get(ChooserRefinementManager.class); + + mRefinementManager.getRefinementCompletion().observe(this, completion -> { + if (completion.consume()) { + TargetInfo targetInfo = completion.getTargetInfo(); + // targetInfo is non-null if the refinement process was successful. + if (targetInfo != null) { + maybeRemoveSharedText(targetInfo); // We already block suspended targets from going to refinement, and we probably // can't recover a Chooser session if that's the reason the refined target fails // to launch now. Fire-and-forget the refined launch; ignore the return value // and just make sure the Sharesheet session gets cleaned up regardless. - super.onTargetSelected(validatedRefinedTarget, false); - finish(); - }, - this::finish); + ChooserActivity.super.onTargetSelected(targetInfo, false); + } + + finish(); + } + }); mChooserContentPreviewUi = new ChooserContentPreviewUi( mChooserRequest.getTargetIntent(), @@ -611,14 +617,7 @@ public class ChooserActivity extends ResolverActivity implements Log.d(TAG, "onResume: " + getComponentName().flattenToShortString()); maybeCancelFinishAnimation(); - if (mRefinementManager.isAwaitingRefinementResult()) { - // This can happen if the refinement activity terminates without ever sending a response - // to our `ResultReceiver`. We're probably not prepared to return the user into a valid - // Chooser session, so we'll treat it as a cancellation instead. - Log.w(TAG, "Chooser resumed while awaiting refinement result; aborting"); - mRefinementManager.destroy(); - finish(); - } + mRefinementManager.onActivityResume(); } @Override @@ -730,6 +729,8 @@ public class ChooserActivity extends ResolverActivity implements @Override protected void onStop() { super.onStop(); + mRefinementManager.onActivityStop(isChangingConfigurations()); + if (maybeCancelFinishAnimation()) { finish(); } @@ -743,11 +744,6 @@ public class ChooserActivity extends ResolverActivity implements mLatencyTracker.onActionCancel(ACTION_LOAD_SHARE_SHEET); } - if (mRefinementManager != null) { // TODO: null-checked in case of early-destroy, or skip? - mRefinementManager.destroy(); - mRefinementManager = null; - } - mBackgroundThreadPoolExecutor.shutdownNow(); destroyProfileRecords(); @@ -873,7 +869,11 @@ public class ChooserActivity extends ResolverActivity implements @Override protected boolean onTargetSelected(TargetInfo target, boolean alwaysCheck) { - if (mRefinementManager.maybeHandleSelection(target)) { + if (mRefinementManager.maybeHandleSelection( + target, + mChooserRequest.getRefinementIntentSender(), + getApplication(), + getMainThreadHandler())) { return false; } updateModelAndChooserCounts(target); diff --git a/java/src/com/android/intentresolver/ChooserRefinementManager.java b/java/src/com/android/intentresolver/ChooserRefinementManager.java index 8d7b1aac..2ebe48a6 100644 --- a/java/src/com/android/intentresolver/ChooserRefinementManager.java +++ b/java/src/com/android/intentresolver/ChooserRefinementManager.java @@ -19,16 +19,19 @@ package com.android.intentresolver; import android.annotation.Nullable; import android.annotation.UiThread; import android.app.Activity; -import android.content.Context; +import android.app.Application; import android.content.Intent; import android.content.IntentSender; -import android.content.IntentSender.SendIntentException; import android.os.Bundle; import android.os.Handler; import android.os.Parcel; import android.os.ResultReceiver; import android.util.Log; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + import com.android.intentresolver.chooser.TargetInfo; import java.util.List; @@ -42,38 +45,51 @@ import java.util.function.Consumer; * call). */ @UiThread -public final class ChooserRefinementManager { +public final class ChooserRefinementManager extends ViewModel { private static final String TAG = "ChooserRefinement"; - @Nullable - private final IntentSender mRefinementIntentSender; - - private final Context mContext; - private final Consumer mOnSelectionRefined; - private final Runnable mOnRefinementCancelled; - @Nullable // Non-null only during an active refinement session. private RefinementResultReceiver mRefinementResultReceiver; - public ChooserRefinementManager( - Context context, - @Nullable IntentSender refinementIntentSender, - Consumer onSelectionRefined, - Runnable onRefinementCancelled) { - mContext = context; - mRefinementIntentSender = refinementIntentSender; - mOnSelectionRefined = onSelectionRefined; - mOnRefinementCancelled = onRefinementCancelled; - } + private boolean mConfigurationChangeInProgress = false; /** - * @return whether a refinement session has been initiated (i.e., an earlier call to - * {@link #maybeHandleSelection(TargetInfo)} returned true), and isn't yet complete. The session - * is complete if the refinement activity calls {@link ResultReceiver#onResultReceived()} (with - * any result), or if it's cancelled on our side by {@link ChooserRefinementManager#destroy()}. + * A token for the completion of a refinement process that can be consumed exactly once. */ - public boolean isAwaitingRefinementResult() { - return (mRefinementResultReceiver != null); + public static class RefinementCompletion { + private TargetInfo mTargetInfo; + private boolean mConsumed; + + RefinementCompletion(TargetInfo targetInfo) { + mTargetInfo = targetInfo; + } + + /** + * @return The output of the completed refinement process. Null if the process was aborted + * or failed. + */ + public TargetInfo getTargetInfo() { + return mTargetInfo; + } + + /** + * Mark this event as consumed if it wasn't already. + * + * @return true if this had not already been consumed. + */ + public boolean consume() { + if (!mConsumed) { + mConsumed = true; + return true; + } + return false; + } + } + + private MutableLiveData mRefinementCompletion = new MutableLiveData<>(); + + public LiveData getRefinementCompletion() { + return mRefinementCompletion; } /** @@ -81,8 +97,9 @@ public final class ChooserRefinementManager { * @return true if the selection should wait for a now-started refinement flow, or false if it * can proceed by the default (non-refinement) logic. */ - public boolean maybeHandleSelection(TargetInfo selectedTarget) { - if (mRefinementIntentSender == null) { + public boolean maybeHandleSelection(TargetInfo selectedTarget, + IntentSender refinementIntentSender, Application application, Handler mainHandler) { + if (refinementIntentSender == null) { return false; } if (selectedTarget.getAllSourceIntents().isEmpty()) { @@ -100,33 +117,61 @@ public final class ChooserRefinementManager { mRefinementResultReceiver = new RefinementResultReceiver( refinedIntent -> { destroy(); + TargetInfo refinedTarget = selectedTarget.tryToCloneWithAppliedRefinement(refinedIntent); if (refinedTarget != null) { - mOnSelectionRefined.accept(refinedTarget); + mRefinementCompletion.setValue(new RefinementCompletion(refinedTarget)); } else { Log.e(TAG, "Failed to apply refinement to any matching source intent"); - mOnRefinementCancelled.run(); + mRefinementCompletion.setValue(new RefinementCompletion(null)); } }, () -> { destroy(); - mOnRefinementCancelled.run(); + mRefinementCompletion.setValue(new RefinementCompletion(null)); }, - mContext.getMainThreadHandler()); + mainHandler); Intent refinementRequest = makeRefinementRequest(mRefinementResultReceiver, selectedTarget); try { - mRefinementIntentSender.sendIntent(mContext, 0, refinementRequest, null, null); + refinementIntentSender.sendIntent(application, 0, refinementRequest, null, null); return true; - } catch (SendIntentException e) { + } catch (IntentSender.SendIntentException e) { Log.e(TAG, "Refinement IntentSender failed to send", e); } - return false; + return true; + } + + /** ChooserActivity has stopped */ + public void onActivityStop(boolean configurationChanging) { + mConfigurationChangeInProgress = configurationChanging; + } + + /** ChooserActivity has resumed */ + public void onActivityResume() { + if (mConfigurationChangeInProgress) { + mConfigurationChangeInProgress = false; + } else { + if (mRefinementResultReceiver != null) { + // This can happen if the refinement activity terminates without ever sending a + // response to our `ResultReceiver`. We're probably not prepared to return the user + // into a valid Chooser session, so we'll treat it as a cancellation instead. + Log.w(TAG, "Chooser resumed while awaiting refinement result; aborting"); + destroy(); + mRefinementCompletion.setValue(new RefinementCompletion(null)); + } + } + } + + @Override + protected void onCleared() { + // App lifecycle over, time to clean up. + destroy(); } /** Clean up any ongoing refinement session. */ - public void destroy() { + private void destroy() { if (mRefinementResultReceiver != null) { mRefinementResultReceiver.destroyReceiver(); mRefinementResultReceiver = null; diff --git a/java/tests/src/com/android/intentresolver/ChooserRefinementManagerTest.kt b/java/tests/src/com/android/intentresolver/ChooserRefinementManagerTest.kt index 50c37c7f..bd355c86 100644 --- a/java/tests/src/com/android/intentresolver/ChooserRefinementManagerTest.kt +++ b/java/tests/src/com/android/intentresolver/ChooserRefinementManagerTest.kt @@ -16,46 +16,227 @@ package com.android.intentresolver -import android.content.Context +import android.app.Activity +import android.app.Application import android.content.Intent import android.content.IntentSender +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.Message +import android.os.ResultReceiver +import androidx.lifecycle.Observer +import androidx.test.annotation.UiThreadTest import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.intentresolver.ChooserRefinementManager.RefinementCompletion +import com.android.intentresolver.chooser.ImmutableTargetInfo import com.android.intentresolver.chooser.TargetInfo +import com.google.common.truth.Truth.assertThat +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.ArgumentCaptor import org.mockito.Mockito -import java.util.function.Consumer -import org.junit.Assert.assertEquals @RunWith(AndroidJUnit4::class) +@UiThreadTest class ChooserRefinementManagerTest { - @Test - fun testMaybeHandleSelection() { - val intentSender = mock() - val refinementManager = ChooserRefinementManager( - mock(), - intentSender, - Consumer{}, - Runnable{}) - - val intents = listOf(Intent(Intent.ACTION_VIEW), Intent(Intent.ACTION_EDIT)) - val targetInfo = mock{ - whenever(allSourceIntents).thenReturn(intents) + private val refinementManager = ChooserRefinementManager() + private val intentSender = mock() + private val application = mock() + private val exampleSourceIntents = + listOf(Intent(Intent.ACTION_VIEW), Intent(Intent.ACTION_EDIT)) + private val exampleTargetInfo = + ImmutableTargetInfo.newBuilder().setAllSourceIntents(exampleSourceIntents).build() + + private val completionObserver = + object : Observer { + val failureCountDown = CountDownLatch(1) + val successCountDown = CountDownLatch(1) + var latestTargetInfo: TargetInfo? = null + + override fun onChanged(completion: RefinementCompletion) { + if (completion.consume()) { + val targetInfo = completion.targetInfo + if (targetInfo == null) { + failureCountDown.countDown() + } else { + latestTargetInfo = targetInfo + successCountDown.countDown() + } + } + } } - refinementManager.maybeHandleSelection(targetInfo) + /** Synchronously executes post() calls. */ + private class FakeHandler(looper: Looper) : Handler(looper) { + override fun sendMessageAtTime(msg: Message, uptimeMillis: Long): Boolean { + dispatchMessage(msg) + return true + } + } + + @Before + fun setup() { + refinementManager.refinementCompletion.observeForever(completionObserver) + } + + @Test + fun testTypicalRefinementFlow() { + assertThat( + refinementManager.maybeHandleSelection( + exampleTargetInfo, + intentSender, + application, + FakeHandler(Looper.myLooper()) + ) + ) + .isTrue() val intentCaptor = ArgumentCaptor.forClass(Intent::class.java) - Mockito.verify(intentSender).sendIntent( - any(), eq(0), intentCaptor.capture(), eq(null), eq(null)) + Mockito.verify(intentSender) + .sendIntent(any(), eq(0), intentCaptor.capture(), eq(null), eq(null)) val intent = intentCaptor.value - assertEquals(intents[0], intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)) + assertThat(intent?.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)) + .isEqualTo(exampleSourceIntents[0]) val alternates = - intent.getParcelableArrayExtra(Intent.EXTRA_ALTERNATE_INTENTS, Intent::class.java) - assertEquals(1, alternates?.size) - assertEquals(intents[1], alternates?.get(0)) + intent?.getParcelableArrayExtra(Intent.EXTRA_ALTERNATE_INTENTS, Intent::class.java) + assertThat(alternates?.size).isEqualTo(1) + assertThat(alternates?.get(0)).isEqualTo(exampleSourceIntents[1]) + + // Complete the refinement + val receiver = + intent?.getParcelableExtra(Intent.EXTRA_RESULT_RECEIVER, ResultReceiver::class.java) + val bundle = Bundle().apply { putParcelable(Intent.EXTRA_INTENT, exampleSourceIntents[0]) } + receiver?.send(Activity.RESULT_OK, bundle) + + assertThat(completionObserver.successCountDown.await(1000, TimeUnit.MILLISECONDS)).isTrue() + assertThat(completionObserver.latestTargetInfo?.resolvedIntent?.action) + .isEqualTo(Intent.ACTION_VIEW) + } + + @Test + fun testRefinementCancelled() { + assertThat( + refinementManager.maybeHandleSelection( + exampleTargetInfo, + intentSender, + application, + FakeHandler(Looper.myLooper()) + ) + ) + .isTrue() + + val intentCaptor = ArgumentCaptor.forClass(Intent::class.java) + Mockito.verify(intentSender) + .sendIntent(any(), eq(0), intentCaptor.capture(), eq(null), eq(null)) + + val intent = intentCaptor.value + + // Complete the refinement + val receiver = + intent?.getParcelableExtra(Intent.EXTRA_RESULT_RECEIVER, ResultReceiver::class.java) + val bundle = Bundle().apply { putParcelable(Intent.EXTRA_INTENT, exampleSourceIntents[0]) } + receiver?.send(Activity.RESULT_CANCELED, bundle) + + assertThat(completionObserver.failureCountDown.await(1000, TimeUnit.MILLISECONDS)).isTrue() + } + + @Test + fun testMaybeHandleSelection_noSourceIntents() { + assertThat( + refinementManager.maybeHandleSelection( + ImmutableTargetInfo.newBuilder().build(), + intentSender, + application, + FakeHandler(Looper.myLooper()) + ) + ) + .isFalse() + } + + @Test + fun testMaybeHandleSelection_suspended() { + val targetInfo = + ImmutableTargetInfo.newBuilder() + .setAllSourceIntents(exampleSourceIntents) + .setIsSuspended(true) + .build() + + assertThat( + refinementManager.maybeHandleSelection( + targetInfo, + intentSender, + application, + FakeHandler(Looper.myLooper()) + ) + ) + .isFalse() + } + + @Test + fun testMaybeHandleSelection_noIntentSender() { + assertThat( + refinementManager.maybeHandleSelection( + exampleTargetInfo, + /* IntentSender */ null, + application, + FakeHandler(Looper.myLooper()) + ) + ) + .isFalse() + } + + @Test + fun testConfigurationChangeDuringRefinement() { + assertThat( + refinementManager.maybeHandleSelection( + exampleTargetInfo, + intentSender, + application, + FakeHandler(Looper.myLooper()) + ) + ) + .isTrue() + + refinementManager.onActivityStop(/* config changing = */ true) + refinementManager.onActivityResume() + + assertThat(completionObserver.failureCountDown.count).isEqualTo(1) + } + + @Test + fun testResumeDuringRefinement() { + assertThat( + refinementManager.maybeHandleSelection( + exampleTargetInfo, + intentSender, + application, + FakeHandler(Looper.myLooper()!!) + ) + ) + .isTrue() + + refinementManager.onActivityStop(/* config changing = */ false) + // Resume during refinement but not during a config change, so finish the activity. + refinementManager.onActivityResume() + + // Call should be synchronous, don't need to await for this one. + assertThat(completionObserver.failureCountDown.count).isEqualTo(0) + } + + @Test + fun testRefinementCompletion() { + val refinementCompletion = RefinementCompletion(exampleTargetInfo) + assertThat(refinementCompletion.targetInfo).isEqualTo(exampleTargetInfo) + assertThat(refinementCompletion.consume()).isTrue() + assertThat(refinementCompletion.targetInfo).isEqualTo(exampleTargetInfo) + + // can only consume once. + assertThat(refinementCompletion.consume()).isFalse() } } diff --git a/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt b/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt index cebccaae..504cfd97 100644 --- a/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt +++ b/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt @@ -222,8 +222,8 @@ class ImmutableTargetInfoTest { .build() val info = originalInfo.tryToCloneWithAppliedRefinement(refinementIntent) - assertThat(info.baseIntentToSend.getBooleanExtra("ORIGINAL", false)).isTrue() - assertThat(info.baseIntentToSend.getBooleanExtra("REFINEMENT", false)).isTrue() + assertThat(info?.baseIntentToSend?.getBooleanExtra("ORIGINAL", false)).isTrue() + assertThat(info?.baseIntentToSend?.getBooleanExtra("REFINEMENT", false)).isTrue() } @Test @@ -245,9 +245,9 @@ class ImmutableTargetInfoTest { val info = infoWithReferrerFillIn.tryToCloneWithAppliedRefinement(refinementIntent) - assertThat(info.baseIntentToSend.getPackage()).isEqualTo("original") // Set all along. - assertThat(info.baseIntentToSend.action).isEqualTo("REFINE_ME") // Refinement wins. - assertThat(info.baseIntentToSend.type).isEqualTo("test/referrer") // Left for referrer. + assertThat(info?.baseIntentToSend?.getPackage()).isEqualTo("original") // Set all along. + assertThat(info?.baseIntentToSend?.action).isEqualTo("REFINE_ME") // Refinement wins. + assertThat(info?.baseIntentToSend?.type).isEqualTo("test/referrer") // Left for referrer. } @Test @@ -266,18 +266,18 @@ class ImmutableTargetInfoTest { .build() val refined1 = originalInfo.tryToCloneWithAppliedRefinement(refinementIntent1) - val refined2 = refined1.tryToCloneWithAppliedRefinement(refinementIntent2) // Cloned clone. + val refined2 = refined1?.tryToCloneWithAppliedRefinement(refinementIntent2) // Cloned clone. // Both clones get the same values filled in from the referrer intent. - assertThat(refined1.baseIntentToSend.getStringExtra("TEST")).isEqualTo("REFERRER") - assertThat(refined2.baseIntentToSend.getStringExtra("TEST")).isEqualTo("REFERRER") + assertThat(refined1?.baseIntentToSend?.getStringExtra("TEST")).isEqualTo("REFERRER") + assertThat(refined2?.baseIntentToSend?.getStringExtra("TEST")).isEqualTo("REFERRER") // Each clone has the respective value that was set in their own refinement request. - assertThat(refined1.baseIntentToSend.getStringExtra("TEST1")).isEqualTo("1") - assertThat(refined2.baseIntentToSend.getStringExtra("TEST2")).isEqualTo("2") + assertThat(refined1?.baseIntentToSend?.getStringExtra("TEST1")).isEqualTo("1") + assertThat(refined2?.baseIntentToSend?.getStringExtra("TEST2")).isEqualTo("2") // The clones don't have the data from each other's refinements, even though the intent // field is empty (thus able to be populated by filling-in). - assertThat(refined1.baseIntentToSend.getStringExtra("TEST2")).isNull() - assertThat(refined2.baseIntentToSend.getStringExtra("TEST1")).isNull() + assertThat(refined1?.baseIntentToSend?.getStringExtra("TEST2")).isNull() + assertThat(refined2?.baseIntentToSend?.getStringExtra("TEST1")).isNull() } @Test @@ -301,15 +301,15 @@ class ImmutableTargetInfoTest { refinement.putExtra("refinement", true) val refinedResult = originalInfo.tryToCloneWithAppliedRefinement(refinement) - assertThat(refinedResult.baseIntentToSend.getBooleanExtra("refinement", false)).isTrue() - assertThat(refinedResult.baseIntentToSend.getBooleanExtra("targetAlternate", false)) + assertThat(refinedResult?.baseIntentToSend?.getBooleanExtra("refinement", false)).isTrue() + assertThat(refinedResult?.baseIntentToSend?.getBooleanExtra("targetAlternate", false)) .isTrue() // None of the other source intents got merged in (not even the later one that matched): - assertThat(refinedResult.baseIntentToSend.getBooleanExtra("originalIntent", false)) + assertThat(refinedResult?.baseIntentToSend?.getBooleanExtra("originalIntent", false)) .isFalse() - assertThat(refinedResult.baseIntentToSend.getBooleanExtra("mismatchedAlternate", false)) + assertThat(refinedResult?.baseIntentToSend?.getBooleanExtra("mismatchedAlternate", false)) .isFalse() - assertThat(refinedResult.baseIntentToSend.getBooleanExtra("extraMatch", false)).isFalse() + assertThat(refinedResult?.baseIntentToSend?.getBooleanExtra("extraMatch", false)).isFalse() } @Test diff --git a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt index 886e32df..f9d3dd96 100644 --- a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt +++ b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt @@ -234,15 +234,15 @@ class TargetInfoTest { val refinedResult = originalInfo.tryToCloneWithAppliedRefinement(refinement) // Note `DisplayResolveInfo` targets merge refinements directly into their `resolvedIntent`. - assertThat(refinedResult.resolvedIntent.getBooleanExtra("refinement", false)).isTrue() - assertThat(refinedResult.resolvedIntent.getBooleanExtra("targetAlternate", false)) + assertThat(refinedResult?.resolvedIntent?.getBooleanExtra("refinement", false)).isTrue() + assertThat(refinedResult?.resolvedIntent?.getBooleanExtra("targetAlternate", false)) .isTrue() // None of the other source intents got merged in (not even the later one that matched): - assertThat(refinedResult.resolvedIntent.getBooleanExtra("originalIntent", false)) + assertThat(refinedResult?.resolvedIntent?.getBooleanExtra("originalIntent", false)) .isFalse() - assertThat(refinedResult.resolvedIntent.getBooleanExtra("mismatchedAlternate", false)) + assertThat(refinedResult?.resolvedIntent?.getBooleanExtra("mismatchedAlternate", false)) .isFalse() - assertThat(refinedResult.resolvedIntent.getBooleanExtra("extraMatch", false)).isFalse() + assertThat(refinedResult?.resolvedIntent?.getBooleanExtra("extraMatch", false)).isFalse() } @Test -- cgit v1.2.3-59-g8ed1b From 4be9494c40c3e42485fb3d92ef44efec34faaf3d Mon Sep 17 00:00:00 2001 From: 1 Date: Mon, 1 May 2023 16:07:57 +0000 Subject: Limit chooser actions to 5 Bug: 280288715 Test: atest ChooserRequestParametersTest Test: manual testing with > 5 actions from ShareTest Change-Id: I32a1e616e1fb3642db190c4a76de4fb0f5b7b39a --- .../intentresolver/ChooserRequestParameters.java | 2 + .../intentresolver/ChooserRequestParametersTest.kt | 88 ++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 java/tests/src/com/android/intentresolver/ChooserRequestParametersTest.kt (limited to 'java/tests') diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java index b3f5a722..5157986b 100644 --- a/java/src/com/android/intentresolver/ChooserRequestParameters.java +++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java @@ -68,6 +68,7 @@ public class ChooserRequestParameters { private static final int LAUNCH_FLAGS_FOR_SEND_ACTION = Intent.FLAG_ACTIVITY_NEW_DOCUMENT | Intent.FLAG_ACTIVITY_MULTIPLE_TASK; + private static final int MAX_CHOOSER_ACTIONS = 5; private final Intent mTarget; private final String mReferrerPackageName; @@ -322,6 +323,7 @@ public class ChooserRequestParameters { true, true) .filter(UriFilters::hasValidIcon) + .limit(MAX_CHOOSER_ACTIONS) .collect(toImmutableList()); } diff --git a/java/tests/src/com/android/intentresolver/ChooserRequestParametersTest.kt b/java/tests/src/com/android/intentresolver/ChooserRequestParametersTest.kt new file mode 100644 index 00000000..331d1c21 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/ChooserRequestParametersTest.kt @@ -0,0 +1,88 @@ +/* + * 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 + +import android.app.PendingIntent +import android.content.Intent +import android.graphics.drawable.Icon +import android.net.Uri +import android.service.chooser.ChooserAction +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ChooserRequestParametersTest { + val flags = TestFeatureFlagRepository(mapOf()) + + @Test + fun testChooserActions() { + val actionCount = 3 + val intent = Intent(Intent.ACTION_SEND) + val actions = createChooserActions(actionCount) + val chooserIntent = + Intent(Intent.ACTION_CHOOSER).apply { + putExtra(Intent.EXTRA_INTENT, intent) + putExtra(Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, actions) + } + val request = ChooserRequestParameters(chooserIntent, "", Uri.EMPTY, flags) + assertThat(request.chooserActions).containsExactlyElementsIn(actions).inOrder() + } + + @Test + fun testChooserActions_empty() { + val intent = Intent(Intent.ACTION_SEND) + val chooserIntent = + Intent(Intent.ACTION_CHOOSER).apply { putExtra(Intent.EXTRA_INTENT, intent) } + val request = ChooserRequestParameters(chooserIntent, "", Uri.EMPTY, flags) + assertThat(request.chooserActions).isEmpty() + } + + @Test + fun testChooserActions_tooMany() { + val intent = Intent(Intent.ACTION_SEND) + val chooserActions = createChooserActions(10) + val chooserIntent = + Intent(Intent.ACTION_CHOOSER).apply { + putExtra(Intent.EXTRA_INTENT, intent) + putExtra(Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, chooserActions) + } + + val request = ChooserRequestParameters(chooserIntent, "", Uri.EMPTY, flags) + + val expectedActions = chooserActions.sliceArray(0 until 5) + assertThat(request.chooserActions).containsExactlyElementsIn(expectedActions).inOrder() + } + + private fun createChooserActions(count: Int): Array { + return Array(count) { i -> createChooserAction("$i") } + } + + private fun createChooserAction(label: CharSequence): ChooserAction { + val icon = Icon.createWithContentUri("content://org.package.app/image") + val pendingIntent = + PendingIntent.getBroadcast( + InstrumentationRegistry.getInstrumentation().getTargetContext(), + 0, + Intent("TESTACTION"), + PendingIntent.FLAG_IMMUTABLE + ) + return ChooserAction.Builder(icon, label, pendingIntent).build() + } +} -- cgit v1.2.3-59-g8ed1b From 72e9bda3d304540515742135bf12e5f1dbed8857 Mon Sep 17 00:00:00 2001 From: 1 Date: Mon, 8 May 2023 21:01:33 +0000 Subject: Fixes based upon UX feedback - Align colors with mocks. - Increase custom action vertical padding - Align text + image and file + text to the same 56x56 image size - Reduce target vertical padding - Don't disable text when share is "image only" - Add an icon for single-file shares - Update multi-file icon to align with mocks. - Vertically align image in text+image - Show image and title with text in landscape - Reduce text preview padding to align with file+text padding. Bug: 281541996 Test: build and test with ShareTest, Files and Photos. Change-Id: Ia58315d765a824cb45ee1b84625c1262a8a3e76d --- java/res/color/resolver_profile_tab_text.xml | 2 +- .../drawable/chooser_content_preview_rounded.xml | 2 +- java/res/drawable/content_preview_badge_bg.xml | 2 +- java/res/drawable/ic_file_copy.xml | 2 +- java/res/drawable/resolver_profile_tab_bg.xml | 2 +- java/res/drawable/single_file.xml | 25 ++++++++++++++++++++++ java/res/layout/chooser_action_row.xml | 4 ++-- java/res/layout/chooser_action_view.xml | 16 +++++++------- java/res/layout/chooser_grid_preview_text.xml | 15 +++++++------ java/res/values-h480dp/bools.xml | 1 - java/res/values-h480dp/dimens.xml | 4 ++-- java/res/values/bools.xml | 1 - java/res/values/dimens.xml | 4 ++-- .../contentpreview/FileContentPreviewUi.java | 3 +++ .../FilesPlusTextContentPreviewUi.java | 1 - .../contentpreview/TextContentPreviewUi.java | 7 ++---- .../UnbundledChooserActivityTest.java | 3 +-- 17 files changed, 58 insertions(+), 36 deletions(-) create mode 100644 java/res/drawable/single_file.xml (limited to 'java/tests') diff --git a/java/res/color/resolver_profile_tab_text.xml b/java/res/color/resolver_profile_tab_text.xml index c82aeefb..7c2723ce 100644 --- a/java/res/color/resolver_profile_tab_text.xml +++ b/java/res/color/resolver_profile_tab_text.xml @@ -16,5 +16,5 @@ - + diff --git a/java/res/drawable/chooser_content_preview_rounded.xml b/java/res/drawable/chooser_content_preview_rounded.xml index 85dfacb9..3d6145bc 100644 --- a/java/res/drawable/chooser_content_preview_rounded.xml +++ b/java/res/drawable/chooser_content_preview_rounded.xml @@ -21,7 +21,7 @@ android:shape="rectangle"> + android:color="?androidprv:attr/materialColorSurfaceBright" /> diff --git a/java/res/drawable/content_preview_badge_bg.xml b/java/res/drawable/content_preview_badge_bg.xml index 51caef5f..7f7a1c6f 100644 --- a/java/res/drawable/content_preview_badge_bg.xml +++ b/java/res/drawable/content_preview_badge_bg.xml @@ -21,7 +21,7 @@ android:type="radial" android:centerX="1" android:centerY="0" - android:gradientRadius="@dimen/chooser_preview_image_width" + android:gradientRadius="@dimen/chooser_preview_image_height_tall" android:startColor="#60000000" android:endColor="#00000000" /> diff --git a/java/res/drawable/ic_file_copy.xml b/java/res/drawable/ic_file_copy.xml index d05b55f1..0667e474 100644 --- a/java/res/drawable/ic_file_copy.xml +++ b/java/res/drawable/ic_file_copy.xml @@ -20,6 +20,6 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/java/res/drawable/resolver_profile_tab_bg.xml b/java/res/drawable/resolver_profile_tab_bg.xml index dba06a7f..8bb23a53 100644 --- a/java/res/drawable/resolver_profile_tab_bg.xml +++ b/java/res/drawable/resolver_profile_tab_bg.xml @@ -29,7 +29,7 @@ - + diff --git a/java/res/drawable/single_file.xml b/java/res/drawable/single_file.xml new file mode 100644 index 00000000..af46c97e --- /dev/null +++ b/java/res/drawable/single_file.xml @@ -0,0 +1,25 @@ + + + + diff --git a/java/res/layout/chooser_action_row.xml b/java/res/layout/chooser_action_row.xml index bdf42211..4a3df79e 100644 --- a/java/res/layout/chooser_action_row.xml +++ b/java/res/layout/chooser_action_row.xml @@ -23,7 +23,7 @@ android:id="@+id/actions_top_divider" android:layout_width="match_parent" android:layout_height="2dp" - android:background="?androidprv:attr/materialColorSurfaceDim" + android:background="?androidprv:attr/materialColorSurfaceContainerHighest" /> diff --git a/java/res/layout/chooser_action_view.xml b/java/res/layout/chooser_action_view.xml index ec1b0687..8bdcd15f 100644 --- a/java/res/layout/chooser_action_view.xml +++ b/java/res/layout/chooser_action_view.xml @@ -15,16 +15,16 @@ --> + android:maxWidth="@dimen/chooser_action_max_width" + android:textColor="?androidprv:attr/materialColorOnSurface" + android:textSize="12sp" /> diff --git a/java/res/layout/chooser_grid_preview_text.xml b/java/res/layout/chooser_grid_preview_text.xml index 1a4404a4..44163b49 100644 --- a/java/res/layout/chooser_grid_preview_text.xml +++ b/java/res/layout/chooser_grid_preview_text.xml @@ -20,6 +20,7 @@ - false - false diff --git a/java/res/values-h480dp/dimens.xml b/java/res/values-h480dp/dimens.xml index 3d9e72b8..369a32b8 100644 --- a/java/res/values-h480dp/dimens.xml +++ b/java/res/values-h480dp/dimens.xml @@ -24,7 +24,7 @@ -1px 18dp 80dp - 100dp 192dp - 24dp + 10dp + 56dp diff --git a/java/res/values/bools.xml b/java/res/values/bools.xml index 38d97d8e..fb6db549 100644 --- a/java/res/values/bools.xml +++ b/java/res/values/bools.xml @@ -18,5 +18,4 @@ true - true diff --git a/java/res/values/dimens.xml b/java/res/values/dimens.xml index 30b788a9..6da0ec1c 100644 --- a/java/res/values/dimens.xml +++ b/java/res/values/dimens.xml @@ -58,8 +58,8 @@ 412dp 8dp 120dp - 32dp 64dp - 8dp + 6dp + 46dp diff --git a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java index 85f94451..13f27493 100644 --- a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java @@ -22,6 +22,7 @@ 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 com.android.intentresolver.R; @@ -91,6 +92,8 @@ class FileContentPreviewUi extends ContentPreviewUi { secondLine.setText( PluralsMessageFormatter.format(resources, arguments, R.string.more_files)); } else { + ImageView icon = contentPreviewLayout.findViewById(R.id.content_preview_file_icon); + icon.setImageResource(R.drawable.single_file); secondLine.setVisibility(View.GONE); } diff --git a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java index 363508b7..4fe54681 100644 --- a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java @@ -181,7 +181,6 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { includeText.setText(isLink ? R.string.include_link : R.string.include_text); shareTextAction.accept(false); includeText.setOnCheckedChangeListener((view, isChecked) -> { - textView.setEnabled(isChecked); if (isChecked) { textView.setText(mText); } else { diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java index c429b2d6..3c8a6e48 100644 --- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java @@ -79,9 +79,6 @@ class TextContentPreviewUi extends ContentPreviewUi { ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_text, parent, false); - boolean minimalPreview = - parent.getContext().getResources().getBoolean(R.bool.minimal_preview); - final ActionRow actionRow = contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); actionRow.setActions( @@ -108,7 +105,7 @@ class TextContentPreviewUi extends ContentPreviewUi { TextView previewTitleView = contentPreviewLayout.findViewById( com.android.internal.R.id.content_preview_title); - if (TextUtils.isEmpty(mPreviewTitle) || minimalPreview) { + if (TextUtils.isEmpty(mPreviewTitle)) { previewTitleView.setVisibility(View.GONE); } else { previewTitleView.setText(mPreviewTitle); @@ -116,7 +113,7 @@ class TextContentPreviewUi extends ContentPreviewUi { ImageView previewThumbnailView = contentPreviewLayout.findViewById( com.android.internal.R.id.content_preview_thumbnail); - if (!isOwnedByCurrentUser(mPreviewThumbnail) || minimalPreview) { + if (!isOwnedByCurrentUser(mPreviewThumbnail)) { previewThumbnailView.setVisibility(View.GONE); } else { mImageLoader.loadImage( diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 5ea0b47d..317e2815 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -26,7 +26,6 @@ import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist; import static androidx.test.espresso.assertion.ViewAssertions.matches; import static androidx.test.espresso.matcher.ViewMatchers.hasSibling; import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; -import static androidx.test.espresso.matcher.ViewMatchers.isEnabled; import static androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withText; @@ -855,7 +854,7 @@ public class UnbundledChooserActivityTest { onView(withId(R.id.image_view)) .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))); onView(withId(R.id.content_preview_text)) - .check(matches(allOf(isDisplayed(), not(isEnabled()), withText("Image only")))); + .check(matches(allOf(isDisplayed(), withText("Image only")))); } @Test -- cgit v1.2.3-59-g8ed1b From 79ca9efe77bf5324bcf6c6ca17f519e2eedbff86 Mon Sep 17 00:00:00 2001 From: 1 Date: Mon, 8 May 2023 15:23:54 +0000 Subject: Make app-provided chooser targets only show in the default profile Previously if, say, a work-profile app injected a chooser target, that target would show in *both* profiles, but showing it in the opposite (personal) profile doesn't really make sense since and uses up direct share space. The placement of this code is highly-suspect but I'm avoiding changing that in this CL given the proximity to release. Bug: 280517912 Test: atest UnbundledChooserActivity Change-Id: Ia73699cde6347cd300eae388a42a2433b3840d63 --- .../com/android/intentresolver/ChooserActivity.java | 21 ++++++++++++++------- .../UnbundledChooserActivityTest.java | 14 +++++++++++++- 2 files changed, 27 insertions(+), 8 deletions(-) (limited to 'java/tests') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 7f55f78f..95c93696 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -807,13 +807,20 @@ public class ChooserActivity extends ResolverActivity implements @Override public void addUseDifferentAppLabelIfNecessary(ResolverListAdapter adapter) { - if (mChooserRequest.getCallerChooserTargets().size() > 0) { - mChooserMultiProfilePagerAdapter.getActiveListAdapter().addServiceResults( - /* origTarget */ null, - new ArrayList<>(mChooserRequest.getCallerChooserTargets()), - TARGET_TYPE_DEFAULT, - /* directShareShortcutInfoCache */ Collections.emptyMap(), - /* directShareAppTargetCache */ Collections.emptyMap()); + if (!mChooserRequest.getCallerChooserTargets().isEmpty()) { + // Send the caller's chooser targets only to the default profile. + UserHandle defaultUser = (findSelectedProfile() == PROFILE_WORK) + ? getAnnotatedUserHandles().workProfileUserHandle + : getAnnotatedUserHandles().personalProfileUserHandle; + ChooserListAdapter chooserAdapter = (ChooserListAdapter) adapter; + if (chooserAdapter.getUserHandle() == defaultUser) { + chooserAdapter.addServiceResults( + /* origTarget */ null, + new ArrayList<>(mChooserRequest.getCallerChooserTargets()), + TARGET_TYPE_DEFAULT, + /* directShareShortcutInfoCache */ Collections.emptyMap(), + /* directShareAppTargetCache */ Collections.emptyMap()); + } } } diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 7c4838a2..7b925bce 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -1651,7 +1651,8 @@ public class UnbundledChooserActivityTest { // We need app targets for direct targets to get displayed List resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos, resolvedComponentInfos); + markWorkProfileUserAvailable(); // set caller-provided target Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null); @@ -1707,6 +1708,17 @@ public class UnbundledChooserActivityTest { "The display label must match", activeAdapter.getItem(0).getDisplayLabel(), is(callerTargetLabel)); + + // Switch to work profile and ensure that the target *doesn't* show up there. + onView(withText(R.string.resolver_work_tab)).perform(click()); + waitForIdle(); + + for (int i = 0; i < activity.getWorkListAdapter().getCount(); i++) { + assertThat( + "Chooser target should not show up in opposite profile", + activity.getWorkListAdapter().getItem(i).getDisplayLabel(), + not(callerTargetLabel)); + } } @Test -- cgit v1.2.3-59-g8ed1b From 8c5c2bf68154e98dbc4cba4406fca46b433e8f50 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Sun, 30 Apr 2023 19:11:17 -0700 Subject: Timebox the preview type determination logic The preview metadata reading logic is moved into a new component, PreviewDataProvider. Each call to a content provider is proxied and cached per URI by a PreviewDataProvider$UriRecord so the provider calls are not duplicated. Content preview classes are modified to accept some of the data asynchronously (i.e. after the UI was created): * FilePreviewUi allows the first file name to be provided later; * UnifiedImagePreviewUi and FilesPlusTextPreviewUi allow preview metadata to be updated later. Bug: 280237072 Test: Manual testing Change-Id: I92447f6d34a781bb65ae92b9447ee51a7ffc685e --- java/res/layout/chooser_headline_row.xml | 2 +- .../android/intentresolver/ChooserActivity.java | 12 +- .../contentpreview/ChooserContentPreviewUi.java | 269 +++----------- .../contentpreview/ContentPreviewUi.java | 6 +- .../contentpreview/FileContentPreviewUi.java | 58 +-- .../intentresolver/contentpreview/FileInfo.kt | 26 +- .../FilesPlusTextContentPreviewUi.java | 120 ++++--- .../contentpreview/MimeTypeClassifier.java | 7 + .../contentpreview/PreviewDataProvider.kt | 399 +++++++++++++++++++++ .../contentpreview/UnifiedContentPreviewUi.java | 88 +++-- .../android/intentresolver/measurements/Tracer.kt | 11 +- .../contentpreview/ChooserContentPreviewUiTest.kt | 368 ++++--------------- .../contentpreview/PreviewDataProviderTest.kt | 349 ++++++++++++++++++ 13 files changed, 1063 insertions(+), 652 deletions(-) create mode 100644 java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt create mode 100644 java/tests/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt (limited to 'java/tests') diff --git a/java/res/layout/chooser_headline_row.xml b/java/res/layout/chooser_headline_row.xml index 5074d5bc..8bee807a 100644 --- a/java/res/layout/chooser_headline_row.xml +++ b/java/res/layout/chooser_headline_row.xml @@ -27,7 +27,7 @@ android:id="@+id/headline" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:visibility="gone" + android:visibility="invisible" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toStartOf="@id/barrier" app:layout_constraintHorizontal_bias="0.0" diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 82cfd8ef..84e14d72 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -86,6 +86,7 @@ import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; import com.android.intentresolver.contentpreview.HeadlineGeneratorImpl; import com.android.intentresolver.contentpreview.ImageLoader; +import com.android.intentresolver.contentpreview.PreviewDataProvider; import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.flags.FeatureFlagRepositoryFactory; import com.android.intentresolver.grid.ChooserGridAdapter; @@ -272,8 +273,9 @@ public class ChooserActivity extends ResolverActivity implements }); mChooserContentPreviewUi = new ChooserContentPreviewUi( + getLifecycle(), + createPreviewDataProvider(), mChooserRequest.getTargetIntent(), - getContentResolver(), this::isImageType, createPreviewImageLoader(), createChooserActionFactory(), @@ -536,6 +538,14 @@ public class ChooserActivity extends ResolverActivity implements mMaxTargetsPerRow); } + private PreviewDataProvider createPreviewDataProvider() { + // TODO: move this into a ViewModel so it could survive orientation change + return new PreviewDataProvider( + mChooserRequest.getTargetIntent(), + getContentResolver(), + this::isImageType); + } + private int findSelectedProfile() { int selectedProfile = getSelectedProfileExtra(); if (selectedProfile == -1) { diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index e9476909..55131de2 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -16,55 +16,36 @@ package com.android.intentresolver.contentpreview; -import static android.provider.DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL; - +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.util.UriFilters.isOwnedByCurrentUser; +import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_TEXT; import android.content.ClipData; -import android.content.ClipDescription; -import android.content.ContentInterface; import android.content.Intent; import android.content.res.Resources; -import android.database.Cursor; -import android.media.MediaMetadata; import android.net.Uri; -import android.provider.DocumentsContract; -import android.provider.Downloads; -import android.provider.OpenableColumns; import android.text.TextUtils; -import android.util.Log; import android.view.LayoutInflater; import android.view.ViewGroup; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; +import androidx.lifecycle.Lifecycle; import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; -import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; /** * 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 { - /** - * A set of metadata columns we read for a content URI (see [readFileMetadata] method). - */ - @VisibleForTesting - static final String[] METADATA_COLUMNS = new String[] { - DocumentsContract.Document.COLUMN_FLAGS, - MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI, - OpenableColumns.DISPLAY_NAME, - Downloads.Impl.COLUMN_TITLE - }; + private final Lifecycle mLifecycle; /** * Delegate to build the default system action buttons to display in the preview layout, if/when @@ -100,20 +81,22 @@ public final class ChooserContentPreviewUi { Consumer getExcludeSharedTextAction(); } - private final ContentPreviewUi mContentPreviewUi; + @VisibleForTesting + final ContentPreviewUi mContentPreviewUi; public ChooserContentPreviewUi( + Lifecycle lifecycle, + PreviewDataProvider previewData, Intent targetIntent, - ContentInterface contentResolver, MimeTypeClassifier imageClassifier, ImageLoader imageLoader, ActionFactory actionFactory, TransitionElementStatusCallback transitionElementStatusCallback, HeadlineGenerator headlineGenerator) { - + mLifecycle = lifecycle; mContentPreviewUi = createContentPreview( + previewData, targetIntent, - contentResolver, imageClassifier, imageLoader, actionFactory, @@ -125,63 +108,62 @@ public final class ChooserContentPreviewUi { } private ContentPreviewUi createContentPreview( + PreviewDataProvider previewData, Intent targetIntent, - ContentInterface contentResolver, MimeTypeClassifier typeClassifier, ImageLoader imageLoader, ActionFactory actionFactory, TransitionElementStatusCallback transitionElementStatusCallback, HeadlineGenerator headlineGenerator) { - /* 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/*"))) { + int previewType = previewData.getPreviewType(); + if (previewType == CONTENT_PREVIEW_TEXT) { return createTextPreview( targetIntent, actionFactory, imageLoader, headlineGenerator); } - List uris = extractContentUris(targetIntent); - if (uris.isEmpty()) { - return createTextPreview( - targetIntent, + if (previewType == CONTENT_PREVIEW_FILE) { + FileContentPreviewUi fileContentPreviewUi = new FileContentPreviewUi( + previewData.getUriCount(), actionFactory, - imageLoader, headlineGenerator); + if (previewData.getUriCount() > 0) { + previewData.getFirstFileName( + mLifecycle, fileContentPreviewUi::setFirstFileName); + } + return fileContentPreviewUi; } - ArrayList files = new ArrayList<>(uris.size()); - int previewCount = readFileInfo(contentResolver, typeClassifier, uris, files); + boolean isSingleImageShare = previewData.getUriCount() == 1 + && typeClassifier.isImageType(previewData.getFirstFileInfo().getMimeType()); CharSequence text = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); if (!TextUtils.isEmpty(text)) { - return new FilesPlusTextContentPreviewUi(files, - targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT), - actionFactory, - imageLoader, - typeClassifier, - headlineGenerator); - } - if (previewCount == 0) { - return new FileContentPreviewUi( - files, - actionFactory, - headlineGenerator); + FilesPlusTextContentPreviewUi previewUi = + new FilesPlusTextContentPreviewUi( + isSingleImageShare, + previewData.getUriCount(), + targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT), + actionFactory, + imageLoader, + typeClassifier, + headlineGenerator); + if (previewData.getUriCount() > 0) { + previewData.getFileMetadataForImagePreview( + mLifecycle, previewUi::updatePreviewMetadata); + } + return previewUi; } - return new UnifiedContentPreviewUi( - files, + + UnifiedContentPreviewUi unifiedContentPreviewUi = new UnifiedContentPreviewUi( + isSingleImageShare, actionFactory, imageLoader, typeClassifier, transitionElementStatusCallback, headlineGenerator); + previewData.getFileMetadataForImagePreview(mLifecycle, unifiedContentPreviewUi::setFiles); + return unifiedContentPreviewUi; } public int getPreferredContentPreview() { @@ -198,100 +180,6 @@ public final class ChooserContentPreviewUi { return mContentPreviewUi.display(resources, layoutInflater, parent); } - private static int readFileInfo( - ContentInterface contentResolver, - MimeTypeClassifier typeClassifier, - List uris, - List fileInfos) { - int previewCount = 0; - for (Uri uri: uris) { - FileInfo fileInfo = getFileInfo(contentResolver, typeClassifier, uri); - if (fileInfo.getPreviewUri() != null) { - previewCount++; - } - fileInfos.add(fileInfo); - } - return previewCount; - } - - private static FileInfo getFileInfo( - ContentInterface resolver, MimeTypeClassifier typeClassifier, Uri uri) { - FileInfo.Builder builder = new FileInfo.Builder(uri) - .withName(getFileName(uri)); - String mimeType = getType(resolver, uri); - builder.withMimeType(mimeType); - if (typeClassifier.isImageType(mimeType)) { - return builder.withPreviewUri(uri).build(); - } - readOtherFileTypes(resolver, uri, typeClassifier, builder); - if (builder.getPreviewUri() == null) { - readFileMetadata(resolver, uri, builder); - } - return builder.build(); - } - - private static void readFileMetadata( - ContentInterface resolver, Uri uri, FileInfo.Builder builder) { - Cursor cursor = query(resolver, uri); - if (cursor == null || !cursor.moveToFirst()) { - return; - } - int flagColIdx = -1; - int displayIconUriColIdx = -1; - int nameColIndex = -1; - int titleColIndex = -1; - String[] columns = cursor.getColumnNames(); - // TODO: double-check why Cursor#getColumnInded didn't work - for (int i = 0; i < columns.length; i++) { - String columnName = columns[i]; - if (DocumentsContract.Document.COLUMN_FLAGS.equals(columnName)) { - flagColIdx = i; - } else if (MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI.equals(columnName)) { - displayIconUriColIdx = i; - } else if (OpenableColumns.DISPLAY_NAME.equals(columnName)) { - nameColIndex = i; - } else if (Downloads.Impl.COLUMN_TITLE.equals(columnName)) { - titleColIndex = i; - } - } - String fileName = ""; - if (nameColIndex >= 0) { - fileName = cursor.getString(nameColIndex); - } else if (titleColIndex >= 0) { - fileName = cursor.getString(titleColIndex); - } - if (!TextUtils.isEmpty(fileName)) { - builder.withName(fileName); - } - - Uri previewUri = null; - if (flagColIdx >= 0 && ((cursor.getInt(flagColIdx) & FLAG_SUPPORTS_THUMBNAIL) != 0)) { - previewUri = uri; - } else if (displayIconUriColIdx >= 0) { - String uriStr = cursor.getString(displayIconUriColIdx); - previewUri = uriStr == null ? null : Uri.parse(uriStr); - } - if (previewUri != null) { - builder.withPreviewUri(previewUri); - } - } - - private static void readOtherFileTypes( - ContentInterface resolver, - Uri uri, - MimeTypeClassifier typeClassifier, - FileInfo.Builder builder) { - String[] otherTypes = getStreamTypes(resolver, uri); - if (otherTypes != null && otherTypes.length > 0) { - for (String mimeType : otherTypes) { - if (typeClassifier.isImageType(mimeType)) { - builder.withPreviewUri(uri); - break; - } - } - } - } - private static TextContentPreviewUi createTextPreview( Intent targetIntent, ChooserContentPreviewUi.ActionFactory actionFactory, @@ -315,77 +203,4 @@ public final class ChooserContentPreviewUi { imageLoader, headlineGenerator); } - - private static List extractContentUris(Intent targetIntent) { - List uris = new ArrayList<>(); - if (Intent.ACTION_SEND.equals(targetIntent.getAction())) { - Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); - if (isOwnedByCurrentUser(uri)) { - uris.add(uri); - } - } else { - List receivedUris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); - if (receivedUris != null) { - for (Uri uri : receivedUris) { - if (isOwnedByCurrentUser(uri)) { - uris.add(uri); - } - } - } - } - return uris; - } - - @Nullable - private static String getType(ContentInterface resolver, Uri uri) { - try { - return resolver.getType(uri); - } catch (SecurityException e) { - logProviderPermissionWarning(uri, "mime type"); - } catch (Throwable t) { - Log.e(ContentPreviewUi.TAG, "Failed to read content type, uri: " + uri, t); - } - return null; - } - - @Nullable - private static Cursor query(ContentInterface resolver, Uri uri) { - try { - return resolver.query(uri, METADATA_COLUMNS, null, null); - } catch (SecurityException e) { - logProviderPermissionWarning(uri, "metadata"); - } catch (Throwable t) { - Log.e(ContentPreviewUi.TAG, "Failed to read metadata, uri: " + uri, t); - } - return null; - } - - @Nullable - private static String[] getStreamTypes(ContentInterface resolver, Uri uri) { - try { - return resolver.getStreamTypes(uri, "*/*"); - } catch (SecurityException e) { - logProviderPermissionWarning(uri, "stream types"); - } catch (Throwable t) { - Log.e(ContentPreviewUi.TAG, "Failed to read stream types, uri: " + uri, t); - } - return null; - } - - private static String getFileName(Uri uri) { - String fileName = uri.getPath(); - fileName = fileName == null ? "" : fileName; - int index = fileName.lastIndexOf('/'); - if (index != -1) { - fileName = fileName.substring(index + 1); - } - return fileName; - } - - private static void logProviderPermissionWarning(Uri uri, String dataName) { - // The ContentResolver already logs the exception. Log something more informative. - Log.w(ContentPreviewUi.TAG, "Could not read " + uri + " " + dataName + "." - + " If a preview is desired, call Intent#setClipData() to ensure that the" - + " sharesheet is given permission."); - } } diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java index c0859e53..9699594e 100644 --- a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java @@ -50,10 +50,10 @@ abstract class ContentPreviewUi { List customActions) { ArrayList actions = new ArrayList<>(systemActions.size() + customActions.size()); - if (customActions != null && !customActions.isEmpty()) { - actions.addAll(customActions); - } else { + if (customActions.isEmpty()) { actions.addAll(systemActions); + } else { + actions.addAll(customActions); } return actions; } diff --git a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java index 13f27493..16ff6c23 100644 --- a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java @@ -25,6 +25,8 @@ import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; +import androidx.annotation.Nullable; + import com.android.intentresolver.R; import com.android.intentresolver.widget.ActionRow; @@ -35,17 +37,20 @@ 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 mFiles; + @Nullable + private String mFirstFileName = null; + private final int mFileCount; private final ChooserContentPreviewUi.ActionFactory mActionFactory; private final HeadlineGenerator mHeadlineGenerator; + @Nullable + private ViewGroup mContentPreview = null; FileContentPreviewUi( - List files, + int fileCount, ChooserContentPreviewUi.ActionFactory actionFactory, HeadlineGenerator headlineGenerator) { - mFiles = files; + mFileCount = fileCount; mActionFactory = actionFactory; mHeadlineGenerator = headlineGenerator; } @@ -55,6 +60,13 @@ class FileContentPreviewUi extends ContentPreviewUi { return ContentPreviewType.CONTENT_PREVIEW_FILE; } + public void setFirstFileName(String fileName) { + mFirstFileName = fileName; + if (mContentPreview != null) { + showFileName(mContentPreview, fileName); + } + } + @Override public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { ViewGroup layout = displayInternal(resources, layoutInflater, parent); @@ -64,48 +76,50 @@ class FileContentPreviewUi extends ContentPreviewUi { private ViewGroup displayInternal( Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { - ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( + mContentPreview = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_file, parent, false); - final int uriCount = mFiles.size(); + displayHeadline(mContentPreview, mHeadlineGenerator.getFilesHeadline(mFileCount)); - displayHeadline(contentPreviewLayout, mHeadlineGenerator.getFilesHeadline(mFiles.size())); - - if (uriCount == 0) { - contentPreviewLayout.setVisibility(View.GONE); + if (mFileCount == 0) { + mContentPreview.setVisibility(View.GONE); Log.i(TAG, "Appears to be no uris available in EXTRA_STREAM," + " removing preview area"); - return contentPreviewLayout; + return mContentPreview; } - FileInfo fileInfo = mFiles.get(0); - TextView fileNameView = contentPreviewLayout.findViewById( - R.id.content_preview_filename); - fileNameView.setText(fileInfo.getName()); + if (mFirstFileName != null) { + showFileName(mContentPreview, mFirstFileName); + } - TextView secondLine = contentPreviewLayout.findViewById( + TextView secondLine = mContentPreview.findViewById( R.id.content_preview_more_files); - if (uriCount > 1) { - int remUriCount = uriCount - 1; + if (mFileCount > 1) { + int remUriCount = mFileCount - 1; Map arguments = new HashMap<>(); arguments.put(PLURALS_COUNT, remUriCount); secondLine.setText( PluralsMessageFormatter.format(resources, arguments, R.string.more_files)); } else { - ImageView icon = contentPreviewLayout.findViewById(R.id.content_preview_file_icon); + ImageView icon = mContentPreview.findViewById(R.id.content_preview_file_icon); icon.setImageResource(R.drawable.single_file); secondLine.setVisibility(View.GONE); } final ActionRow actionRow = - contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); + mContentPreview.findViewById(com.android.internal.R.id.chooser_action_row); List actions = createActions(new ArrayList<>(), mActionFactory.createCustomActions()); actionRow.setActions(actions); if (actions.isEmpty()) { - contentPreviewLayout.findViewById(R.id.actions_top_divider).setVisibility(View.GONE); + mContentPreview.findViewById(R.id.actions_top_divider).setVisibility(View.GONE); } - return contentPreviewLayout; + return mContentPreview; + } + + private void showFileName(ViewGroup contentPreview, String name) { + TextView fileNameView = contentPreview.requireViewById(R.id.content_preview_filename); + fileNameView.setText(name); } } diff --git a/java/src/com/android/intentresolver/contentpreview/FileInfo.kt b/java/src/com/android/intentresolver/contentpreview/FileInfo.kt index 527bfc8e..fe35365b 100644 --- a/java/src/com/android/intentresolver/contentpreview/FileInfo.kt +++ b/java/src/com/android/intentresolver/contentpreview/FileInfo.kt @@ -16,33 +16,21 @@ package com.android.intentresolver.contentpreview import android.net.Uri +import androidx.annotation.VisibleForTesting -internal class FileInfo private constructor( - val uri: Uri, - val name: String?, - val previewUri: Uri?, - val mimeType: String? -) { +class FileInfo private constructor(val uri: Uri, val previewUri: Uri?, val mimeType: String?) { + @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) class Builder(val uri: Uri) { - var name: String = "" - private set var previewUri: Uri? = null private set var mimeType: String? = null private set - fun withName(name: String): Builder = apply { - this.name = name - } - - fun withPreviewUri(uri: Uri?): Builder = apply { - previewUri = uri - } + @Synchronized fun withPreviewUri(uri: Uri?): Builder = apply { previewUri = uri } - fun withMimeType(mimeType: String?): Builder = apply { - this.mimeType = mimeType - } + @Synchronized + fun withMimeType(mimeType: String?): Builder = apply { this.mimeType = mimeType } - fun build(): FileInfo = FileInfo(uri, name, previewUri, mimeType) + @Synchronized fun build(): FileInfo = FileInfo(uri, previewUri, mimeType) } } diff --git a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java index 4fe54681..860423c4 100644 --- a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java @@ -20,6 +20,7 @@ import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTE import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_IMAGE; import android.content.res.Resources; +import android.net.Uri; import android.text.util.Linkify; import android.util.PluralsMessageFormatter; import android.view.LayoutInflater; @@ -29,6 +30,8 @@ import android.widget.CheckBox; import android.widget.ImageView; import android.widget.TextView; +import androidx.annotation.Nullable; + import com.android.intentresolver.R; import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ScrollableImagePreviewView; @@ -45,44 +48,44 @@ import java.util.function.Consumer; * file content). */ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { - private final List mFiles; private final CharSequence mText; private final ChooserContentPreviewUi.ActionFactory mActionFactory; private final ImageLoader mImageLoader; private final MimeTypeClassifier mTypeClassifier; private final HeadlineGenerator mHeadlineGenerator; - private final boolean mAllImages; - private final boolean mAllVideos; + private final boolean mIsSingleImage; + private final int mFileCount; + private ViewGroup mContentPreviewView; + private boolean mIsMetadataUpdated = false; + @Nullable + private Uri mFirstFilePreviewUri; + private boolean mAllImages; + private boolean mAllVideos; FilesPlusTextContentPreviewUi( - List files, + boolean isSingleImage, + int fileCount, CharSequence text, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, MimeTypeClassifier typeClassifier, HeadlineGenerator headlineGenerator) { - mFiles = files; + if (isSingleImage && fileCount != 1) { + throw new IllegalArgumentException( + "fileCount = " + fileCount + " and isSingleImage = true"); + } + mFileCount = fileCount; + mIsSingleImage = isSingleImage; mText = text; mActionFactory = actionFactory; mImageLoader = imageLoader; mTypeClassifier = typeClassifier; mHeadlineGenerator = headlineGenerator; - - boolean allImages = true; - boolean allVideos = true; - for (FileInfo fileInfo : mFiles) { - ScrollableImagePreviewView.PreviewType previewType = - getPreviewType(mTypeClassifier, fileInfo.getMimeType()); - allImages = allImages && previewType == ScrollableImagePreviewView.PreviewType.Image; - allVideos = allVideos && previewType == ScrollableImagePreviewView.PreviewType.Video; - } - mAllImages = allImages; - mAllVideos = allVideos; } @Override public int getType() { - return shouldShowPreview() ? CONTENT_PREVIEW_IMAGE : CONTENT_PREVIEW_FILE; + return mIsSingleImage ? CONTENT_PREVIEW_IMAGE : CONTENT_PREVIEW_FILE; } @Override @@ -92,49 +95,52 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { return layout; } + public void updatePreviewMetadata(List files) { + boolean allImages = true; + boolean allVideos = true; + for (FileInfo fileInfo : files) { + ScrollableImagePreviewView.PreviewType previewType = + getPreviewType(mTypeClassifier, fileInfo.getMimeType()); + allImages = allImages && previewType == ScrollableImagePreviewView.PreviewType.Image; + allVideos = allVideos && previewType == ScrollableImagePreviewView.PreviewType.Video; + } + mAllImages = allImages; + mAllVideos = allVideos; + mFirstFilePreviewUri = files.isEmpty() ? null : files.get(0).getPreviewUri(); + mIsMetadataUpdated = true; + if (mContentPreviewView != null) { + updateUiWithMetadata(mContentPreviewView); + } + } + private ViewGroup displayInternal(LayoutInflater layoutInflater, ViewGroup parent) { - ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( + mContentPreviewView = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_files_text, parent, false); - ImageView imagePreview = - contentPreviewLayout.findViewById(R.id.image_view); final ActionRow actionRow = - contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); + mContentPreviewView.findViewById(com.android.internal.R.id.chooser_action_row); List actions = createActions( createImagePreviewActions(), mActionFactory.createCustomActions()); actionRow.setActions(actions); if (actions.isEmpty()) { - contentPreviewLayout.findViewById(R.id.actions_top_divider).setVisibility(View.GONE); + mContentPreviewView.findViewById(R.id.actions_top_divider).setVisibility(View.GONE); } - if (shouldShowPreview()) { - mImageLoader.loadImage(mFiles.get(0).getPreviewUri(), bitmap -> { - if (bitmap == null) { - imagePreview.setVisibility(View.GONE); - } else { - imagePreview.setImageBitmap(bitmap); - } - }); - } else { - imagePreview.setVisibility(View.GONE); + if (mIsMetadataUpdated) { + updateUiWithMetadata(mContentPreviewView); + } else if (!mIsSingleImage) { + mContentPreviewView.requireViewById(R.id.image_view).setVisibility(View.GONE); } - prepareTextPreview(contentPreviewLayout, mActionFactory); - updateHeadline(contentPreviewLayout); - - return contentPreviewLayout; - } - - private boolean shouldShowPreview() { - return mAllImages && mFiles.size() == 1 && mFiles.get(0).getPreviewUri() != null; + return mContentPreviewView; } private List createImagePreviewActions() { ArrayList actions = new ArrayList<>(2); //TODO: add copy action; - if (mFiles.size() == 1 && mAllImages) { + if (mIsSingleImage) { ActionRow.Action action = mActionFactory.createEditButton(); if (action != null) { actions.add(action); @@ -143,24 +149,42 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { return actions; } + private void updateUiWithMetadata(ViewGroup contentPreviewView) { + prepareTextPreview(contentPreviewView, mActionFactory); + updateHeadline(contentPreviewView); + + ImageView imagePreview = mContentPreviewView.requireViewById(R.id.image_view); + if (mIsSingleImage && mFirstFilePreviewUri != null) { + mImageLoader.loadImage(mFirstFilePreviewUri, bitmap -> { + if (bitmap == null) { + imagePreview.setVisibility(View.GONE); + } else { + imagePreview.setImageBitmap(bitmap); + } + }); + } else { + imagePreview.setVisibility(View.GONE); + } + } + private void updateHeadline(ViewGroup contentPreview) { CheckBox includeText = contentPreview.requireViewById(R.id.include_text_action); String headline; if (includeText.getVisibility() == View.VISIBLE && includeText.isChecked()) { if (mAllImages) { - headline = mHeadlineGenerator.getImagesWithTextHeadline(mText, mFiles.size()); + headline = mHeadlineGenerator.getImagesWithTextHeadline(mText, mFileCount); } else if (mAllVideos) { - headline = mHeadlineGenerator.getVideosWithTextHeadline(mText, mFiles.size()); + headline = mHeadlineGenerator.getVideosWithTextHeadline(mText, mFileCount); } else { - headline = mHeadlineGenerator.getFilesWithTextHeadline(mText, mFiles.size()); + headline = mHeadlineGenerator.getFilesWithTextHeadline(mText, mFileCount); } } else { if (mAllImages) { - headline = mHeadlineGenerator.getImagesHeadline(mFiles.size()); + headline = mHeadlineGenerator.getImagesHeadline(mFileCount); } else if (mAllVideos) { - headline = mHeadlineGenerator.getVideosHeadline(mFiles.size()); + headline = mHeadlineGenerator.getVideosHeadline(mFileCount); } else { - headline = mHeadlineGenerator.getFilesHeadline(mFiles.size()); + headline = mHeadlineGenerator.getFilesHeadline(mFileCount); } } @@ -204,7 +228,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { } HashMap params = new HashMap<>(); - params.put("count", mFiles.size()); + params.put("count", mFileCount); return PluralsMessageFormatter.format( resources, diff --git a/java/src/com/android/intentresolver/contentpreview/MimeTypeClassifier.java b/java/src/com/android/intentresolver/contentpreview/MimeTypeClassifier.java index 44cbd52e..2de60c5b 100644 --- a/java/src/com/android/intentresolver/contentpreview/MimeTypeClassifier.java +++ b/java/src/com/android/intentresolver/contentpreview/MimeTypeClassifier.java @@ -35,4 +35,11 @@ public interface MimeTypeClassifier { default boolean isVideoType(@Nullable String mimeType) { return (mimeType != null) && ClipDescription.compareMimeTypes(mimeType, "video/*"); } + + /** + * @return whether the specified {@code mimeType} is classified as "text" type + */ + default boolean isTextType(@Nullable String mimeType) { + return (mimeType != null) && ClipDescription.compareMimeTypes(mimeType, "text/*"); + } } diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt new file mode 100644 index 00000000..94db7a63 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt @@ -0,0 +1,399 @@ +/* + * 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.Intent +import android.database.Cursor +import android.media.MediaMetadata +import android.net.Uri +import android.provider.DocumentsContract +import android.provider.DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL +import android.provider.Downloads +import android.provider.OpenableColumns +import android.text.TextUtils +import android.util.Log +import androidx.annotation.OpenForTesting +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.coroutineScope +import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_FILE +import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_IMAGE +import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_TEXT +import com.android.intentresolver.measurements.runTracing +import com.android.intentresolver.util.ownedByCurrentUser +import java.util.concurrent.atomic.AtomicInteger +import java.util.function.Consumer +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull + +/** + * A set of metadata columns we read for a content URI (see + * [PreviewDataProvider.UriRecord.readQueryResult] method). + */ +@VisibleForTesting +val METADATA_COLUMNS = + arrayOf( + DocumentsContract.Document.COLUMN_FLAGS, + MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI, + OpenableColumns.DISPLAY_NAME, + Downloads.Impl.COLUMN_TITLE + ) +private const val TIMEOUT_MS = 1_000L + +/** + * Asynchronously loads and stores shared URI metadata (see [Intent.EXTRA_STREAM]) such as mime + * type, file name, and a preview thumbnail URI. + */ +@OpenForTesting +open class PreviewDataProvider +@VisibleForTesting +constructor( + private val targetIntent: Intent, + private val contentResolver: ContentInterface, + private val typeClassifier: MimeTypeClassifier, + private val dispatcher: CoroutineDispatcher, +) { + constructor( + targetIntent: Intent, + contentResolver: ContentInterface, + typeClassifier: MimeTypeClassifier, + ) : this( + targetIntent, + contentResolver, + typeClassifier, + Dispatchers.IO, + ) + + private val records = targetIntent.contentUris.map { UriRecord(it) } + + /** returns number of shared URIs, see [Intent.EXTRA_STREAM] */ + @get:OpenForTesting + open val uriCount: Int + get() = records.size + + /** + * Preview type to use. The type is determined asynchronously with a timeout; the fall-back + * values is [ContentPreviewType.CONTENT_PREVIEW_FILE] + */ + @get:OpenForTesting + @get:ContentPreviewType + open val previewType: Int by lazy { + runTracing("preview-type") { + /* In [android.content.Intent#getType], the app may specify a very general mime type + * that broadly covers all data being shared, such as '*' when sending an image + * and text. We therefore should inspect each item for the preferred type, in order: + * IMAGE, FILE, TEXT. */ + if ( + !targetIntent.isSend || + typeClassifier.isTextType(targetIntent.type) || + records.isEmpty() + ) { + CONTENT_PREVIEW_TEXT + } else { + runBlocking(dispatcher) { + withTimeoutOrNull(TIMEOUT_MS) { + loadPreviewType() + } ?: CONTENT_PREVIEW_FILE + } + } + } + } + + /** + * The first shared URI's metadata. This call wait's for the data to be loaded and falls back to + * a crude value if the data is not loaded within a time limit. + */ + open val firstFileInfo: FileInfo? by lazy { + runTracing("first-uri-metadata") { + records.firstOrNull()?.let { record -> + runBlocking(dispatcher) { + val builder = FileInfo.Builder(record.uri) + withTimeoutOrNull(TIMEOUT_MS) { + builder.readFromRecord(record) + } + builder.build() + } + } + } + } + + /** + * Returns a collection of [FileInfo], for each shared URI in order, with [FileInfo.mimeType] + * and [FileInfo.previewUri] set (a data projection tailored for the image preview UI). + */ + @OpenForTesting + open fun getFileMetadataForImagePreview( + callerLifecycle: Lifecycle, + callback: Consumer>, + ) { + callerLifecycle.coroutineScope.launch { + val result = withContext(dispatcher) { + getFileMetadataForImagePreview() + } + callback.accept(result) + } + } + + private fun getFileMetadataForImagePreview(): List = + runTracing("image-preview-metadata") { + ArrayList(records.size).also { result -> + for (record in records) { + result.add( + FileInfo.Builder(record.uri) + .readFromRecord(record) + .build() + ) + } + } + } + + private fun FileInfo.Builder.readFromRecord(record: UriRecord): FileInfo.Builder { + withMimeType(record.mimeType) + val previewUri = + when { + record.isImageType || record.supportsImageType || record.supportsThumbnail -> + record.uri + else -> record.iconUri + } + withPreviewUri(previewUri) + return this + } + + /** + * Returns a title for the first shared URI which is read from URI metadata or, if the metadata + * is not provided, derived from the URI. + */ + @Throws(IndexOutOfBoundsException::class) + fun getFirstFileName(callerLifecycle: Lifecycle, callback: Consumer) { + if (records.isEmpty()) { + throw IndexOutOfBoundsException("There are no shared URIs") + } + callerLifecycle.coroutineScope.launch { + val result = withContext(dispatcher) { + getFirstFileName() + } + callback.accept(result) + } + } + + @Throws(IndexOutOfBoundsException::class) + private fun getFirstFileName(): String { + if (records.isEmpty()) throw IndexOutOfBoundsException("There are no shared URIs") + + val record = records[0] + return if (TextUtils.isEmpty(record.title)) getFileName(record.uri) else record.title + } + + @ContentPreviewType + private suspend fun loadPreviewType(): Int { + // Execute [ContentResolver#getType()] calls sequentially as the method contains a timeout + // logic for the actual [ContentProvider#getType] call. Thus it is possible for one getType + // call's timeout work against other concurrent getType calls e.g. when a two concurrent + // calls on the caller side are scheduled on the same thread on the callee side. + records + .firstOrNull { it.isImageType } + ?.run { + return CONTENT_PREVIEW_IMAGE + } + + val resultDeferred = CompletableDeferred() + return coroutineScope { + val job = launch { + coroutineScope { + val nextIndex = AtomicInteger(0) + repeat(4) { + launch { + while (isActive) { + val i = nextIndex.getAndIncrement() + if (i >= records.size) break + val hasPreview = + with(records[i]) { + supportsImageType || supportsThumbnail || iconUri != null + } + if (hasPreview) { + resultDeferred.complete(CONTENT_PREVIEW_IMAGE) + break + } + } + } + } + } + resultDeferred.complete(CONTENT_PREVIEW_FILE) + } + resultDeferred.await() + .also { job.cancel() } + } + } + + /** + * Provides a lazy evaluation and caches results of [ContentInterface.getType], + * [ContentInterface.getStreamTypes], and [ContentInterface.query] methods for the given [uri]. + */ + private inner class UriRecord(val uri: Uri) { + val mimeType: String? by lazy { contentResolver.getTypeSafe(uri) } + val isImageType: Boolean + get() = typeClassifier.isImageType(mimeType) + val supportsImageType: Boolean by lazy { + contentResolver.getStreamTypesSafe(uri) + ?.firstOrNull(typeClassifier::isImageType) != null + } + val supportsThumbnail: Boolean + get() = query.supportsThumbnail + val title: String + get() = query.title + val iconUri: Uri? + get() = query.iconUri + + private val query by lazy { readQueryResult() } + + private fun readQueryResult(): QueryResult { + val cursor = contentResolver.querySafe(uri) + ?.takeIf { it.moveToFirst() } + ?: return QueryResult() + + var flagColIdx = -1 + var displayIconUriColIdx = -1 + var nameColIndex = -1 + var titleColIndex = -1 + // TODO: double-check why Cursor#getColumnInded didn't work + cursor.columnNames.forEachIndexed { i, columnName -> + when (columnName) { + DocumentsContract.Document.COLUMN_FLAGS -> flagColIdx = i + MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI -> displayIconUriColIdx = i + OpenableColumns.DISPLAY_NAME -> nameColIndex = i + Downloads.Impl.COLUMN_TITLE -> titleColIndex = i + } + } + + val supportsThumbnail = + flagColIdx >= 0 && ((cursor.getInt(flagColIdx) and FLAG_SUPPORTS_THUMBNAIL) != 0) + + var title = "" + if (nameColIndex >= 0) { + title = cursor.getString(nameColIndex) ?: "" + } + if (TextUtils.isEmpty(title) && titleColIndex >= 0) { + title = cursor.getString(titleColIndex) ?: "" + } + + val iconUri = + if (displayIconUriColIdx >= 0) { + cursor.getString(displayIconUriColIdx)?.let(Uri::parse) + } else { + null + } + + return QueryResult(supportsThumbnail, title, iconUri) + } + } + + private class QueryResult( + val supportsThumbnail: Boolean = false, + val title: String = "", + val iconUri: Uri? = null + ) +} + +private val Intent.isSend: Boolean + get() = + action.let { action -> + Intent.ACTION_SEND == action || Intent.ACTION_SEND_MULTIPLE == action + } + +private val Intent.contentUris: ArrayList + get() = + ArrayList().also { uris -> + if (Intent.ACTION_SEND == action) { + getParcelableExtra(Intent.EXTRA_STREAM) + ?.takeIf { it.ownedByCurrentUser } + ?.let { uris.add(it) } + } else { + getParcelableArrayListExtra(Intent.EXTRA_STREAM)?.fold(uris) { accumulator, uri + -> + if (uri.ownedByCurrentUser) { + accumulator.add(uri) + } + accumulator + } + } + } + +private fun getFileName(uri: Uri): String { + val fileName = uri.path ?: return "" + val index = fileName.lastIndexOf('/') + return if (index < 0) { + fileName + } else { + fileName.substring(index + 1) + } +} + +private fun ContentInterface.getTypeSafe(uri: Uri): String? = + runTracing("getType") { + try { + getType(uri) + } catch (e: SecurityException) { + logProviderPermissionWarning(uri, "mime type") + null + } catch (t: Throwable) { + Log.e(ContentPreviewUi.TAG, "Failed to read metadata, uri: $uri", t) + null + } + } + +private fun ContentInterface.getStreamTypesSafe(uri: Uri): Array? = + runTracing("getStreamTypes") { + try { + getStreamTypes(uri, "*/*") + } catch (e: SecurityException) { + logProviderPermissionWarning(uri, "stream types") + null + } catch (t: Throwable) { + Log.e(ContentPreviewUi.TAG, "Failed to read stream types, uri: $uri", t) + null + } + } + +private fun ContentInterface.querySafe(uri: Uri): Cursor? = + runTracing("query") { + try { + query(uri, METADATA_COLUMNS, null, null) + } catch (e: SecurityException) { + logProviderPermissionWarning(uri, "metadata") + null + } catch (t: Throwable) { + Log.e(ContentPreviewUi.TAG, "Failed to read metadata, uri: $uri", t) + null + } + } + +private fun logProviderPermissionWarning(uri: Uri, dataName: String) { + // The ContentResolver already logs the exception. Log something more informative. + Log.w( + ContentPreviewUi.TAG, + "Could not read $uri $dataName. If a preview is desired, call Intent#setClipData() to" + + " ensure that the sharesheet is given permission." + ) +} diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java index c918a7f6..26f4d007 100644 --- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -36,32 +36,30 @@ import java.util.List; import java.util.Objects; class UnifiedContentPreviewUi extends ContentPreviewUi { - private final List mFiles; - @Nullable + private final boolean mShowEditAction; private final ChooserContentPreviewUi.ActionFactory mActionFactory; private final ImageLoader mImageLoader; private final MimeTypeClassifier mTypeClassifier; private final TransitionElementStatusCallback mTransitionElementStatusCallback; private final HeadlineGenerator mHeadlineGenerator; + @Nullable + private List mFiles; + @Nullable + private ViewGroup mContentPreviewView; UnifiedContentPreviewUi( - List files, + boolean isSingleImage, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, MimeTypeClassifier typeClassifier, TransitionElementStatusCallback transitionElementStatusCallback, HeadlineGenerator headlineGenerator) { - mFiles = files; + mShowEditAction = isSingleImage; mActionFactory = actionFactory; mImageLoader = imageLoader; mTypeClassifier = typeClassifier; mTransitionElementStatusCallback = transitionElementStatusCallback; mHeadlineGenerator = headlineGenerator; - - mImageLoader.prePopulate(mFiles.stream() - .map(FileInfo::getPreviewUri) - .filter(Objects::nonNull) - .toList()); } @Override @@ -76,74 +74,88 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { return layout; } + public void setFiles(List files) { + mImageLoader.prePopulate(files.stream() + .map(FileInfo::getPreviewUri) + .filter(Objects::nonNull) + .toList()); + mFiles = files; + if (mContentPreviewView != null) { + updatePreviewWithFiles(mContentPreviewView, files); + } + } + private ViewGroup displayInternal(LayoutInflater layoutInflater, ViewGroup parent) { - ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( + mContentPreviewView = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_image, parent, false); - ScrollableImagePreviewView imagePreview = - contentPreviewLayout.findViewById(R.id.scrollable_image_preview); final ActionRow actionRow = - contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); + mContentPreviewView.findViewById(com.android.internal.R.id.chooser_action_row); List actions = createActions( createImagePreviewActions(), mActionFactory.createCustomActions()); actionRow.setActions(actions); if (actions.isEmpty()) { - contentPreviewLayout.findViewById(R.id.actions_top_divider).setVisibility(View.GONE); + mContentPreviewView.findViewById(R.id.actions_top_divider).setVisibility(View.GONE); } + ScrollableImagePreviewView imagePreview = + mContentPreviewView.requireViewById(R.id.scrollable_image_preview); + imagePreview.setOnNoPreviewCallback(() -> imagePreview.setVisibility(View.GONE)); + imagePreview.setTransitionElementStatusCallback(mTransitionElementStatusCallback); + + if (mFiles != null) { + updatePreviewWithFiles(mContentPreviewView, mFiles); + } - if (mFiles.size() == 0) { + return mContentPreviewView; + } + + private void updatePreviewWithFiles(ViewGroup contentPreviewView, List files) { + final int count = files.size(); + ScrollableImagePreviewView imagePreview = + contentPreviewView.requireViewById(R.id.scrollable_image_preview); + if (count == 0) { Log.i( TAG, "Attempted to display image preview area with zero" - + " available images detected in EXTRA_STREAM list"); + + " available images detected in EXTRA_STREAM list"); imagePreview.setVisibility(View.GONE); mTransitionElementStatusCallback.onAllTransitionElementsReady(); - return contentPreviewLayout; + return; } List previews = new ArrayList<>(); - boolean allImages = !mFiles.isEmpty(); - boolean allVideos = !mFiles.isEmpty(); - for (FileInfo fileInfo : mFiles) { + boolean allImages = true; + boolean allVideos = true; + for (FileInfo fileInfo : files) { ScrollableImagePreviewView.PreviewType previewType = getPreviewType(mTypeClassifier, fileInfo.getMimeType()); allImages = allImages && previewType == ScrollableImagePreviewView.PreviewType.Image; allVideos = allVideos && previewType == ScrollableImagePreviewView.PreviewType.Video; if (fileInfo.getPreviewUri() != null) { - previews.add(new ScrollableImagePreviewView.Preview( - previewType, - fileInfo.getPreviewUri())); + previews.add( + new ScrollableImagePreviewView.Preview( + previewType, fileInfo.getPreviewUri())); } } - imagePreview.setOnNoPreviewCallback(() -> imagePreview.setVisibility(View.GONE)); - imagePreview.setTransitionElementStatusCallback(mTransitionElementStatusCallback); - imagePreview.setPreviews( - previews, - mFiles.size() - previews.size(), - mImageLoader); + imagePreview.setPreviews(previews, count - previews.size(), mImageLoader); if (allImages) { - displayHeadline( - contentPreviewLayout, mHeadlineGenerator.getImagesHeadline(mFiles.size())); + displayHeadline(contentPreviewView, mHeadlineGenerator.getImagesHeadline(count)); } else if (allVideos) { - displayHeadline( - contentPreviewLayout, mHeadlineGenerator.getVideosHeadline(mFiles.size())); + displayHeadline(contentPreviewView, mHeadlineGenerator.getVideosHeadline(count)); } else { - displayHeadline( - contentPreviewLayout, mHeadlineGenerator.getFilesHeadline(mFiles.size())); + displayHeadline(contentPreviewView, mHeadlineGenerator.getFilesHeadline(count)); } - - return contentPreviewLayout; } private List createImagePreviewActions() { ArrayList actions = new ArrayList<>(1); //TODO: add copy action; - if (mFiles.size() == 1 && mTypeClassifier.isImageType(mFiles.get(0).getMimeType())) { + if (mShowEditAction) { ActionRow.Action action = mActionFactory.createEditButton(); if (action != null) { actions.add(action); diff --git a/java/src/com/android/intentresolver/measurements/Tracer.kt b/java/src/com/android/intentresolver/measurements/Tracer.kt index 168bda0e..f7e01879 100644 --- a/java/src/com/android/intentresolver/measurements/Tracer.kt +++ b/java/src/com/android/intentresolver/measurements/Tracer.kt @@ -16,8 +16,8 @@ package com.android.intentresolver.measurements -import android.os.Trace import android.os.SystemClock +import android.os.Trace import android.util.Log import java.util.concurrent.atomic.AtomicLong @@ -44,3 +44,12 @@ object Tracer { private fun elapsedTimeNow() = SystemClock.elapsedRealtime() } + +inline fun runTracing(name: String, block: () -> R): R { + Trace.beginSection(name) + try { + return block() + } finally { + Trace.endSection() + } +} diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt index 63fa8766..5a4b6c76 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt @@ -17,360 +17,144 @@ package com.android.intentresolver.contentpreview import android.content.ClipDescription -import android.content.ContentInterface import android.content.Intent -import android.database.MatrixCursor import android.graphics.Bitmap -import android.media.MediaMetadata import android.net.Uri -import android.provider.DocumentsContract +import androidx.lifecycle.Lifecycle import com.android.intentresolver.any -import com.android.intentresolver.anyOrNull import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory -import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.METADATA_COLUMNS 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 java.util.function.Consumer 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() + private val lifecycle = mock() + private val previewData = mock() private val imageClassifier = MimeTypeClassifier { mimeType -> mimeType != null && ClipDescription.compareMimeTypes(mimeType, "image/*") } private val headlineGenerator = mock() - private val imageLoader = object : ImageLoader { - override fun loadImage(uri: Uri, callback: Consumer) { - callback.accept(null) - } - override fun prePopulate(uris: List) = Unit - override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? = null - } - private val actionFactory = object : ActionFactory { - override fun createCopyButton() = ActionRow.Action(label = "Copy", icon = null) {} - override fun createEditButton(): ActionRow.Action? = null - override fun createCustomActions(): List = emptyList() - override fun getModifyShareAction(): ActionRow.Action? = null - override fun getExcludeSharedTextAction(): Consumer = Consumer {} - } - private val transitionCallback = mock() - - @Test - fun test_nonSendIntentAction_useTextPreviewUi() { - val targetIntent = Intent(Intent.ACTION_VIEW) - val testSubject = ChooserContentPreviewUi( - targetIntent, - contentResolver, - imageClassifier, - imageLoader, - actionFactory, - transitionCallback, - headlineGenerator - ) - assertThat(testSubject.preferredContentPreview) - .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) - verify(transitionCallback, times(1)).onAllTransitionElementsReady() - } - - @Test - fun test_textMimeType_useTextPreviewUi() { - val targetIntent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, "Text Extra") + private val imageLoader = + object : ImageLoader { + override fun loadImage(uri: Uri, callback: Consumer) { + callback.accept(null) + } + override fun prePopulate(uris: List) = Unit + override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? = null } - val testSubject = ChooserContentPreviewUi( - targetIntent, - contentResolver, - imageClassifier, - imageLoader, - actionFactory, - transitionCallback, - headlineGenerator - ) - assertThat(testSubject.preferredContentPreview) - .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) - verify(transitionCallback, times(1)).onAllTransitionElementsReady() - } - - @Test - fun test_singleImageUri_useImagePreviewUi() { - 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, - headlineGenerator - ) - assertThat(testSubject.preferredContentPreview) - .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) - verify(transitionCallback, never()).onAllTransitionElementsReady() - } - - @Test - fun test_singleNonImageUriWithoutPreview_useFilePreviewUi() { - val uri = Uri.parse("content://$PROVIDER_NAME/test.pdf") - val targetIntent = Intent(Intent.ACTION_SEND).apply { - putExtra(Intent.EXTRA_STREAM, uri) + private val actionFactory = + object : ActionFactory { + override fun createCopyButton() = ActionRow.Action(label = "Copy", icon = null) {} + override fun createEditButton(): ActionRow.Action? = null + override fun createCustomActions(): List = emptyList() + override fun getModifyShareAction(): ActionRow.Action? = null + override fun getExcludeSharedTextAction(): Consumer = Consumer {} } - whenever(contentResolver.getType(uri)).thenReturn("application/pdf") - val testSubject = ChooserContentPreviewUi( - targetIntent, - contentResolver, - imageClassifier, - imageLoader, - actionFactory, - transitionCallback, - headlineGenerator - ) - assertThat(testSubject.preferredContentPreview) - .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) - verify(transitionCallback, times(1)).onAllTransitionElementsReady() - } + private val transitionCallback = mock() @Test - fun test_singleUriWithFailingGetType_useFilePreviewUi() { - val uri = Uri.parse("content://$PROVIDER_NAME/test.pdf") - val targetIntent = Intent(Intent.ACTION_SEND).apply { - putExtra(Intent.EXTRA_STREAM, uri) - } - whenever(contentResolver.getType(any())) - .thenThrow(SecurityException("Test getType() exception")) - val testSubject = ChooserContentPreviewUi( - targetIntent, - contentResolver, + fun test_textPreviewType_useTextPreviewUi() { + whenever(previewData.previewType).thenReturn(ContentPreviewType.CONTENT_PREVIEW_TEXT) + val testSubject = + ChooserContentPreviewUi( + lifecycle, + previewData, + Intent(Intent.ACTION_VIEW), imageClassifier, imageLoader, actionFactory, transitionCallback, headlineGenerator - ) + ) assertThat(testSubject.preferredContentPreview) - .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) + .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) + assertThat(testSubject.mContentPreviewUi).isInstanceOf(TextContentPreviewUi::class.java) verify(transitionCallback, times(1)).onAllTransitionElementsReady() } @Test - fun test_singleNonImageUriWithFailingMetadata_useFilePreviewUi() { - val uri = Uri.parse("content://$PROVIDER_NAME/test.pdf") - val targetIntent = Intent(Intent.ACTION_SEND).apply { - putExtra(Intent.EXTRA_STREAM, uri) - } - whenever(contentResolver.getType(any())).thenReturn("application/pdf") - whenever(contentResolver.query(any(), anyOrNull(), anyOrNull(), anyOrNull())) - .thenThrow(SecurityException("Test query() exception")) - whenever(contentResolver.getStreamTypes(any(), any())) - .thenThrow(SecurityException("Test getStreamType() exception")) - val testSubject = ChooserContentPreviewUi( - targetIntent, - contentResolver, + fun test_filePreviewType_useFilePreviewUi() { + whenever(previewData.previewType).thenReturn(ContentPreviewType.CONTENT_PREVIEW_FILE) + val testSubject = + ChooserContentPreviewUi( + lifecycle, + previewData, + Intent(Intent.ACTION_SEND), imageClassifier, imageLoader, actionFactory, transitionCallback, headlineGenerator - ) + ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) + assertThat(testSubject.mContentPreviewUi).isInstanceOf(FileContentPreviewUi::class.java) verify(transitionCallback, times(1)).onAllTransitionElementsReady() } @Test - fun test_SingleNonImageUriWithImageTypeInGetStreamTypes_useImagePreviewUi() { - val uri = Uri.parse("content://$PROVIDER_NAME/test.pdf") - val targetIntent = Intent(Intent.ACTION_SEND).apply { - putExtra(Intent.EXTRA_STREAM, uri) - } - whenever(contentResolver.getType(uri)).thenReturn("application/pdf") - whenever(contentResolver.getStreamTypes(uri, "*/*")) - .thenReturn(arrayOf("application/pdf", "image/png")) - val testSubject = ChooserContentPreviewUi( - targetIntent, - contentResolver, + fun test_imagePreviewTypeWithText_useFilePlusTextPreviewUi() { + val uri = Uri.parse("content://org.pkg.app/img.png") + whenever(previewData.previewType).thenReturn(ContentPreviewType.CONTENT_PREVIEW_IMAGE) + whenever(previewData.uriCount).thenReturn(2) + whenever(previewData.firstFileInfo) + .thenReturn( + FileInfo.Builder(uri) + .withPreviewUri(uri) + .withMimeType("image/png") + .build() + ) + val testSubject = + ChooserContentPreviewUi( + lifecycle, + previewData, + Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_TEXT, "Shared text") }, imageClassifier, imageLoader, actionFactory, transitionCallback, headlineGenerator - ) - assertThat(testSubject.preferredContentPreview) - .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) - verify(transitionCallback, never()).onAllTransitionElementsReady() - } - - @Test - fun test_SingleNonImageUriWithThumbnailFlag_useImagePreviewUi() { - testMetadataToImagePreview( - columns = arrayOf(DocumentsContract.Document.COLUMN_FLAGS), - values = arrayOf( - DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL or - DocumentsContract.Document.FLAG_SUPPORTS_METADATA ) - ) + assertThat(testSubject.mContentPreviewUi) + .isInstanceOf(FilesPlusTextContentPreviewUi::class.java) + verify(previewData, times(1)).getFileMetadataForImagePreview(any(), any()) + verify(transitionCallback, times(1)).onAllTransitionElementsReady() } @Test - fun test_SingleNonImageUriWithMetadataIconUri_useImagePreviewUi() { - testMetadataToImagePreview( - columns = arrayOf(MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI), - values = arrayOf("content://$PROVIDER_NAME/test.pdf?thumbnail"), - ) - } - - private fun testMetadataToImagePreview(columns: Array, values: Array) { - val uri = Uri.parse("content://$PROVIDER_NAME/test.pdf") - val targetIntent = Intent(Intent.ACTION_SEND).apply { - putExtra(Intent.EXTRA_STREAM, uri) - } - whenever(contentResolver.getType(uri)).thenReturn("application/pdf") - whenever(contentResolver.query(uri, METADATA_COLUMNS, null, null)) + fun test_imagePreviewTypeWithoutText_useImagePreviewUi() { + val uri = Uri.parse("content://org.pkg.app/img.png") + whenever(previewData.previewType).thenReturn(ContentPreviewType.CONTENT_PREVIEW_IMAGE) + whenever(previewData.uriCount).thenReturn(2) + whenever(previewData.firstFileInfo) .thenReturn( - MatrixCursor(columns).apply { - addRow(values) - } - ) - val testSubject = ChooserContentPreviewUi( - targetIntent, - contentResolver, - imageClassifier, - imageLoader, - actionFactory, - transitionCallback, - headlineGenerator - ) - assertThat(testSubject.preferredContentPreview) - .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) - verify(transitionCallback, never()).onAllTransitionElementsReady() - } - - @Test - fun test_multipleImageUri_useImagePreviewUi() { - 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().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, - headlineGenerator - ) - assertThat(testSubject.preferredContentPreview) - .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) - verify(transitionCallback, never()).onAllTransitionElementsReady() - } - - @Test - fun test_SomeImageUri_useImagePreviewUi() { - 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().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, - headlineGenerator - ) - assertThat(testSubject.preferredContentPreview) - .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) - verify(transitionCallback, never()).onAllTransitionElementsReady() - } - - @Test - fun test_someNonImageUriWithPreview_useImagePreviewUi() { - val uri1 = Uri.parse("content://$PROVIDER_NAME/test.mp4") - val uri2 = Uri.parse("content://$PROVIDER_NAME/test.pdf") - val targetIntent = Intent(Intent.ACTION_SEND_MULTIPLE).apply { - putExtra( - Intent.EXTRA_STREAM, - ArrayList().apply { - add(uri1) - add(uri2) - } + FileInfo.Builder(uri) + .withPreviewUri(uri) + .withMimeType("image/png") + .build() ) - } - whenever(contentResolver.getType(uri1)).thenReturn("video/mpeg4") - whenever(contentResolver.getStreamTypes(uri1, "*/*")) - .thenReturn(arrayOf("image/png")) - whenever(contentResolver.getType(uri2)).thenReturn("application/pdf") - val testSubject = ChooserContentPreviewUi( - targetIntent, - contentResolver, + val testSubject = + ChooserContentPreviewUi( + lifecycle, + previewData, + Intent(Intent.ACTION_SEND), imageClassifier, imageLoader, actionFactory, transitionCallback, headlineGenerator - ) - assertThat(testSubject.preferredContentPreview) - .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) - verify(transitionCallback, never()).onAllTransitionElementsReady() - } - - @Test - fun test_allNonImageUrisWithoutPreview_useFilePreviewUi() { - val uri1 = Uri.parse("content://$PROVIDER_NAME/test.html") - val uri2 = Uri.parse("content://$PROVIDER_NAME/test.pdf") - val targetIntent = Intent(Intent.ACTION_SEND_MULTIPLE).apply { - putExtra( - Intent.EXTRA_STREAM, - ArrayList().apply { - add(uri1) - add(uri2) - } ) - } - whenever(contentResolver.getType(uri1)).thenReturn("text/html") - whenever(contentResolver.getType(uri2)).thenReturn("application/pdf") - val testSubject = ChooserContentPreviewUi( - targetIntent, - contentResolver, - imageClassifier, - imageLoader, - actionFactory, - transitionCallback, - headlineGenerator - ) assertThat(testSubject.preferredContentPreview) - .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) - verify(transitionCallback, times(1)).onAllTransitionElementsReady() + .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) + assertThat(testSubject.mContentPreviewUi).isInstanceOf(UnifiedContentPreviewUi::class.java) + verify(previewData, times(1)).getFileMetadataForImagePreview(any(), any()) + verify(transitionCallback, never()).onAllTransitionElementsReady() } } diff --git a/java/tests/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt new file mode 100644 index 00000000..5be373b3 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt @@ -0,0 +1,349 @@ +/* + * 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.database.MatrixCursor +import android.media.MediaMetadata +import android.net.Uri +import android.provider.DocumentsContract +import androidx.lifecycle.Lifecycle +import com.android.intentresolver.TestLifecycleOwner +import com.android.intentresolver.mock +import com.android.intentresolver.whenever +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.any +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify + +@OptIn(ExperimentalCoroutinesApi::class) +class PreviewDataProviderTest { + private val contentResolver = mock() + private val mimeTypeClassifier = MimeTypeClassifier { mimeType -> + mimeType != null && ClipDescription.compareMimeTypes(mimeType, "image/*") + } + + private val lifecycleOwner = TestLifecycleOwner() + private val dispatcher = UnconfinedTestDispatcher() + + @Before + fun setup() { + Dispatchers.setMain(dispatcher) + lifecycleOwner.state = Lifecycle.State.CREATED + } + + @After + fun cleanup() { + lifecycleOwner.state = Lifecycle.State.DESTROYED + Dispatchers.resetMain() + } + + @Test + fun test_nonSendIntentAction_resolvesToTextPreviewUiSynchronously() { + val targetIntent = Intent(Intent.ACTION_VIEW) + val testSubject = + PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) + + assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) + verify(contentResolver, never()).getType(any()) + } + + @Test + fun test_sendIntentWithTextMimeType_resolvesToTextPreviewUiSynchronously() { + val targetIntent = Intent(Intent.ACTION_SEND) + .apply { + type = "text/plain" + } + val testSubject = + PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) + + assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) + verify(contentResolver, never()).getType(any()) + } + + @Test + fun test_sendIntentWithoutUris_resolvesToTextPreviewUiSynchronously() { + val targetIntent = Intent(Intent.ACTION_SEND) + .apply { + type = "image/png" + } + val testSubject = + PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) + + assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) + verify(contentResolver, never()).getType(any()) + } + + @Test + fun test_sendSingleImage_resolvesToImagePreviewUi() { + val uri = Uri.parse("content://org.pkg.app/image.png") + val targetIntent = Intent(Intent.ACTION_SEND) + .apply { + putExtra(Intent.EXTRA_STREAM, uri) + } + whenever(contentResolver.getType(uri)).thenReturn("image/png") + val testSubject = + PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) + + assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) + assertThat(testSubject.uriCount).isEqualTo(1) + assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri) + assertThat(testSubject.firstFileInfo?.previewUri).isEqualTo(uri) + verify(contentResolver, times(1)).getType(any()) + } + + @Test + fun test_sendSingleNonImage_resolvesToFilePreviewUi() { + val uri = Uri.parse("content://org.pkg.app/paper.pdf") + val targetIntent = Intent(Intent.ACTION_SEND) + .apply { + putExtra(Intent.EXTRA_STREAM, uri) + } + whenever(contentResolver.getType(uri)).thenReturn("application/pdf") + val testSubject = + PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) + + assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) + assertThat(testSubject.uriCount).isEqualTo(1) + assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri) + assertThat(testSubject.firstFileInfo?.previewUri).isNull() + verify(contentResolver, times(1)).getType(any()) + } + + @Test + fun test_sendSingleImageWithFailingGetType_resolvesToFilePreviewUi() { + val uri = Uri.parse("content://org.pkg.app/image.png") + val targetIntent = + Intent(Intent.ACTION_SEND) + .apply { + type = "image/png" + putExtra(Intent.EXTRA_STREAM, uri) + } + whenever(contentResolver.getType(uri)).thenThrow(SecurityException("test failure")) + val testSubject = + PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) + + assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) + assertThat(testSubject.uriCount).isEqualTo(1) + assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri) + assertThat(testSubject.firstFileInfo?.previewUri).isNull() + verify(contentResolver, times(1)).getType(any()) + } + + @Test + fun test_sendSingleImageWithFailingMetadata_resolvesToFilePreviewUi() { + val uri = Uri.parse("content://org.pkg.app/image.png") + val targetIntent = + Intent(Intent.ACTION_SEND) + .apply { + type = "image/png" + putExtra(Intent.EXTRA_STREAM, uri) + } + whenever(contentResolver.getStreamTypes(uri, "*/*")) + .thenThrow(SecurityException("test failure")) + whenever(contentResolver.query(uri, METADATA_COLUMNS, null, null)) + .thenThrow(SecurityException("test failure")) + val testSubject = + PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) + + assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) + assertThat(testSubject.uriCount).isEqualTo(1) + assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri) + assertThat(testSubject.firstFileInfo?.previewUri).isNull() + verify(contentResolver, times(1)).getType(any()) + } + + @Test + fun test_SingleNonImageUriWithImageTypeInGetStreamTypes_useImagePreviewUi() { + val uri = Uri.parse("content://org.pkg.app/paper.pdf") + val targetIntent = Intent(Intent.ACTION_SEND) + .apply { + putExtra(Intent.EXTRA_STREAM, uri) + } + whenever(contentResolver.getStreamTypes(uri, "*/*")) + .thenReturn(arrayOf("application/pdf", "image/png")) + val testSubject = + PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) + + assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) + assertThat(testSubject.uriCount).isEqualTo(1) + assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri) + assertThat(testSubject.firstFileInfo?.previewUri).isEqualTo(uri) + verify(contentResolver, times(1)).getType(any()) + } + + @Test + fun test_SingleNonImageUriWithThumbnailFlag_useImagePreviewUi() { + testMetadataToImagePreview( + columns = arrayOf(DocumentsContract.Document.COLUMN_FLAGS), + values = + arrayOf( + DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL or + DocumentsContract.Document.FLAG_SUPPORTS_METADATA + ) + ) + } + + @Test + fun test_SingleNonImageUriWithMetadataIconUri_useImagePreviewUi() { + testMetadataToImagePreview( + columns = arrayOf(MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI), + values = arrayOf("content://org.pkg.app/test.pdf?thumbnail"), + ) + } + + private fun testMetadataToImagePreview(columns: Array, values: Array) { + val uri = Uri.parse("content://org.pkg.app/test.pdf") + val targetIntent = Intent(Intent.ACTION_SEND) + .apply { + putExtra(Intent.EXTRA_STREAM, uri) + } + whenever(contentResolver.getType(uri)).thenReturn("application/pdf") + whenever(contentResolver.query(uri, METADATA_COLUMNS, null, null)) + .thenReturn(MatrixCursor(columns).apply { addRow(values) }) + val testSubject = + PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) + + assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) + assertThat(testSubject.uriCount).isEqualTo(1) + assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri) + assertThat(testSubject.firstFileInfo?.previewUri).isNotNull() + verify(contentResolver, times(1)).getType(any()) + } + + @Test + fun test_multipleImageUri_useImagePreviewUi() { + val uri1 = Uri.parse("content://org.pkg.app/test.png") + val uri2 = Uri.parse("content://org.pkg.app/test.jpg") + val targetIntent = + Intent(Intent.ACTION_SEND_MULTIPLE) + .apply { + putExtra( + Intent.EXTRA_STREAM, + ArrayList().apply { + add(uri1) + add(uri2) + } + ) + } + whenever(contentResolver.getType(uri1)).thenReturn("image/png") + whenever(contentResolver.getType(uri2)).thenReturn("image/jpeg") + val testSubject = + PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) + + assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) + assertThat(testSubject.uriCount).isEqualTo(2) + assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri1) + assertThat(testSubject.firstFileInfo?.previewUri).isEqualTo(uri1) + // preview type can be determined by the first URI type + verify(contentResolver, times(1)).getType(any()) + } + + @Test + fun test_SomeImageUri_useImagePreviewUi() { + val uri1 = Uri.parse("content://org.pkg.app/test.png") + val uri2 = Uri.parse("content://org.pkg.app/test.pdf") + whenever(contentResolver.getType(uri1)).thenReturn("image/png") + whenever(contentResolver.getType(uri2)).thenReturn("application/pdf") + val targetIntent = + Intent(Intent.ACTION_SEND_MULTIPLE) + .apply { + putExtra( + Intent.EXTRA_STREAM, + ArrayList().apply { + add(uri1) + add(uri2) + } + ) + } + val testSubject = + PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) + + assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) + assertThat(testSubject.uriCount).isEqualTo(2) + assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri1) + assertThat(testSubject.firstFileInfo?.previewUri).isEqualTo(uri1) + // preview type can be determined by the first URI type + verify(contentResolver, times(1)).getType(any()) + } + + @Test + fun test_someNonImageUriWithPreview_useImagePreviewUi() { + val uri1 = Uri.parse("content://org.pkg.app/test.mp4") + val uri2 = Uri.parse("content://org.pkg.app/test.pdf") + val targetIntent = + Intent(Intent.ACTION_SEND_MULTIPLE) + .apply { + putExtra( + Intent.EXTRA_STREAM, + ArrayList().apply { + add(uri1) + add(uri2) + } + ) + } + whenever(contentResolver.getType(uri1)).thenReturn("video/mpeg4") + whenever(contentResolver.getStreamTypes(uri1, "*/*")).thenReturn(arrayOf("image/png")) + whenever(contentResolver.getType(uri2)).thenReturn("application/pdf") + val testSubject = + PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) + + assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) + assertThat(testSubject.uriCount).isEqualTo(2) + assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri1) + assertThat(testSubject.firstFileInfo?.previewUri).isEqualTo(uri1) + verify(contentResolver, times(2)).getType(any()) + } + + @Test + fun test_allNonImageUrisWithoutPreview_useFilePreviewUi() { + val uri1 = Uri.parse("content://org.pkg.app/test.html") + val uri2 = Uri.parse("content://org.pkg.app/test.pdf") + val targetIntent = + Intent(Intent.ACTION_SEND_MULTIPLE) + .apply { + putExtra( + Intent.EXTRA_STREAM, + ArrayList().apply { + add(uri1) + add(uri2) + } + ) + } + whenever(contentResolver.getType(uri1)).thenReturn("text/html") + whenever(contentResolver.getType(uri2)).thenReturn("application/pdf") + val testSubject = + PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) + + assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) + assertThat(testSubject.uriCount).isEqualTo(2) + assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri1) + assertThat(testSubject.firstFileInfo?.previewUri).isNull() + verify(contentResolver, times(2)).getType(any()) + } +} -- cgit v1.2.3-59-g8ed1b From 0e19f669c3703a6c29baece731b09011e01e1790 Mon Sep 17 00:00:00 2001 From: Anthony Alridge Date: Tue, 2 May 2023 09:30:05 +0000 Subject: Remove auto-launching for single target, multiple tabs This can lead to poor UX when auto-launching across tabs. If the intent picker should be shown (e.g dev calls startActivity), and there is only a single app to handle the intent, then this has no affect. It is handled in either IntentForwarderActivity, or in ResolverActivity (mini-resolver). However, if the dev calls Intent.createChooser/ACTION_CHOOSER, without an ACTION_SEND intent (ChooserActivity), then we show something that looks like an intent picker, which has this incorrect auto-launching logic. For now we simply remove the logic because it doesn't make sesnse in an actual sharing context, and if ACTION_SEND is not used it leads to a security vulnerability and very poor UX (b/270700718). In a future release we can hopefully improve this and treat this case in-line with the intent-picker (e.g., showing the mini-resolver). Bug: 272208024 Bug: 270700718 Test: atest ResolverActivityTest Test: atest UnbundledChoserActivityTest Test: Manually tested, attached screenshots in bug Change-Id: I1a340cab94230cada1e7e805195070b032e7c5ff --- .../android/intentresolver/ResolverActivity.java | 23 ++++------------------ .../intentresolver/ResolverActivityTest.java | 5 +++-- .../UnbundledChooserActivityTest.java | 4 ++-- 3 files changed, 9 insertions(+), 23 deletions(-) (limited to 'java/tests') diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index aea6c2c9..06a1d90c 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -1864,8 +1864,10 @@ public class ResolverActivity extends FragmentActivity implements } else if (numberOfProfiles == 2 && mMultiProfilePagerAdapter.getActiveListAdapter().isTabLoaded() && mMultiProfilePagerAdapter.getInactiveListAdapter().isTabLoaded() - && (maybeAutolaunchIfNoAppsOnInactiveTab() - || maybeAutolaunchIfCrossProfileSupported())) { + && maybeAutolaunchIfCrossProfileSupported()) { + // TODO(b/280988288): If the ChooserActivity is shown we should consider showing the + // correct intent-picker UIs (e.g., mini-resolver) if it was launched without + // ACTION_SEND. return true; } return false; @@ -1892,23 +1894,6 @@ public class ResolverActivity extends FragmentActivity implements return false; } - private boolean maybeAutolaunchIfNoAppsOnInactiveTab() { - int count = mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); - if (count != 1) { - return false; - } - ResolverListAdapter inactiveListAdapter = - mMultiProfilePagerAdapter.getInactiveListAdapter(); - if (inactiveListAdapter.getUnfilteredCount() != 0) { - return false; - } - TargetInfo target = mMultiProfilePagerAdapter.getActiveListAdapter() - .targetInfoForPosition(0, false); - safelyStartActivity(target); - finish(); - return true; - } - /** * When we have a personal and a work profile, we auto launch in the following scenario: * - There is 1 resolved target on each profile diff --git a/java/tests/src/com/android/intentresolver/ResolverActivityTest.java b/java/tests/src/com/android/intentresolver/ResolverActivityTest.java index 5cece092..31c0a498 100644 --- a/java/tests/src/com/android/intentresolver/ResolverActivityTest.java +++ b/java/tests/src/com/android/intentresolver/ResolverActivityTest.java @@ -34,6 +34,7 @@ import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; import static org.junit.Assert.fail; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; @@ -741,7 +742,7 @@ public class ResolverActivityTest { } @Test - public void testWorkTab_onePersonalTarget_emptyStateOnWorkTarget_autolaunch() { + public void testWorkTab_onePersonalTarget_emptyStateOnWorkTarget_doesNotAutoLaunch() { markWorkProfileUserAvailable(); int workProfileTargets = 4; List personalResolvedComponentInfos = @@ -763,7 +764,7 @@ public class ResolverActivityTest { mActivityRule.launchActivity(sendIntent); waitForIdle(); - assertThat(chosen[0], is(personalResolvedComponentInfos.get(1).getResolveInfoAt(0))); + assertNull(chosen[0]); } @Test diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 6ba67fa7..c2212bc2 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -2341,7 +2341,7 @@ public class UnbundledChooserActivityTest { } @Test - public void testWorkTab_onePersonalTarget_emptyStateOnWorkTarget_autolaunch() { + public void testWorkTab_onePersonalTarget_emptyStateOnWorkTarget_doesNotAutoLaunch() { markWorkProfileUserAvailable(); int workProfileTargets = 4; List personalResolvedComponentInfos = @@ -2360,7 +2360,7 @@ public class UnbundledChooserActivityTest { mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Test")); waitForIdle(); - assertThat(chosen[0], is(personalResolvedComponentInfos.get(1).getResolveInfoAt(0))); + assertNull(chosen[0]); } @Test -- cgit v1.2.3-59-g8ed1b From 03a778d6df2c57a74eed90c659e6c6893bd29170 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Wed, 10 May 2023 20:39:45 -0700 Subject: Kepp PreviewDataProvider instace over a configuration change Add a view model, PreviewViewModel, to retain a PreviewDataProvider instance. Tangentally, move ChooserActivity#isImageType method into MimeTypeClassifier (to avoid keep a link to the activity). Fix: 281929002 Test: manual testing, vierify through injected debug logging that the object is not re-created. Change-Id: I7be397089ca33e589a3ecbd6012929bfe3c5433a --- .../android/intentresolver/ChooserActivity.java | 19 ++------ .../contentpreview/ChooserContentPreviewUi.java | 3 +- .../contentpreview/DefaultMimeTypeClassifier.kt | 19 ++++++++ .../contentpreview/MimeTypeClassifier.java | 8 ++-- .../contentpreview/PreviewDataProvider.kt | 3 +- .../contentpreview/PreviewViewModel.kt | 51 ++++++++++++++++++++++ .../contentpreview/ChooserContentPreviewUiTest.kt | 16 ++----- .../contentpreview/PreviewDataProviderTest.kt | 5 +-- 8 files changed, 84 insertions(+), 40 deletions(-) create mode 100644 java/src/com/android/intentresolver/contentpreview/DefaultMimeTypeClassifier.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt (limited to 'java/tests') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 84e14d72..bc3b63f5 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -86,7 +86,7 @@ import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; import com.android.intentresolver.contentpreview.HeadlineGeneratorImpl; import com.android.intentresolver.contentpreview.ImageLoader; -import com.android.intentresolver.contentpreview.PreviewDataProvider; +import com.android.intentresolver.contentpreview.PreviewViewModel; import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.flags.FeatureFlagRepositoryFactory; import com.android.intentresolver.grid.ChooserGridAdapter; @@ -274,9 +274,10 @@ public class ChooserActivity extends ResolverActivity implements mChooserContentPreviewUi = new ChooserContentPreviewUi( getLifecycle(), - createPreviewDataProvider(), + new ViewModelProvider(this, PreviewViewModel.Companion.getFactory()) + .get(PreviewViewModel.class) + .createOrReuseProvider(mChooserRequest), mChooserRequest.getTargetIntent(), - this::isImageType, createPreviewImageLoader(), createChooserActionFactory(), mEnterTransitionAnimationDelegate, @@ -538,14 +539,6 @@ public class ChooserActivity extends ResolverActivity implements mMaxTargetsPerRow); } - private PreviewDataProvider createPreviewDataProvider() { - // TODO: move this into a ViewModel so it could survive orientation change - return new PreviewDataProvider( - mChooserRequest.getTargetIntent(), - getContentResolver(), - this::isImageType); - } - private int findSelectedProfile() { int selectedProfile = getSelectedProfileExtra(); if (selectedProfile == -1) { @@ -722,10 +715,6 @@ public class ChooserActivity extends ResolverActivity implements return resolver.query(uri, null, null, null, null); } - private boolean isImageType(@Nullable String mimeType) { - return mimeType != null && mimeType.startsWith("image/"); - } - private int getNumSheetExpansions() { return getPreferences(Context.MODE_PRIVATE).getInt(PREF_NUM_SHEET_EXPANSIONS, 0); } diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index 55131de2..787af95f 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -88,7 +88,6 @@ public final class ChooserContentPreviewUi { Lifecycle lifecycle, PreviewDataProvider previewData, Intent targetIntent, - MimeTypeClassifier imageClassifier, ImageLoader imageLoader, ActionFactory actionFactory, TransitionElementStatusCallback transitionElementStatusCallback, @@ -97,7 +96,7 @@ public final class ChooserContentPreviewUi { mContentPreviewUi = createContentPreview( previewData, targetIntent, - imageClassifier, + DefaultMimeTypeClassifier.INSTANCE, imageLoader, actionFactory, transitionElementStatusCallback, diff --git a/java/src/com/android/intentresolver/contentpreview/DefaultMimeTypeClassifier.kt b/java/src/com/android/intentresolver/contentpreview/DefaultMimeTypeClassifier.kt new file mode 100644 index 00000000..b9215709 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/DefaultMimeTypeClassifier.kt @@ -0,0 +1,19 @@ +/* + * 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 + +object DefaultMimeTypeClassifier : MimeTypeClassifier diff --git a/java/src/com/android/intentresolver/contentpreview/MimeTypeClassifier.java b/java/src/com/android/intentresolver/contentpreview/MimeTypeClassifier.java index 2de60c5b..c86b0fe2 100644 --- a/java/src/com/android/intentresolver/contentpreview/MimeTypeClassifier.java +++ b/java/src/com/android/intentresolver/contentpreview/MimeTypeClassifier.java @@ -22,14 +22,12 @@ import androidx.annotation.Nullable; /** * Testing shim to specify whether a given mime type is considered to be an "image." - * - * TODO: move away from {@link ChooserActivityOverrideData} as a model to configure our tests, - * then migrate {@link com.android.intentresolver.ChooserActivity#isImageType(String)} into this - * class. */ public interface MimeTypeClassifier { /** @return whether the specified {@code mimeType} is classified as an "image" type. */ - boolean isImageType(@Nullable String mimeType); + default boolean isImageType(@Nullable String mimeType) { + return (mimeType != null) && ClipDescription.compareMimeTypes(mimeType, "image/*"); + } /** @return whether the specified {@code mimeType} is classified as an "video" type */ default boolean isVideoType(@Nullable String mimeType) { diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt index 94db7a63..ae705369 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt @@ -78,11 +78,10 @@ constructor( constructor( targetIntent: Intent, contentResolver: ContentInterface, - typeClassifier: MimeTypeClassifier, ) : this( targetIntent, contentResolver, - typeClassifier, + DefaultMimeTypeClassifier, Dispatchers.IO, ) diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt new file mode 100644 index 00000000..2f4b0211 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt @@ -0,0 +1,51 @@ +/* + * 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.ContentResolver +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY +import androidx.lifecycle.viewmodel.CreationExtras +import com.android.intentresolver.ChooserRequestParameters + +/** A trivial view model to keep a [PreviewDataProvider] instance over a configuration change */ +class PreviewViewModel(private val contentResolver: ContentResolver) : ViewModel() { + private var previewDataProvider: PreviewDataProvider? = null + + fun createOrReuseProvider(chooserRequest: ChooserRequestParameters): PreviewDataProvider { + return previewDataProvider + ?: PreviewDataProvider(chooserRequest.targetIntent, contentResolver).also { + previewDataProvider = it + } + } + + companion object { + val Factory: ViewModelProvider.Factory = + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create( + modelClass: Class, + extras: CreationExtras + ): T = + PreviewViewModel( + (checkNotNull(extras[APPLICATION_KEY]) as Context).contentResolver + ) as T + } + } +} diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt index 5a4b6c76..0bcd8423 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt @@ -16,7 +16,6 @@ package com.android.intentresolver.contentpreview -import android.content.ClipDescription import android.content.Intent import android.graphics.Bitmap import android.net.Uri @@ -37,9 +36,6 @@ import org.mockito.Mockito.verify class ChooserContentPreviewUiTest { private val lifecycle = mock() private val previewData = mock() - private val imageClassifier = MimeTypeClassifier { mimeType -> - mimeType != null && ClipDescription.compareMimeTypes(mimeType, "image/*") - } private val headlineGenerator = mock() private val imageLoader = object : ImageLoader { @@ -67,11 +63,10 @@ class ChooserContentPreviewUiTest { lifecycle, previewData, Intent(Intent.ACTION_VIEW), - imageClassifier, imageLoader, actionFactory, transitionCallback, - headlineGenerator + headlineGenerator, ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) @@ -87,11 +82,10 @@ class ChooserContentPreviewUiTest { lifecycle, previewData, Intent(Intent.ACTION_SEND), - imageClassifier, imageLoader, actionFactory, transitionCallback, - headlineGenerator + headlineGenerator, ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) @@ -116,11 +110,10 @@ class ChooserContentPreviewUiTest { lifecycle, previewData, Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_TEXT, "Shared text") }, - imageClassifier, imageLoader, actionFactory, transitionCallback, - headlineGenerator + headlineGenerator, ) assertThat(testSubject.mContentPreviewUi) .isInstanceOf(FilesPlusTextContentPreviewUi::class.java) @@ -145,11 +138,10 @@ class ChooserContentPreviewUiTest { lifecycle, previewData, Intent(Intent.ACTION_SEND), - imageClassifier, imageLoader, actionFactory, transitionCallback, - headlineGenerator + headlineGenerator, ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) diff --git a/java/tests/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt index 5be373b3..2c47efa5 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt @@ -16,7 +16,6 @@ package com.android.intentresolver.contentpreview -import android.content.ClipDescription import android.content.ContentInterface import android.content.Intent import android.database.MatrixCursor @@ -44,9 +43,7 @@ import org.mockito.Mockito.verify @OptIn(ExperimentalCoroutinesApi::class) class PreviewDataProviderTest { private val contentResolver = mock() - private val mimeTypeClassifier = MimeTypeClassifier { mimeType -> - mimeType != null && ClipDescription.compareMimeTypes(mimeType, "image/*") - } + private val mimeTypeClassifier = DefaultMimeTypeClassifier private val lifecycleOwner = TestLifecycleOwner() private val dispatcher = UnconfinedTestDispatcher() -- cgit v1.2.3-59-g8ed1b From a418a7e57a4856efae1bc5511ad24944a640da63 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Thu, 11 May 2023 18:03:10 -0700 Subject: Retain the image loader through configuration change Make PreviewViewModel own an image loader instance. This required ImagePreviewImageLoader to: * receive a CoroutineScope instance instead of a Lifecycle as ViewModel does not offer an associated Lifecycle; * make ImagePreviewImageLoader#loadImage to receive a caller's Lifecycle to avoid potential memory (Activity) leak. With ImagePreviewImageLoader now receiving a scope, the dispatcher argument is dropped as a scope can be configured with a proper dispatcher. As ChooserActivity is no longer responsible for the image loader instantiation, to facilitate testing, a method hook to create an image loader is replaced with a method hook to create a preview view model and all the associated plumbing is done for the tests. Plus some kfmt complience changes. ImagePreviewImageLoader (and ImagePreviewImageLoaderTest) is moved into contentpreview package as it's accessed only from within this package. Fix: 282029067 Test: manual testing with an injected debug logging Change-Id: I5dcdee2599714a2c51c3e1b63f5c727e43b26f6a --- .../android/intentresolver/ChooserActivity.java | 21 +-- .../intentresolver/ImagePreviewImageLoader.kt | 147 --------------- .../contentpreview/BasePreviewViewModel.kt | 31 ++++ .../contentpreview/ChooserContentPreviewUi.java | 4 + .../FilesPlusTextContentPreviewUi.java | 21 ++- .../intentresolver/contentpreview/ImageLoader.kt | 19 +- .../contentpreview/ImagePreviewImageLoader.kt | 139 +++++++++++++++ .../contentpreview/PreviewViewModel.kt | 40 +++-- .../contentpreview/TextContentPreviewUi.java | 5 + .../intentresolver/ChooserWrapperActivity.java | 11 +- .../intentresolver/ImagePreviewImageLoaderTest.kt | 186 ------------------- .../intentresolver/TestContentPreviewViewModel.kt | 56 ++++++ .../intentresolver/TestPreviewImageLoader.kt | 7 +- .../contentpreview/ChooserContentPreviewUiTest.kt | 6 +- .../contentpreview/ImagePreviewImageLoaderTest.kt | 197 +++++++++++++++++++++ 15 files changed, 505 insertions(+), 385 deletions(-) delete mode 100644 java/src/com/android/intentresolver/ImagePreviewImageLoader.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt delete mode 100644 java/tests/src/com/android/intentresolver/ImagePreviewImageLoaderTest.kt create mode 100644 java/tests/src/com/android/intentresolver/TestContentPreviewViewModel.kt create mode 100644 java/tests/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt (limited to 'java/tests') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 0786e088..014aa2a2 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -83,9 +83,9 @@ import com.android.intentresolver.NoCrossProfileEmptyStateProvider.DevicePolicyB import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.contentpreview.BasePreviewViewModel; import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; import com.android.intentresolver.contentpreview.HeadlineGeneratorImpl; -import com.android.intentresolver.contentpreview.ImageLoader; import com.android.intentresolver.contentpreview.PreviewViewModel; import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.flags.FeatureFlagRepositoryFactory; @@ -272,13 +272,14 @@ public class ChooserActivity extends ResolverActivity implements } }); + BasePreviewViewModel previewViewModel = + new ViewModelProvider(this, createPreviewViewModelFactory()) + .get(BasePreviewViewModel.class); mChooserContentPreviewUi = new ChooserContentPreviewUi( getLifecycle(), - new ViewModelProvider(this, PreviewViewModel.Companion.getFactory()) - .get(PreviewViewModel.class) - .createOrReuseProvider(mChooserRequest), + previewViewModel.createOrReuseProvider(mChooserRequest), mChooserRequest.getTargetIntent(), - createPreviewImageLoader(), + previewViewModel.createOrReuseImageLoader(), createChooserActionFactory(), mEnterTransitionAnimationDelegate, new HeadlineGeneratorImpl(this)); @@ -1314,14 +1315,8 @@ public class ChooserActivity extends ResolverActivity implements } @VisibleForTesting - protected ImageLoader createPreviewImageLoader() { - final int cacheSize; - float chooserWidth = getResources().getDimension(R.dimen.chooser_width); - // imageWidth = imagePreviewHeight * minAspectRatio (see ScrollableImagePreviewView) - float imageWidth = - getResources().getDimension(R.dimen.chooser_preview_image_height_tall) * 2 / 5; - cacheSize = (int) (Math.ceil(chooserWidth / imageWidth) + 2); - return new ImagePreviewImageLoader(this, getLifecycle(), cacheSize); + protected ViewModelProvider.Factory createPreviewViewModelFactory() { + return PreviewViewModel.Companion.getFactory(); } private ChooserActionFactory createChooserActionFactory() { diff --git a/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt deleted file mode 100644 index c97efdd1..00000000 --- a/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt +++ /dev/null @@ -1,147 +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 android.content.Context -import android.graphics.Bitmap -import android.net.Uri -import android.util.Log -import android.util.Size -import androidx.annotation.GuardedBy -import androidx.annotation.VisibleForTesting -import androidx.collection.LruCache -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.coroutineScope -import com.android.intentresolver.contentpreview.ImageLoader -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import java.util.function.Consumer - -private const val TAG = "ImagePreviewImageLoader" - -/** - * Implements preview image loading for the content preview UI. Provides requests deduplication and - * image caching. - */ -@VisibleForTesting -class ImagePreviewImageLoader @JvmOverloads constructor( - private val context: Context, - private val lifecycle: Lifecycle, - cacheSize: Int, - private val dispatcher: CoroutineDispatcher = Dispatchers.IO -) : ImageLoader { - - private val thumbnailSize: Size = - context.resources.getDimensionPixelSize(R.dimen.chooser_preview_image_max_dimen).let { - Size(it, it) - } - - private val lock = Any() - @GuardedBy("lock") - private val cache = LruCache(cacheSize) - @GuardedBy("lock") - private val runningRequests = HashMap() - - override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? = loadImageAsync(uri, caching) - - override fun loadImage(uri: Uri, callback: Consumer) { - lifecycle.coroutineScope.launch { - val image = loadImageAsync(uri, caching = true) - if (isActive) { - callback.accept(image) - } - } - } - - override fun prePopulate(uris: List) { - uris.asSequence().take(cache.maxSize()).forEach { uri -> - lifecycle.coroutineScope.launch { - loadImageAsync(uri, caching = true) - } - } - } - - private suspend fun loadImageAsync(uri: Uri, caching: Boolean): Bitmap? { - return getRequestDeferred(uri, caching) - .await() - } - - private fun getRequestDeferred(uri: Uri, caching: Boolean): Deferred { - var shouldLaunchImageLoading = false - val request = synchronized(lock) { - cache[uri] - ?: runningRequests.getOrPut(uri) { - shouldLaunchImageLoading = true - RequestRecord(uri, CompletableDeferred(), caching) - }.apply { - this.caching = this.caching || caching - } - } - if (shouldLaunchImageLoading) { - request.loadBitmapAsync() - } - return request.deferred - } - - private fun RequestRecord.loadBitmapAsync() { - lifecycle.coroutineScope.launch(dispatcher) { - loadBitmap() - }.invokeOnCompletion { cause -> - if (cause is CancellationException) { - cancel() - } - } - } - - private fun RequestRecord.loadBitmap() { - val bitmap = try { - context.contentResolver.loadThumbnail(uri, thumbnailSize, null) - } catch (t: Throwable) { - Log.d(TAG, "failed to load $uri preview", t) - null - } - complete(bitmap) - } - - private fun RequestRecord.cancel() { - synchronized(lock) { - runningRequests.remove(uri) - deferred.cancel() - } - } - - private fun RequestRecord.complete(bitmap: Bitmap?) { - deferred.complete(bitmap) - synchronized(lock) { - runningRequests.remove(uri) - if (bitmap != null && caching) { - cache.put(uri, this) - } - } - } - - private class RequestRecord( - val uri: Uri, - val deferred: CompletableDeferred, - @GuardedBy("lock") var caching: Boolean - ) -} diff --git a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt new file mode 100644 index 00000000..103e8bf4 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview + +import androidx.annotation.MainThread +import androidx.lifecycle.ViewModel +import com.android.intentresolver.ChooserRequestParameters + +/** A contract for the preview view model. Added for testing. */ +abstract class BasePreviewViewModel : ViewModel() { + @MainThread + abstract fun createOrReuseProvider( + chooserRequest: ChooserRequestParameters + ): PreviewDataProvider + + @MainThread abstract fun createOrReuseImageLoader(): ImageLoader +} diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index 787af95f..9100b392 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -118,6 +118,7 @@ public final class ChooserContentPreviewUi { int previewType = previewData.getPreviewType(); if (previewType == CONTENT_PREVIEW_TEXT) { return createTextPreview( + mLifecycle, targetIntent, actionFactory, imageLoader, @@ -140,6 +141,7 @@ public final class ChooserContentPreviewUi { if (!TextUtils.isEmpty(text)) { FilesPlusTextContentPreviewUi previewUi = new FilesPlusTextContentPreviewUi( + mLifecycle, isSingleImageShare, previewData.getUriCount(), targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT), @@ -180,6 +182,7 @@ public final class ChooserContentPreviewUi { } private static TextContentPreviewUi createTextPreview( + Lifecycle lifecycle, Intent targetIntent, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, @@ -195,6 +198,7 @@ public final class ChooserContentPreviewUi { } } return new TextContentPreviewUi( + lifecycle, sharingText, previewTitle, previewThumbnail, diff --git a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java index 860423c4..e4e33839 100644 --- a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java @@ -31,6 +31,7 @@ import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.Nullable; +import androidx.lifecycle.Lifecycle; import com.android.intentresolver.R; import com.android.intentresolver.widget.ActionRow; @@ -48,6 +49,7 @@ import java.util.function.Consumer; * file content). */ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { + private final Lifecycle mLifecycle; private final CharSequence mText; private final ChooserContentPreviewUi.ActionFactory mActionFactory; private final ImageLoader mImageLoader; @@ -63,6 +65,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { private boolean mAllVideos; FilesPlusTextContentPreviewUi( + Lifecycle lifecycle, boolean isSingleImage, int fileCount, CharSequence text, @@ -70,6 +73,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { ImageLoader imageLoader, MimeTypeClassifier typeClassifier, HeadlineGenerator headlineGenerator) { + mLifecycle = lifecycle; if (isSingleImage && fileCount != 1) { throw new IllegalArgumentException( "fileCount = " + fileCount + " and isSingleImage = true"); @@ -155,13 +159,16 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { ImageView imagePreview = mContentPreviewView.requireViewById(R.id.image_view); if (mIsSingleImage && mFirstFilePreviewUri != null) { - mImageLoader.loadImage(mFirstFilePreviewUri, bitmap -> { - if (bitmap == null) { - imagePreview.setVisibility(View.GONE); - } else { - imagePreview.setImageBitmap(bitmap); - } - }); + mImageLoader.loadImage( + mLifecycle, + mFirstFilePreviewUri, + bitmap -> { + if (bitmap == null) { + imagePreview.setVisibility(View.GONE); + } else { + imagePreview.setImageBitmap(bitmap); + } + }); } else { imagePreview.setVisibility(View.GONE); } diff --git a/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt index 225807ee..8d0fb84b 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt +++ b/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt @@ -18,32 +18,29 @@ package com.android.intentresolver.contentpreview import android.graphics.Bitmap import android.net.Uri +import androidx.lifecycle.Lifecycle import java.util.function.Consumer -/** - * A content preview image loader. - */ +/** A content preview image loader. */ interface ImageLoader : suspend (Uri) -> Bitmap?, suspend (Uri, Boolean) -> Bitmap? { /** * Load preview image asynchronously; caching is allowed. + * * @param uri content URI * @param callback a callback that will be invoked with the loaded image or null if loading has - * failed. + * failed. */ - fun loadImage(uri: Uri, callback: Consumer) + fun loadImage(callerLifecycle: Lifecycle, uri: Uri, callback: Consumer) - /** - * Prepopulate the image loader cache. - */ + /** Prepopulate the image loader cache. */ fun prePopulate(uris: List) - /** - * Load preview image; caching is allowed. - */ + /** Load preview image; caching is allowed. */ override suspend fun invoke(uri: Uri) = invoke(uri, true) /** * Load preview image. + * * @param uri content URI * @param caching indicates if the loaded image could be cached. */ diff --git a/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt new file mode 100644 index 00000000..89b79a0a --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt @@ -0,0 +1,139 @@ +/* + * 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.ContentResolver +import android.graphics.Bitmap +import android.net.Uri +import android.util.Log +import android.util.Size +import androidx.annotation.GuardedBy +import androidx.annotation.VisibleForTesting +import androidx.collection.LruCache +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.coroutineScope +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.util.function.Consumer + +private const val TAG = "ImagePreviewImageLoader" + +/** + * Implements preview image loading for the content preview UI. Provides requests deduplication and + * image caching. + */ +@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) +class ImagePreviewImageLoader( + private val scope: CoroutineScope, + thumbnailSize: Int, + private val contentResolver: ContentResolver, + cacheSize: Int, +) : ImageLoader { + + private val thumbnailSize: Size = Size(thumbnailSize, thumbnailSize) + + private val lock = Any() + @GuardedBy("lock") private val cache = LruCache(cacheSize) + @GuardedBy("lock") private val runningRequests = HashMap() + + override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? = loadImageAsync(uri, caching) + + override fun loadImage(callerLifecycle: Lifecycle, uri: Uri, callback: Consumer) { + callerLifecycle.coroutineScope.launch { + val image = loadImageAsync(uri, caching = true) + if (isActive) { + callback.accept(image) + } + } + } + + override fun prePopulate(uris: List) { + uris.asSequence().take(cache.maxSize()).forEach { uri -> + scope.launch { loadImageAsync(uri, caching = true) } + } + } + + private suspend fun loadImageAsync(uri: Uri, caching: Boolean): Bitmap? { + return getRequestDeferred(uri, caching).await() + } + + private fun getRequestDeferred(uri: Uri, caching: Boolean): Deferred { + var shouldLaunchImageLoading = false + val request = + synchronized(lock) { + cache[uri] + ?: runningRequests + .getOrPut(uri) { + shouldLaunchImageLoading = true + RequestRecord(uri, CompletableDeferred(), caching) + } + .apply { this.caching = this.caching || caching } + } + if (shouldLaunchImageLoading) { + request.loadBitmapAsync() + } + return request.deferred + } + + private fun RequestRecord.loadBitmapAsync() { + scope + .launch { loadBitmap() } + .invokeOnCompletion { cause -> + if (cause is CancellationException) { + cancel() + } + } + } + + private fun RequestRecord.loadBitmap() { + val bitmap = + try { + contentResolver.loadThumbnail(uri, thumbnailSize, null) + } catch (t: Throwable) { + Log.d(TAG, "failed to load $uri preview", t) + null + } + complete(bitmap) + } + + private fun RequestRecord.cancel() { + synchronized(lock) { + runningRequests.remove(uri) + deferred.cancel() + } + } + + private fun RequestRecord.complete(bitmap: Bitmap?) { + deferred.complete(bitmap) + synchronized(lock) { + runningRequests.remove(uri) + if (bitmap != null && caching) { + cache.put(uri, this) + } + } + } + + private class RequestRecord( + val uri: Uri, + val deferred: CompletableDeferred, + @GuardedBy("lock") var caching: Boolean + ) +} \ No newline at end of file diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt index 2f4b0211..331b0cb6 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt @@ -16,24 +16,45 @@ package com.android.intentresolver.contentpreview -import android.content.ContentResolver -import android.content.Context +import android.app.Application +import androidx.annotation.MainThread import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY +import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras import com.android.intentresolver.ChooserRequestParameters +import com.android.intentresolver.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.plus /** A trivial view model to keep a [PreviewDataProvider] instance over a configuration change */ -class PreviewViewModel(private val contentResolver: ContentResolver) : ViewModel() { +class PreviewViewModel(private val application: Application) : BasePreviewViewModel() { private var previewDataProvider: PreviewDataProvider? = null + private var imageLoader: ImagePreviewImageLoader? = null - fun createOrReuseProvider(chooserRequest: ChooserRequestParameters): PreviewDataProvider { - return previewDataProvider - ?: PreviewDataProvider(chooserRequest.targetIntent, contentResolver).also { + @MainThread + override fun createOrReuseProvider( + chooserRequest: ChooserRequestParameters + ): PreviewDataProvider = + previewDataProvider + ?: PreviewDataProvider(chooserRequest.targetIntent, application.contentResolver).also { previewDataProvider = it } - } + + @MainThread + override fun createOrReuseImageLoader(): ImageLoader = + imageLoader + ?: ImagePreviewImageLoader( + viewModelScope + Dispatchers.IO, + thumbnailSize = + application.resources.getDimensionPixelSize( + R.dimen.chooser_preview_image_max_dimen + ), + application.contentResolver, + cacheSize = 16 + ) + .also { imageLoader = it } companion object { val Factory: ViewModelProvider.Factory = @@ -42,10 +63,7 @@ class PreviewViewModel(private val contentResolver: ContentResolver) : ViewModel override fun create( modelClass: Class, extras: CreationExtras - ): T = - PreviewViewModel( - (checkNotNull(extras[APPLICATION_KEY]) as Context).contentResolver - ) as T + ): T = PreviewViewModel(checkNotNull(extras[APPLICATION_KEY])) as T } } } diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java index 3c8a6e48..19fd3bb4 100644 --- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java @@ -28,6 +28,7 @@ import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.Nullable; +import androidx.lifecycle.Lifecycle; import com.android.intentresolver.R; import com.android.intentresolver.widget.ActionRow; @@ -36,6 +37,7 @@ import java.util.ArrayList; import java.util.List; class TextContentPreviewUi extends ContentPreviewUi { + private final Lifecycle mLifecycle; @Nullable private final CharSequence mSharingText; @Nullable @@ -47,12 +49,14 @@ class TextContentPreviewUi extends ContentPreviewUi { private final HeadlineGenerator mHeadlineGenerator; TextContentPreviewUi( + Lifecycle lifecycle, @Nullable CharSequence sharingText, @Nullable CharSequence previewTitle, @Nullable Uri previewThumbnail, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, HeadlineGenerator headlineGenerator) { + mLifecycle = lifecycle; mSharingText = sharingText; mPreviewTitle = previewTitle; mPreviewThumbnail = previewThumbnail; @@ -117,6 +121,7 @@ class TextContentPreviewUi extends ContentPreviewUi { previewThumbnailView.setVisibility(View.GONE); } else { mImageLoader.loadImage( + mLifecycle, mPreviewThumbnail, (bitmap) -> updateViewWithImage( contentPreviewLayout.findViewById( diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index 82ba8d4d..fa934f87 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -32,10 +32,11 @@ import android.net.Uri; import android.os.Bundle; import android.os.UserHandle; +import androidx.lifecycle.ViewModelProvider; + import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; -import com.android.intentresolver.contentpreview.ImageLoader; import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.grid.ChooserGridAdapter; import com.android.intentresolver.shortcuts.ShortcutLoader; @@ -194,10 +195,10 @@ public class ChooserWrapperActivity } @Override - protected ImageLoader createPreviewImageLoader() { - return sOverrides.imageLoader == null - ? super.createPreviewImageLoader() - : sOverrides.imageLoader; + protected ViewModelProvider.Factory createPreviewViewModelFactory() { + return TestContentPreviewViewModel.Companion.wrap( + super.createPreviewViewModelFactory(), + sOverrides.imageLoader); } @Override diff --git a/java/tests/src/com/android/intentresolver/ImagePreviewImageLoaderTest.kt b/java/tests/src/com/android/intentresolver/ImagePreviewImageLoaderTest.kt deleted file mode 100644 index 3c399cc4..00000000 --- a/java/tests/src/com/android/intentresolver/ImagePreviewImageLoaderTest.kt +++ /dev/null @@ -1,186 +0,0 @@ -/* - * 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 - -import android.content.ContentResolver -import android.content.Context -import android.content.res.Resources -import android.graphics.Bitmap -import android.net.Uri -import android.util.Size -import androidx.lifecycle.Lifecycle -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineStart.UNDISPATCHED -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestCoroutineScheduler -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Before -import org.junit.Test -import org.mockito.Mockito.never -import org.mockito.Mockito.times -import org.mockito.Mockito.verify - -@OptIn(ExperimentalCoroutinesApi::class) -class ImagePreviewImageLoaderTest { - private val imageSize = Size(300, 300) - private val uriOne = Uri.parse("content://org.package.app/image-1.png") - private val uriTwo = Uri.parse("content://org.package.app/image-2.png") - private val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) - private val contentResolver = mock { - whenever(loadThumbnail(any(), any(), anyOrNull())).thenReturn(bitmap) - } - private val resources = mock { - whenever(getDimensionPixelSize(R.dimen.chooser_preview_image_max_dimen)) - .thenReturn(imageSize.width) - } - private val context = mock { - whenever(this.resources).thenReturn(this@ImagePreviewImageLoaderTest.resources) - whenever(this.contentResolver).thenReturn(this@ImagePreviewImageLoaderTest.contentResolver) - } - private val scheduler = TestCoroutineScheduler() - private val lifecycleOwner = TestLifecycleOwner() - private val dispatcher = UnconfinedTestDispatcher(scheduler) - private val testSubject = ImagePreviewImageLoader( - context, lifecycleOwner.lifecycle, 1, dispatcher - ) - - @Before - fun setup() { - Dispatchers.setMain(dispatcher) - lifecycleOwner.state = Lifecycle.State.CREATED - } - - @After - fun cleanup() { - lifecycleOwner.state = Lifecycle.State.DESTROYED - Dispatchers.resetMain() - } - - @Test - fun prePopulate_cachesImagesUpToTheCacheSize() = runTest { - testSubject.prePopulate(listOf(uriOne, uriTwo)) - - verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) - verify(contentResolver, never()).loadThumbnail(uriTwo, imageSize, null) - - testSubject(uriOne) - verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) - } - - @Test - fun invoke_returnCachedImageWhenCalledTwice() = runTest { - testSubject(uriOne) - testSubject(uriOne) - - verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull()) - } - - @Test - fun invoke_whenInstructed_doesNotCache() = runTest { - testSubject(uriOne, false) - testSubject(uriOne, false) - - verify(contentResolver, times(2)).loadThumbnail(any(), any(), anyOrNull()) - } - - @Test - fun invoke_overlappedRequests_Deduplicate() = runTest { - val scheduler = TestCoroutineScheduler() - val dispatcher = StandardTestDispatcher(scheduler) - val testSubject = ImagePreviewImageLoader(context, lifecycleOwner.lifecycle, 1, dispatcher) - coroutineScope { - launch(start = UNDISPATCHED) { - testSubject(uriOne, false) - } - launch(start = UNDISPATCHED) { - testSubject(uriOne, false) - } - scheduler.advanceUntilIdle() - } - - verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull()) - } - - @Test - fun invoke_oldRecordsEvictedFromTheCache() = runTest { - testSubject(uriOne) - testSubject(uriTwo) - testSubject(uriTwo) - testSubject(uriOne) - - verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null) - verify(contentResolver, times(1)).loadThumbnail(uriTwo, imageSize, null) - } - - @Test - fun invoke_doNotCacheNulls() = runTest { - whenever(contentResolver.loadThumbnail(any(), any(), anyOrNull())).thenReturn(null) - testSubject(uriOne) - testSubject(uriOne) - - verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null) - } - - @Test(expected = CancellationException::class) - fun invoke_onClosedImageLoaderScope_throwsCancellationException() = runTest { - lifecycleOwner.state = Lifecycle.State.DESTROYED - testSubject(uriOne) - } - - @Test(expected = CancellationException::class) - fun invoke_imageLoaderScopeClosedMidflight_throwsCancellationException() = runTest { - val scheduler = TestCoroutineScheduler() - val dispatcher = StandardTestDispatcher(scheduler) - val testSubject = ImagePreviewImageLoader(context, lifecycleOwner.lifecycle, 1, dispatcher) - coroutineScope { - val deferred = async(start = UNDISPATCHED) { - testSubject(uriOne, false) - } - lifecycleOwner.state = Lifecycle.State.DESTROYED - scheduler.advanceUntilIdle() - deferred.await() - } - } - - @Test - fun invoke_multipleCallsWithDifferentCacheInstructions_cachingPrevails() = runTest { - val scheduler = TestCoroutineScheduler() - val dispatcher = StandardTestDispatcher(scheduler) - val testSubject = ImagePreviewImageLoader(context, lifecycleOwner.lifecycle, 1, dispatcher) - coroutineScope { - launch(start = UNDISPATCHED) { - testSubject(uriOne, false) - } - launch(start = UNDISPATCHED) { - testSubject(uriOne, true) - } - scheduler.advanceUntilIdle() - } - testSubject(uriOne, true) - - verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) - } -} diff --git a/java/tests/src/com/android/intentresolver/TestContentPreviewViewModel.kt b/java/tests/src/com/android/intentresolver/TestContentPreviewViewModel.kt new file mode 100644 index 00000000..d239f612 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/TestContentPreviewViewModel.kt @@ -0,0 +1,56 @@ +/* + * 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 + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.CreationExtras +import com.android.intentresolver.contentpreview.BasePreviewViewModel +import com.android.intentresolver.contentpreview.ImageLoader +import com.android.intentresolver.contentpreview.PreviewDataProvider + +/** A test content preview model that supports image loader override. */ +class TestContentPreviewViewModel( + private val viewModel: BasePreviewViewModel, + private val imageLoader: ImageLoader? = null, +) : BasePreviewViewModel() { + override fun createOrReuseProvider( + chooserRequest: ChooserRequestParameters + ): PreviewDataProvider = viewModel.createOrReuseProvider(chooserRequest) + + override fun createOrReuseImageLoader(): ImageLoader = + imageLoader ?: viewModel.createOrReuseImageLoader() + + companion object { + fun wrap( + factory: ViewModelProvider.Factory, + imageLoader: ImageLoader?, + ): ViewModelProvider.Factory = + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create( + modelClass: Class, + extras: CreationExtras + ): T { + return TestContentPreviewViewModel( + factory.create(modelClass, extras) as BasePreviewViewModel, + imageLoader, + ) as T + } + } + } +} diff --git a/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt b/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt index 74a253b8..bf87ed8a 100644 --- a/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt +++ b/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt @@ -18,13 +18,12 @@ package com.android.intentresolver import android.graphics.Bitmap import android.net.Uri +import androidx.lifecycle.Lifecycle import com.android.intentresolver.contentpreview.ImageLoader import java.util.function.Consumer -internal class TestPreviewImageLoader( - private val bitmaps: Map -) : ImageLoader { - override fun loadImage(uri: Uri, callback: Consumer) { +internal class TestPreviewImageLoader(private val bitmaps: Map) : ImageLoader { + override fun loadImage(callerLifecycle: Lifecycle, uri: Uri, callback: Consumer) { callback.accept(bitmaps[uri]) } diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt index 0bcd8423..c62f36ce 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt @@ -39,7 +39,11 @@ class ChooserContentPreviewUiTest { private val headlineGenerator = mock() private val imageLoader = object : ImageLoader { - override fun loadImage(uri: Uri, callback: Consumer) { + override fun loadImage( + callerLifecycle: Lifecycle, + uri: Uri, + callback: Consumer, + ) { callback.accept(null) } override fun prePopulate(uris: List) = Unit diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt new file mode 100644 index 00000000..184401a0 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt @@ -0,0 +1,197 @@ +/* + * 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.ContentResolver +import android.graphics.Bitmap +import android.net.Uri +import android.util.Size +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.coroutineScope +import com.android.intentresolver.TestLifecycleOwner +import com.android.intentresolver.any +import com.android.intentresolver.anyOrNull +import com.android.intentresolver.mock +import com.android.intentresolver.whenever +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart.UNDISPATCHED +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify + +@OptIn(ExperimentalCoroutinesApi::class) +class ImagePreviewImageLoaderTest { + private val imageSize = Size(300, 300) + private val uriOne = Uri.parse("content://org.package.app/image-1.png") + private val uriTwo = Uri.parse("content://org.package.app/image-2.png") + private val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) + private val contentResolver = + mock { + whenever(loadThumbnail(any(), any(), anyOrNull())).thenReturn(bitmap) + } + private val lifecycleOwner = TestLifecycleOwner() + private val dispatcher = UnconfinedTestDispatcher() + private lateinit var testSubject: ImagePreviewImageLoader + + @Before + fun setup() { + Dispatchers.setMain(dispatcher) + lifecycleOwner.state = Lifecycle.State.CREATED + // create test subject after we've updated the lifecycle dispatcher + testSubject = + ImagePreviewImageLoader( + lifecycleOwner.lifecycle.coroutineScope + dispatcher, + imageSize.width, + contentResolver, + 1, + ) + } + + @After + fun cleanup() { + lifecycleOwner.state = Lifecycle.State.DESTROYED + Dispatchers.resetMain() + } + + @Test + fun prePopulate_cachesImagesUpToTheCacheSize() = runTest { + testSubject.prePopulate(listOf(uriOne, uriTwo)) + + verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) + verify(contentResolver, never()).loadThumbnail(uriTwo, imageSize, null) + + testSubject(uriOne) + verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) + } + + @Test + fun invoke_returnCachedImageWhenCalledTwice() = runTest { + testSubject(uriOne) + testSubject(uriOne) + + verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull()) + } + + @Test + fun invoke_whenInstructed_doesNotCache() = runTest { + testSubject(uriOne, false) + testSubject(uriOne, false) + + verify(contentResolver, times(2)).loadThumbnail(any(), any(), anyOrNull()) + } + + @Test + fun invoke_overlappedRequests_Deduplicate() = runTest { + val scheduler = TestCoroutineScheduler() + val dispatcher = StandardTestDispatcher(scheduler) + val testSubject = + ImagePreviewImageLoader( + lifecycleOwner.lifecycle.coroutineScope + dispatcher, + imageSize.width, + contentResolver, + 1, + ) + coroutineScope { + launch(start = UNDISPATCHED) { testSubject(uriOne, false) } + launch(start = UNDISPATCHED) { testSubject(uriOne, false) } + scheduler.advanceUntilIdle() + } + + verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull()) + } + + @Test + fun invoke_oldRecordsEvictedFromTheCache() = runTest { + testSubject(uriOne) + testSubject(uriTwo) + testSubject(uriTwo) + testSubject(uriOne) + + verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null) + verify(contentResolver, times(1)).loadThumbnail(uriTwo, imageSize, null) + } + + @Test + fun invoke_doNotCacheNulls() = runTest { + whenever(contentResolver.loadThumbnail(any(), any(), anyOrNull())).thenReturn(null) + testSubject(uriOne) + testSubject(uriOne) + + verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null) + } + + @Test(expected = CancellationException::class) + fun invoke_onClosedImageLoaderScope_throwsCancellationException() = runTest { + lifecycleOwner.state = Lifecycle.State.DESTROYED + testSubject(uriOne) + } + + @Test(expected = CancellationException::class) + fun invoke_imageLoaderScopeClosedMidflight_throwsCancellationException() = runTest { + val scheduler = TestCoroutineScheduler() + val dispatcher = StandardTestDispatcher(scheduler) + val testSubject = + ImagePreviewImageLoader( + lifecycleOwner.lifecycle.coroutineScope + dispatcher, + imageSize.width, + contentResolver, + 1 + ) + coroutineScope { + val deferred = async(start = UNDISPATCHED) { testSubject(uriOne, false) } + lifecycleOwner.state = Lifecycle.State.DESTROYED + scheduler.advanceUntilIdle() + deferred.await() + } + } + + @Test + fun invoke_multipleCallsWithDifferentCacheInstructions_cachingPrevails() = runTest { + val scheduler = TestCoroutineScheduler() + val dispatcher = StandardTestDispatcher(scheduler) + val testSubject = + ImagePreviewImageLoader( + lifecycleOwner.lifecycle.coroutineScope + dispatcher, + imageSize.width, + contentResolver, + 1 + ) + coroutineScope { + launch(start = UNDISPATCHED) { testSubject(uriOne, false) } + launch(start = UNDISPATCHED) { testSubject(uriOne, true) } + scheduler.advanceUntilIdle() + } + testSubject(uriOne, true) + + verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) + } +} -- cgit v1.2.3-59-g8ed1b From 89714ce0759dabe11b53495c77ef164c6de1c677 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Tue, 16 May 2023 15:22:32 -0700 Subject: Limit the number of parallel preview loadings in the image preview loader. With ag/23192997 the preview cache size effectively increased the total number of parallel image requests to a large enough value to DDoS Files app's content provider i.e. when sharing a large number of images, cache pre-population requests overlap with the actual preview loadings and causing some of them to fail to load. Update ImagePreviewImageLoader to use a Semaphore to limit the total number of parallel preview loadings. Fix: 283000541 Test: manual testing Change-Id: I6152f6e589a8b36a4810d617633017b72202e66f --- .../contentpreview/ImagePreviewImageLoader.kt | 29 +++- .../contentpreview/ImagePreviewImageLoaderTest.kt | 177 ++++++++++++++++++++- 2 files changed, 196 insertions(+), 10 deletions(-) (limited to 'java/tests') diff --git a/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt index 89b79a0a..22dd1125 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt +++ b/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt @@ -26,28 +26,42 @@ import androidx.annotation.VisibleForTesting import androidx.collection.LruCache import androidx.lifecycle.Lifecycle import androidx.lifecycle.coroutineScope +import java.util.function.Consumer import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import java.util.function.Consumer +import kotlinx.coroutines.sync.Semaphore private const val TAG = "ImagePreviewImageLoader" /** - * Implements preview image loading for the content preview UI. Provides requests deduplication and - * image caching. + * Implements preview image loading for the content preview UI. Provides requests deduplication, + * image caching, and a limit on the number of parallel loadings. */ @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) -class ImagePreviewImageLoader( +class ImagePreviewImageLoader +@VisibleForTesting +constructor( private val scope: CoroutineScope, thumbnailSize: Int, private val contentResolver: ContentResolver, cacheSize: Int, + // TODO: consider providing a scope with the dispatcher configured with + // [CoroutineDispatcher#limitedParallelism] instead + private val contentResolverSemaphore: Semaphore, ) : ImageLoader { + constructor( + scope: CoroutineScope, + thumbnailSize: Int, + contentResolver: ContentResolver, + cacheSize: Int, + maxSimultaneousRequests: Int = 4 + ) : this(scope, thumbnailSize, contentResolver, cacheSize, Semaphore(maxSimultaneousRequests)) + private val thumbnailSize: Size = Size(thumbnailSize, thumbnailSize) private val lock = Any() @@ -103,13 +117,16 @@ class ImagePreviewImageLoader( } } - private fun RequestRecord.loadBitmap() { + private suspend fun RequestRecord.loadBitmap() { + contentResolverSemaphore.acquire() val bitmap = try { contentResolver.loadThumbnail(uri, thumbnailSize, null) } catch (t: Throwable) { Log.d(TAG, "failed to load $uri preview", t) null + } finally { + contentResolverSemaphore.release() } complete(bitmap) } @@ -136,4 +153,4 @@ class ImagePreviewImageLoader( val deferred: CompletableDeferred, @GuardedBy("lock") var caching: Boolean ) -} \ No newline at end of file +} diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt index 184401a0..6e57c289 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt @@ -27,20 +27,33 @@ import com.android.intentresolver.any import com.android.intentresolver.anyOrNull import com.android.intentresolver.mock import com.android.intentresolver.whenever +import com.google.common.truth.Truth.assertThat +import java.util.ArrayDeque +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit.MILLISECONDS +import java.util.concurrent.TimeUnit.SECONDS +import java.util.concurrent.atomic.AtomicInteger +import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineStart.UNDISPATCHED import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Runnable import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.plus +import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestCoroutineScheduler import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.yield import org.junit.After import org.junit.Before import org.junit.Test @@ -72,7 +85,7 @@ class ImagePreviewImageLoaderTest { lifecycleOwner.lifecycle.coroutineScope + dispatcher, imageSize.width, contentResolver, - 1, + cacheSize = 1, ) } @@ -118,7 +131,7 @@ class ImagePreviewImageLoaderTest { lifecycleOwner.lifecycle.coroutineScope + dispatcher, imageSize.width, contentResolver, - 1, + cacheSize = 1, ) coroutineScope { launch(start = UNDISPATCHED) { testSubject(uriOne, false) } @@ -164,7 +177,7 @@ class ImagePreviewImageLoaderTest { lifecycleOwner.lifecycle.coroutineScope + dispatcher, imageSize.width, contentResolver, - 1 + cacheSize = 1, ) coroutineScope { val deferred = async(start = UNDISPATCHED) { testSubject(uriOne, false) } @@ -183,7 +196,7 @@ class ImagePreviewImageLoaderTest { lifecycleOwner.lifecycle.coroutineScope + dispatcher, imageSize.width, contentResolver, - 1 + cacheSize = 1, ) coroutineScope { launch(start = UNDISPATCHED) { testSubject(uriOne, false) } @@ -194,4 +207,160 @@ class ImagePreviewImageLoaderTest { verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) } + + @Test + fun invoke_semaphoreGuardsContentResolverCalls() = runTest { + val contentResolver = + mock { + whenever(loadThumbnail(any(), any(), anyOrNull())) + .thenThrow(SecurityException("test")) + } + val acquireCount = AtomicInteger() + val releaseCount = AtomicInteger() + val testSemaphore = + object : Semaphore { + override val availablePermits: Int + get() = error("Unexpected invocation") + + override suspend fun acquire() { + acquireCount.getAndIncrement() + } + + override fun tryAcquire(): Boolean { + error("Unexpected invocation") + } + + override fun release() { + releaseCount.getAndIncrement() + } + } + + val testSubject = + ImagePreviewImageLoader( + lifecycleOwner.lifecycle.coroutineScope + dispatcher, + imageSize.width, + contentResolver, + cacheSize = 1, + testSemaphore, + ) + testSubject(uriOne, false) + + verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) + assertThat(acquireCount.get()).isEqualTo(1) + assertThat(releaseCount.get()).isEqualTo(1) + } + + @Test + fun invoke_semaphoreIsReleasedAfterContentResolverFailure() = runTest { + val semaphoreDeferred = CompletableDeferred() + val releaseCount = AtomicInteger() + val testSemaphore = + object : Semaphore { + override val availablePermits: Int + get() = error("Unexpected invocation") + + override suspend fun acquire() { + semaphoreDeferred.await() + } + + override fun tryAcquire(): Boolean { + error("Unexpected invocation") + } + + override fun release() { + releaseCount.getAndIncrement() + } + } + + val testSubject = + ImagePreviewImageLoader( + lifecycleOwner.lifecycle.coroutineScope + dispatcher, + imageSize.width, + contentResolver, + cacheSize = 1, + testSemaphore, + ) + launch(start = UNDISPATCHED) { testSubject(uriOne, false) } + + verify(contentResolver, never()).loadThumbnail(any(), any(), anyOrNull()) + + semaphoreDeferred.complete(Unit) + + verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) + assertThat(releaseCount.get()).isEqualTo(1) + } + + @Test + fun invoke_multipleSimultaneousCalls_limitOnNumberOfSimultaneousOutgoingCallsIsRespected() { + val requestCount = 4 + val thumbnailCallsCdl = CountDownLatch(requestCount) + val pendingThumbnailCalls = ArrayDeque() + val contentResolver = + mock { + whenever(loadThumbnail(any(), any(), anyOrNull())).thenAnswer { + val latch = CountDownLatch(1) + synchronized(pendingThumbnailCalls) { pendingThumbnailCalls.offer(latch) } + thumbnailCallsCdl.countDown() + latch.await() + bitmap + } + } + val name = "LoadImage" + val maxSimultaneousRequests = 2 + val threadsStartedCdl = CountDownLatch(requestCount) + val dispatcher = NewThreadDispatcher(name) { threadsStartedCdl.countDown() } + val testSubject = + ImagePreviewImageLoader( + lifecycleOwner.lifecycle.coroutineScope + dispatcher + CoroutineName(name), + imageSize.width, + contentResolver, + cacheSize = 1, + maxSimultaneousRequests, + ) + runTest { + repeat(requestCount) { + launch { testSubject(Uri.parse("content://org.pkg.app/image-$it.png")) } + } + yield() + // wait for all requests to be dispatched + assertThat(threadsStartedCdl.await(5, SECONDS)).isTrue() + + assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isFalse() + synchronized(pendingThumbnailCalls) { + assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests) + } + + pendingThumbnailCalls.poll()?.countDown() + assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isFalse() + synchronized(pendingThumbnailCalls) { + assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests) + } + + pendingThumbnailCalls.poll()?.countDown() + assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isTrue() + synchronized(pendingThumbnailCalls) { + assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests) + } + for (cdl in pendingThumbnailCalls) { + cdl.countDown() + } + } + } +} + +private class NewThreadDispatcher( + private val coroutineName: String, + private val launchedCallback: () -> Unit +) : CoroutineDispatcher() { + override fun isDispatchNeeded(context: CoroutineContext): Boolean = true + + override fun dispatch(context: CoroutineContext, block: Runnable) { + Thread { + if (coroutineName == context[CoroutineName.Key]?.name) { + launchedCallback() + } + block.run() + } + .start() + } } -- cgit v1.2.3-59-g8ed1b From 4cf61d3013c9ac98a478d65bf7204bd13a8607e1 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Fri, 19 May 2023 10:42:08 -0700 Subject: Encapsulate target icon and label loading in an isolated component. Patchset #1 Make ResolverListAdapter's and ChooserListAdapter icon and label async task classes static, move them into a separate package. After the modification anonymous AsyncTask from ResolverListAdapter#loadFilteredItemIconTaskAsync became the identical to the ResolverListAdapter$LoadIconTask and thus removed. Patchset #2 Incapsulate target icons and labels loading within a new component, IconLoader; use it instead of the direct async task usage. Use coroutines Dispatchers.IO pool for icon and label loading. Bug: 280653893 Test: manual testing Change-Id: Ie995a9ee9e88baeacc62821390ee90ef6b7e31e3 --- .../android/intentresolver/ChooserActivity.java | 39 ++-- .../android/intentresolver/ChooserListAdapter.java | 147 +++------------ .../android/intentresolver/ResolverActivity.java | 104 +++++++---- .../intentresolver/ResolverListAdapter.java | 208 +++++---------------- .../intentresolver/icons/BaseLoadIconTask.java | 50 +++++ .../icons/DefaultTargetDataLoader.kt | 128 +++++++++++++ .../icons/LoadDirectShareIconTask.java | 125 +++++++++++++ .../android/intentresolver/icons/LoadIconTask.java | 71 +++++++ .../intentresolver/icons/LoadLabelTask.java | 94 ++++++++++ .../intentresolver/icons/TargetDataLoader.kt | 50 +++++ .../intentresolver/ChooserListAdapterTest.kt | 86 +++++---- .../intentresolver/ChooserWrapperActivity.java | 7 +- .../intentresolver/ResolverActivityTest.java | 12 +- .../intentresolver/ResolverWrapperActivity.java | 83 +++++++- .../intentresolver/ResolverWrapperAdapter.java | 86 --------- 15 files changed, 822 insertions(+), 468 deletions(-) create mode 100644 java/src/com/android/intentresolver/icons/BaseLoadIconTask.java create mode 100644 java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt create mode 100644 java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java create mode 100644 java/src/com/android/intentresolver/icons/LoadIconTask.java create mode 100644 java/src/com/android/intentresolver/icons/LoadLabelTask.java create mode 100644 java/src/com/android/intentresolver/icons/TargetDataLoader.kt delete mode 100644 java/tests/src/com/android/intentresolver/ResolverWrapperAdapter.java (limited to 'java/tests') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 014aa2a2..a2dff970 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -90,6 +90,8 @@ import com.android.intentresolver.contentpreview.PreviewViewModel; import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.flags.FeatureFlagRepositoryFactory; import com.android.intentresolver.grid.ChooserGridAdapter; +import com.android.intentresolver.icons.DefaultTargetDataLoader; +import com.android.intentresolver.icons.TargetDataLoader; import com.android.intentresolver.measurements.Tracer; import com.android.intentresolver.model.AbstractResolverComparator; import com.android.intentresolver.model.AppPredictionServiceResolverComparator; @@ -309,7 +311,8 @@ public class ChooserActivity extends ResolverActivity implements mChooserRequest.getDefaultTitleResource(), mChooserRequest.getInitialIntents(), /* rList: List = */ null, - /* supportsAlwaysUseOption = */ false); + /* supportsAlwaysUseOption = */ false, + new DefaultTargetDataLoader(this, getLifecycle(), false)); mChooserShownTime = System.currentTimeMillis(); final long systemCost = mChooserShownTime - intentReceivedTime; @@ -442,13 +445,14 @@ public class ChooserActivity extends ResolverActivity implements protected AbstractMultiProfilePagerAdapter createMultiProfilePagerAdapter( Intent[] initialIntents, List rList, - boolean filterLastUsed) { + boolean filterLastUsed, + TargetDataLoader targetDataLoader) { if (shouldShowTabs()) { mChooserMultiProfilePagerAdapter = createChooserMultiProfilePagerAdapterForTwoProfiles( - initialIntents, rList, filterLastUsed); + initialIntents, rList, filterLastUsed, targetDataLoader); } else { mChooserMultiProfilePagerAdapter = createChooserMultiProfilePagerAdapterForOneProfile( - initialIntents, rList, filterLastUsed); + initialIntents, rList, filterLastUsed, targetDataLoader); } return mChooserMultiProfilePagerAdapter; } @@ -491,14 +495,16 @@ public class ChooserActivity extends ResolverActivity implements private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForOneProfile( Intent[] initialIntents, List rList, - boolean filterLastUsed) { + boolean filterLastUsed, + TargetDataLoader targetDataLoader) { ChooserGridAdapter adapter = createChooserGridAdapter( /* context */ this, /* payloadIntents */ mIntents, initialIntents, rList, filterLastUsed, - /* userHandle */ getPersonalProfileUserHandle()); + /* userHandle */ getPersonalProfileUserHandle(), + targetDataLoader); return new ChooserMultiProfilePagerAdapter( /* context */ this, adapter, @@ -512,7 +518,8 @@ public class ChooserActivity extends ResolverActivity implements private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForTwoProfiles( Intent[] initialIntents, List rList, - boolean filterLastUsed) { + boolean filterLastUsed, + TargetDataLoader targetDataLoader) { int selectedProfile = findSelectedProfile(); ChooserGridAdapter personalAdapter = createChooserGridAdapter( /* context */ this, @@ -520,14 +527,16 @@ public class ChooserActivity extends ResolverActivity implements selectedProfile == PROFILE_PERSONAL ? initialIntents : null, rList, filterLastUsed, - /* userHandle */ getPersonalProfileUserHandle()); + /* userHandle */ getPersonalProfileUserHandle(), + targetDataLoader); ChooserGridAdapter workAdapter = createChooserGridAdapter( /* context */ this, /* payloadIntents */ mIntents, selectedProfile == PROFILE_WORK ? initialIntents : null, rList, filterLastUsed, - /* userHandle */ getWorkProfileUserHandle()); + /* userHandle */ getWorkProfileUserHandle(), + targetDataLoader); return new ChooserMultiProfilePagerAdapter( /* context */ this, personalAdapter, @@ -1183,7 +1192,8 @@ public class ChooserActivity extends ResolverActivity implements Intent[] initialIntents, List rList, boolean filterLastUsed, - UserHandle userHandle) { + UserHandle userHandle, + TargetDataLoader targetDataLoader) { ChooserListAdapter chooserListAdapter = createChooserListAdapter( context, payloadIntents, @@ -1194,7 +1204,8 @@ public class ChooserActivity extends ResolverActivity implements userHandle, getTargetIntent(), mChooserRequest, - mMaxTargetsPerRow); + mMaxTargetsPerRow, + targetDataLoader); return new ChooserGridAdapter( context, @@ -1252,7 +1263,8 @@ public class ChooserActivity extends ResolverActivity implements UserHandle userHandle, Intent targetIntent, ChooserRequestParameters chooserRequest, - int maxTargetsPerRow) { + int maxTargetsPerRow, + TargetDataLoader targetDataLoader) { UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile() && userHandle.equals(getPersonalProfileUserHandle()) ? getCloneProfileUserHandle() : userHandle; @@ -1270,7 +1282,8 @@ public class ChooserActivity extends ResolverActivity implements getChooserActivityLogger(), chooserRequest, maxTargetsPerRow, - initialIntentsUserSpace); + initialIntentsUserSpace, + targetDataLoader); } @Override diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index c20af20c..b1fa16b0 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -27,14 +27,10 @@ import android.content.Context; import android.content.Intent; import android.content.pm.ActivityInfo; import android.content.pm.LabeledIntent; -import android.content.pm.LauncherApps; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.content.pm.ShortcutInfo; -import android.graphics.Bitmap; -import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; -import android.graphics.drawable.Icon; import android.os.AsyncTask; import android.os.Trace; import android.os.UserHandle; @@ -47,20 +43,20 @@ import android.view.View; import android.view.ViewGroup; import android.widget.TextView; -import androidx.annotation.WorkerThread; - import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.NotSelectableTargetInfo; import com.android.intentresolver.chooser.SelectableTargetInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.icons.TargetDataLoader; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import java.util.ArrayList; -import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; public class ChooserListAdapter extends ResolverListAdapter { @@ -86,10 +82,11 @@ public class ChooserListAdapter extends ResolverListAdapter { private final ChooserActivityLogger mChooserActivityLogger; - private final Map mIconLoaders = new HashMap<>(); + private final Set mRequestedIcons = new HashSet<>(); // Reserve spots for incoming direct share targets by adding placeholders private final TargetInfo mPlaceHolderTargetInfo; + private final TargetDataLoader mTargetDataLoader; private final List mServiceTargets = new ArrayList<>(); private final List mCallerTargets = new ArrayList<>(); @@ -145,7 +142,8 @@ public class ChooserListAdapter extends ResolverListAdapter { ChooserActivityLogger chooserActivityLogger, ChooserRequestParameters chooserRequest, int maxRankedTargets, - UserHandle initialIntentsUserSpace) { + UserHandle initialIntentsUserSpace, + TargetDataLoader targetDataLoader) { // Don't send the initial intents through the shared ResolverActivity path, // we want to separate them into a different section. super( @@ -158,13 +156,14 @@ public class ChooserListAdapter extends ResolverListAdapter { userHandle, targetIntent, resolverListCommunicator, - false, - initialIntentsUserSpace); + initialIntentsUserSpace, + targetDataLoader); mChooserRequest = chooserRequest; mMaxRankedTargets = maxRankedTargets; mPlaceHolderTargetInfo = NotSelectableTargetInfo.newPlaceHolderTargetInfo(context); + mTargetDataLoader = targetDataLoader; createPlaceHolders(); mChooserActivityLogger = chooserActivityLogger; mShortcutSelectionLogic = new ShortcutSelectionLogic( @@ -227,8 +226,9 @@ public class ChooserListAdapter extends ResolverListAdapter { ri.icon = 0; } ri.userHandle = initialIntentsUserSpace; + // TODO: remove DisplayResolveInfo dependency on presentation getter DisplayResolveInfo displayResolveInfo = DisplayResolveInfo.newDisplayResolveInfo( - ii, ri, ii, mPresentationFactory.makePresentationGetter(ri)); + ii, ri, ii, mTargetDataLoader.createPresentationGetter(ri)); mCallerTargets.add(displayResolveInfo); if (mCallerTargets.size() == MAX_SUGGESTED_APP_TARGETS) break; } @@ -344,19 +344,19 @@ public class ChooserListAdapter extends ResolverListAdapter { } private void loadDirectShareIcon(SelectableTargetInfo info) { - LoadDirectShareIconTask task = (LoadDirectShareIconTask) mIconLoaders.get(info); - if (task == null) { - task = createLoadDirectShareIconTask(info); - mIconLoaders.put(info, task); - task.loadIcon(); + if (mRequestedIcons.add(info)) { + mTargetDataLoader.loadDirectShareIcon( + info, + getUserHandle(), + (drawable) -> onDirectShareIconLoaded(info, drawable)); } } - @VisibleForTesting - protected LoadDirectShareIconTask createLoadDirectShareIconTask(SelectableTargetInfo info) { - return new LoadDirectShareIconTask( - mContext.createContextAsUser(getUserHandle(), 0), - info); + private void onDirectShareIconLoaded(SelectableTargetInfo mTargetInfo, Drawable icon) { + if (icon != null && !mTargetInfo.hasDisplayIcon()) { + mTargetInfo.getDisplayIconHolder().setDisplayIcon(icon); + notifyDataSetChanged(); + } } void updateAlphabeticalList() { @@ -365,6 +365,15 @@ public class ChooserListAdapter extends ResolverListAdapter { new AsyncTask>() { @Override protected List doInBackground(Void... voids) { + try { + Trace.beginSection("update-alphabetical-list"); + return updateList(); + } finally { + Trace.endSection(); + } + } + + private List updateList() { List allTargets = new ArrayList<>(); allTargets.addAll(getTargetsInCurrentDisplayList()); allTargets.addAll(mCallerTargets); @@ -660,98 +669,4 @@ public class ChooserListAdapter extends ResolverListAdapter { }; } - /** - * Loads direct share targets icons. - */ - @VisibleForTesting - public class LoadDirectShareIconTask extends AsyncTask { - private final Context mContext; - private final SelectableTargetInfo mTargetInfo; - - private LoadDirectShareIconTask(Context context, SelectableTargetInfo targetInfo) { - mContext = context; - mTargetInfo = targetInfo; - } - - @Override - protected Drawable doInBackground(Void... voids) { - Drawable drawable; - Trace.beginSection("shortcut-icon"); - try { - drawable = getChooserTargetIconDrawable( - mContext, - mTargetInfo.getChooserTargetIcon(), - mTargetInfo.getChooserTargetComponentName(), - mTargetInfo.getDirectShareShortcutInfo()); - } catch (Exception e) { - Log.e(TAG, - "Failed to load shortcut icon for " - + mTargetInfo.getChooserTargetComponentName(), - e); - drawable = loadIconPlaceholder(); - } finally { - Trace.endSection(); - } - return drawable; - } - - @Override - protected void onPostExecute(@Nullable Drawable icon) { - if (icon != null && !mTargetInfo.hasDisplayIcon()) { - mTargetInfo.getDisplayIconHolder().setDisplayIcon(icon); - notifyDataSetChanged(); - } - } - - @WorkerThread - private Drawable getChooserTargetIconDrawable( - Context context, - @Nullable Icon icon, - ComponentName targetComponentName, - @Nullable ShortcutInfo shortcutInfo) { - Drawable directShareIcon = null; - - // First get the target drawable and associated activity info - if (icon != null) { - directShareIcon = icon.loadDrawable(context); - } else if (shortcutInfo != null) { - LauncherApps launcherApps = context.getSystemService(LauncherApps.class); - if (launcherApps != null) { - directShareIcon = launcherApps.getShortcutIconDrawable(shortcutInfo, 0); - } - } - - if (directShareIcon == null) { - return null; - } - - ActivityInfo info = null; - try { - info = context.getPackageManager().getActivityInfo(targetComponentName, 0); - } catch (PackageManager.NameNotFoundException error) { - Log.e(TAG, "Could not find activity associated with ChooserTarget"); - } - - if (info == null) { - return null; - } - - // Now fetch app icon and raster with no badging even in work profile - Bitmap appIcon = mPresentationFactory.makePresentationGetter(info).getIconBitmap(null); - - // Raster target drawable with appIcon as a badge - SimpleIconFactory sif = SimpleIconFactory.obtain(context); - Bitmap directShareBadgedIcon = sif.createAppBadgedIconBitmap(directShareIcon, appIcon); - sif.recycle(); - - return new BitmapDrawable(context.getResources(), directShareBadgedIcon); - } - - /** - * An alias for execute to use with unit tests. - */ - public void loadIcon() { - execute(); - } - } } diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index ac3b9a61..57871532 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -60,7 +60,6 @@ import android.content.pm.UserInfo; import android.content.res.Configuration; import android.content.res.TypedArray; import android.graphics.Insets; -import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Build; import android.os.Bundle; @@ -108,6 +107,8 @@ import com.android.intentresolver.AbstractMultiProfilePagerAdapter.Profile; import com.android.intentresolver.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.icons.DefaultTargetDataLoader; +import com.android.intentresolver.icons.TargetDataLoader; import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.internal.annotations.VisibleForTesting; @@ -333,7 +334,7 @@ public class ResolverActivity extends FragmentActivity implements setSafeForwardingMode(true); - onCreate(savedInstanceState, intent, null, 0, null, null, true); + onCreate(savedInstanceState, intent, null, 0, null, null, true, createIconLoader()); } /** @@ -343,13 +344,26 @@ public class ResolverActivity extends FragmentActivity implements protected void onCreate(Bundle savedInstanceState, Intent intent, CharSequence title, Intent[] initialIntents, List rList, boolean supportsAlwaysUseOption) { - onCreate(savedInstanceState, intent, title, 0, initialIntents, rList, - supportsAlwaysUseOption); + onCreate( + savedInstanceState, + intent, + title, + 0, + initialIntents, + rList, + supportsAlwaysUseOption, + createIconLoader()); } - protected void onCreate(Bundle savedInstanceState, Intent intent, - CharSequence title, int defaultTitleRes, Intent[] initialIntents, - List rList, boolean supportsAlwaysUseOption) { + protected void onCreate( + Bundle savedInstanceState, + Intent intent, + CharSequence title, + int defaultTitleRes, + Intent[] initialIntents, + List rList, + boolean supportsAlwaysUseOption, + TargetDataLoader targetDataLoader) { setTheme(appliedThemeResId()); super.onCreate(savedInstanceState); @@ -384,8 +398,9 @@ public class ResolverActivity extends FragmentActivity implements // provide any more information to help us select between them. boolean filterLastUsed = mSupportsAlwaysUseOption && !isVoiceInteraction() && !shouldShowTabs() && !hasCloneProfile(); - mMultiProfilePagerAdapter = createMultiProfilePagerAdapter(initialIntents, rList, filterLastUsed); - if (configureContentView()) { + mMultiProfilePagerAdapter = createMultiProfilePagerAdapter( + initialIntents, rList, filterLastUsed, targetDataLoader); + if (configureContentView(targetDataLoader)) { return; } @@ -441,15 +456,16 @@ public class ResolverActivity extends FragmentActivity implements protected AbstractMultiProfilePagerAdapter createMultiProfilePagerAdapter( Intent[] initialIntents, List rList, - boolean filterLastUsed) { + boolean filterLastUsed, + TargetDataLoader targetDataLoader) { AbstractMultiProfilePagerAdapter resolverMultiProfilePagerAdapter = null; if (shouldShowTabs()) { resolverMultiProfilePagerAdapter = createResolverMultiProfilePagerAdapterForTwoProfiles( - initialIntents, rList, filterLastUsed); + initialIntents, rList, filterLastUsed, targetDataLoader); } else { resolverMultiProfilePagerAdapter = createResolverMultiProfilePagerAdapterForOneProfile( - initialIntents, rList, filterLastUsed); + initialIntents, rList, filterLastUsed, targetDataLoader); } return resolverMultiProfilePagerAdapter; } @@ -1023,12 +1039,14 @@ public class ResolverActivity extends FragmentActivity implements // @NonFinalForTesting @VisibleForTesting - protected ResolverListAdapter createResolverListAdapter(Context context, - List payloadIntents, Intent[] initialIntents, List rList, - boolean filterLastUsed, UserHandle userHandle) { - Intent startIntent = getIntent(); - boolean isAudioCaptureDevice = - startIntent.getBooleanExtra(EXTRA_IS_AUDIO_CAPTURE_DEVICE, false); + protected ResolverListAdapter createResolverListAdapter( + Context context, + List payloadIntents, + Intent[] initialIntents, + List rList, + boolean filterLastUsed, + UserHandle userHandle, + TargetDataLoader targetDataLoader) { UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile() && userHandle.equals(getPersonalProfileUserHandle()) ? getCloneProfileUserHandle() : userHandle; @@ -1042,8 +1060,15 @@ public class ResolverActivity extends FragmentActivity implements userHandle, getTargetIntent(), this, - isAudioCaptureDevice, - initialIntentsUserSpace); + initialIntentsUserSpace, + targetDataLoader); + } + + private TargetDataLoader createIconLoader() { + Intent startIntent = getIntent(); + boolean isAudioCaptureDevice = + startIntent.getBooleanExtra(EXTRA_IS_AUDIO_CAPTURE_DEVICE, false); + return new DefaultTargetDataLoader(this, getLifecycle(), isAudioCaptureDevice); } private LatencyTracker getLatencyTracker() { @@ -1118,14 +1143,16 @@ public class ResolverActivity extends FragmentActivity implements createResolverMultiProfilePagerAdapterForOneProfile( Intent[] initialIntents, List rList, - boolean filterLastUsed) { + boolean filterLastUsed, + TargetDataLoader targetDataLoader) { ResolverListAdapter adapter = createResolverListAdapter( /* context */ this, /* payloadIntents */ mIntents, initialIntents, rList, filterLastUsed, - /* userHandle */ getPersonalProfileUserHandle()); + /* userHandle */ getPersonalProfileUserHandle(), + targetDataLoader); return new ResolverMultiProfilePagerAdapter( /* context */ this, adapter, @@ -1144,7 +1171,8 @@ public class ResolverActivity extends FragmentActivity implements private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForTwoProfiles( Intent[] initialIntents, List rList, - boolean filterLastUsed) { + boolean filterLastUsed, + TargetDataLoader targetDataLoader) { // In the edge case when we have 0 apps in the current profile and >1 apps in the other, // the intent resolver is started in the other profile. Since this is the only case when // this happens, we check for it here and set the current profile's tab. @@ -1172,7 +1200,8 @@ public class ResolverActivity extends FragmentActivity implements rList, (filterLastUsed && UserHandle.myUserId() == getPersonalProfileUserHandle().getIdentifier()), - /* userHandle */ getPersonalProfileUserHandle()); + /* userHandle */ getPersonalProfileUserHandle(), + targetDataLoader); UserHandle workProfileUserHandle = getWorkProfileUserHandle(); ResolverListAdapter workAdapter = createResolverListAdapter( /* context */ this, @@ -1181,7 +1210,8 @@ public class ResolverActivity extends FragmentActivity implements rList, (filterLastUsed && UserHandle.myUserId() == workProfileUserHandle.getIdentifier()), - /* userHandle */ workProfileUserHandle); + /* userHandle */ workProfileUserHandle, + targetDataLoader); return new ResolverMultiProfilePagerAdapter( /* context */ this, personalAdapter, @@ -1698,7 +1728,7 @@ public class ResolverActivity extends FragmentActivity implements * Sets up the content view. * @return true if the activity is finishing and creation should halt. */ - private boolean configureContentView() { + private boolean configureContentView(TargetDataLoader targetDataLoader) { if (mMultiProfilePagerAdapter.getActiveListAdapter() == null) { throw new IllegalStateException("mMultiProfilePagerAdapter.getCurrentListAdapter() " + "cannot be null."); @@ -1715,7 +1745,7 @@ public class ResolverActivity extends FragmentActivity implements } if (shouldUseMiniResolver()) { - configureMiniResolverContent(); + configureMiniResolverContent(targetDataLoader); Trace.endSection(); return false; } @@ -1738,7 +1768,7 @@ public class ResolverActivity extends FragmentActivity implements * and asks the user if they'd like to open that cross-profile app or use the in-profile * browser. */ - private void configureMiniResolverContent() { + private void configureMiniResolverContent(TargetDataLoader targetDataLoader) { mLayoutId = R.layout.miniresolver; setContentView(mLayoutId); @@ -1753,15 +1783,15 @@ public class ResolverActivity extends FragmentActivity implements // Load the icon asynchronously ImageView icon = findViewById(com.android.internal.R.id.icon); - inactiveAdapter.new LoadIconTask(otherProfileResolveInfo) { - @Override - protected void onPostExecute(Drawable drawable) { - if (!isDestroyed()) { - otherProfileResolveInfo.getDisplayIconHolder().setDisplayIcon(drawable); - new ResolverListAdapter.ViewHolder(icon).bindIcon(otherProfileResolveInfo); - } - } - }.execute(); + targetDataLoader.loadAppTargetIcon( + otherProfileResolveInfo, + inactiveAdapter.getUserHandle(), + (drawable) -> { + if (!isDestroyed()) { + otherProfileResolveInfo.getDisplayIconHolder().setDisplayIcon(drawable); + new ResolverListAdapter.ViewHolder(icon).bindIcon(otherProfileResolveInfo); + } + }); ((TextView) findViewById(com.android.internal.R.id.open_cross_profile)).setText( getResources().getString( diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index a5fdd320..282a672f 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -16,15 +16,10 @@ package com.android.intentresolver; -import static android.content.Context.ACTIVITY_SERVICE; - import android.annotation.NonNull; import android.annotation.Nullable; -import android.app.ActivityManager; -import android.content.ComponentName; import android.content.Context; import android.content.Intent; -import android.content.PermissionChecker; import android.content.pm.ActivityInfo; import android.content.pm.LabeledIntent; import android.content.pm.PackageManager; @@ -49,15 +44,15 @@ import android.widget.TextView; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.icons.TargetDataLoader; import com.android.internal.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; import java.util.ArrayList; -import java.util.Collection; -import java.util.HashMap; +import java.util.HashSet; import java.util.List; -import java.util.Map; +import java.util.Set; public class ResolverListAdapter extends BaseAdapter { private static final String TAG = "ResolverListAdapter"; @@ -69,30 +64,28 @@ public class ResolverListAdapter extends BaseAdapter { protected final LayoutInflater mInflater; protected final ResolverListCommunicator mResolverListCommunicator; protected final ResolverListController mResolverListController; - protected final TargetPresentationGetter.Factory mPresentationFactory; private final List mIntents; private final Intent[] mInitialIntents; private final List mBaseResolveList; private final PackageManager mPm; - private final int mIconDpi; - private final boolean mIsAudioCaptureDevice; + private final TargetDataLoader mTargetDataLoader; private final UserHandle mUserHandle; private final Intent mTargetIntent; - private final Map mIconLoaders = new HashMap<>(); - private final Map mLabelLoaders = new HashMap<>(); + private final Set mRequestedIcons = new HashSet<>(); + private final Set mRequestedLabels = new HashSet<>(); private ResolveInfo mLastChosen; private DisplayResolveInfo mOtherProfile; private int mPlaceholderCount; // This one is the list that the Adapter will actually present. - private List mDisplayList; + private final List mDisplayList; private List mUnfilteredResolveList; private int mLastChosenPosition = -1; - private boolean mFilterLastUsed; + private final boolean mFilterLastUsed; private Runnable mPostListReadyRunnable; private boolean mIsTabLoaded; // Represents the UserSpace in which the Initial Intents should be resolved. @@ -108,24 +101,21 @@ public class ResolverListAdapter extends BaseAdapter { UserHandle userHandle, Intent targetIntent, ResolverListCommunicator resolverListCommunicator, - boolean isAudioCaptureDevice, - UserHandle initialIntentsUserSpace) { + UserHandle initialIntentsUserSpace, + TargetDataLoader targetDataLoader) { mContext = context; mIntents = payloadIntents; mInitialIntents = initialIntents; mBaseResolveList = rList; mInflater = LayoutInflater.from(context); mPm = context.getPackageManager(); + mTargetDataLoader = targetDataLoader; mDisplayList = new ArrayList<>(); mFilterLastUsed = filterLastUsed; mResolverListController = resolverListController; mUserHandle = userHandle; mTargetIntent = targetIntent; mResolverListCommunicator = resolverListCommunicator; - mIsAudioCaptureDevice = isAudioCaptureDevice; - final ActivityManager am = (ActivityManager) mContext.getSystemService(ACTIVITY_SERVICE); - mIconDpi = am.getLauncherLargeIconDensity(); - mPresentationFactory = new TargetPresentationGetter.Factory(mContext, mIconDpi); mInitialIntentsUserSpace = initialIntentsUserSpace; } @@ -364,12 +354,11 @@ public class ResolverListAdapter extends BaseAdapter { if (otherProfileInfo != null) { mOtherProfile = makeOtherProfileDisplayResolveInfo( - mContext, otherProfileInfo, mPm, mTargetIntent, mResolverListCommunicator, - mIconDpi); + mTargetDataLoader); } else { mOtherProfile = null; try { @@ -483,7 +472,7 @@ public class ResolverListAdapter extends BaseAdapter { ri.loadLabel(mPm), null, ii, - mPresentationFactory.makePresentationGetter(ri))); + mTargetDataLoader.createPresentationGetter(ri))); } } @@ -536,7 +525,7 @@ public class ResolverListAdapter extends BaseAdapter { intent, add, (replaceIntent != null) ? replaceIntent : defaultIntent, - mPresentationFactory.makePresentationGetter(add)); + mTargetDataLoader.createPresentationGetter(add)); dri.setPinned(rci.isPinned()); if (rci.isPinned()) { Log.i(TAG, "Pinned item: " + rci.name); @@ -704,25 +693,37 @@ public class ResolverListAdapter extends BaseAdapter { } protected final void loadIcon(DisplayResolveInfo info) { - LoadIconTask task = mIconLoaders.get(info); - if (task == null) { - task = new LoadIconTask(info); - mIconLoaders.put(info, task); - task.execute(); + if (mRequestedIcons.add(info)) { + mTargetDataLoader.loadAppTargetIcon( + info, + getUserHandle(), + (drawable) -> onIconLoaded(info, drawable)); + } + } + + private void onIconLoaded(DisplayResolveInfo displayResolveInfo, Drawable drawable) { + if (getOtherProfile() == displayResolveInfo) { + mResolverListCommunicator.updateProfileViewButton(); + } else if (!displayResolveInfo.hasDisplayIcon()) { + displayResolveInfo.getDisplayIconHolder().setDisplayIcon(drawable); + notifyDataSetChanged(); } } private void loadLabel(DisplayResolveInfo info) { - LoadLabelTask task = mLabelLoaders.get(info); - if (task == null) { - task = createLoadLabelTask(info); - mLabelLoaders.put(info, task); - task.execute(); + if (mRequestedLabels.add(info)) { + mTargetDataLoader.loadLabel(info, (result) -> onLabelLoaded(info, result)); } } - protected LoadLabelTask createLoadLabelTask(DisplayResolveInfo info) { - return new LoadLabelTask(info); + protected final void onLabelLoaded( + DisplayResolveInfo displayResolveInfo, CharSequence[] result) { + if (displayResolveInfo.hasDisplayLabel()) { + return; + } + displayResolveInfo.setDisplayLabel(result[0]); + displayResolveInfo.setExtendedInfo(result[1]); + notifyDataSetChanged(); } public void onDestroy() { @@ -733,16 +734,8 @@ public class ResolverListAdapter extends BaseAdapter { if (mResolverListController != null) { mResolverListController.destroy(); } - cancelTasks(mIconLoaders.values()); - cancelTasks(mLabelLoaders.values()); - mIconLoaders.clear(); - mLabelLoaders.clear(); - } - - private void cancelTasks(Collection tasks) { - for (T task: tasks) { - task.cancel(false); - } + mRequestedIcons.clear(); + mRequestedLabels.clear(); } private static ColorMatrixColorFilter getSuspendedColorMatrix() { @@ -768,39 +761,15 @@ public class ResolverListAdapter extends BaseAdapter { return sSuspendedMatrixColorFilter; } - Drawable loadIconForResolveInfo(ResolveInfo ri) { - // Load icons based on userHandle from ResolveInfo. If in work profile/clone profile, icons - // should be badged. - return mPresentationFactory.makePresentationGetter(ri) - .getIcon(ResolverActivity.getResolveInfoUserHandle(ri, getUserHandle())); - } - protected final Drawable loadIconPlaceholder() { return mContext.getDrawable(R.drawable.resolver_icon_placeholder); } void loadFilteredItemIconTaskAsync(@NonNull ImageView iconView) { final DisplayResolveInfo iconInfo = getFilteredItem(); - if (iconView != null && iconInfo != null) { - new AsyncTask() { - @Override - protected Drawable doInBackground(Void... params) { - Drawable drawable; - try { - drawable = loadIconForResolveInfo(iconInfo.getResolveInfo()); - } catch (Exception e) { - ComponentName componentName = iconInfo.getResolvedComponentName(); - Log.e(TAG, "Failed to load app icon for " + componentName, e); - drawable = loadIconPlaceholder(); - } - return drawable; - } - - @Override - protected void onPostExecute(Drawable d) { - iconView.setImageDrawable(d); - } - }.execute(); + if (iconInfo != null) { + mTargetDataLoader.loadAppTargetIcon( + iconInfo, getUserHandle(), iconView::setImageDrawable); } } @@ -856,12 +825,11 @@ public class ResolverListAdapter extends BaseAdapter { * of an element in the resolve list). */ private static DisplayResolveInfo makeOtherProfileDisplayResolveInfo( - Context context, ResolvedComponentInfo resolvedComponentInfo, PackageManager pm, Intent targetIntent, ResolverListCommunicator resolverListCommunicator, - int iconDpi) { + TargetDataLoader targetDataLoader) { ResolveInfo resolveInfo = resolvedComponentInfo.getResolveInfoAt(0); Intent pOrigIntent = resolverListCommunicator.getReplacementIntent( @@ -871,8 +839,7 @@ public class ResolverListAdapter extends BaseAdapter { resolveInfo.activityInfo, targetIntent); TargetPresentationGetter presentationGetter = - new TargetPresentationGetter.Factory(context, iconDpi) - .makePresentationGetter(resolveInfo); + targetDataLoader.createPresentationGetter(resolveInfo); return DisplayResolveInfo.newDisplayResolveInfo( resolvedComponentInfo.getIntentAt(0), @@ -971,89 +938,4 @@ public class ResolverListAdapter extends BaseAdapter { } } } - - protected class LoadLabelTask extends AsyncTask { - private final DisplayResolveInfo mDisplayResolveInfo; - - protected LoadLabelTask(DisplayResolveInfo dri) { - mDisplayResolveInfo = dri; - } - - @Override - protected CharSequence[] doInBackground(Void... voids) { - TargetPresentationGetter pg = mPresentationFactory.makePresentationGetter( - mDisplayResolveInfo.getResolveInfo()); - - if (mIsAudioCaptureDevice) { - // This is an audio capture device, so check record permissions - ActivityInfo activityInfo = mDisplayResolveInfo.getResolveInfo().activityInfo; - String packageName = activityInfo.packageName; - - int uid = activityInfo.applicationInfo.uid; - boolean hasRecordPermission = - PermissionChecker.checkPermissionForPreflight( - mContext, - android.Manifest.permission.RECORD_AUDIO, -1, uid, - packageName) - == android.content.pm.PackageManager.PERMISSION_GRANTED; - - if (!hasRecordPermission) { - // Doesn't have record permission, so warn the user - return new CharSequence[] { - pg.getLabel(), - mContext.getString(R.string.usb_device_resolve_prompt_warn) - }; - } - } - - return new CharSequence[] { - pg.getLabel(), - pg.getSubLabel() - }; - } - - @Override - protected void onPostExecute(CharSequence[] result) { - if (mDisplayResolveInfo.hasDisplayLabel()) { - return; - } - mDisplayResolveInfo.setDisplayLabel(result[0]); - mDisplayResolveInfo.setExtendedInfo(result[1]); - notifyDataSetChanged(); - } - } - - class LoadIconTask extends AsyncTask { - protected final DisplayResolveInfo mDisplayResolveInfo; - private final ResolveInfo mResolveInfo; - - LoadIconTask(DisplayResolveInfo dri) { - mDisplayResolveInfo = dri; - mResolveInfo = dri.getResolveInfo(); - } - - @Override - protected Drawable doInBackground(Void... params) { - Trace.beginSection("app-icon"); - try { - return loadIconForResolveInfo(mResolveInfo); - } catch (Exception e) { - ComponentName componentName = mDisplayResolveInfo.getResolvedComponentName(); - Log.e(TAG, "Failed to load app icon for " + componentName, e); - return loadIconPlaceholder(); - } finally { - Trace.endSection(); - } - } - - @Override - protected void onPostExecute(Drawable d) { - if (getOtherProfile() == mDisplayResolveInfo) { - mResolverListCommunicator.updateProfileViewButton(); - } else if (!mDisplayResolveInfo.hasDisplayIcon()) { - mDisplayResolveInfo.getDisplayIconHolder().setDisplayIcon(d); - notifyDataSetChanged(); - } - } - } } diff --git a/java/src/com/android/intentresolver/icons/BaseLoadIconTask.java b/java/src/com/android/intentresolver/icons/BaseLoadIconTask.java new file mode 100644 index 00000000..2eceb89c --- /dev/null +++ b/java/src/com/android/intentresolver/icons/BaseLoadIconTask.java @@ -0,0 +1,50 @@ +/* + * 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.icons; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.os.AsyncTask; + +import com.android.intentresolver.R; +import com.android.intentresolver.TargetPresentationGetter; + +import java.util.function.Consumer; + +abstract class BaseLoadIconTask extends AsyncTask { + protected final Context mContext; + protected final TargetPresentationGetter.Factory mPresentationFactory; + private final Consumer mCallback; + + BaseLoadIconTask( + Context context, + TargetPresentationGetter.Factory presentationFactory, + Consumer callback) { + mContext = context; + mPresentationFactory = presentationFactory; + mCallback = callback; + } + + protected final Drawable loadIconPlaceholder() { + return mContext.getDrawable(R.drawable.resolver_icon_placeholder); + } + + @Override + protected final void onPostExecute(Drawable d) { + mCallback.accept(d); + } +} diff --git a/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt b/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt new file mode 100644 index 00000000..0414dea7 --- /dev/null +++ b/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt @@ -0,0 +1,128 @@ +/* + * 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.icons + +import android.app.ActivityManager +import android.content.Context +import android.content.pm.ResolveInfo +import android.graphics.drawable.Drawable +import android.os.AsyncTask +import android.os.UserHandle +import android.util.SparseArray +import androidx.annotation.GuardedBy +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import com.android.intentresolver.TargetPresentationGetter +import com.android.intentresolver.chooser.DisplayResolveInfo +import com.android.intentresolver.chooser.SelectableTargetInfo +import java.util.concurrent.atomic.AtomicInteger +import java.util.function.Consumer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asExecutor + +/** An actual [TargetDataLoader] implementation. */ +// TODO: replace async tasks with coroutines. +class DefaultTargetDataLoader( + private val context: Context, + private val lifecycle: Lifecycle, + private val isAudioCaptureDevice: Boolean, +) : TargetDataLoader() { + private val presentationFactory = + TargetPresentationGetter.Factory( + context, + context.getSystemService(ActivityManager::class.java)?.launcherLargeIconDensity + ?: error("Unable to access ActivityManager") + ) + private val nextTaskId = AtomicInteger(0) + @GuardedBy("self") private val activeTasks = SparseArray>() + private val executor = Dispatchers.IO.asExecutor() + + init { + lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + lifecycle.removeObserver(this) + destroy() + } + } + ) + } + + override fun loadAppTargetIcon( + info: DisplayResolveInfo, + userHandle: UserHandle, + callback: Consumer, + ) { + val taskId = nextTaskId.getAndIncrement() + LoadIconTask(context, info, userHandle, presentationFactory) { result -> + removeTask(taskId) + callback.accept(result) + } + .also { addTask(taskId, it) } + .executeOnExecutor(executor) + } + + override fun loadDirectShareIcon( + info: SelectableTargetInfo, + userHandle: UserHandle, + callback: Consumer, + ) { + val taskId = nextTaskId.getAndIncrement() + LoadDirectShareIconTask( + context.createContextAsUser(userHandle, 0), + info, + userHandle, + presentationFactory, + ) { result -> + removeTask(taskId) + callback.accept(result) + } + .also { addTask(taskId, it) } + .executeOnExecutor(executor) + } + + override fun loadLabel(info: DisplayResolveInfo, callback: Consumer>) { + val taskId = nextTaskId.getAndIncrement() + LoadLabelTask(context, info, isAudioCaptureDevice, presentationFactory) { result -> + removeTask(taskId) + callback.accept(result) + } + .also { addTask(taskId, it) } + .executeOnExecutor(executor) + } + + override fun createPresentationGetter(info: ResolveInfo): TargetPresentationGetter = + presentationFactory.makePresentationGetter(info) + + private fun addTask(id: Int, task: AsyncTask<*, *, *>) { + synchronized(activeTasks) { activeTasks.put(id, task) } + } + + private fun removeTask(id: Int) { + synchronized(activeTasks) { activeTasks.remove(id) } + } + + private fun destroy() { + synchronized(activeTasks) { + for (i in 0 until activeTasks.size()) { + activeTasks.valueAt(i).cancel(false) + } + activeTasks.clear() + } + } +} diff --git a/java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java b/java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java new file mode 100644 index 00000000..b7bacc90 --- /dev/null +++ b/java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java @@ -0,0 +1,125 @@ +/* + * 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.icons; + +import android.annotation.Nullable; +import android.content.ComponentName; +import android.content.Context; +import android.content.pm.ActivityInfo; +import android.content.pm.LauncherApps; +import android.content.pm.PackageManager; +import android.content.pm.ShortcutInfo; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.Icon; +import android.os.Trace; +import android.os.UserHandle; +import android.util.Log; + +import androidx.annotation.WorkerThread; + +import com.android.intentresolver.SimpleIconFactory; +import com.android.intentresolver.TargetPresentationGetter; +import com.android.intentresolver.chooser.SelectableTargetInfo; + +import java.util.function.Consumer; + +/** + * Loads direct share targets icons. + */ +class LoadDirectShareIconTask extends BaseLoadIconTask { + private static final String TAG = "DirectShareIconTask"; + private final SelectableTargetInfo mTargetInfo; + + LoadDirectShareIconTask( + Context context, + SelectableTargetInfo targetInfo, + UserHandle userHandle, + TargetPresentationGetter.Factory presentationFactory, + Consumer callback) { + super(context, presentationFactory, callback); + mTargetInfo = targetInfo; + } + + @Override + protected Drawable doInBackground(Void... voids) { + Drawable drawable; + Trace.beginSection("shortcut-icon"); + try { + drawable = getChooserTargetIconDrawable( + mContext, + mTargetInfo.getChooserTargetIcon(), + mTargetInfo.getChooserTargetComponentName(), + mTargetInfo.getDirectShareShortcutInfo()); + } catch (Exception e) { + Log.e( + TAG, + "Failed to load shortcut icon for " + + mTargetInfo.getChooserTargetComponentName(), + e); + drawable = loadIconPlaceholder(); + } finally { + Trace.endSection(); + } + return drawable; + } + + @WorkerThread + private Drawable getChooserTargetIconDrawable( + Context context, + @Nullable Icon icon, + ComponentName targetComponentName, + @Nullable ShortcutInfo shortcutInfo) { + Drawable directShareIcon = null; + + // First get the target drawable and associated activity info + if (icon != null) { + directShareIcon = icon.loadDrawable(context); + } else if (shortcutInfo != null) { + LauncherApps launcherApps = context.getSystemService(LauncherApps.class); + if (launcherApps != null) { + directShareIcon = launcherApps.getShortcutIconDrawable(shortcutInfo, 0); + } + } + + if (directShareIcon == null) { + return null; + } + + ActivityInfo info = null; + try { + info = context.getPackageManager().getActivityInfo(targetComponentName, 0); + } catch (PackageManager.NameNotFoundException error) { + Log.e(TAG, "Could not find activity associated with ChooserTarget"); + } + + if (info == null) { + return null; + } + + // Now fetch app icon and raster with no badging even in work profile + Bitmap appIcon = mPresentationFactory.makePresentationGetter(info).getIconBitmap(null); + + // Raster target drawable with appIcon as a badge + SimpleIconFactory sif = SimpleIconFactory.obtain(context); + Bitmap directShareBadgedIcon = sif.createAppBadgedIconBitmap(directShareIcon, appIcon); + sif.recycle(); + + return new BitmapDrawable(context.getResources(), directShareBadgedIcon); + } +} diff --git a/java/src/com/android/intentresolver/icons/LoadIconTask.java b/java/src/com/android/intentresolver/icons/LoadIconTask.java new file mode 100644 index 00000000..37ce4093 --- /dev/null +++ b/java/src/com/android/intentresolver/icons/LoadIconTask.java @@ -0,0 +1,71 @@ +/* + * 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.icons; + +import android.content.ComponentName; +import android.content.Context; +import android.content.pm.ResolveInfo; +import android.graphics.drawable.Drawable; +import android.os.Trace; +import android.os.UserHandle; +import android.util.Log; + +import com.android.intentresolver.ResolverActivity; +import com.android.intentresolver.TargetPresentationGetter; +import com.android.intentresolver.chooser.DisplayResolveInfo; + +import java.util.function.Consumer; + +class LoadIconTask extends BaseLoadIconTask { + private static final String TAG = "IconTask"; + protected final DisplayResolveInfo mDisplayResolveInfo; + private final UserHandle mUserHandle; + private final ResolveInfo mResolveInfo; + + LoadIconTask( + Context context, DisplayResolveInfo dri, + UserHandle userHandle, + TargetPresentationGetter.Factory presentationFactory, + Consumer callback) { + super(context, presentationFactory, callback); + mUserHandle = userHandle; + mDisplayResolveInfo = dri; + mResolveInfo = dri.getResolveInfo(); + } + + @Override + protected Drawable doInBackground(Void... params) { + Trace.beginSection("app-icon"); + try { + return loadIconForResolveInfo(mResolveInfo); + } catch (Exception e) { + ComponentName componentName = mDisplayResolveInfo.getResolvedComponentName(); + Log.e(TAG, "Failed to load app icon for " + componentName, e); + return loadIconPlaceholder(); + } finally { + Trace.endSection(); + } + } + + protected final Drawable loadIconForResolveInfo(ResolveInfo ri) { + // Load icons based on userHandle from ResolveInfo. If in work profile/clone profile, icons + // should be badged. + return mPresentationFactory.makePresentationGetter(ri) + .getIcon(ResolverActivity.getResolveInfoUserHandle(ri, mUserHandle)); + } + +} diff --git a/java/src/com/android/intentresolver/icons/LoadLabelTask.java b/java/src/com/android/intentresolver/icons/LoadLabelTask.java new file mode 100644 index 00000000..a0867b8e --- /dev/null +++ b/java/src/com/android/intentresolver/icons/LoadLabelTask.java @@ -0,0 +1,94 @@ +/* + * 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.icons; + +import android.content.Context; +import android.content.PermissionChecker; +import android.content.pm.ActivityInfo; +import android.os.AsyncTask; +import android.os.Trace; + +import com.android.intentresolver.R; +import com.android.intentresolver.TargetPresentationGetter; +import com.android.intentresolver.chooser.DisplayResolveInfo; + +import java.util.function.Consumer; + +class LoadLabelTask extends AsyncTask { + private final Context mContext; + private final DisplayResolveInfo mDisplayResolveInfo; + private final boolean mIsAudioCaptureDevice; + protected final TargetPresentationGetter.Factory mPresentationFactory; + private final Consumer mCallback; + + LoadLabelTask(Context context, DisplayResolveInfo dri, + boolean isAudioCaptureDevice, TargetPresentationGetter.Factory presentationFactory, + Consumer callback) { + mContext = context; + mDisplayResolveInfo = dri; + mIsAudioCaptureDevice = isAudioCaptureDevice; + mPresentationFactory = presentationFactory; + mCallback = callback; + } + + @Override + protected CharSequence[] doInBackground(Void... voids) { + try { + Trace.beginSection("app-label"); + return loadLabel(); + } finally { + Trace.endSection(); + } + } + + private CharSequence[] loadLabel() { + TargetPresentationGetter pg = mPresentationFactory.makePresentationGetter( + mDisplayResolveInfo.getResolveInfo()); + + if (mIsAudioCaptureDevice) { + // This is an audio capture device, so check record permissions + ActivityInfo activityInfo = mDisplayResolveInfo.getResolveInfo().activityInfo; + String packageName = activityInfo.packageName; + + int uid = activityInfo.applicationInfo.uid; + boolean hasRecordPermission = + PermissionChecker.checkPermissionForPreflight( + mContext, + android.Manifest.permission.RECORD_AUDIO, -1, uid, + packageName) + == android.content.pm.PackageManager.PERMISSION_GRANTED; + + if (!hasRecordPermission) { + // Doesn't have record permission, so warn the user + return new CharSequence[]{ + pg.getLabel(), + mContext.getString(R.string.usb_device_resolve_prompt_warn) + }; + } + } + + return new CharSequence[]{ + pg.getLabel(), + pg.getSubLabel() + }; + } + + @Override + protected void onPostExecute(CharSequence[] result) { + mCallback.accept(result); + } +} diff --git a/java/src/com/android/intentresolver/icons/TargetDataLoader.kt b/java/src/com/android/intentresolver/icons/TargetDataLoader.kt new file mode 100644 index 00000000..50f731f8 --- /dev/null +++ b/java/src/com/android/intentresolver/icons/TargetDataLoader.kt @@ -0,0 +1,50 @@ +/* + * 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.icons + +import android.content.pm.ResolveInfo +import android.graphics.drawable.Drawable +import android.os.UserHandle +import com.android.intentresolver.TargetPresentationGetter +import com.android.intentresolver.chooser.DisplayResolveInfo +import com.android.intentresolver.chooser.SelectableTargetInfo +import java.util.function.Consumer + +/** A target data loader contract. Added to support testing. */ +abstract class TargetDataLoader { + /** Load an app target icon */ + abstract fun loadAppTargetIcon( + info: DisplayResolveInfo, + userHandle: UserHandle, + callback: Consumer, + ) + + /** Load a shortcut icon */ + abstract fun loadDirectShareIcon( + info: SelectableTargetInfo, + userHandle: UserHandle, + callback: Consumer, + ) + + /** Load target label */ + abstract fun loadLabel(info: DisplayResolveInfo, callback: Consumer>) + + /** Create a presentation getter to be used with a [DisplayResolveInfo] */ + // TODO: get rid of DisplayResolveInfo's dependency on the presentation getter and remove this + // method. + abstract fun createPresentationGetter(info: ResolveInfo): TargetPresentationGetter +} diff --git a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt index 9504f377..4612b430 100644 --- a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt +++ b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt @@ -27,10 +27,10 @@ import android.widget.ImageView import android.widget.TextView import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import com.android.intentresolver.ChooserListAdapter.LoadDirectShareIconTask import com.android.intentresolver.chooser.DisplayResolveInfo import com.android.intentresolver.chooser.SelectableTargetInfo import com.android.intentresolver.chooser.TargetInfo +import com.android.intentresolver.icons.TargetDataLoader import com.android.internal.R import org.junit.Before import org.junit.Test @@ -40,47 +40,43 @@ import org.mockito.Mockito.verify @RunWith(AndroidJUnit4::class) class ChooserListAdapterTest { - private val PERSONAL_USER_HANDLE: UserHandle = InstrumentationRegistry - .getInstrumentation().getTargetContext().getUser() + private val userHandle: UserHandle = + InstrumentationRegistry.getInstrumentation().targetContext.user - private val packageManager = mock { - whenever( - resolveActivity(any(), any()) - ).thenReturn(mock()) - } - private val context = InstrumentationRegistry.getInstrumentation().getContext() + private val packageManager = + mock { + whenever(resolveActivity(any(), any())).thenReturn(mock()) + } + private val context = InstrumentationRegistry.getInstrumentation().context private val resolverListController = mock() private val chooserActivityLogger = mock() + private val mTargetDataLoader = mock() - private fun createChooserListAdapter( - taskProvider: (TargetInfo?) -> LoadDirectShareIconTask - ) = object : ChooserListAdapter( + private val testSubject by lazy { + ChooserListAdapter( context, emptyList(), emptyArray(), emptyList(), false, resolverListController, - null, + userHandle, Intent(), mock(), packageManager, chooserActivityLogger, mock(), 0, - null - ) { - override fun createLoadDirectShareIconTask( - info: SelectableTargetInfo - ): LoadDirectShareIconTask = taskProvider(info) - } + null, + mTargetDataLoader + ) + } @Before fun setup() { // ChooserListAdapter reads DeviceConfig and needs a permission for that. - InstrumentationRegistry - .getInstrumentation() - .getUiAutomation() + InstrumentationRegistry.getInstrumentation() + .uiAutomation .adoptShellPermissionIdentity("android.permission.READ_DEVICE_CONFIG") } @@ -90,41 +86,56 @@ class ChooserListAdapterTest { val viewHolder = ResolverListAdapter.ViewHolder(view) view.tag = viewHolder val targetInfo = createSelectableTargetInfo() - val iconTask = mock() - val testSubject = createChooserListAdapter { iconTask } testSubject.onBindView(view, targetInfo, 0) - verify(iconTask, times(1)).loadIcon() + verify(mTargetDataLoader, times(1)).loadDirectShareIcon(any(), any(), any()) } @Test - fun testOnlyOneTaskPerTarget() { + fun onBindView_DirectShareTargetIconAndLabelLoadedOnlyOnce() { val view = createView() val viewHolderOne = ResolverListAdapter.ViewHolder(view) view.tag = viewHolderOne val targetInfo = createSelectableTargetInfo() - val iconTaskOne = mock() - val testTaskProvider = mock<() -> LoadDirectShareIconTask> { - whenever(invoke()).thenReturn(iconTaskOne) - } - val testSubject = createChooserListAdapter { testTaskProvider.invoke() } testSubject.onBindView(view, targetInfo, 0) val viewHolderTwo = ResolverListAdapter.ViewHolder(view) view.tag = viewHolderTwo - whenever(testTaskProvider()).thenReturn(mock()) testSubject.onBindView(view, targetInfo, 0) - verify(iconTaskOne, times(1)).loadIcon() - verify(testTaskProvider, times(1)).invoke() + verify(mTargetDataLoader, times(1)).loadDirectShareIcon(any(), any(), any()) + } + + @Test + fun onBindView_AppTargetIconAndLabelLoadedOnlyOnce() { + val view = createView() + val viewHolderOne = ResolverListAdapter.ViewHolder(view) + view.tag = viewHolderOne + val targetInfo = + DisplayResolveInfo.newDisplayResolveInfo( + Intent(), + ResolverDataProvider.createResolveInfo(2, 0, userHandle), + null, + "extended info", + Intent(), + /* resolveInfoPresentationGetter= */ null + ) + testSubject.onBindView(view, targetInfo, 0) + + val viewHolderTwo = ResolverListAdapter.ViewHolder(view) + view.tag = viewHolderTwo + + testSubject.onBindView(view, targetInfo, 0) + + verify(mTargetDataLoader, times(1)).loadAppTargetIcon(any(), any(), any()) } private fun createSelectableTargetInfo(): TargetInfo = SelectableTargetInfo.newSelectableTargetInfo( /* sourceInfo = */ DisplayResolveInfo.newDisplayResolveInfo( Intent(), - ResolverDataProvider.createResolveInfo(2, 0, PERSONAL_USER_HANDLE), + ResolverDataProvider.createResolveInfo(2, 0, userHandle), "label", "extended info", Intent(), @@ -133,7 +144,10 @@ class ChooserListAdapterTest { /* backupResolveInfo = */ mock(), /* resolvedIntent = */ Intent(), /* chooserTarget = */ createChooserTarget( - "Target", 0.5f, ComponentName("pkg", "Class"), "id-1" + "Target", + 0.5f, + ComponentName("pkg", "Class"), + "id-1" ), /* modifiedScore = */ 1f, /* shortcutInfo = */ createShortcutInfo("id-1", ComponentName("pkg", "Class"), 1), diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index fa934f87..6ac6b6d3 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -39,6 +39,7 @@ import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.grid.ChooserGridAdapter; +import com.android.intentresolver.icons.TargetDataLoader; import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; @@ -72,7 +73,8 @@ public class ChooserWrapperActivity UserHandle userHandle, Intent targetIntent, ChooserRequestParameters chooserRequest, - int maxTargetsPerRow) { + int maxTargetsPerRow, + TargetDataLoader targetDataLoader) { PackageManager packageManager = sOverrides.packageManager == null ? context.getPackageManager() : sOverrides.packageManager; @@ -90,7 +92,8 @@ public class ChooserWrapperActivity getChooserActivityLogger(), chooserRequest, maxTargetsPerRow, - userHandle); + userHandle, + targetDataLoader); } @Override diff --git a/java/tests/src/com/android/intentresolver/ResolverActivityTest.java b/java/tests/src/com/android/intentresolver/ResolverActivityTest.java index 31c0a498..7233fd3d 100644 --- a/java/tests/src/com/android/intentresolver/ResolverActivityTest.java +++ b/java/tests/src/com/android/intentresolver/ResolverActivityTest.java @@ -109,7 +109,7 @@ public class ResolverActivityTest { setupResolverControllers(resolvedComponentInfos); final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - Espresso.registerIdlingResources(activity.getAdapter().getLabelIdlingResource()); + Espresso.registerIdlingResources(activity.getLabelIdlingResource()); waitForIdle(); assertThat(activity.getAdapter().getCount(), is(2)); @@ -246,7 +246,7 @@ public class ResolverActivityTest { ResolveInfo toChoose = personalResolvedComponentInfos.get(1).getResolveInfoAt(0); Intent sendIntent = createSendImageIntent(); final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - Espresso.registerIdlingResources(activity.getAdapter().getLabelIdlingResource()); + Espresso.registerIdlingResources(activity.getLabelIdlingResource()); waitForIdle(); // The other entry is filtered to the last used slot @@ -280,7 +280,7 @@ public class ResolverActivityTest { setupResolverControllers(resolvedComponentInfos); final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - Espresso.registerIdlingResources(activity.getAdapter().getLabelIdlingResource()); + Espresso.registerIdlingResources(activity.getLabelIdlingResource()); waitForIdle(); // The other entry is filtered to the other profile slot @@ -321,7 +321,7 @@ public class ResolverActivityTest { .thenReturn(resolvedComponentInfos.get(1).getResolveInfoAt(0)); final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - Espresso.registerIdlingResources(activity.getAdapter().getLabelIdlingResource()); + Espresso.registerIdlingResources(activity.getLabelIdlingResource()); waitForIdle(); // The other entry is filtered to the other profile slot @@ -782,7 +782,7 @@ public class ResolverActivityTest { .thenReturn(resolvedComponentInfos.get(1).getResolveInfoAt(0)); final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - Espresso.registerIdlingResources(activity.getAdapter().getLabelIdlingResource()); + Espresso.registerIdlingResources(activity.getLabelIdlingResource()); waitForIdle(); // The other entry is filtered to the last used slot @@ -848,7 +848,7 @@ public class ResolverActivityTest { .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0)); final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - Espresso.registerIdlingResources(activity.getAdapter().getLabelIdlingResource()); + Espresso.registerIdlingResources(activity.getLabelIdlingResource()); waitForIdle(); assertThat(activity.getAdapter().hasFilteredItem(), is(false)); diff --git a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java index 645e8c72..401ede26 100644 --- a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java @@ -22,19 +22,26 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import android.annotation.Nullable; -import android.app.usage.UsageStatsManager; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; +import android.graphics.drawable.Drawable; import android.os.Bundle; import android.os.UserHandle; import android.util.Pair; +import androidx.annotation.NonNull; +import androidx.test.espresso.idling.CountingIdlingResource; + import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; +import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.chooser.SelectableTargetInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.icons.TargetDataLoader; import java.util.List; +import java.util.function.Consumer; import java.util.function.Function; /* @@ -42,7 +49,9 @@ import java.util.function.Function; */ public class ResolverWrapperActivity extends ResolverActivity { static final OverrideData sOverrides = new OverrideData(); - private UsageStatsManager mUsm; + + private final CountingIdlingResource mLabelIdlingResource = + new CountingIdlingResource("LoadLabelTask"); public ResolverWrapperActivity() { super(/* isIntentPicker= */ true); @@ -55,11 +64,20 @@ public class ResolverWrapperActivity extends ResolverActivity { return 1234; } + public CountingIdlingResource getLabelIdlingResource() { + return mLabelIdlingResource; + } + @Override - public ResolverListAdapter createResolverListAdapter(Context context, - List payloadIntents, Intent[] initialIntents, List rList, - boolean filterLastUsed, UserHandle userHandle) { - return new ResolverWrapperAdapter( + public ResolverListAdapter createResolverListAdapter( + Context context, + List payloadIntents, + Intent[] initialIntents, + List rList, + boolean filterLastUsed, + UserHandle userHandle, + TargetDataLoader targetDataLoader) { + return new ResolverListAdapter( context, payloadIntents, initialIntents, @@ -69,7 +87,8 @@ public class ResolverWrapperActivity extends ResolverActivity { userHandle, payloadIntents.get(0), // TODO: extract upstream this, - userHandle); + userHandle, + new TargetDataLoaderWrapper(targetDataLoader, mLabelIdlingResource)); } @Override @@ -88,8 +107,8 @@ public class ResolverWrapperActivity extends ResolverActivity { return super.createWorkProfileAvailabilityManager(); } - ResolverWrapperAdapter getAdapter() { - return (ResolverWrapperAdapter) mMultiProfilePagerAdapter.getActiveListAdapter(); + ResolverListAdapter getAdapter() { + return mMultiProfilePagerAdapter.getActiveListAdapter(); } ResolverListAdapter getPersonalListAdapter() { @@ -226,4 +245,50 @@ public class ResolverWrapperActivity extends ResolverActivity { .thenAnswer(invocation -> hasCrossProfileIntents); } } + + private static class TargetDataLoaderWrapper extends TargetDataLoader { + private final TargetDataLoader mTargetDataLoader; + private final CountingIdlingResource mLabelIdlingResource; + + private TargetDataLoaderWrapper( + TargetDataLoader targetDataLoader, CountingIdlingResource labelIdlingResource) { + mTargetDataLoader = targetDataLoader; + mLabelIdlingResource = labelIdlingResource; + } + + @Override + public void loadAppTargetIcon( + @NonNull DisplayResolveInfo info, + @NonNull UserHandle userHandle, + @NonNull Consumer callback) { + mTargetDataLoader.loadAppTargetIcon(info, userHandle, callback); + } + + @Override + public void loadDirectShareIcon( + @NonNull SelectableTargetInfo info, + @NonNull UserHandle userHandle, + @NonNull Consumer callback) { + mTargetDataLoader.loadDirectShareIcon(info, userHandle, callback); + } + + @Override + public void loadLabel( + @NonNull DisplayResolveInfo info, + @NonNull Consumer callback) { + mLabelIdlingResource.increment(); + mTargetDataLoader.loadLabel( + info, + (result) -> { + mLabelIdlingResource.decrement(); + callback.accept(result); + }); + } + + @NonNull + @Override + public TargetPresentationGetter createPresentationGetter(@NonNull ResolveInfo info) { + return mTargetDataLoader.createPresentationGetter(info); + } + } } diff --git a/java/tests/src/com/android/intentresolver/ResolverWrapperAdapter.java b/java/tests/src/com/android/intentresolver/ResolverWrapperAdapter.java deleted file mode 100644 index fd310fd8..00000000 --- a/java/tests/src/com/android/intentresolver/ResolverWrapperAdapter.java +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright (C) 2019 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 android.content.Context; -import android.content.Intent; -import android.content.pm.ResolveInfo; -import android.os.UserHandle; - -import androidx.test.espresso.idling.CountingIdlingResource; - -import com.android.intentresolver.chooser.DisplayResolveInfo; - -import java.util.List; - -public class ResolverWrapperAdapter extends ResolverListAdapter { - - private CountingIdlingResource mLabelIdlingResource = - new CountingIdlingResource("LoadLabelTask"); - - public ResolverWrapperAdapter( - Context context, - List payloadIntents, - Intent[] initialIntents, - List rList, - boolean filterLastUsed, - ResolverListController resolverListController, - UserHandle userHandle, - Intent targetIntent, - ResolverListCommunicator resolverListCommunicator, - UserHandle initialIntentsUserHandle) { - super( - context, - payloadIntents, - initialIntents, - rList, - filterLastUsed, - resolverListController, - userHandle, - targetIntent, - resolverListCommunicator, - false, - initialIntentsUserHandle); - } - - public CountingIdlingResource getLabelIdlingResource() { - return mLabelIdlingResource; - } - - @Override - protected LoadLabelTask createLoadLabelTask(DisplayResolveInfo info) { - return new LoadLabelWrapperTask(info); - } - - class LoadLabelWrapperTask extends LoadLabelTask { - - protected LoadLabelWrapperTask(DisplayResolveInfo dri) { - super(dri); - } - - @Override - protected void onPreExecute() { - mLabelIdlingResource.increment(); - } - - @Override - protected void onPostExecute(CharSequence[] result) { - super.onPostExecute(result); - mLabelIdlingResource.decrement(); - } - } -} -- cgit v1.2.3-59-g8ed1b From ef59bb80955fe53c24da4d6d40331e1d463c3f36 Mon Sep 17 00:00:00 2001 From: 1 Date: Mon, 22 May 2023 21:01:06 +0000 Subject: Move copy and edit actions into preview space. Omitting edit action from landscape phones for now. Bug: 283245199 Test: Manual test with ShareTest with varying combinations of text length, title, icon, image shares. Test: atest IntentResolverUnitTests Change-Id: Iea40bd95db5665fb59684bf317bcac5e33b080bb --- java/res/drawable/edit_action_background.xml | 29 ++++ .../res/layout-h480dp/image_preview_image_item.xml | 22 +++ java/res/layout/chooser_grid_preview_text.xml | 54 ++++++-- .../intentresolver/ChooserActionFactory.java | 58 +++----- .../contentpreview/ChooserContentPreviewUi.java | 13 +- .../contentpreview/ContentPreviewUi.java | 16 --- .../contentpreview/FileContentPreviewUi.java | 4 +- .../FilesPlusTextContentPreviewUi.java | 17 +-- .../contentpreview/TextContentPreviewUi.java | 22 ++- .../contentpreview/UnifiedContentPreviewUi.java | 20 +-- .../widget/ScrollableImagePreviewView.kt | 17 ++- .../UnbundledChooserActivityTest.java | 8 +- .../contentpreview/ChooserContentPreviewUiTest.kt | 18 +-- .../contentpreview/ContentPreviewUiTest.kt | 44 +----- .../widget/BatchPreviewLoaderTest.kt | 148 +++++++++------------ 15 files changed, 221 insertions(+), 269 deletions(-) create mode 100644 java/res/drawable/edit_action_background.xml (limited to 'java/tests') diff --git a/java/res/drawable/edit_action_background.xml b/java/res/drawable/edit_action_background.xml new file mode 100644 index 00000000..91726f49 --- /dev/null +++ b/java/res/drawable/edit_action_background.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + diff --git a/java/res/layout-h480dp/image_preview_image_item.xml b/java/res/layout-h480dp/image_preview_image_item.xml index db44c8be..ac63b2d5 100644 --- a/java/res/layout-h480dp/image_preview_image_item.xml +++ b/java/res/layout-h480dp/image_preview_image_item.xml @@ -17,6 +17,7 @@ @@ -52,4 +53,25 @@ android:tint="@android:color/white" android:layout_gravity="top|end" /> + + + + diff --git a/java/res/layout/chooser_grid_preview_text.xml b/java/res/layout/chooser_grid_preview_text.xml index 44163b49..96496a30 100644 --- a/java/res/layout/chooser_grid_preview_text.xml +++ b/java/res/layout/chooser_grid_preview_text.xml @@ -29,7 +29,7 @@ - - + + + + + diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java index f355d9d4..6ec62753 100644 --- a/java/src/com/android/intentresolver/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/ChooserActionFactory.java @@ -84,11 +84,8 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio private static final String IMAGE_EDITOR_SHARED_ELEMENT = "screenshot_preview_image"; private final Context mContext; - private final String mCopyButtonLabel; - private final Drawable mCopyButtonDrawable; - private final Runnable mOnCopyButtonClicked; - private final TargetInfo mEditSharingTarget; - private final Runnable mOnEditButtonClicked; + private final Runnable mCopyButtonRunnable; + private final Runnable mEditButtonRunnable; private final ImmutableList mCustomActions; private final @Nullable ChooserAction mModifyShareAction; private final Consumer mExcludeSharedTextAction; @@ -119,19 +116,13 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio Consumer finishCallback) { this( context, - context.getString(com.android.internal.R.string.copy), - context.getDrawable(com.android.internal.R.drawable.ic_menu_copy_material), - makeOnCopyRunnable( + makeCopyButtonRunnable( context, chooserRequest.getTargetIntent(), chooserRequest.getReferrerPackageName(), finishCallback, logger), - getEditSharingTarget( - context, - chooserRequest.getTargetIntent(), - integratedDeviceComponents), - makeOnEditRunnable( + makeEditButtonRunnable( getEditSharingTarget( context, chooserRequest.getTargetIntent(), @@ -149,22 +140,16 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio @VisibleForTesting ChooserActionFactory( Context context, - String copyButtonLabel, - Drawable copyButtonDrawable, - Runnable onCopyButtonClicked, - TargetInfo editSharingTarget, - Runnable onEditButtonClicked, + Runnable copyButtonRunnable, + Runnable editButtonRunnable, List customActions, @Nullable ChooserAction modifyShareAction, Consumer onUpdateSharedTextIsExcluded, ChooserActivityLogger logger, Consumer finishCallback) { mContext = context; - mCopyButtonLabel = copyButtonLabel; - mCopyButtonDrawable = copyButtonDrawable; - mOnCopyButtonClicked = onCopyButtonClicked; - mEditSharingTarget = editSharingTarget; - mOnEditButtonClicked = onEditButtonClicked; + mCopyButtonRunnable = copyButtonRunnable; + mEditButtonRunnable = editButtonRunnable; mCustomActions = ImmutableList.copyOf(customActions); mModifyShareAction = modifyShareAction; mExcludeSharedTextAction = onUpdateSharedTextIsExcluded; @@ -172,29 +157,16 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio mFinishCallback = finishCallback; } - /** Create an action that copies the share content to the clipboard. */ @Override - public ActionRow.Action createCopyButton() { - return new ActionRow.Action( - com.android.internal.R.id.chooser_copy_button, - mCopyButtonLabel, - mCopyButtonDrawable, - mOnCopyButtonClicked); + @Nullable + public Runnable getEditButtonRunnable() { + return mEditButtonRunnable; } - /** Create an action that opens the share content in a system-default editor. */ @Override @Nullable - public ActionRow.Action createEditButton() { - if (mEditSharingTarget == null) { - return null; - } - - return new ActionRow.Action( - com.android.internal.R.id.chooser_edit_button, - mEditSharingTarget.getDisplayLabel(), - mEditSharingTarget.getDisplayIconHolder().getDisplayIcon(), - mOnEditButtonClicked); + public Runnable getCopyButtonRunnable() { + return mCopyButtonRunnable; } /** Create custom actions */ @@ -247,7 +219,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio return mExcludeSharedTextAction; } - private static Runnable makeOnCopyRunnable( + private static Runnable makeCopyButtonRunnable( Context context, Intent targetIntent, String referrerPackageName, @@ -344,7 +316,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio return dri; } - private static Runnable makeOnEditRunnable( + private static Runnable makeEditButtonRunnable( TargetInfo editSharingTarget, Callable firstVisibleImageQuery, ActionActivityStarter activityStarter, diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index 9100b392..e8367c4e 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -53,12 +53,17 @@ public final class ChooserContentPreviewUi { * 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(); + /** + * @return Runnable to be run when an edit button is clicked (if available). + */ + @Nullable + Runnable getEditButtonRunnable(); - /** Create an action that opens the share content in a system-default editor. */ + /** + * @return Runnable to be run when a copy button is clicked (if available). + */ @Nullable - ActionRow.Action createEditButton(); + Runnable getCopyButtonRunnable(); /** Create custom actions */ List createCustomActions(); diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java index 9699594e..07071236 100644 --- a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java @@ -32,9 +32,6 @@ import com.android.intentresolver.R; import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ScrollableImagePreviewView; -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"; @@ -45,19 +42,6 @@ abstract class ContentPreviewUi { public abstract ViewGroup display( Resources resources, LayoutInflater layoutInflater, ViewGroup parent); - protected static List createActions( - List systemActions, - List customActions) { - ArrayList actions = - new ArrayList<>(systemActions.size() + customActions.size()); - if (customActions.isEmpty()) { - actions.addAll(systemActions); - } else { - actions.addAll(customActions); - } - return actions; - } - protected static void updateViewWithImage(ImageView imageView, Bitmap image) { if (image == null) { imageView.setVisibility(View.GONE); diff --git a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java index 16ff6c23..8d3e62aa 100644 --- a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java @@ -30,7 +30,6 @@ import androidx.annotation.Nullable; import com.android.intentresolver.R; import com.android.intentresolver.widget.ActionRow; -import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -108,8 +107,7 @@ class FileContentPreviewUi extends ContentPreviewUi { final ActionRow actionRow = mContentPreview.findViewById(com.android.internal.R.id.chooser_action_row); - List actions = - createActions(new ArrayList<>(), mActionFactory.createCustomActions()); + List actions = mActionFactory.createCustomActions(); actionRow.setActions(actions); if (actions.isEmpty()) { mContentPreview.findViewById(R.id.actions_top_divider).setVisibility(View.GONE); diff --git a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java index e4e33839..61ca44e0 100644 --- a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java @@ -37,7 +37,6 @@ import com.android.intentresolver.R; import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ScrollableImagePreviewView; -import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.function.Consumer; @@ -123,9 +122,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { final ActionRow actionRow = mContentPreviewView.findViewById(com.android.internal.R.id.chooser_action_row); - List actions = createActions( - createImagePreviewActions(), - mActionFactory.createCustomActions()); + List actions = mActionFactory.createCustomActions(); actionRow.setActions(actions); if (actions.isEmpty()) { @@ -141,18 +138,6 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { return mContentPreviewView; } - private List createImagePreviewActions() { - ArrayList actions = new ArrayList<>(2); - //TODO: add copy action; - if (mIsSingleImage) { - ActionRow.Action action = mActionFactory.createEditButton(); - if (action != null) { - actions.add(action); - } - } - return actions; - } - private void updateUiWithMetadata(ViewGroup contentPreviewView) { prepareTextPreview(contentPreviewView, mActionFactory); updateHeadline(contentPreviewView); diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java index 19fd3bb4..c38ed03a 100644 --- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java @@ -33,9 +33,6 @@ import androidx.lifecycle.Lifecycle; import com.android.intentresolver.R; import com.android.intentresolver.widget.ActionRow; -import java.util.ArrayList; -import java.util.List; - class TextContentPreviewUi extends ContentPreviewUi { private final Lifecycle mLifecycle; @Nullable @@ -85,10 +82,7 @@ class TextContentPreviewUi extends ContentPreviewUi { final ActionRow actionRow = contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); - actionRow.setActions( - createActions( - createTextPreviewActions(), - mActionFactory.createCustomActions())); + actionRow.setActions(mActionFactory.createCustomActions()); if (mSharingText == null) { contentPreviewLayout @@ -129,14 +123,16 @@ class TextContentPreviewUi extends ContentPreviewUi { bitmap)); } + Runnable onCopy = mActionFactory.getCopyButtonRunnable(); + View copyButton = contentPreviewLayout.findViewById(R.id.copy); + if (onCopy != null) { + copyButton.setOnClickListener((v) -> onCopy.run()); + } else { + copyButton.setVisibility(View.GONE); + } + displayHeadline(contentPreviewLayout, mHeadlineGenerator.getTextHeadline(mSharingText)); return contentPreviewLayout; } - - private List createTextPreviewActions() { - ArrayList actions = new ArrayList<>(2); - actions.add(mActionFactory.createCopyButton()); - return actions; - } } diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java index 26f4d007..eb3e8e72 100644 --- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -91,9 +91,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { final ActionRow actionRow = mContentPreviewView.findViewById(com.android.internal.R.id.chooser_action_row); - List actions = createActions( - createImagePreviewActions(), - mActionFactory.createCustomActions()); + List actions = mActionFactory.createCustomActions(); actionRow.setActions(actions); if (actions.isEmpty()) { mContentPreviewView.findViewById(R.id.actions_top_divider).setVisibility(View.GONE); @@ -135,9 +133,11 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { allVideos = allVideos && previewType == ScrollableImagePreviewView.PreviewType.Video; if (fileInfo.getPreviewUri() != null) { + Runnable editAction = + mShowEditAction ? mActionFactory.getEditButtonRunnable() : null; previews.add( new ScrollableImagePreviewView.Preview( - previewType, fileInfo.getPreviewUri())); + previewType, fileInfo.getPreviewUri(), editAction)); } } @@ -151,16 +151,4 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { displayHeadline(contentPreviewView, mHeadlineGenerator.getFilesHeadline(count)); } } - - private List createImagePreviewActions() { - ArrayList actions = new ArrayList<>(1); - //TODO: add copy action; - if (mShowEditAction) { - ActionRow.Action action = mActionFactory.createEditButton(); - if (action != null) { - actions.add(action); - } - } - return actions; - } } diff --git a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt index e761c0aa..9c948bd9 100644 --- a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt +++ b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt @@ -237,9 +237,14 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { internal constructor( val type: PreviewType, val uri: Uri, + val editAction: Runnable?, internal var aspectRatioString: String ) { - constructor(type: PreviewType, uri: Uri) : this(type, uri, "1:1") + constructor( + type: PreviewType, + uri: Uri, + editAction: Runnable? + ) : this(type, uri, editAction, "1:1") } enum class PreviewType { @@ -370,6 +375,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { val image = view.requireViewById(R.id.image) private val badgeFrame = view.requireViewById(R.id.badge_frame) private val badge = view.requireViewById(R.id.badge) + private val editActionContainer = view.findViewById(R.id.edit) private var scope: CoroutineScope? = null fun bind( @@ -404,6 +410,12 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { badgeFrame.visibility = View.VISIBLE } } + preview.editAction?.also { onClick -> + editActionContainer?.apply { + setOnClickListener { onClick.run() } + visibility = View.VISIBLE + } + } resetScope().launch { loadImage(preview, imageLoader) if (preview.type == PreviewType.Image) { @@ -545,7 +557,8 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { bitmap.width, bitmap.height ) - } ?: 0 + } + ?: 0 } .getOrDefault(0) diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index c2212bc2..99564ae3 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -868,8 +868,8 @@ public class UnbundledChooserActivityTest { mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); - onView(withId(com.android.internal.R.id.chooser_copy_button)).check(matches(isDisplayed())); - onView(withId(com.android.internal.R.id.chooser_copy_button)).perform(click()); + onView(withId(R.id.copy)).check(matches(isDisplayed())); + onView(withId(R.id.copy)).perform(click()); ClipboardManager clipboard = (ClipboardManager) activity.getSystemService( Context.CLIPBOARD_SERVICE); ClipData clipData = clipboard.getPrimaryClip(); @@ -892,8 +892,8 @@ public class UnbundledChooserActivityTest { mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); - onView(withId(com.android.internal.R.id.chooser_copy_button)).check(matches(isDisplayed())); - onView(withId(com.android.internal.R.id.chooser_copy_button)).perform(click()); + onView(withId(R.id.copy)).check(matches(isDisplayed())); + onView(withId(R.id.copy)).perform(click()); ChooserActivityLogger logger = activity.getChooserActivityLogger(); verify(logger, times(1)).logActionSelected(eq(ChooserActivityLogger.SELECTION_TYPE_COPY)); diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt index c62f36ce..9bfd2052 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt @@ -51,8 +51,8 @@ class ChooserContentPreviewUiTest { } private val actionFactory = object : ActionFactory { - override fun createCopyButton() = ActionRow.Action(label = "Copy", icon = null) {} - override fun createEditButton(): ActionRow.Action? = null + override fun getCopyButtonRunnable(): Runnable? = null + override fun getEditButtonRunnable(): Runnable? = null override fun createCustomActions(): List = emptyList() override fun getModifyShareAction(): ActionRow.Action? = null override fun getExcludeSharedTextAction(): Consumer = Consumer {} @@ -103,12 +103,7 @@ class ChooserContentPreviewUiTest { whenever(previewData.previewType).thenReturn(ContentPreviewType.CONTENT_PREVIEW_IMAGE) whenever(previewData.uriCount).thenReturn(2) whenever(previewData.firstFileInfo) - .thenReturn( - FileInfo.Builder(uri) - .withPreviewUri(uri) - .withMimeType("image/png") - .build() - ) + .thenReturn(FileInfo.Builder(uri).withPreviewUri(uri).withMimeType("image/png").build()) val testSubject = ChooserContentPreviewUi( lifecycle, @@ -131,12 +126,7 @@ class ChooserContentPreviewUiTest { whenever(previewData.previewType).thenReturn(ContentPreviewType.CONTENT_PREVIEW_IMAGE) whenever(previewData.uriCount).thenReturn(2) whenever(previewData.firstFileInfo) - .thenReturn( - FileInfo.Builder(uri) - .withPreviewUri(uri) - .withMimeType("image/png") - .build() - ) + .thenReturn(FileInfo.Builder(uri).withPreviewUri(uri).withMimeType("image/png").build()) val testSubject = ChooserContentPreviewUi( lifecycle, diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ContentPreviewUiTest.kt index 6c30fc9e..6db53a9e 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/ContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/ContentPreviewUiTest.kt @@ -16,41 +16,18 @@ package com.android.intentresolver.contentpreview -import android.content.res.Resources -import android.view.LayoutInflater -import android.view.ViewGroup -import com.android.intentresolver.widget.ActionRow import com.android.intentresolver.widget.ScrollableImagePreviewView.PreviewType import com.google.common.truth.Truth.assertThat import org.junit.Test class ContentPreviewUiTest { - private class TestablePreview() : ContentPreviewUi() { - override fun getType() = 0 - - override fun display( - resources: Resources?, - layoutInflater: LayoutInflater?, - parent: ViewGroup? - ): ViewGroup { - throw IllegalStateException() - } - - // exposing for testing - fun makeActions( - system: List, - custom: List - ): List { - return createActions(system, custom) - } - } - @Test fun testPreviewTypes() { - val typeClassifier = object : MimeTypeClassifier { - override fun isImageType(type: String?) = (type == "image") - override fun isVideoType(type: String?) = (type == "video") - } + val typeClassifier = + object : MimeTypeClassifier { + override fun isImageType(type: String?) = (type == "image") + override fun isVideoType(type: String?) = (type == "video") + } assertThat(ContentPreviewUi.getPreviewType(typeClassifier, "image")) .isEqualTo(PreviewType.Image) @@ -61,15 +38,4 @@ class ContentPreviewUiTest { assertThat(ContentPreviewUi.getPreviewType(typeClassifier, null)) .isEqualTo(PreviewType.File) } - - @Test - fun testCreateActions() { - val preview = TestablePreview() - - val system = listOf(ActionRow.Action(label="system", icon=null) {}) - val custom = listOf(ActionRow.Action(label="custom", icon=null) {}) - - assertThat(preview.makeActions(system, custom)).isEqualTo(custom) - assertThat(preview.makeActions(system, listOf())).isEqualTo(system) - } } diff --git a/java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt b/java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt index c1d7451f..e65cba5f 100644 --- a/java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt +++ b/java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt @@ -18,29 +18,29 @@ package com.android.intentresolver.widget import android.graphics.Bitmap import android.net.Uri +import com.android.intentresolver.captureMany +import com.android.intentresolver.mock import com.android.intentresolver.widget.ScrollableImagePreviewView.BatchPreviewLoader import com.android.intentresolver.widget.ScrollableImagePreviewView.Preview import com.android.intentresolver.widget.ScrollableImagePreviewView.PreviewType +import com.android.intentresolver.withArgCaptor +import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.cancel import kotlinx.coroutines.test.UnconfinedTestDispatcher -import org.junit.Test -import com.android.intentresolver.mock -import com.android.intentresolver.captureMany -import com.android.intentresolver.withArgCaptor -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Before +import org.junit.Test import org.mockito.Mockito.atLeast import org.mockito.Mockito.times import org.mockito.Mockito.verify -import com.google.common.truth.Truth.assertThat @OptIn(ExperimentalCoroutinesApi::class) class BatchPreviewLoaderTest { @@ -67,22 +67,21 @@ class BatchPreviewLoaderTest { val uriOne = createUri(1) val uriTwo = createUri(2) imageLoader.setUriLoadingOrder(succeed(uriTwo), succeed(uriOne)) - val testSubject = BatchPreviewLoader( - imageLoader, - previews(uriOne, uriTwo), - 0, - onReset, - onUpdate, - onCompletion - ) + val testSubject = + BatchPreviewLoader( + imageLoader, + previews(uriOne, uriTwo), + 0, + onReset, + onUpdate, + onCompletion + ) testSubject.loadAspectRatios(200) { _, _, _ -> 100 } dispatcher.scheduler.advanceUntilIdle() verify(onCompletion, times(1)).invoke() verify(onReset, times(1)).invoke(2) - val list = withArgCaptor { - verify(onUpdate, times(1)).invoke(capture()) - }.map { it.uri } + val list = withArgCaptor { verify(onUpdate, times(1)).invoke(capture()) }.map { it.uri } assertThat(list).containsExactly(uriOne, uriTwo).inOrder() } @@ -93,22 +92,21 @@ class BatchPreviewLoaderTest { val uriTwo = createUri(2) val uriThree = createUri(3) imageLoader.setUriLoadingOrder(succeed(uriThree), fail(uriTwo), succeed(uriOne)) - val testSubject = BatchPreviewLoader( - imageLoader, - previews(uriOne, uriTwo, uriThree), - 0, - onReset, - onUpdate, - onCompletion - ) + val testSubject = + BatchPreviewLoader( + imageLoader, + previews(uriOne, uriTwo, uriThree), + 0, + onReset, + onUpdate, + onCompletion + ) testSubject.loadAspectRatios(200) { _, _, _ -> 100 } dispatcher.scheduler.advanceUntilIdle() verify(onCompletion, times(1)).invoke() verify(onReset, times(1)).invoke(3) - val list = withArgCaptor { - verify(onUpdate, times(1)).invoke(capture()) - }.map { it.uri } + val list = withArgCaptor { verify(onUpdate, times(1)).invoke(capture()) }.map { it.uri } assertThat(list).containsExactly(uriOne, uriThree).inOrder() } @@ -116,35 +114,28 @@ class BatchPreviewLoaderTest { fun test_imagesLoadedNotInOrder_updatedInOrder() { val imageLoader = TestImageLoader(testScope) val uris = Array(10) { createUri(it) } - val loadingOrder = Array(uris.size) { i -> - val uriIdx = when { - i % 2 == 1 -> i - 1 - i % 2 == 0 && i < uris.size - 1 -> i + 1 - else -> i + val loadingOrder = + Array(uris.size) { i -> + val uriIdx = + when { + i % 2 == 1 -> i - 1 + i % 2 == 0 && i < uris.size - 1 -> i + 1 + else -> i + } + succeed(uris[uriIdx]) } - succeed(uris[uriIdx]) - } imageLoader.setUriLoadingOrder(*loadingOrder) - val testSubject = BatchPreviewLoader( - imageLoader, - previews(*uris), - 0, - onReset, - onUpdate, - onCompletion - ) + val testSubject = + BatchPreviewLoader(imageLoader, previews(*uris), 0, onReset, onUpdate, onCompletion) testSubject.loadAspectRatios(200) { _, _, _ -> 100 } dispatcher.scheduler.advanceUntilIdle() verify(onCompletion, times(1)).invoke() verify(onReset, times(1)).invoke(uris.size) - val list = captureMany { - verify(onUpdate, atLeast(1)).invoke(capture()) - }.fold(ArrayList()) { acc, update -> - acc.apply { - addAll(update) - } - }.map { it.uri } + val list = + captureMany { verify(onUpdate, atLeast(1)).invoke(capture()) } + .fold(ArrayList()) { acc, update -> acc.apply { addAll(update) } } + .map { it.uri } assertThat(list).containsExactly(*uris).inOrder() } @@ -152,36 +143,29 @@ class BatchPreviewLoaderTest { fun test_imagesLoadedNotInOrderSomeFailed_updatedInOrder() { val imageLoader = TestImageLoader(testScope) val uris = Array(10) { createUri(it) } - val loadingOrder = Array(uris.size) { i -> - val uriIdx = when { - i % 2 == 1 -> i - 1 - i % 2 == 0 && i < uris.size - 1 -> i + 1 - else -> i + val loadingOrder = + Array(uris.size) { i -> + val uriIdx = + when { + i % 2 == 1 -> i - 1 + i % 2 == 0 && i < uris.size - 1 -> i + 1 + else -> i + } + if (uriIdx % 2 == 0) fail(uris[uriIdx]) else succeed(uris[uriIdx]) } - if (uriIdx % 2 == 0) fail(uris[uriIdx]) else succeed(uris[uriIdx]) - } val expectedUris = Array(uris.size / 2) { createUri(it * 2 + 1) } imageLoader.setUriLoadingOrder(*loadingOrder) - val testSubject = BatchPreviewLoader( - imageLoader, - previews(*uris), - 0, - onReset, - onUpdate, - onCompletion - ) + val testSubject = + BatchPreviewLoader(imageLoader, previews(*uris), 0, onReset, onUpdate, onCompletion) testSubject.loadAspectRatios(200) { _, _, _ -> 100 } dispatcher.scheduler.advanceUntilIdle() verify(onCompletion, times(1)).invoke() verify(onReset, times(1)).invoke(uris.size) - val list = captureMany { - verify(onUpdate, atLeast(1)).invoke(capture()) - }.fold(ArrayList()) { acc, update -> - acc.apply { - addAll(update) - } - }.map { it.uri } + val list = + captureMany { verify(onUpdate, atLeast(1)).invoke(capture()) } + .fold(ArrayList()) { acc, update -> acc.apply { addAll(update) } } + .map { it.uri } assertThat(list).containsExactly(*expectedUris).inOrder() } @@ -191,21 +175,15 @@ class BatchPreviewLoaderTest { private fun succeed(uri: Uri) = uri to true private fun previews(vararg uris: Uri) = uris.fold(ArrayList(uris.size)) { acc, uri -> - acc.apply { - add(Preview(PreviewType.Image, uri)) - } + acc.apply { add(Preview(PreviewType.Image, uri, editAction = null)) } } } -private class TestImageLoader( - scope: CoroutineScope -) : suspend (Uri, Boolean) -> Bitmap? { +private class TestImageLoader(scope: CoroutineScope) : suspend (Uri, Boolean) -> Bitmap? { private val loadingOrder = ArrayDeque>() private val pendingRequests = LinkedHashMap>() private val flow = MutableSharedFlow(replay = 1) - private val bitmap by lazy { - Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) - } + private val bitmap by lazy { Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) } init { scope.launch { @@ -217,9 +195,7 @@ private class TestImageLoader( deferred.complete(if (isLoaded) bitmap else null) } if (loadingOrder.isEmpty()) { - pendingRequests.forEach { (uri, deferred) -> - deferred.complete(bitmap) - } + pendingRequests.forEach { (uri, deferred) -> deferred.complete(bitmap) } pendingRequests.clear() } } -- cgit v1.2.3-59-g8ed1b From c86df12077c3948cf857d3e2087ca93bf2d256af Mon Sep 17 00:00:00 2001 From: 1 Date: Thu, 1 Jun 2023 14:11:59 +0000 Subject: Hide text toggle check We may use it in the future, but hiding it for UDC based upon product/UX feedback. Bug: 283245199 Bug: 285309527 Test: atest IntentResolverUnitTests Change-Id: Ife0911200191adc05ddeb0f5c62b7f3ca58fa961 --- .../contentpreview/FilesPlusTextContentPreviewUi.java | 6 +++++- .../com/android/intentresolver/UnbundledChooserActivityTest.java | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) (limited to 'java/tests') diff --git a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java index e4677d90..35990990 100644 --- a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java @@ -62,6 +62,8 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { private Uri mFirstFilePreviewUri; private boolean mAllImages; private boolean mAllVideos; + // TODO(b/285309527): make this a flag + private static final boolean SHOW_TOGGLE_CHECKMARK = false; FilesPlusTextContentPreviewUi( Lifecycle lifecycle, @@ -201,7 +203,9 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { shareTextAction.accept(!isChecked); updateHeadline(contentPreview); }); - includeText.setVisibility(View.VISIBLE); + if (SHOW_TOGGLE_CHECKMARK) { + includeText.setVisibility(View.VISIBLE); + } } private String getNoTextString(Resources resources) { diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 99564ae3..3ddd4394 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -694,6 +694,7 @@ public class UnbundledChooserActivityTest { } @Test + @Ignore("b/285309527") public void testFilePlusTextSharing_ExcludeText() { Uri uri = createTestContentProviderUri(null, "image/png"); Intent sendIntent = createSendImageIntent(uri); @@ -735,6 +736,7 @@ public class UnbundledChooserActivityTest { } @Test + @Ignore("b/285309527") public void testFilePlusTextSharing_RemoveAndAddBackText() { Uri uri = createTestContentProviderUri("application/pdf", "image/png"); Intent sendIntent = createSendImageIntent(uri); @@ -782,6 +784,7 @@ public class UnbundledChooserActivityTest { } @Test + @Ignore("b/285309527") public void testFilePlusTextSharing_TextExclusionDoesNotAffectAlternativeIntent() { Uri uri = createTestContentProviderUri("image/png", null); Intent sendIntent = createSendImageIntent(uri); @@ -825,6 +828,7 @@ public class UnbundledChooserActivityTest { } @Test + @Ignore("b/285309527") public void testImagePlusTextSharing_failedThumbnailAndExcludedText_textChanges() { Uri uri = createTestContentProviderUri("image/png", null); Intent sendIntent = createSendImageIntent(uri); -- cgit v1.2.3-59-g8ed1b From 8764dfb8c1720b6bc1f8e2fbe400477cb125b9af Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Thu, 1 Jun 2023 15:19:26 -0700 Subject: Always use file or image preview UI when sending files When sending files, always use the file preivew UI (or the image preview UI, when applicable) regardless of the intent type. Fix: 285174069 Test: manual testing, unit tests Change-Id: I6f27839427b58a09ffb397ba56c2ce8401380a21 --- .../intentresolver/contentpreview/MimeTypeClassifier.java | 7 ------- .../intentresolver/contentpreview/PreviewDataProvider.kt | 6 +----- .../intentresolver/contentpreview/PreviewDataProviderTest.kt | 11 ++++++++--- 3 files changed, 9 insertions(+), 15 deletions(-) (limited to 'java/tests') diff --git a/java/src/com/android/intentresolver/contentpreview/MimeTypeClassifier.java b/java/src/com/android/intentresolver/contentpreview/MimeTypeClassifier.java index c86b0fe2..0c333b68 100644 --- a/java/src/com/android/intentresolver/contentpreview/MimeTypeClassifier.java +++ b/java/src/com/android/intentresolver/contentpreview/MimeTypeClassifier.java @@ -33,11 +33,4 @@ public interface MimeTypeClassifier { default boolean isVideoType(@Nullable String mimeType) { return (mimeType != null) && ClipDescription.compareMimeTypes(mimeType, "video/*"); } - - /** - * @return whether the specified {@code mimeType} is classified as "text" type - */ - default boolean isTextType(@Nullable String mimeType) { - return (mimeType != null) && ClipDescription.compareMimeTypes(mimeType, "text/*"); - } } diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt index ae705369..8ab3a272 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt @@ -104,11 +104,7 @@ constructor( * that broadly covers all data being shared, such as '*' when sending an image * and text. We therefore should inspect each item for the preferred type, in order: * IMAGE, FILE, TEXT. */ - if ( - !targetIntent.isSend || - typeClassifier.isTextType(targetIntent.type) || - records.isEmpty() - ) { + if (!targetIntent.isSend || records.isEmpty()) { CONTENT_PREVIEW_TEXT } else { runBlocking(dispatcher) { diff --git a/java/tests/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt index 2c47efa5..145b89ad 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt @@ -71,16 +71,21 @@ class PreviewDataProviderTest { } @Test - fun test_sendIntentWithTextMimeType_resolvesToTextPreviewUiSynchronously() { + fun test_sendSingleTextFileWithoutPreview_resolvesToFilePreviewUi() { + val uri = Uri.parse("content://org.pkg.app/notes.txt") val targetIntent = Intent(Intent.ACTION_SEND) .apply { + putExtra(Intent.EXTRA_STREAM, uri) type = "text/plain" } + whenever(contentResolver.getType(uri)).thenReturn("text/plain") val testSubject = PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) - assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) - verify(contentResolver, never()).getType(any()) + assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) + assertThat(testSubject.uriCount).isEqualTo(1) + assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri) + verify(contentResolver, times(1)).getType(any()) } @Test -- cgit v1.2.3-59-g8ed1b