From c5657c5eae0c049c99753a18ad38acd0f15a6f9c Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Mon, 11 Dec 2023 06:57:52 -0800 Subject: Use a bespoke label view Create a bespoke label view that centers text more precisely in the presense of a end-side badge. The view is used under a flag. One unflagged trivial refactoring was made: some of the ResolverListAdapter$ViewHolder's methods that were only used inside ChooserListAdapter were moved there for a better cohesion; the reset method is split similarly. Bug: 302188527 Test: The view is tested in both Chooser and a stand-alone test app Flag: ACONFIG com.android.intentresolver.bespoke_label_view DEVELOPMENT Change-Id: Iec871afcdc634f2ce50a4b31a8cc34b88ebb496c --- .../android/intentresolver/ChooserActivity.java | 3 +- .../android/intentresolver/ChooserListAdapter.java | 73 ++++++++++++++---- .../intentresolver/ResolverListAdapter.java | 23 +----- .../android/intentresolver/v2/ChooserActivity.java | 3 +- .../android/intentresolver/widget/BadgeTextView.kt | 88 ++++++++++++++++++++++ 5 files changed, 153 insertions(+), 37 deletions(-) create mode 100644 java/src/com/android/intentresolver/widget/BadgeTextView.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 9000ab3a..fc011aef 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -1252,7 +1252,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements maxTargetsPerRow, initialIntentsUserSpace, targetDataLoader, - null); + null, + mFeatureFlags); } @Override diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 876ad5c3..94a89722 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -54,6 +54,7 @@ import com.android.intentresolver.chooser.SelectableTargetInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.icons.TargetDataLoader; import com.android.intentresolver.logging.EventLog; +import com.android.intentresolver.widget.BadgeTextView; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; @@ -109,6 +110,7 @@ public class ChooserListAdapter extends ResolverListAdapter { // Reserve spots for incoming direct share targets by adding placeholders private final TargetInfo mPlaceHolderTargetInfo; private final TargetDataLoader mTargetDataLoader; + private final boolean mUseBadgeTextViewForLabels; private final List mServiceTargets = new ArrayList<>(); private final List mCallerTargets = new ArrayList<>(); @@ -166,7 +168,8 @@ public class ChooserListAdapter extends ResolverListAdapter { int maxRankedTargets, UserHandle initialIntentsUserSpace, TargetDataLoader targetDataLoader, - @Nullable PackageChangeCallback packageChangeCallback) { + @Nullable PackageChangeCallback packageChangeCallback, + FeatureFlags featureFlags) { this( context, payloadIntents, @@ -185,7 +188,8 @@ public class ChooserListAdapter extends ResolverListAdapter { targetDataLoader, packageChangeCallback, AsyncTask.SERIAL_EXECUTOR, - context.getMainExecutor()); + context.getMainExecutor(), + featureFlags); } @VisibleForTesting @@ -207,7 +211,8 @@ public class ChooserListAdapter extends ResolverListAdapter { TargetDataLoader targetDataLoader, @Nullable PackageChangeCallback packageChangeCallback, Executor bgExecutor, - Executor mainExecutor) { + Executor mainExecutor, + FeatureFlags featureFlags) { // Don't send the initial intents through the shared ResolverActivity path, // we want to separate them into a different section. super( @@ -231,6 +236,7 @@ public class ChooserListAdapter extends ResolverListAdapter { mPlaceHolderTargetInfo = NotSelectableTargetInfo.newPlaceHolderTargetInfo(context); mTargetDataLoader = targetDataLoader; mPackageChangeCallback = packageChangeCallback; + mUseBadgeTextViewForLabels = featureFlags.bespokeLabelView(); createPlaceHolders(); mEventLog = eventLog; mShortcutSelectionLogic = new ShortcutSelectionLogic( @@ -332,7 +338,12 @@ public class ChooserListAdapter extends ResolverListAdapter { @Override View onCreateView(ViewGroup parent) { - return mInflater.inflate(R.layout.resolve_grid_item, parent, false); + return mInflater.inflate( + mUseBadgeTextViewForLabels + ? R.layout.chooser_grid_item + : R.layout.resolve_grid_item, + parent, + false); } @VisibleForTesting @@ -340,7 +351,7 @@ public class ChooserListAdapter extends ResolverListAdapter { public void onBindView(View view, TargetInfo info, int position) { final ViewHolder holder = (ViewHolder) view.getTag(); - holder.reset(); + resetViewHolder(holder); // Always remove the spacing listener, attach as needed to direct share targets below. holder.text.removeOnLayoutChangeListener(mPinTextSpacingListener); @@ -377,16 +388,18 @@ public class ChooserListAdapter extends ResolverListAdapter { contentDescription, mContext.getResources().getString(R.string.pinned)); } - holder.updateContentDescription(contentDescription); + updateContentDescription(holder, contentDescription); if (!info.hasDisplayIcon()) { loadDirectShareIcon((SelectableTargetInfo) info); } } else if (info.isDisplayResolveInfo()) { if (info.isPinned()) { - holder.updateContentDescription(String.join( - ". ", - info.getDisplayLabel(), - mContext.getResources().getString(R.string.pinned))); + updateContentDescription( + holder, + String.join( + ". ", + info.getDisplayLabel(), + mContext.getResources().getString(R.string.pinned))); } DisplayResolveInfo dri = (DisplayResolveInfo) info; if (!dri.hasDisplayIcon()) { @@ -398,22 +411,56 @@ public class ChooserListAdapter extends ResolverListAdapter { } if (info.isPlaceHolderTargetInfo()) { - holder.bindPlaceholder(); + bindPlaceholder(holder); } if (info.isMultiDisplayResolveInfo()) { // If the target is grouped show an indicator - holder.bindGroupIndicator( + bindGroupIndicator( + holder, mContext.getDrawable(R.drawable.chooser_group_background)); } else if (info.isPinned() && (getPositionTargetType(position) == TARGET_STANDARD || getPositionTargetType(position) == TARGET_SERVICE)) { // If the appShare or directShare target is pinned and in the suggested row show a // pinned indicator - holder.bindPinnedIndicator(mContext.getDrawable(R.drawable.chooser_pinned_background)); + bindPinnedIndicator(holder, mContext.getDrawable(R.drawable.chooser_pinned_background)); holder.text.addOnLayoutChangeListener(mPinTextSpacingListener); } } + private void resetViewHolder(ViewHolder holder) { + holder.reset(); + holder.itemView.setBackground(holder.defaultItemViewBackground); + + if (mUseBadgeTextViewForLabels) { + ((BadgeTextView) holder.text).setBadgeDrawable(null); + } + holder.text.setBackground(null); + holder.text.setPaddingRelative(0, 0, 0, 0); + } + + private void updateContentDescription(ViewHolder holder, String description) { + holder.itemView.setContentDescription(description); + } + + private void bindPlaceholder(ViewHolder holder) { + holder.itemView.setBackground(null); + } + + private void bindGroupIndicator(ViewHolder holder, Drawable indicator) { + if (mUseBadgeTextViewForLabels) { + ((BadgeTextView) holder.text).setBadgeDrawable(indicator); + } else { + holder.text.setPaddingRelative(0, 0, /*end = */indicator.getIntrinsicWidth(), 0); + holder.text.setBackground(indicator); + } + } + + private void bindPinnedIndicator(ViewHolder holder, Drawable indicator) { + holder.text.setPaddingRelative(/*start = */indicator.getIntrinsicWidth(), 0, 0, 0); + holder.text.setBackground(indicator); + } + private void loadDirectShareIcon(SelectableTargetInfo info) { if (mRequestedIcons.add(info)) { mTargetDataLoader.loadDirectShareIcon( diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index 564d8d19..262d180a 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -25,7 +25,6 @@ import android.content.pm.ResolveInfo; import android.graphics.ColorMatrix; import android.graphics.ColorMatrixColorFilter; import android.graphics.drawable.Drawable; -import android.net.Uri; import android.os.AsyncTask; import android.os.RemoteException; import android.os.Trace; @@ -930,7 +929,7 @@ public class ResolverListAdapter extends BaseAdapter { @VisibleForTesting public static class ViewHolder { public View itemView; - public Drawable defaultItemViewBackground; + public final Drawable defaultItemViewBackground; public TextView text; public TextView text2; @@ -940,8 +939,6 @@ public class ResolverListAdapter extends BaseAdapter { text.setText(""); text.setMaxLines(2); text.setMaxWidth(Integer.MAX_VALUE); - text.setBackground(null); - text.setPaddingRelative(0, 0, 0, 0); text2.setVisibility(View.GONE); text2.setText(""); @@ -982,10 +979,6 @@ public class ResolverListAdapter extends BaseAdapter { itemView.setContentDescription(null); } - public void updateContentDescription(String description) { - itemView.setContentDescription(description); - } - /** * Bind view holder to a TargetInfo. */ @@ -998,19 +991,5 @@ public class ResolverListAdapter extends BaseAdapter { icon.setColorFilter(null); } } - - public void bindPlaceholder() { - itemView.setBackground(null); - } - - public void bindGroupIndicator(Drawable indicator) { - text.setPaddingRelative(0, 0, /*end = */indicator.getIntrinsicWidth(), 0); - text.setBackground(indicator); - } - - public void bindPinnedIndicator(Drawable indicator) { - text.setPaddingRelative(/*start = */indicator.getIntrinsicWidth(), 0, 0, 0); - text.setBackground(indicator); - } } } diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 70812642..d3c97075 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -1263,7 +1263,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (record != null && record.shortcutLoader != null) { record.shortcutLoader.reset(); } - }); + }, + mFeatureFlags); } @Override diff --git a/java/src/com/android/intentresolver/widget/BadgeTextView.kt b/java/src/com/android/intentresolver/widget/BadgeTextView.kt new file mode 100644 index 00000000..b6cadd86 --- /dev/null +++ b/java/src/com/android/intentresolver/widget/BadgeTextView.kt @@ -0,0 +1,88 @@ +package com.android.intentresolver.widget + +import android.content.Context +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.Gravity +import android.widget.TextView + +/** + * A TextView that supports a badge at the end of the text. If the text, when centered in the view, + * leaves enough room for the badge, the badge is just displayed at the end of the view. Otherwise, + * the necessary amount of space for the badge is reserved and the text gets centered in the + * remaining free space. + */ +class BadgeTextView : TextView { + 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) { + super.setGravity(Gravity.CENTER) + defaultPaddingLeft = paddingLeft + defaultPaddingRight = paddingRight + } + + private var defaultPaddingLeft = 0 + private var defaultPaddingRight = 0 + + override fun setPadding(left: Int, top: Int, right: Int, bottom: Int) { + super.setPadding(left, top, right, bottom) + defaultPaddingLeft = paddingLeft + defaultPaddingRight = paddingRight + } + + override fun setPaddingRelative(start: Int, top: Int, end: Int, bottom: Int) { + super.setPaddingRelative(start, top, end, bottom) + defaultPaddingLeft = paddingLeft + defaultPaddingRight = paddingRight + } + + /** Sets end-sided badge. */ + var badgeDrawable: Drawable? = null + set(value) { + if (field !== value) { + field = value + super.setBackground(value) + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.setPadding(defaultPaddingLeft, paddingTop, defaultPaddingRight, paddingBottom) + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + val badge = badgeDrawable ?: return + if (badge.intrinsicWidth <= paddingEnd) return + var maxLineWidth = 0f + for (i in 0 until layout.lineCount) { + maxLineWidth = maxOf(maxLineWidth, layout.getLineWidth(i)) + } + val sideSpace = (measuredWidth - maxLineWidth) / 2 + if (sideSpace < badge.intrinsicWidth) { + super.setPaddingRelative( + paddingStart, + paddingTop, + paddingEnd + badge.intrinsicWidth - sideSpace.toInt(), + paddingBottom + ) + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } + } + + override fun setBackground(background: Drawable?) { + badgeDrawable = null + super.setBackground(background) + } + + override fun setGravity(gravity: Int): Unit = error("Not supported") +} -- cgit v1.2.3-59-g8ed1b From cf71ac0e1ba5b4ac3e11183c9a016439bf539b70 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Wed, 15 Nov 2023 15:27:11 -0800 Subject: MultiDisplayResolveItem to return a selected resolved component name. Fix: 309169275 Test: atest com.android.intentresolver.chooser.TargetInfoTest Test: confirm the correct behavior with an injected debug logging Change-Id: Idce3fedb3fbed130132b985479152f8f7e641dae --- .../chooser/MultiDisplayResolveInfo.java | 15 + .../intentresolver/chooser/TargetInfoTest.kt | 430 +++++++++++---------- 2 files changed, 251 insertions(+), 194 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java index b97e6b45..4fe28384 100644 --- a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java @@ -17,9 +17,11 @@ package com.android.intentresolver.chooser; import android.app.Activity; +import android.content.ComponentName; import android.content.Intent; import android.os.Bundle; import android.os.UserHandle; +import android.util.Log; import androidx.annotation.Nullable; @@ -120,6 +122,19 @@ public class MultiDisplayResolveInfo extends DisplayResolveInfo { return mTargetInfos.get(mSelected).startAsCaller(activity, options, userId); } + @Override + public ComponentName getResolvedComponentName() { + if (hasSelected()) { + return mTargetInfos.get(mSelected).getResolvedComponentName(); + } + // It is not expected to have this method be called on an unselected multi-display item. + // Call super to preserve the legacy (most likely erroneous) behavior. + Log.wtf( + "ChooserActivity", + "retrieving ResolvedComponentName from an unselected MultiDisplayResolveInfo"); + return super.getResolvedComponentName(); + } + @Override public boolean startAsUser(Activity activity, Bundle options, UserHandle user) { return mTargetInfos.get(mSelected).startAsUser(activity, options, user); diff --git a/tests/unit/src/com/android/intentresolver/chooser/TargetInfoTest.kt b/tests/unit/src/com/android/intentresolver/chooser/TargetInfoTest.kt index a7574c12..b346bee5 100644 --- a/tests/unit/src/com/android/intentresolver/chooser/TargetInfoTest.kt +++ b/tests/unit/src/com/android/intentresolver/chooser/TargetInfoTest.kt @@ -24,9 +24,10 @@ import android.content.pm.ActivityInfo import android.content.pm.ResolveInfo import android.graphics.drawable.AnimatedVectorDrawable import android.os.UserHandle -import android.test.UiThreadTest +import androidx.test.annotation.UiThreadTest import androidx.test.platform.app.InstrumentationRegistry import com.android.intentresolver.ResolverDataProvider +import com.android.intentresolver.ResolverDataProvider.createResolveInfo import com.android.intentresolver.createChooserTarget import com.android.intentresolver.createShortcutInfo import com.android.intentresolver.mock @@ -41,38 +42,37 @@ import org.mockito.Mockito.times import org.mockito.Mockito.verify class TargetInfoTest { - private val PERSONAL_USER_HANDLE: UserHandle = InstrumentationRegistry - .getInstrumentation().getTargetContext().getUser() + private val PERSONAL_USER_HANDLE: UserHandle = + InstrumentationRegistry.getInstrumentation().getTargetContext().getUser() private val context = InstrumentationRegistry.getInstrumentation().getContext() @Before fun setup() { // SelectableTargetInfo reads DeviceConfig and needs a permission for that. - InstrumentationRegistry - .getInstrumentation() - .getUiAutomation() + InstrumentationRegistry.getInstrumentation() + .uiAutomation .adoptShellPermissionIdentity("android.permission.READ_DEVICE_CONFIG") } @Test fun testNewEmptyTargetInfo() { val info = NotSelectableTargetInfo.newEmptyTargetInfo() - assertThat(info.isEmptyTargetInfo()).isTrue() - assertThat(info.isChooserTargetInfo()).isTrue() // From legacy inheritance model. + assertThat(info.isEmptyTargetInfo).isTrue() + assertThat(info.isChooserTargetInfo).isTrue() // From legacy inheritance model. assertThat(info.hasDisplayIcon()).isFalse() assertThat(info.getDisplayIconHolder().getDisplayIcon()).isNull() } - @UiThreadTest // AnimatedVectorDrawable needs to start from a thread with a Looper. + @UiThreadTest // AnimatedVectorDrawable needs to start from a thread with a Looper. @Test fun testNewPlaceholderTargetInfo() { val info = NotSelectableTargetInfo.newPlaceHolderTargetInfo(context) assertThat(info.isPlaceHolderTargetInfo).isTrue() - assertThat(info.isChooserTargetInfo).isTrue() // From legacy inheritance model. + assertThat(info.isChooserTargetInfo).isTrue() // From legacy inheritance model. assertThat(info.hasDisplayIcon()).isTrue() assertThat(info.displayIconHolder.displayIcon) - .isInstanceOf(AnimatedVectorDrawable::class.java) + .isInstanceOf(AnimatedVectorDrawable::class.java) // TODO: assert that the animation is pre-started/running (IIUC this requires synchronizing // with some "render thread" per the `AnimatedVectorDrawable` docs). I believe this is // possible using `AnimatorTestRule` but I couldn't find any sample usage in Kotlin nor get @@ -82,34 +82,43 @@ class TargetInfoTest { @Test fun testNewSelectableTargetInfo() { val resolvedIntent = Intent() - val baseDisplayInfo = DisplayResolveInfo.newDisplayResolveInfo( - resolvedIntent, - ResolverDataProvider.createResolveInfo(1, 0, PERSONAL_USER_HANDLE), - "label", - "extended info", - resolvedIntent - ) - val chooserTarget = createChooserTarget( - "title", 0.3f, ResolverDataProvider.createComponentName(2), "test_shortcut_id") + val baseDisplayInfo = + DisplayResolveInfo.newDisplayResolveInfo( + resolvedIntent, + createResolveInfo(1, 0, PERSONAL_USER_HANDLE), + "label", + "extended info", + resolvedIntent + ) + val chooserTarget = + createChooserTarget( + "title", + 0.3f, + ResolverDataProvider.createComponentName(2), + "test_shortcut_id" + ) val shortcutInfo = createShortcutInfo("id", ResolverDataProvider.createComponentName(3), 3) - val appTarget = AppTarget( - AppTargetId("id"), - chooserTarget.componentName.packageName, - chooserTarget.componentName.className, - UserHandle.CURRENT) - - val targetInfo = SelectableTargetInfo.newSelectableTargetInfo( - baseDisplayInfo, - mock(), - resolvedIntent, - chooserTarget, - 0.1f, - shortcutInfo, - appTarget, - mock(), - ) + val appTarget = + AppTarget( + AppTargetId("id"), + chooserTarget.componentName.packageName, + chooserTarget.componentName.className, + UserHandle.CURRENT + ) + + val targetInfo = + SelectableTargetInfo.newSelectableTargetInfo( + baseDisplayInfo, + mock(), + resolvedIntent, + chooserTarget, + 0.1f, + shortcutInfo, + appTarget, + mock(), + ) assertThat(targetInfo.isSelectableTargetInfo).isTrue() - assertThat(targetInfo.isChooserTargetInfo).isTrue() // From legacy inheritance model. + assertThat(targetInfo.isChooserTargetInfo).isTrue() // From legacy inheritance model. assertThat(targetInfo.displayResolveInfo).isSameInstanceAs(baseDisplayInfo) assertThat(targetInfo.chooserTargetComponentName).isEqualTo(chooserTarget.componentName) assertThat(targetInfo.directShareShortcutId).isEqualTo(shortcutInfo.id) @@ -121,33 +130,43 @@ class TargetInfoTest { @Test fun test_SelectableTargetInfo_componentName_no_source_info() { - val chooserTarget = createChooserTarget( - "title", 0.3f, ResolverDataProvider.createComponentName(1), "test_shortcut_id") + val chooserTarget = + createChooserTarget( + "title", + 0.3f, + ResolverDataProvider.createComponentName(1), + "test_shortcut_id" + ) val shortcutInfo = createShortcutInfo("id", ResolverDataProvider.createComponentName(2), 3) - val appTarget = AppTarget( - AppTargetId("id"), - chooserTarget.componentName.packageName, - chooserTarget.componentName.className, - UserHandle.CURRENT) + val appTarget = + AppTarget( + AppTargetId("id"), + chooserTarget.componentName.packageName, + chooserTarget.componentName.className, + UserHandle.CURRENT + ) val pkgName = "org.package" val className = "MainActivity" - val backupResolveInfo = ResolveInfo().apply { - activityInfo = ActivityInfo().apply { - packageName = pkgName - name = className + val backupResolveInfo = + ResolveInfo().apply { + activityInfo = + ActivityInfo().apply { + packageName = pkgName + name = className + } } - } - - val targetInfo = SelectableTargetInfo.newSelectableTargetInfo( - null, - backupResolveInfo, - mock(), - chooserTarget, - 0.1f, - shortcutInfo, - appTarget, - mock(), - ) + + val targetInfo = + SelectableTargetInfo.newSelectableTargetInfo( + null, + backupResolveInfo, + mock(), + chooserTarget, + 0.1f, + shortcutInfo, + appTarget, + mock(), + ) assertThat(targetInfo.resolvedComponentName).isEqualTo(ComponentName(pkgName, className)) } @@ -156,32 +175,41 @@ class TargetInfoTest { val resolvedIntent = Intent("DONT_REFINE_ME") resolvedIntent.putExtra("resolvedIntent", true) - val baseDisplayInfo = DisplayResolveInfo.newDisplayResolveInfo( - resolvedIntent, - ResolverDataProvider.createResolveInfo(1, 0), - "label", - "extended info", - resolvedIntent - ) - val chooserTarget = createChooserTarget( - "title", 0.3f, ResolverDataProvider.createComponentName(2), "test_shortcut_id") + val baseDisplayInfo = + DisplayResolveInfo.newDisplayResolveInfo( + resolvedIntent, + createResolveInfo(1, 0), + "label", + "extended info", + resolvedIntent + ) + val chooserTarget = + createChooserTarget( + "title", + 0.3f, + ResolverDataProvider.createComponentName(2), + "test_shortcut_id" + ) val shortcutInfo = createShortcutInfo("id", ResolverDataProvider.createComponentName(3), 3) - val appTarget = AppTarget( - AppTargetId("id"), - chooserTarget.componentName.packageName, - chooserTarget.componentName.className, - UserHandle.CURRENT) - - val targetInfo = SelectableTargetInfo.newSelectableTargetInfo( - baseDisplayInfo, - mock(), - resolvedIntent, - chooserTarget, - 0.1f, - shortcutInfo, - appTarget, - mock(), - ) + val appTarget = + AppTarget( + AppTargetId("id"), + chooserTarget.componentName.packageName, + chooserTarget.componentName.className, + UserHandle.CURRENT + ) + + val targetInfo = + SelectableTargetInfo.newSelectableTargetInfo( + baseDisplayInfo, + mock(), + resolvedIntent, + chooserTarget, + 0.1f, + shortcutInfo, + appTarget, + mock(), + ) val refinement = Intent("PROPOSED_REFINEMENT") assertThat(targetInfo.tryToCloneWithAppliedRefinement(refinement)).isNull() @@ -193,18 +221,19 @@ class TargetInfoTest { intent.putExtra(Intent.EXTRA_TEXT, "testing intent sending") intent.setType("text/plain") - val resolveInfo = ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE) - - val targetInfo = DisplayResolveInfo.newDisplayResolveInfo( - intent, - resolveInfo, - "label", - "extended info", - intent - ) - assertThat(targetInfo.isDisplayResolveInfo()).isTrue() - assertThat(targetInfo.isMultiDisplayResolveInfo()).isFalse() - assertThat(targetInfo.isChooserTargetInfo()).isFalse() + val resolveInfo = createResolveInfo(3, 0, PERSONAL_USER_HANDLE) + + val targetInfo = + DisplayResolveInfo.newDisplayResolveInfo( + intent, + resolveInfo, + "label", + "extended info", + intent + ) + assertThat(targetInfo.isDisplayResolveInfo).isTrue() + assertThat(targetInfo.isMultiDisplayResolveInfo).isFalse() + assertThat(targetInfo.isChooserTargetInfo).isFalse() } @Test @@ -218,31 +247,30 @@ class TargetInfoTest { val extraMatch = Intent("REFINE_ME") extraMatch.putExtra("extraMatch", true) - val originalInfo = DisplayResolveInfo.newDisplayResolveInfo( - originalIntent, - ResolverDataProvider.createResolveInfo(3, 0), - "label", - "extended info", - originalIntent - ) + val originalInfo = + DisplayResolveInfo.newDisplayResolveInfo( + originalIntent, + createResolveInfo(3, 0), + "label", + "extended info", + originalIntent + ) originalInfo.addAlternateSourceIntent(mismatchedAlternate) originalInfo.addAlternateSourceIntent(targetAlternate) originalInfo.addAlternateSourceIntent(extraMatch) - val refinement = Intent("REFINE_ME") // First match is `targetAlternate` + val refinement = Intent("REFINE_ME") // First match is `targetAlternate` refinement.putExtra("refinement", true) val refinedResult = checkNotNull(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)) - .isTrue() + 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)) - .isFalse() - assertThat(refinedResult?.resolvedIntent?.getBooleanExtra("mismatchedAlternate", false)) + assertThat(refinedResult.resolvedIntent?.getBooleanExtra("originalIntent", false)).isFalse() + assertThat(refinedResult.resolvedIntent?.getBooleanExtra("mismatchedAlternate", false)) .isFalse() - assertThat(refinedResult?.resolvedIntent?.getBooleanExtra("extraMatch", false)).isFalse() + assertThat(refinedResult.resolvedIntent?.getBooleanExtra("extraMatch", false)).isFalse() } @Test @@ -252,13 +280,14 @@ class TargetInfoTest { val mismatchedAlternate = Intent("DOESNT_MATCH") mismatchedAlternate.putExtra("mismatchedAlternate", true) - val originalInfo = DisplayResolveInfo.newDisplayResolveInfo( - originalIntent, - ResolverDataProvider.createResolveInfo(3, 0), - "label", - "extended info", - originalIntent - ) + val originalInfo = + DisplayResolveInfo.newDisplayResolveInfo( + originalIntent, + createResolveInfo(3, 0), + "label", + "extended info", + originalIntent + ) originalInfo.addAlternateSourceIntent(mismatchedAlternate) val refinement = Intent("PROPOSED_REFINEMENT") @@ -271,41 +300,50 @@ class TargetInfoTest { intent.putExtra(Intent.EXTRA_TEXT, "testing intent sending") intent.setType("text/plain") - val resolveInfo = ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE) - val firstTargetInfo = DisplayResolveInfo.newDisplayResolveInfo( - intent, - resolveInfo, - "label 1", - "extended info 1", - intent - ) - val secondTargetInfo = DisplayResolveInfo.newDisplayResolveInfo( - intent, - resolveInfo, - "label 2", - "extended info 2", - intent - ) - - val multiTargetInfo = MultiDisplayResolveInfo.newMultiDisplayResolveInfo( - listOf(firstTargetInfo, secondTargetInfo)) - - assertThat(multiTargetInfo.isMultiDisplayResolveInfo()).isTrue() - assertThat(multiTargetInfo.isDisplayResolveInfo()).isTrue() // From legacy inheritance. - assertThat(multiTargetInfo.isChooserTargetInfo()).isFalse() - - assertThat(multiTargetInfo.getExtendedInfo()).isNull() - - assertThat(multiTargetInfo.getAllDisplayTargets()) - .containsExactly(firstTargetInfo, secondTargetInfo) + val packageName = "org.pkg.app" + val componentA = ComponentName(packageName, "org.pkg.app.ActivityA") + val componentB = ComponentName(packageName, "org.pkg.app.ActivityB") + val resolveInfoA = createResolveInfo(componentA, 0, PERSONAL_USER_HANDLE) + val resolveInfoB = createResolveInfo(componentB, 0, PERSONAL_USER_HANDLE) + val firstTargetInfo = + DisplayResolveInfo.newDisplayResolveInfo( + intent, + resolveInfoA, + "label 1", + "extended info 1", + intent + ) + val secondTargetInfo = + DisplayResolveInfo.newDisplayResolveInfo( + intent, + resolveInfoB, + "label 2", + "extended info 2", + intent + ) + + val multiTargetInfo = + MultiDisplayResolveInfo.newMultiDisplayResolveInfo( + listOf(firstTargetInfo, secondTargetInfo) + ) + + assertThat(multiTargetInfo.isMultiDisplayResolveInfo).isTrue() + assertThat(multiTargetInfo.isDisplayResolveInfo).isTrue() // From legacy inheritance. + assertThat(multiTargetInfo.isChooserTargetInfo).isFalse() + + assertThat(multiTargetInfo.extendedInfo).isNull() + + assertThat(multiTargetInfo.allDisplayTargets) + .containsExactly(firstTargetInfo, secondTargetInfo) assertThat(multiTargetInfo.hasSelected()).isFalse() - assertThat(multiTargetInfo.getSelectedTarget()).isNull() + assertThat(multiTargetInfo.selectedTarget).isNull() multiTargetInfo.setSelected(1) assertThat(multiTargetInfo.hasSelected()).isTrue() - assertThat(multiTargetInfo.getSelectedTarget()).isEqualTo(secondTargetInfo) + assertThat(multiTargetInfo.selectedTarget).isEqualTo(secondTargetInfo) + assertThat(multiTargetInfo.resolvedComponentName).isEqualTo(componentB) val refined = multiTargetInfo.tryToCloneWithAppliedRefinement(intent) assertThat(refined).isInstanceOf(MultiDisplayResolveInfo::class.java) @@ -321,37 +359,40 @@ class TargetInfoTest { val sendImage = Intent("SEND").apply { type = "image/png" } val sendUri = Intent("SEND").apply { type = "text/uri" } - val resolveInfo = ResolverDataProvider.createResolveInfo(1, 0) - - val imageOnlyTarget = DisplayResolveInfo.newDisplayResolveInfo( - sendImage, - resolveInfo, - "Send Image", - "Sends only images", - sendImage - ) - - val textOnlyTarget = DisplayResolveInfo.newDisplayResolveInfo( - sendUri, - resolveInfo, - "Send Text", - "Sends only text", - sendUri - ) - - val imageOrTextTarget = DisplayResolveInfo.newDisplayResolveInfo( - sendImage, - resolveInfo, - "Send Image or Text", - "Sends images or text", - sendImage - ).apply { - addAlternateSourceIntent(sendUri) - } - - val multiTargetInfo = MultiDisplayResolveInfo.newMultiDisplayResolveInfo( - listOf(imageOnlyTarget, textOnlyTarget, imageOrTextTarget) - ) + val resolveInfo = createResolveInfo(1, 0) + + val imageOnlyTarget = + DisplayResolveInfo.newDisplayResolveInfo( + sendImage, + resolveInfo, + "Send Image", + "Sends only images", + sendImage + ) + + val textOnlyTarget = + DisplayResolveInfo.newDisplayResolveInfo( + sendUri, + resolveInfo, + "Send Text", + "Sends only text", + sendUri + ) + + val imageOrTextTarget = + DisplayResolveInfo.newDisplayResolveInfo( + sendImage, + resolveInfo, + "Send Image or Text", + "Sends images or text", + sendImage + ) + .apply { addAlternateSourceIntent(sendUri) } + + val multiTargetInfo = + MultiDisplayResolveInfo.newMultiDisplayResolveInfo( + listOf(imageOnlyTarget, textOnlyTarget, imageOrTextTarget) + ) multiTargetInfo.setSelected(0) assertThat(multiTargetInfo.selectedTarget).isEqualTo(imageOnlyTarget) @@ -370,22 +411,23 @@ class TargetInfoTest { fun testNewMultiDisplayResolveInfo_tryToCloneWithAppliedRefinement_delegatedToSelectedTarget() { val refined = Intent("SEND") val sendImage = Intent("SEND") - val targetOne = spy( - DisplayResolveInfo.newDisplayResolveInfo( - sendImage, - ResolverDataProvider.createResolveInfo(1, 0), - "Target One", - "Target One", - sendImage + val targetOne = + spy( + DisplayResolveInfo.newDisplayResolveInfo( + sendImage, + createResolveInfo(1, 0), + "Target One", + "Target One", + sendImage + ) ) - ) - val targetTwo = mock { - whenever(tryToCloneWithAppliedRefinement(any())).thenReturn(this) - } - - val multiTargetInfo = MultiDisplayResolveInfo.newMultiDisplayResolveInfo( - listOf(targetOne, targetTwo) - ) + val targetTwo = + mock { + whenever(tryToCloneWithAppliedRefinement(any())).thenReturn(this) + } + + val multiTargetInfo = + MultiDisplayResolveInfo.newMultiDisplayResolveInfo(listOf(targetOne, targetTwo)) multiTargetInfo.setSelected(1) assertThat(multiTargetInfo.selectedTarget).isEqualTo(targetTwo) -- cgit v1.2.3-59-g8ed1b From 91c38786bac73496d8437f83741587120199cbfe Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Thu, 14 Dec 2023 20:41:03 +0000 Subject: Favor 'profiles' over 'user handles'/'page numbers' (Or at least, a first couple changes in that direction.) Historically the pager-adapter 'profiles' and 'page numbers' have been equivalent, but if we're trying to refer to a specific profile, we shouldn't have to know its mapped page number. These changes are as prototyped in ag/25335069 and described in go/chooser-ntab-refactoring, in particular replicating the changes of "snapshot 16" and "snapshot 18." There are more changes planned in this general direction, but these first two happened to be dependencies of unrelated work in the same overall refactoring effort, so I wanted to get them in first. The first snapshot just switches over a few obvious cases. The second starts to introduce some distinction in the pager-adapter between "profiles" and "page numbers," which had historically been equivalent. There are no behavior changes so far, but this sets up forward-looking "internal APIs" (helper methods) that we'll go on to use in subsequent CLs to adapt the remaining usages. See the prototype for more context. Bug: 310211468 Test: `ResolverActivityTest`/activity tests/presubmit Change-Id: I226e3801da4514f495a1781a98fa5b4630506163 --- .../android/intentresolver/v2/ChooserActivity.java | 8 ++--- .../v2/MultiProfilePagerAdapter.java | 40 ++++++++++++---------- .../intentresolver/v2/ResolverActivity.java | 6 ++-- 3 files changed, 27 insertions(+), 27 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 70812642..b791b1c1 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -787,10 +787,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements ChooserRequestParameters chooserRequest = requireChooserRequest(); if (!chooserRequest.getCallerChooserTargets().isEmpty()) { // Send the caller's chooser targets only to the default profile. - UserHandle defaultUser = (findSelectedProfile() == PROFILE_WORK) - ? requireAnnotatedUserHandles().workProfileUserHandle - : requireAnnotatedUserHandles().personalProfileUserHandle; - if (mChooserMultiProfilePagerAdapter.getCurrentUserHandle() == defaultUser) { + if (mChooserMultiProfilePagerAdapter.getActiveProfile() == findSelectedProfile()) { mChooserMultiProfilePagerAdapter.getActiveListAdapter().addServiceResults( /* origTarget */ null, new ArrayList<>(chooserRequest.getCallerChooserTargets()), @@ -1404,8 +1401,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements updateTabPadding(); } - UserHandle currentUserHandle = mChooserMultiProfilePagerAdapter.getCurrentUserHandle(); - int currentProfile = getProfileForUser(currentUserHandle); + int currentProfile = mChooserMultiProfilePagerAdapter.getActiveProfile(); int initialProfile = findSelectedProfile(); if (currentProfile != initialProfile) { return; diff --git a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java index 2d9be816..aa161921 100644 --- a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java @@ -140,6 +140,14 @@ public class MultiProfilePagerAdapter< mPageViewInflater.get(), adapter, containerBottomPaddingOverrideSupplier); } + private @Profile int getProfileForPageNumber(int position) { + return position; + } + + private int getPageNumberForProfile(@Profile int profile) { + return profile; + } + public void setOnProfileSelectedListener(OnProfileSelectedListener listener) { mOnProfileSelectedListener = listener; } @@ -205,11 +213,7 @@ public class MultiProfilePagerAdapter< } public final @Profile int getActiveProfile() { - // TODO: here and elsewhere in this class, distinguish between a "profile ID" integer and - // its mapped "page index." When we support more than two profiles, this won't be a "stable - // mapping" -- some particular profile may not be represented by a "page," but the ones that - // are will be assigned contiguous page numbers that skip over the holes. - return getCurrentPage(); + return getProfileForPageNumber(getCurrentPage()); } @VisibleForTesting @@ -304,6 +308,10 @@ public class MultiProfilePagerAdapter< return null; } + private ListAdapterT getListAdapterForPageNumber(int pageNumber) { + return mListAdapterExtractor.apply(getAdapterForIndex(pageNumber)); + } + /** * Returns the {@link ListAdapterT} instance of the profile that is currently visible * to the user. @@ -313,7 +321,7 @@ public class MultiProfilePagerAdapter< */ @VisibleForTesting public final ListAdapterT getActiveListAdapter() { - return mListAdapterExtractor.apply(getAdapterForIndex(getCurrentPage())); + return getListAdapterForPageNumber(getCurrentPage()); } /** @@ -330,28 +338,24 @@ public class MultiProfilePagerAdapter< if (getCount() < 2) { return null; } - return mListAdapterExtractor.apply(getAdapterForIndex(1 - getCurrentPage())); + return getListAdapterForPageNumber(1 - getCurrentPage()); } public final ListAdapterT getPersonalListAdapter() { - return mListAdapterExtractor.apply(getAdapterForIndex(PROFILE_PERSONAL)); + return getListAdapterForPageNumber(getPageNumberForProfile(PROFILE_PERSONAL)); } /** @return whether our tab data contains a page for the specified {@code profile} ID. */ public final boolean hasPageForProfile(@Profile int profile) { - // TODO: here and elsewhere in this class, distinguish between a "profile ID" integer and - // its mapped "page index." When we support more than two profiles, this won't be a "stable - // mapping" -- some particular profile may not be represented by a "page," but the ones that - // are will be assigned contiguous page numbers that skip over the holes. - return hasAdapterForIndex(profile); + return hasAdapterForIndex(getPageNumberForProfile(profile)); } @Nullable public final ListAdapterT getWorkListAdapter() { - if (!hasAdapterForIndex(PROFILE_WORK)) { + if (!hasPageForProfile(PROFILE_WORK)) { return null; } - return mListAdapterExtractor.apply(getAdapterForIndex(PROFILE_WORK)); + return getListAdapterForPageNumber(getPageNumberForProfile(PROFILE_WORK)); } public final SinglePageAdapterT getCurrentRootAdapter() { @@ -480,9 +484,9 @@ public class MultiProfilePagerAdapter< private int userHandleToPageIndex(UserHandle userHandle) { if (userHandle.equals(getPersonalListAdapter().getUserHandle())) { - return PROFILE_PERSONAL; + return getPageNumberForProfile(PROFILE_PERSONAL); } else { - return PROFILE_WORK; + return getPageNumberForProfile(PROFILE_WORK); } } @@ -500,7 +504,7 @@ public class MultiProfilePagerAdapter< } private boolean hasAdapterForIndex(int pageIndex) { - return (pageIndex < getCount()); + return (pageIndex >= 0) && (pageIndex < getCount()); } /** diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index 2ba50ec3..cc91e9bf 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -934,8 +934,7 @@ public class ResolverActivity extends FragmentActivity implements } protected Unit onWorkProfileStatusUpdated() { - if (mMultiProfilePagerAdapter.getCurrentUserHandle().equals( - requireAnnotatedUserHandles().workProfileUserHandle)) { + if (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_WORK) { mMultiProfilePagerAdapter.rebuildActiveTab(true); } else { mMultiProfilePagerAdapter.clearInactiveProfileCache(); @@ -1358,7 +1357,8 @@ public class ResolverActivity extends FragmentActivity implements // 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)) { + if (hasCloneProfile() + && (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL)) { mAlwaysButton.setEnabled(false); return; } -- cgit v1.2.3-59-g8ed1b From 91c10ea1620ee887b477375d7a804b9e5bffac3f Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Thu, 14 Dec 2023 17:42:55 +0000 Subject: Treat all inactive profiles equally Now that we've handled all the "profile-specific" special cases in ag/25599173, remaining references to a generic "inactive profile" can be adapted to apply to *all* inactive profiles, since in the future we may have more than one. These changes are as prototyped in ag/25335069 and described in go/chooser-ntab-refactoring. This CL covers the original "snapshot 20" through "snapshot 25." See below for a "by-snapshot" breakdown of the incremental changes composed in this CL. Snapshot 1: Establish `forEachPage()` and `forEachInactivePage()` helpers that we'll use to implement per-page operations throughout the changes included in this CL. Convert a first operation, the Resolver-specific `clearCheckedItemsInInactiveProfiles()`. Snapshot 2: Generalize the `shouldShowEmptyStateScreenInAnyInactiveAdapter()` implementation (so it in fact considers "any" inactive adapter). Snapshot 3: Generalize `refreshPackagesInAllTabs()` to handle any number of tabs. Snapshot 4: Generalize `rebuildInactiveTab()` -> `rebuildInactiveTabs()`. This relies on some imagination about how the legacy intention might apply to multiple tabs, but it's at least equivalent in the legacy two-tab case. Note this implementation may appear to differ from the legacy code by returning true instead of false when there are no inactive tabs, but it's a vacuous distinction because the legacy caller effectively checked for a tabbed presentation as a precondition for calling this method. Returning true by default is more appropriate if we relax that precondition, so we wouldn't end up waiting in an "incomplete" status on behalf of a tab that doesn't exist. Snapshot 5: Remove the API to access some single "inactive tab" -- there may be more than one in the future. Snapshot 6: Generalize `clearInactiveProfileCache()`. Bug: 310211468 Test: `ResolverActivityTest` & IntentResolver activity tests. Change-Id: Ifcba88bb3d678364a55771a9801f3626dfbfd25a --- .../v2/MultiProfilePagerAdapter.java | 103 ++++++++++----------- .../v2/ResolverMultiProfilePagerAdapter.java | 12 ++- .../intentresolver/v2/ChooserWrapperActivity.java | 10 +- .../intentresolver/v2/ResolverWrapperActivity.java | 7 +- .../v2/MultiProfilePagerAdapterTest.kt | 3 - 5 files changed, 61 insertions(+), 74 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java index aa161921..6a286f21 100644 --- a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java @@ -36,6 +36,8 @@ import com.google.common.collect.ImmutableList; import java.util.HashSet; import java.util.Optional; import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Supplier; @@ -184,10 +186,7 @@ public class MultiProfilePagerAdapter< } public void clearInactiveProfileCache() { - if (mLoadedPages.size() == 1) { - return; - } - mLoadedPages.remove(1 - mCurrentPage); + forEachInactivePage(pageNumber -> mLoadedPages.remove(pageNumber)); } @Override @@ -317,30 +316,12 @@ public class MultiProfilePagerAdapter< * to the user. *

For example, if the user is viewing the work tab in the share sheet, this method returns * the work profile {@link ListAdapterT}. - * @see #getInactiveListAdapter() */ @VisibleForTesting public final ListAdapterT getActiveListAdapter() { return getListAdapterForPageNumber(getCurrentPage()); } - /** - * If this is a device with a work profile, returns the {@link ListAdapterT} instance - * of the profile that is not currently visible to the user. Otherwise returns - * {@code null}. - *

For example, if the user is viewing the work tab in the share sheet, this method returns - * the personal profile {@link ListAdapterT}. - * @see #getActiveListAdapter() - */ - @VisibleForTesting - @Nullable - public final ListAdapterT getInactiveListAdapter() { - if (getCount() < 2) { - return null; - } - return getListAdapterForPageNumber(1 - getCurrentPage()); - } - public final ListAdapterT getPersonalListAdapter() { return getListAdapterForPageNumber(getPageNumberForProfile(PROFILE_PERSONAL)); } @@ -366,14 +347,6 @@ public class MultiProfilePagerAdapter< return getListViewForIndex(getCurrentPage()); } - @Nullable - public final PageViewT getInactiveAdapterView() { - if (getCount() < 2) { - return null; - } - return getListViewForIndex(1 - getCurrentPage()); - } - private boolean anyAdapterHasItems() { for (int i = 0; i < mItems.size(); ++i) { ListAdapterT listAdapter = mListAdapterExtractor.apply(getAdapterForIndex(i)); @@ -385,13 +358,10 @@ public class MultiProfilePagerAdapter< } public void refreshPackagesInAllTabs() { - // TODO: handle all inactive profiles; for now we can only have at most one. It's unclear if - // this legacy logic really requires the active tab to be rebuilt first, or if we could just - // iterate over the tabs in arbitrary order. + // TODO: it's unclear if this legacy logic really requires the active tab to be rebuilt + // first, or if we could just iterate over the tabs in arbitrary order. getActiveListAdapter().handlePackagesChanged(); - if (getCount() > 1) { - getInactiveListAdapter().handlePackagesChanged(); - } + forEachInactivePage(page -> getListAdapterForPageNumber(page).handlePackagesChanged()); } /** @@ -449,9 +419,10 @@ public class MultiProfilePagerAdapter< // autolaunch conditions). boolean rebuildCompleted = rebuildActiveTab(true) || getActiveListAdapter().isTabLoaded(); if (includePartialRebuildOfInactiveTabs) { - boolean rebuildInactiveCompleted = - rebuildInactiveTab(false) || getInactiveListAdapter().isTabLoaded(); - rebuildCompleted = rebuildCompleted && rebuildInactiveCompleted; + // Per legacy logic, avoid short-circuiting (TODO: why? possibly so that we *start* + // loading the inactive tabs even if we're still waiting on the active tab to finish?). + boolean completedRebuildingInactiveTabs = rebuildInactiveTabs(false); + rebuildCompleted = rebuildCompleted && completedRebuildingInactiveTabs; } return rebuildCompleted; } @@ -468,18 +439,27 @@ public class MultiProfilePagerAdapter< } /** - * Rebuilds the tab that is not currently visible to the user, if such one exists. - *

Returns {@code true} if rebuild has completed. + * Rebuilds any tabs that are not currently visible to the user. + *

Returns {@code true} if rebuild has completed in all inactive tabs. */ - private boolean rebuildInactiveTab(boolean doPostProcessing) { + private boolean rebuildInactiveTabs(boolean doPostProcessing) { Trace.beginSection("MultiProfilePagerAdapter#rebuildInactiveTab"); - if (getItemCount() == 1) { - Trace.endSection(); - return false; - } - boolean result = rebuildTab(getInactiveListAdapter(), doPostProcessing); + AtomicBoolean allRebuildsComplete = new AtomicBoolean(true); + forEachInactivePage(pageNumber -> { + // Evaluate the rebuild for every inactive page, even if we've already seen some adapter + // return an "incomplete" status (i.e., even if `allRebuildsComplete` is already false) + // and so we already know we'll end up returning false for the batch. + // TODO: any particular reason the per-page legacy logic was set up in this order, or + // could we possibly short-circuit the rebuild if the tab is already "loaded"? + ListAdapterT inactiveAdapter = getListAdapterForPageNumber(pageNumber); + boolean rebuildInactivePageCompleted = + rebuildTab(inactiveAdapter, doPostProcessing) || inactiveAdapter.isTabLoaded(); + if (!rebuildInactivePageCompleted) { + allRebuildsComplete.set(false); + } + }); Trace.endSection(); - return result; + return allRebuildsComplete.get(); } private int userHandleToPageIndex(UserHandle userHandle) { @@ -490,6 +470,20 @@ public class MultiProfilePagerAdapter< } } + protected void forEachPage(Consumer pageNumberHandler) { + for (int pageNumber = 0; pageNumber < getItemCount(); ++pageNumber) { + pageNumberHandler.accept(pageNumber); + } + } + + protected void forEachInactivePage(Consumer inactivePageNumberHandler) { + forEachPage(pageNumber -> { + if (pageNumber != getCurrentPage()) { + inactivePageNumberHandler.accept(pageNumber); + } + }); + } + protected boolean rebuildTab(ListAdapterT activeListAdapter, boolean doPostProcessing) { if (shouldSkipRebuild(activeListAdapter)) { activeListAdapter.postListReadyRunnable(doPostProcessing, /* rebuildCompleted */ true); @@ -585,11 +579,14 @@ public class MultiProfilePagerAdapter< * application state. */ public final boolean shouldShowEmptyStateScreenInAnyInactiveAdapter() { - if (getCount() < 2) { - return false; - } - // TODO: check against *any* inactive adapter; for now we only have one. - return shouldShowEmptyStateScreen(getInactiveListAdapter()); + AtomicBoolean anyEmpty = new AtomicBoolean(false); + // TODO: The "inactive" condition is legacy logic. Could we simplify and ask "any"? + forEachInactivePage(pageNumber -> { + if (shouldShowEmptyStateScreen(getListAdapterForPageNumber(pageNumber))) { + anyEmpty.set(true); + } + }); + return anyEmpty.get(); } public boolean shouldShowEmptyStateScreen(ListAdapterT listAdapter) { diff --git a/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java index d96fd15a..21e36614 100644 --- a/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java @@ -109,11 +109,13 @@ public class ResolverMultiProfilePagerAdapter extends /** Un-check any item(s) that may be checked in any of our inactive adapter(s). */ public void clearCheckedItemsInInactiveProfiles() { - // TODO: apply to all inactive adapters; for now we just have the one. - ListView inactiveListView = getInactiveAdapterView(); - if (inactiveListView.getCheckedItemCount() > 0) { - inactiveListView.setItemChecked(inactiveListView.getCheckedItemPosition(), false); - } + // TODO: The "inactive" condition is legacy logic. Could we simplify and clear-all? + forEachInactivePage(pageNumber -> { + ListView inactiveListView = getListViewForIndex(pageNumber); + if (inactiveListView.getCheckedItemCount() > 0) { + inactiveListView.setItemChecked(inactiveListView.getCheckedItemPosition(), false); + } + }); } private static class BottomPaddingOverrideSupplier implements Supplier> { diff --git a/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java b/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java index a7930f8a..04efc4e2 100644 --- a/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java @@ -40,7 +40,6 @@ import com.android.intentresolver.TestContentPreviewViewModel; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; -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; @@ -117,17 +116,12 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW @Override public ChooserListAdapter getPersonalListAdapter() { - return ((ChooserGridAdapter) mMultiProfilePagerAdapter.getAdapterForIndex(0)) - .getListAdapter(); + return mChooserMultiProfilePagerAdapter.getPersonalListAdapter(); } @Override public ChooserListAdapter getWorkListAdapter() { - if (mMultiProfilePagerAdapter.getInactiveListAdapter() == null) { - return null; - } - return ((ChooserGridAdapter) mMultiProfilePagerAdapter.getAdapterForIndex(1)) - .getListAdapter(); + return mChooserMultiProfilePagerAdapter.getWorkListAdapter(); } @Override diff --git a/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java b/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java index 7ae58254..a09ee894 100644 --- a/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java @@ -118,14 +118,11 @@ public class ResolverWrapperActivity extends ResolverActivity { } ResolverListAdapter getPersonalListAdapter() { - return ((ResolverListAdapter) mMultiProfilePagerAdapter.getAdapterForIndex(0)); + return mMultiProfilePagerAdapter.getPersonalListAdapter(); } ResolverListAdapter getWorkListAdapter() { - if (mMultiProfilePagerAdapter.getInactiveListAdapter() == null) { - return null; - } - return ((ResolverListAdapter) mMultiProfilePagerAdapter.getAdapterForIndex(1)); + return mMultiProfilePagerAdapter.getWorkListAdapter(); } @Override diff --git a/tests/unit/src/com/android/intentresolver/v2/MultiProfilePagerAdapterTest.kt b/tests/unit/src/com/android/intentresolver/v2/MultiProfilePagerAdapterTest.kt index f5dc0935..892fbb4e 100644 --- a/tests/unit/src/com/android/intentresolver/v2/MultiProfilePagerAdapterTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/MultiProfilePagerAdapterTest.kt @@ -69,7 +69,6 @@ class MultiProfilePagerAdapterTest { assertThat(pagerAdapter.currentUserHandle).isEqualTo(PERSONAL_USER_HANDLE) assertThat(pagerAdapter.getAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.inactiveListAdapter).isNull() assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter) assertThat(pagerAdapter.workListAdapter).isNull() assertThat(pagerAdapter.itemCount).isEqualTo(1) @@ -104,7 +103,6 @@ class MultiProfilePagerAdapterTest { assertThat(pagerAdapter.getAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) assertThat(pagerAdapter.getAdapterForIndex(1)).isSameInstanceAs(workListAdapter) assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.inactiveListAdapter).isSameInstanceAs(workListAdapter) assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter) assertThat(pagerAdapter.workListAdapter).isSameInstanceAs(workListAdapter) assertThat(pagerAdapter.itemCount).isEqualTo(2) @@ -143,7 +141,6 @@ class MultiProfilePagerAdapterTest { assertThat(pagerAdapter.getAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) assertThat(pagerAdapter.getAdapterForIndex(1)).isSameInstanceAs(workListAdapter) assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(workListAdapter) - assertThat(pagerAdapter.inactiveListAdapter).isSameInstanceAs(personalListAdapter) assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter) assertThat(pagerAdapter.workListAdapter).isSameInstanceAs(workListAdapter) assertThat(pagerAdapter.itemCount).isEqualTo(2) -- cgit v1.2.3-59-g8ed1b From 37b363161b23f4b9d4c85713468c38613fc5fbc4 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Tue, 12 Dec 2023 12:32:38 -0500 Subject: Separates the Chooser and Resolver activities This change removes the class inheritence relationship between these two activities. They bear only historic similarities and have remained related and grown together unnaturually over the years. This only separates the dependency at the class level while much of the implementation relies on classes which also form a hierarchy. Because of this, most of the common code is left duplicated in both locations pending other cleanup work. Other notes: * Variables which depended on class have now been logically inlined, simplifying or removing the code in each location * All code related to 'profile switch button' removed (obsolete UI) Bug: 309960444 Test: atest com.android.intentresolver.v2 Change-Id: I7c754388387ea7f23d534d5c487416e0edaaf968 --- .../android/intentresolver/ChooserActivity.java | 8 - .../android/intentresolver/ChooserListAdapter.java | 3 - .../intentresolver/ResolverListAdapter.java | 25 +- .../intentresolver/grid/ChooserGridAdapter.java | 35 +- .../com/android/intentresolver/v2/ActivityLogic.kt | 58 +- .../android/intentresolver/v2/ChooserActivity.java | 1192 +++++++++++++++++--- .../intentresolver/v2/ChooserActivityLogic.kt | 58 +- .../v2/ChooserMultiProfilePagerAdapter.java | 2 - .../v2/MultiProfilePagerAdapter.java | 4 +- .../intentresolver/v2/ResolverActivity.java | 656 ++++------- .../intentresolver/v2/ResolverActivityLogic.kt | 47 +- .../v2/ResolverMultiProfilePagerAdapter.java | 1 - .../android/intentresolver/v2/ui/ActionTitle.java | 1 - .../intentresolver/v2/ChooserWrapperActivity.java | 15 +- .../intentresolver/v2/ResolverActivityTest.java | 12 +- .../intentresolver/v2/ResolverWrapperActivity.java | 13 +- .../intentresolver/v2/TestChooserActivityLogic.kt | 10 +- .../intentresolver/v2/TestResolverActivityLogic.kt | 4 +- .../intentresolver/FakeResolverListCommunicator.kt | 6 - .../intentresolver/ResolverListAdapterTest.kt | 9 - 20 files changed, 1366 insertions(+), 793 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index fc011aef..b1c7d6fb 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -1206,13 +1206,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements showTargetDetails(longPressedTargetInfo); } } - - @Override - public void updateProfileViewButton(View newButtonFromProfileRow) { - mProfileView = newButtonFromProfileRow; - mProfileView.setOnClickListener(ChooserActivity.this::onProfileClick); - ChooserActivity.this.updateProfileViewButton(); - } }, chooserListAdapter, shouldShowContentPreview(), @@ -1410,7 +1403,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements int offset = mSystemWindowInsets != null ? mSystemWindowInsets.bottom : 0; int rowsToShow = gridAdapter.getSystemRowCount() - + gridAdapter.getProfileRowCount() + gridAdapter.getServiceTargetRowCount() + gridAdapter.getCallerAndRankedTargetRowCount(); diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 94a89722..5060f4f1 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -791,9 +791,6 @@ public class ChooserListAdapter extends ResolverListAdapter { @Nullable List sortedComponents, boolean doPostProcessing) { processSortedList(sortedComponents, doPostProcessing); if (doPostProcessing) { - mResolverListCommunicator.updateProfileViewButton(); - //TODO: this method is different from super's only in that `notifyDataSetChanged` is - // called conditionally here; is it really important? notifyDataSetChanged(); } } diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index 262d180a..80d07d2c 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -476,9 +476,6 @@ public class ResolverListAdapter extends BaseAdapter { @Nullable List sortedComponents, boolean doPostProcessing) { processSortedList(sortedComponents, doPostProcessing); notifyDataSetChanged(); - if (doPostProcessing) { - mResolverListCommunicator.updateProfileViewButton(); - } } protected void processSortedList( @@ -650,6 +647,7 @@ public class ResolverListAdapter extends BaseAdapter { return null; } + @Override public int getCount() { int totalSize = mDisplayList == null || mDisplayList.isEmpty() ? mPlaceholderCount : mDisplayList.size(); @@ -663,6 +661,7 @@ public class ResolverListAdapter extends BaseAdapter { return mDisplayList.size(); } + @Override @Nullable public TargetInfo getItem(int position) { if (mFilterLastUsed && mLastChosenPosition >= 0 && position >= mLastChosenPosition) { @@ -675,6 +674,7 @@ public class ResolverListAdapter extends BaseAdapter { } } + @Override public long getItemId(int position) { return position; } @@ -692,6 +692,7 @@ public class ResolverListAdapter extends BaseAdapter { return mDisplayList.get(index); } + @Override public final View getView(int position, View convertView, ViewGroup parent) { View view = convertView; if (view == null) { @@ -752,9 +753,7 @@ public class ResolverListAdapter extends BaseAdapter { } private void onIconLoaded(DisplayResolveInfo displayResolveInfo, Drawable drawable) { - if (getOtherProfile() == displayResolveInfo) { - mResolverListCommunicator.updateProfileViewButton(); - } else if (!displayResolveInfo.hasDisplayIcon()) { + if (!displayResolveInfo.hasDisplayIcon()) { displayResolveInfo.getDisplayIconHolder().setDisplayIcon(drawable); notifyDataSetChanged(); } @@ -902,14 +901,18 @@ public class ResolverListAdapter extends BaseAdapter { Intent getReplacementIntent(ActivityInfo activityInfo, Intent defIntent); + // ResolverListCommunicator + default void updateProfileViewButton() { + } + void onPostListReady(ResolverListAdapter listAdapter, boolean updateUi, boolean rebuildCompleted); void sendVoiceChoicesIfNeeded(); - void updateProfileViewButton(); - - boolean useLayoutWithDefault(); + default boolean useLayoutWithDefault() { + return false; + } boolean shouldGetActivityMetadata(); @@ -917,7 +920,9 @@ public class ResolverListAdapter extends BaseAdapter { * @return true to filter only apps that can handle * {@link android.content.Intent#CATEGORY_DEFAULT} intents */ - default boolean shouldGetOnlyDefaultActivities() { return true; }; + default boolean shouldGetOnlyDefaultActivities() { + return true; + } void onHandlePackagesChanged(ResolverListAdapter listAdapter); } diff --git a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java index 51d4e677..5ed3e67a 100644 --- a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java +++ b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java @@ -85,19 +85,11 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter 0 && position < countSum) return VIEW_TYPE_CONTENT_PREVIEW; - countSum += (count = getProfileRowCount()); - if (count > 0 && position < countSum) return VIEW_TYPE_PROFILE; - countSum += (count = getServiceTargetRowCount()); if (count > 0 && position < countSum) return VIEW_TYPE_DIRECT_SHARE; @@ -400,12 +373,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter? - /** Whether or not this activity supports choosing a default handler for the intent. */ - val supportsAlwaysUseOption: Boolean /** Fetches display info for processed candidates. */ val targetDataLoader: TargetDataLoader - /** The theme to use. */ - val themeResId: Int - /** - * Message showing that intent is forwarded from managed profile to owner or other way around. - */ - val profileSwitchMessage: String? /** The intents for potential actual targets. [targetIntent] must be first. */ val payloadIntents: List - - /** - * Called after Activity superclass creation, but before any other onCreate logic is performed. - */ - fun preInitialization() - - /** Sets [profileSwitchMessage] to null */ - fun clearProfileSwitchMessage() } /** @@ -84,54 +68,44 @@ interface CommonActivityLogic { */ class CommonActivityLogicImpl( override val tag: String, - activityProvider: () -> ComponentActivity, + override val activity: ComponentActivity, onWorkProfileStatusUpdated: () -> Unit, ) : CommonActivityLogic { - override val activity: ComponentActivity by lazy { activityProvider() } - - override val referrerPackageName: String? by lazy { - activity.referrer.let { - if (ANDROID_APP_URI_SCHEME == it?.scheme) { - it.host - } else { - null - } + override val referrerPackageName: String? = activity.referrer.let { + if (ANDROID_APP_URI_SCHEME == it?.scheme) { + it.host + } else { + null } } - override val userManager: UserManager by lazy { activity.getSystemService()!! } + override val userManager: UserManager = activity.getSystemService()!! - override val devicePolicyManager: DevicePolicyManager by lazy { activity.getSystemService()!! } + override val devicePolicyManager: DevicePolicyManager = activity.getSystemService()!! - override val annotatedUserHandles: AnnotatedUserHandles? by lazy { - try { - AnnotatedUserHandles.forShareActivity(activity) - } catch (e: SecurityException) { - Log.e(tag, "Request from UID without necessary permissions", e) - null - } + override val annotatedUserHandles: AnnotatedUserHandles? = try { + AnnotatedUserHandles.forShareActivity(activity) + } catch (e: SecurityException) { + Log.e(tag, "Request from UID without necessary permissions", e) + null } - override val workProfileAvailabilityManager: WorkProfileAvailabilityManager by lazy { - WorkProfileAvailabilityManager( + override val workProfileAvailabilityManager = WorkProfileAvailabilityManager( userManager, annotatedUserHandles?.workProfileUserHandle, onWorkProfileStatusUpdated, ) - } - private val forwardToPersonalMessage: String? by lazy { + private val forwardToPersonalMessage: String? = devicePolicyManager.resources.getString(FORWARD_INTENT_TO_PERSONAL) { activity.getString(R.string.forward_intent_to_owner) } - } - private val forwardToWorkMessage: String? by lazy { + private val forwardToWorkMessage: String? = devicePolicyManager.resources.getString(FORWARD_INTENT_TO_WORK) { activity.getString(R.string.forward_intent_to_work) } - } override fun forwardMessageFor(intent: Intent): String? { val contentUserHint = intent.contentUserHint diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index ddabc1bf..2d2c71af 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -16,23 +16,34 @@ package com.android.intentresolver.v2; +import static android.Manifest.permission.INTERACT_ACROSS_PROFILES; +import static android.app.VoiceInteractor.PickOptionRequest.Option; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_PERSONAL; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_WORK; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_SHARE_WITH_PERSONAL; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_SHARE_WITH_WORK; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE; +import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; +import static android.content.PermissionChecker.PID_UNKNOWN; import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL; 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 androidx.lifecycle.LifecycleKt.getCoroutineScope; +import static com.android.internal.annotations.VisibleForTesting.Visibility.PROTECTED; import static com.android.internal.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET; +import static java.util.Collections.emptyList; import static java.util.Objects.requireNonNull; +import static java.util.Objects.requireNonNullElse; import android.app.Activity; import android.app.ActivityManager; import android.app.ActivityOptions; +import android.app.ActivityThread; +import android.app.VoiceInteractor; +import android.app.admin.DevicePolicyEventLogger; import android.app.prediction.AppPredictor; import android.app.prediction.AppTarget; import android.app.prediction.AppTargetEvent; @@ -43,33 +54,52 @@ import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.IntentSender; +import android.content.PermissionChecker; import android.content.SharedPreferences; import android.content.pm.ActivityInfo; +import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.content.pm.ShortcutInfo; +import android.content.pm.UserInfo; import android.content.res.Configuration; import android.database.Cursor; import android.graphics.Insets; import android.net.Uri; +import android.os.Build; import android.os.Bundle; +import android.os.StrictMode; import android.os.SystemClock; +import android.os.Trace; import android.os.UserHandle; import android.os.UserManager; import android.service.chooser.ChooserTarget; +import android.stats.devicepolicy.DevicePolicyEnums; +import android.text.TextUtils; import android.util.Log; import android.util.Slog; import android.util.SparseArray; +import android.view.Gravity; +import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.ViewGroup.LayoutParams; import android.view.ViewTreeObserver; +import android.view.Window; import android.view.WindowInsets; +import android.view.WindowManager; +import android.widget.Button; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TabHost; +import android.widget.TabWidget; import android.widget.TextView; +import android.widget.Toast; import androidx.annotation.MainThread; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.fragment.app.FragmentActivity; import androidx.lifecycle.ViewModelProvider; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -89,6 +119,7 @@ import com.android.intentresolver.R; import com.android.intentresolver.ResolverListAdapter; import com.android.intentresolver.ResolverListController; import com.android.intentresolver.ResolverViewPager; +import com.android.intentresolver.WorkProfileAvailabilityManager; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; @@ -96,6 +127,8 @@ import com.android.intentresolver.contentpreview.BasePreviewViewModel; import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; import com.android.intentresolver.contentpreview.HeadlineGeneratorImpl; import com.android.intentresolver.contentpreview.PreviewViewModel; +import com.android.intentresolver.emptystate.CompositeEmptyStateProvider; +import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; import com.android.intentresolver.emptystate.EmptyState; import com.android.intentresolver.emptystate.EmptyStateProvider; import com.android.intentresolver.grid.ChooserGridAdapter; @@ -107,28 +140,35 @@ 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.v2.data.repository.DevicePolicyResources; +import com.android.intentresolver.v2.emptystate.NoAppsAvailableEmptyStateProvider; import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider; import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; +import com.android.intentresolver.v2.emptystate.WorkProfilePausedEmptyStateProvider; import com.android.intentresolver.v2.platform.ImageEditor; import com.android.intentresolver.v2.platform.NearbyShare; +import com.android.intentresolver.v2.ui.ActionTitle; import com.android.intentresolver.widget.ImagePreviewView; +import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.content.PackageMonitor; +import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; +import com.android.internal.util.LatencyTracker; import dagger.hilt.android.AndroidEntryPoint; import kotlin.Unit; -import java.text.Collator; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; -import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicLong; @@ -142,7 +182,7 @@ import javax.inject.Inject; * */ @SuppressWarnings("OptionalUsedAsFieldOrParameterType") -@AndroidEntryPoint(ResolverActivity.class) +@AndroidEntryPoint(FragmentActivity.class) public class ChooserActivity extends Hilt_ChooserActivity implements ResolverListAdapter.ResolverListCommunicator { private static final String TAG = "ChooserActivity"; @@ -167,6 +207,42 @@ public class ChooserActivity extends Hilt_ChooserActivity implements public static final String LAUNCH_LOCATION_DIRECT_SHARE = "direct_share"; private static final String SHORTCUT_TARGET = "shortcut_target"; + ////////////////////////////////////////////////////////////////////////////////////////////// + // Inherited properties. + ////////////////////////////////////////////////////////////////////////////////////////////// + private static final String TAB_TAG_PERSONAL = "personal"; + private static final String TAB_TAG_WORK = "work"; + + private static final String LAST_SHOWN_TAB_KEY = "last_shown_tab_key"; + protected static final String METRICS_CATEGORY_CHOOSER = "intent_chooser"; + + private int mLayoutId; + private UserHandle mHeaderCreatorUser; + protected static final int PROFILE_PERSONAL = MultiProfilePagerAdapter.PROFILE_PERSONAL; + protected static final int PROFILE_WORK = MultiProfilePagerAdapter.PROFILE_WORK; + private boolean mRegistered; + protected PackageManager mPm; + private PackageMonitor mPersonalPackageMonitor; + private PackageMonitor mWorkPackageMonitor; + protected View mProfileView; + + protected ActivityLogic mLogic; + protected ResolverDrawerLayout mResolverDrawerLayout; + protected ChooserMultiProfilePagerAdapter mChooserMultiProfilePagerAdapter; + protected final LatencyTracker mLatencyTracker = getLatencyTracker(); + + /** See {@link #setRetainInOnStop}. */ + private boolean mRetainInOnStop; + protected Insets mSystemWindowInsets = null; + private ResolverActivity.PickTargetOptionRequest mPickOptionRequest; + + @Nullable + private MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; + + ////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////// + + // TODO: these data structures are for one-time use in shuttling data from where they're // populated in `ShortcutToChooserTargetConverter` to where they're consumed in // `ShortcutSelectionLogic` which packs the appropriate elements into the final `TargetInfo`. @@ -190,6 +266,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Inject @NearbyShare public Optional mNearbyShare; @Inject public TargetDataLoader mTargetDataLoader; + @Inject public DevicePolicyResources mDevicePolicyResources; private ChooserRefinementManager mRefinementManager; private ChooserContentPreviewUi mChooserContentPreviewUi; @@ -216,12 +293,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private int mScrollStatus = SCROLL_STATUS_IDLE; - @VisibleForTesting - protected ChooserMultiProfilePagerAdapter mChooserMultiProfilePagerAdapter; private final EnterTransitionAnimationDelegate mEnterTransitionAnimationDelegate = new EnterTransitionAnimationDelegate(this, () -> mResolverDrawerLayout); - private View mContentView = null; + private final View mContentView = null; private final SparseArray mProfileRecords = new SparseArray<>(); @@ -236,35 +311,133 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private final AtomicLong mIntentReceivedTime = new AtomicLong(-1); - @Override - protected void onCreate(Bundle savedInstanceState) { - Tracer.INSTANCE.markLaunched(); - super.onCreate(savedInstanceState); - setLogic(new ChooserActivityLogic( + @VisibleForTesting + protected ActivityLogic createActivityLogic() { + return new ChooserActivityLogic( TAG, - () -> this, + /* activity = */ this, this::onWorkProfileStatusUpdated, - () -> mTargetDataLoader, - this::onPreinitialization)); - addInitializer(this::init); + mTargetDataLoader); } - private void init() { - if (getChooserRequest() == null) { - finish(); - return; - } + @Override + protected final void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); if (isFinishing()) { // Performing a clean exit: // Skip initializing any additional resources. return; } - setTheme(mLogic.getThemeResId()); + setTheme(R.style.Theme_DeviceDefault_Chooser); + mLogic = createActivityLogic(); + Tracer.INSTANCE.markLaunched(); + } - getEventLog().logSharesheetTriggered(); + @Override + protected final void onPostCreate(@Nullable Bundle savedInstanceState) { + super.onPostCreate(savedInstanceState); + mIntentReceivedTime.set(System.currentTimeMillis()); + mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET); - mRefinementManager = new ViewModelProvider(this).get(ChooserRefinementManager.class); + mPinnedSharedPrefs = getPinnedSharedPrefs(this); + mMaxTargetsPerRow = + getResources().getInteger(R.integer.config_chooser_max_targets_per_row); + mShouldDisplayLandscape = + shouldDisplayLandscape(getResources().getConfiguration().orientation); + + + ChooserRequestParameters chooserRequest = getChooserRequest(); + if (chooserRequest != null) { + setRetainInOnStop(chooserRequest.shouldRetainInOnStop()); + } + ChooserRequestParameters chooserRequest1 = getChooserRequest(); + if (chooserRequest1 != null) { + createProfileRecords( + new AppPredictorFactory( + this, + chooserRequest1.getSharedText(), + chooserRequest1.getTargetIntentFilter() + ), + chooserRequest1.getTargetIntentFilter() + ); + } + Intent intent = mLogic.getTargetIntent(); + List initialIntents = mLogic.getInitialIntents(); + TargetDataLoader targetDataLoader = mLogic.getTargetDataLoader(); + + // Calling UID did not have valid permissions + if (mLogic.getAnnotatedUserHandles() == null) { + finish(); + return; + } + + mPm = getPackageManager(); + mChooserMultiProfilePagerAdapter = createMultiProfilePagerAdapter( + requireNonNullElse(initialIntents, emptyList()).toArray(new Intent[0]), + /* resolutionList = */ null, + false, + targetDataLoader + ); + if (!configureContentView(targetDataLoader)) { + mPersonalPackageMonitor = createPackageMonitor( + mChooserMultiProfilePagerAdapter.getPersonalListAdapter()); + mPersonalPackageMonitor.register( + this, + getMainLooper(), + requireAnnotatedUserHandles().personalProfileUserHandle, + false + ); + if (hasWorkProfile()) { + mWorkPackageMonitor = createPackageMonitor( + mChooserMultiProfilePagerAdapter.getWorkListAdapter()); + mWorkPackageMonitor.register( + this, + getMainLooper(), + requireAnnotatedUserHandles().workProfileUserHandle, + false + ); + } + mRegistered = true; + final ResolverDrawerLayout rdl = findViewById( + com.android.internal.R.id.contentPanel); + if (rdl != null) { + rdl.setOnDismissedListener(new ResolverDrawerLayout.OnDismissedListener() { + @Override + public void onDismissed() { + finish(); + } + }); + + boolean hasTouchScreen = getPackageManager() + .hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN); + if (isVoiceInteraction() || !hasTouchScreen) { + rdl.setCollapsed(false); + } + + rdl.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); + rdl.setOnApplyWindowInsetsListener(this::onApplyWindowInsets); + + mResolverDrawerLayout = rdl; + } + final Set categories = intent.getCategories(); + MetricsLogger.action(this, + mChooserMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() + ? MetricsEvent.ACTION_SHOW_APP_DISAMBIG_APP_FEATURED + : MetricsEvent.ACTION_SHOW_APP_DISAMBIG_NONE_FEATURED, + intent.getAction() + ":" + intent.getType() + ":" + + (categories != null ? Arrays.toString(categories.toArray()) + : "")); + } + + if (getChooserRequest() == null) { + finish(); + return; + } + + getEventLog().logSharesheetTriggered(); + mRefinementManager = new ViewModelProvider(this).get(ChooserRefinementManager.class); mRefinementManager.getRefinementCompletion().observe(this, completion -> { if (completion.consume()) { TargetInfo targetInfo = completion.getTargetInfo(); @@ -276,26 +449,31 @@ public class ChooserActivity extends Hilt_ChooserActivity implements // 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. - ChooserActivity.super.onTargetSelected(targetInfo, false); + final ResolveInfo ri = targetInfo.getResolveInfo(); + final Intent intent1 = targetInfo.getResolvedIntent(); + + safelyStartActivity(targetInfo); + + // Rely on the ActivityManager to pop up a dialog regarding app suspension + // and return false + targetInfo.isSuspended(); } finish(); } }); - BasePreviewViewModel previewViewModel = new ViewModelProvider(this, createPreviewViewModelFactory()) .get(BasePreviewViewModel.class); - ChooserRequestParameters chooserRequest = requireChooserRequest(); + ChooserRequestParameters chooserRequest2 = requireChooserRequest(); mChooserContentPreviewUi = new ChooserContentPreviewUi( getCoroutineScope(getLifecycle()), - previewViewModel.createOrReuseProvider(chooserRequest.getTargetIntent()), - chooserRequest.getTargetIntent(), + previewViewModel.createOrReuseProvider(chooserRequest2.getTargetIntent()), + chooserRequest2.getTargetIntent(), previewViewModel.createOrReuseImageLoader(), createChooserActionFactory(), mEnterTransitionAnimationDelegate, new HeadlineGeneratorImpl(this)); - updateStickyContentPreview(); if (shouldShowStickyContentPreview() || mChooserMultiProfilePagerAdapter @@ -303,12 +481,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements getEventLog().logActionShareWithPreview( mChooserContentPreviewUi.getPreferredContentPreview()); } - mChooserShownTime = System.currentTimeMillis(); final long systemCost = mChooserShownTime - mIntentReceivedTime.get(); getEventLog().logChooserActivityShown( - isWorkProfile(), chooserRequest.getTargetType(), systemCost); - + isWorkProfile(), chooserRequest2.getTargetType(), systemCost); if (mResolverDrawerLayout != null) { mResolverDrawerLayout.addOnLayoutChangeListener(this::handleLayoutChange); @@ -318,55 +494,765 @@ public class ChooserActivity extends Hilt_ChooserActivity implements getEventLog().logSharesheetExpansionChanged(isCollapsed); }); } - if (DEBUG) { Log.d(TAG, "System Time Cost is " + systemCost); } - getEventLog().logShareStarted( mLogic.getReferrerPackageName(), - chooserRequest.getTargetType(), - chooserRequest.getCallerChooserTargets().size(), - (chooserRequest.getInitialIntents() == null) - ? 0 : chooserRequest.getInitialIntents().length, + chooserRequest2.getTargetType(), + chooserRequest2.getCallerChooserTargets().size(), + (chooserRequest2.getInitialIntents() == null) + ? 0 : chooserRequest2.getInitialIntents().length, isWorkProfile(), mChooserContentPreviewUi.getPreferredContentPreview(), - chooserRequest.getTargetAction(), - chooserRequest.getChooserActions().size(), - chooserRequest.getModifyShareAction() != null + chooserRequest2.getTargetAction(), + chooserRequest2.getChooserActions().size(), + chooserRequest2.getModifyShareAction() != null ); - mEnterTransitionAnimationDelegate.postponeTransition(); + + restore(savedInstanceState); } - protected final Unit onPreinitialization() { - mIntentReceivedTime.set(System.currentTimeMillis()); - mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET); + private void restore(@Nullable Bundle savedInstanceState) { + if (savedInstanceState != null) { + // onRestoreInstanceState + //resetButtonBar(); + ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); + if (viewPager != null) { + viewPager.setCurrentItem(savedInstanceState.getInt(LAST_SHOWN_TAB_KEY)); + } + } - mPinnedSharedPrefs = getPinnedSharedPrefs(this); - mMaxTargetsPerRow = - getResources().getInteger(R.integer.config_chooser_max_targets_per_row); - mShouldDisplayLandscape = - shouldDisplayLandscape(getResources().getConfiguration().orientation); + mChooserMultiProfilePagerAdapter.clearInactiveProfileCache(); + } + ////////////////////////////////////////////////////////////////////////////////////////////// + // Inherited methods + ////////////////////////////////////////////////////////////////////////////////////////////// - ChooserRequestParameters chooserRequest = getChooserRequest(); - if (chooserRequest == null) { - return Unit.INSTANCE; + private boolean isAutolaunching() { + return !mRegistered && isFinishing(); + } + + private boolean maybeAutolaunchIfSingleTarget() { + int count = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); + if (count != 1) { + return false; + } + + if (mChooserMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile() != null) { + return false; + } + + // Only one target, so we're a candidate to auto-launch! + final TargetInfo target = mChooserMultiProfilePagerAdapter.getActiveListAdapter() + .targetInfoForPosition(0, false); + if (shouldAutoLaunchSingleChoice(target)) { + safelyStartActivity(target); + finish(); + return true; + } + return false; + } + + private int isPermissionGranted(String permission, int uid) { + return ActivityManager.checkComponentPermission(permission, uid, + /* owningUid= */-1, /* exported= */ true); + } + + /** + * Returns whether the package has the necessary permissions to interact across profiles on + * behalf of a given user. + * + *

This means meeting the following condition: + *

    + *
  • The app's {@link ApplicationInfo#crossProfile} flag must be true, and at least + * one of the following conditions must be fulfilled
  • + *
  • {@code Manifest.permission.INTERACT_ACROSS_USERS_FULL} granted.
  • + *
  • {@code Manifest.permission.INTERACT_ACROSS_USERS} granted.
  • + *
  • {@code Manifest.permission.INTERACT_ACROSS_PROFILES} granted, or the corresponding + * AppOps {@code android:interact_across_profiles} is set to "allow".
  • + *
+ * + */ + private boolean canAppInteractCrossProfiles(String packageName) { + ApplicationInfo applicationInfo; + try { + applicationInfo = getPackageManager().getApplicationInfo(packageName, 0); + } catch (PackageManager.NameNotFoundException e) { + Log.e(TAG, "Package " + packageName + " does not exist on current user."); + return false; + } + if (!applicationInfo.crossProfile) { + return false; + } + + int packageUid = applicationInfo.uid; + + if (isPermissionGranted(android.Manifest.permission.INTERACT_ACROSS_USERS_FULL, + packageUid) == PackageManager.PERMISSION_GRANTED) { + return true; } - setRetainInOnStop(chooserRequest.shouldRetainInOnStop()); + if (isPermissionGranted(android.Manifest.permission.INTERACT_ACROSS_USERS, packageUid) + == PackageManager.PERMISSION_GRANTED) { + return true; + } + return PermissionChecker.checkPermissionForPreflight(this, INTERACT_ACROSS_PROFILES, + PID_UNKNOWN, packageUid, packageName) == PackageManager.PERMISSION_GRANTED; + } - createProfileRecords( - new AppPredictorFactory( + private boolean isTwoPagePersonalAndWorkConfiguration() { + return (mChooserMultiProfilePagerAdapter.getCount() == 2) + && mChooserMultiProfilePagerAdapter.hasPageForProfile(PROFILE_PERSONAL) + && mChooserMultiProfilePagerAdapter.hasPageForProfile(PROFILE_WORK); + } + + /** + * When we have a personal and a work profile, we auto launch in the following scenario: + * - There is 1 resolved target on each profile + * - That target is the same app on both profiles + * - The target app has permission to communicate cross profiles + * - The target app has declared it supports cross-profile communication via manifest metadata + */ + private boolean maybeAutolaunchIfCrossProfileSupported() { + if (!isTwoPagePersonalAndWorkConfiguration()) { + return false; + } + + ResolverListAdapter activeListAdapter = + (mChooserMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mChooserMultiProfilePagerAdapter.getPersonalListAdapter() + : mChooserMultiProfilePagerAdapter.getWorkListAdapter(); + + ResolverListAdapter inactiveListAdapter = + (mChooserMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mChooserMultiProfilePagerAdapter.getWorkListAdapter() + : mChooserMultiProfilePagerAdapter.getPersonalListAdapter(); + + if (!activeListAdapter.isTabLoaded() || !inactiveListAdapter.isTabLoaded()) { + return false; + } + + if ((activeListAdapter.getUnfilteredCount() != 1) + || (inactiveListAdapter.getUnfilteredCount() != 1)) { + return false; + } + + TargetInfo activeProfileTarget = activeListAdapter.targetInfoForPosition(0, false); + TargetInfo inactiveProfileTarget = inactiveListAdapter.targetInfoForPosition(0, false); + if (!Objects.equals( + activeProfileTarget.getResolvedComponentName(), + inactiveProfileTarget.getResolvedComponentName())) { + return false; + } + + if (!shouldAutoLaunchSingleChoice(activeProfileTarget)) { + return false; + } + + String packageName = activeProfileTarget.getResolvedComponentName().getPackageName(); + if (!canAppInteractCrossProfiles(packageName)) { + return false; + } + + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_AUTOLAUNCH_CROSS_PROFILE_TARGET) + .setBoolean(activeListAdapter.getUserHandle() + .equals(requireAnnotatedUserHandles().personalProfileUserHandle)) + .setStrings(getMetricsCategory()) + .write(); + safelyStartActivity(activeProfileTarget); + finish(); + return true; + } + + /** + * @return {@code true} if a resolved target is autolaunched, otherwise {@code false} + */ + private boolean maybeAutolaunchActivity() { + int numberOfProfiles = mChooserMultiProfilePagerAdapter.getItemCount(); + // 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. + if (numberOfProfiles == 1 && maybeAutolaunchIfSingleTarget()) { + return true; + } else if (maybeAutolaunchIfCrossProfileSupported()) { + return true; + } + return false; + } + + @Override // ResolverListCommunicator + public final void onPostListReady(ResolverListAdapter listAdapter, boolean doPostProcessing, + boolean rebuildCompleted) { + if (isAutolaunching()) { + return; + } + if (mChooserMultiProfilePagerAdapter + .shouldShowEmptyStateScreen((ChooserListAdapter) listAdapter)) { + mChooserMultiProfilePagerAdapter + .showEmptyResolverListEmptyState((ChooserListAdapter) listAdapter); + } else { + mChooserMultiProfilePagerAdapter.showListView((ChooserListAdapter) listAdapter); + } + // showEmptyResolverListEmptyState can mark the tab as loaded, + // which is a precondition for auto launching + if (rebuildCompleted && maybeAutolaunchActivity()) { + return; + } + if (doPostProcessing) { + maybeCreateHeader(listAdapter); + onListRebuilt(listAdapter, rebuildCompleted); + } + } + + private CharSequence getOrLoadDisplayLabel(TargetInfo info) { + if (info.isDisplayResolveInfo()) { + mLogic.getTargetDataLoader().getOrLoadLabel((DisplayResolveInfo) info); + } + CharSequence displayLabel = info.getDisplayLabel(); + return displayLabel == null ? "" : displayLabel; + } + + protected final CharSequence getTitleForAction(Intent intent, int defaultTitleRes) { + final ActionTitle title = ActionTitle.forAction(intent.getAction()); + + // While there may already be a filtered item, we can only use it in the title if the list + // is already sorted and all information relevant to it is already in the list. + final boolean named = + mChooserMultiProfilePagerAdapter.getActiveListAdapter().getFilteredPosition() >= 0; + if (title == ActionTitle.DEFAULT && defaultTitleRes != 0) { + return getString(defaultTitleRes); + } else { + return named + ? getString( + title.namedTitleRes, + getOrLoadDisplayLabel( + mChooserMultiProfilePagerAdapter + .getActiveListAdapter().getFilteredItem())) + : getString(title.titleRes); + } + } + + /** + * Configure the area above the app selection list (title, content preview, etc). + */ + private void maybeCreateHeader(ResolverListAdapter listAdapter) { + if (mHeaderCreatorUser != null + && !listAdapter.getUserHandle().equals(mHeaderCreatorUser)) { + return; + } + if (!hasWorkProfile() + && listAdapter.getCount() == 0 && listAdapter.getPlaceholderCount() == 0) { + final TextView titleView = findViewById(com.android.internal.R.id.title); + if (titleView != null) { + titleView.setVisibility(View.GONE); + } + } + + CharSequence title = mLogic.getTitle() != null + ? mLogic.getTitle() + : getTitleForAction(mLogic.getTargetIntent(), mLogic.getDefaultTitleResId()); + + if (!TextUtils.isEmpty(title)) { + final TextView titleView = findViewById(com.android.internal.R.id.title); + if (titleView != null) { + titleView.setText(title); + } + setTitle(title); + } + + final ImageView iconView = findViewById(com.android.internal.R.id.icon); + if (iconView != null) { + listAdapter.loadFilteredItemIconTaskAsync(iconView); + } + mHeaderCreatorUser = listAdapter.getUserHandle(); + } + + @Override + protected final void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); + if (viewPager != null) { + outState.putInt(LAST_SHOWN_TAB_KEY, viewPager.getCurrentItem()); + } + } + + @Override + protected final void onRestart() { + super.onRestart(); + if (!mRegistered) { + mPersonalPackageMonitor.register( + this, + getMainLooper(), + requireAnnotatedUserHandles().personalProfileUserHandle, + false); + if (hasWorkProfile()) { + if (mWorkPackageMonitor == null) { + mWorkPackageMonitor = createPackageMonitor( + mChooserMultiProfilePagerAdapter.getWorkListAdapter()); + } + mWorkPackageMonitor.register( this, - chooserRequest.getSharedText(), - chooserRequest.getTargetIntentFilter() - ), - chooserRequest.getTargetIntentFilter() + getMainLooper(), + requireAnnotatedUserHandles().workProfileUserHandle, + false); + } + mRegistered = true; + } + WorkProfileAvailabilityManager workProfileAvailabilityManager = + mLogic.getWorkProfileAvailabilityManager(); + if (hasWorkProfile() && workProfileAvailabilityManager.isWaitingToEnableWorkProfile()) { + if (workProfileAvailabilityManager.isQuietModeEnabled()) { + workProfileAvailabilityManager.markWorkProfileEnabledBroadcastReceived(); + } + } + mChooserMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); + } + + public boolean super_shouldAutoLaunchSingleChoice(TargetInfo target) { + return !target.isSuspended(); + } + + /** 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 the adapter's userHandle. resolveInfo.userHandle + // identifies the correct user space in such cases. + UserHandle activityUserHandle = cti.getResolveInfo().userHandle; + safelyStartActivityAsUser(cti, activityUserHandle, null); + } + + protected final void safelyStartActivityAsUser( + TargetInfo cti, UserHandle user, @Nullable Bundle options) { + // We're dispatching intents that might be coming from legacy apps, so + // don't kill ourselves. + StrictMode.disableDeathOnFileUriExposure(); + try { + safelyStartActivityInternal(cti, user, options); + } finally { + StrictMode.enableDeathOnFileUriExposure(); + } + } + + @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 + if (!cti.isSuspended() && mRegistered) { + if (mPersonalPackageMonitor != null) { + mPersonalPackageMonitor.unregister(); + } + if (mWorkPackageMonitor != null) { + mWorkPackageMonitor.unregister(); + } + mRegistered = false; + } + // If needed, show that intent is forwarded + // from managed profile to owner or other way around. + String profileSwitchMessage = mLogic.forwardMessageFor(mLogic.getTargetIntent()); + if (profileSwitchMessage != null) { + Toast.makeText(this, profileSwitchMessage, Toast.LENGTH_LONG).show(); + } + try { + if (cti.startAsCaller(this, options, user.getIdentifier())) { + onActivityStarted(cti); + maybeLogCrossProfileTargetLaunch(cti, user); + } + } catch (RuntimeException e) { + Slog.wtf(TAG, + "Unable to launch as uid " + requireAnnotatedUserHandles().userIdOfCallingApp + + " package " + getLaunchedFromPackage() + ", while running in " + + ActivityThread.currentProcessName(), e); + } + } + + private void maybeLogCrossProfileTargetLaunch(TargetInfo cti, UserHandle currentUserHandle) { + if (!hasWorkProfile() || currentUserHandle.equals(getUser())) { + return; + } + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_CROSS_PROFILE_TARGET_OPENED) + .setBoolean( + currentUserHandle.equals( + requireAnnotatedUserHandles().personalProfileUserHandle)) + .setStrings(getMetricsCategory(), + cti.isInDirectShareMetricsCategory() ? "direct_share" : "other_target") + .write(); + } + + private boolean hasWorkProfile() { + return requireAnnotatedUserHandles().workProfileUserHandle != null; + } + private LatencyTracker getLatencyTracker() { + return LatencyTracker.getInstance(this); + } + + /** + * If {@code retainInOnStop} is set to true, we will not finish ourselves when onStop gets + * called and we are launched in a new task. + */ + protected final void setRetainInOnStop(boolean retainInOnStop) { + mRetainInOnStop = retainInOnStop; + } + + // @NonFinalForTesting + @VisibleForTesting + protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { + return new CrossProfileIntentsChecker(getContentResolver()); + } + + protected final EmptyStateProvider createEmptyStateProvider( + @Nullable UserHandle workProfileUserHandle) { + final EmptyStateProvider blockerEmptyStateProvider = createBlockerEmptyStateProvider(); + + final EmptyStateProvider workProfileOffEmptyStateProvider = + new WorkProfilePausedEmptyStateProvider(this, workProfileUserHandle, + mLogic.getWorkProfileAvailabilityManager(), + /* onSwitchOnWorkSelectedListener= */ + () -> { + if (mOnSwitchOnWorkSelectedListener != null) { + mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); + } + }, + getMetricsCategory()); + + final EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider( + this, + workProfileUserHandle, + requireAnnotatedUserHandles().personalProfileUserHandle, + getMetricsCategory(), + requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch ); - return Unit.INSTANCE; + + // Return composite provider, the order matters (the higher, the more priority) + return new CompositeEmptyStateProvider( + blockerEmptyStateProvider, + workProfileOffEmptyStateProvider, + noAppsEmptyStateProvider + ); + } + + private boolean supportsManagedProfiles(ResolveInfo resolveInfo) { + try { + ApplicationInfo appInfo = getPackageManager().getApplicationInfo( + resolveInfo.activityInfo.packageName, 0 /* default flags */); + return appInfo.targetSdkVersion >= Build.VERSION_CODES.LOLLIPOP; + } catch (PackageManager.NameNotFoundException e) { + return false; + } + } + + @Override + protected final void onStart() { + super.onStart(); + + this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); + if (hasWorkProfile()) { + mLogic.getWorkProfileAvailabilityManager().registerWorkProfileStateReceiver(this); + } + } + + private boolean hasManagedProfile() { + UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE); + if (userManager == null) { + return false; + } + + try { + List profiles = userManager.getProfiles(getUserId()); + for (UserInfo userInfo : profiles) { + if (userInfo != null && userInfo.isManagedProfile()) { + return true; + } + } + } catch (SecurityException e) { + return false; + } + return false; + } + + /** + * 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 requireAnnotatedUserHandles().getQueryIntentsUser(userHandle); + } + + protected final boolean isLaunchedAsCloneProfile() { + UserHandle launchUser = requireAnnotatedUserHandles().userHandleSharesheetLaunchedAs; + UserHandle cloneUser = requireAnnotatedUserHandles().cloneProfileUserHandle; + return hasCloneProfile() && launchUser.equals(cloneUser); + } + + private boolean hasCloneProfile() { + return requireAnnotatedUserHandles().cloneProfileUserHandle != null; + } + + /** + * 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(requireAnnotatedUserHandles().personalProfileUserHandle) + && hasCloneProfile()) { + userList.add(requireAnnotatedUserHandles().cloneProfileUserHandle); + } + return userList; + } + + /** + * Start activity as a fixed user handle. + * @param cti TargetInfo to be launched. + * @param user User to launch this activity as. + */ + @VisibleForTesting(visibility = VisibleForTesting.Visibility.PROTECTED) + public final void safelyStartActivityAsUser(TargetInfo cti, UserHandle user) { + safelyStartActivityAsUser(cti, user, null); + } + + protected WindowInsets super_onApplyWindowInsets(View v, WindowInsets insets) { + mSystemWindowInsets = insets.getSystemWindowInsets(); + + mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top, + mSystemWindowInsets.right, 0); + + // Need extra padding so the list can fully scroll up + // To accommodate for window insets + applyFooterView(mSystemWindowInsets.bottom); + + return insets.consumeSystemWindowInsets(); + } + + @Override // ResolverListCommunicator + public final void onHandlePackagesChanged(ResolverListAdapter listAdapter) { + if (!mChooserMultiProfilePagerAdapter.onHandlePackagesChanged( + (ChooserListAdapter) listAdapter, + mLogic.getWorkProfileAvailabilityManager().isWaitingToEnableWorkProfile())) { + // We no longer have any items... just finish the activity. + finish(); + } + } + + final Option optionForChooserTarget(TargetInfo target, int index) { + return new Option(getOrLoadDisplayLabel(target), index); + } + + @Override // ResolverListCommunicator + public final void sendVoiceChoicesIfNeeded() { + if (!isVoiceInteraction()) { + // Clearly not needed. + return; + } + + int count = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getCount(); + final Option[] options = new Option[count]; + for (int i = 0; i < options.length; i++) { + TargetInfo target = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getItem(i); + if (target == null) { + // If this occurs, a new set of targets is being loaded. Let that complete, + // and have the next call to send voice choices proceed instead. + return; + } + options[i] = optionForChooserTarget(target, i); + } + + mPickOptionRequest = new ResolverActivity.PickTargetOptionRequest( + new VoiceInteractor.Prompt(getTitle()), options, null); + getVoiceInteractor().submitRequest(mPickOptionRequest); + } + + /** + * Sets up the content view. + * @return true if the activity is finishing and creation should halt. + */ + private boolean configureContentView(TargetDataLoader targetDataLoader) { + if (mChooserMultiProfilePagerAdapter.getActiveListAdapter() == null) { + throw new IllegalStateException("mMultiProfilePagerAdapter.getCurrentListAdapter() " + + "cannot be null."); + } + Trace.beginSection("configureContentView"); + // We partially rebuild the inactive adapter to determine if we should auto launch + // isTabLoaded will be true here if the empty state screen is shown instead of the list. + boolean rebuildCompleted = mChooserMultiProfilePagerAdapter.rebuildTabs(hasWorkProfile()); + + mLayoutId = mFeatureFlags.scrollablePreview() + ? R.layout.chooser_grid_scrollable_preview + : R.layout.chooser_grid; + + setContentView(mLayoutId); + mChooserMultiProfilePagerAdapter.setupViewPager( + requireViewById(com.android.internal.R.id.profile_pager)); + boolean result = postRebuildList(rebuildCompleted); + Trace.endSection(); + return result; + } + + /** + * Finishing procedures to be performed after the list has been rebuilt. + *

Subclasses must call postRebuildListInternal at the end of postRebuildList. + * @param rebuildCompleted + * @return true if the activity is finishing and creation should halt. + */ + protected boolean postRebuildList(boolean rebuildCompleted) { + return postRebuildListInternal(rebuildCompleted); + } + + /** + * Add a label to signify that the user can pick a different app. + * @param adapter The adapter used to provide data to item views. + */ + public void addUseDifferentAppLabelIfNecessary(ResolverListAdapter adapter) { + final boolean useHeader = adapter.hasFilteredItem(); + if (useHeader) { + FrameLayout stub = findViewById(com.android.internal.R.id.stub); + stub.setVisibility(View.VISIBLE); + TextView textView = (TextView) LayoutInflater.from(this).inflate( + R.layout.resolver_different_item_header, null, false); + if (hasWorkProfile()) { + textView.setGravity(Gravity.CENTER); + } + stub.addView(textView); + } + } + private void setupViewVisibilities() { + ChooserListAdapter activeListAdapter = + mChooserMultiProfilePagerAdapter.getActiveListAdapter(); + if (!mChooserMultiProfilePagerAdapter.shouldShowEmptyStateScreen(activeListAdapter)) { + addUseDifferentAppLabelIfNecessary(activeListAdapter); + } + } + /** + * Finishing procedures to be performed after the list has been rebuilt. + * @param rebuildCompleted + * @return true if the activity is finishing and creation should halt. + */ + final boolean postRebuildListInternal(boolean rebuildCompleted) { + int count = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); + + // We only rebuild asynchronously when we have multiple elements to sort. In the case where + // we're already done, we can check if we should auto-launch immediately. + if (rebuildCompleted && maybeAutolaunchActivity()) { + return true; + } + + setupViewVisibilities(); + + if (hasWorkProfile()) { + setupProfileTabs(); + } + + return false; + } + + private void updateActiveTabStyle(TabHost tabHost) { + int currentTab = tabHost.getCurrentTab(); + TextView selected = (TextView) tabHost.getTabWidget().getChildAt(currentTab); + TextView unselected = (TextView) tabHost.getTabWidget().getChildAt(1 - currentTab); + selected.setSelected(true); + unselected.setSelected(false); + } + + private void setupProfileTabs() { + TabHost tabHost = findViewById(com.android.internal.R.id.profile_tabhost); + tabHost.setup(); + ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); + viewPager.setSaveEnabled(false); + + Button personalButton = (Button) getLayoutInflater().inflate( + R.layout.resolver_profile_tab_button, tabHost.getTabWidget(), false); + personalButton.setText(mDevicePolicyResources.getPersonalTabLabel()); + personalButton.setContentDescription( + mDevicePolicyResources.getPersonalTabAccessibilityLabel()); + + TabHost.TabSpec tabSpec = tabHost.newTabSpec(TAB_TAG_PERSONAL) + .setContent(com.android.internal.R.id.profile_pager) + .setIndicator(personalButton); + tabHost.addTab(tabSpec); + + Button workButton = (Button) getLayoutInflater().inflate( + R.layout.resolver_profile_tab_button, tabHost.getTabWidget(), false); + workButton.setText(mDevicePolicyResources.getWorkTabLabel()); + workButton.setContentDescription(mDevicePolicyResources.getWorkTabAccessibilityLabel()); + + tabSpec = tabHost.newTabSpec(TAB_TAG_WORK) + .setContent(com.android.internal.R.id.profile_pager) + .setIndicator(workButton); + tabHost.addTab(tabSpec); + + TabWidget tabWidget = tabHost.getTabWidget(); + tabWidget.setVisibility(View.VISIBLE); + updateActiveTabStyle(tabHost); + + tabHost.setOnTabChangedListener(tabId -> { + updateActiveTabStyle(tabHost); + if (TAB_TAG_PERSONAL.equals(tabId)) { + viewPager.setCurrentItem(0); + } else { + viewPager.setCurrentItem(1); + } + setupViewVisibilities(); + maybeLogProfileChange(); + onProfileTabSelected(); + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_SWITCH_TABS) + .setInt(viewPager.getCurrentItem()) + .setStrings(getMetricsCategory()) + .write(); + }); + + viewPager.setVisibility(View.VISIBLE); + tabHost.setCurrentTab(mChooserMultiProfilePagerAdapter.getCurrentPage()); + mChooserMultiProfilePagerAdapter.setOnProfileSelectedListener( + new MultiProfilePagerAdapter.OnProfileSelectedListener() { + @Override + public void onProfileSelected(int index) { + tabHost.setCurrentTab(index); + + } + + @Override + public void onProfilePageStateChanged(int state) { + onHorizontalSwipeStateChanged(state); + } + }); + mOnSwitchOnWorkSelectedListener = () -> { + final View workTab = tabHost.getTabWidget().getChildAt(1); + workTab.setFocusable(true); + workTab.setFocusableInTouchMode(true); + workTab.requestFocus(); + }; + } + + public void super_onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + mChooserMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); + + if (mSystemWindowInsets != null) { + mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top, + mSystemWindowInsets.right, 0); + } + } + + ////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////// + @Nullable private ChooserRequestParameters getChooserRequest() { return ((ChooserActivityLogic) mLogic).getChooserRequestParameters(); @@ -435,13 +1321,12 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return context.getSharedPreferences(PINNED_SHARED_PREFS_NAME, MODE_PRIVATE); } - @Override protected ChooserMultiProfilePagerAdapter createMultiProfilePagerAdapter( Intent[] initialIntents, List rList, boolean filterLastUsed, TargetDataLoader targetDataLoader) { - if (shouldShowTabs()) { + if (hasWorkProfile()) { mChooserMultiProfilePagerAdapter = createChooserMultiProfilePagerAdapterForTwoProfiles( initialIntents, rList, filterLastUsed, targetDataLoader); } else { @@ -451,7 +1336,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return mChooserMultiProfilePagerAdapter; } - @Override protected EmptyStateProvider createBlockerEmptyStateProvider() { final boolean isSendAction = requireChooserRequest().isSendActionTarget(); @@ -549,12 +1433,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private int findSelectedProfile() { - int selectedProfile = getSelectedProfileExtra(); - if (selectedProfile == -1) { - selectedProfile = getProfileForUser( - requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch); - } - return selectedProfile; + return getProfileForUser(requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch); } /** @@ -567,7 +1446,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements .getUserInfo(UserHandle.myUserId()).isManagedProfile(); } - @Override + //@Override protected PackageMonitor createPackageMonitor(ResolverListAdapter listAdapter) { return new PackageMonitor() { @Override @@ -597,7 +1476,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } else { listAdapter.handlePackagesChanged(); } - updateProfileViewButton(); } @Override @@ -610,10 +1488,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override public void onConfigurationChanged(Configuration newConfig) { - super.onConfigurationChanged(newConfig); + super_onConfigurationChanged(newConfig); ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); if (viewPager.isLayoutRtl()) { - mMultiProfilePagerAdapter.setupViewPager(viewPager); + mChooserMultiProfilePagerAdapter.setupViewPager(viewPager); } mShouldDisplayLandscape = shouldDisplayLandscape(newConfig.orientation); @@ -643,7 +1521,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private void updateTabPadding() { - if (shouldShowTabs()) { + if (hasWorkProfile()) { View tabs = findViewById(com.android.internal.R.id.tabs); float iconSize = getResources().getDimension(R.dimen.chooser_icon_size); // The entire width consists of icons or padding. Divide the item padding in half to get @@ -706,6 +1584,35 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override protected void onStop() { super.onStop(); + + final Window window = this.getWindow(); + final WindowManager.LayoutParams attrs = window.getAttributes(); + attrs.privateFlags &= ~SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; + window.setAttributes(attrs); + + if (mRegistered) { + mPersonalPackageMonitor.unregister(); + if (mWorkPackageMonitor != null) { + mWorkPackageMonitor.unregister(); + } + mRegistered = false; + } + final Intent intent = getIntent(); + if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction() + && !mLogic.getResolvingHome() && !mRetainInOnStop) { + // This resolver is in the unusual situation where it has been + // launched at the top of a new task. We don't let it be added + // to the recent tasks shown to the user, and we need to make sure + // that each time we are launched we get the correct launching + // uid (not re-using the same resolver from an old launching uid), + // so we will now finish ourself since being no longer visible, + // the user probably can't get back to us. + if (!isChangingConfigurations()) { + finish(); + } + } + mLogic.getWorkProfileAvailabilityManager().unregisterWorkProfileStateReceiver(this); + if (mRefinementManager != null) { mRefinementManager.onActivityStop(isChangingConfigurations()); } @@ -765,7 +1672,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return result; } - @Override public void onActivityStarted(TargetInfo cti) { ChooserRequestParameters chooserRequest = requireChooserRequest(); if (chooserRequest.getChosenComponentSender() != null) { @@ -798,24 +1704,16 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } } - @Override - public int getLayoutResource() { - return mFeatureFlags.scrollablePreview() - ? R.layout.chooser_grid_scrollable_preview - : R.layout.chooser_grid; - } - @Override // ResolverListCommunicator public boolean shouldGetActivityMetadata() { return true; } - @Override public boolean shouldAutoLaunchSingleChoice(TargetInfo target) { // Note that this is only safe because the Intent handled by the ChooserActivity is // guaranteed to contain no extras unknown to the local ClassLoader. That is why this // method can not be replaced in the ResolverActivity whole hog. - if (!super.shouldAutoLaunchSingleChoice(target)) { + if (!super_shouldAutoLaunchSingleChoice(target)) { return false; } @@ -852,8 +1750,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements intentFilter); } - @Override - protected boolean onTargetSelected(TargetInfo target, boolean alwaysCheck) { + protected boolean onTargetSelected(TargetInfo target) { if (mRefinementManager.maybeHandleSelection( target, requireChooserRequest().getRefinementIntentSender(), @@ -863,11 +1760,14 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } updateModelAndChooserCounts(target); maybeRemoveSharedText(target); - return super.onTargetSelected(target, alwaysCheck); + safelyStartActivity(target); + + // Rely on the ActivityManager to pop up a dialog regarding app suspension + // and return false + return !target.isSuspended(); } - @Override - public void startSelected(int which, boolean always, boolean filtered) { + public void startSelected(int which, boolean filtered) { ChooserListAdapter currentListAdapter = mChooserMultiProfilePagerAdapter.getActiveListAdapter(); TargetInfo targetInfo = currentListAdapter @@ -890,8 +1790,23 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return; } } + if (isFinishing()) { + return; + } - super.startSelected(which, always, filtered); + TargetInfo target = mChooserMultiProfilePagerAdapter.getActiveListAdapter() + .targetInfoForPosition(which, filtered); + if (target != null) { + if (onTargetSelected(target)) { + MetricsLogger.action( + this, MetricsEvent.ACTION_APP_DISAMBIG_TAP); + MetricsLogger.action(this, + mChooserMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() + ? MetricsEvent.ACTION_HIDE_APP_DISAMBIG_APP_FEATURED + : MetricsEvent.ACTION_HIDE_APP_DISAMBIG_NONE_FEATURED); + finish(); + } + } // TODO: both of the conditions around this switch logic *should* be redundant, and // can be removed if certain invariants can be guaranteed. In particular, it seems @@ -948,7 +1863,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mIsSuccessfullySelected, selectionCost ); - return; } } } @@ -970,13 +1884,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return -1; } - @Override - protected boolean shouldAddFooterView() { - // To accommodate for window insets - return true; - } - - @Override protected void applyFooterView(int height) { mChooserMultiProfilePagerAdapter.setFooterHeightInEveryAdapter(height); } @@ -1100,27 +2007,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements ? null : record.appPredictor; } - /** - * Sort intents alphabetically based on display label. - */ - static class AzInfoComparator implements Comparator { - Comparator mComparator; - AzInfoComparator(Context context) { - 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(target -> target.getResolveInfo().userHandle.getIdentifier()); - } - - @Override - public int compare( - DisplayResolveInfo lhsp, DisplayResolveInfo rhsp) { - return mComparator.compare(lhsp, rhsp); - } - } - protected EventLog getEventLog() { return mEventLog; } @@ -1183,7 +2069,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements new ChooserGridAdapter.ChooserActivityDelegate() { @Override public boolean shouldShowTabs() { - return ChooserActivity.this.shouldShowTabs(); + return hasWorkProfile(); } @Override @@ -1193,7 +2079,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override public void onTargetSelected(int itemIndex) { - startSelected(itemIndex, false, true); + startSelected(itemIndex, true); } @Override @@ -1209,13 +2095,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements showTargetDetails(longPressedTargetInfo); } } - - @Override - public void updateProfileViewButton(View newButtonFromProfileRow) { - mProfileView = newButtonFromProfileRow; - mProfileView.setOnClickListener(ChooserActivity.this::onProfileClick); - ChooserActivity.this.updateProfileViewButton(); - } }, chooserListAdapter, shouldShowContentPreview(), @@ -1264,17 +2143,21 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mFeatureFlags); } - @Override protected Unit onWorkProfileStatusUpdated() { UserHandle workUser = requireAnnotatedUserHandles().workProfileUserHandle; ProfileRecord record = workUser == null ? null : getProfileRecord(workUser); if (record != null && record.shortcutLoader != null) { record.shortcutLoader.reset(); } - return super.onWorkProfileStatusUpdated(); + if (mChooserMultiProfilePagerAdapter.getCurrentUserHandle().equals( + requireAnnotatedUserHandles().workProfileUserHandle)) { + mChooserMultiProfilePagerAdapter.rebuildActiveTab(true); + } else { + mChooserMultiProfilePagerAdapter.clearInactiveProfileCache(); + } + return Unit.INSTANCE; } - @Override @VisibleForTesting protected ChooserListController createListController(UserHandle userHandle) { AppPredictor appPredictor = getAppPredictor(userHandle); @@ -1429,7 +2312,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements int offset = mSystemWindowInsets != null ? mSystemWindowInsets.bottom : 0; int rowsToShow = gridAdapter.getSystemRowCount() - + gridAdapter.getProfileRowCount() + gridAdapter.getServiceTargetRowCount() + gridAdapter.getCallerAndRankedTargetRowCount(); @@ -1452,7 +2334,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements offset += stickyContentPreview.getHeight(); } - if (shouldShowTabs()) { + if (hasWorkProfile()) { offset += findViewById(com.android.internal.R.id.tabs).getHeight(); } @@ -1509,7 +2391,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return PROFILE_PERSONAL; } - @Override protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildComplete) { setupScrollListener(); maybeSetupGlobalLayoutListener(); @@ -1579,7 +2460,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements adapter.completeServiceTargetLoading(); } - if (mMultiProfilePagerAdapter.getActiveListAdapter() == adapter) { + if (mChooserMultiProfilePagerAdapter.getActiveListAdapter() == adapter) { long duration = Tracer.INSTANCE.endLaunchToShortcutTrace(); if (duration >= 0) { Log.d(TAG, "stat to first shortcut time: " + duration + " ms"); @@ -1594,7 +2475,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (mResolverDrawerLayout == null) { return; } - int elevatedViewResId = shouldShowTabs() ? com.android.internal.R.id.tabs : com.android.internal.R.id.chooser_header; + int elevatedViewResId = hasWorkProfile() ? + com.android.internal.R.id.tabs : com.android.internal.R.id.chooser_header; final View elevatedView = mResolverDrawerLayout.findViewById(elevatedViewResId); final float defaultElevation = elevatedView.getElevation(); final float chooserHeaderScrollElevation = @@ -1632,7 +2514,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private void maybeSetupGlobalLayoutListener() { - if (shouldShowTabs()) { + if (hasWorkProfile()) { return; } final View recyclerView = mChooserMultiProfilePagerAdapter.getActiveAdapterView(); @@ -1666,9 +2548,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (!shouldShowContentPreview()) { return false; } - boolean isEmpty = mMultiProfilePagerAdapter.getListAdapterForUserHandle( + boolean isEmpty = mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle( UserHandle.of(UserHandle.myUserId())).getCount() == 0; - return (mFeatureFlags.scrollablePreview() || shouldShowTabs()) + return (mFeatureFlags.scrollablePreview() || hasWorkProfile()) && (!isEmpty || shouldShowContentPreviewWhenEmpty()); } @@ -1732,33 +2614,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements contentPreviewContainer.setVisibility(View.GONE); } - private View findRootView() { - if (mContentView == null) { - mContentView = findViewById(android.R.id.content); - } - return mContentView; - } - - /** - * Intentionally override the {@link ResolverActivity} implementation as we only need that - * implementation for the intent resolver case. - */ - @Override - public void onButtonClick(View v) {} - - /** - * Intentionally override the {@link ResolverActivity} implementation as we only need that - * implementation for the intent resolver case. - */ - @Override - protected void resetButtonBar() {} - - @Override protected String getMetricsCategory() { return METRICS_CATEGORY_CHOOSER; } - @Override protected void onProfileTabSelected() { // This fixes an edge case where after performing a variety of gestures, vertical scrolling // ends up disabled. That's because at some point the old tab's vertical scrolling is @@ -1769,14 +2628,13 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } } - @Override protected WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { - if (shouldShowTabs()) { + if (hasWorkProfile()) { mChooserMultiProfilePagerAdapter .setEmptyStateBottomOffset(insets.getSystemWindowInsetBottom()); } - WindowInsets result = super.onApplyWindowInsets(v, insets); + WindowInsets result = super_onApplyWindowInsets(v, insets); if (mResolverDrawerLayout != null) { mResolverDrawerLayout.requestLayout(); } @@ -1795,7 +2653,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements layoutManager.setVerticalScrollEnabled(enabled); } - @Override void onHorizontalSwipeStateChanged(int state) { if (state == ViewPager.SCROLL_STATE_DRAGGING) { if (mScrollStatus == SCROLL_STATUS_IDLE) { @@ -1810,7 +2667,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } } - @Override protected void maybeLogProfileChange() { getEventLog().logSharesheetProfileChanged(); } diff --git a/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt b/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt index 7bc39a24..2cc75fab 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt +++ b/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt @@ -6,7 +6,6 @@ import android.util.Log import androidx.activity.ComponentActivity import androidx.annotation.OpenForTesting import com.android.intentresolver.ChooserRequestParameters -import com.android.intentresolver.R import com.android.intentresolver.icons.TargetDataLoader import com.android.intentresolver.v2.util.mutableLazy @@ -22,49 +21,18 @@ private const val TAG = "ChooserActivityLogic" @OpenForTesting open class ChooserActivityLogic( tag: String, - activityProvider: () -> ComponentActivity, + activity: ComponentActivity, onWorkProfileStatusUpdated: () -> Unit, - targetDataLoaderProvider: () -> TargetDataLoader, - private val onPreInitialization: () -> Unit, + override val targetDataLoader: TargetDataLoader, ) : ActivityLogic, CommonActivityLogic by CommonActivityLogicImpl( tag, - activityProvider, + activity, onWorkProfileStatusUpdated, ) { - override val targetIntent: Intent by lazy { chooserRequestParameters?.targetIntent ?: Intent() } - - override val resolvingHome: Boolean = false - - override val title: CharSequence? by lazy { chooserRequestParameters?.title } - - override val defaultTitleResId: Int by lazy { - chooserRequestParameters?.defaultTitleResource ?: 0 - } - - override val initialIntents: List? by lazy { - chooserRequestParameters?.initialIntents?.toList() - } - - override val supportsAlwaysUseOption: Boolean = false - - override val targetDataLoader: TargetDataLoader by lazy { targetDataLoaderProvider() } - - override val themeResId: Int = R.style.Theme_DeviceDefault_Chooser - - private val _profileSwitchMessage = mutableLazy { forwardMessageFor(targetIntent) } - override val profileSwitchMessage: String? by _profileSwitchMessage - - override val payloadIntents: List by lazy { - buildList { - add(targetIntent) - chooserRequestParameters?.additionalTargets?.let { addAll(it) } - } - } - - val chooserRequestParameters: ChooserRequestParameters? by lazy { + val chooserRequestParameters: ChooserRequestParameters? = try { ChooserRequestParameters( (activity as Activity).intent, @@ -75,13 +43,19 @@ open class ChooserActivityLogic( Log.e(tag, "Caller provided invalid Chooser request parameters", e) null } - } - override fun preInitialization() { - onPreInitialization() - } + override val targetIntent: Intent = chooserRequestParameters?.targetIntent ?: Intent() + + override val resolvingHome: Boolean = false + + override val title: CharSequence? = chooserRequestParameters?.title + + override val defaultTitleResId: Int = chooserRequestParameters?.defaultTitleResource ?: 0 + + override val initialIntents: List? = chooserRequestParameters?.initialIntents?.toList() - override fun clearProfileSwitchMessage() { - _profileSwitchMessage.setLazy(null) + override val payloadIntents: List = buildList { + add(targetIntent) + chooserRequestParameters?.additionalTargets?.let { addAll(it) } } } diff --git a/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java index de0a9426..06f4bfae 100644 --- a/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java @@ -32,7 +32,6 @@ import com.android.intentresolver.R; import com.android.intentresolver.emptystate.EmptyStateProvider; import com.android.intentresolver.grid.ChooserGridAdapter; import com.android.intentresolver.measurements.Tracer; -import com.android.internal.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; @@ -42,7 +41,6 @@ import java.util.function.Supplier; /** * A {@link PagerAdapter} which describes the work and personal profile share sheet screens. */ -@VisibleForTesting public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< RecyclerView, ChooserGridAdapter, ChooserListAdapter> { private static final int SINGLE_CELL_SPAN_SIZE = 1; diff --git a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java index 6a286f21..c8e9481a 100644 --- a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java @@ -66,7 +66,7 @@ import java.util.function.Supplier; * be possible to get the list adapter from the page adapter via our * mListAdapterExtractor. */ -public class MultiProfilePagerAdapter< +class MultiProfilePagerAdapter< PageViewT extends ViewGroup, SinglePageAdapterT, ListAdapterT extends ResolverListAdapter> extends PagerAdapter { @@ -101,7 +101,7 @@ public class MultiProfilePagerAdapter< private final UserHandle mCloneProfileUserHandle; private final Supplier mWorkProfileQuietModeChecker; // True when work is quiet. - private Set mLoadedPages; + private final Set mLoadedPages; private int mCurrentPage; private OnProfileSelectedListener mOnProfileSelectedListener; diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index cc91e9bf..a56d15a2 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -38,7 +38,6 @@ import android.app.VoiceInteractor.PickOptionRequest; import android.app.VoiceInteractor.PickOptionRequest.Option; import android.app.VoiceInteractor.Prompt; import android.app.admin.DevicePolicyEventLogger; -import android.app.admin.DevicePolicyManager; import android.content.ComponentName; import android.content.Context; import android.content.Intent; @@ -51,7 +50,6 @@ import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.ResolveInfo; import android.content.pm.UserInfo; import android.content.res.Configuration; -import android.content.res.TypedArray; import android.graphics.Insets; import android.net.Uri; import android.os.Build; @@ -89,8 +87,6 @@ import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.annotation.UiThread; import androidx.fragment.app.FragmentActivity; import androidx.viewpager.widget.ViewPager; @@ -107,7 +103,6 @@ import com.android.intentresolver.emptystate.EmptyState; import com.android.intentresolver.emptystate.EmptyStateProvider; import com.android.intentresolver.icons.TargetDataLoader; import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; -import com.android.intentresolver.v2.MultiProfilePagerAdapter.MyUserIdProvider; import com.android.intentresolver.v2.MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; import com.android.intentresolver.v2.MultiProfilePagerAdapter.Profile; import com.android.intentresolver.v2.data.repository.DevicePolicyResources; @@ -121,7 +116,8 @@ import com.android.internal.annotations.VisibleForTesting; import com.android.internal.content.PackageMonitor; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto; -import com.android.internal.util.LatencyTracker; + +import dagger.hilt.android.AndroidEntryPoint; import kotlin.Unit; @@ -132,30 +128,21 @@ import java.util.List; import java.util.Objects; import java.util.Set; +import javax.inject.Inject; + /** * This is a copy of ResolverActivity to support IntentResolver's ChooserActivity. This code is * *not* the resolver that is actually triggered by the system right now (you want * frameworks/base/core/java/com/android/internal/app/ResolverActivity.java for that), the full * migration is not complete. */ -@UiThread -public class ResolverActivity extends FragmentActivity implements +@AndroidEntryPoint(FragmentActivity.class) +public class ResolverActivity extends Hilt_ResolverActivity implements ResolverListAdapter.ResolverListCommunicator { - - private final List mInit = new ArrayList<>(); + @Inject public DevicePolicyResources mDevicePolicyResources; protected ActivityLogic mLogic; - private DevicePolicyResources mDevicePolicyResources; - - public ResolverActivity() { - mIsIntentPicker = getClass().equals(ResolverActivity.class); - } - - protected ResolverActivity(boolean isIntentPicker) { - mIsIntentPicker = isIntentPicker; - } - private Button mAlwaysButton; private Button mOnceButton; protected View mProfileView; @@ -163,7 +150,6 @@ public class ResolverActivity extends FragmentActivity implements private int mLayoutId; private PickTargetOptionRequest mPickOptionRequest; // Expected to be true if this object is ResolverActivity or is ResolverWrapperActivity. - private final boolean mIsIntentPicker; protected ResolverDrawerLayout mResolverDrawerLayout; protected PackageManager mPm; @@ -176,14 +162,11 @@ public class ResolverActivity extends FragmentActivity implements protected Insets mSystemWindowInsets = null; private Space mFooterSpacer = null; - /** See {@link #setRetainInOnStop}. */ - private boolean mRetainInOnStop; - protected static final String METRICS_CATEGORY_RESOLVER = "intent_resolver"; protected static final String METRICS_CATEGORY_CHOOSER = "intent_chooser"; /** Tracks if we should ignore future broadcasts telling us the work profile is enabled */ - private boolean mWorkProfileHasBeenEnabled = false; + private final boolean mWorkProfileHasBeenEnabled = false; private static final String TAB_TAG_PERSONAL = "personal"; private static final String TAB_TAG_WORK = "work"; @@ -191,8 +174,7 @@ public class ResolverActivity extends FragmentActivity implements private PackageMonitor mPersonalPackageMonitor; private PackageMonitor mWorkPackageMonitor; - @VisibleForTesting - protected MultiProfilePagerAdapter mMultiProfilePagerAdapter; + protected ResolverMultiProfilePagerAdapter mMultiProfilePagerAdapter; // Intent extra for connected audio devices @@ -226,14 +208,11 @@ public class ResolverActivity extends FragmentActivity implements @Nullable private OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; - protected final LatencyTracker mLatencyTracker = getLatencyTracker(); - protected PackageMonitor createPackageMonitor(ResolverListAdapter listAdapter) { return new PackageMonitor() { @Override public void onSomePackagesChanged() { listAdapter.handlePackagesChanged(); - updateProfileViewButton(); } @Override @@ -244,54 +223,30 @@ public class ResolverActivity extends FragmentActivity implements } }; } - protected interface Initializer { - void initialize(ActivityLogic value); - } - - protected void setLogic(ActivityLogic logic) { - mLogic = logic; - } - protected void addInitializer(Runnable initializer) { - mInit.add(initializer); + @VisibleForTesting + protected ActivityLogic createActivityLogic() { + return new ResolverActivityLogic( + TAG, + /* activity = */ this, + this::onWorkProfileStatusUpdated); } @Override - protected void onCreate(Bundle savedInstanceState) { + protected final void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - if (isFinishing()) { - // Performing a clean exit: - // Skip initializing anything. - return; - } - mDevicePolicyResources = new DevicePolicyResources(getApplication().getResources(), - requireNonNull(getSystemService(DevicePolicyManager.class))); - setLogic(new ResolverActivityLogic( - TAG, - () -> this, - this::onWorkProfileStatusUpdated)); - addInitializer(this::init); + setTheme(R.style.Theme_DeviceDefault_Resolver); + mLogic = createActivityLogic(); } @Override protected final void onPostCreate(@Nullable Bundle savedInstanceState) { super.onPostCreate(savedInstanceState); - mInit.forEach(Runnable::run); - - if (savedInstanceState != null) { - resetButtonBar(); - ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - if (viewPager != null) { - viewPager.setCurrentItem(savedInstanceState.getInt(LAST_SHOWN_TAB_KEY)); - } - mMultiProfilePagerAdapter.clearInactiveProfileCache(); - } + init(); + restore(savedInstanceState); } private void init() { - setTheme(mLogic.getThemeResId()); - mLogic.preInitialization(); - Intent intent = mLogic.getTargetIntent(); List initialIntents = mLogic.getInitialIntents(); TargetDataLoader targetDataLoader = mLogic.getTargetDataLoader(); @@ -312,8 +267,8 @@ public class ResolverActivity extends FragmentActivity implements // 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 = mLogic.getSupportsAlwaysUseOption() && !isVoiceInteraction() - && !shouldShowTabs() && !hasCloneProfile(); + boolean filterLastUsed = !isVoiceInteraction() + && !hasWorkProfile() && !hasCloneProfile(); mMultiProfilePagerAdapter = createMultiProfilePagerAdapter( requireNonNullElse(initialIntents, emptyList()).toArray(new Intent[0]), /* resolutionList = */ null, @@ -368,12 +323,6 @@ public class ResolverActivity extends FragmentActivity implements mResolverDrawerLayout = rdl; } - mProfileView = findViewById(com.android.internal.R.id.profile_button); - if (mProfileView != null) { - mProfileView.setOnClickListener(this::onProfileClick); - updateProfileViewButton(); - } - final Set categories = intent.getCategories(); MetricsLogger.action(this, mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() ? MetricsProto.MetricsEvent.ACTION_SHOW_APP_DISAMBIG_APP_FEATURED @@ -382,13 +331,26 @@ public class ResolverActivity extends FragmentActivity implements + (categories != null ? Arrays.toString(categories.toArray()) : "")); } - protected MultiProfilePagerAdapter createMultiProfilePagerAdapter( + private void restore(@Nullable Bundle savedInstanceState) { + if (savedInstanceState != null) { + // onRestoreInstanceState + resetButtonBar(); + ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); + if (viewPager != null) { + viewPager.setCurrentItem(savedInstanceState.getInt(LAST_SHOWN_TAB_KEY)); + } + } + + mMultiProfilePagerAdapter.clearInactiveProfileCache(); + } + + protected ResolverMultiProfilePagerAdapter createMultiProfilePagerAdapter( Intent[] initialIntents, List resolutionList, boolean filterLastUsed, TargetDataLoader targetDataLoader) { - MultiProfilePagerAdapter resolverMultiProfilePagerAdapter = null; - if (shouldShowTabs()) { + ResolverMultiProfilePagerAdapter resolverMultiProfilePagerAdapter = null; + if (hasWorkProfile()) { resolverMultiProfilePagerAdapter = createResolverMultiProfilePagerAdapterForTwoProfiles( initialIntents, resolutionList, filterLastUsed, targetDataLoader); @@ -448,9 +410,7 @@ public class ResolverActivity extends FragmentActivity implements if (useLayoutWithDefault()) return true; View buttonBar = findViewById(com.android.internal.R.id.button_bar); - if (buttonBar == null || buttonBar.getVisibility() == View.GONE) return true; - - return false; + return buttonBar == null || buttonBar.getVisibility() == View.GONE; } protected void applyFooterView(int height) { @@ -492,7 +452,7 @@ public class ResolverActivity extends FragmentActivity implements public void onConfigurationChanged(Configuration newConfig) { super.onConfigurationChanged(newConfig); mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); - if (mIsIntentPicker && shouldShowTabs() && !useLayoutWithDefault() + if (hasWorkProfile() && !useLayoutWithDefault() && !shouldUseMiniResolver()) { updateIntentPickerPaddings(); } @@ -525,7 +485,7 @@ public class ResolverActivity extends FragmentActivity implements } final Intent intent = getIntent(); if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction() - && !mLogic.getResolvingHome() && !mRetainInOnStop) { + && !mLogic.getResolvingHome()) { // This resolver is in the unusual situation where it has been // launched at the top of a new task. We don't let it be added // to the recent tasks shown to the user, and we need to make sure @@ -553,6 +513,7 @@ public class ResolverActivity extends FragmentActivity implements } } + // referenced by layout XML: android:onClick="onButtonClick" public void onButtonClick(View v) { final int id = v.getId(); ListView listView = (ListView) mMultiProfilePagerAdapter.getActiveAdapterView(); @@ -584,15 +545,12 @@ public class ResolverActivity extends FragmentActivity implements return; } if (onTargetSelected(target, always)) { - if (always && mLogic.getSupportsAlwaysUseOption()) { + if (always) { MetricsLogger.action( this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_ALWAYS); - } else if (mLogic.getSupportsAlwaysUseOption()) { - MetricsLogger.action( - this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_JUST_ONCE); } else { MetricsLogger.action( - this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_TAP); + this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_JUST_ONCE); } MetricsLogger.action(this, mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() @@ -602,9 +560,6 @@ public class ResolverActivity extends FragmentActivity implements } } - /** - * Replace me in subclasses! - */ @Override // ResolverListCommunicator public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) { return defIntent; @@ -613,7 +568,7 @@ public class ResolverActivity extends FragmentActivity implements protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildCompleted) { final ItemClickListener listener = new ItemClickListener(); setupAdapterListView((ListView) mMultiProfilePagerAdapter.getActiveAdapterView(), listener); - if (shouldShowTabs() && mIsIntentPicker) { + if (hasWorkProfile()) { final ResolverDrawerLayout rdl = findViewById(com.android.internal.R.id.contentPanel); if (rdl != null) { rdl.setMaxCollapsedHeight(getResources() @@ -628,8 +583,7 @@ public class ResolverActivity extends FragmentActivity implements final ResolveInfo ri = target.getResolveInfo(); final Intent intent = target != null ? target.getResolvedIntent() : null; - if (intent != null && (mLogic.getSupportsAlwaysUseOption() - || mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()) + if (intent != null /*&& mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()*/ && mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredResolveList() != null) { // Build a reasonable intent filter, based on what matched. IntentFilter filter = new IntentFilter(); @@ -672,7 +626,7 @@ public class ResolverActivity extends FragmentActivity implements // or "content:" schemes (see IntentFilter for the reason). if (cat != IntentFilter.MATCH_CATEGORY_TYPE || (!"file".equals(data.getScheme()) - && !"content".equals(data.getScheme()))) { + && !"content".equals(data.getScheme()))) { filter.addDataScheme(data.getScheme()); // Look through the resolved filter to determine which part @@ -730,7 +684,7 @@ public class ResolverActivity extends FragmentActivity implements } int bestMatch = 0; - for (int i=0; iThis method is intended to be overridden by subclasses. - */ - protected void onProfileTabSelected() { } - /** * Add a label to signify that the user can pick a different app. * @param adapter The adapter used to provide data to item views. @@ -858,7 +784,7 @@ public class ResolverActivity extends FragmentActivity implements stub.setVisibility(View.VISIBLE); TextView textView = (TextView) LayoutInflater.from(this).inflate( R.layout.resolver_different_item_header, null, false); - if (shouldShowTabs()) { + if (hasWorkProfile()) { textView.setGravity(Gravity.CENTER); } stub.addView(textView); @@ -866,9 +792,6 @@ public class ResolverActivity extends FragmentActivity implements } protected void resetButtonBar() { - if (!mLogic.getSupportsAlwaysUseOption()) { - return; - } final ViewGroup buttonLayout = findViewById(com.android.internal.R.id.button_bar); if (buttonLayout == null) { Log.e(TAG, "Layout unexpectedly does not have a button bar"); @@ -921,13 +844,6 @@ public class ResolverActivity extends FragmentActivity implements protected void maybeLogProfileChange() {} - // @NonFinalForTesting - @VisibleForTesting - protected MyUserIdProvider createMyUserIdProvider() { - return new MyUserIdProvider(); - } - - // @NonFinalForTesting @VisibleForTesting protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { return new CrossProfileIntentsChecker(getContentResolver()); @@ -969,22 +885,6 @@ public class ResolverActivity extends FragmentActivity implements targetDataLoader); } - private LatencyTracker getLatencyTracker() { - return LatencyTracker.getInstance(this); - } - - /** - * Get the string resource to be used as a label for the link to the resolver activity for an - * action. - * - * @param action The action to resolve - * - * @return The string resource to be used as a label - */ - public static @StringRes int getLabelRes(String action) { - return ActionTitle.forAction(action).labelRes; - } - protected final EmptyStateProvider createEmptyStateProvider( @Nullable UserHandle workProfileUserHandle) { final EmptyStateProvider blockerEmptyStateProvider = createBlockerEmptyStateProvider(); @@ -1143,24 +1043,6 @@ public class ResolverActivity extends FragmentActivity implements return hasCloneProfile() && launchUser.equals(cloneUser); } - protected final boolean shouldShowTabs() { - return hasWorkProfile(); - } - - protected final void onProfileClick(View v) { - final DisplayResolveInfo dri = - mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile(); - if (dri == null) { - return; - } - - // Do not show the profile switch message anymore. - mLogic.clearProfileSwitchMessage(); - - onTargetSelected(dri, false); - finish(); - } - private void updateIntentPickerPaddings() { View titleCont = findViewById(com.android.internal.R.id.title_container); titleCont.setPadding( @@ -1218,26 +1100,6 @@ public class ResolverActivity extends FragmentActivity implements return new Option(getOrLoadDisplayLabel(target), index); } - @Override // ResolverListCommunicator - public final void updateProfileViewButton() { - if (mProfileView == null) { - return; - } - - final DisplayResolveInfo dri = - mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile(); - if (dri != null && !shouldShowTabs()) { - mProfileView.setVisibility(View.VISIBLE); - View text = mProfileView.findViewById(com.android.internal.R.id.profile_button); - if (!(text instanceof TextView)) { - text = mProfileView.findViewById(com.android.internal.R.id.text1); - } - ((TextView) text).setText(dri.getDisplayLabel()); - } else { - mProfileView.setVisibility(View.GONE); - } - } - protected final CharSequence getTitleForAction(Intent intent, int defaultTitleRes) { final ActionTitle title = mLogic.getResolvingHome() ? ActionTitle.HOME @@ -1260,12 +1122,6 @@ public class ResolverActivity extends FragmentActivity implements } } - final void dismiss() { - if (!isFinishing()) { - finish(); - } - } - @Override protected final void onRestart() { super.onRestart(); @@ -1296,7 +1152,15 @@ public class ResolverActivity extends FragmentActivity implements } } mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); - updateProfileViewButton(); + } + + @Override + protected final void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); + if (viewPager != null) { + outState.putInt(LAST_SHOWN_TAB_KEY, viewPager.getCurrentItem()); + } } @Override @@ -1309,15 +1173,6 @@ public class ResolverActivity extends FragmentActivity implements } } - @Override - protected final void onSaveInstanceState(@NonNull Bundle outState) { - super.onSaveInstanceState(outState); - ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - if (viewPager != null) { - outState.putInt(LAST_SHOWN_TAB_KEY, viewPager.getCurrentItem()); - } - } - private boolean hasManagedProfile() { UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE); if (userManager == null) { @@ -1406,10 +1261,8 @@ public class ResolverActivity extends FragmentActivity implements if (isAutolaunching()) { return; } - if (mIsIntentPicker) { - ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) - .setUseLayoutWithDefault(useLayoutWithDefault()); - } + mMultiProfilePagerAdapter.setUseLayoutWithDefault(useLayoutWithDefault()); + if (mMultiProfilePagerAdapter.shouldShowEmptyStateScreen(listAdapter)) { mMultiProfilePagerAdapter.showEmptyResolverListEmptyState(listAdapter); } else { @@ -1458,39 +1311,6 @@ public class ResolverActivity extends FragmentActivity implements } } - @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 - if (!cti.isSuspended() && mRegistered) { - if (mPersonalPackageMonitor != null) { - mPersonalPackageMonitor.unregister(); - } - if (mWorkPackageMonitor != null) { - mWorkPackageMonitor.unregister(); - } - mRegistered = false; - } - // If needed, show that intent is forwarded - // from managed profile to owner or other way around. - String profileSwitchMessage = mLogic.getProfileSwitchMessage(); - if (profileSwitchMessage != null) { - Toast.makeText(this, profileSwitchMessage, Toast.LENGTH_LONG).show(); - } - try { - if (cti.startAsCaller(this, options, user.getIdentifier())) { - onActivityStarted(cti); - maybeLogCrossProfileTargetLaunch(cti, user); - } - } catch (RuntimeException e) { - Slog.wtf(TAG, - "Unable to launch as uid " + requireAnnotatedUserHandles().userIdOfCallingApp - + " package " + getLaunchedFromPackage() + ", while running in " - + ActivityThread.currentProcessName(), e); - } - } - final void showTargetDetails(ResolveInfo ri) { Intent in = new Intent().setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) .setData(Uri.fromParts("package", ri.activityInfo.packageName, null)) @@ -1511,7 +1331,7 @@ public class ResolverActivity extends FragmentActivity implements // We partially rebuild the inactive adapter to determine if we should auto launch // isTabLoaded will be true here if the empty state screen is shown instead of the list. // To date, we really only care about "partially rebuilding" tabs for work and/or personal. - boolean rebuildCompleted = mMultiProfilePagerAdapter.rebuildTabs(shouldShowTabs()); + boolean rebuildCompleted = mMultiProfilePagerAdapter.rebuildTabs(hasWorkProfile()); if (shouldUseMiniResolver()) { configureMiniResolverContent(targetDataLoader); @@ -1541,11 +1361,6 @@ public class ResolverActivity extends FragmentActivity implements mLayoutId = R.layout.miniresolver; setContentView(mLayoutId); - // TODO: try to dedupe and use the pager's `getActiveProfile()` instead of the activity - // `getCurrentProfile()` (or align them if they're not currently equivalent). If they truly - // need to be distinct here, then `getCurrentProfile()` should at *least* get a more - // specific name -- but note that checking `getCurrentProfile()` here, then following - // `getActiveProfile()` to find the "in/active adapter," is exactly the legacy behavior. boolean inWorkProfile = getCurrentProfile() == PROFILE_WORK; ResolverListAdapter sameProfileAdapter = @@ -1604,17 +1419,73 @@ public class ResolverActivity extends FragmentActivity implements && mMultiProfilePagerAdapter.hasPageForProfile(PROFILE_WORK); } + @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 + if (!cti.isSuspended() && mRegistered) { + if (mPersonalPackageMonitor != null) { + mPersonalPackageMonitor.unregister(); + } + if (mWorkPackageMonitor != null) { + mWorkPackageMonitor.unregister(); + } + mRegistered = false; + } + // If needed, show that intent is forwarded + // from managed profile to owner or other way around. + String profileSwitchMessage = mLogic.forwardMessageFor(mLogic.getTargetIntent()); + if (profileSwitchMessage != null) { + Toast.makeText(this, profileSwitchMessage, Toast.LENGTH_LONG).show(); + } + try { + if (cti.startAsCaller(this, options, user.getIdentifier())) { + maybeLogCrossProfileTargetLaunch(cti, user); + } + } catch (RuntimeException e) { + Slog.wtf(TAG, + "Unable to launch as uid " + requireAnnotatedUserHandles().userIdOfCallingApp + + " package " + getLaunchedFromPackage() + ", while running in " + + ActivityThread.currentProcessName(), e); + } + } + + /** + * Finishing procedures to be performed after the list has been rebuilt. + * @param rebuildCompleted + * @return true if the activity is finishing and creation should halt. + */ + final boolean postRebuildListInternal(boolean rebuildCompleted) { + int count = mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); + + // We only rebuild asynchronously when we have multiple elements to sort. In the case where + // we're already done, we can check if we should auto-launch immediately. + if (rebuildCompleted && maybeAutolaunchActivity()) { + return true; + } + + setupViewVisibilities(); + + if (hasWorkProfile()) { + setupProfileTabs(); + } + + return false; + } + + private int isPermissionGranted(String permission, int uid) { + return ActivityManager.checkComponentPermission(permission, uid, + /* owningUid= */-1, /* exported= */ true); + } + /** * Mini resolver should be used when all of the following are true: * 1. This is the intent picker (ResolverActivity). - * 2. There are exactly two tabs, for the "personal" and "work" profiles. - * 3. This profile only has web browser matches. - * 4. The other profile has a single non-browser match. + * 2. This profile only has web browser matches. + * 3. The other profile has a single non-browser match. */ private boolean shouldUseMiniResolver() { - if (!mIsIntentPicker) { - return false; - } if (!isTwoPagePersonalAndWorkConfiguration()) { return false; } @@ -1652,50 +1523,6 @@ public class ResolverActivity extends FragmentActivity implements return true; } - /** - * Finishing procedures to be performed after the list has been rebuilt. - * @param rebuildCompleted - * @return true if the activity is finishing and creation should halt. - */ - final boolean postRebuildListInternal(boolean rebuildCompleted) { - int count = mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); - - // We only rebuild asynchronously when we have multiple elements to sort. In the case where - // we're already done, we can check if we should auto-launch immediately. - if (rebuildCompleted && maybeAutolaunchActivity()) { - return true; - } - - setupViewVisibilities(); - - if (shouldShowTabs()) { - setupProfileTabs(); - } - - return false; - } - - private int isPermissionGranted(String permission, int uid) { - return ActivityManager.checkComponentPermission(permission, uid, - /* owningUid= */-1, /* exported= */ true); - } - - /** - * @return {@code true} if a resolved target is autolaunched, otherwise {@code false} - */ - private boolean maybeAutolaunchActivity() { - int numberOfProfiles = mMultiProfilePagerAdapter.getItemCount(); - if (numberOfProfiles == 1 && maybeAutolaunchIfSingleTarget()) { - return true; - } else if (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; - } - private boolean maybeAutolaunchIfSingleTarget() { int count = mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); if (count != 1) { @@ -1813,94 +1640,70 @@ public class ResolverActivity extends FragmentActivity implements == PackageManager.PERMISSION_GRANTED) { return true; } - if (PermissionChecker.checkPermissionForPreflight(this, INTERACT_ACROSS_PROFILES, - PID_UNKNOWN, packageUid, packageName) == PackageManager.PERMISSION_GRANTED) { - return true; - } - return false; + return PermissionChecker.checkPermissionForPreflight(this, INTERACT_ACROSS_PROFILES, + PID_UNKNOWN, packageUid, packageName) == PackageManager.PERMISSION_GRANTED; } private boolean isAutolaunching() { return !mRegistered && isFinishing(); } - private void setupProfileTabs() { - maybeHideDivider(); - TabHost tabHost = findViewById(com.android.internal.R.id.profile_tabhost); - tabHost.setup(); - ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - viewPager.setSaveEnabled(false); + /** + * @return {@code true} if a resolved target is autolaunched, otherwise {@code false} + */ + private boolean maybeAutolaunchActivity() { + if (!isTwoPagePersonalAndWorkConfiguration()) { + return false; + } - Button personalButton = (Button) getLayoutInflater().inflate( - R.layout.resolver_profile_tab_button, tabHost.getTabWidget(), false); - personalButton.setText(mDevicePolicyResources.getPersonalTabLabel()); - personalButton.setContentDescription( - mDevicePolicyResources.getPersonalTabAccessibilityLabel()); + ResolverListAdapter activeListAdapter = + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getPersonalListAdapter() + : mMultiProfilePagerAdapter.getWorkListAdapter(); - TabHost.TabSpec tabSpec = tabHost.newTabSpec(TAB_TAG_PERSONAL) - .setContent(com.android.internal.R.id.profile_pager) - .setIndicator(personalButton); - tabHost.addTab(tabSpec); + ResolverListAdapter inactiveListAdapter = + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getWorkListAdapter() + : mMultiProfilePagerAdapter.getPersonalListAdapter(); - Button workButton = (Button) getLayoutInflater().inflate( - R.layout.resolver_profile_tab_button, tabHost.getTabWidget(), false); - workButton.setText(mDevicePolicyResources.getWorkTabLabel()); - workButton.setContentDescription(mDevicePolicyResources.getWorkTabAccessibilityLabel()); + if (!activeListAdapter.isTabLoaded() || !inactiveListAdapter.isTabLoaded()) { + return false; + } - tabSpec = tabHost.newTabSpec(TAB_TAG_WORK) - .setContent(com.android.internal.R.id.profile_pager) - .setIndicator(workButton); - tabHost.addTab(tabSpec); + if ((activeListAdapter.getUnfilteredCount() != 1) + || (inactiveListAdapter.getUnfilteredCount() != 1)) { + return false; + } - TabWidget tabWidget = tabHost.getTabWidget(); - tabWidget.setVisibility(View.VISIBLE); - updateActiveTabStyle(tabHost); + TargetInfo activeProfileTarget = activeListAdapter.targetInfoForPosition(0, false); + TargetInfo inactiveProfileTarget = inactiveListAdapter.targetInfoForPosition(0, false); + if (!Objects.equals( + activeProfileTarget.getResolvedComponentName(), + inactiveProfileTarget.getResolvedComponentName())) { + return false; + } - tabHost.setOnTabChangedListener(tabId -> { - updateActiveTabStyle(tabHost); - if (TAB_TAG_PERSONAL.equals(tabId)) { - viewPager.setCurrentItem(0); - } else { - viewPager.setCurrentItem(1); - } - setupViewVisibilities(); - maybeLogProfileChange(); - onProfileTabSelected(); - DevicePolicyEventLogger - .createEvent(DevicePolicyEnums.RESOLVER_SWITCH_TABS) - .setInt(viewPager.getCurrentItem()) - .setStrings(getMetricsCategory()) - .write(); - }); + if (!shouldAutoLaunchSingleChoice(activeProfileTarget)) { + return false; + } - viewPager.setVisibility(View.VISIBLE); - tabHost.setCurrentTab(mMultiProfilePagerAdapter.getCurrentPage()); - mMultiProfilePagerAdapter.setOnProfileSelectedListener( - new MultiProfilePagerAdapter.OnProfileSelectedListener() { - @Override - public void onProfileSelected(int index) { - tabHost.setCurrentTab(index); - resetButtonBar(); - resetCheckedItem(); - } + String packageName = activeProfileTarget.getResolvedComponentName().getPackageName(); + if (!canAppInteractCrossProfiles(packageName)) { + return false; + } - @Override - public void onProfilePageStateChanged(int state) { - onHorizontalSwipeStateChanged(state); - } - }); - mOnSwitchOnWorkSelectedListener = () -> { - final View workTab = tabHost.getTabWidget().getChildAt(1); - workTab.setFocusable(true); - workTab.setFocusableInTouchMode(true); - workTab.requestFocus(); - }; + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_AUTOLAUNCH_CROSS_PROFILE_TARGET) + .setBoolean(activeListAdapter.getUserHandle() + .equals(requireAnnotatedUserHandles().personalProfileUserHandle)) + .setStrings(getMetricsCategory()) + .write(); + safelyStartActivity(activeProfileTarget); + finish(); + return true; } private void maybeHideDivider() { - if (!mIsIntentPicker) { - return; - } final View divider = findViewById(com.android.internal.R.id.divider); if (divider == null) { return; @@ -1909,21 +1712,11 @@ public class ResolverActivity extends FragmentActivity implements } private void resetCheckedItem() { - if (!mIsIntentPicker) { - return; - } mLastSelected = ListView.INVALID_POSITION; ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) .clearCheckedItemsInInactiveProfiles(); } - private static int getAttrColor(Context context, int attr) { - TypedArray ta = context.obtainStyledAttributes(new int[]{attr}); - int colorAccent = ta.getColor(0, 0); - ta.recycle(); - return colorAccent; - } - private void updateActiveTabStyle(TabHost tabHost) { int currentTab = tabHost.getCurrentTab(); TextView selected = (TextView) tabHost.getTabWidget().getChildAt(currentTab); @@ -1957,10 +1750,7 @@ public class ResolverActivity extends FragmentActivity implements private void setupAdapterListView(ListView listView, ItemClickListener listener) { listView.setOnItemClickListener(listener); listView.setOnItemLongClickListener(listener); - - if (mLogic.getSupportsAlwaysUseOption()) { - listView.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE); - } + listView.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE); } /** @@ -1971,7 +1761,7 @@ public class ResolverActivity extends FragmentActivity implements && !listAdapter.getUserHandle().equals(mHeaderCreatorUser)) { return; } - if (!shouldShowTabs() + if (!hasWorkProfile() && listAdapter.getCount() == 0 && listAdapter.getPlaceholderCount() == 0) { final TextView titleView = findViewById(com.android.internal.R.id.title); if (titleView != null) { @@ -2027,19 +1817,9 @@ 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 adapterForCurrentUserHasFilteredItem = - mMultiProfilePagerAdapter.getListAdapterForUserHandle( - requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch - ).hasFilteredItem(); - return mLogic.getSupportsAlwaysUseOption() && adapterForCurrentUserHasFilteredItem; - } - - /** - * If {@code retainInOnStop} is set to true, we will not finish ourselves when onStop gets - * called and we are launched in a new task. - */ - protected final void setRetainInOnStop(boolean retainInOnStop) { - mRetainInOnStop = retainInOnStop; + return mMultiProfilePagerAdapter.getListAdapterForUserHandle( + requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch + ).hasFilteredItem(); } final class ItemClickListener implements AdapterView.OnItemClickListener, @@ -2096,11 +1876,75 @@ public class ResolverActivity extends FragmentActivity implements } - /** Determine whether a given match result is considered "specific" in our application. */ - public static final boolean isSpecificUriMatch(int match) { - match = (match & IntentFilter.MATCH_CATEGORY_MASK); - return match >= IntentFilter.MATCH_CATEGORY_HOST - && match <= IntentFilter.MATCH_CATEGORY_PATH; + private void setupProfileTabs() { + maybeHideDivider(); + TabHost tabHost = findViewById(com.android.internal.R.id.profile_tabhost); + tabHost.setup(); + ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); + viewPager.setSaveEnabled(false); + + Button personalButton = (Button) getLayoutInflater().inflate( + R.layout.resolver_profile_tab_button, tabHost.getTabWidget(), false); + personalButton.setText(mDevicePolicyResources.getPersonalTabLabel()); + personalButton.setContentDescription( + mDevicePolicyResources.getPersonalTabAccessibilityLabel()); + + TabHost.TabSpec tabSpec = tabHost.newTabSpec(TAB_TAG_PERSONAL) + .setContent(com.android.internal.R.id.profile_pager) + .setIndicator(personalButton); + tabHost.addTab(tabSpec); + + Button workButton = (Button) getLayoutInflater().inflate( + R.layout.resolver_profile_tab_button, tabHost.getTabWidget(), false); + workButton.setText(mDevicePolicyResources.getWorkTabLabel()); + workButton.setContentDescription(mDevicePolicyResources.getWorkTabAccessibilityLabel()); + + tabSpec = tabHost.newTabSpec(TAB_TAG_WORK) + .setContent(com.android.internal.R.id.profile_pager) + .setIndicator(workButton); + tabHost.addTab(tabSpec); + + TabWidget tabWidget = tabHost.getTabWidget(); + tabWidget.setVisibility(View.VISIBLE); + updateActiveTabStyle(tabHost); + + tabHost.setOnTabChangedListener(tabId -> { + updateActiveTabStyle(tabHost); + if (TAB_TAG_PERSONAL.equals(tabId)) { + viewPager.setCurrentItem(0); + } else { + viewPager.setCurrentItem(1); + } + setupViewVisibilities(); + maybeLogProfileChange(); + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_SWITCH_TABS) + .setInt(viewPager.getCurrentItem()) + .setStrings(getMetricsCategory()) + .write(); + }); + + viewPager.setVisibility(View.VISIBLE); + tabHost.setCurrentTab(mMultiProfilePagerAdapter.getCurrentPage()); + mMultiProfilePagerAdapter.setOnProfileSelectedListener( + new MultiProfilePagerAdapter.OnProfileSelectedListener() { + @Override + public void onProfileSelected(int index) { + tabHost.setCurrentTab(index); + resetButtonBar(); + resetCheckedItem(); + } + + @Override + public void onProfilePageStateChanged(int state) { + } + }); + mOnSwitchOnWorkSelectedListener = () -> { + final View workTab = tabHost.getTabWidget().getChildAt(1); + workTab.setFocusable(true); + workTab.setFocusableInTouchMode(true); + workTab.requestFocus(); + }; } static final class PickTargetOptionRequest extends PickOptionRequest { diff --git a/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt b/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt index 0e2b25ec..51288e51 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt +++ b/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt @@ -3,7 +3,6 @@ package com.android.intentresolver.v2 import android.content.Intent import androidx.activity.ComponentActivity import androidx.annotation.OpenForTesting -import com.android.intentresolver.R import com.android.intentresolver.icons.DefaultTargetDataLoader import com.android.intentresolver.icons.TargetDataLoader import com.android.intentresolver.v2.util.mutableLazy @@ -12,17 +11,17 @@ import com.android.intentresolver.v2.util.mutableLazy @OpenForTesting open class ResolverActivityLogic( tag: String, - activityProvider: () -> ComponentActivity, + activity: ComponentActivity, onWorkProfileStatusUpdated: () -> Unit, ) : ActivityLogic, CommonActivityLogic by CommonActivityLogicImpl( tag, - activityProvider, + activity, onWorkProfileStatusUpdated, ) { - override val targetIntent: Intent by lazy { + final override val targetIntent: Intent = let { val intent = Intent(activity.intent) intent.setComponent(null) // The resolver activity is set to be hidden from recent tasks. @@ -40,10 +39,9 @@ open class ResolverActivityLogic( intent } - override val resolvingHome: Boolean by lazy { + override val resolvingHome: Boolean = targetIntent.action == Intent.ACTION_MAIN && - targetIntent.categories.singleOrNull() == Intent.CATEGORY_HOME - } + targetIntent.categories.singleOrNull() == Intent.CATEGORY_HOME override val title: CharSequence? = null @@ -51,31 +49,14 @@ open class ResolverActivityLogic( override val initialIntents: List? = null - override val supportsAlwaysUseOption: Boolean = true - - override val targetDataLoader: TargetDataLoader by lazy { - DefaultTargetDataLoader( - activity, - activity.lifecycle, - activity.intent.getBooleanExtra( - ResolverActivity.EXTRA_IS_AUDIO_CAPTURE_DEVICE, - /* defaultValue = */ false, - ), - ) - } - - override val themeResId: Int = R.style.Theme_DeviceDefault_Resolver - - private val _profileSwitchMessage = mutableLazy { forwardMessageFor(targetIntent) } - override val profileSwitchMessage: String? by _profileSwitchMessage + override val targetDataLoader: TargetDataLoader = DefaultTargetDataLoader( + activity, + activity.lifecycle, + activity.intent.getBooleanExtra( + ResolverActivity.EXTRA_IS_AUDIO_CAPTURE_DEVICE, + /* defaultValue = */ false, + ), + ) - override val payloadIntents: List by lazy { listOf(targetIntent) } - - override fun preInitialization() { - // Do nothing - } - - override fun clearProfileSwitchMessage() { - _profileSwitchMessage.setLazy(null) - } + override val payloadIntents: List = listOf(targetIntent) } diff --git a/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java index 21e36614..99de3b4d 100644 --- a/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java @@ -37,7 +37,6 @@ import java.util.function.Supplier; /** * A {@link PagerAdapter} which describes the work and personal profile intent resolver screens. */ -@VisibleForTesting public class ResolverMultiProfilePagerAdapter extends MultiProfilePagerAdapter { private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier; diff --git a/java/src/com/android/intentresolver/v2/ui/ActionTitle.java b/java/src/com/android/intentresolver/v2/ui/ActionTitle.java index 271c6f38..a1e1c7fa 100644 --- a/java/src/com/android/intentresolver/v2/ui/ActionTitle.java +++ b/java/src/com/android/intentresolver/v2/ui/ActionTitle.java @@ -21,7 +21,6 @@ import android.provider.MediaStore; import androidx.annotation.StringRes; import com.android.intentresolver.R; -import com.android.intentresolver.v2.ResolverActivity; /** * Provides a set of related resources for different use cases. diff --git a/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java b/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java index 700be615..8da045dc 100644 --- a/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java @@ -40,6 +40,7 @@ import com.android.intentresolver.TestContentPreviewViewModel; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; +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; @@ -56,15 +57,13 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW private UsageStatsManager mUsm; @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setLogic(new TestChooserActivityLogic( + protected final ActivityLogic createActivityLogic() { + return new TestChooserActivityLogic( "ChooserWrapper", - () -> this, + /* activity = */ this, this::onWorkProfileStatusUpdated, - () -> mTargetDataLoader, - this::onPreinitialization, - sOverrides)); + mTargetDataLoader, + sOverrides); } // ResolverActivity (the base class of ChooserActivity) inspects the launched-from UID at @@ -233,7 +232,7 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW @Override public UserHandle getCurrentUserHandle() { - return mMultiProfilePagerAdapter.getCurrentUserHandle(); + return mChooserMultiProfilePagerAdapter.getCurrentUserHandle(); } @Override diff --git a/tests/activity/src/com/android/intentresolver/v2/ResolverActivityTest.java b/tests/activity/src/com/android/intentresolver/v2/ResolverActivityTest.java index f0911833..993f1760 100644 --- a/tests/activity/src/com/android/intentresolver/v2/ResolverActivityTest.java +++ b/tests/activity/src/com/android/intentresolver/v2/ResolverActivityTest.java @@ -25,8 +25,10 @@ import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; import static androidx.test.espresso.matcher.ViewMatchers.isEnabled; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withText; + import static com.android.intentresolver.MatcherUtils.first; import static com.android.intentresolver.v2.ResolverWrapperActivity.sOverrides; + import static org.hamcrest.CoreMatchers.allOf; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.not; @@ -58,8 +60,12 @@ import com.android.intentresolver.R; import com.android.intentresolver.ResolvedComponentInfo; import com.android.intentresolver.ResolverDataProvider; import com.android.intentresolver.widget.ResolverDrawerLayout; + import com.google.android.collect.Lists; +import dagger.hilt.android.testing.HiltAndroidRule; +import dagger.hilt.android.testing.HiltAndroidTest; + import org.junit.Before; import org.junit.Ignore; import org.junit.Rule; @@ -74,6 +80,7 @@ import java.util.List; * Resolver activity instrumentation tests */ @RunWith(AndroidJUnit4.class) +@HiltAndroidTest public class ResolverActivityTest { private static final UserHandle PERSONAL_USER_HANDLE = androidx.test.platform.app @@ -88,7 +95,10 @@ public class ResolverActivityTest { return clientIntent; } - @Rule + @Rule(order = 0) + public HiltAndroidRule mHiltAndroidRule = new HiltAndroidRule(this); + + @Rule(order = 1) public ActivityTestRule mActivityRule = new ActivityTestRule<>(ResolverWrapperActivity.class, false, false); diff --git a/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java b/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java index a09ee894..fcd6205c 100644 --- a/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java @@ -60,22 +60,17 @@ public class ResolverWrapperActivity extends ResolverActivity { private final CountingIdlingResource mLabelIdlingResource = new CountingIdlingResource("LoadLabelTask"); - public ResolverWrapperActivity() { - super(/* isIntentPicker= */ true); - } - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setLogic(new TestResolverActivityLogic( + protected final ActivityLogic createActivityLogic() { + return new TestResolverActivityLogic( "ResolverWrapper", - () -> this, + this, () -> { onWorkProfileStatusUpdated(); return Unit.INSTANCE; }, sOverrides - )); + ); } public CountingIdlingResource getLabelIdlingResource() { diff --git a/tests/activity/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt b/tests/activity/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt index 198b9236..b6354c7a 100644 --- a/tests/activity/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt +++ b/tests/activity/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt @@ -8,18 +8,16 @@ import com.android.intentresolver.icons.TargetDataLoader /** Activity logic for use when testing [ChooserActivity]. */ class TestChooserActivityLogic( tag: String, - activityProvider: () -> ComponentActivity, + activity: ComponentActivity, onWorkProfileStatusUpdated: () -> Unit, - targetDataLoaderProvider: () -> TargetDataLoader, - onPreinitialization: () -> Unit, + targetDataLoader: TargetDataLoader, private val overrideData: ChooserActivityOverrideData, ) : ChooserActivityLogic( tag, - activityProvider, + activity, onWorkProfileStatusUpdated, - targetDataLoaderProvider, - onPreinitialization, + targetDataLoader, ) { override val annotatedUserHandles: AnnotatedUserHandles? by lazy { diff --git a/tests/activity/src/com/android/intentresolver/v2/TestResolverActivityLogic.kt b/tests/activity/src/com/android/intentresolver/v2/TestResolverActivityLogic.kt index 7581043e..6826f23d 100644 --- a/tests/activity/src/com/android/intentresolver/v2/TestResolverActivityLogic.kt +++ b/tests/activity/src/com/android/intentresolver/v2/TestResolverActivityLogic.kt @@ -7,10 +7,10 @@ import com.android.intentresolver.WorkProfileAvailabilityManager /** Activity logic for use when testing [ResolverActivity]. */ class TestResolverActivityLogic( tag: String, - activityProvider: () -> ComponentActivity, + activity: ComponentActivity, onWorkProfileStatusUpdated: () -> Unit, private val overrideData: ResolverWrapperActivity.OverrideData, -) : ResolverActivityLogic(tag, activityProvider, onWorkProfileStatusUpdated) { +) : ResolverActivityLogic(tag, activity, onWorkProfileStatusUpdated) { override val annotatedUserHandles: AnnotatedUserHandles? by lazy { overrideData.annotatedUserHandles diff --git a/tests/unit/src/com/android/intentresolver/FakeResolverListCommunicator.kt b/tests/unit/src/com/android/intentresolver/FakeResolverListCommunicator.kt index 5e9cd98f..b25f4036 100644 --- a/tests/unit/src/com/android/intentresolver/FakeResolverListCommunicator.kt +++ b/tests/unit/src/com/android/intentresolver/FakeResolverListCommunicator.kt @@ -27,8 +27,6 @@ class FakeResolverListCommunicator(private val layoutWithDefaults: Boolean = tru val sendVoiceCommandCount get() = sendVoiceCounter.get() - val updateProfileViewButtonCount - get() = updateProfileViewButtonCounter.get() override fun getReplacementIntent(activityInfo: ActivityInfo?, defIntent: Intent): Intent { return defIntent @@ -44,10 +42,6 @@ class FakeResolverListCommunicator(private val layoutWithDefaults: Boolean = tru sendVoiceCounter.incrementAndGet() } - override fun updateProfileViewButton() { - updateProfileViewButtonCounter.incrementAndGet() - } - override fun useLayoutWithDefault(): Boolean = layoutWithDefaults override fun shouldGetActivityMetadata(): Boolean = true diff --git a/tests/unit/src/com/android/intentresolver/ResolverListAdapterTest.kt b/tests/unit/src/com/android/intentresolver/ResolverListAdapterTest.kt index 61b9fd9c..2953a650 100644 --- a/tests/unit/src/com/android/intentresolver/ResolverListAdapterTest.kt +++ b/tests/unit/src/com/android/intentresolver/ResolverListAdapterTest.kt @@ -105,7 +105,6 @@ class ResolverListAdapterTest { assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) assertThat(testSubject.isTabLoaded).isTrue() assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(0) - assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(0) assertThat(resolverListCommunicator.sendVoiceCommandCount).isEqualTo(1) } @@ -318,7 +317,6 @@ class ResolverListAdapterTest { assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) assertThat(testSubject.isTabLoaded).isFalse() assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(1) - assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(0) assertThat(resolverListCommunicator.sendVoiceCommandCount).isEqualTo(0) backgroundExecutor.runUntilIdle() @@ -337,7 +335,6 @@ class ResolverListAdapterTest { } assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) assertThat(testSubject.isTabLoaded).isTrue() - assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(1) assertThat(resolverListCommunicator.sendVoiceCommandCount).isEqualTo(1) assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(0) } @@ -391,7 +388,6 @@ class ResolverListAdapterTest { assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) assertThat(testSubject.isTabLoaded).isFalse() assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(1) - assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(0) backgroundExecutor.runUntilIdle() @@ -403,7 +399,6 @@ class ResolverListAdapterTest { assertThat(testSubject.filteredPosition).isLessThan(0) assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) assertThat(testSubject.isTabLoaded).isTrue() - assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(0) assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(0) } @@ -722,7 +717,6 @@ class ResolverListAdapterTest { assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) assertThat(testSubject.isTabLoaded).isFalse() assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(1) - assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(0) assertThat(resolverListCommunicator.sendVoiceCommandCount).isEqualTo(0) backgroundExecutor.runUntilIdle() @@ -737,7 +731,6 @@ class ResolverListAdapterTest { assertThat(testSubject.filteredPosition).isLessThan(0) assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) assertThat(testSubject.isTabLoaded).isTrue() - assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(1) assertThat(resolverListCommunicator.sendVoiceCommandCount).isEqualTo(1) assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(0) } @@ -794,7 +787,6 @@ class ResolverListAdapterTest { assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) assertThat(testSubject.isTabLoaded).isFalse() assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(1) - assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(0) assertThat(resolverListCommunicator.sendVoiceCommandCount).isEqualTo(0) backgroundExecutor.runUntilIdle() @@ -809,7 +801,6 @@ class ResolverListAdapterTest { assertThat(testSubject.filteredPosition).isLessThan(0) assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) assertThat(testSubject.isTabLoaded).isTrue() - assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(1) assertThat(resolverListCommunicator.sendVoiceCommandCount).isEqualTo(1) assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(0) } -- cgit v1.2.3-59-g8ed1b From cfd972848d9a4bd873c3740d28673db8edb2fb88 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Fri, 15 Dec 2023 16:47:33 -0800 Subject: Some code cleanup Bug: 309960444 Test: atest com.android.intentresolver.v2 Flag: ACONFIG com.android.intentresolver.modular_framework Development Change-Id: I5be0f1c9659be091d7337e0eebfcf2e85466a1c8 --- .../android/intentresolver/v2/ChooserActivity.java | 54 ++++++++++------------ 1 file changed, 24 insertions(+), 30 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 2d2c71af..e281613d 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -345,22 +345,22 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mShouldDisplayLandscape = shouldDisplayLandscape(getResources().getConfiguration().orientation); - ChooserRequestParameters chooserRequest = getChooserRequest(); - if (chooserRequest != null) { - setRetainInOnStop(chooserRequest.shouldRetainInOnStop()); - } - ChooserRequestParameters chooserRequest1 = getChooserRequest(); - if (chooserRequest1 != null) { - createProfileRecords( - new AppPredictorFactory( - this, - chooserRequest1.getSharedText(), - chooserRequest1.getTargetIntentFilter() - ), - chooserRequest1.getTargetIntentFilter() - ); + if (chooserRequest == null) { + finish(); + return; } + + setRetainInOnStop(chooserRequest.shouldRetainInOnStop()); + createProfileRecords( + new AppPredictorFactory( + this, + chooserRequest.getSharedText(), + chooserRequest.getTargetIntentFilter() + ), + chooserRequest.getTargetIntentFilter() + ); + Intent intent = mLogic.getTargetIntent(); List initialIntents = mLogic.getInitialIntents(); TargetDataLoader targetDataLoader = mLogic.getTargetDataLoader(); @@ -431,11 +431,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements : "")); } - if (getChooserRequest() == null) { - finish(); - return; - } - getEventLog().logSharesheetTriggered(); mRefinementManager = new ViewModelProvider(this).get(ChooserRefinementManager.class); mRefinementManager.getRefinementCompletion().observe(this, completion -> { @@ -465,11 +460,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements BasePreviewViewModel previewViewModel = new ViewModelProvider(this, createPreviewViewModelFactory()) .get(BasePreviewViewModel.class); - ChooserRequestParameters chooserRequest2 = requireChooserRequest(); mChooserContentPreviewUi = new ChooserContentPreviewUi( getCoroutineScope(getLifecycle()), - previewViewModel.createOrReuseProvider(chooserRequest2.getTargetIntent()), - chooserRequest2.getTargetIntent(), + previewViewModel.createOrReuseProvider(chooserRequest.getTargetIntent()), + chooserRequest.getTargetIntent(), previewViewModel.createOrReuseImageLoader(), createChooserActionFactory(), mEnterTransitionAnimationDelegate, @@ -484,7 +478,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mChooserShownTime = System.currentTimeMillis(); final long systemCost = mChooserShownTime - mIntentReceivedTime.get(); getEventLog().logChooserActivityShown( - isWorkProfile(), chooserRequest2.getTargetType(), systemCost); + isWorkProfile(), chooserRequest.getTargetType(), systemCost); if (mResolverDrawerLayout != null) { mResolverDrawerLayout.addOnLayoutChangeListener(this::handleLayoutChange); @@ -499,15 +493,15 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } getEventLog().logShareStarted( mLogic.getReferrerPackageName(), - chooserRequest2.getTargetType(), - chooserRequest2.getCallerChooserTargets().size(), - (chooserRequest2.getInitialIntents() == null) - ? 0 : chooserRequest2.getInitialIntents().length, + chooserRequest.getTargetType(), + chooserRequest.getCallerChooserTargets().size(), + (chooserRequest.getInitialIntents() == null) + ? 0 : chooserRequest.getInitialIntents().length, isWorkProfile(), mChooserContentPreviewUi.getPreferredContentPreview(), - chooserRequest2.getTargetAction(), - chooserRequest2.getChooserActions().size(), - chooserRequest2.getModifyShareAction() != null + chooserRequest.getTargetAction(), + chooserRequest.getChooserActions().size(), + chooserRequest.getModifyShareAction() != null ); mEnterTransitionAnimationDelegate.postponeTransition(); -- cgit v1.2.3-59-g8ed1b From f07f2e8c571915cf69e3367805eda273cb1bc4d0 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Fri, 15 Dec 2023 16:00:56 +0000 Subject: More "favoring to identify tabs by profile" Switching over more cases in the style of ag/25661285. These changes are as prototyped in ag/25335069 and described in go/chooser-ntab-refactoring, in particular replicating the changes of "snapshot 26" through "snapshot 30." See below for a "by-snapshot" breakdown of the incremental changes composed in this CL. Snapshot 1: Change the signature of the pager's listener API to include both profile ID and page number, and update the (one) implementation to be explicit about which mechanism it's trying to use. Snapshot 2: Add metadata to the pager's `ProfileDescriptor` to identify the associated profile, and begin to generalize away from the assumption that profile ID numbers are equivalent to their associated page numbers (i.e., their indexes in the `mItems` list). Snapshot 3: Move ResolverActivity's `updateActiveTabStyle()` to be an internal implementation detail (as a "lambda") of its `setupProfileTabs()` method. An upcoming snapshot will move `setupProfileTabs()` to the pager, and this prepares to move it as a unit. Snapshot 4: Move "application" logic for tab-switches into the existing `onProfileTabSelected()` callback, again to make `setupProfileTabs()` more generic in preparation to move it over to the pager. Snapshot 5 rebase / snapshot 6 fix conflicts (notably, `ChooserActivity` no longer inherits from `ResolverActivity` and so the changes to `ResolverActivity` needed to be replicated in Chooser.) (Remaining snapshots are tweaks to continue fixing merge conflicts.) Bug: 310211468 Test: `IntentResolver-tests-actvity`, `ResolverActivityTest` Change-Id: I9a51a671447a6c4d1a200bf3b0a12a33088ac042 --- .../android/intentresolver/v2/ChooserActivity.java | 48 ++++++++++-------- .../v2/MultiProfilePagerAdapter.java | 57 +++++++++++++++++---- .../intentresolver/v2/ResolverActivity.java | 58 +++++++++++++++------- 3 files changed, 112 insertions(+), 51 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 2d2c71af..f94ed247 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -140,6 +140,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.v2.MultiProfilePagerAdapter.Profile; import com.android.intentresolver.v2.data.repository.DevicePolicyResources; import com.android.intentresolver.v2.emptystate.NoAppsAvailableEmptyStateProvider; import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider; @@ -1161,14 +1162,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return false; } - private void updateActiveTabStyle(TabHost tabHost) { - int currentTab = tabHost.getCurrentTab(); - TextView selected = (TextView) tabHost.getTabWidget().getChildAt(currentTab); - TextView unselected = (TextView) tabHost.getTabWidget().getChildAt(1 - currentTab); - selected.setSelected(true); - unselected.setSelected(false); - } - private void setupProfileTabs() { TabHost tabHost = findViewById(com.android.internal.R.id.profile_tabhost); tabHost.setup(); @@ -1198,23 +1191,25 @@ public class ChooserActivity extends Hilt_ChooserActivity implements TabWidget tabWidget = tabHost.getTabWidget(); tabWidget.setVisibility(View.VISIBLE); - updateActiveTabStyle(tabHost); + + Runnable updateActiveTabStyle = () -> { + int currentTab = tabHost.getCurrentTab(); + TextView selected = (TextView) tabHost.getTabWidget().getChildAt(currentTab); + TextView unselected = (TextView) tabHost.getTabWidget().getChildAt(1 - currentTab); + selected.setSelected(true); + unselected.setSelected(false); + }; + + updateActiveTabStyle.run(); tabHost.setOnTabChangedListener(tabId -> { - updateActiveTabStyle(tabHost); + updateActiveTabStyle.run(); if (TAB_TAG_PERSONAL.equals(tabId)) { viewPager.setCurrentItem(0); } else { viewPager.setCurrentItem(1); } - setupViewVisibilities(); - maybeLogProfileChange(); - onProfileTabSelected(); - DevicePolicyEventLogger - .createEvent(DevicePolicyEnums.RESOLVER_SWITCH_TABS) - .setInt(viewPager.getCurrentItem()) - .setStrings(getMetricsCategory()) - .write(); + onProfileTabSelected(viewPager.getCurrentItem()); }); viewPager.setVisibility(View.VISIBLE); @@ -1222,8 +1217,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mChooserMultiProfilePagerAdapter.setOnProfileSelectedListener( new MultiProfilePagerAdapter.OnProfileSelectedListener() { @Override - public void onProfileSelected(int index) { - tabHost.setCurrentTab(index); + public void onProfilePageSelected(@Profile int profileId, int pageNumber) { + tabHost.setCurrentTab(pageNumber); } @@ -2618,7 +2613,18 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return METRICS_CATEGORY_CHOOSER; } - protected void onProfileTabSelected() { + protected void onProfileTabSelected(int currentPage) { + setupViewVisibilities(); + maybeLogProfileChange(); + if (hasWorkProfile()) { + // The device policy logger is only concerned with sessions that include a work profile. + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_SWITCH_TABS) + .setInt(currentPage) + .setStrings(getMetricsCategory()) + .write(); + } + // This fixes an edge case where after performing a variety of gestures, vertical scrolling // ends up disabled. That's because at some point the old tab's vertical scrolling is // disabled and the new tab's is enabled. For context, see b/159997845 diff --git a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java index c8e9481a..dc821e88 100644 --- a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java @@ -129,25 +129,54 @@ class MultiProfilePagerAdapter< ImmutableList.Builder> items = new ImmutableList.Builder<>(); - for (SinglePageAdapterT adapter : adapters) { - items.add(createProfileDescriptor(adapter, containerBottomPaddingOverrideSupplier)); + // TODO: for now this only builds in personal and work tabs; any other provided `adapters` + // are ignored. Historically this class wouldn't have behaved correctly for any more than + // those two tabs, so this is more explicit about our current support. Upcoming changes will + // generalize to support more tabs. + for (SinglePageAdapterT pageAdapter : adapters) { + ListAdapterT listAdapter = mListAdapterExtractor.apply(pageAdapter); + if (listAdapter.getUserHandle().equals(workProfileUserHandle)) { + items.add( + createProfileDescriptor( + PROFILE_WORK, pageAdapter, containerBottomPaddingOverrideSupplier)); + } else { + // TODO: it shouldn't be possible to add multiple "personal" descriptors. For now + // we're just trusting our clients to provide valid data. We should avoid making + // inferences from the adapter's user handle, and instead have the pager-adapter + // receive all the necessary configuration data (in some format that ensures + // uniqueness of the adapters assigned to a given profile). + items.add( + createProfileDescriptor( + PROFILE_PERSONAL, + pageAdapter, + containerBottomPaddingOverrideSupplier)); + } } mItems = items.build(); } private ProfileDescriptor createProfileDescriptor( + @Profile int profile, SinglePageAdapterT adapter, Supplier> containerBottomPaddingOverrideSupplier) { return new ProfileDescriptor<>( - mPageViewInflater.get(), adapter, containerBottomPaddingOverrideSupplier); + profile, mPageViewInflater.get(), adapter, containerBottomPaddingOverrideSupplier); } private @Profile int getProfileForPageNumber(int position) { - return position; + if (hasAdapterForIndex(position)) { + return mItems.get(position).mProfile; + } + return -1; } private int getPageNumberForProfile(@Profile int profile) { - return profile; + for (int i = 0; i < mItems.size(); ++i) { + if (profile == mItems.get(i).mProfile) { + return i; + } + } + return -1; } public void setOnProfileSelectedListener(OnProfileSelectedListener listener) { @@ -169,7 +198,8 @@ class MultiProfilePagerAdapter< mLoadedPages.add(position); } if (mOnProfileSelectedListener != null) { - mOnProfileSelectedListener.onProfileSelected(position); + mOnProfileSelectedListener.onProfilePageSelected( + getProfileForPageNumber(position), position); } } @@ -599,6 +629,8 @@ class MultiProfilePagerAdapter< // 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 ProfileDescriptor { + final @Profile int mProfile; + final ViewGroup mRootView; final EmptyStateUiHelper mEmptyStateUi; @@ -610,9 +642,11 @@ class MultiProfilePagerAdapter< private final PageViewT mView; ProfileDescriptor( + @Profile int forProfile, ViewGroup rootView, SinglePageAdapterT adapter, Supplier> containerBottomPaddingOverrideSupplier) { + mProfile = forProfile; mRootView = rootView; mAdapter = adapter; mEmptyStateView = rootView.findViewById(com.android.internal.R.id.resolver_empty_state); @@ -635,13 +669,14 @@ class MultiProfilePagerAdapter< /** Listener interface for changes between the per-profile UI tabs. */ public interface OnProfileSelectedListener { /** - * Callback for when the user changes the active tab from personal to work or vice versa. + * Callback for when the user changes the active tab. *

This callback is only called when the intent resolver or share sheet shows - * the work and personal profiles. - * @param profileIndex {@link #PROFILE_PERSONAL} if the personal profile was selected or - * {@link #PROFILE_WORK} if the work profile was selected. + * more than one profile. + * @param profileId the ID of the newly-selected profile, e.g. {@link #PROFILE_PERSONAL} + * if the personal profile tab was selected or {@link #PROFILE_WORK} if the work profile tab + * was selected. */ - void onProfileSelected(int profileIndex); + void onProfilePageSelected(@Profile int profileId, int pageNumber); /** diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index a56d15a2..bc3fb16b 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -773,6 +773,31 @@ public class ResolverActivity extends Hilt_ResolverActivity implements return postRebuildListInternal(rebuildCompleted); } + /** + * Callback called when user changes the profile tab. + */ + /* TODO: consider merging with the customized considerations of our implemented + * {@link MultiProfilePagerAdapter.OnProfileSelectedListener}. The only apparent distinctions + * between the respective listener callbacks would occur in the triggering patterns during init + * (when the `OnProfileSelectedListener` is registered after a possible tab-change), or possibly + * if there's some way to trigger an update in one model but not the other. If there's an + * initialization dependency, we can probably reason about it with confidence. If there's a + * discrepancy between the `TabHost` and pager-adapter data models, that inconsistency is + * likely to be a bug that would benefit from consolidation. + */ + protected void onProfileTabSelected(int currentPage) { + setupViewVisibilities(); + maybeLogProfileChange(); + if (hasWorkProfile()) { + // The device policy logger is only concerned with sessions that include a work profile. + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_SWITCH_TABS) + .setInt(currentPage) + .setStrings(getMetricsCategory()) + .write(); + } + } + /** * Add a label to signify that the user can pick a different app. * @param adapter The adapter used to provide data to item views. @@ -1717,14 +1742,6 @@ public class ResolverActivity extends Hilt_ResolverActivity implements .clearCheckedItemsInInactiveProfiles(); } - private void updateActiveTabStyle(TabHost tabHost) { - int currentTab = tabHost.getCurrentTab(); - TextView selected = (TextView) tabHost.getTabWidget().getChildAt(currentTab); - TextView unselected = (TextView) tabHost.getTabWidget().getChildAt(1 - currentTab); - selected.setSelected(true); - unselected.setSelected(false); - } - private void setupViewVisibilities() { ResolverListAdapter activeListAdapter = mMultiProfilePagerAdapter.getActiveListAdapter(); if (!mMultiProfilePagerAdapter.shouldShowEmptyStateScreen(activeListAdapter)) { @@ -1906,22 +1923,25 @@ public class ResolverActivity extends Hilt_ResolverActivity implements TabWidget tabWidget = tabHost.getTabWidget(); tabWidget.setVisibility(View.VISIBLE); - updateActiveTabStyle(tabHost); + + Runnable updateActiveTabStyle = () -> { + int currentTab = tabHost.getCurrentTab(); + TextView selected = (TextView) tabHost.getTabWidget().getChildAt(currentTab); + TextView unselected = (TextView) tabHost.getTabWidget().getChildAt(1 - currentTab); + selected.setSelected(true); + unselected.setSelected(false); + }; + + updateActiveTabStyle.run(); tabHost.setOnTabChangedListener(tabId -> { - updateActiveTabStyle(tabHost); + updateActiveTabStyle.run(); if (TAB_TAG_PERSONAL.equals(tabId)) { viewPager.setCurrentItem(0); } else { viewPager.setCurrentItem(1); } - setupViewVisibilities(); - maybeLogProfileChange(); - DevicePolicyEventLogger - .createEvent(DevicePolicyEnums.RESOLVER_SWITCH_TABS) - .setInt(viewPager.getCurrentItem()) - .setStrings(getMetricsCategory()) - .write(); + onProfileTabSelected(viewPager.getCurrentItem()); }); viewPager.setVisibility(View.VISIBLE); @@ -1929,8 +1949,8 @@ public class ResolverActivity extends Hilt_ResolverActivity implements mMultiProfilePagerAdapter.setOnProfileSelectedListener( new MultiProfilePagerAdapter.OnProfileSelectedListener() { @Override - public void onProfileSelected(int index) { - tabHost.setCurrentTab(index); + public void onProfilePageSelected(@Profile int profileId, int pageNumber) { + tabHost.setCurrentTab(pageNumber); resetButtonBar(); resetCheckedItem(); } -- cgit v1.2.3-59-g8ed1b From c5d2a0a21d5fb04440c7c759a193cbc6fad854cd Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Sat, 16 Dec 2023 13:36:17 -0500 Subject: Inject app prediction service availability directly Instead of depending on and mocking PackageManager, this change creates a module to provide the availability signal. This greatly simplifies test setup when used with Hilt. This also sets the @Parameter label to a human readable value shoing the parameter value for a given test (in the test name). Test: atest IntentResolver-tests-activity Bug: 300157408 Change-Id: Ief02bf0e51e879916296d5872d1946db43f78ad0 --- .../android/intentresolver/ChooserActivity.java | 3 +- .../shortcuts/AppPredictorFactory.kt | 8 ++-- .../android/intentresolver/v2/ChooserActivity.java | 11 +++--- .../intentresolver/v2/ResolverActivity.java | 8 ++-- .../v2/platform/AppPredictionModule.kt | 45 ++++++++++++++++++++++ .../v2/ChooserActivityOverrideData.java | 4 -- .../intentresolver/v2/ChooserWrapperActivity.java | 8 ---- .../v2/UnbundledChooserActivityTest.java | 43 +++++++++------------ 8 files changed, 77 insertions(+), 53 deletions(-) create mode 100644 java/src/com/android/intentresolver/v2/platform/AppPredictionModule.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index b1c7d6fb..82e46a57 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -259,7 +259,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements new AppPredictorFactory( this, mChooserRequest.getSharedText(), - mChooserRequest.getTargetIntentFilter()), + mChooserRequest.getTargetIntentFilter(), + getPackageManager().getAppPredictionServicePackageName() != null), mChooserRequest.getTargetIntentFilter()); diff --git a/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt b/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt index 82f40b91..e544e064 100644 --- a/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt +++ b/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt @@ -41,16 +41,14 @@ private const val SHARED_TEXT_KEY = "shared_text" class AppPredictorFactory( private val context: Context, private val sharedText: String?, - private val targetIntentFilter: IntentFilter? + private val targetIntentFilter: IntentFilter?, + private val appPredictionAvailable: Boolean, ) { - private val mIsComponentAvailable = - context.packageManager.appPredictionServicePackageName != null - /** * Creates an AppPredictor instance for a profile or `null` if app predictor is not available. */ fun create(userHandle: UserHandle): AppPredictor? { - if (!mIsComponentAvailable) return null + if (!appPredictionAvailable) return null val contextAsUser = context.createContextAsUser(userHandle, 0 /* flags */) val extras = Bundle().apply { putParcelable(APP_PREDICTION_INTENT_FILTER_KEY, targetIntentFilter) diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index e281613d..54d8b4cb 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -145,6 +145,7 @@ import com.android.intentresolver.v2.emptystate.NoAppsAvailableEmptyStateProvide import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider; import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; import com.android.intentresolver.v2.emptystate.WorkProfilePausedEmptyStateProvider; +import com.android.intentresolver.v2.platform.AppPredictionAvailable; import com.android.intentresolver.v2.platform.ImageEditor; import com.android.intentresolver.v2.platform.NearbyShare; import com.android.intentresolver.v2.ui.ActionTitle; @@ -221,7 +222,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements protected static final int PROFILE_PERSONAL = MultiProfilePagerAdapter.PROFILE_PERSONAL; protected static final int PROFILE_WORK = MultiProfilePagerAdapter.PROFILE_WORK; private boolean mRegistered; - protected PackageManager mPm; private PackageMonitor mPersonalPackageMonitor; private PackageMonitor mWorkPackageMonitor; protected View mProfileView; @@ -262,11 +262,12 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Inject public FeatureFlags mFeatureFlags; @Inject public EventLog mEventLog; + @Inject @AppPredictionAvailable public boolean mAppPredictionAvailable; @Inject @ImageEditor public Optional mImageEditor; @Inject @NearbyShare public Optional mNearbyShare; @Inject public TargetDataLoader mTargetDataLoader; - @Inject public DevicePolicyResources mDevicePolicyResources; + private ChooserRefinementManager mRefinementManager; private ChooserContentPreviewUi mChooserContentPreviewUi; @@ -356,7 +357,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements new AppPredictorFactory( this, chooserRequest.getSharedText(), - chooserRequest.getTargetIntentFilter() + chooserRequest.getTargetIntentFilter(), + mAppPredictionAvailable ), chooserRequest.getTargetIntentFilter() ); @@ -371,7 +373,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return; } - mPm = getPackageManager(); mChooserMultiProfilePagerAdapter = createMultiProfilePagerAdapter( requireNonNullElse(initialIntents, emptyList()).toArray(new Intent[0]), /* resolutionList = */ null, @@ -2180,7 +2181,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return new ChooserListController( this, - mPm, + getPackageManager(), mLogic.getTargetIntent(), mLogic.getReferrerPackageName(), requireAnnotatedUserHandles().userIdOfCallingApp, diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index a56d15a2..dbc24604 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -151,7 +151,6 @@ public class ResolverActivity extends Hilt_ResolverActivity implements private PickTargetOptionRequest mPickOptionRequest; // Expected to be true if this object is ResolverActivity or is ResolverWrapperActivity. protected ResolverDrawerLayout mResolverDrawerLayout; - protected PackageManager mPm; private static final String TAG = "ResolverActivity"; private static final boolean DEBUG = false; @@ -257,7 +256,6 @@ public class ResolverActivity extends Hilt_ResolverActivity implements return; } - mPm = getPackageManager(); // The last argument of createResolverListAdapter is whether to do special handling // of the last used choice to highlight it in the list. We need to always @@ -755,7 +753,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements null); return new ResolverListController( this, - mPm, + getPackageManager(), mLogic.getTargetIntent(), mLogic.getReferrerPackageName(), requireAnnotatedUserHandles().userIdOfCallingApp, @@ -1239,8 +1237,8 @@ public class ResolverActivity extends Hilt_ResolverActivity implements if (ri != null) { ActivityInfo activityInfo = ri.activityInfo; - boolean hasRecordPermission = - mPm.checkPermission(android.Manifest.permission.RECORD_AUDIO, + boolean hasRecordPermission = getPackageManager() + .checkPermission(android.Manifest.permission.RECORD_AUDIO, activityInfo.packageName) == PackageManager.PERMISSION_GRANTED; diff --git a/java/src/com/android/intentresolver/v2/platform/AppPredictionModule.kt b/java/src/com/android/intentresolver/v2/platform/AppPredictionModule.kt new file mode 100644 index 00000000..9ca9d871 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/platform/AppPredictionModule.kt @@ -0,0 +1,45 @@ +/* + * 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.v2.platform + +import android.content.pm.PackageManager +import dagger.Module +import dagger.Provides +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Qualifier +import javax.inject.Singleton + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class AppPredictionAvailable + +@Module +@InstallIn(SingletonComponent::class) +object AppPredictionModule { + + /** + * Eventually replaced with: Optional, etc. + */ + @Provides + @Singleton + @AppPredictionAvailable + fun isAppPredictionAvailable(packageManager: PackageManager): Boolean { + return packageManager.appPredictionServicePackageName != null + } +} \ No newline at end of file diff --git a/tests/activity/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java b/tests/activity/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java index 32eabbed..c84c25e3 100644 --- a/tests/activity/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java +++ b/tests/activity/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java @@ -52,9 +52,6 @@ public class ChooserActivityOverrideData { } return sInstance; } - - @SuppressWarnings("Since15") - public Function createPackageManager; public Function onSafelyStartInternalCallback; public Function onSafelyStartCallback; public Function2, ShortcutLoader> @@ -78,7 +75,6 @@ public class ChooserActivityOverrideData { public void reset() { onSafelyStartInternalCallback = null; isVoiceInteraction = null; - createPackageManager = null; imageLoader = null; resolverCursor = null; resolverForceException = false; diff --git a/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java b/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java index 8da045dc..cc14202f 100644 --- a/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java @@ -171,14 +171,6 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW return sOverrides.workResolverListController; } - @Override - public PackageManager getPackageManager() { - if (sOverrides.createPackageManager != null) { - return sOverrides.createPackageManager.apply(super.getPackageManager()); - } - return super.getPackageManager(); - } - @Override public Resources getResources() { if (sOverrides.resources != null) { diff --git a/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java b/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java index 5245f655..770cabbc 100644 --- a/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java +++ b/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java @@ -131,6 +131,8 @@ import com.android.intentresolver.contentpreview.ImageLoader; import com.android.intentresolver.logging.EventLog; import com.android.intentresolver.logging.FakeEventLog; import com.android.intentresolver.shortcuts.ShortcutLoader; +import com.android.intentresolver.v2.platform.AppPredictionAvailable; +import com.android.intentresolver.v2.platform.AppPredictionModule; import com.android.intentresolver.v2.platform.ImageEditor; import com.android.intentresolver.v2.platform.ImageEditorModule; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; @@ -150,12 +152,12 @@ import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; import org.mockito.ArgumentCaptor; 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; @@ -165,16 +167,16 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; -import java.util.function.Function; /** * Instrumentation tests for ChooserActivity. *

* Legacy test suite migrated from framework CoreTests. */ +@SuppressWarnings("OptionalUsedAsFieldOrParameterType") @RunWith(Parameterized.class) @HiltAndroidTest -@UninstallModules(ImageEditorModule.class) +@UninstallModules({ImageEditorModule.class, AppPredictionModule.class}) public class UnbundledChooserActivityTest { private static FakeEventLog getEventLog(ChooserWrapperActivity activity) { @@ -186,22 +188,12 @@ public class UnbundledChooserActivityTest { private static final UserHandle WORK_PROFILE_USER_HANDLE = UserHandle.of(10); private static final UserHandle CLONE_PROFILE_USER_HANDLE = UserHandle.of(11); - private static final Function DEFAULT_PM = pm -> pm; - private static final Function NO_APP_PREDICTION_SERVICE_PM = - pm -> { - PackageManager mock = Mockito.spy(pm); - when(mock.getAppPredictionServicePackageName()).thenReturn(null); - return mock; - }; - - @Parameterized.Parameters - public static Collection packageManagers() { - return Arrays.asList(new Object[][] { - // Default PackageManager - { DEFAULT_PM }, - // No App Prediction Service - { NO_APP_PREDICTION_SERVICE_PM} - }); + @Parameters(name = "appPrediction={0}") + public static Iterable parameters() { + return Arrays.asList( + /* appPredictionAvailable = */ true, + /* appPredictionAvailable = */ false + ); } private static final String TEST_MIME_TYPE = "application/TestType"; @@ -233,8 +225,6 @@ public class UnbundledChooserActivityTest { mHiltAndroidRule.inject(); } - private final Function mPackageManagerOverride; - /** An arbitrary pre-installed activity that handles this type of intent. */ @BindValue @ImageEditor @@ -242,9 +232,13 @@ public class UnbundledChooserActivityTest { ComponentName.unflattenFromString("com.google.android.apps.messaging/" + ".ui.conversationlist.ShareIntentActivity")); - public UnbundledChooserActivityTest( - Function packageManagerOverride) { - mPackageManagerOverride = packageManagerOverride; + /** Whether an AppPredictionService is available for use. */ + @BindValue + @AppPredictionAvailable + final boolean mAppPredictionAvailable; + + public UnbundledChooserActivityTest(boolean appPredictionAvailable) { + mAppPredictionAvailable = appPredictionAvailable; } private void setDeviceConfigProperty( @@ -267,7 +261,6 @@ public class UnbundledChooserActivityTest { public void cleanOverrideData() { ChooserActivityOverrideData.getInstance().reset(); - ChooserActivityOverrideData.getInstance().createPackageManager = mPackageManagerOverride; setDeviceConfigProperty( SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, -- cgit v1.2.3-59-g8ed1b From c66c3b8e0d3c9ed894c3d69d84d52e0ecff98a37 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Tue, 19 Dec 2023 15:38:59 +0000 Subject: Move `setupProfileTabs()` to the pager adapter This splits out aspects of the "setup" behavior that are generic to the pager adapter (building the tabs, setting up the event handlers) from "application-specific" parameterizations (like providing the strings for the tab labels, or implementing the event handlers in a way that integrates with other UI elements in our app). These changes are as prototyped in ag/25335069 and described in go/chooser-ntab-refactoring, in particular replicating the changes of "snapshot 31" and "snapshot 32." See below for a "by-snapshot" breakdown of the incremental changes composed in this CL. Snapshot 1: separate the `setupProfileTabs()` design within `ResolverActivity` so it's easier to see the diffs before moving to the pager adapter. A newly-introduced static method (of the same name) takes over most of the implementation, while parameterizing the details we'll consider "application-specific." Notably, this splits up two existing "callback" designs that had both "generic" and "application-specific" responsibilities -- our `OnTabChangedListener` and `MultiProfilePagerAdapter.OnProfileSelectedListener` (which, TODO: might be consolidated to a single design in the future?). The "generic" aspects of these listeners move to the newly-extracted static method, but the listeners also compose-in application-specific behavior that's provided in the parameterization. Snapshot 2: move the parameterized method into the pager-adapter (replace "static" and use the pager-adapter "implicit this" instead of taking it as the explicit first parameter). Snapshot 3: integrate ChooserActivity with the "parameterized method" that was extracted to the pager adapter. This wasn't part of the original prototype but is needed now that ChooserActivity no longer inherits a shared implementation from ResolverActivity. Equivalence is clear from side-by-side reading of the pager-adapter implementation and the (before-change) ChooserActivity implementation. Snapshot 4: move `updateActiveTabStyle()` from a caller "parameter" of `setupProfileTabs()` to an internal implementation detail in the pager-adapter, and generalize the behavior to support any number of tabs. (This was "snapshot 32" in the original prototype.) Bug: 310211468 Test: `IntentResolver-tests-actvity`, `ResolverActivityTest` Change-Id: Ifc3a0d81d148241966041f7efd74774f22a933d7 --- .../android/intentresolver/v2/ChooserActivity.java | 73 +++++------------- .../v2/MultiProfilePagerAdapter.java | 87 +++++++++++++++++++++- .../intentresolver/v2/ResolverActivity.java | 72 +++++------------- 3 files changed, 119 insertions(+), 113 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index aa2b792a..2e020743 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -88,11 +88,9 @@ import android.view.ViewTreeObserver; import android.view.Window; import android.view.WindowInsets; import android.view.WindowManager; -import android.widget.Button; import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.TabHost; -import android.widget.TabWidget; import android.widget.TextView; import android.widget.Toast; @@ -1158,63 +1156,24 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private void setupProfileTabs() { TabHost tabHost = findViewById(com.android.internal.R.id.profile_tabhost); - tabHost.setup(); ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - viewPager.setSaveEnabled(false); - - Button personalButton = (Button) getLayoutInflater().inflate( - R.layout.resolver_profile_tab_button, tabHost.getTabWidget(), false); - personalButton.setText(mDevicePolicyResources.getPersonalTabLabel()); - personalButton.setContentDescription( - mDevicePolicyResources.getPersonalTabAccessibilityLabel()); - - TabHost.TabSpec tabSpec = tabHost.newTabSpec(TAB_TAG_PERSONAL) - .setContent(com.android.internal.R.id.profile_pager) - .setIndicator(personalButton); - tabHost.addTab(tabSpec); - - Button workButton = (Button) getLayoutInflater().inflate( - R.layout.resolver_profile_tab_button, tabHost.getTabWidget(), false); - workButton.setText(mDevicePolicyResources.getWorkTabLabel()); - workButton.setContentDescription(mDevicePolicyResources.getWorkTabAccessibilityLabel()); - - tabSpec = tabHost.newTabSpec(TAB_TAG_WORK) - .setContent(com.android.internal.R.id.profile_pager) - .setIndicator(workButton); - tabHost.addTab(tabSpec); - - TabWidget tabWidget = tabHost.getTabWidget(); - tabWidget.setVisibility(View.VISIBLE); - - Runnable updateActiveTabStyle = () -> { - int currentTab = tabHost.getCurrentTab(); - TextView selected = (TextView) tabHost.getTabWidget().getChildAt(currentTab); - TextView unselected = (TextView) tabHost.getTabWidget().getChildAt(1 - currentTab); - selected.setSelected(true); - unselected.setSelected(false); - }; - - updateActiveTabStyle.run(); - - tabHost.setOnTabChangedListener(tabId -> { - updateActiveTabStyle.run(); - if (TAB_TAG_PERSONAL.equals(tabId)) { - viewPager.setCurrentItem(0); - } else { - viewPager.setCurrentItem(1); - } - onProfileTabSelected(viewPager.getCurrentItem()); - }); - viewPager.setVisibility(View.VISIBLE); - tabHost.setCurrentTab(mChooserMultiProfilePagerAdapter.getCurrentPage()); - mChooserMultiProfilePagerAdapter.setOnProfileSelectedListener( + mChooserMultiProfilePagerAdapter.setupProfileTabs( + getLayoutInflater(), + tabHost, + viewPager, + R.layout.resolver_profile_tab_button, + com.android.internal.R.id.profile_pager, + mDevicePolicyResources.getPersonalTabLabel(), + mDevicePolicyResources.getPersonalTabAccessibilityLabel(), + TAB_TAG_PERSONAL, + mDevicePolicyResources.getWorkTabLabel(), + mDevicePolicyResources.getWorkTabAccessibilityLabel(), + TAB_TAG_WORK, + () -> onProfileTabSelected(viewPager.getCurrentItem()), new MultiProfilePagerAdapter.OnProfileSelectedListener() { @Override - public void onProfilePageSelected(@Profile int profileId, int pageNumber) { - tabHost.setCurrentTab(pageNumber); - - } + public void onProfilePageSelected(@Profile int profileId, int pageNumber) {} @Override public void onProfilePageStateChanged(int state) { @@ -1222,7 +1181,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } }); mOnSwitchOnWorkSelectedListener = () -> { - final View workTab = tabHost.getTabWidget().getChildAt(1); + final View workTab = + tabHost.getTabWidget().getChildAt( + mChooserMultiProfilePagerAdapter.getPageNumberForProfile(PROFILE_WORK)); workTab.setFocusable(true); workTab.setFocusableInTouchMode(true); workTab.requestFocus(); diff --git a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java index dc821e88..3387a73f 100644 --- a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java @@ -19,8 +19,12 @@ import android.annotation.IntDef; import android.annotation.Nullable; import android.os.Trace; import android.os.UserHandle; +import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TabHost; +import android.widget.TextView; import androidx.viewpager.widget.PagerAdapter; import androidx.viewpager.widget.ViewPager; @@ -170,7 +174,7 @@ class MultiProfilePagerAdapter< return -1; } - private int getPageNumberForProfile(@Profile int profile) { + public int getPageNumberForProfile(@Profile int profile) { for (int i = 0; i < mItems.size(); ++i) { if (profile == mItems.get(i).mProfile) { return i; @@ -179,8 +183,85 @@ class MultiProfilePagerAdapter< return -1; } - public void setOnProfileSelectedListener(OnProfileSelectedListener listener) { - mOnProfileSelectedListener = listener; + private void updateActiveTabStyle(TabHost tabHost) { + int currentTab = tabHost.getCurrentTab(); + + for (int pageNumber = 0; pageNumber < getItemCount(); ++pageNumber) { + // TODO: can we avoid this downcast by pushing our knowledge of the intended view type + // somewhere else? + TextView tabText = (TextView) tabHost.getTabWidget().getChildAt(pageNumber); + tabText.setSelected(currentTab == pageNumber); + } + } + + public void setupProfileTabs( + LayoutInflater layoutInflater, + TabHost tabHost, + ViewPager viewPager, + int tabButtonLayoutResId, + int tabPageContentViewId, + String personalTabLabel, + String personalTabAccessibilityLabel, + String personalTabTag, + String workTabLabel, + String workTabAccessibilityLabel, + String workTabTag, + Runnable onTabChangeListener, + MultiProfilePagerAdapter.OnProfileSelectedListener clientOnProfileSelectedListener) { + tabHost.setup(); + viewPager.setSaveEnabled(false); + + Button personalButton = (Button) layoutInflater.inflate( + tabButtonLayoutResId, tabHost.getTabWidget(), false); + personalButton.setText(personalTabLabel); + personalButton.setContentDescription(personalTabAccessibilityLabel); + + TabHost.TabSpec personalTabSpec = tabHost.newTabSpec(personalTabTag) + .setContent(tabPageContentViewId) + .setIndicator(personalButton); + tabHost.addTab(personalTabSpec); + + Button workButton = (Button) layoutInflater.inflate( + tabButtonLayoutResId, tabHost.getTabWidget(), false); + workButton.setText(workTabLabel); + workButton.setContentDescription(workTabAccessibilityLabel); + + TabHost.TabSpec workTabSpec = tabHost.newTabSpec(workTabTag) + .setContent(tabPageContentViewId) + .setIndicator(workButton); + tabHost.addTab(workTabSpec); + + tabHost.getTabWidget().setVisibility(View.VISIBLE); + + updateActiveTabStyle(tabHost); + + tabHost.setOnTabChangedListener(tabId -> { + updateActiveTabStyle(tabHost); + // TODO: update for 3+ tabs. + if (personalTabTag.equals(tabId)) { + viewPager.setCurrentItem(0); + } else { + viewPager.setCurrentItem(1); + } + onTabChangeListener.run(); + }); + + viewPager.setVisibility(View.VISIBLE); + tabHost.setCurrentTab(getCurrentPage()); + mOnProfileSelectedListener = + new MultiProfilePagerAdapter.OnProfileSelectedListener() { + @Override + public void onProfilePageSelected(@Profile int profileId, int pageNumber) { + tabHost.setCurrentTab(pageNumber); + clientOnProfileSelectedListener.onProfilePageSelected( + profileId, pageNumber); + } + + @Override + public void onProfilePageStateChanged(int state) { + clientOnProfileSelectedListener.onProfilePageStateChanged(state); + } + }; } /** diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index bc3fb16b..2a6ed9d5 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -81,7 +81,6 @@ import android.widget.ImageView; import android.widget.ListView; import android.widget.Space; import android.widget.TabHost; -import android.widget.TabWidget; import android.widget.TextView; import android.widget.Toast; @@ -1895,72 +1894,37 @@ public class ResolverActivity extends Hilt_ResolverActivity implements private void setupProfileTabs() { maybeHideDivider(); + TabHost tabHost = findViewById(com.android.internal.R.id.profile_tabhost); - tabHost.setup(); ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - viewPager.setSaveEnabled(false); - - Button personalButton = (Button) getLayoutInflater().inflate( - R.layout.resolver_profile_tab_button, tabHost.getTabWidget(), false); - personalButton.setText(mDevicePolicyResources.getPersonalTabLabel()); - personalButton.setContentDescription( - mDevicePolicyResources.getPersonalTabAccessibilityLabel()); - - TabHost.TabSpec tabSpec = tabHost.newTabSpec(TAB_TAG_PERSONAL) - .setContent(com.android.internal.R.id.profile_pager) - .setIndicator(personalButton); - tabHost.addTab(tabSpec); - - Button workButton = (Button) getLayoutInflater().inflate( - R.layout.resolver_profile_tab_button, tabHost.getTabWidget(), false); - workButton.setText(mDevicePolicyResources.getWorkTabLabel()); - workButton.setContentDescription(mDevicePolicyResources.getWorkTabAccessibilityLabel()); - - tabSpec = tabHost.newTabSpec(TAB_TAG_WORK) - .setContent(com.android.internal.R.id.profile_pager) - .setIndicator(workButton); - tabHost.addTab(tabSpec); - - TabWidget tabWidget = tabHost.getTabWidget(); - tabWidget.setVisibility(View.VISIBLE); - - Runnable updateActiveTabStyle = () -> { - int currentTab = tabHost.getCurrentTab(); - TextView selected = (TextView) tabHost.getTabWidget().getChildAt(currentTab); - TextView unselected = (TextView) tabHost.getTabWidget().getChildAt(1 - currentTab); - selected.setSelected(true); - unselected.setSelected(false); - }; - - updateActiveTabStyle.run(); - - tabHost.setOnTabChangedListener(tabId -> { - updateActiveTabStyle.run(); - if (TAB_TAG_PERSONAL.equals(tabId)) { - viewPager.setCurrentItem(0); - } else { - viewPager.setCurrentItem(1); - } - onProfileTabSelected(viewPager.getCurrentItem()); - }); - viewPager.setVisibility(View.VISIBLE); - tabHost.setCurrentTab(mMultiProfilePagerAdapter.getCurrentPage()); - mMultiProfilePagerAdapter.setOnProfileSelectedListener( + mMultiProfilePagerAdapter.setupProfileTabs( + getLayoutInflater(), + tabHost, + viewPager, + R.layout.resolver_profile_tab_button, + com.android.internal.R.id.profile_pager, + mDevicePolicyResources.getPersonalTabLabel(), + mDevicePolicyResources.getPersonalTabAccessibilityLabel(), + TAB_TAG_PERSONAL, + mDevicePolicyResources.getWorkTabLabel(), + mDevicePolicyResources.getWorkTabAccessibilityLabel(), + TAB_TAG_WORK, + () -> onProfileTabSelected(viewPager.getCurrentItem()), new MultiProfilePagerAdapter.OnProfileSelectedListener() { @Override public void onProfilePageSelected(@Profile int profileId, int pageNumber) { - tabHost.setCurrentTab(pageNumber); resetButtonBar(); resetCheckedItem(); } @Override - public void onProfilePageStateChanged(int state) { - } + public void onProfilePageStateChanged(int state) {} }); mOnSwitchOnWorkSelectedListener = () -> { - final View workTab = tabHost.getTabWidget().getChildAt(1); + final View workTab = + tabHost.getTabWidget().getChildAt( + mMultiProfilePagerAdapter.getPageNumberForProfile(PROFILE_WORK)); workTab.setFocusable(true); workTab.setFocusableInTouchMode(true); workTab.requestFocus(); -- cgit v1.2.3-59-g8ed1b From 7d6e64b1c978424d001f6238d9e355d7f78b6561 Mon Sep 17 00:00:00 2001 From: mrenouf Date: Wed, 20 Dec 2023 15:21:24 -0500 Subject: Provide Users map keyed by role instead of UserHandle Provides this map keyed by role since callers should not be referencing a specific UserHandle. Bug: 309960444 Test: atest UserRepositoryImplTest Change-Id: Ic5f2007b06aaaaea627046b5f0a0b16f47e58e52 --- .../intentresolver/v2/data/repository/UserRepository.kt | 8 +++++--- .../v2/data/repository/UserRepositoryImplTest.kt | 16 ++++++++-------- 2 files changed, 13 insertions(+), 11 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt b/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt index dc809b46..cbf89fe8 100644 --- a/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt +++ b/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt @@ -21,6 +21,7 @@ import com.android.intentresolver.inject.Main import com.android.intentresolver.inject.ProfileParent import com.android.intentresolver.v2.data.broadcastFlow import com.android.intentresolver.v2.data.model.User +import com.android.intentresolver.v2.data.model.User.Role import com.android.intentresolver.v2.data.repository.UserRepositoryImpl.UserEvent import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject @@ -42,7 +43,7 @@ interface UserRepository { * A [Flow] user profile groups. Each map contains the context user along with all members of * the profile group. This includes the (Full) parent user, if the context user is a profile. */ - val users: Flow> + val users: Flow> /** * A [Flow] of availability. Only profile users may become unavailable. @@ -140,8 +141,9 @@ constructor( .stateIn(scope, SharingStarted.Eagerly, emptyMap()) .filterNot { it.isEmpty() } - override val users: Flow> = - usersWithState.map { map -> map.mapValues { it.value.user } }.distinctUntilChanged() + override val users: Flow> = usersWithState.map { userStateMap -> + userStateMap.map { it.value.user }.associateBy { it.role } + }.distinctUntilChanged() private val availability: Flow> = usersWithState.map { map -> map.mapValues { it.value.available } }.distinctUntilChanged() diff --git a/tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt b/tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt index 4f514db5..5cfcb872 100644 --- a/tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt @@ -35,7 +35,7 @@ internal class UserRepositoryImplTest { assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() assertThat(users) .containsExactly( - userState.primaryUserHandle, + Role.PERSONAL, User(userState.primaryUserHandle.identifier, Role.PERSONAL) ) } @@ -49,7 +49,7 @@ internal class UserRepositoryImplTest { assertThat(users!!.values.filter { it.role.type == User.Type.PROFILE }).isEmpty() val profile = userState.createProfile(ProfileType.WORK) - assertThat(users).containsEntry(profile, User(profile.identifier, Role.WORK)) + assertThat(users).containsEntry(Role.WORK, User(profile.identifier, Role.WORK)) } @Test @@ -59,10 +59,10 @@ internal class UserRepositoryImplTest { assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() val work = userState.createProfile(ProfileType.WORK) - assertThat(users).containsEntry(work, User(work.identifier, Role.WORK)) + assertThat(users).containsEntry(Role.WORK, User(work.identifier, Role.WORK)) userState.removeProfile(work) - assertThat(users).doesNotContainEntry(work, User(work.identifier, Role.WORK)) + assertThat(users).doesNotContainEntry(Role.WORK, User(work.identifier, Role.WORK)) } @Test @@ -129,7 +129,7 @@ internal class UserRepositoryImplTest { val users by collectLastValue(repo.users) assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() - assertThat(users).containsExactly(SYSTEM, User(USER_SYSTEM, Role.PERSONAL)) + assertThat(users).containsExactly(Role.PERSONAL, User(USER_SYSTEM, Role.PERSONAL)) } @Test @@ -154,7 +154,7 @@ internal class UserRepositoryImplTest { val users by collectLastValue(repo.users) assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() - assertThat(users).containsExactly(SYSTEM, User(USER_SYSTEM, Role.PERSONAL)) + assertThat(users).containsExactly(Role.PERSONAL, User(USER_SYSTEM, Role.PERSONAL)) } @Test @@ -173,7 +173,7 @@ internal class UserRepositoryImplTest { val users by collectLastValue(repo.users) assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() - assertThat(users).containsExactly(SYSTEM, User(USER_SYSTEM, Role.PERSONAL)) + assertThat(users).containsExactly(Role.PERSONAL, User(USER_SYSTEM, Role.PERSONAL)) } @Test @@ -195,7 +195,7 @@ internal class UserRepositoryImplTest { val users by collectLastValue(repo.users) assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() - assertThat(users).containsExactly(SYSTEM, User(USER_SYSTEM, Role.PERSONAL)) + assertThat(users).containsExactly(Role.PERSONAL, User(USER_SYSTEM, Role.PERSONAL)) } } -- cgit v1.2.3-59-g8ed1b From eee44c1f6fc6164be300868b5d97a4b93e517ca7 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Wed, 3 Jan 2024 12:21:22 -0800 Subject: ShortcutLoader to report empty result if the intent filter is null In cases when the target intent filter is null, instead of not reporting anything, report an empty result (as it is not expected to get any other result). Bug: 317978381 Test: atest IntentResolver-tests-unit Change-Id: I9c9a215a7083b2118f441a1060449d6a2053bb6a --- .../intentresolver/shortcuts/ShortcutLoader.kt | 5 +++ .../intentresolver/shortcuts/ShortcutLoaderTest.kt | 42 ++++++++++++++++++++++ 2 files changed, 47 insertions(+) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt index a8b59fb0..08230d90 100644 --- a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt +++ b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt @@ -186,6 +186,11 @@ constructor( // Default to just querying ShortcutManager if AppPredictor not present. if (targetIntentFilter == null) { Log.d(TAG, "skip querying ShortcutManager for $userHandle") + sendShareShortcutInfoList( + emptyList(), + isFromAppPredictor = false, + appPredictorTargets = null + ) return } Log.d(TAG, "query ShortcutManager for user $userHandle") diff --git a/tests/unit/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt b/tests/unit/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt index 43d0df79..4eeae872 100644 --- a/tests/unit/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt +++ b/tests/unit/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt @@ -36,6 +36,7 @@ import com.android.intentresolver.createShareShortcutInfo import com.android.intentresolver.createShortcutInfo import com.android.intentresolver.mock import com.android.intentresolver.whenever +import com.google.common.truth.Truth.assertWithMessage import java.util.function.Consumer import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestCoroutineScheduler @@ -394,6 +395,47 @@ class ShortcutLoaderTest { verify(appPredictor, times(1)).unregisterPredictionUpdates(any()) } + @Test + fun test_nullIntentFilterNoAppAppPredictorResults_returnEmptyResult() = + scope.runTest { + val shortcutManager = mock() + whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) + val testSubject = + ShortcutLoader( + context, + backgroundScope, + appPredictor, + UserHandle.of(0), + isPersonalProfile = true, + targetIntentFilter = null, + dispatcher, + callback + ) + + testSubject.updateAppTargets(appTargets) + + verify(appPredictor, times(1)).requestPredictionUpdate() + val appPredictorCallbackCaptor = argumentCaptor() + verify(appPredictor, times(1)) + .registerPredictionUpdates(any(), capture(appPredictorCallbackCaptor)) + appPredictorCallbackCaptor.value.onTargetsAvailable(emptyList()) + + verify(shortcutManager, never()).getShareTargets(any()) + val resultCaptor = argumentCaptor() + verify(callback, times(1)).accept(capture(resultCaptor)) + + val result = resultCaptor.value + assertWithMessage("A ShortcutManager result is expected") + .that(result.isFromAppPredictor) + .isFalse() + assertArrayEquals( + "Wrong input app targets in the result", + appTargets, + result.appTargets + ) + assertWithMessage("An empty result is expected").that(result.shortcutsByApp).isEmpty() + } + @Test fun test_workProfileNotRunning_doNotCallServices() { testDisabledWorkProfileDoNotCallSystem(isUserRunning = false) -- cgit v1.2.3-59-g8ed1b From af6a22081dca5b68e99af1cd6127bae660651565 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Tue, 19 Dec 2023 21:21:57 +0000 Subject: Clean up indexing/lookup methods in pager adapter This is originally from snapshot 33 in ag/25335069: "consolidate and rename-more-explicitly the assorted index conversion/lookup methods in `MultiProfilePagerAdapter`. Keeping these methods together (and implemented in terms of each other where possible) makes it easier to audit our logic as we prepare for 3+ tabs. Particularly, this establishes a 'source of truth' for the aspect of our business logic that assigns the clone profile as a sort of 'secondary handle' for the personal tab." Bug: 310211468 Test: `IntentResolver-tests-actvity`, `ResolverActivityTest` Change-Id: I5dda5909318d9741927f3e2657cb4aefdfab6c6b --- .../v2/ChooserMultiProfilePagerAdapter.java | 4 +- .../v2/MultiProfilePagerAdapter.java | 127 ++++++++++++--------- .../v2/MultiProfilePagerAdapterTest.kt | 10 +- 3 files changed, 83 insertions(+), 58 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java index 06f4bfae..c3db4a51 100644 --- a/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java @@ -135,7 +135,7 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< */ public void setIsCollapsed(boolean isCollapsed) { for (int i = 0, size = getItemCount(); i < size; i++) { - getAdapterForIndex(i).setAzLabelVisibility(!isCollapsed); + getPageAdapterForIndex(i).setAzLabelVisibility(!isCollapsed); } } @@ -170,7 +170,7 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< /** Apply the specified {@code height} as the footer in each tab's adapter. */ public void setFooterHeightInEveryAdapter(int height) { for (int i = 0; i < getItemCount(); ++i) { - getAdapterForIndex(i).setFooterHeight(height); + getPageAdapterForIndex(i).setFooterHeight(height); } } diff --git a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java index 3387a73f..3f772775 100644 --- a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java @@ -167,8 +167,16 @@ class MultiProfilePagerAdapter< profile, mPageViewInflater.get(), adapter, containerBottomPaddingOverrideSupplier); } + private boolean hasPageForIndex(int pageIndex) { + return (pageIndex >= 0) && (pageIndex < getCount()); + } + + public final boolean hasPageForProfile(@Profile int profile) { + return hasPageForIndex(getPageNumberForProfile(profile)); + } + private @Profile int getProfileForPageNumber(int position) { - if (hasAdapterForIndex(position)) { + if (hasPageForIndex(position)) { return mItems.get(position).mProfile; } return -1; @@ -183,6 +191,56 @@ class MultiProfilePagerAdapter< return -1; } + private ListAdapterT getListAdapterForPageNumber(int pageNumber) { + SinglePageAdapterT pageAdapter = getPageAdapterForIndex(pageNumber); + if (pageAdapter == null) { + return null; + } + return mListAdapterExtractor.apply(pageAdapter); + } + + private @Profile int getProfileForUserHandle(UserHandle userHandle) { + if (userHandle.equals(getCloneUserHandle())) { + // TODO: can we push this special case elsewhere -- e.g., when we check against each + // list adapter's user handle in the loop below, could we instead ask the list adapter + // whether it "represents" the queried user handle, and have the personal list adapter + // return true because it knows it's also associated with the clone profile? Or if we + // don't want to make modifications to the list adapter, maybe we could at least specify + // it in our per-page configuration data that we use to build our tabs/pages, and then + // maintain the relevant bookkeeping in our own ProfileDescriptor? + return PROFILE_PERSONAL; + } + for (int i = 0; i < mItems.size(); ++i) { + ListAdapterT listAdapter = getListAdapterForPageNumber(i); + if (listAdapter.getUserHandle().equals(userHandle)) { + return mItems.get(i).mProfile; + } + } + return -1; + } + + private int getPageNumberForUserHandle(UserHandle userHandle) { + return getPageNumberForProfile(getProfileForUserHandle(userHandle)); + } + + /** + * Returns the {@link ListAdapterT} instance of the profile that represents + * userHandle. If there is no such adapter for the specified + * userHandle, returns {@code null}. + *

For example, if there is a work profile on the device with user id 10, calling this method + * with UserHandle.of(10) returns the work profile {@link ListAdapterT}. + */ + @Nullable + public final ListAdapterT getListAdapterForUserHandle(UserHandle userHandle) { + return getListAdapterForPageNumber(getPageNumberForUserHandle(userHandle)); + } + + @Nullable + private ProfileDescriptor getDescriptorForUserHandle( + UserHandle userHandle) { + return getItem(getPageNumberForUserHandle(userHandle)); + } + private void updateActiveTabStyle(TabHost tabHost) { int currentTab = tabHost.getCurrentTab(); @@ -355,7 +413,11 @@ class MultiProfilePagerAdapter< * 1 would return the work profile {@link ProfileDescriptor}. * */ + @Nullable private ProfileDescriptor getItem(int pageIndex) { + if (!hasPageForIndex(pageIndex)) { + return null; + } return mItems.get(pageIndex); } @@ -387,7 +449,10 @@ class MultiProfilePagerAdapter< * depending on the adapter type. */ @VisibleForTesting - public final SinglePageAdapterT getAdapterForIndex(int index) { + public final SinglePageAdapterT getPageAdapterForIndex(int index) { + if (!hasPageForIndex(index)) { + return null; + } return getItem(index).mAdapter; } @@ -396,30 +461,7 @@ class MultiProfilePagerAdapter< * by pageIndex. */ public final void setupListAdapter(int pageIndex) { - mAdapterBinder.bind(getListViewForIndex(pageIndex), getAdapterForIndex(pageIndex)); - } - - /** - * Returns the {@link ListAdapterT} instance of the profile that represents - * userHandle. If there is no such adapter for the specified - * userHandle, returns {@code null}. - *

For example, if there is a work profile on the device with user id 10, calling this method - * with UserHandle.of(10) returns the work profile {@link ListAdapterT}. - */ - @Nullable - public final ListAdapterT getListAdapterForUserHandle(UserHandle userHandle) { - if (getPersonalListAdapter().getUserHandle().equals(userHandle) - || userHandle.equals(getCloneUserHandle())) { - return getPersonalListAdapter(); - } else if ((getWorkListAdapter() != null) - && getWorkListAdapter().getUserHandle().equals(userHandle)) { - return getWorkListAdapter(); - } - return null; - } - - private ListAdapterT getListAdapterForPageNumber(int pageNumber) { - return mListAdapterExtractor.apply(getAdapterForIndex(pageNumber)); + mAdapterBinder.bind(getListViewForIndex(pageIndex), getPageAdapterForIndex(pageIndex)); } /** @@ -437,11 +479,6 @@ class MultiProfilePagerAdapter< return getListAdapterForPageNumber(getPageNumberForProfile(PROFILE_PERSONAL)); } - /** @return whether our tab data contains a page for the specified {@code profile} ID. */ - public final boolean hasPageForProfile(@Profile int profile) { - return hasAdapterForIndex(getPageNumberForProfile(profile)); - } - @Nullable public final ListAdapterT getWorkListAdapter() { if (!hasPageForProfile(PROFILE_WORK)) { @@ -451,7 +488,7 @@ class MultiProfilePagerAdapter< } public final SinglePageAdapterT getCurrentRootAdapter() { - return getAdapterForIndex(getCurrentPage()); + return getPageAdapterForIndex(getCurrentPage()); } public final PageViewT getActiveAdapterView() { @@ -460,7 +497,7 @@ class MultiProfilePagerAdapter< private boolean anyAdapterHasItems() { for (int i = 0; i < mItems.size(); ++i) { - ListAdapterT listAdapter = mListAdapterExtractor.apply(getAdapterForIndex(i)); + ListAdapterT listAdapter = getListAdapterForPageNumber(i); if (listAdapter.getCount() > 0) { return true; } @@ -573,14 +610,6 @@ class MultiProfilePagerAdapter< return allRebuildsComplete.get(); } - private int userHandleToPageIndex(UserHandle userHandle) { - if (userHandle.equals(getPersonalListAdapter().getUserHandle())) { - return getPageNumberForProfile(PROFILE_PERSONAL); - } else { - return getPageNumberForProfile(PROFILE_WORK); - } - } - protected void forEachPage(Consumer pageNumberHandler) { for (int pageNumber = 0; pageNumber < getItemCount(); ++pageNumber) { pageNumberHandler.accept(pageNumber); @@ -608,10 +637,6 @@ class MultiProfilePagerAdapter< return emptyState != null && emptyState.shouldSkipDataRebuild(); } - private boolean hasAdapterForIndex(int pageIndex) { - return (pageIndex >= 0) && (pageIndex < getCount()); - } - /** * The empty state screens are shown according to their priority: *

    @@ -640,8 +665,8 @@ class MultiProfilePagerAdapter< if (emptyState.getButtonClickListener() != null) { clickListener = v -> emptyState.getButtonClickListener().onClick(() -> { - ProfileDescriptor descriptor = getItem( - userHandleToPageIndex(listAdapter.getUserHandle())); + ProfileDescriptor descriptor = + getDescriptorForUserHandle(listAdapter.getUserHandle()); descriptor.mEmptyStateUi.showSpinner(); }); } @@ -665,8 +690,8 @@ class MultiProfilePagerAdapter< ListAdapterT activeListAdapter, EmptyState emptyState, View.OnClickListener buttonOnClick) { - ProfileDescriptor descriptor = getItem( - userHandleToPageIndex(activeListAdapter.getUserHandle())); + ProfileDescriptor descriptor = + getDescriptorForUserHandle(activeListAdapter.getUserHandle()); descriptor.mEmptyStateUi.showEmptyState(emptyState, buttonOnClick); activeListAdapter.markTabLoaded(); } @@ -680,8 +705,8 @@ class MultiProfilePagerAdapter< } public void showListView(ListAdapterT activeListAdapter) { - ProfileDescriptor descriptor = getItem( - userHandleToPageIndex(activeListAdapter.getUserHandle())); + ProfileDescriptor descriptor = + getDescriptorForUserHandle(activeListAdapter.getUserHandle()); descriptor.mEmptyStateUi.hide(); } diff --git a/tests/unit/src/com/android/intentresolver/v2/MultiProfilePagerAdapterTest.kt b/tests/unit/src/com/android/intentresolver/v2/MultiProfilePagerAdapterTest.kt index 892fbb4e..fc9931fe 100644 --- a/tests/unit/src/com/android/intentresolver/v2/MultiProfilePagerAdapterTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/MultiProfilePagerAdapterTest.kt @@ -67,7 +67,7 @@ class MultiProfilePagerAdapterTest { assertThat(pagerAdapter.count).isEqualTo(1) assertThat(pagerAdapter.currentPage).isEqualTo(PROFILE_PERSONAL) assertThat(pagerAdapter.currentUserHandle).isEqualTo(PERSONAL_USER_HANDLE) - assertThat(pagerAdapter.getAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.getPageAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(personalListAdapter) assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter) assertThat(pagerAdapter.workListAdapter).isNull() @@ -100,8 +100,8 @@ class MultiProfilePagerAdapterTest { assertThat(pagerAdapter.count).isEqualTo(2) assertThat(pagerAdapter.currentPage).isEqualTo(PROFILE_PERSONAL) assertThat(pagerAdapter.currentUserHandle).isEqualTo(PERSONAL_USER_HANDLE) - assertThat(pagerAdapter.getAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.getAdapterForIndex(1)).isSameInstanceAs(workListAdapter) + assertThat(pagerAdapter.getPageAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.getPageAdapterForIndex(1)).isSameInstanceAs(workListAdapter) assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(personalListAdapter) assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter) assertThat(pagerAdapter.workListAdapter).isSameInstanceAs(workListAdapter) @@ -138,8 +138,8 @@ class MultiProfilePagerAdapterTest { assertThat(pagerAdapter.count).isEqualTo(2) assertThat(pagerAdapter.currentPage).isEqualTo(PROFILE_WORK) assertThat(pagerAdapter.currentUserHandle).isEqualTo(WORK_USER_HANDLE) - assertThat(pagerAdapter.getAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.getAdapterForIndex(1)).isSameInstanceAs(workListAdapter) + assertThat(pagerAdapter.getPageAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.getPageAdapterForIndex(1)).isSameInstanceAs(workListAdapter) assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(workListAdapter) assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter) assertThat(pagerAdapter.workListAdapter).isSameInstanceAs(workListAdapter) -- cgit v1.2.3-59-g8ed1b From 6cf3e38519ef4704b14e8ac3f94ea651fa309f68 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Tue, 19 Dec 2023 22:36:59 +0000 Subject: Generalize pager-adapter to support "many" tabs (as in "0-1-many": if we have to support "multiple" then we shouldn't hard-code limitations on the number that we *do* support.) These changes are as prototyped in ag/25335069 and described in go/chooser-ntab-refactoring, in particular replicating the changes of "snapshot 34" through "snapshot 36." See below for a "by-snapshot" breakdown of the incremental changes composed in this CL. Snapshot 1: build pager-adapters from requests that include enough details about the pages for us to be able to implement `setupProfileTabs()` from our own records, without requiring the caller to provide per-tab data. This change stops short of actually changing the implementation of `setupProfileTabs()` and mostly just establishes the bookkeeping / new convention for configuration. Snapshot 2: implement `MultiProfilePagerAdapter.setupProfileTabs()` to build tabs from its own records. Snapshot 3: look up the page number for the "default profile" page, instead of assuming it's equal to the profile ID (since that may not be true in the "n-tab" case). Bug: 310211468 Test: `IntentResolver-tests-{activity,unit}`, `ResolverActivityTest` Change-Id: I3f8b9f9e53ff7a0740d1accd3a50b08e7c01bb55 --- .../android/intentresolver/v2/ChooserActivity.java | 15 ++- .../v2/ChooserMultiProfilePagerAdapter.java | 37 +++++- .../v2/MultiProfilePagerAdapter.java | 139 ++++++++++++--------- .../intentresolver/v2/ResolverActivity.java | 23 ++-- .../v2/ResolverMultiProfilePagerAdapter.java | 38 +++++- .../v2/MultiProfilePagerAdapterTest.kt | 75 +++++++++-- 6 files changed, 237 insertions(+), 90 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 2e020743..25024799 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -1164,12 +1164,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements viewPager, R.layout.resolver_profile_tab_button, com.android.internal.R.id.profile_pager, - mDevicePolicyResources.getPersonalTabLabel(), - mDevicePolicyResources.getPersonalTabAccessibilityLabel(), - TAB_TAG_PERSONAL, - mDevicePolicyResources.getWorkTabLabel(), - mDevicePolicyResources.getWorkTabAccessibilityLabel(), - TAB_TAG_WORK, () -> onProfileTabSelected(viewPager.getCurrentItem()), new MultiProfilePagerAdapter.OnProfileSelectedListener() { @Override @@ -1338,6 +1332,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements targetDataLoader); return new ChooserMultiProfilePagerAdapter( /* context */ this, + mDevicePolicyResources.getPersonalTabLabel(), + mDevicePolicyResources.getPersonalTabAccessibilityLabel(), + TAB_TAG_PERSONAL, adapter, createEmptyStateProvider(/* workProfileUserHandle= */ null), /* workProfileQuietModeChecker= */ () -> false, @@ -1371,7 +1368,13 @@ public class ChooserActivity extends Hilt_ChooserActivity implements targetDataLoader); return new ChooserMultiProfilePagerAdapter( /* context */ this, + mDevicePolicyResources.getPersonalTabLabel(), + mDevicePolicyResources.getPersonalTabAccessibilityLabel(), + TAB_TAG_PERSONAL, personalAdapter, + mDevicePolicyResources.getWorkTabLabel(), + mDevicePolicyResources.getWorkTabAccessibilityLabel(), + TAB_TAG_WORK, workAdapter, createEmptyStateProvider(requireAnnotatedUserHandles().workProfileUserHandle), () -> mLogic.getWorkProfileAvailabilityManager().isQuietModeEnabled(), diff --git a/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java index c3db4a51..14532b67 100644 --- a/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java @@ -50,7 +50,10 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< public ChooserMultiProfilePagerAdapter( Context context, - ChooserGridAdapter adapter, + String personalTabLabel, + String personalTabAccessibilityLabel, + String personalTabTag, + ChooserGridAdapter personalAdapter, EmptyStateProvider emptyStateProvider, Supplier workProfileQuietModeChecker, UserHandle workProfileUserHandle, @@ -60,7 +63,13 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< this( context, new ChooserProfileAdapterBinder(maxTargetsPerRow), - ImmutableList.of(adapter), + ImmutableList.of( + new TabConfig<>( + PROFILE_PERSONAL, + personalTabLabel, + personalTabAccessibilityLabel, + personalTabTag, + personalAdapter)), emptyStateProvider, workProfileQuietModeChecker, /* defaultProfile= */ 0, @@ -72,7 +81,13 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< public ChooserMultiProfilePagerAdapter( Context context, + String personalTabLabel, + String personalTabAccessibilityLabel, + String personalTabTag, ChooserGridAdapter personalAdapter, + String workTabLabel, + String workTabAccessibilityLabel, + String workTabTag, ChooserGridAdapter workAdapter, EmptyStateProvider emptyStateProvider, Supplier workProfileQuietModeChecker, @@ -84,7 +99,19 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< this( context, new ChooserProfileAdapterBinder(maxTargetsPerRow), - ImmutableList.of(personalAdapter, workAdapter), + ImmutableList.of( + new TabConfig<>( + PROFILE_PERSONAL, + personalTabLabel, + personalTabAccessibilityLabel, + personalTabTag, + personalAdapter), + new TabConfig<>( + PROFILE_WORK, + workTabLabel, + workTabAccessibilityLabel, + workTabTag, + workAdapter)), emptyStateProvider, workProfileQuietModeChecker, defaultProfile, @@ -97,7 +124,7 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< private ChooserMultiProfilePagerAdapter( Context context, ChooserProfileAdapterBinder adapterBinder, - ImmutableList gridAdapters, + ImmutableList> tabs, EmptyStateProvider emptyStateProvider, Supplier workProfileQuietModeChecker, @Profile int defaultProfile, @@ -108,7 +135,7 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< super( gridAdapter -> gridAdapter.getListAdapter(), adapterBinder, - gridAdapters, + tabs, emptyStateProvider, workProfileQuietModeChecker, defaultProfile, diff --git a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java index 3f772775..2883542e 100644 --- a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java @@ -38,6 +38,7 @@ import com.android.internal.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; import java.util.HashSet; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; @@ -109,10 +110,31 @@ class MultiProfilePagerAdapter< private int mCurrentPage; private OnProfileSelectedListener mOnProfileSelectedListener; + public static class TabConfig { + private final @Profile int mProfile; + private final String mTabLabel; + private final String mTabAccessibilityLabel; + private final String mTabTag; + private final PageAdapterT mPageAdapter; + + public TabConfig( + @Profile int profile, + String tabLabel, + String tabAccessibilityLabel, + String tabTag, + PageAdapterT pageAdapter) { + mProfile = profile; + mTabLabel = tabLabel; + mTabAccessibilityLabel = tabAccessibilityLabel; + mTabTag = tabTag; + mPageAdapter = pageAdapter; + } + } + protected MultiProfilePagerAdapter( Function listAdapterExtractor, AdapterBinder adapterBinder, - ImmutableList adapters, + ImmutableList> tabs, EmptyStateProvider emptyStateProvider, Supplier workProfileQuietModeChecker, @Profile int defaultProfile, @@ -120,7 +142,6 @@ class MultiProfilePagerAdapter< UserHandle cloneProfileUserHandle, Supplier pageViewInflater, Supplier> containerBottomPaddingOverrideSupplier) { - mCurrentPage = defaultProfile; mLoadedPages = new HashSet<>(); mWorkProfileUserHandle = workProfileUserHandle; mCloneProfileUserHandle = cloneProfileUserHandle; @@ -133,38 +154,40 @@ class MultiProfilePagerAdapter< ImmutableList.Builder> items = new ImmutableList.Builder<>(); - // TODO: for now this only builds in personal and work tabs; any other provided `adapters` - // are ignored. Historically this class wouldn't have behaved correctly for any more than - // those two tabs, so this is more explicit about our current support. Upcoming changes will - // generalize to support more tabs. - for (SinglePageAdapterT pageAdapter : adapters) { - ListAdapterT listAdapter = mListAdapterExtractor.apply(pageAdapter); - if (listAdapter.getUserHandle().equals(workProfileUserHandle)) { - items.add( - createProfileDescriptor( - PROFILE_WORK, pageAdapter, containerBottomPaddingOverrideSupplier)); - } else { - // TODO: it shouldn't be possible to add multiple "personal" descriptors. For now - // we're just trusting our clients to provide valid data. We should avoid making - // inferences from the adapter's user handle, and instead have the pager-adapter - // receive all the necessary configuration data (in some format that ensures - // uniqueness of the adapters assigned to a given profile). - items.add( - createProfileDescriptor( - PROFILE_PERSONAL, - pageAdapter, - containerBottomPaddingOverrideSupplier)); - } + for (TabConfig tab : tabs) { + // TODO: consider representing tabConfig in a different data structure that can ensure + // uniqueness of their profile assignments (while still respecting the client's + // requested tab order). + items.add( + createProfileDescriptor( + tab.mProfile, + tab.mTabLabel, + tab.mTabAccessibilityLabel, + tab.mTabTag, + tab.mPageAdapter, + containerBottomPaddingOverrideSupplier)); } mItems = items.build(); + + mCurrentPage = + hasPageForProfile(defaultProfile) ? getPageNumberForProfile(defaultProfile) : 0; } private ProfileDescriptor createProfileDescriptor( @Profile int profile, + String tabLabel, + String tabAccessibilityLabel, + String tabTag, SinglePageAdapterT adapter, Supplier> containerBottomPaddingOverrideSupplier) { return new ProfileDescriptor<>( - profile, mPageViewInflater.get(), adapter, containerBottomPaddingOverrideSupplier); + profile, + tabLabel, + tabAccessibilityLabel, + tabTag, + mPageViewInflater.get(), + adapter, + containerBottomPaddingOverrideSupplier); } private boolean hasPageForIndex(int pageIndex) { @@ -241,6 +264,15 @@ class MultiProfilePagerAdapter< return getItem(getPageNumberForUserHandle(userHandle)); } + private int getPageNumberForTabTag(String tag) { + for (int i = 0; i < mItems.size(); ++i) { + if (Objects.equals(mItems.get(i).mTabTag, tag)) { + return i; + } + } + return -1; + } + private void updateActiveTabStyle(TabHost tabHost) { int currentTab = tabHost.getCurrentTab(); @@ -258,48 +290,34 @@ class MultiProfilePagerAdapter< ViewPager viewPager, int tabButtonLayoutResId, int tabPageContentViewId, - String personalTabLabel, - String personalTabAccessibilityLabel, - String personalTabTag, - String workTabLabel, - String workTabAccessibilityLabel, - String workTabTag, Runnable onTabChangeListener, MultiProfilePagerAdapter.OnProfileSelectedListener clientOnProfileSelectedListener) { tabHost.setup(); viewPager.setSaveEnabled(false); - Button personalButton = (Button) layoutInflater.inflate( - tabButtonLayoutResId, tabHost.getTabWidget(), false); - personalButton.setText(personalTabLabel); - personalButton.setContentDescription(personalTabAccessibilityLabel); - - TabHost.TabSpec personalTabSpec = tabHost.newTabSpec(personalTabTag) - .setContent(tabPageContentViewId) - .setIndicator(personalButton); - tabHost.addTab(personalTabSpec); - - Button workButton = (Button) layoutInflater.inflate( - tabButtonLayoutResId, tabHost.getTabWidget(), false); - workButton.setText(workTabLabel); - workButton.setContentDescription(workTabAccessibilityLabel); - - TabHost.TabSpec workTabSpec = tabHost.newTabSpec(workTabTag) - .setContent(tabPageContentViewId) - .setIndicator(workButton); - tabHost.addTab(workTabSpec); + for (int pageNumber = 0; pageNumber < getItemCount(); ++pageNumber) { + ProfileDescriptor descriptor = mItems.get(pageNumber); + Button profileButton = (Button) layoutInflater.inflate( + tabButtonLayoutResId, tabHost.getTabWidget(), false); + profileButton.setText(descriptor.mTabLabel); + profileButton.setContentDescription(descriptor.mTabAccessibilityLabel); + + TabHost.TabSpec profileTabSpec = tabHost.newTabSpec(descriptor.mTabTag) + .setContent(tabPageContentViewId) + .setIndicator(profileButton); + tabHost.addTab(profileTabSpec); + } tabHost.getTabWidget().setVisibility(View.VISIBLE); updateActiveTabStyle(tabHost); - tabHost.setOnTabChangedListener(tabId -> { + tabHost.setOnTabChangedListener(tabTag -> { updateActiveTabStyle(tabHost); - // TODO: update for 3+ tabs. - if (personalTabTag.equals(tabId)) { - viewPager.setCurrentItem(0); - } else { - viewPager.setCurrentItem(1); + + int pageNumber = getPageNumberForTabTag(tabTag); + if (pageNumber >= 0) { + viewPager.setCurrentItem(pageNumber); } onTabChangeListener.run(); }); @@ -736,6 +754,9 @@ class MultiProfilePagerAdapter< // should be the owner of all per-profile data (especially now that the API is generic)? private static class ProfileDescriptor { final @Profile int mProfile; + final String mTabLabel; + final String mTabAccessibilityLabel; + final String mTabTag; final ViewGroup mRootView; final EmptyStateUiHelper mEmptyStateUi; @@ -749,10 +770,16 @@ class MultiProfilePagerAdapter< ProfileDescriptor( @Profile int forProfile, + String tabLabel, + String tabAccessibilityLabel, + String tabTag, ViewGroup rootView, SinglePageAdapterT adapter, Supplier> containerBottomPaddingOverrideSupplier) { mProfile = forProfile; + mTabLabel = tabLabel; + mTabAccessibilityLabel = tabAccessibilityLabel; + mTabTag = tabTag; mRootView = rootView; mAdapter = adapter; mEmptyStateView = rootView.findViewById(com.android.internal.R.id.resolver_empty_state); diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index 2a6ed9d5..3394c543 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -167,8 +167,8 @@ public class ResolverActivity extends Hilt_ResolverActivity implements /** Tracks if we should ignore future broadcasts telling us the work profile is enabled */ private final boolean mWorkProfileHasBeenEnabled = false; - private static final String TAB_TAG_PERSONAL = "personal"; - private static final String TAB_TAG_WORK = "work"; + protected static final String TAB_TAG_PERSONAL = "personal"; + protected static final String TAB_TAG_WORK = "work"; private PackageMonitor mPersonalPackageMonitor; private PackageMonitor mWorkPackageMonitor; @@ -946,7 +946,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements List resolutionList, boolean filterLastUsed, TargetDataLoader targetDataLoader) { - ResolverListAdapter adapter = createResolverListAdapter( + ResolverListAdapter personalAdapter = createResolverListAdapter( /* context */ this, mLogic.getPayloadIntents(), initialIntents, @@ -956,7 +956,10 @@ public class ResolverActivity extends Hilt_ResolverActivity implements targetDataLoader); return new ResolverMultiProfilePagerAdapter( /* context */ this, - adapter, + mDevicePolicyResources.getPersonalTabLabel(), + mDevicePolicyResources.getPersonalTabAccessibilityLabel(), + TAB_TAG_PERSONAL, + personalAdapter, createEmptyStateProvider(/* workProfileUserHandle= */ null), /* workProfileQuietModeChecker= */ () -> false, /* workProfileUserHandle= */ null, @@ -1015,7 +1018,13 @@ public class ResolverActivity extends Hilt_ResolverActivity implements targetDataLoader); return new ResolverMultiProfilePagerAdapter( /* context */ this, + mDevicePolicyResources.getPersonalTabLabel(), + mDevicePolicyResources.getPersonalTabAccessibilityLabel(), + TAB_TAG_PERSONAL, personalAdapter, + mDevicePolicyResources.getWorkTabLabel(), + mDevicePolicyResources.getWorkTabAccessibilityLabel(), + TAB_TAG_WORK, workAdapter, createEmptyStateProvider(workProfileUserHandle), () -> mLogic.getWorkProfileAvailabilityManager().isQuietModeEnabled(), @@ -1904,12 +1913,6 @@ public class ResolverActivity extends Hilt_ResolverActivity implements viewPager, R.layout.resolver_profile_tab_button, com.android.internal.R.id.profile_pager, - mDevicePolicyResources.getPersonalTabLabel(), - mDevicePolicyResources.getPersonalTabAccessibilityLabel(), - TAB_TAG_PERSONAL, - mDevicePolicyResources.getWorkTabLabel(), - mDevicePolicyResources.getWorkTabAccessibilityLabel(), - TAB_TAG_WORK, () -> onProfileTabSelected(viewPager.getCurrentItem()), new MultiProfilePagerAdapter.OnProfileSelectedListener() { @Override diff --git a/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java index 99de3b4d..4c1358ed 100644 --- a/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java @@ -27,7 +27,6 @@ import androidx.viewpager.widget.PagerAdapter; import com.android.intentresolver.R; import com.android.intentresolver.ResolverListAdapter; import com.android.intentresolver.emptystate.EmptyStateProvider; -import com.android.internal.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; @@ -43,14 +42,23 @@ public class ResolverMultiProfilePagerAdapter extends public ResolverMultiProfilePagerAdapter( Context context, - ResolverListAdapter adapter, + String personalTabLabel, + String personalTabAccessibilityLabel, + String personalTabTag, + ResolverListAdapter personalAdapter, EmptyStateProvider emptyStateProvider, Supplier workProfileQuietModeChecker, UserHandle workProfileUserHandle, UserHandle cloneProfileUserHandle) { this( context, - ImmutableList.of(adapter), + ImmutableList.of( + new TabConfig<>( + PROFILE_PERSONAL, + personalTabLabel, + personalTabAccessibilityLabel, + personalTabTag, + personalAdapter)), emptyStateProvider, workProfileQuietModeChecker, /* defaultProfile= */ 0, @@ -60,7 +68,13 @@ public class ResolverMultiProfilePagerAdapter extends } public ResolverMultiProfilePagerAdapter(Context context, + String personalTabLabel, + String personalTabAccessibilityLabel, + String personalTabTag, ResolverListAdapter personalAdapter, + String workTabLabel, + String workTabAccessibilityLabel, + String workTabTag, ResolverListAdapter workAdapter, EmptyStateProvider emptyStateProvider, Supplier workProfileQuietModeChecker, @@ -69,7 +83,19 @@ public class ResolverMultiProfilePagerAdapter extends UserHandle cloneProfileUserHandle) { this( context, - ImmutableList.of(personalAdapter, workAdapter), + ImmutableList.of( + new TabConfig<>( + PROFILE_PERSONAL, + personalTabLabel, + personalTabAccessibilityLabel, + personalTabTag, + personalAdapter), + new TabConfig<>( + PROFILE_WORK, + workTabLabel, + workTabAccessibilityLabel, + workTabTag, + workAdapter)), emptyStateProvider, workProfileQuietModeChecker, defaultProfile, @@ -80,7 +106,7 @@ public class ResolverMultiProfilePagerAdapter extends private ResolverMultiProfilePagerAdapter( Context context, - ImmutableList listAdapters, + ImmutableList> tabs, EmptyStateProvider emptyStateProvider, Supplier workProfileQuietModeChecker, @Profile int defaultProfile, @@ -90,7 +116,7 @@ public class ResolverMultiProfilePagerAdapter extends super( listAdapter -> listAdapter, (listView, bindAdapter) -> listView.setAdapter(bindAdapter), - listAdapters, + tabs, emptyStateProvider, workProfileQuietModeChecker, defaultProfile, diff --git a/tests/unit/src/com/android/intentresolver/v2/MultiProfilePagerAdapterTest.kt b/tests/unit/src/com/android/intentresolver/v2/MultiProfilePagerAdapterTest.kt index fc9931fe..8e5f00ac 100644 --- a/tests/unit/src/com/android/intentresolver/v2/MultiProfilePagerAdapterTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/MultiProfilePagerAdapterTest.kt @@ -28,6 +28,7 @@ import com.android.intentresolver.R import com.android.intentresolver.ResolverListAdapter import com.android.intentresolver.emptystate.EmptyStateProvider import com.android.intentresolver.mock +import com.android.intentresolver.v2.MultiProfilePagerAdapter.TabConfig import com.android.intentresolver.whenever import com.google.common.collect.ImmutableList import com.google.common.truth.Truth.assertThat @@ -55,7 +56,15 @@ class MultiProfilePagerAdapterTest { { listView: ListView, bindAdapter: ResolverListAdapter -> listView.setAdapter(bindAdapter) }, - ImmutableList.of(personalListAdapter), + ImmutableList.of( + TabConfig( + PROFILE_PERSONAL, + "personal", + "personal_a11y", + "TAG_PERSONAL", + personalListAdapter + ) + ), object : EmptyStateProvider {}, { false }, PROFILE_PERSONAL, @@ -88,7 +97,16 @@ class MultiProfilePagerAdapterTest { { listView: ListView, bindAdapter: ResolverListAdapter -> listView.setAdapter(bindAdapter) }, - ImmutableList.of(personalListAdapter, workListAdapter), + ImmutableList.of( + TabConfig( + PROFILE_PERSONAL, + "personal", + "personal_a11y", + "TAG_PERSONAL", + personalListAdapter + ), + TabConfig(PROFILE_WORK, "work", "work_a11y", "TAG_WORK", workListAdapter) + ), object : EmptyStateProvider {}, { false }, PROFILE_PERSONAL, @@ -126,7 +144,16 @@ class MultiProfilePagerAdapterTest { { listView: ListView, bindAdapter: ResolverListAdapter -> listView.setAdapter(bindAdapter) }, - ImmutableList.of(personalListAdapter, workListAdapter), + ImmutableList.of( + TabConfig( + PROFILE_PERSONAL, + "personal", + "personal_a11y", + "TAG_PERSONAL", + personalListAdapter + ), + TabConfig(PROFILE_WORK, "work", "work_a11y", "TAG_WORK", workListAdapter) + ), object : EmptyStateProvider {}, { false }, PROFILE_WORK, // <-- This test specifically requests we start on work profile. @@ -160,7 +187,15 @@ class MultiProfilePagerAdapterTest { { listView: ListView, bindAdapter: ResolverListAdapter -> listView.setAdapter(bindAdapter) }, - ImmutableList.of(personalListAdapter), + ImmutableList.of( + TabConfig( + PROFILE_PERSONAL, + "personal", + "personal_a11y", + "TAG_PERSONAL", + personalListAdapter + ) + ), object : EmptyStateProvider {}, { false }, PROFILE_PERSONAL, @@ -191,7 +226,15 @@ class MultiProfilePagerAdapterTest { { listView: ListView, bindAdapter: ResolverListAdapter -> listView.setAdapter(bindAdapter) }, - ImmutableList.of(personalListAdapter), + ImmutableList.of( + TabConfig( + PROFILE_PERSONAL, + "personal", + "personal_a11y", + "TAG_PERSONAL", + personalListAdapter + ) + ), object : EmptyStateProvider {}, { false }, PROFILE_PERSONAL, @@ -233,7 +276,16 @@ class MultiProfilePagerAdapterTest { { listView: ListView, bindAdapter: ResolverListAdapter -> listView.setAdapter(bindAdapter) }, - ImmutableList.of(personalListAdapter, workListAdapter), + ImmutableList.of( + TabConfig( + PROFILE_PERSONAL, + "personal", + "personal_a11y", + "TAG_PERSONAL", + personalListAdapter + ), + TabConfig(PROFILE_WORK, "work", "work_a11y", "TAG_WORK", workListAdapter) + ), object : EmptyStateProvider {}, { true }, // <-- Work mode is quiet. PROFILE_WORK, @@ -267,7 +319,16 @@ class MultiProfilePagerAdapterTest { { listView: ListView, bindAdapter: ResolverListAdapter -> listView.setAdapter(bindAdapter) }, - ImmutableList.of(personalListAdapter, workListAdapter), + ImmutableList.of( + TabConfig( + PROFILE_PERSONAL, + "personal", + "personal_a11y", + "TAG_PERSONAL", + personalListAdapter + ), + TabConfig(PROFILE_WORK, "work", "work_a11y", "TAG_WORK", workListAdapter) + ), object : EmptyStateProvider {}, { false }, // <-- Work mode is not quiet. PROFILE_WORK, -- cgit v1.2.3-59-g8ed1b From 3453b29f41a51981f37cf424b13b4597849f8543 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Mon, 8 Jan 2024 14:25:44 -0500 Subject: Fix crash on pinning a target, and launching 'stacked' results ChooserTargetActionsDialogFragment was attempting to cast it's containing activity to a specific instance in order to make a listener call. ChooserStackedAppDialogFragment has the same issue, downcasting getActivity(). This change fixes the crashes by introducing a common interface for both versions. Bug: 319127480 Test: manual; enable chooser v2, share, pin a target Change-Id: Ib4817494c257e8620fe742320d3fc157a0daa15e --- .../android/intentresolver/ChooserActivity.java | 3 ++- .../ChooserStackedAppDialogFragment.java | 2 +- .../ChooserTargetActionsDialogFragment.java | 2 +- .../intentresolver/PackagesChangedListener.kt | 22 ++++++++++++++++++++++ .../android/intentresolver/StartsSelectedItem.kt | 21 +++++++++++++++++++++ .../android/intentresolver/v2/ChooserActivity.java | 10 +++++++--- 6 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 java/src/com/android/intentresolver/PackagesChangedListener.kt create mode 100644 java/src/com/android/intentresolver/StartsSelectedItem.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 82e46a57..37a9cdc2 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -125,7 +125,7 @@ import javax.inject.Inject; */ @AndroidEntryPoint(ResolverActivity.class) public class ChooserActivity extends Hilt_ChooserActivity implements - ResolverListAdapter.ResolverListCommunicator { + ResolverListAdapter.ResolverListCommunicator, PackagesChangedListener, StartsSelectedItem { private static final String TAG = "ChooserActivity"; /** @@ -565,6 +565,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements /** * Update UI to reflect changes in data. */ + @Override public void handlePackagesChanged() { handlePackagesChanged(/* listAdapter */ null); } diff --git a/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java b/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java index f0fcd149..30e69c18 100644 --- a/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java +++ b/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java @@ -63,7 +63,7 @@ public class ChooserStackedAppDialogFragment extends ChooserTargetActionsDialogF @Override public void onClick(DialogInterface dialog, int which) { mMultiDisplayResolveInfo.setSelected(which); - ((ChooserActivity) getActivity()).startSelected(mParentWhich, false, true); + ((StartsSelectedItem) getActivity()).startSelected(mParentWhich, false, true); dismiss(); } diff --git a/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java b/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java index b6b7de96..ae80fad4 100644 --- a/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java +++ b/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java @@ -205,7 +205,7 @@ public class ChooserTargetActionsDialogFragment extends DialogFragment } else { pinComponent(mTargetInfos.get(which).getResolvedComponentName()); } - ((ChooserActivity) getActivity()).handlePackagesChanged(); + ((PackagesChangedListener) getActivity()).handlePackagesChanged(); dismiss(); } diff --git a/java/src/com/android/intentresolver/PackagesChangedListener.kt b/java/src/com/android/intentresolver/PackagesChangedListener.kt new file mode 100644 index 00000000..10f0bf51 --- /dev/null +++ b/java/src/com/android/intentresolver/PackagesChangedListener.kt @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2024 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 + +/** A component which can be notified when packages have changed. */ +interface PackagesChangedListener { + /** Report that packages have changed. */ + fun handlePackagesChanged() +} diff --git a/java/src/com/android/intentresolver/StartsSelectedItem.kt b/java/src/com/android/intentresolver/StartsSelectedItem.kt new file mode 100644 index 00000000..01cdf124 --- /dev/null +++ b/java/src/com/android/intentresolver/StartsSelectedItem.kt @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2024 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 + +interface StartsSelectedItem { + /** Start the selected item. */ + fun startSelected(which: Int, always: Boolean, filtered: Boolean) +} diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 945cca76..6be0175f 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -113,10 +113,12 @@ import com.android.intentresolver.ChooserTargetActionsDialogFragment; import com.android.intentresolver.EnterTransitionAnimationDelegate; import com.android.intentresolver.FeatureFlags; import com.android.intentresolver.IntentForwarderActivity; +import com.android.intentresolver.PackagesChangedListener; import com.android.intentresolver.R; import com.android.intentresolver.ResolverListAdapter; import com.android.intentresolver.ResolverListController; import com.android.intentresolver.ResolverViewPager; +import com.android.intentresolver.StartsSelectedItem; import com.android.intentresolver.WorkProfileAvailabilityManager; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; @@ -184,7 +186,7 @@ import javax.inject.Inject; @SuppressWarnings("OptionalUsedAsFieldOrParameterType") @AndroidEntryPoint(FragmentActivity.class) public class ChooserActivity extends Hilt_ChooserActivity implements - ResolverListAdapter.ResolverListCommunicator { + ResolverListAdapter.ResolverListCommunicator, PackagesChangedListener, StartsSelectedItem { private static final String TAG = "ChooserActivity"; /** @@ -1413,6 +1415,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements /** * Update UI to reflect changes in data. */ + @Override public void handlePackagesChanged() { handlePackagesChanged(/* listAdapter */ null); } @@ -1721,7 +1724,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return !target.isSuspended(); } - public void startSelected(int which, boolean filtered) { + @Override + public void startSelected(int which, /* unused */ boolean always, boolean filtered) { ChooserListAdapter currentListAdapter = mChooserMultiProfilePagerAdapter.getActiveListAdapter(); TargetInfo targetInfo = currentListAdapter @@ -2033,7 +2037,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override public void onTargetSelected(int itemIndex) { - startSelected(itemIndex, true); + startSelected(itemIndex, false, true); } @Override -- cgit v1.2.3-59-g8ed1b From c50f7196ef9ae1abac546f49d45128e42678fec3 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Tue, 9 Jan 2024 11:48:39 -0500 Subject: Break system service deps into separate modules This is required to selectively replace dependencies within tests using @UninstallModules with @TestInstallIn or @BindValue. Within this change, tests which override package manager behavior are updated to use this mechanism, and PackageManager is removed from ChooserActivityOverrideData. Bug: 300157408 Test: atest IntentResolver-tests-activity:com.android.intentresolver.v2 Change-Id: I439b291b5768871f5a1c10f608bea1a9e7c2635b --- .../intentresolver/inject/FrameworkModule.kt | 76 --------------- .../intentresolver/inject/SystemServices.kt | 102 ++++++++++++++++++++ .../android/intentresolver/v2/ChooserActivity.java | 11 ++- .../v2/ChooserActivityOverrideData.java | 7 +- .../intentresolver/v2/ChooserWrapperActivity.java | 8 +- .../v2/UnbundledChooserActivityTest.java | 105 +++++++++++---------- 6 files changed, 165 insertions(+), 144 deletions(-) delete mode 100644 java/src/com/android/intentresolver/inject/FrameworkModule.kt create mode 100644 java/src/com/android/intentresolver/inject/SystemServices.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/inject/FrameworkModule.kt b/java/src/com/android/intentresolver/inject/FrameworkModule.kt deleted file mode 100644 index 2f6cc6a0..00000000 --- a/java/src/com/android/intentresolver/inject/FrameworkModule.kt +++ /dev/null @@ -1,76 +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.inject - -import android.app.ActivityManager -import android.app.admin.DevicePolicyManager -import android.content.ClipboardManager -import android.content.Context -import android.content.pm.LauncherApps -import android.content.pm.ShortcutManager -import android.os.UserManager -import android.view.WindowManager -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent - -private fun Context.requireSystemService(serviceClass: Class): T { - return checkNotNull(getSystemService(serviceClass)) -} - -@Module -@InstallIn(SingletonComponent::class) -object FrameworkModule { - - @Provides - fun contentResolver(@ApplicationContext ctx: Context) = - requireNotNull(ctx.contentResolver) { "ContentResolver is expected but missing" } - - @Provides - fun activityManager(@ApplicationContext ctx: Context) = - ctx.requireSystemService(ActivityManager::class.java) - - @Provides - fun clipboardManager(@ApplicationContext ctx: Context) = - ctx.requireSystemService(ClipboardManager::class.java) - - @Provides - fun devicePolicyManager(@ApplicationContext ctx: Context) = - ctx.requireSystemService(DevicePolicyManager::class.java) - - @Provides - fun launcherApps(@ApplicationContext ctx: Context) = - ctx.requireSystemService(LauncherApps::class.java) - - @Provides - fun packageManager(@ApplicationContext ctx: Context) = - requireNotNull(ctx.packageManager) { "PackageManager is expected but missing" } - - @Provides - fun shortcutManager(@ApplicationContext ctx: Context) = - ctx.requireSystemService(ShortcutManager::class.java) - - @Provides - fun userManager(@ApplicationContext ctx: Context) = - ctx.requireSystemService(UserManager::class.java) - - @Provides - fun windowManager(@ApplicationContext ctx: Context) = - ctx.requireSystemService(WindowManager::class.java) -} diff --git a/java/src/com/android/intentresolver/inject/SystemServices.kt b/java/src/com/android/intentresolver/inject/SystemServices.kt new file mode 100644 index 00000000..32894d43 --- /dev/null +++ b/java/src/com/android/intentresolver/inject/SystemServices.kt @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2024 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.inject + +import android.app.ActivityManager +import android.app.admin.DevicePolicyManager +import android.content.ClipboardManager +import android.content.Context +import android.content.pm.LauncherApps +import android.content.pm.ShortcutManager +import android.os.UserManager +import android.view.WindowManager +import androidx.core.content.getSystemService +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent + +inline fun Context.requireSystemService(): T { + return checkNotNull(getSystemService()) +} + +@Module +@InstallIn(SingletonComponent::class) +class ActivityManagerModule { + @Provides + fun activityManager(@ApplicationContext ctx: Context): ActivityManager = + ctx.requireSystemService() +} + +@Module +@InstallIn(SingletonComponent::class) +class ClipboardManagerModule { + @Provides + fun clipboardManager(@ApplicationContext ctx: Context): ClipboardManager = + ctx.requireSystemService() +} + +@Module +@InstallIn(SingletonComponent::class) +class ContentResolverModule { + @Provides + fun contentResolver(@ApplicationContext ctx: Context) = requireNotNull(ctx.contentResolver) +} + +@Module +@InstallIn(SingletonComponent::class) +class DevicePolicyManagerModule { + @Provides + fun devicePolicyManager(@ApplicationContext ctx: Context): DevicePolicyManager = + ctx.requireSystemService() +} + +@Module +@InstallIn(SingletonComponent::class) +class LauncherAppsModule { + @Provides + fun launcherApps(@ApplicationContext ctx: Context): LauncherApps = ctx.requireSystemService() +} + +@Module +@InstallIn(SingletonComponent::class) +class PackageManagerModule { + @Provides + fun packageManager(@ApplicationContext ctx: Context) = requireNotNull(ctx.packageManager) +} + +@Module +@InstallIn(SingletonComponent::class) +class ShortcutManagerModule { + @Provides + fun shortcutManager(@ApplicationContext ctx: Context): ShortcutManager = + ctx.requireSystemService() +} + +@Module +@InstallIn(SingletonComponent::class) +class UserManagerModule { + @Provides + fun userManager(@ApplicationContext ctx: Context): UserManager = ctx.requireSystemService() +} + +@Module +@InstallIn(SingletonComponent::class) +class WindowManagerModule { + @Provides + fun windowManager(@ApplicationContext ctx: Context): WindowManager = ctx.requireSystemService() +} diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 6be0175f..78246213 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -268,6 +268,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Inject @NearbyShare public Optional mNearbyShare; @Inject public TargetDataLoader mTargetDataLoader; @Inject public DevicePolicyResources mDevicePolicyResources; + @Inject public PackageManager mPackageManager; private ChooserRefinementManager mRefinementManager; @@ -410,7 +411,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } }); - boolean hasTouchScreen = getPackageManager() + boolean hasTouchScreen = mPackageManager .hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN); if (isVoiceInteraction() || !hasTouchScreen) { @@ -575,7 +576,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private boolean canAppInteractCrossProfiles(String packageName) { ApplicationInfo applicationInfo; try { - applicationInfo = getPackageManager().getApplicationInfo(packageName, 0); + applicationInfo = mPackageManager.getApplicationInfo(packageName, 0); } catch (PackageManager.NameNotFoundException e) { Log.e(TAG, "Package " + packageName + " does not exist on current user."); return false; @@ -933,7 +934,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private boolean supportsManagedProfiles(ResolveInfo resolveInfo) { try { - ApplicationInfo appInfo = getPackageManager().getApplicationInfo( + ApplicationInfo appInfo = mPackageManager.getApplicationInfo( resolveInfo.activityInfo.packageName, 0 /* default flags */); return appInfo.targetSdkVersion >= Build.VERSION_CODES.LOLLIPOP; } catch (PackageManager.NameNotFoundException e) { @@ -2087,7 +2088,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements targetIntent, referrerFillInIntent, this, - context.getPackageManager(), + mPackageManager, getEventLog(), maxTargetsPerRow, initialIntentsUserSpace, @@ -2144,7 +2145,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return new ChooserListController( this, - getPackageManager(), + mPackageManager, mLogic.getTargetIntent(), mLogic.getReferrerPackageName(), requireAnnotatedUserHandles().userIdOfCallingApp, diff --git a/tests/activity/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java b/tests/activity/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java index c84c25e3..df1399d8 100644 --- a/tests/activity/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java +++ b/tests/activity/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java @@ -21,7 +21,6 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import android.content.pm.PackageManager; import android.content.res.Resources; import android.database.Cursor; import android.os.UserHandle; @@ -33,11 +32,11 @@ import com.android.intentresolver.contentpreview.ImageLoader; import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; import com.android.intentresolver.shortcuts.ShortcutLoader; +import kotlin.jvm.functions.Function2; + import java.util.function.Consumer; import java.util.function.Function; -import kotlin.jvm.functions.Function2; - /** * Singleton providing overrides to be applied by any {@code IChooserWrapper} used in testing. * We cannot directly mock the activity created since instrumentation creates it, so instead we use @@ -70,7 +69,6 @@ public class ChooserActivityOverrideData { public Integer myUserId; public WorkProfileAvailabilityManager mWorkProfileAvailability; public CrossProfileIntentsChecker mCrossProfileIntentsChecker; - public PackageManager packageManager; public void reset() { onSafelyStartInternalCallback = null; @@ -90,7 +88,6 @@ public class ChooserActivityOverrideData { hasCrossProfileIntents = true; isQuietModeEnabled = false; myUserId = null; - packageManager = null; mWorkProfileAvailability = new WorkProfileAvailabilityManager(null, null, null) { @Override public boolean isQuietModeEnabled() { diff --git a/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java b/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java index cc14202f..b045c801 100644 --- a/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java @@ -23,7 +23,6 @@ import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; -import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.content.res.Resources; import android.database.Cursor; @@ -40,7 +39,6 @@ import com.android.intentresolver.TestContentPreviewViewModel; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; -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; @@ -86,9 +84,7 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW Intent referrerFillInIntent, int maxTargetsPerRow, TargetDataLoader targetDataLoader) { - PackageManager packageManager = - sOverrides.packageManager == null ? context.getPackageManager() - : sOverrides.packageManager; + return new ChooserListAdapter( context, payloadIntents, @@ -100,7 +96,7 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW targetIntent, referrerFillInIntent, this, - packageManager, + mPackageManager, getEventLog(), maxTargetsPerRow, userHandle, diff --git a/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java b/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java index 770cabbc..b8113422 100644 --- a/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java +++ b/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java @@ -128,6 +128,7 @@ import com.android.intentresolver.TestContentProvider; import com.android.intentresolver.TestPreviewImageLoader; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.contentpreview.ImageLoader; +import com.android.intentresolver.inject.PackageManagerModule; import com.android.intentresolver.logging.EventLog; import com.android.intentresolver.logging.FakeEventLog; import com.android.intentresolver.shortcuts.ShortcutLoader; @@ -138,6 +139,7 @@ import com.android.intentresolver.v2.platform.ImageEditorModule; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; +import dagger.hilt.android.qualifiers.ApplicationContext; import dagger.hilt.android.testing.BindValue; import dagger.hilt.android.testing.HiltAndroidRule; import dagger.hilt.android.testing.HiltAndroidTest; @@ -168,6 +170,8 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; +import javax.inject.Inject; + /** * Instrumentation tests for ChooserActivity. *

    @@ -176,7 +180,11 @@ import java.util.function.Consumer; @SuppressWarnings("OptionalUsedAsFieldOrParameterType") @RunWith(Parameterized.class) @HiltAndroidTest -@UninstallModules({ImageEditorModule.class, AppPredictionModule.class}) +@UninstallModules({ + AppPredictionModule.class, + ImageEditorModule.class, + PackageManagerModule.class +}) public class UnbundledChooserActivityTest { private static FakeEventLog getEventLog(ChooserWrapperActivity activity) { @@ -212,18 +220,9 @@ public class UnbundledChooserActivityTest { public ActivityTestRule mActivityRule = new ActivityTestRule<>(ChooserWrapperActivity.class, false, false); - @Before - public void setUp() { - // TODO: use the other form of `adoptShellPermissionIdentity()` where we explicitly list the - // permissions we require (which we'll read from the manifest at runtime). - InstrumentationRegistry - .getInstrumentation() - .getUiAutomation() - .adoptShellPermissionIdentity(); - - cleanOverrideData(); - mHiltAndroidRule.inject(); - } + @Inject + @ApplicationContext + Context mContext; /** An arbitrary pre-installed activity that handles this type of intent. */ @BindValue @@ -237,6 +236,29 @@ public class UnbundledChooserActivityTest { @AppPredictionAvailable final boolean mAppPredictionAvailable; + @BindValue + PackageManager mPackageManager; + + @Before + public void setUp() { + // TODO: use the other form of `adoptShellPermissionIdentity()` where we explicitly list the + // permissions we require (which we'll read from the manifest at runtime). + InstrumentationRegistry + .getInstrumentation() + .getUiAutomation() + .adoptShellPermissionIdentity(); + + cleanOverrideData(); + + // Assign @Inject fields + mHiltAndroidRule.inject(); + + // Populate @BindValue dependencies using injected values. These fields contribute + // values to the dependency graph at activity launch time. This allows replacing + // arbitrary bindings per-test case if needed. + mPackageManager = mContext.getPackageManager(); + } + public UnbundledChooserActivityTest(boolean appPredictionAvailable) { mAppPredictionAvailable = appPredictionAvailable; } @@ -267,6 +289,12 @@ public class UnbundledChooserActivityTest { Boolean.toString(true)); } + private static PackageManager createFakePackageManager(ResolveInfo resolveInfo) { + PackageManager packageManager = mock(PackageManager.class); + when(packageManager.resolveActivity(any(Intent.class), any())).thenReturn(resolveInfo); + return packageManager; + } + @Test public void customTitle() throws InterruptedException { Intent viewIntent = createViewTextIntent(); @@ -948,8 +976,10 @@ public class UnbundledChooserActivityTest { throw exception; } RecyclerView recyclerView = (RecyclerView) view; - assertThat(recyclerView.getAdapter().getItemCount(), is(1)); - assertThat(recyclerView.getChildCount(), is(1)); + assertThat("recyclerView adapter item count", + recyclerView.getAdapter().getItemCount(), is(1)); + assertThat("recyclerView child view count", + recyclerView.getChildCount(), is(1)); View imageView = recyclerView.getChildAt(0); Rect rect = new Rect(); boolean isPartiallyVisible = imageView.getGlobalVisibleRect(rect); @@ -2527,13 +2557,7 @@ public class UnbundledChooserActivityTest { chosen[0] = targetInfo.getResolveInfo(); return true; }; - ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class); - ResolveInfo ri = createFakeResolveInfo(); - when( - ChooserActivityOverrideData - .getInstance().packageManager - .resolveActivity(any(Intent.class), any())) - .thenReturn(ri); + mPackageManager = createFakePackageManager(createFakeResolveInfo()); waitForIdle(); IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(chooserIntent); @@ -2558,13 +2582,7 @@ public class UnbundledChooserActivityTest { new Intent("action.fake2") }; Intent chooserIntent = createChooserIntent(createSendTextIntent(), initialIntents); - ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class); - when( - ChooserActivityOverrideData - .getInstance() - .packageManager - .resolveActivity(any(Intent.class), any())) - .thenReturn(createFakeResolveInfo()); + mPackageManager = createFakePackageManager(createFakeResolveInfo()); waitForIdle(); IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(chooserIntent); @@ -2589,13 +2607,8 @@ public class UnbundledChooserActivityTest { new Intent("action.fake2") }; Intent chooserIntent = createChooserIntent(new Intent(), initialIntents); - ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class); - when( - ChooserActivityOverrideData - .getInstance() - .packageManager - .resolveActivity(any(Intent.class), any())) - .thenReturn(createFakeResolveInfo()); + mPackageManager = createFakePackageManager(createFakeResolveInfo()); + mActivityRule.launchActivity(chooserIntent); waitForIdle(); @@ -2621,13 +2634,8 @@ public class UnbundledChooserActivityTest { new Intent("action.fake2") }; Intent chooserIntent = createChooserIntent(new Intent(), initialIntents); - ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class); - when( - ChooserActivityOverrideData - .getInstance() - .packageManager - .resolveActivity(any(Intent.class), any())) - .thenReturn(createFakeResolveInfo()); + mPackageManager = createFakePackageManager(createFakeResolveInfo()); + mActivityRule.launchActivity(chooserIntent); waitForIdle(); @@ -2649,15 +2657,8 @@ public class UnbundledChooserActivityTest { // Create caller target which is duplicate with one of app targets Intent chooserIntent = createChooserIntent(createSendTextIntent(), new Intent[] {new Intent("action.fake")}); - ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class); - ResolveInfo ri = ResolverDataProvider.createResolveInfo(0, - UserHandle.USER_CURRENT, PERSONAL_USER_HANDLE); - when( - ChooserActivityOverrideData - .getInstance() - .packageManager - .resolveActivity(any(Intent.class), any())) - .thenReturn(ri); + mPackageManager = createFakePackageManager(ResolverDataProvider.createResolveInfo(0, + UserHandle.USER_CURRENT, PERSONAL_USER_HANDLE)); waitForIdle(); IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(chooserIntent); -- cgit v1.2.3-59-g8ed1b From 6d0e03bb3eba2bfea7ac63755f390ca88a4b7faa Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Tue, 9 Jan 2024 16:02:08 -0500 Subject: Move common intent forwarding code into shared module This change combines and deduplicates intent forwarding code from both Chooser and Resolver, as well the temporary scaffolding from ActivityLogic. Bug: 300157408 Test: atest IntentResolver-tests-activity:com.android.intentresolver.v2 Change-Id: If9dde430f24608e4a0c5bb9ec69a374386c044ed --- .../com/android/intentresolver/v2/ActivityLogic.kt | 67 +++---------- .../android/intentresolver/v2/ChooserActivity.java | 53 +--------- .../android/intentresolver/v2/IntentForwarding.kt | 111 +++++++++++++++++++++ .../intentresolver/v2/ResolverActivity.java | 58 +---------- .../v2/data/repository/DevicePolicyResources.kt | 70 +++++++++---- 5 files changed, 186 insertions(+), 173 deletions(-) create mode 100644 java/src/com/android/intentresolver/v2/IntentForwarding.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ActivityLogic.kt b/java/src/com/android/intentresolver/v2/ActivityLogic.kt index 5c7594f5..495c39e6 100644 --- a/java/src/com/android/intentresolver/v2/ActivityLogic.kt +++ b/java/src/com/android/intentresolver/v2/ActivityLogic.kt @@ -1,8 +1,5 @@ package com.android.intentresolver.v2 -import android.app.admin.DevicePolicyManager -import android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_PERSONAL -import android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_WORK import android.content.Intent import android.os.UserHandle import android.os.UserManager @@ -10,7 +7,6 @@ import android.util.Log import androidx.activity.ComponentActivity import androidx.core.content.getSystemService import com.android.intentresolver.AnnotatedUserHandles -import com.android.intentresolver.R import com.android.intentresolver.WorkProfileAvailabilityManager import com.android.intentresolver.icons.TargetDataLoader @@ -50,15 +46,10 @@ interface CommonActivityLogic { val referrerPackageName: String? /** User manager system service. */ val userManager: UserManager - /** Device policy manager system service. */ - val devicePolicyManager: DevicePolicyManager /** Current [UserHandle]s retrievable by type. */ val annotatedUserHandles: AnnotatedUserHandles? /** Monitors for changes to work profile availability. */ val workProfileAvailabilityManager: WorkProfileAvailabilityManager - - /** Returns display message indicating intent forwarding or null if not intent forwarding. */ - fun forwardMessageFor(intent: Intent): String? } /** @@ -72,58 +63,32 @@ class CommonActivityLogicImpl( onWorkProfileStatusUpdated: () -> Unit, ) : CommonActivityLogic { - override val referrerPackageName: String? = activity.referrer.let { - if (ANDROID_APP_URI_SCHEME == it?.scheme) { - it.host - } else { - null + override val referrerPackageName: String? = + activity.referrer.let { + if (ANDROID_APP_URI_SCHEME == it?.scheme) { + it.host + } else { + null + } } - } override val userManager: UserManager = activity.getSystemService()!! - override val devicePolicyManager: DevicePolicyManager = activity.getSystemService()!! - - override val annotatedUserHandles: AnnotatedUserHandles? = try { - AnnotatedUserHandles.forShareActivity(activity) - } catch (e: SecurityException) { - Log.e(tag, "Request from UID without necessary permissions", e) - null - } + override val annotatedUserHandles: AnnotatedUserHandles? = + try { + AnnotatedUserHandles.forShareActivity(activity) + } catch (e: SecurityException) { + Log.e(tag, "Request from UID without necessary permissions", e) + null + } - override val workProfileAvailabilityManager = WorkProfileAvailabilityManager( + override val workProfileAvailabilityManager = + WorkProfileAvailabilityManager( userManager, annotatedUserHandles?.workProfileUserHandle, onWorkProfileStatusUpdated, ) - private val forwardToPersonalMessage: String? = - devicePolicyManager.resources.getString(FORWARD_INTENT_TO_PERSONAL) { - activity.getString(R.string.forward_intent_to_owner) - } - - private val forwardToWorkMessage: String? = - devicePolicyManager.resources.getString(FORWARD_INTENT_TO_WORK) { - activity.getString(R.string.forward_intent_to_work) - } - - override fun forwardMessageFor(intent: Intent): String? { - val contentUserHint = intent.contentUserHint - if ( - contentUserHint != UserHandle.USER_CURRENT && contentUserHint != UserHandle.myUserId() - ) { - val originUserInfo = userManager.getUserInfo(contentUserHint) - val originIsManaged = originUserInfo?.isManagedProfile ?: false - val targetIsManaged = userManager.isManagedProfile - return when { - originIsManaged && !targetIsManaged -> forwardToPersonalMessage - !originIsManaged && targetIsManaged -> forwardToWorkMessage - else -> null - } - } - return null - } - companion object { private const val ANDROID_APP_URI_SCHEME = "android-app" } diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 78246213..fe55a936 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -16,7 +16,6 @@ package com.android.intentresolver.v2; -import static android.Manifest.permission.INTERACT_ACROSS_PROFILES; import static android.app.VoiceInteractor.PickOptionRequest.Option; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_PERSONAL; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_WORK; @@ -24,7 +23,6 @@ import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_SHARE_WITH_WORK; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE; import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; -import static android.content.PermissionChecker.PID_UNKNOWN; import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL; 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; @@ -54,7 +52,6 @@ import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.IntentSender; -import android.content.PermissionChecker; import android.content.SharedPreferences; import android.content.pm.ActivityInfo; import android.content.pm.ApplicationInfo; @@ -269,6 +266,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Inject public TargetDataLoader mTargetDataLoader; @Inject public DevicePolicyResources mDevicePolicyResources; @Inject public PackageManager mPackageManager; + @Inject public IntentForwarding mIntentForwarding; private ChooserRefinementManager mRefinementManager; @@ -553,51 +551,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return false; } - private int isPermissionGranted(String permission, int uid) { - return ActivityManager.checkComponentPermission(permission, uid, - /* owningUid= */-1, /* exported= */ true); - } - - /** - * Returns whether the package has the necessary permissions to interact across profiles on - * behalf of a given user. - * - *

    This means meeting the following condition: - *

      - *
    • The app's {@link ApplicationInfo#crossProfile} flag must be true, and at least - * one of the following conditions must be fulfilled
    • - *
    • {@code Manifest.permission.INTERACT_ACROSS_USERS_FULL} granted.
    • - *
    • {@code Manifest.permission.INTERACT_ACROSS_USERS} granted.
    • - *
    • {@code Manifest.permission.INTERACT_ACROSS_PROFILES} granted, or the corresponding - * AppOps {@code android:interact_across_profiles} is set to "allow".
    • - *
    - * - */ - private boolean canAppInteractCrossProfiles(String packageName) { - ApplicationInfo applicationInfo; - try { - applicationInfo = mPackageManager.getApplicationInfo(packageName, 0); - } catch (PackageManager.NameNotFoundException e) { - Log.e(TAG, "Package " + packageName + " does not exist on current user."); - return false; - } - if (!applicationInfo.crossProfile) { - return false; - } - - int packageUid = applicationInfo.uid; - - if (isPermissionGranted(android.Manifest.permission.INTERACT_ACROSS_USERS_FULL, - packageUid) == PackageManager.PERMISSION_GRANTED) { - return true; - } - if (isPermissionGranted(android.Manifest.permission.INTERACT_ACROSS_USERS, packageUid) - == PackageManager.PERMISSION_GRANTED) { - return true; - } - return PermissionChecker.checkPermissionForPreflight(this, INTERACT_ACROSS_PROFILES, - PID_UNKNOWN, packageUid, packageName) == PackageManager.PERMISSION_GRANTED; - } private boolean isTwoPagePersonalAndWorkConfiguration() { return (mChooserMultiProfilePagerAdapter.getCount() == 2) @@ -649,7 +602,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } String packageName = activeProfileTarget.getResolvedComponentName().getPackageName(); - if (!canAppInteractCrossProfiles(packageName)) { + if (!mIntentForwarding.canAppInteractAcrossProfiles(this, packageName)) { return false; } @@ -849,7 +802,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } // If needed, show that intent is forwarded // from managed profile to owner or other way around. - String profileSwitchMessage = mLogic.forwardMessageFor(mLogic.getTargetIntent()); + String profileSwitchMessage = mIntentForwarding.forwardMessageFor(mLogic.getTargetIntent()); if (profileSwitchMessage != null) { Toast.makeText(this, profileSwitchMessage, Toast.LENGTH_LONG).show(); } diff --git a/java/src/com/android/intentresolver/v2/IntentForwarding.kt b/java/src/com/android/intentresolver/v2/IntentForwarding.kt new file mode 100644 index 00000000..3d366d10 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/IntentForwarding.kt @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2024 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.v2 + +import android.Manifest +import android.Manifest.permission.INTERACT_ACROSS_USERS +import android.Manifest.permission.INTERACT_ACROSS_USERS_FULL +import android.app.ActivityManager +import android.content.Context +import android.content.Intent +import android.content.PermissionChecker +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.content.pm.PackageManager.PERMISSION_GRANTED +import android.os.UserHandle +import android.os.UserManager +import android.util.Log +import com.android.intentresolver.v2.data.repository.DevicePolicyResources +import javax.inject.Inject +import javax.inject.Singleton + +private const val TAG: String = "IntentForwarding" + +@Singleton +class IntentForwarding +@Inject +constructor( + private val resources: DevicePolicyResources, + private val userManager: UserManager, + private val packageManager: PackageManager +) { + + fun forwardMessageFor(intent: Intent): String? { + val contentUserHint = intent.contentUserHint + if ( + contentUserHint != UserHandle.USER_CURRENT && contentUserHint != UserHandle.myUserId() + ) { + val originUserInfo = userManager.getUserInfo(contentUserHint) + val originIsManaged = originUserInfo?.isManagedProfile ?: false + val targetIsManaged = userManager.isManagedProfile + return when { + originIsManaged && !targetIsManaged -> resources.forwardToPersonalMessage + !originIsManaged && targetIsManaged -> resources.forwardToWorkMessage + else -> null + } + } + return null + } + + private fun isPermissionGranted(permission: String, uid: Int) = + ActivityManager.checkComponentPermission( + /* permission = */ permission, + /* uid = */ uid, + /* owningUid= */ -1, + /* exported= */ true + ) + + /** + * Returns whether the package has the necessary permissions to interact across profiles on + * behalf of a given user. + * + * This means meeting the following condition: + * * The app's [ApplicationInfo.crossProfile] flag must be true, and at least one of the + * following conditions must be fulfilled + * * `Manifest.permission.INTERACT_ACROSS_USERS_FULL` granted. + * * `Manifest.permission.INTERACT_ACROSS_USERS` granted. + * * `Manifest.permission.INTERACT_ACROSS_PROFILES` granted, or the corresponding AppOps + * `android:interact_across_profiles` is set to "allow". + */ + fun canAppInteractAcrossProfiles(context: Context, packageName: String): Boolean { + val applicationInfo: ApplicationInfo + try { + applicationInfo = packageManager.getApplicationInfo(packageName, 0) + } catch (e: PackageManager.NameNotFoundException) { + Log.e(TAG, "Package $packageName does not exist on current user.") + return false + } + if (!applicationInfo.crossProfile) { + return false + } + + val packageUid = applicationInfo.uid + + if (isPermissionGranted(INTERACT_ACROSS_USERS_FULL, packageUid) == PERMISSION_GRANTED) { + return true + } + if (isPermissionGranted(INTERACT_ACROSS_USERS, packageUid) == PERMISSION_GRANTED) { + return true + } + return PermissionChecker.checkPermissionForPreflight( + context, + Manifest.permission.INTERACT_ACROSS_PROFILES, + PermissionChecker.PID_UNKNOWN, + packageUid, + packageName + ) == PERMISSION_GRANTED + } +} diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index 44224c10..c3654f3f 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -16,12 +16,10 @@ package com.android.intentresolver.v2; -import static android.Manifest.permission.INTERACT_ACROSS_PROFILES; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_PERSONAL; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_WORK; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE; import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; -import static android.content.PermissionChecker.PID_UNKNOWN; import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL; 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; @@ -32,7 +30,6 @@ import static java.util.Collections.emptyList; import static java.util.Objects.requireNonNull; import static java.util.Objects.requireNonNullElse; -import android.app.ActivityManager; import android.app.ActivityThread; import android.app.VoiceInteractor.PickOptionRequest; import android.app.VoiceInteractor.PickOptionRequest.Option; @@ -42,7 +39,6 @@ import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; -import android.content.PermissionChecker; import android.content.pm.ActivityInfo; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; @@ -138,7 +134,9 @@ import javax.inject.Inject; @AndroidEntryPoint(FragmentActivity.class) public class ResolverActivity extends Hilt_ResolverActivity implements ResolverListAdapter.ResolverListCommunicator { + @Inject public DevicePolicyResources mDevicePolicyResources; + @Inject public IntentForwarding mIntentForwarding; protected ActivityLogic mLogic; @@ -1466,7 +1464,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements } // If needed, show that intent is forwarded // from managed profile to owner or other way around. - String profileSwitchMessage = mLogic.forwardMessageFor(mLogic.getTargetIntent()); + String profileSwitchMessage = mIntentForwarding.forwardMessageFor(mLogic.getTargetIntent()); if (profileSwitchMessage != null) { Toast.makeText(this, profileSwitchMessage, Toast.LENGTH_LONG).show(); } @@ -1505,11 +1503,6 @@ public class ResolverActivity extends Hilt_ResolverActivity implements return false; } - private int isPermissionGranted(String permission, int uid) { - return ActivityManager.checkComponentPermission(permission, uid, - /* owningUid= */-1, /* exported= */ true); - } - /** * Mini resolver should be used when all of the following are true: * 1. This is the intent picker (ResolverActivity). @@ -1619,7 +1612,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements } String packageName = activeProfileTarget.getResolvedComponentName().getPackageName(); - if (!canAppInteractCrossProfiles(packageName)) { + if (!mIntentForwarding.canAppInteractAcrossProfiles(this, packageName)) { return false; } @@ -1634,47 +1627,6 @@ public class ResolverActivity extends Hilt_ResolverActivity implements return true; } - /** - * Returns whether the package has the necessary permissions to interact across profiles on - * behalf of a given user. - * - *

    This means meeting the following condition: - *

      - *
    • The app's {@link ApplicationInfo#crossProfile} flag must be true, and at least - * one of the following conditions must be fulfilled
    • - *
    • {@code Manifest.permission.INTERACT_ACROSS_USERS_FULL} granted.
    • - *
    • {@code Manifest.permission.INTERACT_ACROSS_USERS} granted.
    • - *
    • {@code Manifest.permission.INTERACT_ACROSS_PROFILES} granted, or the corresponding - * AppOps {@code android:interact_across_profiles} is set to "allow".
    • - *
    - * - */ - private boolean canAppInteractCrossProfiles(String packageName) { - ApplicationInfo applicationInfo; - try { - applicationInfo = getPackageManager().getApplicationInfo(packageName, 0); - } catch (NameNotFoundException e) { - Log.e(TAG, "Package " + packageName + " does not exist on current user."); - return false; - } - if (!applicationInfo.crossProfile) { - return false; - } - - int packageUid = applicationInfo.uid; - - if (isPermissionGranted(android.Manifest.permission.INTERACT_ACROSS_USERS_FULL, - packageUid) == PackageManager.PERMISSION_GRANTED) { - return true; - } - if (isPermissionGranted(android.Manifest.permission.INTERACT_ACROSS_USERS, packageUid) - == PackageManager.PERMISSION_GRANTED) { - return true; - } - return PermissionChecker.checkPermissionForPreflight(this, INTERACT_ACROSS_PROFILES, - PID_UNKNOWN, packageUid, packageName) == PackageManager.PERMISSION_GRANTED; - } - private boolean isAutolaunching() { return !mRegistered && isFinishing(); } @@ -1719,7 +1671,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements } String packageName = activeProfileTarget.getResolvedComponentName().getPackageName(); - if (!canAppInteractCrossProfiles(packageName)) { + if (!mIntentForwarding.canAppInteractAcrossProfiles(this, packageName)) { return false; } diff --git a/java/src/com/android/intentresolver/v2/data/repository/DevicePolicyResources.kt b/java/src/com/android/intentresolver/v2/data/repository/DevicePolicyResources.kt index 7debdf07..5719ff08 100644 --- a/java/src/com/android/intentresolver/v2/data/repository/DevicePolicyResources.kt +++ b/java/src/com/android/intentresolver/v2/data/repository/DevicePolicyResources.kt @@ -16,6 +16,8 @@ package com.android.intentresolver.v2.data.repository import android.app.admin.DevicePolicyManager +import android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_PERSONAL +import android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_WORK import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERSONAL_TAB import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERSONAL_TAB_ACCESSIBILITY import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PROFILE_NOT_SUPPORTED @@ -28,41 +30,71 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class DevicePolicyResources @Inject constructor( +class DevicePolicyResources +@Inject +constructor( @ApplicationOwned private val resources: Resources, devicePolicyManager: DevicePolicyManager ) { private val policyResources = devicePolicyManager.resources val personalTabLabel by lazy { - requireNotNull(policyResources.getString(RESOLVER_PERSONAL_TAB) { - resources.getString(R.string.resolver_personal_tab) - }) + requireNotNull( + policyResources.getString(RESOLVER_PERSONAL_TAB) { + resources.getString(R.string.resolver_personal_tab) + } + ) } val workTabLabel by lazy { - requireNotNull(policyResources.getString(RESOLVER_WORK_TAB) { - resources.getString(R.string.resolver_work_tab) - }) + requireNotNull( + policyResources.getString(RESOLVER_WORK_TAB) { + resources.getString(R.string.resolver_work_tab) + } + ) } val personalTabAccessibilityLabel by lazy { - requireNotNull(policyResources.getString(RESOLVER_PERSONAL_TAB_ACCESSIBILITY) { - resources.getString(R.string.resolver_personal_tab_accessibility) - }) + requireNotNull( + policyResources.getString(RESOLVER_PERSONAL_TAB_ACCESSIBILITY) { + resources.getString(R.string.resolver_personal_tab_accessibility) + } + ) } val workTabAccessibilityLabel by lazy { - requireNotNull(policyResources.getString(RESOLVER_WORK_TAB_ACCESSIBILITY) { - resources.getString(R.string.resolver_work_tab_accessibility) - }) + requireNotNull( + policyResources.getString(RESOLVER_WORK_TAB_ACCESSIBILITY) { + resources.getString(R.string.resolver_work_tab_accessibility) + } + ) + } + + val forwardToPersonalMessage: String? = + devicePolicyManager.resources.getString(FORWARD_INTENT_TO_PERSONAL) { + resources.getString(R.string.forward_intent_to_owner) + } + + val forwardToWorkMessage by lazy { + requireNotNull( + policyResources.getString(FORWARD_INTENT_TO_WORK) { + resources.getString(R.string.forward_intent_to_work) + } + ) } fun getWorkProfileNotSupportedMessage(launcherName: String): String { - return requireNotNull(policyResources.getString(RESOLVER_WORK_PROFILE_NOT_SUPPORTED, { - resources.getString( - R.string.activity_resolver_work_profiles_support, - launcherName) - }, launcherName)) + return requireNotNull( + policyResources.getString( + RESOLVER_WORK_PROFILE_NOT_SUPPORTED, + { + resources.getString( + R.string.activity_resolver_work_profiles_support, + launcherName + ) + }, + launcherName + ) + ) } -} \ No newline at end of file +} -- cgit v1.2.3-59-g8ed1b From 99c9828d732ff25c87e8b41e386131dae70b4652 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Wed, 10 Jan 2024 08:47:19 -0500 Subject: Removes TargetDataLoader from adapter factory method chain TargetDataLoader is removed from ActivityLogic since it is now only used via direct constructor call by ResolverActivity, and injected directly to a field of ChooserActivity. Since it is only needed to pass to the ChooserListAdapter or ResolverListAdapter constructor, this removes the parameter from being forwarded through a sequence of functions and instead references the injected parameter directly when calling the contructor. This allows smoother transition to an assisted-inject factory. Bug: 300157408 Test: atest IntentResolver-tests-activity:com.android.intentresolver.v2 Change-Id: I18b230eaf97bf8e26e23e2e1cd1372e7d078520f --- .../com/android/intentresolver/v2/ActivityLogic.kt | 3 -- .../android/intentresolver/v2/ChooserActivity.java | 48 +++++++++------------- .../intentresolver/v2/ChooserActivityLogic.kt | 5 +-- .../intentresolver/v2/ResolverActivity.java | 47 +++++++++++---------- .../intentresolver/v2/ResolverActivityLogic.kt | 14 +------ .../intentresolver/v2/ChooserWrapperActivity.java | 7 +--- .../intentresolver/v2/ResolverWrapperActivity.java | 5 +-- .../intentresolver/v2/TestChooserActivityLogic.kt | 3 -- 8 files changed, 51 insertions(+), 81 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ActivityLogic.kt b/java/src/com/android/intentresolver/v2/ActivityLogic.kt index 495c39e6..7062da33 100644 --- a/java/src/com/android/intentresolver/v2/ActivityLogic.kt +++ b/java/src/com/android/intentresolver/v2/ActivityLogic.kt @@ -8,7 +8,6 @@ import androidx.activity.ComponentActivity import androidx.core.content.getSystemService import com.android.intentresolver.AnnotatedUserHandles import com.android.intentresolver.WorkProfileAvailabilityManager -import com.android.intentresolver.icons.TargetDataLoader /** * Logic for IntentResolver Activities. Anything that is not the same across activities (including @@ -27,8 +26,6 @@ interface ActivityLogic : CommonActivityLogic { val defaultTitleResId: Int /** Intents received to be processed. */ val initialIntents: List? - /** Fetches display info for processed candidates. */ - val targetDataLoader: TargetDataLoader /** The intents for potential actual targets. [targetIntent] must be first. */ val payloadIntents: List } diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index fe55a936..e093058a 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -317,8 +317,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return new ChooserActivityLogic( TAG, /* activity = */ this, - this::onWorkProfileStatusUpdated, - mTargetDataLoader); + this::onWorkProfileStatusUpdated); } @Override @@ -365,7 +364,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements Intent intent = mLogic.getTargetIntent(); List initialIntents = mLogic.getInitialIntents(); - TargetDataLoader targetDataLoader = mLogic.getTargetDataLoader(); // Calling UID did not have valid permissions if (mLogic.getAnnotatedUserHandles() == null) { @@ -376,10 +374,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mChooserMultiProfilePagerAdapter = createMultiProfilePagerAdapter( requireNonNullElse(initialIntents, emptyList()).toArray(new Intent[0]), /* resolutionList = */ null, - false, - targetDataLoader + false ); - if (!configureContentView(targetDataLoader)) { + if (!configureContentView(mTargetDataLoader)) { mPersonalPackageMonitor = createPackageMonitor( mChooserMultiProfilePagerAdapter.getPersonalListAdapter()); mPersonalPackageMonitor.register( @@ -659,7 +656,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private CharSequence getOrLoadDisplayLabel(TargetInfo info) { if (info.isDisplayResolveInfo()) { - mLogic.getTargetDataLoader().getOrLoadLabel((DisplayResolveInfo) info); + mTargetDataLoader.getOrLoadLabel((DisplayResolveInfo) info); } CharSequence displayLabel = info.getDisplayLabel(); return displayLabel == null ? "" : displayLabel; @@ -1225,14 +1222,13 @@ public class ChooserActivity extends Hilt_ChooserActivity implements protected ChooserMultiProfilePagerAdapter createMultiProfilePagerAdapter( Intent[] initialIntents, List rList, - boolean filterLastUsed, - TargetDataLoader targetDataLoader) { + boolean filterLastUsed) { if (hasWorkProfile()) { mChooserMultiProfilePagerAdapter = createChooserMultiProfilePagerAdapterForTwoProfiles( - initialIntents, rList, filterLastUsed, targetDataLoader); + initialIntents, rList, filterLastUsed); } else { mChooserMultiProfilePagerAdapter = createChooserMultiProfilePagerAdapterForOneProfile( - initialIntents, rList, filterLastUsed, targetDataLoader); + initialIntents, rList, filterLastUsed); } return mChooserMultiProfilePagerAdapter; } @@ -1277,16 +1273,15 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForOneProfile( Intent[] initialIntents, List rList, - boolean filterLastUsed, - TargetDataLoader targetDataLoader) { + boolean filterLastUsed) { ChooserGridAdapter adapter = createChooserGridAdapter( /* context */ this, mLogic.getPayloadIntents(), initialIntents, rList, filterLastUsed, - /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle, - targetDataLoader); + /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle + ); return new ChooserMultiProfilePagerAdapter( /* context */ this, mDevicePolicyResources.getPersonalTabLabel(), @@ -1304,8 +1299,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForTwoProfiles( Intent[] initialIntents, List rList, - boolean filterLastUsed, - TargetDataLoader targetDataLoader) { + boolean filterLastUsed) { int selectedProfile = findSelectedProfile(); ChooserGridAdapter personalAdapter = createChooserGridAdapter( /* context */ this, @@ -1313,16 +1307,16 @@ public class ChooserActivity extends Hilt_ChooserActivity implements selectedProfile == PROFILE_PERSONAL ? initialIntents : null, rList, filterLastUsed, - /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle, - targetDataLoader); + /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle + ); ChooserGridAdapter workAdapter = createChooserGridAdapter( /* context */ this, mLogic.getPayloadIntents(), selectedProfile == PROFILE_WORK ? initialIntents : null, rList, filterLastUsed, - /* userHandle */ requireAnnotatedUserHandles().workProfileUserHandle, - targetDataLoader); + /* userHandle */ requireAnnotatedUserHandles().workProfileUserHandle + ); return new ChooserMultiProfilePagerAdapter( /* context */ this, mDevicePolicyResources.getPersonalTabLabel(), @@ -1960,8 +1954,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements Intent[] initialIntents, List rList, boolean filterLastUsed, - UserHandle userHandle, - TargetDataLoader targetDataLoader) { + UserHandle userHandle) { ChooserRequestParameters parameters = requireChooserRequest(); ChooserListAdapter chooserListAdapter = createChooserListAdapter( context, @@ -1973,8 +1966,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements userHandle, mLogic.getTargetIntent(), parameters.getReferrerFillInIntent(), - mMaxTargetsPerRow, - targetDataLoader); + mMaxTargetsPerRow + ); return new ChooserGridAdapter( context, @@ -2025,8 +2018,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements UserHandle userHandle, Intent targetIntent, Intent referrerFillInIntent, - int maxTargetsPerRow, - TargetDataLoader targetDataLoader) { + int maxTargetsPerRow) { UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile() && userHandle.equals(requireAnnotatedUserHandles().personalProfileUserHandle) ? requireAnnotatedUserHandles().cloneProfileUserHandle : userHandle; @@ -2045,7 +2037,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements getEventLog(), maxTargetsPerRow, initialIntentsUserSpace, - targetDataLoader, + mTargetDataLoader, () -> { ProfileRecord record = getProfileRecord(userHandle); if (record != null && record.shortcutLoader != null) { diff --git a/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt b/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt index 2cc75fab..a8150f52 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt +++ b/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt @@ -6,8 +6,6 @@ import android.util.Log import androidx.activity.ComponentActivity import androidx.annotation.OpenForTesting import com.android.intentresolver.ChooserRequestParameters -import com.android.intentresolver.icons.TargetDataLoader -import com.android.intentresolver.v2.util.mutableLazy private const val TAG = "ChooserActivityLogic" @@ -22,8 +20,7 @@ private const val TAG = "ChooserActivityLogic" open class ChooserActivityLogic( tag: String, activity: ComponentActivity, - onWorkProfileStatusUpdated: () -> Unit, - override val targetDataLoader: TargetDataLoader, + onWorkProfileStatusUpdated: () -> Unit ) : ActivityLogic, CommonActivityLogic by CommonActivityLogicImpl( diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index c3654f3f..9672e9d6 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -96,6 +96,7 @@ import com.android.intentresolver.emptystate.CompositeEmptyStateProvider; import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; import com.android.intentresolver.emptystate.EmptyState; import com.android.intentresolver.emptystate.EmptyStateProvider; +import com.android.intentresolver.icons.DefaultTargetDataLoader; import com.android.intentresolver.icons.TargetDataLoader; import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; import com.android.intentresolver.v2.MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; @@ -139,6 +140,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements @Inject public IntentForwarding mIntentForwarding; protected ActivityLogic mLogic; + protected TargetDataLoader mTargetDataLoader; private Button mAlwaysButton; private Button mOnceButton; @@ -233,6 +235,13 @@ public class ResolverActivity extends Hilt_ResolverActivity implements super.onCreate(savedInstanceState); setTheme(R.style.Theme_DeviceDefault_Resolver); mLogic = createActivityLogic(); + mTargetDataLoader = new DefaultTargetDataLoader( + this, + getLifecycle(), + getIntent().getBooleanExtra( + ResolverActivity.EXTRA_IS_AUDIO_CAPTURE_DEVICE, + /* defaultValue = */ false) + ); } @Override @@ -245,7 +254,6 @@ public class ResolverActivity extends Hilt_ResolverActivity implements private void init() { Intent intent = mLogic.getTargetIntent(); List initialIntents = mLogic.getInitialIntents(); - TargetDataLoader targetDataLoader = mLogic.getTargetDataLoader(); // Calling UID did not have valid permissions if (mLogic.getAnnotatedUserHandles() == null) { @@ -267,10 +275,9 @@ public class ResolverActivity extends Hilt_ResolverActivity implements mMultiProfilePagerAdapter = createMultiProfilePagerAdapter( requireNonNullElse(initialIntents, emptyList()).toArray(new Intent[0]), /* resolutionList = */ null, - filterLastUsed, - targetDataLoader + filterLastUsed ); - if (configureContentView(targetDataLoader)) { + if (configureContentView(mTargetDataLoader)) { return; } @@ -342,16 +349,15 @@ public class ResolverActivity extends Hilt_ResolverActivity implements protected ResolverMultiProfilePagerAdapter createMultiProfilePagerAdapter( Intent[] initialIntents, List resolutionList, - boolean filterLastUsed, - TargetDataLoader targetDataLoader) { + boolean filterLastUsed) { ResolverMultiProfilePagerAdapter resolverMultiProfilePagerAdapter = null; if (hasWorkProfile()) { resolverMultiProfilePagerAdapter = createResolverMultiProfilePagerAdapterForTwoProfiles( - initialIntents, resolutionList, filterLastUsed, targetDataLoader); + initialIntents, resolutionList, filterLastUsed); } else { resolverMultiProfilePagerAdapter = createResolverMultiProfilePagerAdapterForOneProfile( - initialIntents, resolutionList, filterLastUsed, targetDataLoader); + initialIntents, resolutionList, filterLastUsed); } return resolverMultiProfilePagerAdapter; } @@ -886,8 +892,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements Intent[] initialIntents, List resolutionList, boolean filterLastUsed, - UserHandle userHandle, - TargetDataLoader targetDataLoader) { + UserHandle userHandle) { UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile() && userHandle.equals(requireAnnotatedUserHandles().personalProfileUserHandle) ? requireAnnotatedUserHandles().cloneProfileUserHandle : userHandle; @@ -902,7 +907,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements mLogic.getTargetIntent(), this, initialIntentsUserSpace, - targetDataLoader); + mTargetDataLoader); } protected final EmptyStateProvider createEmptyStateProvider( @@ -940,16 +945,15 @@ public class ResolverActivity extends Hilt_ResolverActivity implements createResolverMultiProfilePagerAdapterForOneProfile( Intent[] initialIntents, List resolutionList, - boolean filterLastUsed, - TargetDataLoader targetDataLoader) { + boolean filterLastUsed) { ResolverListAdapter personalAdapter = createResolverListAdapter( /* context */ this, mLogic.getPayloadIntents(), initialIntents, resolutionList, filterLastUsed, - /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle, - targetDataLoader); + /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle + ); return new ResolverMultiProfilePagerAdapter( /* context */ this, mDevicePolicyResources.getPersonalTabLabel(), @@ -971,8 +975,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForTwoProfiles( Intent[] initialIntents, List resolutionList, - boolean filterLastUsed, - TargetDataLoader targetDataLoader) { + boolean filterLastUsed) { // 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. @@ -1000,8 +1003,8 @@ public class ResolverActivity extends Hilt_ResolverActivity implements resolutionList, (filterLastUsed && UserHandle.myUserId() == requireAnnotatedUserHandles().personalProfileUserHandle.getIdentifier()), - /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle, - targetDataLoader); + /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle + ); UserHandle workProfileUserHandle = requireAnnotatedUserHandles().workProfileUserHandle; ResolverListAdapter workAdapter = createResolverListAdapter( /* context */ this, @@ -1010,8 +1013,8 @@ public class ResolverActivity extends Hilt_ResolverActivity implements resolutionList, (filterLastUsed && UserHandle.myUserId() == workProfileUserHandle.getIdentifier()), - /* userHandle */ workProfileUserHandle, - targetDataLoader); + /* userHandle */ workProfileUserHandle + ); return new ResolverMultiProfilePagerAdapter( /* context */ this, mDevicePolicyResources.getPersonalTabLabel(), @@ -1954,7 +1957,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements private CharSequence getOrLoadDisplayLabel(TargetInfo info) { if (info.isDisplayResolveInfo()) { - mLogic.getTargetDataLoader().getOrLoadLabel((DisplayResolveInfo) info); + mTargetDataLoader.getOrLoadLabel((DisplayResolveInfo) info); } CharSequence displayLabel = info.getDisplayLabel(); return displayLabel == null ? "" : displayLabel; diff --git a/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt b/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt index 51288e51..cf843043 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt +++ b/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt @@ -3,9 +3,6 @@ package com.android.intentresolver.v2 import android.content.Intent import androidx.activity.ComponentActivity import androidx.annotation.OpenForTesting -import com.android.intentresolver.icons.DefaultTargetDataLoader -import com.android.intentresolver.icons.TargetDataLoader -import com.android.intentresolver.v2.util.mutableLazy /** Activity logic for [ResolverActivity]. */ @OpenForTesting @@ -41,7 +38,7 @@ open class ResolverActivityLogic( override val resolvingHome: Boolean = targetIntent.action == Intent.ACTION_MAIN && - targetIntent.categories.singleOrNull() == Intent.CATEGORY_HOME + targetIntent.categories.singleOrNull() == Intent.CATEGORY_HOME override val title: CharSequence? = null @@ -49,14 +46,5 @@ open class ResolverActivityLogic( override val initialIntents: List? = null - override val targetDataLoader: TargetDataLoader = DefaultTargetDataLoader( - activity, - activity.lifecycle, - activity.intent.getBooleanExtra( - ResolverActivity.EXTRA_IS_AUDIO_CAPTURE_DEVICE, - /* defaultValue = */ false, - ), - ) - override val payloadIntents: List = listOf(targetIntent) } diff --git a/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java b/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java index b045c801..0b268905 100644 --- a/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java @@ -39,7 +39,6 @@ import com.android.intentresolver.TestContentPreviewViewModel; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; -import com.android.intentresolver.icons.TargetDataLoader; import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; @@ -60,7 +59,6 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW "ChooserWrapper", /* activity = */ this, this::onWorkProfileStatusUpdated, - mTargetDataLoader, sOverrides); } @@ -82,8 +80,7 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW UserHandle userHandle, Intent targetIntent, Intent referrerFillInIntent, - int maxTargetsPerRow, - TargetDataLoader targetDataLoader) { + int maxTargetsPerRow) { return new ChooserListAdapter( context, @@ -100,7 +97,7 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW getEventLog(), maxTargetsPerRow, userHandle, - targetDataLoader, + mTargetDataLoader, null, mFeatureFlags); } diff --git a/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java b/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java index fcd6205c..d06b7929 100644 --- a/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java @@ -84,8 +84,7 @@ public class ResolverWrapperActivity extends ResolverActivity { Intent[] initialIntents, List rList, boolean filterLastUsed, - UserHandle userHandle, - TargetDataLoader targetDataLoader) { + UserHandle userHandle) { return new ResolverListAdapter( context, payloadIntents, @@ -97,7 +96,7 @@ public class ResolverWrapperActivity extends ResolverActivity { payloadIntents.get(0), // TODO: extract upstream this, userHandle, - new TargetDataLoaderWrapper(targetDataLoader, mLabelIdlingResource)); + new TargetDataLoaderWrapper(mTargetDataLoader, mLabelIdlingResource)); } @Override diff --git a/tests/activity/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt b/tests/activity/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt index b6354c7a..0849e511 100644 --- a/tests/activity/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt +++ b/tests/activity/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt @@ -3,21 +3,18 @@ package com.android.intentresolver.v2 import androidx.activity.ComponentActivity import com.android.intentresolver.AnnotatedUserHandles import com.android.intentresolver.WorkProfileAvailabilityManager -import com.android.intentresolver.icons.TargetDataLoader /** Activity logic for use when testing [ChooserActivity]. */ class TestChooserActivityLogic( tag: String, activity: ComponentActivity, onWorkProfileStatusUpdated: () -> Unit, - targetDataLoader: TargetDataLoader, private val overrideData: ChooserActivityOverrideData, ) : ChooserActivityLogic( tag, activity, onWorkProfileStatusUpdated, - targetDataLoader, ) { override val annotatedUserHandles: AnnotatedUserHandles? by lazy { -- cgit v1.2.3-59-g8ed1b From 5e5dd511a3031df38dfe35ca741e31ca9f0eec65 Mon Sep 17 00:00:00 2001 From: mrenouf Date: Fri, 1 Dec 2023 13:48:32 -0500 Subject: Refactor ChooserRequestParameters usage Creates ChooserRequest data class Uses validation lib to implement parsing of source data Introduces ChooserViewModel as a new target to begin migration of control flow, data and dependencies out of ChooserActivity and into smaller testable units. Test: atest IntentResolver-tests-activity:com.android.intentresolver.v2 Bug: 309960444 Change-Id: I39b3517ec9e17525441d349b3da139ad5956c600 --- .../contentpreview/BasePreviewViewModel.kt | 6 +- .../contentpreview/PreviewViewModel.kt | 5 +- .../com/android/intentresolver/v2/ActivityLogic.kt | 17 +- .../android/intentresolver/v2/ChooserActivity.java | 106 ++++++------ .../intentresolver/v2/ChooserActivityLogic.kt | 34 ++-- .../intentresolver/v2/ResolverActivity.java | 17 +- .../intentresolver/v2/ResolverActivityLogic.kt | 4 - .../com/android/intentresolver/v2/ext/IntentExt.kt | 39 +++++ .../intentresolver/v2/ui/model/CallerInfo.kt | 59 +++++++ .../intentresolver/v2/ui/model/ChooserRequest.kt | 180 +++++++++++++++++++++ .../v2/ui/viewmodel/ChooserRequestReader.kt | 157 ++++++++++++++++++ .../v2/ui/viewmodel/ChooserViewModel.kt | 55 +++++++ .../v2/validation/ValidationResult.kt | 2 +- .../intentresolver/v2/ChooserWrapperActivity.java | 13 +- .../intentresolver/v2/ResolverWrapperActivity.java | 2 +- .../intentresolver/v2/TestChooserActivityLogic.kt | 17 +- .../android/intentresolver/v2/ext/IntentExtTest.kt | 70 ++++++++ .../v2/ui/viewmodel/ChooserRequestTest.kt | 63 ++++++++ 18 files changed, 728 insertions(+), 118 deletions(-) create mode 100644 java/src/com/android/intentresolver/v2/ext/IntentExt.kt create mode 100644 java/src/com/android/intentresolver/v2/ui/model/CallerInfo.kt create mode 100644 java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt create mode 100644 java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt create mode 100644 java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt create mode 100644 tests/unit/src/com/android/intentresolver/v2/ext/IntentExtTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt index 10ee5af1..4c781a46 100644 --- a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt @@ -19,14 +19,10 @@ package com.android.intentresolver.contentpreview import android.content.Intent 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( - targetIntent: Intent - ): PreviewDataProvider + @MainThread abstract fun createOrReuseProvider(targetIntent: Intent): PreviewDataProvider @MainThread abstract fun createOrReuseImageLoader(): ImageLoader } diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt index 6350756e..9acc4689 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt @@ -24,7 +24,6 @@ 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 com.android.intentresolver.inject.Background import dagger.hilt.android.lifecycle.HiltViewModel @@ -45,9 +44,7 @@ constructor( private var imageLoader: ImagePreviewImageLoader? = null @MainThread - override fun createOrReuseProvider( - targetIntent: Intent - ): PreviewDataProvider = + override fun createOrReuseProvider(targetIntent: Intent): PreviewDataProvider = previewDataProvider ?: PreviewDataProvider( viewModelScope + dispatcher, diff --git a/java/src/com/android/intentresolver/v2/ActivityLogic.kt b/java/src/com/android/intentresolver/v2/ActivityLogic.kt index 7062da33..b9686418 100644 --- a/java/src/com/android/intentresolver/v2/ActivityLogic.kt +++ b/java/src/com/android/intentresolver/v2/ActivityLogic.kt @@ -1,3 +1,18 @@ +/* + * Copyright (C) 2024 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.v2 import android.content.Intent @@ -18,8 +33,6 @@ import com.android.intentresolver.WorkProfileAvailabilityManager interface ActivityLogic : CommonActivityLogic { /** The intent for the target. This will always come before additional targets, if any. */ val targetIntent: Intent - /** Whether the intent is for home. */ - val resolvingHome: Boolean /** Custom title to display. */ val title: CharSequence? /** Resource ID for the title to display when there is no custom title. */ diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index e093058a..a71de19d 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2008 The Android Open Source Project + * Copyright (C) 2024 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. @@ -95,7 +95,9 @@ import androidx.annotation.MainThread; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.SavedStateHandleSupport; import androidx.lifecycle.ViewModelProvider; +import androidx.lifecycle.viewmodel.CreationExtras; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.viewpager.widget.ViewPager; @@ -104,7 +106,6 @@ import com.android.intentresolver.AnnotatedUserHandles; import com.android.intentresolver.ChooserGridLayoutManager; import com.android.intentresolver.ChooserListAdapter; import com.android.intentresolver.ChooserRefinementManager; -import com.android.intentresolver.ChooserRequestParameters; import com.android.intentresolver.ChooserStackedAppDialogFragment; import com.android.intentresolver.ChooserTargetActionsDialogFragment; import com.android.intentresolver.EnterTransitionAnimationDelegate; @@ -147,6 +148,9 @@ import com.android.intentresolver.v2.platform.AppPredictionAvailable; import com.android.intentresolver.v2.platform.ImageEditor; import com.android.intentresolver.v2.platform.NearbyShare; import com.android.intentresolver.v2.ui.ActionTitle; +import com.android.intentresolver.v2.ui.model.CallerInfo; +import com.android.intentresolver.v2.ui.model.ChooserRequest; +import com.android.intentresolver.v2.ui.viewmodel.ChooserViewModel; import com.android.intentresolver.widget.ImagePreviewView; import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.internal.annotations.VisibleForTesting; @@ -311,31 +315,46 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private boolean mFinishWhenStopped = false; private final AtomicLong mIntentReceivedTime = new AtomicLong(-1); + private ChooserViewModel mViewModel; @VisibleForTesting - protected ActivityLogic createActivityLogic() { + protected ChooserActivityLogic createActivityLogic(ChooserRequest chooserRequest) { return new ChooserActivityLogic( TAG, /* activity = */ this, - this::onWorkProfileStatusUpdated); + this::onWorkProfileStatusUpdated, + chooserRequest); + } + + @NonNull + @Override + public CreationExtras getDefaultViewModelCreationExtras() { + CreationExtras extras = super.getDefaultViewModelCreationExtras(); + // Inserts a CallerInfo into the Bundle at stored at DEFAULT_ARGS_KEY + Bundle defaultArgs = requireNonNull(extras.get(SavedStateHandleSupport.DEFAULT_ARGS_KEY)); + defaultArgs.putParcelable(CallerInfo.SAVED_STATE_HANDLE_KEY, + new CallerInfo(getLaunchedFromUid(), + getLaunchedFromPackage(), + requireNonNull(getReferrer()))); + return extras; } @Override protected final void onCreate(Bundle savedInstanceState) { + Log.d(TAG, "onCreate"); super.onCreate(savedInstanceState); - if (isFinishing()) { - // Performing a clean exit: - // Skip initializing any additional resources. - return; - } setTheme(R.style.Theme_DeviceDefault_Chooser); - mLogic = createActivityLogic(); Tracer.INSTANCE.markLaunched(); + mViewModel = new ViewModelProvider(this).get(ChooserViewModel.class); + if (!mViewModel.init()) { + finish(); + return; + } + mLogic = createActivityLogic(mViewModel.getChooserRequest()); + init(); } - @Override - protected final void onPostCreate(@Nullable Bundle savedInstanceState) { - super.onPostCreate(savedInstanceState); + private void init() { mIntentReceivedTime.set(System.currentTimeMillis()); mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET); @@ -345,21 +364,16 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mShouldDisplayLandscape = shouldDisplayLandscape(getResources().getConfiguration().orientation); - ChooserRequestParameters chooserRequest = getChooserRequest(); - if (chooserRequest == null) { - finish(); - return; - } - + ChooserRequest chooserRequest = mViewModel.getChooserRequest(); setRetainInOnStop(chooserRequest.shouldRetainInOnStop()); createProfileRecords( new AppPredictorFactory( this, - chooserRequest.getSharedText(), - chooserRequest.getTargetIntentFilter(), + Objects.toString(chooserRequest.getSharedText(), null), + chooserRequest.getShareTargetFilter(), mAppPredictionAvailable ), - chooserRequest.getTargetIntentFilter() + chooserRequest.getShareTargetFilter() ); Intent intent = mLogic.getTargetIntent(); @@ -493,8 +507,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mLogic.getReferrerPackageName(), chooserRequest.getTargetType(), chooserRequest.getCallerChooserTargets().size(), - (chooserRequest.getInitialIntents() == null) - ? 0 : chooserRequest.getInitialIntents().length, + chooserRequest.getInitialIntents().size(), isWorkProfile(), mChooserContentPreviewUi.getPreferredContentPreview(), chooserRequest.getTargetAction(), @@ -502,8 +515,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements chooserRequest.getModifyShareAction() != null ); mEnterTransitionAnimationDelegate.postponeTransition(); - - restore(savedInstanceState); } private void restore(@Nullable Bundle savedInstanceState) { @@ -1151,15 +1162,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements ////////////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////////////// - @Nullable - private ChooserRequestParameters getChooserRequest() { - return ((ChooserActivityLogic) mLogic).getChooserRequestParameters(); - } - - private ChooserRequestParameters requireChooserRequest() { - return requireNonNull(getChooserRequest()); - } - private AnnotatedUserHandles requireAnnotatedUserHandles() { return requireNonNull(mLogic.getAnnotatedUserHandles()); } @@ -1234,7 +1236,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } protected EmptyStateProvider createBlockerEmptyStateProvider() { - final boolean isSendAction = requireChooserRequest().isSendActionTarget(); + final boolean isSendAction = mViewModel.getChooserRequest().isSendActionTarget(); final EmptyState noWorkToPersonalEmptyState = new DevicePolicyBlockerEmptyState( @@ -1504,7 +1506,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } final Intent intent = getIntent(); if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction() - && !mLogic.getResolvingHome() && !mRetainInOnStop) { + && !mRetainInOnStop) { // This resolver is in the unusual situation where it has been // launched at the top of a new task. We don't let it be added // to the recent tasks shown to the user, and we need to make sure @@ -1550,10 +1552,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override // ResolverListCommunicator public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) { - ChooserRequestParameters chooserRequest = getChooserRequest(); - if (chooserRequest == null) { - return defIntent; - } + ChooserRequest chooserRequest = mViewModel.getChooserRequest(); Intent result = defIntent; if (chooserRequest.getReplacementExtras() != null) { @@ -1578,7 +1577,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } public void onActivityStarted(TargetInfo cti) { - ChooserRequestParameters chooserRequest = requireChooserRequest(); + ChooserRequest chooserRequest = mViewModel.getChooserRequest(); if (chooserRequest.getChosenComponentSender() != null) { final ComponentName target = cti.getResolvedComponentName(); if (target != null) { @@ -1595,7 +1594,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private void addCallerChooserTargets() { - ChooserRequestParameters chooserRequest = requireChooserRequest(); + ChooserRequest chooserRequest = mViewModel.getChooserRequest(); if (!chooserRequest.getCallerChooserTargets().isEmpty()) { // Send the caller's chooser targets only to the default profile. if (mChooserMultiProfilePagerAdapter.getActiveProfile() == findSelectedProfile()) { @@ -1637,8 +1636,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements // TODO: implement these type-conditioned behaviors polymorphically, and consider moving // the logic into `ChooserTargetActionsDialogFragment.show()`. boolean isShortcutPinned = targetInfo.isSelectableTargetInfo() && targetInfo.isPinned(); - IntentFilter intentFilter = targetInfo.isSelectableTargetInfo() - ? requireChooserRequest().getTargetIntentFilter() : null; + IntentFilter intentFilter; + intentFilter = targetInfo.isSelectableTargetInfo() + ? mViewModel.getChooserRequest().getShareTargetFilter() : null; String shortcutTitle = targetInfo.isSelectableTargetInfo() ? targetInfo.getDisplayLabel().toString() : null; String shortcutIdKey = targetInfo.getDirectShareShortcutId(); @@ -1658,7 +1658,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements protected boolean onTargetSelected(TargetInfo target) { if (mRefinementManager.maybeHandleSelection( target, - requireChooserRequest().getRefinementIntentSender(), + mViewModel.getChooserRequest().getRefinementIntentSender(), getApplication(), getMainThreadHandler())) { return false; @@ -1732,7 +1732,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements targetInfo.getResolveInfo().activityInfo.processName, which, /* directTargetAlsoRanked= */ getRankedPosition(targetInfo), - requireChooserRequest().getCallerChooserTargets().size(), + mViewModel.getChooserRequest().getCallerChooserTargets().size(), targetInfo.getHashedTargetIdForMetrics(this), targetInfo.isPinned(), mIsSuccessfullySelected, @@ -1839,7 +1839,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (targetIntent == null) { return; } - Intent originalTargetIntent = new Intent(requireChooserRequest().getTargetIntent()); + Intent originalTargetIntent = new Intent(mViewModel.getChooserRequest().getTargetIntent()); // Our TargetInfo implementations add associated component to the intent, let's do the same // for the sake of the comparison below. if (targetIntent.getComponent() != null) { @@ -1938,7 +1938,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override public boolean isComponentFiltered(ComponentName name) { - return requireChooserRequest().getFilteredComponentNames().contains(name); + return mViewModel.getChooserRequest().getFilteredComponentNames().contains(name); } @Override @@ -1955,7 +1955,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements List rList, boolean filterLastUsed, UserHandle userHandle) { - ChooserRequestParameters parameters = requireChooserRequest(); + ChooserRequest parameters = mViewModel.getChooserRequest(); ChooserListAdapter chooserListAdapter = createChooserListAdapter( context, payloadIntents, @@ -2104,11 +2104,11 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private ChooserActionFactory createChooserActionFactory() { - ChooserRequestParameters request = requireChooserRequest(); + ChooserRequest request = mViewModel.getChooserRequest(); return new ChooserActionFactory( this, request.getTargetIntent(), - request.getReferrerPackageName(), + request.getLaunchedFromPackage(), request.getChooserActions(), request.getModifyShareAction(), mImageEditor, @@ -2473,7 +2473,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements * @return true if we want to show the content preview area */ protected boolean shouldShowContentPreview() { - ChooserRequestParameters chooserRequest = getChooserRequest(); + ChooserRequest chooserRequest = mViewModel.getChooserRequest(); return (chooserRequest != null) && chooserRequest.isSendActionTarget(); } diff --git a/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt b/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt index a8150f52..f6054885 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt +++ b/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt @@ -1,11 +1,9 @@ package com.android.intentresolver.v2 -import android.app.Activity import android.content.Intent -import android.util.Log import androidx.activity.ComponentActivity import androidx.annotation.OpenForTesting -import com.android.intentresolver.ChooserRequestParameters +import com.android.intentresolver.v2.ui.model.ChooserRequest private const val TAG = "ChooserActivityLogic" @@ -13,14 +11,14 @@ private const val TAG = "ChooserActivityLogic" * Activity logic for [ChooserActivity]. * * TODO: Make this class no longer open once [ChooserActivity] no longer needs to cast to access - * [chooserRequestParameters]. For now, this class being open is better than using reflection - * there. + * [chooserRequest]. For now, this class being open is better than using reflection there. */ @OpenForTesting open class ChooserActivityLogic( tag: String, activity: ComponentActivity, - onWorkProfileStatusUpdated: () -> Unit + onWorkProfileStatusUpdated: () -> Unit, + private val chooserRequest: ChooserRequest? = null, ) : ActivityLogic, CommonActivityLogic by CommonActivityLogicImpl( @@ -29,30 +27,16 @@ open class ChooserActivityLogic( onWorkProfileStatusUpdated, ) { - val chooserRequestParameters: ChooserRequestParameters? = - try { - ChooserRequestParameters( - (activity as Activity).intent, - referrerPackageName, - (activity as Activity).referrer, - ) - } catch (e: IllegalArgumentException) { - Log.e(tag, "Caller provided invalid Chooser request parameters", e) - null - } + override val targetIntent: Intent = chooserRequest?.targetIntent ?: Intent() - override val targetIntent: Intent = chooserRequestParameters?.targetIntent ?: Intent() + override val title: CharSequence? = chooserRequest?.title - override val resolvingHome: Boolean = false + override val defaultTitleResId: Int = chooserRequest?.defaultTitleResource ?: 0 - override val title: CharSequence? = chooserRequestParameters?.title - - override val defaultTitleResId: Int = chooserRequestParameters?.defaultTitleResource ?: 0 - - override val initialIntents: List? = chooserRequestParameters?.initialIntents?.toList() + override val initialIntents: List? = chooserRequest?.initialIntents?.toList() override val payloadIntents: List = buildList { add(targetIntent) - chooserRequestParameters?.additionalTargets?.let { addAll(it) } + chooserRequest?.additionalTargets?.let { addAll(it) } } } diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index 9672e9d6..0e526b4c 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -106,6 +106,7 @@ import com.android.intentresolver.v2.emptystate.NoAppsAvailableEmptyStateProvide import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider; import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; import com.android.intentresolver.v2.emptystate.WorkProfilePausedEmptyStateProvider; +import com.android.intentresolver.v2.ext.IntentExtKt; import com.android.intentresolver.v2.ui.ActionTitle; import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.internal.annotations.VisibleForTesting; @@ -141,6 +142,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements protected ActivityLogic mLogic; protected TargetDataLoader mTargetDataLoader; + private boolean mResolvingHome; private Button mAlwaysButton; private Button mOnceButton; @@ -223,7 +225,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements } @VisibleForTesting - protected ActivityLogic createActivityLogic() { + protected ResolverActivityLogic createActivityLogic() { return new ResolverActivityLogic( TAG, /* activity = */ this, @@ -235,6 +237,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements super.onCreate(savedInstanceState); setTheme(R.style.Theme_DeviceDefault_Resolver); mLogic = createActivityLogic(); + mResolvingHome = IntentExtKt.isHomeIntent(getIntent()); mTargetDataLoader = new DefaultTargetDataLoader( this, getLifecycle(), @@ -242,11 +245,6 @@ public class ResolverActivity extends Hilt_ResolverActivity implements ResolverActivity.EXTRA_IS_AUDIO_CAPTURE_DEVICE, /* defaultValue = */ false) ); - } - - @Override - protected final void onPostCreate(@Nullable Bundle savedInstanceState) { - super.onPostCreate(savedInstanceState); init(); restore(savedInstanceState); } @@ -486,7 +484,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements } final Intent intent = getIntent(); if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction() - && !mLogic.getResolvingHome()) { + && !mResolvingHome) { // This resolver is in the unusual situation where it has been // launched at the top of a new task. We don't let it be added // to the recent tasks shown to the user, and we need to make sure @@ -532,7 +530,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements } ResolveInfo ri = mMultiProfilePagerAdapter.getActiveListAdapter() .resolveInfoForPosition(which, hasIndexBeenFiltered); - if (mLogic.getResolvingHome() && hasManagedProfile() && !supportsManagedProfiles(ri)) { + if (mResolvingHome && hasManagedProfile() && !supportsManagedProfiles(ri)) { String launcherName = ri.activityInfo.loadLabel(getPackageManager()).toString(); Toast.makeText(this, mDevicePolicyResources.getWorkProfileNotSupportedMessage(launcherName), @@ -1133,7 +1131,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements } protected final CharSequence getTitleForAction(Intent intent, int defaultTitleRes) { - final ActionTitle title = mLogic.getResolvingHome() + final ActionTitle title = mResolvingHome ? ActionTitle.HOME : ActionTitle.forAction(intent.getAction()); @@ -1198,7 +1196,6 @@ public class ResolverActivity extends Hilt_ResolverActivity implements @Override protected final void onStart() { super.onStart(); - this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); if (hasWorkProfile()) { mLogic.getWorkProfileAvailabilityManager().registerWorkProfileStateReceiver(this); diff --git a/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt b/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt index cf843043..13353041 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt +++ b/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt @@ -36,10 +36,6 @@ open class ResolverActivityLogic( intent } - override val resolvingHome: Boolean = - targetIntent.action == Intent.ACTION_MAIN && - targetIntent.categories.singleOrNull() == Intent.CATEGORY_HOME - override val title: CharSequence? = null override val defaultTitleResId: Int = 0 diff --git a/java/src/com/android/intentresolver/v2/ext/IntentExt.kt b/java/src/com/android/intentresolver/v2/ext/IntentExt.kt new file mode 100644 index 00000000..7aa8e036 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ext/IntentExt.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2024 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.v2.ext + +import android.content.Intent +import java.util.function.Predicate + +/** Applies an operation on this Intent if matches the given filter. */ +inline fun Intent.ifMatch( + predicate: Predicate, + crossinline block: Intent.() -> Unit +): Intent { + if (predicate.test(this)) { + apply(block) + } + return this +} + +/** True if the Intent has one of the specified actions. */ +fun Intent.hasAction(vararg actions: String): Boolean = action in actions + +/** True if the Intent has a single matching category. */ +fun Intent.hasSingleCategory(category: String) = categories.singleOrNull() == category + +/** True if the Intent resolves to the special Home (Launcher) component */ +fun Intent.isHomeIntent() = hasAction(Intent.ACTION_MAIN) && hasSingleCategory(Intent.CATEGORY_HOME) diff --git a/java/src/com/android/intentresolver/v2/ui/model/CallerInfo.kt b/java/src/com/android/intentresolver/v2/ui/model/CallerInfo.kt new file mode 100644 index 00000000..9addeef2 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ui/model/CallerInfo.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2024 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.v2.ui.model + +import android.net.Uri +import android.os.Parcel +import android.os.Parcelable + +data class CallerInfo( + val launchedFromUid: Int, + val launchedFomPackage: String?, + /* logged to metrics, forwarded to outgoing intent */ + val referrer: Uri +) : Parcelable { + constructor( + source: Parcel + ) : this( + launchedFromUid = source.readInt(), + launchedFomPackage = source.readString(), + checkNotNull(source.readParcelable()) + ) + + override fun describeContents() = 0 /* flags */ + + override fun writeToParcel(dest: Parcel, flags: Int) { + dest.writeInt(launchedFromUid) + dest.writeString(launchedFomPackage) + dest.writeParcelable(referrer, 0) + } + + companion object { + const val SAVED_STATE_HANDLE_KEY = "com.android.intentresolver.CALLER_INFO" + + @JvmStatic + @Suppress("unused") + val CREATOR = + object : Parcelable.Creator { + override fun newArray(size: Int) = arrayOfNulls(size) + override fun createFromParcel(source: Parcel) = CallerInfo(source) + } + } +} + +inline fun Parcel.readParcelable(): T? { + return readParcelable(T::class.java.classLoader, T::class.java) +} diff --git a/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt b/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt new file mode 100644 index 00000000..2fbf94a2 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt @@ -0,0 +1,180 @@ +/* + * Copyright (C) 2024 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.v2.ui.model + +import android.content.ComponentName +import android.content.Intent +import android.content.Intent.ACTION_SEND +import android.content.Intent.ACTION_SEND_MULTIPLE +import android.content.IntentFilter +import android.content.IntentSender +import android.os.Bundle +import android.service.chooser.ChooserAction +import android.service.chooser.ChooserTarget +import androidx.annotation.StringRes +import com.android.intentresolver.v2.ext.hasAction + +const val MAX_CHOOSER_ACTIONS = 5 +const val MAX_INITIAL_INTENTS = 2 + +/** All of the things that are consumed from an incoming share Intent (+Extras). */ +data class ChooserRequest( + /** Required. Represents the content being sent. */ + val targetIntent: Intent, + + /** The action from [targetIntent] as retrieved with [Intent.getAction]. */ + val targetAction: String?, + + /** + * Whether [targetAction] is ACTION_SEND or ACTION_SEND_MULTIPLE. These are considered the + * canonical "Share" actions. When handling other actions, this flag controls behavioral and + * visual changes. + */ + val isSendActionTarget: Boolean, + + /** The top-level content type as retrieved using [Intent.getType]. */ + val targetType: String?, + + /** The package name of the app which started the current activity instance. */ + val launchedFromPackage: String, + + /** A custom tile for the main UI. Ignored when the intent is ACTION_SEND(_MULTIPLE). */ + val title: CharSequence? = null, + + /** A String resource ID to load when [title] is null. */ + @get:StringRes val defaultTitleResource: Int = 0, + + /** + * An empty intent which carries an extra of [Intent.EXTRA_REFERRER]. To be merged with outgoing + * intents. This provides the original referrer value to the target. + */ + val referrerFillInIntent: Intent, + + /** + * Choices to exclude from results. + * + * Any resolved intents with a component in this list will be omitted before presentation. + */ + val filteredComponentNames: List = emptyList(), + + /** + * App provided shortcut share intents (aka "direct share targets") + * + * Normally share shortcuts are published and consumed using + * [ShortcutManager][android.content.pm.ShortcutManager]. This is an alternate channel to allow + * apps to directly inject the same information. + * + * Historical note: This option was initially integrated with other results from the + * ChooserTargetService API (since deprecated and removed), hence the name and data format. + * These are more correctly called "Share Shortcuts" now. + */ + val callerChooserTargets: List = emptyList(), + + /** + * Actions the user may perform. These are presented as separate affordances from the main list + * of choices. Selecting a choice is a terminal action which results in finishing. The item + * limit is [MAX_CHOOSER_ACTIONS]. This may be further constrained as appropriate. + */ + val chooserActions: List = emptyList(), + + /** + * An action to start an Activity which for user updating of shared content. Selection is a + * terminal action, closing the current activity and launching the target of the action. + */ + val modifyShareAction: ChooserAction? = null, + + /** + * When false the host activity will be [finished][android.app.Activity.finish] when stopped. + */ + @get:JvmName("shouldRetainInOnStop") val shouldRetainInOnStop: Boolean = false, + + /** + * Intents which contain alternate representations of the content being shared. Any results from + * resolving these _alternate_ intents are included with the results of the primary intent as + * additional choices (e.g. share as image content vs. link to content). + */ + val additionalTargets: List = emptyList(), + + /** + * Alternate [extras][Intent.getExtras] to substitute when launching a selected app. + * + * For a given app (by package name), the Bundle describes what parameters to substitute when + * that app is selected. + * + * // TODO: Map + */ + val replacementExtras: Bundle? = null, + + /** + * App-supplied choices to be presented first in the list. + * + * Custom labels and icons may be supplied using + * [LabeledIntent][android.content.pm.LabeledIntent]. + * + * Limit 2. + */ + val initialIntents: List = emptyList(), + + /** + * Provides for callers to be notified when a component is selected. + * + * The selection is reported in the Intent as [Intent.EXTRA_CHOSEN_COMPONENT] with the + * [ComponentName] of the item. + */ + val chosenComponentSender: IntentSender? = null, + + /** + * Provides a mechanism for callers to post-process a target when a selection is made. + * + * The received intent will contain: + * * **EXTRA_INTENT** The chosen target + * * **EXTRA_ALTERNATE_INTENTS** Additional intents which also match the target + * * **EXTRA_RESULT_RECEIVER** A [ResultReceiver][android.os.ResultReceiver] providing a + * mechanism for the caller to return information. An updated intent to send must be included + * as [Intent.EXTRA_INTENT]. + */ + val refinementIntentSender: IntentSender? = null, + + /** + * Contains the text content to share supplied by the source app. + * + * TODO: Constrain length? + */ + val sharedText: CharSequence? = null, + + /** + * Supplied to + * [ShortcutManager.getShareTargets][android.content.pm.ShortcutManager.getShareTargets] to + * query for matching shortcuts. Specifically, only the [dataTypes][IntentFilter.hasDataType] + * are considered for matching share shortcuts currently. + */ + val shareTargetFilter: IntentFilter? = null +) { + + /** Constructs an instance from only the required values. */ + constructor( + targetIntent: Intent, + referrerPackageName: String + ) : this( + targetIntent, + targetIntent.action, + targetIntent.hasAction(ACTION_SEND, ACTION_SEND_MULTIPLE), + targetIntent.type, + referrerPackageName, + referrerFillInIntent = + Intent().apply { putExtra(Intent.EXTRA_REFERRER, referrerPackageName) } + ) +} diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt new file mode 100644 index 00000000..6878be5f --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2024 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.v2.ui.viewmodel + +import android.content.ComponentName +import android.content.Intent +import android.content.Intent.ACTION_SEND +import android.content.Intent.ACTION_SEND_MULTIPLE +import android.content.Intent.EXTRA_ALTERNATE_INTENTS +import android.content.Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS +import android.content.Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION +import android.content.Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER +import android.content.Intent.EXTRA_CHOOSER_TARGETS +import android.content.Intent.EXTRA_CHOSEN_COMPONENT_INTENT_SENDER +import android.content.Intent.EXTRA_EXCLUDE_COMPONENTS +import android.content.Intent.EXTRA_INITIAL_INTENTS +import android.content.Intent.EXTRA_INTENT +import android.content.Intent.EXTRA_REFERRER +import android.content.Intent.EXTRA_REPLACEMENT_EXTRAS +import android.content.Intent.EXTRA_TEXT +import android.content.Intent.EXTRA_TITLE +import android.content.Intent.FLAG_ACTIVITY_MULTIPLE_TASK +import android.content.Intent.FLAG_ACTIVITY_NEW_DOCUMENT +import android.content.IntentFilter +import android.content.IntentSender +import android.os.Bundle +import android.service.chooser.ChooserAction +import android.service.chooser.ChooserTarget +import com.android.intentresolver.ChooserActivity +import com.android.intentresolver.R +import com.android.intentresolver.util.hasValidIcon +import com.android.intentresolver.v2.ext.hasAction +import com.android.intentresolver.v2.ext.ifMatch +import com.android.intentresolver.v2.ui.model.CallerInfo +import com.android.intentresolver.v2.ui.model.ChooserRequest +import com.android.intentresolver.v2.ui.model.MAX_CHOOSER_ACTIONS +import com.android.intentresolver.v2.ui.model.MAX_INITIAL_INTENTS +import com.android.intentresolver.v2.validation.types.IntentOrUri +import com.android.intentresolver.v2.validation.types.array +import com.android.intentresolver.v2.validation.types.value +import com.android.intentresolver.v2.validation.validateFrom + +private fun Intent.hasSendAction() = hasAction(ACTION_SEND, ACTION_SEND_MULTIPLE) + +internal fun Intent.maybeAddSendActionFlags() = + ifMatch(Intent::hasSendAction) { + addFlags(FLAG_ACTIVITY_NEW_DOCUMENT) + addFlags(FLAG_ACTIVITY_MULTIPLE_TASK) + } + +fun readChooserRequest(callerInfo: CallerInfo, source: (String) -> Any?) = + validateFrom(source) { + val targetIntent = required(IntentOrUri(EXTRA_INTENT)).maybeAddSendActionFlags() + + val isSendAction = targetIntent.hasAction(ACTION_SEND, ACTION_SEND_MULTIPLE) + + val additionalTargets = + optional(array(EXTRA_ALTERNATE_INTENTS))?.map { it.maybeAddSendActionFlags() } + ?: emptyList() + + val replacementExtras = optional(value(EXTRA_REPLACEMENT_EXTRAS)) + + val (customTitle, defaultTitleResource) = + if (isSendAction) { + ignored( + value(EXTRA_TITLE), + "deprecated in P. You may wish to set a preview title by using EXTRA_TITLE " + + "property of the wrapped EXTRA_INTENT." + ) + null to R.string.chooseActivity + } else { + val custom = optional(value(EXTRA_TITLE)) + custom to (custom?.let { 0 } ?: R.string.chooseActivity) + } + + val initialIntents = + optional(array(EXTRA_INITIAL_INTENTS))?.take(MAX_INITIAL_INTENTS)?.map { + it.maybeAddSendActionFlags() + } + ?: emptyList() + + val chosenComponentSender = + optional(value(EXTRA_CHOSEN_COMPONENT_INTENT_SENDER)) + + val refinementIntentSender = + optional(value(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER)) + + val filteredComponents = + optional(array(EXTRA_EXCLUDE_COMPONENTS)) ?: emptyList() + + @Suppress("DEPRECATION") + val callerChooserTargets = + optional(array(EXTRA_CHOOSER_TARGETS)) ?: emptyList() + + val retainInOnStop = + optional(value(ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP)) ?: false + + val sharedText = optional(value(EXTRA_TEXT)) + + val chooserActions = + optional(array(EXTRA_CHOOSER_CUSTOM_ACTIONS)) + ?.filter { hasValidIcon(it) } + ?.take(MAX_CHOOSER_ACTIONS) + ?: emptyList() + + val modifyShareAction = optional(value(EXTRA_CHOOSER_MODIFY_SHARE_ACTION)) + + val referrerFillIn = Intent().putExtra(EXTRA_REFERRER, callerInfo.referrer) + + ChooserRequest( + targetIntent = targetIntent, + targetAction = targetIntent.action, + isSendActionTarget = isSendAction, + targetType = targetIntent.type, + launchedFromPackage = + requireNotNull(callerInfo.launchedFomPackage) { + "launchedFromPackage was null, See Activity.getLaunchedFromPackage()" + }, + title = customTitle, + defaultTitleResource = defaultTitleResource, + referrerFillInIntent = referrerFillIn, + filteredComponentNames = filteredComponents, + callerChooserTargets = callerChooserTargets, + chooserActions = chooserActions, + modifyShareAction = modifyShareAction, + shouldRetainInOnStop = retainInOnStop, + additionalTargets = additionalTargets, + replacementExtras = replacementExtras, + initialIntents = initialIntents, + chosenComponentSender = chosenComponentSender, + refinementIntentSender = refinementIntentSender, + sharedText = sharedText, + shareTargetFilter = targetIntent.toShareTargetFilter() + ) + } + +private fun Intent.toShareTargetFilter(): IntentFilter? { + return type?.let { + IntentFilter().apply { + action?.also { addAction(it) } + addDataType(it) + } + } +} diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt new file mode 100644 index 00000000..663235ca --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2024 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.v2.ui.viewmodel + +import android.util.Log +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import com.android.intentresolver.v2.ui.model.CallerInfo +import com.android.intentresolver.v2.ui.model.ChooserRequest +import com.android.intentresolver.v2.validation.ValidationResult +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +private const val TAG = "ChooserViewModel" + +@HiltViewModel +class ChooserViewModel +@Inject +constructor( + private val args: SavedStateHandle, +) : ViewModel() { + + private val callerInfo: CallerInfo = + requireNotNull(args[CallerInfo.SAVED_STATE_HANDLE_KEY]) { + "CallerInfo missing in SavedStateHandle! (${CallerInfo.SAVED_STATE_HANDLE_KEY})" + } + + /** The result of reading and validating the inputs provided in savedState. */ + private val status: ValidationResult = readChooserRequest(callerInfo, args::get) + + val chooserRequest: ChooserRequest by lazy { status.getOrThrow() } + + fun init(): Boolean { + Log.i(TAG, "viewModel init") + if (!status.isSuccess()) { + status.reportToLogcat(TAG) + return false + } + Log.i(TAG, "request = $chooserRequest") + return true + } +} diff --git a/java/src/com/android/intentresolver/v2/validation/ValidationResult.kt b/java/src/com/android/intentresolver/v2/validation/ValidationResult.kt index 092cabe8..856a521e 100644 --- a/java/src/com/android/intentresolver/v2/validation/ValidationResult.kt +++ b/java/src/com/android/intentresolver/v2/validation/ValidationResult.kt @@ -26,7 +26,7 @@ sealed interface ValidationResult { fun getOrThrow(): T = checkNotNull(value) { "The result was invalid: " + findings.joinToString(separator = "\n") } - fun reportToLogcat(tag: String) { + fun reportToLogcat(tag: String) { findings.forEach { Log.println(it.logcatPriority, tag, it.toString()) } } } diff --git a/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java b/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java index 0b268905..e7c8cce3 100644 --- a/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java @@ -40,6 +40,7 @@ import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; import com.android.intentresolver.shortcuts.ShortcutLoader; +import com.android.intentresolver.v2.ui.model.ChooserRequest; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import java.util.List; @@ -54,12 +55,14 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW private UsageStatsManager mUsm; @Override - protected final ActivityLogic createActivityLogic() { + protected final ChooserActivityLogic createActivityLogic(ChooserRequest chooserRequest) { return new TestChooserActivityLogic( - "ChooserWrapper", - /* activity = */ this, - this::onWorkProfileStatusUpdated, - sOverrides); + "ChooserWrapper", + /* activity = */ this, + this::onWorkProfileStatusUpdated, + chooserRequest, + sOverrides.annotatedUserHandles, + sOverrides.mWorkProfileAvailability); } // ResolverActivity (the base class of ChooserActivity) inspects the launched-from UID at diff --git a/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java b/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java index d06b7929..9eaf9261 100644 --- a/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java @@ -61,7 +61,7 @@ public class ResolverWrapperActivity extends ResolverActivity { new CountingIdlingResource("LoadLabelTask"); @Override - protected final ActivityLogic createActivityLogic() { + protected final ResolverActivityLogic createActivityLogic() { return new TestResolverActivityLogic( "ResolverWrapper", this, diff --git a/tests/activity/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt b/tests/activity/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt index 0849e511..3c22254a 100644 --- a/tests/activity/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt +++ b/tests/activity/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt @@ -3,25 +3,26 @@ package com.android.intentresolver.v2 import androidx.activity.ComponentActivity import com.android.intentresolver.AnnotatedUserHandles import com.android.intentresolver.WorkProfileAvailabilityManager +import com.android.intentresolver.v2.ui.model.ChooserRequest /** Activity logic for use when testing [ChooserActivity]. */ class TestChooserActivityLogic( tag: String, activity: ComponentActivity, onWorkProfileStatusUpdated: () -> Unit, - private val overrideData: ChooserActivityOverrideData, + chooserRequest: ChooserRequest? = null, + private val annotatedUserHandlesOverride: AnnotatedUserHandles?, + private val workProfileAvailabilityOverride: WorkProfileAvailabilityManager?, ) : ChooserActivityLogic( tag, activity, onWorkProfileStatusUpdated, + chooserRequest, ) { + override val annotatedUserHandles: AnnotatedUserHandles? + get() = annotatedUserHandlesOverride ?: super.annotatedUserHandles - override val annotatedUserHandles: AnnotatedUserHandles? by lazy { - overrideData.annotatedUserHandles - } - - override val workProfileAvailabilityManager: WorkProfileAvailabilityManager by lazy { - overrideData.mWorkProfileAvailability ?: super.workProfileAvailabilityManager - } + override val workProfileAvailabilityManager: WorkProfileAvailabilityManager + get() = workProfileAvailabilityOverride ?: super.workProfileAvailabilityManager } diff --git a/tests/unit/src/com/android/intentresolver/v2/ext/IntentExtTest.kt b/tests/unit/src/com/android/intentresolver/v2/ext/IntentExtTest.kt new file mode 100644 index 00000000..6a16168c --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/v2/ext/IntentExtTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2024 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.v2.ext + +import android.content.Intent +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import java.util.function.Predicate +import org.junit.Test + +class IntentExtTest { + + private val hasSendAction = + Predicate { + it?.action == Intent.ACTION_SEND || it?.action == Intent.ACTION_SEND_MULTIPLE + } + + @Test + fun hasAction() { + val sendIntent = Intent(Intent.ACTION_SEND) + assertThat(sendIntent.hasAction(Intent.ACTION_SEND)).isTrue() + assertThat(sendIntent.hasAction(Intent.ACTION_VIEW)).isFalse() + } + + @Test + fun hasSingleCategory() { + val intent = Intent().addCategory(Intent.CATEGORY_HOME) + assertThat(intent.hasSingleCategory(Intent.CATEGORY_HOME)).isTrue() + assertThat(intent.hasSingleCategory(Intent.CATEGORY_DEFAULT)).isFalse() + + intent.addCategory(Intent.CATEGORY_TEST) + assertThat(intent.hasSingleCategory(Intent.CATEGORY_TEST)).isFalse() + } + + @Test + fun ifMatch_matched() { + val sendIntent = Intent(Intent.ACTION_SEND) + val sendMultipleIntent = Intent(Intent.ACTION_SEND_MULTIPLE) + + sendIntent.ifMatch(hasSendAction) { addFlags(Intent.FLAG_ACTIVITY_MATCH_EXTERNAL) } + sendMultipleIntent.ifMatch(hasSendAction) { addFlags(Intent.FLAG_ACTIVITY_MATCH_EXTERNAL) } + assertWithMessage("sendIntent flags") + .that(sendIntent.flags) + .isEqualTo(Intent.FLAG_ACTIVITY_MATCH_EXTERNAL) + assertWithMessage("sendMultipleIntent flags") + .that(sendMultipleIntent.flags) + .isEqualTo(Intent.FLAG_ACTIVITY_MATCH_EXTERNAL) + } + + @Test + fun ifMatch_notMatched() { + val viewIntent = Intent(Intent.ACTION_VIEW) + + viewIntent.ifMatch(hasSendAction) { addFlags(Intent.FLAG_ACTIVITY_MATCH_EXTERNAL) } + assertWithMessage("viewIntent flags").that(viewIntent.flags).isEqualTo(0) + } +} diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt new file mode 100644 index 00000000..bcc1054c --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2024 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.v2.ui.viewmodel + +import android.content.Intent +import android.content.Intent.ACTION_SEND +import android.content.Intent.EXTRA_INTENT +import androidx.core.net.toUri +import androidx.core.os.bundleOf +import com.android.intentresolver.v2.ui.model.CallerInfo +import com.android.intentresolver.v2.ui.model.ChooserRequest +import com.android.intentresolver.v2.validation.RequiredValueMissing +import com.android.intentresolver.v2.validation.ValidationResultSubject.Companion.assertThat +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +@Suppress("DEPRECATION") +class ChooserRequestTest { + + private val callerInfo = + CallerInfo( + launchedFromUid = 10000, + launchedFomPackage = "com.android.example", + referrer = "android-app://com.android.example".toUri() + ) + + @Test + fun missingIntent() { + val args = bundleOf() + + val result = readChooserRequest(callerInfo, args::get) + + assertThat(result).value().isNull() + assertThat(result) + .findings() + .containsExactly(RequiredValueMissing(EXTRA_INTENT, Intent::class)) + } + + @Test + fun minimal() { + val args = bundleOf(EXTRA_INTENT to Intent(ACTION_SEND)) + + val result = readChooserRequest(callerInfo, args::get) + + assertThat(result).value().isNotNull() + val value: ChooserRequest = result.getOrThrow() + assertThat(value.launchedFromPackage).isEqualTo(callerInfo.launchedFomPackage) + assertThat(result).findings().isEmpty() + } +} -- cgit v1.2.3-59-g8ed1b From 5e087748cca8efa1f9d93818607c81191ed5ddb3 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Fri, 19 Jan 2024 14:46:17 +0000 Subject: Don't crash using a destroyed AppPredictor client Bug: 318294957 Test: Build/presubmits Change-Id: Ic0a99e61cf6e1e687b75034c392f08eb098f7544 --- .../AppPredictionServiceResolverComparator.java | 32 ++++++++++++++-------- 1 file changed, 20 insertions(+), 12 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java b/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java index 0651d26c..c6de3260 100644 --- a/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java +++ b/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java @@ -107,16 +107,20 @@ public class AppPredictionServiceResolverComparator extends AbstractResolverComp .setClassName(target.name.getClassName()) .build()); } - mAppPredictor.sortTargets( - appTargets, - Executors.newSingleThreadExecutor(), - new ScopedAppTargetListCallback( - mContext, - sortedAppTargets -> { - onAppTargetsSorted(targets, sortedAppTargets); - return kotlin.Unit.INSTANCE; - }).toConsumer() - ); + try { + mAppPredictor.sortTargets( + appTargets, + Executors.newSingleThreadExecutor(), + new ScopedAppTargetListCallback( + mContext, + sortedAppTargets -> { + onAppTargetsSorted(targets, sortedAppTargets); + return kotlin.Unit.INSTANCE; + }).toConsumer() + ); + } catch (IllegalStateException e) { + Log.w(TAG, "Couldn't sort targets with AppPredictionService", e); + } } private void onAppTargetsSorted( @@ -292,8 +296,12 @@ public class AppPredictionServiceResolverComparator extends AbstractResolverComp new AppTarget.Builder(targetId, targetComponent.getPackageName(), mUser) .setClassName(targetComponent.getClassName()) .build(); - mAppPredictor.notifyAppTargetEvent( - new AppTargetEvent.Builder(appTarget, ACTION_LAUNCH).build()); + try { + mAppPredictor.notifyAppTargetEvent( + new AppTargetEvent.Builder(appTarget, ACTION_LAUNCH).build()); + } catch (IllegalStateException e) { + Log.w(TAG, "Couldn't send feedback to AppPredictionService", e); + } } } } -- cgit v1.2.3-59-g8ed1b From eacea3131f2af18ffb1092db5907d9cd698fe061 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Tue, 23 Jan 2024 10:53:11 -0500 Subject: Rename @Profile to @ProfileType This is a temporary step before introducing a new data class to represent a Profile which will also carry a type identifier within. This is a name only change with no functional changes. Bug: 309960444 Test: atest IntentResolver-tests-unit Change-Id: I40e09b2b59005b3d91ea310aff1137813230733f --- .../android/intentresolver/v2/ChooserActivity.java | 4 ++-- .../v2/ChooserMultiProfilePagerAdapter.java | 4 ++-- .../v2/MultiProfilePagerAdapter.java | 28 +++++++++++----------- .../intentresolver/v2/ResolverActivity.java | 6 ++--- .../v2/ResolverMultiProfilePagerAdapter.java | 4 ++-- 5 files changed, 23 insertions(+), 23 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index a71de19d..343e1af1 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -138,7 +138,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.v2.MultiProfilePagerAdapter.Profile; +import com.android.intentresolver.v2.MultiProfilePagerAdapter.ProfileType; import com.android.intentresolver.v2.data.repository.DevicePolicyResources; import com.android.intentresolver.v2.emptystate.NoAppsAvailableEmptyStateProvider; import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider; @@ -1132,7 +1132,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements () -> onProfileTabSelected(viewPager.getCurrentItem()), new MultiProfilePagerAdapter.OnProfileSelectedListener() { @Override - public void onProfilePageSelected(@Profile int profileId, int pageNumber) {} + public void onProfilePageSelected(@ProfileType int profileId, int pageNumber) {} @Override public void onProfilePageStateChanged(int state) { diff --git a/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java index 14532b67..7bbeedc9 100644 --- a/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java @@ -91,7 +91,7 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< ChooserGridAdapter workAdapter, EmptyStateProvider emptyStateProvider, Supplier workProfileQuietModeChecker, - @Profile int defaultProfile, + @ProfileType int defaultProfile, UserHandle workProfileUserHandle, UserHandle cloneProfileUserHandle, int maxTargetsPerRow, @@ -127,7 +127,7 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< ImmutableList> tabs, EmptyStateProvider emptyStateProvider, Supplier workProfileQuietModeChecker, - @Profile int defaultProfile, + @ProfileType int defaultProfile, UserHandle workProfileUserHandle, UserHandle cloneProfileUserHandle, BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier, diff --git a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java index 2883542e..79403095 100644 --- a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java @@ -93,7 +93,7 @@ class MultiProfilePagerAdapter< public static final int PROFILE_WORK = 1; @IntDef({PROFILE_PERSONAL, PROFILE_WORK}) - public @interface Profile {} + public @interface ProfileType {} private final Function mListAdapterExtractor; private final AdapterBinder mAdapterBinder; @@ -111,14 +111,14 @@ class MultiProfilePagerAdapter< private OnProfileSelectedListener mOnProfileSelectedListener; public static class TabConfig { - private final @Profile int mProfile; + private final @ProfileType int mProfile; private final String mTabLabel; private final String mTabAccessibilityLabel; private final String mTabTag; private final PageAdapterT mPageAdapter; public TabConfig( - @Profile int profile, + @ProfileType int profile, String tabLabel, String tabAccessibilityLabel, String tabTag, @@ -137,7 +137,7 @@ class MultiProfilePagerAdapter< ImmutableList> tabs, EmptyStateProvider emptyStateProvider, Supplier workProfileQuietModeChecker, - @Profile int defaultProfile, + @ProfileType int defaultProfile, UserHandle workProfileUserHandle, UserHandle cloneProfileUserHandle, Supplier pageViewInflater, @@ -174,7 +174,7 @@ class MultiProfilePagerAdapter< } private ProfileDescriptor createProfileDescriptor( - @Profile int profile, + @ProfileType int profile, String tabLabel, String tabAccessibilityLabel, String tabTag, @@ -194,18 +194,18 @@ class MultiProfilePagerAdapter< return (pageIndex >= 0) && (pageIndex < getCount()); } - public final boolean hasPageForProfile(@Profile int profile) { + public final boolean hasPageForProfile(@ProfileType int profile) { return hasPageForIndex(getPageNumberForProfile(profile)); } - private @Profile int getProfileForPageNumber(int position) { + private @ProfileType int getProfileForPageNumber(int position) { if (hasPageForIndex(position)) { return mItems.get(position).mProfile; } return -1; } - public int getPageNumberForProfile(@Profile int profile) { + public int getPageNumberForProfile(@ProfileType int profile) { for (int i = 0; i < mItems.size(); ++i) { if (profile == mItems.get(i).mProfile) { return i; @@ -222,7 +222,7 @@ class MultiProfilePagerAdapter< return mListAdapterExtractor.apply(pageAdapter); } - private @Profile int getProfileForUserHandle(UserHandle userHandle) { + private @ProfileType int getProfileForUserHandle(UserHandle userHandle) { if (userHandle.equals(getCloneUserHandle())) { // TODO: can we push this special case elsewhere -- e.g., when we check against each // list adapter's user handle in the loop below, could we instead ask the list adapter @@ -327,7 +327,7 @@ class MultiProfilePagerAdapter< mOnProfileSelectedListener = new MultiProfilePagerAdapter.OnProfileSelectedListener() { @Override - public void onProfilePageSelected(@Profile int profileId, int pageNumber) { + public void onProfilePageSelected(@ProfileType int profileId, int pageNumber) { tabHost.setCurrentTab(pageNumber); clientOnProfileSelectedListener.onProfilePageSelected( profileId, pageNumber); @@ -398,7 +398,7 @@ class MultiProfilePagerAdapter< return mCurrentPage; } - public final @Profile int getActiveProfile() { + public final @ProfileType int getActiveProfile() { return getProfileForPageNumber(getCurrentPage()); } @@ -753,7 +753,7 @@ class MultiProfilePagerAdapter< // 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 ProfileDescriptor { - final @Profile int mProfile; + final @ProfileType int mProfile; final String mTabLabel; final String mTabAccessibilityLabel; final String mTabTag; @@ -769,7 +769,7 @@ class MultiProfilePagerAdapter< private final PageViewT mView; ProfileDescriptor( - @Profile int forProfile, + @ProfileType int forProfile, String tabLabel, String tabAccessibilityLabel, String tabTag, @@ -809,7 +809,7 @@ class MultiProfilePagerAdapter< * if the personal profile tab was selected or {@link #PROFILE_WORK} if the work profile tab * was selected. */ - void onProfilePageSelected(@Profile int profileId, int pageNumber); + void onProfilePageSelected(@ProfileType int profileId, int pageNumber); /** diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index 0e526b4c..1450a883 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -100,7 +100,7 @@ import com.android.intentresolver.icons.DefaultTargetDataLoader; import com.android.intentresolver.icons.TargetDataLoader; import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; import com.android.intentresolver.v2.MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; -import com.android.intentresolver.v2.MultiProfilePagerAdapter.Profile; +import com.android.intentresolver.v2.MultiProfilePagerAdapter.ProfileType; import com.android.intentresolver.v2.data.repository.DevicePolicyResources; import com.android.intentresolver.v2.emptystate.NoAppsAvailableEmptyStateProvider; import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider; @@ -1049,7 +1049,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements return selectedProfile; } - protected final @Profile int getCurrentProfile() { + protected final @ProfileType int getCurrentProfile() { UserHandle launchUser = requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch; UserHandle personalUser = requireAnnotatedUserHandles().personalProfileUserHandle; return launchUser.equals(personalUser) ? PROFILE_PERSONAL : PROFILE_WORK; @@ -1866,7 +1866,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements () -> onProfileTabSelected(viewPager.getCurrentItem()), new MultiProfilePagerAdapter.OnProfileSelectedListener() { @Override - public void onProfilePageSelected(@Profile int profileId, int pageNumber) { + public void onProfilePageSelected(@ProfileType int profileId, int pageNumber) { resetButtonBar(); resetCheckedItem(); } diff --git a/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java index 4c1358ed..9c98d574 100644 --- a/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java @@ -78,7 +78,7 @@ public class ResolverMultiProfilePagerAdapter extends ResolverListAdapter workAdapter, EmptyStateProvider emptyStateProvider, Supplier workProfileQuietModeChecker, - @Profile int defaultProfile, + @ProfileType int defaultProfile, UserHandle workProfileUserHandle, UserHandle cloneProfileUserHandle) { this( @@ -109,7 +109,7 @@ public class ResolverMultiProfilePagerAdapter extends ImmutableList> tabs, EmptyStateProvider emptyStateProvider, Supplier workProfileQuietModeChecker, - @Profile int defaultProfile, + @ProfileType int defaultProfile, UserHandle workProfileUserHandle, UserHandle cloneProfileUserHandle, BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) { -- cgit v1.2.3-59-g8ed1b From 561298bef94365950247075723dafdcfe998c745 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Tue, 23 Jan 2024 16:16:02 +0000 Subject: Specify tabs to display by a list of `TabConfig`s This was the last "snapshot" (#37) from my prototype CL ag/25335069. We should now be able to support arbitrary sets of tabs as specified by the activity (or other client, if/when we move the instantiation). Test: IntentResolver-tests-{unit,activity}; ResolverActivityTest Bug: 310211468 Change-Id: I1526cbce97bff6305c4e79c53a96181e185ab27f --- .../android/intentresolver/v2/ChooserActivity.java | 36 ++++++++++----- .../v2/ChooserMultiProfilePagerAdapter.java | 54 +--------------------- .../intentresolver/v2/ResolverActivity.java | 36 ++++++++++----- .../v2/ResolverMultiProfilePagerAdapter.java | 50 +------------------- 4 files changed, 52 insertions(+), 124 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 343e1af1..eea9fd2c 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -139,6 +139,7 @@ import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; import com.android.intentresolver.shortcuts.AppPredictorFactory; import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.intentresolver.v2.MultiProfilePagerAdapter.ProfileType; +import com.android.intentresolver.v2.MultiProfilePagerAdapter.TabConfig; import com.android.intentresolver.v2.data.repository.DevicePolicyResources; import com.android.intentresolver.v2.emptystate.NoAppsAvailableEmptyStateProvider; import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider; @@ -159,6 +160,8 @@ import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import com.android.internal.util.LatencyTracker; +import com.google.common.collect.ImmutableList; + import dagger.hilt.android.AndroidEntryPoint; import kotlin.Unit; @@ -1286,12 +1289,16 @@ public class ChooserActivity extends Hilt_ChooserActivity implements ); return new ChooserMultiProfilePagerAdapter( /* context */ this, - mDevicePolicyResources.getPersonalTabLabel(), - mDevicePolicyResources.getPersonalTabAccessibilityLabel(), - TAB_TAG_PERSONAL, - adapter, + ImmutableList.of( + new TabConfig<>( + PROFILE_PERSONAL, + mDevicePolicyResources.getPersonalTabLabel(), + mDevicePolicyResources.getPersonalTabAccessibilityLabel(), + TAB_TAG_PERSONAL, + adapter)), createEmptyStateProvider(/* workProfileUserHandle= */ null), /* workProfileQuietModeChecker= */ () -> false, + /* defaultProfile= */ PROFILE_PERSONAL, /* workProfileUserHandle= */ null, requireAnnotatedUserHandles().cloneProfileUserHandle, mMaxTargetsPerRow, @@ -1321,14 +1328,19 @@ public class ChooserActivity extends Hilt_ChooserActivity implements ); return new ChooserMultiProfilePagerAdapter( /* context */ this, - mDevicePolicyResources.getPersonalTabLabel(), - mDevicePolicyResources.getPersonalTabAccessibilityLabel(), - TAB_TAG_PERSONAL, - personalAdapter, - mDevicePolicyResources.getWorkTabLabel(), - mDevicePolicyResources.getWorkTabAccessibilityLabel(), - TAB_TAG_WORK, - workAdapter, + ImmutableList.of( + new TabConfig<>( + PROFILE_PERSONAL, + mDevicePolicyResources.getPersonalTabLabel(), + mDevicePolicyResources.getPersonalTabAccessibilityLabel(), + TAB_TAG_PERSONAL, + personalAdapter), + new TabConfig<>( + PROFILE_WORK, + mDevicePolicyResources.getWorkTabLabel(), + mDevicePolicyResources.getWorkTabAccessibilityLabel(), + TAB_TAG_WORK, + workAdapter)), createEmptyStateProvider(requireAnnotatedUserHandles().workProfileUserHandle), () -> mLogic.getWorkProfileAvailabilityManager().isQuietModeEnabled(), selectedProfile, diff --git a/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java index 7bbeedc9..42eb077b 100644 --- a/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java @@ -50,45 +50,7 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< public ChooserMultiProfilePagerAdapter( Context context, - String personalTabLabel, - String personalTabAccessibilityLabel, - String personalTabTag, - ChooserGridAdapter personalAdapter, - EmptyStateProvider emptyStateProvider, - Supplier workProfileQuietModeChecker, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle, - int maxTargetsPerRow, - FeatureFlags featureFlags) { - this( - context, - new ChooserProfileAdapterBinder(maxTargetsPerRow), - ImmutableList.of( - new TabConfig<>( - PROFILE_PERSONAL, - personalTabLabel, - personalTabAccessibilityLabel, - personalTabTag, - personalAdapter)), - emptyStateProvider, - workProfileQuietModeChecker, - /* defaultProfile= */ 0, - workProfileUserHandle, - cloneProfileUserHandle, - new BottomPaddingOverrideSupplier(context), - featureFlags); - } - - public ChooserMultiProfilePagerAdapter( - Context context, - String personalTabLabel, - String personalTabAccessibilityLabel, - String personalTabTag, - ChooserGridAdapter personalAdapter, - String workTabLabel, - String workTabAccessibilityLabel, - String workTabTag, - ChooserGridAdapter workAdapter, + ImmutableList> tabs, EmptyStateProvider emptyStateProvider, Supplier workProfileQuietModeChecker, @ProfileType int defaultProfile, @@ -99,19 +61,7 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< this( context, new ChooserProfileAdapterBinder(maxTargetsPerRow), - ImmutableList.of( - new TabConfig<>( - PROFILE_PERSONAL, - personalTabLabel, - personalTabAccessibilityLabel, - personalTabTag, - personalAdapter), - new TabConfig<>( - PROFILE_WORK, - workTabLabel, - workTabAccessibilityLabel, - workTabTag, - workAdapter)), + tabs, emptyStateProvider, workProfileQuietModeChecker, defaultProfile, diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index 1450a883..55e698a6 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -101,6 +101,7 @@ import com.android.intentresolver.icons.TargetDataLoader; import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; import com.android.intentresolver.v2.MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; import com.android.intentresolver.v2.MultiProfilePagerAdapter.ProfileType; +import com.android.intentresolver.v2.MultiProfilePagerAdapter.TabConfig; import com.android.intentresolver.v2.data.repository.DevicePolicyResources; import com.android.intentresolver.v2.emptystate.NoAppsAvailableEmptyStateProvider; import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider; @@ -114,6 +115,8 @@ import com.android.internal.content.PackageMonitor; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto; +import com.google.common.collect.ImmutableList; + import dagger.hilt.android.AndroidEntryPoint; import kotlin.Unit; @@ -954,12 +957,16 @@ public class ResolverActivity extends Hilt_ResolverActivity implements ); return new ResolverMultiProfilePagerAdapter( /* context */ this, - mDevicePolicyResources.getPersonalTabLabel(), - mDevicePolicyResources.getPersonalTabAccessibilityLabel(), - TAB_TAG_PERSONAL, - personalAdapter, + ImmutableList.of( + new TabConfig<>( + PROFILE_PERSONAL, + mDevicePolicyResources.getPersonalTabLabel(), + mDevicePolicyResources.getPersonalTabAccessibilityLabel(), + TAB_TAG_PERSONAL, + personalAdapter)), createEmptyStateProvider(/* workProfileUserHandle= */ null), /* workProfileQuietModeChecker= */ () -> false, + /* defaultProfile= */ PROFILE_PERSONAL, /* workProfileUserHandle= */ null, requireAnnotatedUserHandles().cloneProfileUserHandle); } @@ -1015,14 +1022,19 @@ public class ResolverActivity extends Hilt_ResolverActivity implements ); return new ResolverMultiProfilePagerAdapter( /* context */ this, - mDevicePolicyResources.getPersonalTabLabel(), - mDevicePolicyResources.getPersonalTabAccessibilityLabel(), - TAB_TAG_PERSONAL, - personalAdapter, - mDevicePolicyResources.getWorkTabLabel(), - mDevicePolicyResources.getWorkTabAccessibilityLabel(), - TAB_TAG_WORK, - workAdapter, + ImmutableList.of( + new TabConfig<>( + PROFILE_PERSONAL, + mDevicePolicyResources.getPersonalTabLabel(), + mDevicePolicyResources.getPersonalTabAccessibilityLabel(), + TAB_TAG_PERSONAL, + personalAdapter), + new TabConfig<>( + PROFILE_WORK, + mDevicePolicyResources.getWorkTabLabel(), + mDevicePolicyResources.getWorkTabAccessibilityLabel(), + TAB_TAG_WORK, + workAdapter)), createEmptyStateProvider(workProfileUserHandle), () -> mLogic.getWorkProfileAvailabilityManager().isQuietModeEnabled(), selectedProfile, diff --git a/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java index 9c98d574..c2e1ae07 100644 --- a/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java @@ -40,42 +40,8 @@ public class ResolverMultiProfilePagerAdapter extends MultiProfilePagerAdapter { private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier; - public ResolverMultiProfilePagerAdapter( - Context context, - String personalTabLabel, - String personalTabAccessibilityLabel, - String personalTabTag, - ResolverListAdapter personalAdapter, - EmptyStateProvider emptyStateProvider, - Supplier workProfileQuietModeChecker, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle) { - this( - context, - ImmutableList.of( - new TabConfig<>( - PROFILE_PERSONAL, - personalTabLabel, - personalTabAccessibilityLabel, - personalTabTag, - personalAdapter)), - emptyStateProvider, - workProfileQuietModeChecker, - /* defaultProfile= */ 0, - workProfileUserHandle, - cloneProfileUserHandle, - new BottomPaddingOverrideSupplier()); - } - public ResolverMultiProfilePagerAdapter(Context context, - String personalTabLabel, - String personalTabAccessibilityLabel, - String personalTabTag, - ResolverListAdapter personalAdapter, - String workTabLabel, - String workTabAccessibilityLabel, - String workTabTag, - ResolverListAdapter workAdapter, + ImmutableList> tabs, EmptyStateProvider emptyStateProvider, Supplier workProfileQuietModeChecker, @ProfileType int defaultProfile, @@ -83,19 +49,7 @@ public class ResolverMultiProfilePagerAdapter extends UserHandle cloneProfileUserHandle) { this( context, - ImmutableList.of( - new TabConfig<>( - PROFILE_PERSONAL, - personalTabLabel, - personalTabAccessibilityLabel, - personalTabTag, - personalAdapter), - new TabConfig<>( - PROFILE_WORK, - workTabLabel, - workTabAccessibilityLabel, - workTabTag, - workAdapter)), + tabs, emptyStateProvider, workProfileQuietModeChecker, defaultProfile, -- cgit v1.2.3-59-g8ed1b From 6eaed71f6a9b3a487441c8abb82c2ccb0c714f98 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Fri, 12 Jan 2024 13:06:21 -0500 Subject: Updates UserRepository users to List This is a way to provide a stable iteration order to consumers. Bug: 300157408 Bug: 309960444 Test: atest IntentResolver-tests-unit Change-Id: I502b9744e93288bf1682d009c1e2ba03cfc013a9 --- .../v2/data/repository/UserRepository.kt | 62 +++++++++++----------- .../v2/data/repository/UserRepositoryImplTest.kt | 21 ++++---- 2 files changed, 40 insertions(+), 43 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt b/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt index cbf89fe8..d2011aed 100644 --- a/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt +++ b/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt @@ -21,7 +21,6 @@ import com.android.intentresolver.inject.Main import com.android.intentresolver.inject.ProfileParent import com.android.intentresolver.v2.data.broadcastFlow import com.android.intentresolver.v2.data.model.User -import com.android.intentresolver.v2.data.model.User.Role import com.android.intentresolver.v2.data.repository.UserRepositoryImpl.UserEvent import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject @@ -40,10 +39,10 @@ import kotlinx.coroutines.withContext interface UserRepository { /** - * A [Flow] user profile groups. Each map contains the context user along with all members of + * A [Flow] user profile groups. Each list contains the context user along with all members of * the profile group. This includes the (Full) parent user, if the context user is a profile. */ - val users: Flow> + val users: Flow> /** * A [Flow] of availability. Only profile users may become unavailable. @@ -71,7 +70,7 @@ private const val TAG = "UserRepository" private data class UserWithState(val user: User, val available: Boolean) -private typealias UserStateMap = Map +private typealias UserStates = List /** Tracks and publishes state for the parent user and associated profiles. */ class UserRepositoryImpl @@ -111,15 +110,16 @@ constructor( override val cause: Throwable? = null ) : RuntimeException("$message: event=$event", cause) - private val usersWithState: Flow = + private val sharingScope = CoroutineScope(scope.coroutineContext + backgroundDispatcher) + private val usersWithState: Flow = userEvents .onStart { emit(UserEvent(INITIALIZE, profileParent)) } - .onEach { Log.i("UserDataSource", "userEvent: $it") } - .runningFold(emptyMap()) { users, event -> + .onEach { Log.i(TAG, "userEvent: $it") } + .runningFold(emptyList()) { users, event -> try { // Handle an action by performing some operation, then returning a new map when (event.action) { - INITIALIZE -> createNewUserStateMap(profileParent) + INITIALIZE -> createNewUserStates(profileParent) ACTION_PROFILE_ADDED -> handleProfileAdded(event, users) ACTION_PROFILE_REMOVED -> handleProfileRemoved(event, users) ACTION_MANAGED_PROFILE_UNAVAILABLE, @@ -134,19 +134,21 @@ constructor( } catch (e: UserStateException) { Log.e(TAG, "An error occurred handling an event: ${e.event}", e) Log.e(TAG, "Attempting to recover...") - createNewUserStateMap(profileParent) + createNewUserStates(profileParent) } } - .onEach { Log.i("UserDataSource", "userStateMap: $it") } - .stateIn(scope, SharingStarted.Eagerly, emptyMap()) + .distinctUntilChanged() + .onEach { Log.i(TAG, "userStateList: $it") } + .stateIn(sharingScope, SharingStarted.Eagerly, emptyList()) .filterNot { it.isEmpty() } - override val users: Flow> = usersWithState.map { userStateMap -> - userStateMap.map { it.value.user }.associateBy { it.role } - }.distinctUntilChanged() + override val users: Flow> = + usersWithState.map { userStateMap -> userStateMap.map { it.user } }.distinctUntilChanged() private val availability: Flow> = - usersWithState.map { map -> map.mapValues { it.value.available } }.distinctUntilChanged() + usersWithState + .map { list -> list.associateBy { it.user.handle }.mapValues { it.value.available } } + .distinctUntilChanged() override fun isAvailable(user: User): Flow { return isAvailable(user.handle) @@ -170,42 +172,40 @@ constructor( } } - private fun handleAvailability(event: UserEvent, current: UserStateMap): UserStateMap { + private fun handleAvailability(event: UserEvent, current: UserStates): UserStates { val userEntry = - current[event.user] + current.firstOrNull { it.user.id == event.user.identifier } ?: throw UserStateException("User was not present in the map", event) - return current + (event.user to userEntry.copy(available = !event.quietMode)) + return current + userEntry.copy(available = !event.quietMode) } - private fun handleProfileRemoved(event: UserEvent, current: UserStateMap): UserStateMap { - if (!current.containsKey(event.user)) { + private fun handleProfileRemoved(event: UserEvent, current: UserStates): UserStates { + if (!current.any { it.user.id == event.user.identifier }) { throw UserStateException("User was not present in the map", event) } - return current.filterKeys { it != event.user } + return current.filter { it.user.id != event.user.identifier } } - private suspend fun handleProfileAdded(event: UserEvent, current: UserStateMap): UserStateMap { + private suspend fun handleProfileAdded(event: UserEvent, current: UserStates): UserStates { val user = try { requireNotNull(readUser(event.user)) } catch (e: Exception) { throw UserStateException("Failed to read user from UserManager", event, e) } - return current + (event.user to UserWithState(user, !event.quietMode)) + return current + UserWithState(user, !event.quietMode) } - private suspend fun createNewUserStateMap(user: UserHandle): UserStateMap { + private suspend fun createNewUserStates(user: UserHandle): UserStates { val profiles = readProfileGroup(user) - return profiles - .mapNotNull { userInfo -> - userInfo.toUser()?.let { user -> UserWithState(user, userInfo.isAvailable()) } - } - .associateBy { it.user.handle } + return profiles.mapNotNull { userInfo -> + userInfo.toUser()?.let { user -> UserWithState(user, userInfo.isAvailable()) } + } } - private suspend fun readProfileGroup(handle: UserHandle): List { + private suspend fun readProfileGroup(member: UserHandle): List { return withContext(backgroundDispatcher) { - @Suppress("DEPRECATION") userManager.getEnabledProfiles(handle.identifier) + @Suppress("DEPRECATION") userManager.getEnabledProfiles(member.identifier) } .toList() } diff --git a/tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt b/tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt index 5cfcb872..77f47285 100644 --- a/tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt @@ -34,10 +34,7 @@ internal class UserRepositoryImplTest { assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() assertThat(users) - .containsExactly( - Role.PERSONAL, - User(userState.primaryUserHandle.identifier, Role.PERSONAL) - ) + .containsExactly(User(userState.primaryUserHandle.identifier, Role.PERSONAL)) } @Test @@ -46,10 +43,10 @@ internal class UserRepositoryImplTest { val users by collectLastValue(repo.users) assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() - assertThat(users!!.values.filter { it.role.type == User.Type.PROFILE }).isEmpty() + assertThat(users!!.filter { it.role.type == User.Type.PROFILE }).isEmpty() val profile = userState.createProfile(ProfileType.WORK) - assertThat(users).containsEntry(Role.WORK, User(profile.identifier, Role.WORK)) + assertThat(users).contains(User(profile.identifier, Role.WORK)) } @Test @@ -59,10 +56,10 @@ internal class UserRepositoryImplTest { assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() val work = userState.createProfile(ProfileType.WORK) - assertThat(users).containsEntry(Role.WORK, User(work.identifier, Role.WORK)) + assertThat(users).contains(User(work.identifier, Role.WORK)) userState.removeProfile(work) - assertThat(users).doesNotContainEntry(Role.WORK, User(work.identifier, Role.WORK)) + assertThat(users).doesNotContain(User(work.identifier, Role.WORK)) } @Test @@ -129,7 +126,7 @@ internal class UserRepositoryImplTest { val users by collectLastValue(repo.users) assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() - assertThat(users).containsExactly(Role.PERSONAL, User(USER_SYSTEM, Role.PERSONAL)) + assertThat(users).containsExactly(User(USER_SYSTEM, Role.PERSONAL)) } @Test @@ -154,7 +151,7 @@ internal class UserRepositoryImplTest { val users by collectLastValue(repo.users) assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() - assertThat(users).containsExactly(Role.PERSONAL, User(USER_SYSTEM, Role.PERSONAL)) + assertThat(users).containsExactly(User(USER_SYSTEM, Role.PERSONAL)) } @Test @@ -173,7 +170,7 @@ internal class UserRepositoryImplTest { val users by collectLastValue(repo.users) assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() - assertThat(users).containsExactly(Role.PERSONAL, User(USER_SYSTEM, Role.PERSONAL)) + assertThat(users).containsExactly(User(USER_SYSTEM, Role.PERSONAL)) } @Test @@ -195,7 +192,7 @@ internal class UserRepositoryImplTest { val users by collectLastValue(repo.users) assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() - assertThat(users).containsExactly(Role.PERSONAL, User(USER_SYSTEM, Role.PERSONAL)) + assertThat(users).containsExactly(User(USER_SYSTEM, Role.PERSONAL)) } } -- cgit v1.2.3-59-g8ed1b From c5d725eec15d2cff2aab08437948d6d0f2d01a63 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Tue, 23 Jan 2024 22:20:37 -0500 Subject: Make ProfileRecords map a normal Map Change-Id: Ifb0f34133c2c6c12e985eb6e732acb4f5858675f --- java/src/com/android/intentresolver/v2/ChooserActivity.java | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index eea9fd2c..95d9ea18 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -306,7 +306,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private final View mContentView = null; - private final SparseArray mProfileRecords = new SparseArray<>(); + private final Map mProfileRecords = new HashMap<>(); private boolean mExcludeSharedText = false; /** @@ -1201,7 +1201,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Nullable private ProfileRecord getProfileRecord(UserHandle userHandle) { - return mProfileRecords.get(userHandle.getIdentifier(), null); + return mProfileRecords.get(userHandle.getIdentifier()); } @VisibleForTesting @@ -1556,9 +1556,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private void destroyProfileRecords() { - for (int i = 0; i < mProfileRecords.size(); ++i) { - mProfileRecords.valueAt(i).destroy(); - } + mProfileRecords.values().forEach(ProfileRecord::destroy); mProfileRecords.clear(); } -- cgit v1.2.3-59-g8ed1b From f30cb97a784ba508a82863ef74ea0135355aad0c Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Tue, 23 Jan 2024 12:21:43 -0500 Subject: UserInteractor and Profile A domain component which applies business logic to User data. This component maps Users to Profiles, the set of which is defined by Profile.Type Bug: 309960444 Test: atest IntentResolver-tests-unit:UserInteractorTest \ FakeUserRepositoryTest Change-Id: I9832836ae019ba1b0ae45366f9fc0e26bc9b23ce --- .../android/intentresolver/v2/data/model/User.kt | 50 ------ .../v2/data/repository/UserInfoExt.kt | 4 +- .../v2/data/repository/UserRepository.kt | 24 +-- .../v2/data/repository/UserRepositoryModule.kt | 7 +- .../v2/data/repository/UserScopedService.kt | 2 +- .../v2/domain/interactor/UserInteractor.kt | 92 +++++++++++ .../intentresolver/v2/domain/model/Profile.kt | 53 ++++++ .../android/intentresolver/v2/shared/model/User.kt | 65 ++++++++ .../v2/data/repository/FakeUserRepository.kt | 61 +++++++ .../v2/data/repository/FakeUserRepositoryTest.kt | 108 +++++++++++++ .../v2/data/repository/UserRepositoryImplTest.kt | 26 +-- .../v2/domain/interactor/UserInteractorTest.kt | 179 +++++++++++++++++++++ 12 files changed, 585 insertions(+), 86 deletions(-) delete mode 100644 java/src/com/android/intentresolver/v2/data/model/User.kt create mode 100644 java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.kt create mode 100644 java/src/com/android/intentresolver/v2/domain/model/Profile.kt create mode 100644 java/src/com/android/intentresolver/v2/shared/model/User.kt create mode 100644 tests/shared/src/com/android/intentresolver/v2/data/repository/FakeUserRepository.kt create mode 100644 tests/unit/src/com/android/intentresolver/v2/data/repository/FakeUserRepositoryTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/v2/domain/interactor/UserInteractorTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/data/model/User.kt b/java/src/com/android/intentresolver/v2/data/model/User.kt deleted file mode 100644 index 504b04c8..00000000 --- a/java/src/com/android/intentresolver/v2/data/model/User.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.android.intentresolver.v2.data.model - -import android.annotation.UserIdInt -import android.os.UserHandle -import com.android.intentresolver.v2.data.model.User.Type -import com.android.intentresolver.v2.data.model.User.Type.FULL -import com.android.intentresolver.v2.data.model.User.Type.PROFILE - -/** - * A User represents the owner of a distinct set of content. - * * maps 1:1 to a UserHandle or UserId (Int) value. - * * refers to either [Full][Type.FULL], or a [Profile][Type.PROFILE] user, as indicated by the - * [type] property. - * - * See - * [Users for system developers](https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/os/Users.md) - * - * ``` - * val users = listOf( - * User(id = 0, role = PERSONAL), - * User(id = 10, role = WORK), - * User(id = 11, role = CLONE), - * User(id = 12, role = PRIVATE), - * ) - * ``` - */ -data class User( - @UserIdInt val id: Int, - val role: Role, -) { - val handle: UserHandle = UserHandle.of(id) - - val type: Type - get() = role.type - - enum class Type { - FULL, - PROFILE - } - - enum class Role( - /** The type of the role user. */ - val type: Type - ) { - PERSONAL(FULL), - PRIVATE(PROFILE), - WORK(PROFILE), - CLONE(PROFILE) - } -} diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt b/java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt index fc82efee..a0b2d1ef 100644 --- a/java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt +++ b/java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt @@ -1,8 +1,8 @@ package com.android.intentresolver.v2.data.repository import android.content.pm.UserInfo -import com.android.intentresolver.v2.data.model.User -import com.android.intentresolver.v2.data.model.User.Role +import com.android.intentresolver.v2.shared.model.User +import com.android.intentresolver.v2.shared.model.User.Role /** Maps the UserInfo to one of the defined [Roles][User.Role], if possible. */ fun UserInfo.getSupportedUserRole(): Role? = diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt b/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt index d2011aed..91ad6409 100644 --- a/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt +++ b/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt @@ -20,8 +20,8 @@ import com.android.intentresolver.inject.Background import com.android.intentresolver.inject.Main import com.android.intentresolver.inject.ProfileParent import com.android.intentresolver.v2.data.broadcastFlow -import com.android.intentresolver.v2.data.model.User import com.android.intentresolver.v2.data.repository.UserRepositoryImpl.UserEvent +import com.android.intentresolver.v2.shared.model.User import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher @@ -49,7 +49,7 @@ interface UserRepository { * * Availability is currently defined as not being in [quietMode][UserInfo.isQuietModeEnabled]. */ - fun isAvailable(user: User): Flow + val availability: Flow> /** * Request that availability be updated to the requested state. This currently includes toggling @@ -145,30 +145,16 @@ constructor( override val users: Flow> = usersWithState.map { userStateMap -> userStateMap.map { it.user } }.distinctUntilChanged() - private val availability: Flow> = + override val availability: Flow> = usersWithState - .map { list -> list.associateBy { it.user.handle }.mapValues { it.value.available } } + .map { list -> list.associate { it.user to it.available } } .distinctUntilChanged() - override fun isAvailable(user: User): Flow { - return isAvailable(user.handle) - } - - @VisibleForTesting - fun isAvailable(handle: UserHandle): Flow { - return availability.map { it[handle] ?: false } - } - override suspend fun requestState(user: User, available: Boolean) { require(user.type == User.Type.PROFILE) { "Only profile users are supported" } - return requestState(user.handle, available) - } - - @VisibleForTesting - suspend fun requestState(user: UserHandle, available: Boolean) { return withContext(backgroundDispatcher) { Log.i(TAG, "requestQuietModeEnabled: ${!available} for user $user") - userManager.requestQuietModeEnabled(/* enableQuietMode = */ !available, user) + userManager.requestQuietModeEnabled(/* enableQuietMode = */ !available, user.handle) } } diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt b/java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt index 94f985e7..a84342f4 100644 --- a/java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt +++ b/java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt @@ -25,8 +25,11 @@ interface UserRepositoryModule { @Provides @Singleton @ProfileParent - fun profileParent(@ApplicationUser user: UserHandle, userManager: UserManager): UserHandle { - return userManager.getProfileParent(user) ?: user + fun profileParent( + @ApplicationContext context: Context, + userManager: UserManager + ): UserHandle { + return userManager.getProfileParent(context.user) ?: context.user } } diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt b/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt index 7ee78d91..3553744a 100644 --- a/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt +++ b/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt @@ -2,7 +2,7 @@ package com.android.intentresolver.v2.data.repository import android.content.Context import androidx.core.content.getSystemService -import com.android.intentresolver.v2.data.model.User +import com.android.intentresolver.v2.shared.model.User /** * Provides cached instances of a [system service][Context.getSystemService] created with diff --git a/java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.kt b/java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.kt new file mode 100644 index 00000000..e1b3fb36 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.kt @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2024 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.v2.domain.interactor + +import android.os.UserHandle +import com.android.intentresolver.inject.ApplicationUser +import com.android.intentresolver.v2.data.repository.UserRepository +import com.android.intentresolver.v2.domain.model.Profile +import com.android.intentresolver.v2.domain.model.Profile.Type +import com.android.intentresolver.v2.shared.model.User +import com.android.intentresolver.v2.shared.model.User.Role +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull + +/** The high level User interface. */ +class UserInteractor +@Inject +constructor( + private val userRepository: UserRepository, + /** The specific [User] of the application which started this one. */ + @ApplicationUser val launchedAs: UserHandle, +) { + /** The profile group associated with the launching app user. */ + val profiles: Flow> = + userRepository.users.map { users -> + users.mapNotNull { user -> + when (user.role) { + // PERSONAL includes CLONE + Role.PERSONAL -> { + Profile(Type.PERSONAL, user, users.firstOrNull { it.role == Role.CLONE }) + } + Role.CLONE -> { + /* ignore, included above */ + null + } + // others map 1:1 + else -> Profile(profileFromRole(user.role), user) + } + } + } + + /** The [Profile] of the application which started this one. */ + val launchedAsProfile: Flow = + profiles.map { profiles -> + // The launching user profile is the one with a primary id or clone id + // matching the application user id. By definition there must always be exactly + // one matching profile for the current user. + profiles.single { + it.primary.id == launchedAs.identifier || it.clone?.id == launchedAs.identifier + } + } + + /** + * Provides a flow to report on the availability of the profile. An unavailable profile may be + * hidden or appear disabled within the app. + */ + fun isAvailable(type: Type): Flow { + val profileFlow = profiles.map { list -> list.firstOrNull { it.type == type } } + return combine(profileFlow, userRepository.availability) { profile, availability -> + when (profile) { + null -> false + else -> availability.getOrDefault(profile.primary, false) + } + } + } + + private fun profileFromRole(role: Role): Type = + when (role) { + Role.PERSONAL -> Type.PERSONAL + Role.CLONE -> Type.PERSONAL /* CLONE maps to PERSONAL */ + Role.PRIVATE -> Type.PRIVATE + Role.WORK -> Type.WORK + } +} diff --git a/java/src/com/android/intentresolver/v2/domain/model/Profile.kt b/java/src/com/android/intentresolver/v2/domain/model/Profile.kt new file mode 100644 index 00000000..46015c7a --- /dev/null +++ b/java/src/com/android/intentresolver/v2/domain/model/Profile.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2024 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.v2.domain.model + +import com.android.intentresolver.v2.domain.model.Profile.Type +import com.android.intentresolver.v2.shared.model.User + +/** + * A domain layer model which associates [users][User] into a [Type] instance. + * + * This is a simple abstraction which combines a primary [user][User] with an optional + * [cloned apps][User.Role.CLONE] user. This encapsulates the cloned app user id, while still being + * available where needed. + */ +data class Profile( + val type: Type, + val primary: User, + /** + * An optional [User] of which contains second instances of some applications installed for the + * personal user. This value may only be supplied when creating the PERSONAL profile. + */ + val clone: User? = null +) { + + init { + clone?.apply { + require(primary.role == User.Role.PERSONAL) { + "clone is not supported for profile=${this@Profile.type} / primary=$primary" + } + require(role == User.Role.CLONE) { "clone is not a clone user ($this)" } + } + } + + enum class Type { + PERSONAL, + WORK, + PRIVATE + } +} diff --git a/java/src/com/android/intentresolver/v2/shared/model/User.kt b/java/src/com/android/intentresolver/v2/shared/model/User.kt new file mode 100644 index 00000000..97db3280 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/shared/model/User.kt @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2024 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.v2.shared.model + +import android.annotation.UserIdInt +import android.os.UserHandle +import com.android.intentresolver.v2.shared.model.User.Type.FULL +import com.android.intentresolver.v2.shared.model.User.Type.PROFILE + +/** + * A User represents the owner of a distinct set of content. + * * maps 1:1 to a UserHandle or UserId (Int) value. + * * refers to either [Full][Type.FULL], or a [Profile][Type.PROFILE] user, as indicated by the + * [type] property. + * + * See + * [Users for system developers](https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/os/Users.md) + * + * ``` + * val users = listOf( + * User(id = 0, role = PERSONAL), + * User(id = 10, role = WORK), + * User(id = 11, role = CLONE), + * User(id = 12, role = PRIVATE), + * ) + * ``` + */ +data class User( + @UserIdInt val id: Int, + val role: Role, +) { + val handle: UserHandle = UserHandle.of(id) + + val type: Type + get() = role.type + + enum class Type { + FULL, + PROFILE + } + + enum class Role( + /** The type of the role user. */ + val type: Type + ) { + PERSONAL(FULL), + PRIVATE(PROFILE), + WORK(PROFILE), + CLONE(PROFILE) + } +} diff --git a/tests/shared/src/com/android/intentresolver/v2/data/repository/FakeUserRepository.kt b/tests/shared/src/com/android/intentresolver/v2/data/repository/FakeUserRepository.kt new file mode 100644 index 00000000..5ed6f506 --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/v2/data/repository/FakeUserRepository.kt @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2024 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.v2.data.repository + +import com.android.intentresolver.v2.shared.model.User +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update + +/** A simple repository which can be initialized from a list and updated. */ +class FakeUserRepository(vararg userList: User) : UserRepository { + internal data class UserState(val user: User, val available: Boolean) + + private val userState = MutableStateFlow(userList.map { UserState(it, available = true) }) + + // Expose a List from List + override val users = userState.map { userList -> userList.map { it.user } } + + fun addUser(user: User, available: Boolean) { + require(userState.value.none { it.user.id == user.id }) { + "A User with ${user.id} already exists!" + } + userState.update { it + UserState(user, available) } + } + + fun removeUser(user: User) { + require(userState.value.any { it.user.id == user.id }) { + "A User with ${user.id} does not exist!" + } + userState.update { it.filterNot { state -> state.user.id == user.id } } + } + + override val availability = + userState.map { userStateList -> userStateList.associate { it.user to it.available } } + + override suspend fun requestState(user: User, available: Boolean) { + userState.update { userStateList -> + userStateList.map { userState -> + if (userState.user.id == user.id) { + UserState(user, available) + } else { + userState + } + } + } + } +} diff --git a/tests/unit/src/com/android/intentresolver/v2/data/repository/FakeUserRepositoryTest.kt b/tests/unit/src/com/android/intentresolver/v2/data/repository/FakeUserRepositoryTest.kt new file mode 100644 index 00000000..334f31ad --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/v2/data/repository/FakeUserRepositoryTest.kt @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2024 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.v2.data.repository + +import com.android.intentresolver.v2.coroutines.collectLastValue +import com.android.intentresolver.v2.shared.model.User +import com.google.common.truth.Truth.assertThat +import kotlin.random.Random +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class FakeUserRepositoryTest { + private val baseId = Random.nextInt(1000, 2000) + + private val personalUser = User(id = baseId, role = User.Role.PERSONAL) + private val cloneUser = User(id = baseId + 1, role = User.Role.CLONE) + private val workUser = User(id = baseId + 2, role = User.Role.WORK) + private val privateUser = User(id = baseId + 3, role = User.Role.PRIVATE) + + @Test + fun init() = runTest { + val repo = FakeUserRepository(personalUser, workUser, privateUser) + + val users by collectLastValue(repo.users) + assertThat(users).containsExactly(personalUser, workUser, privateUser) + } + + @Test + fun addUser() = runTest { + val repo = FakeUserRepository() + + val users by collectLastValue(repo.users) + assertThat(users).isEmpty() + + repo.addUser(personalUser, true) + assertThat(users).containsExactly(personalUser) + + repo.addUser(workUser, false) + assertThat(users).containsExactly(personalUser, workUser) + } + + @Test + fun removeUser() = runTest { + val repo = FakeUserRepository(personalUser, workUser) + + val users by collectLastValue(repo.users) + repo.removeUser(workUser) + assertThat(users).containsExactly(personalUser) + + repo.removeUser(personalUser) + assertThat(users).isEmpty() + } + + @Test + fun isAvailable_defaultValue() = runTest { + val repo = FakeUserRepository(personalUser, workUser) + + val available by collectLastValue(repo.availability) + + repo.requestState(workUser, false) + assertThat(available!![workUser]).isFalse() + + repo.requestState(workUser, true) + assertThat(available!![workUser]).isTrue() + } + + @Test + fun isAvailable() = runTest { + val repo = FakeUserRepository(personalUser, workUser) + + val available by collectLastValue(repo.availability) + assertThat(available!![workUser]).isTrue() + + repo.requestState(workUser, false) + assertThat(available!![workUser]).isFalse() + + repo.requestState(workUser, true) + assertThat(available!![workUser]).isTrue() + } + + @Test + fun isAvailable_addRemove() = runTest { + val repo = FakeUserRepository(personalUser, workUser) + + val available by collectLastValue(repo.availability) + assertThat(available!![workUser]).isTrue() + + repo.removeUser(workUser) + assertThat(available!![workUser]).isNull() + + repo.addUser(workUser, true) + assertThat(available!![workUser]).isTrue() + } +} diff --git a/tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt b/tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt index 77f47285..6c61dfd6 100644 --- a/tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt @@ -8,10 +8,10 @@ import android.os.UserHandle.USER_SYSTEM import android.os.UserManager import com.android.intentresolver.mock import com.android.intentresolver.v2.coroutines.collectLastValue -import com.android.intentresolver.v2.data.model.User -import com.android.intentresolver.v2.data.model.User.Role import com.android.intentresolver.v2.platform.FakeUserManager import com.android.intentresolver.v2.platform.FakeUserManager.ProfileType +import com.android.intentresolver.v2.shared.model.User +import com.android.intentresolver.v2.shared.model.User.Role import com.android.intentresolver.whenever import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage @@ -66,30 +66,32 @@ internal class UserRepositoryImplTest { fun isAvailable() = runTest { val repo = createUserRepository(userManager) val work = userState.createProfile(ProfileType.WORK) + val workUser = User(work.identifier, Role.WORK) - val available by collectLastValue(repo.isAvailable(work)) - assertThat(available).isTrue() + val available by collectLastValue(repo.availability) + assertThat(available?.get(workUser)).isTrue() userState.setQuietMode(work, true) - assertThat(available).isFalse() + assertThat(available?.get(workUser)).isFalse() userState.setQuietMode(work, false) - assertThat(available).isTrue() + assertThat(available?.get(workUser)).isTrue() } @Test fun requestState() = runTest { val repo = createUserRepository(userManager) val work = userState.createProfile(ProfileType.WORK) + val workUser = User(work.identifier, Role.WORK) - val available by collectLastValue(repo.isAvailable(work)) - assertThat(available).isTrue() + val available by collectLastValue(repo.availability) + assertThat(available?.get(workUser)).isTrue() - repo.requestState(work, false) - assertThat(available).isFalse() + repo.requestState(workUser, false) + assertThat(available?.get(workUser)).isFalse() - repo.requestState(work, true) - assertThat(available).isTrue() + repo.requestState(workUser, true) + assertThat(available?.get(workUser)).isTrue() } @Test(expected = IllegalArgumentException::class) diff --git a/tests/unit/src/com/android/intentresolver/v2/domain/interactor/UserInteractorTest.kt b/tests/unit/src/com/android/intentresolver/v2/domain/interactor/UserInteractorTest.kt new file mode 100644 index 00000000..6fa055ef --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/v2/domain/interactor/UserInteractorTest.kt @@ -0,0 +1,179 @@ +/* + * Copyright (C) 2024 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.v2.domain.interactor + +import com.android.intentresolver.v2.coroutines.collectLastValue +import com.android.intentresolver.v2.data.repository.FakeUserRepository +import com.android.intentresolver.v2.domain.model.Profile +import com.android.intentresolver.v2.domain.model.Profile.Type.PERSONAL +import com.android.intentresolver.v2.domain.model.Profile.Type.PRIVATE +import com.android.intentresolver.v2.domain.model.Profile.Type.WORK +import com.android.intentresolver.v2.shared.model.User +import com.android.intentresolver.v2.shared.model.User.Role +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import kotlin.random.Random +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class UserInteractorTest { + private val baseId = Random.nextInt(1000, 2000) + + private val personalUser = User(id = baseId, role = Role.PERSONAL) + private val cloneUser = User(id = baseId + 1, role = Role.CLONE) + private val workUser = User(id = baseId + 2, role = Role.WORK) + private val privateUser = User(id = baseId + 3, role = Role.PRIVATE) + + @Test + fun launchedByProfile(): Unit = runTest { + val profileInteractor = + UserInteractor( + userRepository = FakeUserRepository(personalUser, cloneUser), + launchedAs = personalUser.handle + ) + + val launchedAsProfile by collectLastValue(profileInteractor.launchedAsProfile) + + assertThat(launchedAsProfile).isEqualTo(Profile(PERSONAL, personalUser, cloneUser)) + } + + @Test + fun launchedByProfile_asClone(): Unit = runTest { + val profileInteractor = + UserInteractor( + userRepository = FakeUserRepository(personalUser, cloneUser), + launchedAs = cloneUser.handle + ) + val profiles by collectLastValue(profileInteractor.launchedAsProfile) + + assertThat(profiles).isEqualTo(Profile(PERSONAL, personalUser, cloneUser)) + } + + @Test + fun profiles_withPersonal(): Unit = runTest { + val profileInteractor = + UserInteractor( + userRepository = FakeUserRepository(personalUser), + launchedAs = personalUser.handle + ) + + val profiles by collectLastValue(profileInteractor.profiles) + + assertThat(profiles).containsExactly(Profile(PERSONAL, personalUser)) + } + + @Test + fun profiles_addClone(): Unit = runTest { + val fakeUserRepo = FakeUserRepository(personalUser) + val profileInteractor = + UserInteractor(userRepository = fakeUserRepo, launchedAs = personalUser.handle) + + val profiles by collectLastValue(profileInteractor.profiles) + assertThat(profiles).containsExactly(Profile(PERSONAL, personalUser)) + + fakeUserRepo.addUser(cloneUser, available = true) + assertThat(profiles).containsExactly(Profile(PERSONAL, personalUser, cloneUser)) + } + + @Test + fun profiles_withPersonalAndClone(): Unit = runTest { + val profileInteractor = + UserInteractor( + userRepository = FakeUserRepository(personalUser, cloneUser), + launchedAs = personalUser.handle + ) + val profiles by collectLastValue(profileInteractor.profiles) + + assertThat(profiles).containsExactly(Profile(PERSONAL, personalUser, cloneUser)) + } + + @Test + fun profiles_withAllSupportedTypes(): Unit = runTest { + val profileInteractor = + UserInteractor( + userRepository = FakeUserRepository(personalUser, cloneUser, workUser, privateUser), + launchedAs = personalUser.handle + ) + val profiles by collectLastValue(profileInteractor.profiles) + + assertThat(profiles) + .containsExactly( + Profile(PERSONAL, personalUser, cloneUser), + Profile(WORK, workUser), + Profile(PRIVATE, privateUser) + ) + } + + @Test + fun profiles_preservesIterationOrder(): Unit = runTest { + val profileInteractor = + UserInteractor( + userRepository = FakeUserRepository(workUser, cloneUser, privateUser, personalUser), + launchedAs = personalUser.handle + ) + + val profiles by collectLastValue(profileInteractor.profiles) + + assertThat(profiles) + .containsExactly( + Profile(WORK, workUser), + Profile(PRIVATE, privateUser), + Profile(PERSONAL, personalUser, cloneUser), + ) + } + + @Test + fun isAvailable_defaultValue() = runTest { + val userRepo = FakeUserRepository(personalUser) + userRepo.addUser(workUser, false) + + val profileInteractor = + UserInteractor(userRepository = userRepo, launchedAs = personalUser.handle) + val personalAvailable by collectLastValue(profileInteractor.isAvailable(PERSONAL)) + val workAvailable by collectLastValue(profileInteractor.isAvailable(WORK)) + + assertWithMessage("personalAvailable").that(personalAvailable!!).isTrue() + + assertWithMessage("workAvailable").that(workAvailable!!).isFalse() + } + + @Test + fun isAvailable() = runTest { + val userRepo = FakeUserRepository(workUser, personalUser) + val profileInteractor = + UserInteractor(userRepository = userRepo, launchedAs = personalUser.handle) + val workAvailable by collectLastValue(profileInteractor.isAvailable(WORK)) + + // Default state is enabled in FakeUserManager + assertWithMessage("workAvailable").that(workAvailable).isTrue() + + // Making user unavailable makes profile unavailable + userRepo.requestState(workUser, false) + assertWithMessage("workAvailable").that(workAvailable).isFalse() + + // Making user available makes profile available again + userRepo.requestState(workUser, true) + assertWithMessage("workAvailable").that(workAvailable).isTrue() + + // When a user is removed availability should update to false + userRepo.removeUser(workUser) + assertWithMessage("workAvailable").that(workAvailable).isFalse() + } +} -- cgit v1.2.3-59-g8ed1b From 7b468b86871b26e164c9bf8163fe7ca810ddbe08 Mon Sep 17 00:00:00 2001 From: Adam Bookatz Date: Fri, 26 Jan 2024 13:26:01 -0800 Subject: ChooserAdapter: fix NPE for non-work profiles For some non-work profiles (Communal, and maybe Private), the getListAdapterForUserHandle is returning null. The method is indeed nullable. But we run getCount on it without doing a null-check, causing crashes for such profiles. This previously couldn't have happened, since shouldShowTabs() returns false in these cases, and the evaluation was skipped; however, ag/I8273cf365a1e00b1acff4030086f1a044ad7531f moved that check to later, so we are hitting this problem. Bug: 322523699 Bug: 320369524 Bug: 320615143 Bug: 322497953 Test: atest IntentResolver-tests-unit Change-Id: I82303d6a6a1be1066f5c3e05c53f6a70d486bf5a --- java/src/com/android/intentresolver/ChooserActivity.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 82e46a57..e30c198a 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -1651,8 +1651,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (!shouldShowContentPreview()) { return false; } - boolean isEmpty = mMultiProfilePagerAdapter.getListAdapterForUserHandle( - UserHandle.of(UserHandle.myUserId())).getCount() == 0; + ResolverListAdapter adapter = mMultiProfilePagerAdapter.getListAdapterForUserHandle( + UserHandle.of(UserHandle.myUserId())); + boolean isEmpty = adapter == null || adapter.getCount() == 0; return (mFeatureFlags.scrollablePreview() || shouldShowTabs()) && (!isEmpty || shouldShowContentPreviewWhenEmpty()); } -- cgit v1.2.3-59-g8ed1b From f0c6a508f930c7dab1ec95fa6b322abdc6609bbe Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Fri, 26 Jan 2024 12:03:59 -0500 Subject: Rename CallerInfo -> ActivityLaunch and inject Rename CallerInfo to ActivityLaunch and renames some existing properties as appropriate for clarity. Adds the [Intent] from the activity. This completes the set of info that is needed as inputs from the caller, containing the extras with all request parameters. Updates readChooserRequest require only an ActivityLaunchInfo instance. Injects ActivityLaunch into usage sites. This removes the direct link of reading acitivty.intent, and will allow writing test code which operates directly on inputs without starting an activity. Introduces an extension method to minimize duplicated code in the activities: CreationExtras.addDefaultArgs: Bug: 300157408 Test: atest IntentResolver-tests-activity Test: atest IntentResolver-tests-unit:ActivityLaunchTest Change-Id: Ie132cb3d61e139e03316063186c3ad79d2c488ef --- .../android/intentresolver/v2/ChooserActivity.java | 30 +++++----- .../intentresolver/v2/ResolverActivity.java | 32 ++++++++--- .../intentresolver/v2/ext/CreationExtrasExt.kt | 31 ++++++++++ .../com/android/intentresolver/v2/ext/ParcelExt.kt | 27 +++++++++ .../intentresolver/v2/ui/model/ActivityLaunch.kt | 65 +++++++++++++++++++++ .../v2/ui/model/ActivityLaunchModule.kt | 40 +++++++++++++ .../intentresolver/v2/ui/model/CallerInfo.kt | 59 ------------------- .../v2/ui/viewmodel/ChooserRequestReader.kt | 16 ++++-- .../v2/ui/viewmodel/ChooserViewModel.kt | 17 +++--- .../intentresolver/ChooserWrapperActivity.java | 7 --- .../intentresolver/v2/ChooserWrapperActivity.java | 7 --- .../v2/ui/model/TestActivityLaunchModule.kt | 41 +++++++++++++ .../android/intentresolver/v2/ext/ParcelableExt.kt | 45 +++++++++++++++ .../v2/ui/model/ActivityLaunchTest.kt | 67 ++++++++++++++++++++++ .../v2/ui/viewmodel/ChooserRequestTest.kt | 23 ++++---- 15 files changed, 384 insertions(+), 123 deletions(-) create mode 100644 java/src/com/android/intentresolver/v2/ext/CreationExtrasExt.kt create mode 100644 java/src/com/android/intentresolver/v2/ext/ParcelExt.kt create mode 100644 java/src/com/android/intentresolver/v2/ui/model/ActivityLaunch.kt create mode 100644 java/src/com/android/intentresolver/v2/ui/model/ActivityLaunchModule.kt delete mode 100644 java/src/com/android/intentresolver/v2/ui/model/CallerInfo.kt create mode 100644 tests/activity/src/com/android/intentresolver/v2/ui/model/TestActivityLaunchModule.kt create mode 100644 tests/shared/src/com/android/intentresolver/v2/ext/ParcelableExt.kt create mode 100644 tests/unit/src/com/android/intentresolver/v2/ui/model/ActivityLaunchTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 95d9ea18..c1184a80 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -29,6 +29,7 @@ import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTE import static androidx.lifecycle.LifecycleKt.getCoroutineScope; +import static com.android.intentresolver.v2.ext.CreationExtrasExtKt.addDefaultArgs; import static com.android.internal.annotations.VisibleForTesting.Visibility.PROTECTED; import static com.android.internal.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET; @@ -75,7 +76,6 @@ import android.stats.devicepolicy.DevicePolicyEnums; import android.text.TextUtils; import android.util.Log; import android.util.Slog; -import android.util.SparseArray; import android.view.Gravity; import android.view.LayoutInflater; import android.view.View; @@ -95,7 +95,6 @@ import androidx.annotation.MainThread; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.FragmentActivity; -import androidx.lifecycle.SavedStateHandleSupport; import androidx.lifecycle.ViewModelProvider; import androidx.lifecycle.viewmodel.CreationExtras; import androidx.recyclerview.widget.GridLayoutManager; @@ -149,7 +148,7 @@ import com.android.intentresolver.v2.platform.AppPredictionAvailable; import com.android.intentresolver.v2.platform.ImageEditor; import com.android.intentresolver.v2.platform.NearbyShare; import com.android.intentresolver.v2.ui.ActionTitle; -import com.android.intentresolver.v2.ui.model.CallerInfo; +import com.android.intentresolver.v2.ui.model.ActivityLaunch; import com.android.intentresolver.v2.ui.model.ChooserRequest; import com.android.intentresolver.v2.ui.viewmodel.ChooserViewModel; import com.android.intentresolver.widget.ImagePreviewView; @@ -164,6 +163,7 @@ import com.google.common.collect.ImmutableList; import dagger.hilt.android.AndroidEntryPoint; +import kotlin.Pair; import kotlin.Unit; import java.util.ArrayList; @@ -265,6 +265,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private static final int SCROLL_STATUS_SCROLLING_VERTICAL = 1; private static final int SCROLL_STATUS_SCROLLING_HORIZONTAL = 2; + @Inject public ActivityLaunch mActivityLaunch; @Inject public FeatureFlags mFeatureFlags; @Inject public EventLog mEventLog; @Inject @AppPredictionAvailable public boolean mAppPredictionAvailable; @@ -332,20 +333,21 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @NonNull @Override public CreationExtras getDefaultViewModelCreationExtras() { - CreationExtras extras = super.getDefaultViewModelCreationExtras(); - // Inserts a CallerInfo into the Bundle at stored at DEFAULT_ARGS_KEY - Bundle defaultArgs = requireNonNull(extras.get(SavedStateHandleSupport.DEFAULT_ARGS_KEY)); - defaultArgs.putParcelable(CallerInfo.SAVED_STATE_HANDLE_KEY, - new CallerInfo(getLaunchedFromUid(), - getLaunchedFromPackage(), - requireNonNull(getReferrer()))); - return extras; + return addDefaultArgs( + super.getDefaultViewModelCreationExtras(), + new Pair<>(ActivityLaunch.ACTIVITY_LAUNCH_KEY, mActivityLaunch)); } @Override protected final void onCreate(Bundle savedInstanceState) { - Log.d(TAG, "onCreate"); super.onCreate(savedInstanceState); + Log.i(TAG, "onCreate"); + Log.i(TAG, "activityLaunch=" + mActivityLaunch.toString()); + int callerUid = mActivityLaunch.getFromUid(); + if (callerUid < 0 || UserHandle.isIsolated(callerUid)) { + Log.e(TAG, "Can't start a resolver from uid " + callerUid); + finish(); + } setTheme(R.style.Theme_DeviceDefault_Chooser); Tracer.INSTANCE.markLaunched(); mViewModel = new ViewModelProvider(this).get(ChooserViewModel.class); @@ -824,7 +826,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } } catch (RuntimeException e) { Slog.wtf(TAG, - "Unable to launch as uid " + requireAnnotatedUserHandles().userIdOfCallingApp + "Unable to launch as uid " + mActivityLaunch.getFromUid() + " package " + getLaunchedFromPackage() + ", while running in " + ActivityThread.currentProcessName(), e); } @@ -2103,7 +2105,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mPackageManager, mLogic.getTargetIntent(), mLogic.getReferrerPackageName(), - requireAnnotatedUserHandles().userIdOfCallingApp, + mActivityLaunch.getFromUid(), resolverComparator, getQueryIntentsUser(userHandle)); } diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index 55e698a6..a308ea14 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -24,6 +24,7 @@ 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.intentresolver.v2.ext.CreationExtrasExtKt.addDefaultArgs; import static com.android.internal.annotations.VisibleForTesting.Visibility.PROTECTED; import static java.util.Collections.emptyList; @@ -83,6 +84,7 @@ import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.viewmodel.CreationExtras; import androidx.viewpager.widget.ViewPager; import com.android.intentresolver.AnnotatedUserHandles; @@ -109,6 +111,7 @@ import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider import com.android.intentresolver.v2.emptystate.WorkProfilePausedEmptyStateProvider; import com.android.intentresolver.v2.ext.IntentExtKt; import com.android.intentresolver.v2.ui.ActionTitle; +import com.android.intentresolver.v2.ui.model.ActivityLaunch; import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.content.PackageMonitor; @@ -119,6 +122,7 @@ import com.google.common.collect.ImmutableList; import dagger.hilt.android.AndroidEntryPoint; +import kotlin.Pair; import kotlin.Unit; import java.util.ArrayList; @@ -140,6 +144,7 @@ import javax.inject.Inject; public class ResolverActivity extends Hilt_ResolverActivity implements ResolverListAdapter.ResolverListCommunicator { + @Inject public ActivityLaunch mActivityLaunch; @Inject public DevicePolicyResources mDevicePolicyResources; @Inject public IntentForwarding mIntentForwarding; @@ -235,10 +240,26 @@ public class ResolverActivity extends Hilt_ResolverActivity implements this::onWorkProfileStatusUpdated); } + @NonNull + @Override + public CreationExtras getDefaultViewModelCreationExtras() { + return addDefaultArgs( + super.getDefaultViewModelCreationExtras(), + new Pair<>(ActivityLaunch.ACTIVITY_LAUNCH_KEY, mActivityLaunch)); + } + @Override protected final void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setTheme(R.style.Theme_DeviceDefault_Resolver); + Log.i(TAG, "onCreate"); + Log.i(TAG, "activityLaunch=" + mActivityLaunch.toString()); + int callerUid = mActivityLaunch.getFromUid(); + if (callerUid < 0 || UserHandle.isIsolated(callerUid)) { + Log.e(TAG, "Can't start a resolver from uid " + callerUid); + finish(); + } + mLogic = createActivityLogic(); mResolvingHome = IntentExtKt.isHomeIntent(getIntent()); mTargetDataLoader = new DefaultTargetDataLoader( @@ -256,13 +277,6 @@ public class ResolverActivity extends Hilt_ResolverActivity implements Intent intent = mLogic.getTargetIntent(); List initialIntents = mLogic.getInitialIntents(); - // Calling UID did not have valid permissions - if (mLogic.getAnnotatedUserHandles() == null) { - finish(); - return; - } - - // The last argument of createResolverListAdapter is whether to do special handling // of the last used choice to highlight it in the list. We need to always // turn this off when running under voice interaction, since it results in @@ -760,7 +774,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements getPackageManager(), mLogic.getTargetIntent(), mLogic.getReferrerPackageName(), - requireAnnotatedUserHandles().userIdOfCallingApp, + mActivityLaunch.getFromUid(), resolverComparator, getQueryIntentsUser(userHandle)); } @@ -1486,7 +1500,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements } } catch (RuntimeException e) { Slog.wtf(TAG, - "Unable to launch as uid " + requireAnnotatedUserHandles().userIdOfCallingApp + "Unable to launch as uid " + mActivityLaunch.getFromUid() + " package " + getLaunchedFromPackage() + ", while running in " + ActivityThread.currentProcessName(), e); } diff --git a/java/src/com/android/intentresolver/v2/ext/CreationExtrasExt.kt b/java/src/com/android/intentresolver/v2/ext/CreationExtrasExt.kt new file mode 100644 index 00000000..ebd613f1 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ext/CreationExtrasExt.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2024 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.v2.ext + +import android.os.Bundle +import android.os.Parcelable +import androidx.lifecycle.DEFAULT_ARGS_KEY +import androidx.lifecycle.viewmodel.CreationExtras + +/** Adds one or more key-value pairs to the default Args bundle in this extras instance. */ +fun CreationExtras.addDefaultArgs(vararg values: Pair): CreationExtras { + val defaultArgs: Bundle = get(DEFAULT_ARGS_KEY) ?: Bundle() + for ((key, value) in values) { + defaultArgs.putParcelable(key, value) + } + return this +} diff --git a/java/src/com/android/intentresolver/v2/ext/ParcelExt.kt b/java/src/com/android/intentresolver/v2/ext/ParcelExt.kt new file mode 100644 index 00000000..b0ec97f4 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ext/ParcelExt.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2024 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.v2.ext + +import android.os.Parcel + +inline fun Parcel.requireParcelable(): T { + return requireNotNull(readParcelable()) { "A non-value required from this parcel was null!" } +} + +inline fun Parcel.readParcelable(): T? { + return readParcelable(T::class.java.classLoader, T::class.java) +} diff --git a/java/src/com/android/intentresolver/v2/ui/model/ActivityLaunch.kt b/java/src/com/android/intentresolver/v2/ui/model/ActivityLaunch.kt new file mode 100644 index 00000000..fd25ea42 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ui/model/ActivityLaunch.kt @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2024 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.v2.ui.model + +import android.content.Intent +import android.net.Uri +import android.os.Parcel +import android.os.Parcelable +import com.android.intentresolver.v2.ext.readParcelable +import com.android.intentresolver.v2.ext.requireParcelable + +/** Contains Activity-scope information about the state at launch time. */ +data class ActivityLaunch( + /** The [Intent] received by the app */ + val intent: Intent, + /** The identifier for the sending app and user */ + val fromUid: Int, + /** The package of the sending app */ + val fromPackage: String?, + /** The referrer as supplied to the activity. */ + val referrer: Uri? +) : Parcelable { + constructor( + source: Parcel + ) : this( + intent = source.requireParcelable(), + fromUid = source.readInt(), + fromPackage = source.readString(), + referrer = source.readParcelable() + ) + + override fun describeContents() = 0 /* flags */ + + override fun writeToParcel(dest: Parcel, flags: Int) { + dest.writeParcelable(intent, flags) + dest.writeInt(fromUid) + dest.writeString(fromPackage) + dest.writeParcelable(referrer, flags) + } + + companion object { + const val ACTIVITY_LAUNCH_KEY = "com.android.intentresolver.ACTIVITY_LAUNCH" + + @JvmField + @Suppress("unused") + val CREATOR = + object : Parcelable.Creator { + override fun newArray(size: Int) = arrayOfNulls(size) + override fun createFromParcel(source: Parcel) = ActivityLaunch(source) + } + } +} diff --git a/java/src/com/android/intentresolver/v2/ui/model/ActivityLaunchModule.kt b/java/src/com/android/intentresolver/v2/ui/model/ActivityLaunchModule.kt new file mode 100644 index 00000000..3311467e --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ui/model/ActivityLaunchModule.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2024 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.v2.ui.model + +import android.app.Activity +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.android.scopes.ActivityScoped + +@Module +@InstallIn(ActivityComponent::class) +object ActivityLaunchModule { + + @Provides + @ActivityScoped + fun callerInfo(activity: Activity): ActivityLaunch { + return ActivityLaunch( + activity.intent, + activity.launchedFromUid, + activity.launchedFromPackage, + activity.referrer + ) + } +} diff --git a/java/src/com/android/intentresolver/v2/ui/model/CallerInfo.kt b/java/src/com/android/intentresolver/v2/ui/model/CallerInfo.kt deleted file mode 100644 index 9addeef2..00000000 --- a/java/src/com/android/intentresolver/v2/ui/model/CallerInfo.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright (C) 2024 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.v2.ui.model - -import android.net.Uri -import android.os.Parcel -import android.os.Parcelable - -data class CallerInfo( - val launchedFromUid: Int, - val launchedFomPackage: String?, - /* logged to metrics, forwarded to outgoing intent */ - val referrer: Uri -) : Parcelable { - constructor( - source: Parcel - ) : this( - launchedFromUid = source.readInt(), - launchedFomPackage = source.readString(), - checkNotNull(source.readParcelable()) - ) - - override fun describeContents() = 0 /* flags */ - - override fun writeToParcel(dest: Parcel, flags: Int) { - dest.writeInt(launchedFromUid) - dest.writeString(launchedFomPackage) - dest.writeParcelable(referrer, 0) - } - - companion object { - const val SAVED_STATE_HANDLE_KEY = "com.android.intentresolver.CALLER_INFO" - - @JvmStatic - @Suppress("unused") - val CREATOR = - object : Parcelable.Creator { - override fun newArray(size: Int) = arrayOfNulls(size) - override fun createFromParcel(source: Parcel) = CallerInfo(source) - } - } -} - -inline fun Parcel.readParcelable(): T? { - return readParcelable(T::class.java.classLoader, T::class.java) -} diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt index 6878be5f..33868aaf 100644 --- a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt +++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt @@ -44,10 +44,11 @@ import com.android.intentresolver.R import com.android.intentresolver.util.hasValidIcon import com.android.intentresolver.v2.ext.hasAction import com.android.intentresolver.v2.ext.ifMatch -import com.android.intentresolver.v2.ui.model.CallerInfo +import com.android.intentresolver.v2.ui.model.ActivityLaunch import com.android.intentresolver.v2.ui.model.ChooserRequest import com.android.intentresolver.v2.ui.model.MAX_CHOOSER_ACTIONS import com.android.intentresolver.v2.ui.model.MAX_INITIAL_INTENTS +import com.android.intentresolver.v2.validation.ValidationResult import com.android.intentresolver.v2.validation.types.IntentOrUri import com.android.intentresolver.v2.validation.types.array import com.android.intentresolver.v2.validation.types.value @@ -61,8 +62,10 @@ internal fun Intent.maybeAddSendActionFlags() = addFlags(FLAG_ACTIVITY_MULTIPLE_TASK) } -fun readChooserRequest(callerInfo: CallerInfo, source: (String) -> Any?) = - validateFrom(source) { +fun readChooserRequest(launch: ActivityLaunch): ValidationResult { + val extras = launch.intent.extras ?: Bundle() + @Suppress("DEPRECATION") + return validateFrom(extras::get) { val targetIntent = required(IntentOrUri(EXTRA_INTENT)).maybeAddSendActionFlags() val isSendAction = targetIntent.hasAction(ACTION_SEND, ACTION_SEND_MULTIPLE) @@ -118,7 +121,7 @@ fun readChooserRequest(callerInfo: CallerInfo, source: (String) -> Any?) = val modifyShareAction = optional(value(EXTRA_CHOOSER_MODIFY_SHARE_ACTION)) - val referrerFillIn = Intent().putExtra(EXTRA_REFERRER, callerInfo.referrer) + val referrerFillIn = Intent().putExtra(EXTRA_REFERRER, launch.referrer) ChooserRequest( targetIntent = targetIntent, @@ -126,8 +129,8 @@ fun readChooserRequest(callerInfo: CallerInfo, source: (String) -> Any?) = isSendActionTarget = isSendAction, targetType = targetIntent.type, launchedFromPackage = - requireNotNull(callerInfo.launchedFomPackage) { - "launchedFromPackage was null, See Activity.getLaunchedFromPackage()" + requireNotNull(launch.fromPackage) { + "launch.fromPackage was null, See Activity.getLaunchedFromPackage()" }, title = customTitle, defaultTitleResource = defaultTitleResource, @@ -146,6 +149,7 @@ fun readChooserRequest(callerInfo: CallerInfo, source: (String) -> Any?) = shareTargetFilter = targetIntent.toShareTargetFilter() ) } +} private fun Intent.toShareTargetFilter(): IntentFilter? { return type?.let { diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt index 663235ca..17b1e664 100644 --- a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt +++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt @@ -18,7 +18,8 @@ package com.android.intentresolver.v2.ui.viewmodel import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel -import com.android.intentresolver.v2.ui.model.CallerInfo +import com.android.intentresolver.v2.ui.model.ActivityLaunch +import com.android.intentresolver.v2.ui.model.ActivityLaunch.Companion.ACTIVITY_LAUNCH_KEY import com.android.intentresolver.v2.ui.model.ChooserRequest import com.android.intentresolver.v2.validation.ValidationResult import dagger.hilt.android.lifecycle.HiltViewModel @@ -27,19 +28,15 @@ import javax.inject.Inject private const val TAG = "ChooserViewModel" @HiltViewModel -class ChooserViewModel -@Inject -constructor( - private val args: SavedStateHandle, -) : ViewModel() { +class ChooserViewModel @Inject constructor(args: SavedStateHandle) : ViewModel() { - private val callerInfo: CallerInfo = - requireNotNull(args[CallerInfo.SAVED_STATE_HANDLE_KEY]) { - "CallerInfo missing in SavedStateHandle! (${CallerInfo.SAVED_STATE_HANDLE_KEY})" + private val mActivityLaunch: ActivityLaunch = + requireNotNull(args[ACTIVITY_LAUNCH_KEY]) { + "ActivityLaunch missing in SavedStateHandle! ($ACTIVITY_LAUNCH_KEY)" } /** The result of reading and validating the inputs provided in savedState. */ - private val status: ValidationResult = readChooserRequest(callerInfo, args::get) + private val status: ValidationResult = readChooserRequest(mActivityLaunch) val chooserRequest: ChooserRequest by lazy { status.getOrThrow() } diff --git a/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java b/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java index c0121f2e..37bbc6ce 100644 --- a/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -54,13 +54,6 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW static final ChooserActivityOverrideData sOverrides = ChooserActivityOverrideData.getInstance(); private UsageStatsManager mUsm; - // ResolverActivity (the base class of ChooserActivity) inspects the launched-from UID at - // onCreate and needs to see some non-negative value in the test. - @Override - public int getLaunchedFromUid() { - return 1234; - } - @Override public ChooserListAdapter createChooserListAdapter( Context context, diff --git a/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java b/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java index e7c8cce3..64c8e49d 100644 --- a/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java @@ -65,13 +65,6 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW sOverrides.mWorkProfileAvailability); } - // ResolverActivity (the base class of ChooserActivity) inspects the launched-from UID at - // onCreate and needs to see some non-negative value in the test. - @Override - public int getLaunchedFromUid() { - return 1234; - } - @Override public ChooserListAdapter createChooserListAdapter( Context context, diff --git a/tests/activity/src/com/android/intentresolver/v2/ui/model/TestActivityLaunchModule.kt b/tests/activity/src/com/android/intentresolver/v2/ui/model/TestActivityLaunchModule.kt new file mode 100644 index 00000000..d674bbc2 --- /dev/null +++ b/tests/activity/src/com/android/intentresolver/v2/ui/model/TestActivityLaunchModule.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2024 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.v2.ui.model + +import android.app.Activity +import android.net.Uri +import dagger.Module +import dagger.Provides +import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.android.scopes.ActivityScoped +import dagger.hilt.testing.TestInstallIn + +@Module +@TestInstallIn(components = [ActivityComponent::class], replaces = [ActivityLaunchModule::class]) +class TestActivityLaunchModule { + + @Provides + @ActivityScoped + fun activityLaunch(activity: Activity): ActivityLaunch { + return ActivityLaunch(activity.intent, LAUNCHED_FROM_UID, LAUNCHED_FROM_PACKAGE, REFERRER) + } + + companion object { + const val LAUNCHED_FROM_PACKAGE = "example.com" + const val LAUNCHED_FROM_UID = 1234 + val REFERRER: Uri = Uri.parse("android-app://$LAUNCHED_FROM_PACKAGE") + } +} diff --git a/tests/shared/src/com/android/intentresolver/v2/ext/ParcelableExt.kt b/tests/shared/src/com/android/intentresolver/v2/ext/ParcelableExt.kt new file mode 100644 index 00000000..3878c39c --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/v2/ext/ParcelableExt.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2024 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.v2.ext + +import android.os.Parcel +import android.os.Parcelable +import java.lang.reflect.Field + +inline fun T.toParcelAndBack(): T { + val creator: Parcelable.Creator = getCreator() + val parcel = Parcel.obtain() + writeToParcel(parcel, 0) + parcel.setDataPosition(0) + return creator.createFromParcel(parcel) +} + +inline fun getCreator(): Parcelable.Creator { + return getCreator(T::class.java) +} + +inline fun getCreator(clazz: Class): Parcelable.Creator { + return try { + val field: Field = clazz.getDeclaredField("CREATOR") + @Suppress("UNCHECKED_CAST") + field.get(null) as Parcelable.Creator + } catch (e: NoSuchFieldException) { + error("$clazz is a Parcelable without CREATOR") + } catch (e: IllegalAccessException) { + error("CREATOR in $clazz::class is not accessible") + } +} diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/model/ActivityLaunchTest.kt b/tests/unit/src/com/android/intentresolver/v2/ui/model/ActivityLaunchTest.kt new file mode 100644 index 00000000..3e9f43da --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/v2/ui/model/ActivityLaunchTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2024 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.v2.ui.model + +import android.content.Intent +import android.content.Intent.ACTION_CHOOSER +import android.content.Intent.EXTRA_TEXT +import android.net.Uri +import com.android.intentresolver.v2.ext.toParcelAndBack +import com.google.common.truth.Truth.assertWithMessage +import org.junit.Test + +class ActivityLaunchTest { + + @Test + fun testDefaultValues() { + val input = ActivityLaunch(Intent(ACTION_CHOOSER), 0, null, null) + + val output = input.toParcelAndBack() + + assertEquals(input, output) + } + + @Test + fun testCommonValues() { + val intent = Intent(ACTION_CHOOSER).apply { putExtra(EXTRA_TEXT, "Test") } + val input = + ActivityLaunch(intent, 1234, "com.example", Uri.parse("android-app://example.com")) + + val output = input.toParcelAndBack() + + assertEquals(input, output) + } + + fun assertEquals(expected: ActivityLaunch, actual: ActivityLaunch) { + // Test fields separately: Intent does not override equals() + assertWithMessage("%s.filterEquals(%s)", actual.intent, expected.intent) + .that(actual.intent.filterEquals(expected.intent)) + .isTrue() + + assertWithMessage("actual fromUid is equal to expected") + .that(actual.fromUid) + .isEqualTo(expected.fromUid) + + assertWithMessage("actual fromPackage is equal to expected") + .that(actual.fromPackage) + .isEqualTo(expected.fromPackage) + + assertWithMessage("actual referrer is equal to expected") + .that(actual.referrer) + .isEqualTo(expected.referrer) + } +} diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt index bcc1054c..29bb5cbd 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt @@ -16,11 +16,12 @@ package com.android.intentresolver.v2.ui.viewmodel import android.content.Intent +import android.content.Intent.ACTION_CHOOSER import android.content.Intent.ACTION_SEND import android.content.Intent.EXTRA_INTENT import androidx.core.net.toUri import androidx.core.os.bundleOf -import com.android.intentresolver.v2.ui.model.CallerInfo +import com.android.intentresolver.v2.ui.model.ActivityLaunch import com.android.intentresolver.v2.ui.model.ChooserRequest import com.android.intentresolver.v2.validation.RequiredValueMissing import com.android.intentresolver.v2.validation.ValidationResultSubject.Companion.assertThat @@ -30,18 +31,18 @@ import org.junit.Test @Suppress("DEPRECATION") class ChooserRequestTest { - private val callerInfo = - CallerInfo( - launchedFromUid = 10000, - launchedFomPackage = "com.android.example", + val intent = Intent(ACTION_CHOOSER) + private val mActivityLaunch = + ActivityLaunch( + intent, + fromUid = 10000, + fromPackage = "com.android.example", referrer = "android-app://com.android.example".toUri() ) @Test fun missingIntent() { - val args = bundleOf() - - val result = readChooserRequest(callerInfo, args::get) + val result = readChooserRequest(mActivityLaunch) assertThat(result).value().isNull() assertThat(result) @@ -51,13 +52,13 @@ class ChooserRequestTest { @Test fun minimal() { - val args = bundleOf(EXTRA_INTENT to Intent(ACTION_SEND)) + intent.putExtras(bundleOf(EXTRA_INTENT to Intent(ACTION_SEND))) - val result = readChooserRequest(callerInfo, args::get) + val result = readChooserRequest(mActivityLaunch) assertThat(result).value().isNotNull() val value: ChooserRequest = result.getOrThrow() - assertThat(value.launchedFromPackage).isEqualTo(callerInfo.launchedFomPackage) + assertThat(value.launchedFromPackage).isEqualTo(mActivityLaunch.fromPackage) assertThat(result).findings().isEmpty() } } -- cgit v1.2.3-59-g8ed1b From dee3f78318b9f5e0bec18bb1915641ae4717af54 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Fri, 26 Jan 2024 14:52:29 -0500 Subject: Use Injected package manager consistently Fixes several places in ResolverActivity which access activity.getPackageManager() directly. This is to prepare migrating the existing depdendency override mechansim to the one provided by hilt-testing. Bug: 300157408 Test: atest IntentResolver-tests-activity Change-Id: I16052383061ff8f44d54fd3d6f9dc0d1e9809821 --- .../src/com/android/intentresolver/v2/ResolverActivity.java | 13 +++++++------ .../android/intentresolver/v2/ResolverWrapperActivity.java | 8 -------- 2 files changed, 7 insertions(+), 14 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index a308ea14..b8638ba4 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -147,6 +147,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements @Inject public ActivityLaunch mActivityLaunch; @Inject public DevicePolicyResources mDevicePolicyResources; @Inject public IntentForwarding mIntentForwarding; + @Inject public PackageManager mPackageManager; protected ActivityLogic mLogic; protected TargetDataLoader mTargetDataLoader; @@ -326,7 +327,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements } }); - boolean hasTouchScreen = getPackageManager() + boolean hasTouchScreen = mPackageManager .hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN); if (isVoiceInteraction() || !hasTouchScreen) { @@ -548,7 +549,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements ResolveInfo ri = mMultiProfilePagerAdapter.getActiveListAdapter() .resolveInfoForPosition(which, hasIndexBeenFiltered); if (mResolvingHome && hasManagedProfile() && !supportsManagedProfiles(ri)) { - String launcherName = ri.activityInfo.loadLabel(getPackageManager()).toString(); + String launcherName = ri.activityInfo.loadLabel(mPackageManager).toString(); Toast.makeText(this, mDevicePolicyResources.getWorkProfileNotSupportedMessage(launcherName), Toast.LENGTH_LONG).show(); @@ -718,7 +719,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements if (always) { final int userId = getUserId(); - final PackageManager pm = getPackageManager(); + final PackageManager pm = mPackageManager; // Set the preferred Activity pm.addUniquePreferredActivity(filter, bestMatch, set, intent.getComponent()); @@ -771,7 +772,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements null); return new ResolverListController( this, - getPackageManager(), + mPackageManager, mLogic.getTargetIntent(), mLogic.getReferrerPackageName(), mActivityLaunch.getFromUid(), @@ -1249,7 +1250,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements private boolean supportsManagedProfiles(ResolveInfo resolveInfo) { try { - ApplicationInfo appInfo = getPackageManager().getApplicationInfo( + ApplicationInfo appInfo = mPackageManager.getApplicationInfo( resolveInfo.activityInfo.packageName, 0 /* default flags */); return appInfo.targetSdkVersion >= Build.VERSION_CODES.LOLLIPOP; } catch (NameNotFoundException e) { @@ -1294,7 +1295,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements if (ri != null) { ActivityInfo activityInfo = ri.activityInfo; - boolean hasRecordPermission = getPackageManager() + boolean hasRecordPermission = mPackageManager .checkPermission(android.Manifest.permission.RECORD_AUDIO, activityInfo.packageName) == PackageManager.PERMISSION_GRANTED; diff --git a/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java b/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java index 9eaf9261..2e29be11 100644 --- a/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java @@ -145,14 +145,6 @@ public class ResolverWrapperActivity extends ResolverActivity { return sOverrides.workResolverListController; } - @Override - public PackageManager getPackageManager() { - if (sOverrides.createPackageManager != null) { - return sOverrides.createPackageManager.apply(super.getPackageManager()); - } - return super.getPackageManager(); - } - protected UserHandle getCurrentUserHandle() { return mMultiProfilePagerAdapter.getCurrentUserHandle(); } -- cgit v1.2.3-59-g8ed1b From 068c7147bd553869fca073fee25f62809c82fb3d Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Tue, 30 Jan 2024 15:54:03 -0500 Subject: Adds ProfilePagerResources This consolidates resources for tabbed profile UI into a form which can be accessed generically, via profile type, instead of specific properties. Bug: 309960444 Test: manual Change-Id: If96d1a5956ee5c25a36ab5d7160289bb655c224b --- java/res/values/strings.xml | 6 ++- .../intentresolver/v2/ui/ProfilePagerResources.kt | 53 ++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 java/src/com/android/intentresolver/v2/ui/ProfilePagerResources.kt (limited to 'java/src') diff --git a/java/res/values/strings.xml b/java/res/values/strings.xml index 0c772573..f98f5cd1 100644 --- a/java/res/values/strings.xml +++ b/java/res/values/strings.xml @@ -250,16 +250,20 @@ This app has not been granted record permission but could capture audio through this USB device. - + Personal Work + + Private Personal view Work view + + Private view Blocked by your IT admin diff --git a/java/src/com/android/intentresolver/v2/ui/ProfilePagerResources.kt b/java/src/com/android/intentresolver/v2/ui/ProfilePagerResources.kt new file mode 100644 index 00000000..0d31b23e --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ui/ProfilePagerResources.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2024 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.v2.ui + +import android.content.res.Resources +import com.android.intentresolver.inject.ApplicationOwned +import com.android.intentresolver.v2.data.repository.DevicePolicyResources +import com.android.intentresolver.v2.domain.model.Profile +import javax.inject.Inject +import com.android.intentresolver.R + +class ProfilePagerResources +@Inject +constructor( + @ApplicationOwned private val resources: Resources, + private val devicePolicyResources: DevicePolicyResources +) { + private val privateTabLabel by lazy { resources.getString(R.string.resolver_private_tab) } + + private val privateTabAccessibilityLabel by lazy { + resources.getString(R.string.resolver_private_tab_accessibility) + } + + fun profileTabLabel(profile: Profile.Type): String { + return when (profile) { + Profile.Type.PERSONAL -> devicePolicyResources.personalTabLabel + Profile.Type.WORK -> devicePolicyResources.workTabLabel + Profile.Type.PRIVATE -> privateTabLabel + } + } + + fun profileTabAccessibilityLabel(type: Profile.Type): String { + return when (type) { + Profile.Type.PERSONAL -> devicePolicyResources.personalTabAccessibilityLabel + Profile.Type.WORK -> devicePolicyResources.workTabAccessibilityLabel + Profile.Type.PRIVATE -> privateTabAccessibilityLabel + } + } +} \ No newline at end of file -- cgit v1.2.3-59-g8ed1b From 29bd514e948c60b1dae2ae0fabc0d15adb2b0950 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Mon, 29 Jan 2024 14:10:07 -0500 Subject: Adds ResolverRequest, moves handing to tested code This formalizes the inputs to ResolverActivity, replacing the equivalent inline code. Fields that were temporarily routed through 'ActivityLogic' are now removed. Bug: 300157408 Test: atest IntentResolver-tests-activity Test: atest IntentResolver-tests-unit:ResolveRequestTest Change-Id: I79d9fa21b91d0ce9b008af12ba3bffbd60e91a38 --- .../com/android/intentresolver/v2/ActivityLogic.kt | 36 +------ .../android/intentresolver/v2/ChooserActivity.java | 58 +++++----- .../intentresolver/v2/ChooserActivityLogic.kt | 21 +--- .../intentresolver/v2/ResolverActivity.java | 114 ++++++++------------ .../intentresolver/v2/ResolverActivityLogic.kt | 30 +----- .../intentresolver/v2/ui/model/ActivityLaunch.kt | 7 +- .../v2/ui/model/ActivityLaunchModule.kt | 5 +- .../intentresolver/v2/ui/model/ChooserRequest.kt | 38 ++++--- .../intentresolver/v2/ui/model/ResolverRequest.kt | 68 ++++++++++++ .../v2/ui/viewmodel/ChooserRequestReader.kt | 7 +- .../v2/ui/viewmodel/ResolverRequestReader.kt | 59 ++++++++++ .../intentresolver/v2/ChooserWrapperActivity.java | 4 +- .../intentresolver/v2/TestChooserActivityLogic.kt | 3 - .../v2/ui/model/TestActivityLaunchModule.kt | 2 +- .../v2/ui/model/ActivityLaunchTest.kt | 44 +++++++- .../v2/ui/viewmodel/ChooserRequestTest.kt | 87 ++++++++++++--- .../v2/ui/viewmodel/ResolverRequestTest.kt | 120 +++++++++++++++++++++ 17 files changed, 478 insertions(+), 225 deletions(-) create mode 100644 java/src/com/android/intentresolver/v2/ui/model/ResolverRequest.kt create mode 100644 java/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestReader.kt create mode 100644 tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ActivityLogic.kt b/java/src/com/android/intentresolver/v2/ActivityLogic.kt index b9686418..62ace0da 100644 --- a/java/src/com/android/intentresolver/v2/ActivityLogic.kt +++ b/java/src/com/android/intentresolver/v2/ActivityLogic.kt @@ -15,7 +15,6 @@ */ package com.android.intentresolver.v2 -import android.content.Intent import android.os.UserHandle import android.os.UserManager import android.util.Log @@ -30,18 +29,7 @@ import com.android.intentresolver.WorkProfileAvailabilityManager * activity, including test activities, but all implementations should delegate to a * CommonActivityLogic implementation. */ -interface ActivityLogic : CommonActivityLogic { - /** The intent for the target. This will always come before additional targets, if any. */ - val targetIntent: Intent - /** Custom title to display. */ - val title: CharSequence? - /** Resource ID for the title to display when there is no custom title. */ - val defaultTitleResId: Int - /** Intents received to be processed. */ - val initialIntents: List? - /** The intents for potential actual targets. [targetIntent] must be first. */ - val payloadIntents: List -} +interface ActivityLogic : CommonActivityLogic /** * Logic that is common to all IntentResolver activities. Anything that is the same across @@ -50,14 +38,13 @@ interface ActivityLogic : CommonActivityLogic { interface CommonActivityLogic { /** The tag to use when logging. */ val tag: String + /** A reference to the activity owning, and used by, this logic. */ val activity: ComponentActivity - /** The name of the referring package. */ - val referrerPackageName: String? - /** User manager system service. */ - val userManager: UserManager + /** Current [UserHandle]s retrievable by type. */ val annotatedUserHandles: AnnotatedUserHandles? + /** Monitors for changes to work profile availability. */ val workProfileAvailabilityManager: WorkProfileAvailabilityManager } @@ -73,16 +60,7 @@ class CommonActivityLogicImpl( onWorkProfileStatusUpdated: () -> Unit, ) : CommonActivityLogic { - override val referrerPackageName: String? = - activity.referrer.let { - if (ANDROID_APP_URI_SCHEME == it?.scheme) { - it.host - } else { - null - } - } - - override val userManager: UserManager = activity.getSystemService()!! + private val userManager: UserManager = activity.getSystemService()!! override val annotatedUserHandles: AnnotatedUserHandles? = try { @@ -98,8 +76,4 @@ class CommonActivityLogicImpl( annotatedUserHandles?.workProfileUserHandle, onWorkProfileStatusUpdated, ) - - companion object { - private const val ANDROID_APP_URI_SCHEME = "android-app" - } } diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index c1184a80..29a792f6 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -322,12 +322,11 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private ChooserViewModel mViewModel; @VisibleForTesting - protected ChooserActivityLogic createActivityLogic(ChooserRequest chooserRequest) { + protected ChooserActivityLogic createActivityLogic() { return new ChooserActivityLogic( TAG, /* activity = */ this, - this::onWorkProfileStatusUpdated, - chooserRequest); + this::onWorkProfileStatusUpdated); } @NonNull @@ -355,7 +354,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements finish(); return; } - mLogic = createActivityLogic(mViewModel.getChooserRequest()); + mLogic = createActivityLogic(); init(); } @@ -381,14 +380,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements chooserRequest.getShareTargetFilter() ); - Intent intent = mLogic.getTargetIntent(); - List initialIntents = mLogic.getInitialIntents(); - - // Calling UID did not have valid permissions - if (mLogic.getAnnotatedUserHandles() == null) { - finish(); - return; - } + Intent intent = mViewModel.getChooserRequest().getTargetIntent(); + List initialIntents = mViewModel.getChooserRequest().getInitialIntents(); mChooserMultiProfilePagerAdapter = createMultiProfilePagerAdapter( requireNonNullElse(initialIntents, emptyList()).toArray(new Intent[0]), @@ -509,7 +502,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements Log.d(TAG, "System Time Cost is " + systemCost); } getEventLog().logShareStarted( - mLogic.getReferrerPackageName(), + chooserRequest.getReferrerPackage(), chooserRequest.getTargetType(), chooserRequest.getCallerChooserTargets().size(), chooserRequest.getInitialIntents().size(), @@ -714,9 +707,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } } - CharSequence title = mLogic.getTitle() != null - ? mLogic.getTitle() - : getTitleForAction(mLogic.getTargetIntent(), mLogic.getDefaultTitleResId()); + CharSequence title = mViewModel.getChooserRequest().getTitle() != null + ? mViewModel.getChooserRequest().getTitle() + : getTitleForAction(mViewModel.getChooserRequest().getTargetIntent(), + mViewModel.getChooserRequest().getDefaultTitleResource()); if (!TextUtils.isEmpty(title)) { final TextView titleView = findViewById(com.android.internal.R.id.title); @@ -815,7 +809,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } // If needed, show that intent is forwarded // from managed profile to owner or other way around. - String profileSwitchMessage = mIntentForwarding.forwardMessageFor(mLogic.getTargetIntent()); + String profileSwitchMessage = mIntentForwarding.forwardMessageFor( + mViewModel.getChooserRequest().getTargetIntent()); if (profileSwitchMessage != null) { Toast.makeText(this, profileSwitchMessage, Toast.LENGTH_LONG).show(); } @@ -1283,7 +1278,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements boolean filterLastUsed) { ChooserGridAdapter adapter = createChooserGridAdapter( /* context */ this, - mLogic.getPayloadIntents(), + mViewModel.getChooserRequest().getPayloadIntents(), initialIntents, rList, filterLastUsed, @@ -1314,7 +1309,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements int selectedProfile = findSelectedProfile(); ChooserGridAdapter personalAdapter = createChooserGridAdapter( /* context */ this, - mLogic.getPayloadIntents(), + mViewModel.getChooserRequest().getPayloadIntents(), selectedProfile == PROFILE_PERSONAL ? initialIntents : null, rList, filterLastUsed, @@ -1322,7 +1317,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements ); ChooserGridAdapter workAdapter = createChooserGridAdapter( /* context */ this, - mLogic.getPayloadIntents(), + mViewModel.getChooserRequest().getPayloadIntents(), selectedProfile == PROFILE_WORK ? initialIntents : null, rList, filterLastUsed, @@ -1823,7 +1818,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (info != null) { sendClickToAppPredictor(info); final ResolveInfo ri = info.getResolveInfo(); - Intent targetIntent = mLogic.getTargetIntent(); + Intent targetIntent = mViewModel.getChooserRequest().getTargetIntent(); if (ri != null && ri.activityInfo != null && targetIntent != null) { ChooserListAdapter currentListAdapter = mChooserMultiProfilePagerAdapter.getActiveListAdapter(); @@ -1959,7 +1954,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } } - @VisibleForTesting public ChooserGridAdapter createChooserGridAdapter( Context context, List payloadIntents, @@ -1967,7 +1961,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements List rList, boolean filterLastUsed, UserHandle userHandle) { - ChooserRequest parameters = mViewModel.getChooserRequest(); + ChooserRequest request = mViewModel.getChooserRequest(); ChooserListAdapter chooserListAdapter = createChooserListAdapter( context, payloadIntents, @@ -1976,8 +1970,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements filterLastUsed, createListController(userHandle), userHandle, - mLogic.getTargetIntent(), - parameters.getReferrerFillInIntent(), + request.getTargetIntent(), + request.getReferrerFillInIntent(), mMaxTargetsPerRow ); @@ -2081,8 +2075,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (appPredictor != null) { resolverComparator = new AppPredictionServiceResolverComparator( this, - mLogic.getTargetIntent(), - mLogic.getReferrerPackageName(), + mViewModel.getChooserRequest().getTargetIntent(), + mViewModel.getChooserRequest().getLaunchedFromPackage(), appPredictor, userHandle, getEventLog(), @@ -2092,8 +2086,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements resolverComparator = new ResolverRankerServiceResolverComparator( this, - mLogic.getTargetIntent(), - mLogic.getReferrerPackageName(), + mViewModel.getChooserRequest().getTargetIntent(), + mViewModel.getChooserRequest().getReferrerPackage(), null, getEventLog(), getResolverRankerServiceUserHandleList(userHandle), @@ -2103,9 +2097,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return new ChooserListController( this, mPackageManager, - mLogic.getTargetIntent(), - mLogic.getReferrerPackageName(), - mActivityLaunch.getFromUid(), + mViewModel.getChooserRequest().getTargetIntent(), + mViewModel.getChooserRequest().getReferrerPackage(), + requireAnnotatedUserHandles().userIdOfCallingApp, resolverComparator, getQueryIntentsUser(userHandle)); } diff --git a/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt b/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt index f6054885..84b7d9a9 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt +++ b/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt @@ -1,11 +1,7 @@ package com.android.intentresolver.v2 -import android.content.Intent import androidx.activity.ComponentActivity import androidx.annotation.OpenForTesting -import com.android.intentresolver.v2.ui.model.ChooserRequest - -private const val TAG = "ChooserActivityLogic" /** * Activity logic for [ChooserActivity]. @@ -18,25 +14,10 @@ open class ChooserActivityLogic( tag: String, activity: ComponentActivity, onWorkProfileStatusUpdated: () -> Unit, - private val chooserRequest: ChooserRequest? = null, ) : ActivityLogic, CommonActivityLogic by CommonActivityLogicImpl( tag, activity, onWorkProfileStatusUpdated, - ) { - - override val targetIntent: Intent = chooserRequest?.targetIntent ?: Intent() - - override val title: CharSequence? = chooserRequest?.title - - override val defaultTitleResId: Int = chooserRequest?.defaultTitleResource ?: 0 - - override val initialIntents: List? = chooserRequest?.initialIntents?.toList() - - override val payloadIntents: List = buildList { - add(targetIntent) - chooserRequest?.additionalTargets?.let { addAll(it) } - } -} + ) diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index b8638ba4..77d1dbf5 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -25,11 +25,10 @@ import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_S import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; import static com.android.intentresolver.v2.ext.CreationExtrasExtKt.addDefaultArgs; +import static com.android.intentresolver.v2.ui.viewmodel.ResolverRequestReaderKt.readResolverRequest; import static com.android.internal.annotations.VisibleForTesting.Visibility.PROTECTED; -import static java.util.Collections.emptyList; import static java.util.Objects.requireNonNull; -import static java.util.Objects.requireNonNullElse; import android.app.ActivityThread; import android.app.VoiceInteractor.PickOptionRequest; @@ -105,13 +104,15 @@ import com.android.intentresolver.v2.MultiProfilePagerAdapter.OnSwitchOnWorkSele import com.android.intentresolver.v2.MultiProfilePagerAdapter.ProfileType; import com.android.intentresolver.v2.MultiProfilePagerAdapter.TabConfig; import com.android.intentresolver.v2.data.repository.DevicePolicyResources; +import com.android.intentresolver.v2.domain.model.Profile; import com.android.intentresolver.v2.emptystate.NoAppsAvailableEmptyStateProvider; import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider; import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; import com.android.intentresolver.v2.emptystate.WorkProfilePausedEmptyStateProvider; -import com.android.intentresolver.v2.ext.IntentExtKt; import com.android.intentresolver.v2.ui.ActionTitle; import com.android.intentresolver.v2.ui.model.ActivityLaunch; +import com.android.intentresolver.v2.ui.model.ResolverRequest; +import com.android.intentresolver.v2.validation.ValidationResult; import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.content.PackageMonitor; @@ -144,10 +145,11 @@ import javax.inject.Inject; public class ResolverActivity extends Hilt_ResolverActivity implements ResolverListAdapter.ResolverListCommunicator { + @Inject public PackageManager mPackageManager; @Inject public ActivityLaunch mActivityLaunch; @Inject public DevicePolicyResources mDevicePolicyResources; @Inject public IntentForwarding mIntentForwarding; - @Inject public PackageManager mPackageManager; + private ResolverRequest mResolverRequest; protected ActivityLogic mLogic; protected TargetDataLoader mTargetDataLoader; @@ -185,32 +187,8 @@ public class ResolverActivity extends Hilt_ResolverActivity implements protected ResolverMultiProfilePagerAdapter mMultiProfilePagerAdapter; - - // Intent extra for connected audio devices - public static final String EXTRA_IS_AUDIO_CAPTURE_DEVICE = "is_audio_capture_device"; - - /** - * Integer extra to indicate which profile should be automatically selected. - *

    Can only be used if there is a work profile. - *

    Possible values can be either {@link #PROFILE_PERSONAL} or {@link #PROFILE_WORK}. - */ - protected static final String EXTRA_SELECTED_PROFILE = - "com.android.internal.app.ResolverActivity.EXTRA_SELECTED_PROFILE"; - - /** - * {@link UserHandle} extra to indicate the user of the user that the starting intent - * originated from. - *

    This is not necessarily the same as {@link #getUserId()} or {@link UserHandle#myUserId()}, - * as there are edge cases when the intent resolver is launched in the other profile. - * For example, when we have 0 resolved apps in current profile and multiple resolved - * apps in the other profile, opening a link from the current profile launches the intent - * resolver in the other one. b/148536209 for more info. - */ - static final String EXTRA_CALLING_USER = - "com.android.internal.app.ResolverActivity.EXTRA_CALLING_USER"; - - protected static final int PROFILE_PERSONAL = MultiProfilePagerAdapter.PROFILE_PERSONAL; - protected static final int PROFILE_WORK = MultiProfilePagerAdapter.PROFILE_WORK; + public static final int PROFILE_PERSONAL = MultiProfilePagerAdapter.PROFILE_PERSONAL; + public static final int PROFILE_WORK = MultiProfilePagerAdapter.PROFILE_WORK; private UserHandle mHeaderCreatorUser; @@ -234,7 +212,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements } @VisibleForTesting - protected ResolverActivityLogic createActivityLogic() { + protected ActivityLogic createActivityLogic() { return new ResolverActivityLogic( TAG, /* activity = */ this, @@ -261,22 +239,24 @@ public class ResolverActivity extends Hilt_ResolverActivity implements finish(); } + ValidationResult result = readResolverRequest(mActivityLaunch); + if (!result.isSuccess()) { + result.reportToLogcat(TAG); + finish(); + } + mResolverRequest = result.getOrThrow(); mLogic = createActivityLogic(); - mResolvingHome = IntentExtKt.isHomeIntent(getIntent()); + mResolvingHome = mResolverRequest.isResolvingHome(); mTargetDataLoader = new DefaultTargetDataLoader( this, getLifecycle(), - getIntent().getBooleanExtra( - ResolverActivity.EXTRA_IS_AUDIO_CAPTURE_DEVICE, - /* defaultValue = */ false) - ); + mResolverRequest.isAudioCaptureDevice()); init(); restore(savedInstanceState); } private void init() { - Intent intent = mLogic.getTargetIntent(); - List initialIntents = mLogic.getInitialIntents(); + Intent intent = mResolverRequest.getIntent(); // The last argument of createResolverListAdapter is whether to do special handling // of the last used choice to highlight it in the list. We need to always @@ -289,8 +269,8 @@ public class ResolverActivity extends Hilt_ResolverActivity implements boolean filterLastUsed = !isVoiceInteraction() && !hasWorkProfile() && !hasCloneProfile(); mMultiProfilePagerAdapter = createMultiProfilePagerAdapter( - requireNonNullElse(initialIntents, emptyList()).toArray(new Intent[0]), - /* resolutionList = */ null, + new Intent[0], + /* resolutionList = */ mResolverRequest.getResolutionList(), filterLastUsed ); if (configureContentView(mTargetDataLoader)) { @@ -764,8 +744,8 @@ public class ResolverActivity extends Hilt_ResolverActivity implements ResolverRankerServiceResolverComparator resolverComparator = new ResolverRankerServiceResolverComparator( this, - mLogic.getTargetIntent(), - mLogic.getReferrerPackageName(), + mResolverRequest.getIntent(), + mActivityLaunch.getReferrerPackage(), null, null, getResolverRankerServiceUserHandleList(userHandle), @@ -773,8 +753,8 @@ public class ResolverActivity extends Hilt_ResolverActivity implements return new ResolverListController( this, mPackageManager, - mLogic.getTargetIntent(), - mLogic.getReferrerPackageName(), + mActivityLaunch.getIntent(), + mActivityLaunch.getReferrerPackage(), mActivityLaunch.getFromUid(), resolverComparator, getQueryIntentsUser(userHandle)); @@ -920,7 +900,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements filterLastUsed, createListController(userHandle), userHandle, - mLogic.getTargetIntent(), + mResolverRequest.getIntent(), this, initialIntentsUserSpace, mTargetDataLoader); @@ -964,7 +944,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements boolean filterLastUsed) { ResolverListAdapter personalAdapter = createResolverListAdapter( /* context */ this, - mLogic.getPayloadIntents(), + mResolverRequest.getPayloadIntents(), initialIntents, resolutionList, filterLastUsed, @@ -987,9 +967,8 @@ public class ResolverActivity extends Hilt_ResolverActivity implements } private UserHandle getIntentUser() { - return getIntent().hasExtra(EXTRA_CALLING_USER) - ? getIntent().getParcelableExtra(EXTRA_CALLING_USER) - : requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch; + return Objects.requireNonNullElse(mResolverRequest.getCallingUser(), + requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch); } private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForTwoProfiles( @@ -1018,7 +997,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements // resolver list. So filterLastUsed should be false for the other profile. ResolverListAdapter personalAdapter = createResolverListAdapter( /* context */ this, - mLogic.getPayloadIntents(), + mResolverRequest.getPayloadIntents(), selectedProfile == PROFILE_PERSONAL ? initialIntents : null, resolutionList, (filterLastUsed && UserHandle.myUserId() @@ -1028,7 +1007,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements UserHandle workProfileUserHandle = requireAnnotatedUserHandles().workProfileUserHandle; ResolverListAdapter workAdapter = createResolverListAdapter( /* context */ this, - mLogic.getPayloadIntents(), + mResolverRequest.getPayloadIntents(), selectedProfile == PROFILE_WORK ? initialIntents : null, resolutionList, (filterLastUsed && UserHandle.myUserId() @@ -1060,20 +1039,17 @@ public class ResolverActivity extends Hilt_ResolverActivity implements /** * Returns {@link #PROFILE_PERSONAL} or {@link #PROFILE_WORK} if the {@link * #EXTRA_SELECTED_PROFILE} extra was supplied, or {@code -1} if no extra was supplied. - * @throws IllegalArgumentException if the value passed to the {@link #EXTRA_SELECTED_PROFILE} - * extra is not {@link #PROFILE_PERSONAL} or {@link #PROFILE_WORK} */ final int getSelectedProfileExtra() { - int selectedProfile = -1; - if (getIntent().hasExtra(EXTRA_SELECTED_PROFILE)) { - selectedProfile = getIntent().getIntExtra(EXTRA_SELECTED_PROFILE, /* defValue = */ -1); - if (selectedProfile != PROFILE_PERSONAL && selectedProfile != PROFILE_WORK) { - throw new IllegalArgumentException(EXTRA_SELECTED_PROFILE + " has invalid value " - + selectedProfile + ". Must be either ResolverActivity.PROFILE_PERSONAL or " - + "ResolverActivity.PROFILE_WORK."); - } + Profile.Type selected = mResolverRequest.getSelectedProfile(); + if (selected == null) { + return -1; + } + switch (selected) { + case PERSONAL: return PROFILE_PERSONAL; + case WORK: return PROFILE_WORK; + default: return -1; } - return selectedProfile; } protected final @ProfileType int getCurrentProfile() { @@ -1302,9 +1278,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements if (!hasRecordPermission) { // OK, we know the record permission, is this a capture device - boolean hasAudioCapture = - getIntent().getBooleanExtra( - ResolverActivity.EXTRA_IS_AUDIO_CAPTURE_DEVICE, false); + boolean hasAudioCapture = mResolverRequest.isAudioCaptureDevice(); enabled = !hasAudioCapture; } } @@ -1491,7 +1465,8 @@ public class ResolverActivity extends Hilt_ResolverActivity implements } // If needed, show that intent is forwarded // from managed profile to owner or other way around. - String profileSwitchMessage = mIntentForwarding.forwardMessageFor(mLogic.getTargetIntent()); + String profileSwitchMessage = + mIntentForwarding.forwardMessageFor(mResolverRequest.getIntent()); if (profileSwitchMessage != null) { Toast.makeText(this, profileSwitchMessage, Toast.LENGTH_LONG).show(); } @@ -1771,10 +1746,9 @@ public class ResolverActivity extends Hilt_ResolverActivity implements } } - - CharSequence title = mLogic.getTitle() != null - ? mLogic.getTitle() - : getTitleForAction(mLogic.getTargetIntent(), mLogic.getDefaultTitleResId()); + CharSequence title = mResolverRequest.getTitle() != null + ? mResolverRequest.getTitle() + : getTitleForAction(mResolverRequest.getIntent(), 0); if (!TextUtils.isEmpty(title)) { final TextView titleView = findViewById(com.android.internal.R.id.title); diff --git a/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt b/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt index 13353041..7eb63ab3 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt +++ b/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt @@ -1,6 +1,5 @@ package com.android.intentresolver.v2 -import android.content.Intent import androidx.activity.ComponentActivity import androidx.annotation.OpenForTesting @@ -16,31 +15,4 @@ open class ResolverActivityLogic( tag, activity, onWorkProfileStatusUpdated, - ) { - - final override val targetIntent: Intent = let { - val intent = Intent(activity.intent) - intent.setComponent(null) - // The resolver activity is set to be hidden from recent tasks. - // we don't want this attribute to be propagated to the next activity - // being launched. Note that if the original Intent also had this - // flag set, we are now losing it. That should be a very rare case - // and we can live with this. - intent.setFlags(intent.flags and Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS.inv()) - - // If FLAG_ACTIVITY_LAUNCH_ADJACENT was set, ResolverActivity was opened in the alternate - // side, which means we want to open the target app on the same side as ResolverActivity. - if (intent.flags and Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT != 0) { - intent.setFlags(intent.flags and Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT.inv()) - } - intent - } - - override val title: CharSequence? = null - - override val defaultTitleResId: Int = 0 - - override val initialIntents: List? = null - - override val payloadIntents: List = listOf(targetIntent) -} + ) diff --git a/java/src/com/android/intentresolver/v2/ui/model/ActivityLaunch.kt b/java/src/com/android/intentresolver/v2/ui/model/ActivityLaunch.kt index fd25ea42..e5f342d9 100644 --- a/java/src/com/android/intentresolver/v2/ui/model/ActivityLaunch.kt +++ b/java/src/com/android/intentresolver/v2/ui/model/ActivityLaunch.kt @@ -29,7 +29,7 @@ data class ActivityLaunch( /** The identifier for the sending app and user */ val fromUid: Int, /** The package of the sending app */ - val fromPackage: String?, + val fromPackage: String, /** The referrer as supplied to the activity. */ val referrer: Uri? ) : Parcelable { @@ -38,10 +38,13 @@ data class ActivityLaunch( ) : this( intent = source.requireParcelable(), fromUid = source.readInt(), - fromPackage = source.readString(), + fromPackage = requireNotNull(source.readString()), referrer = source.readParcelable() ) + /** A package name from referrer, if it is an android-app URI */ + val referrerPackage = referrer?.takeIf { it.scheme == ANDROID_APP_SCHEME }?.authority + override fun describeContents() = 0 /* flags */ override fun writeToParcel(dest: Parcel, flags: Int) { diff --git a/java/src/com/android/intentresolver/v2/ui/model/ActivityLaunchModule.kt b/java/src/com/android/intentresolver/v2/ui/model/ActivityLaunchModule.kt index 3311467e..bb8f3a54 100644 --- a/java/src/com/android/intentresolver/v2/ui/model/ActivityLaunchModule.kt +++ b/java/src/com/android/intentresolver/v2/ui/model/ActivityLaunchModule.kt @@ -33,7 +33,10 @@ object ActivityLaunchModule { return ActivityLaunch( activity.intent, activity.launchedFromUid, - activity.launchedFromPackage, + requireNotNull(activity.launchedFromPackage) { + "activity.launchedFromPackage was null. This is expected to be non-null for " + + "any system-signed application!" + }, activity.referrer ) } diff --git a/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt b/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt index 2fbf94a2..d41d0874 100644 --- a/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt +++ b/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt @@ -19,16 +19,17 @@ import android.content.ComponentName import android.content.Intent import android.content.Intent.ACTION_SEND import android.content.Intent.ACTION_SEND_MULTIPLE +import android.content.Intent.EXTRA_REFERRER import android.content.IntentFilter import android.content.IntentSender +import android.net.Uri import android.os.Bundle import android.service.chooser.ChooserAction import android.service.chooser.ChooserTarget import androidx.annotation.StringRes import com.android.intentresolver.v2.ext.hasAction -const val MAX_CHOOSER_ACTIONS = 5 -const val MAX_INITIAL_INTENTS = 2 +const val ANDROID_APP_SCHEME = "android-app" /** All of the things that are consumed from an incoming share Intent (+Extras). */ data class ChooserRequest( @@ -58,10 +59,10 @@ data class ChooserRequest( @get:StringRes val defaultTitleResource: Int = 0, /** - * An empty intent which carries an extra of [Intent.EXTRA_REFERRER]. To be merged with outgoing - * intents. This provides the original referrer value to the target. + * The referrer value as received by the caller. It may have been supplied via [EXTRA_REFERRER] + * or synthesized from callerPackageName. This value is merged into outgoing intents. */ - val referrerFillInIntent: Intent, + val referrer: Uri?, /** * Choices to exclude from results. @@ -163,18 +164,29 @@ data class ChooserRequest( */ val shareTargetFilter: IntentFilter? = null ) { + val referrerPackage = referrer?.takeIf { it.scheme == ANDROID_APP_SCHEME }?.authority + + fun getReferrerFillInIntent(): Intent { + return Intent().apply { + referrerPackage?.also { pkg -> + putExtra(EXTRA_REFERRER, Uri.parse("$ANDROID_APP_SCHEME://$pkg")) + } + } + } + + val payloadIntents = listOf(targetIntent) + additionalTargets /** Constructs an instance from only the required values. */ constructor( targetIntent: Intent, - referrerPackageName: String + launchedFromPackage: String, + referrer: Uri? ) : this( - targetIntent, - targetIntent.action, - targetIntent.hasAction(ACTION_SEND, ACTION_SEND_MULTIPLE), - targetIntent.type, - referrerPackageName, - referrerFillInIntent = - Intent().apply { putExtra(Intent.EXTRA_REFERRER, referrerPackageName) } + targetIntent = targetIntent, + targetAction = targetIntent.action, + isSendActionTarget = targetIntent.hasAction(ACTION_SEND, ACTION_SEND_MULTIPLE), + targetType = targetIntent.type, + launchedFromPackage = launchedFromPackage, + referrer = referrer ) } diff --git a/java/src/com/android/intentresolver/v2/ui/model/ResolverRequest.kt b/java/src/com/android/intentresolver/v2/ui/model/ResolverRequest.kt new file mode 100644 index 00000000..5abfb602 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ui/model/ResolverRequest.kt @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2024 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.v2.ui.model + +import android.content.Intent +import android.content.pm.ResolveInfo +import android.os.UserHandle +import com.android.intentresolver.v2.domain.model.Profile +import com.android.intentresolver.v2.ext.isHomeIntent + +/** All of the things that are consumed from an incoming Intent Resolution request (+Extras). */ +data class ResolverRequest( + /** The intent to be resolved to a target. */ + val intent: Intent, + + /** + * Supplied by the system to indicate which profile should be selected by default. This is + * required since ResolverActivity may be launched as either the originating OR target user when + * resolving a cross profile intent. + * + * Valid values are: [PERSONAL][Profile.Type.PERSONAL] and [WORK][Profile.Type.WORK] and null + * when the intent is not a forwarded cross-profile intent. + */ + val selectedProfile: Profile.Type?, + + /** + * When handing a cross profile forwarded intent, this is the user which started the original + * intent. This is required to allow ResolverActivity to be launched as the target user under + * some conditions. + */ + val callingUser: UserHandle?, + + /** + * Indicates if resolving actions for a connected device which has audio capture capability + * (e.g. is a USB Microphone). + * + * When used to handle a connected device, ResolverActivity uses this signal to present a + * warning when a resolved application does not hold the RECORD_AUDIO permission. (If selected + * the app would be able to capture audio directly via the device, bypassing audio API + * permissions.) + */ + val isAudioCaptureDevice: Boolean = false, + + /** A list of a resolved activity targets. This list overrides normal intent resolution. */ + val resolutionList: List? = null, + + /** A customized title for the resolver interface. */ + val title: String? = null, +) { + val isResolvingHome = intent.isHomeIntent() + + /** For compatibility with existing code shared between chooser/resolver. */ + val payloadIntents: List = listOf(intent) +} diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt index 33868aaf..45e2ea64 100644 --- a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt +++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt @@ -46,14 +46,15 @@ import com.android.intentresolver.v2.ext.hasAction import com.android.intentresolver.v2.ext.ifMatch import com.android.intentresolver.v2.ui.model.ActivityLaunch import com.android.intentresolver.v2.ui.model.ChooserRequest -import com.android.intentresolver.v2.ui.model.MAX_CHOOSER_ACTIONS -import com.android.intentresolver.v2.ui.model.MAX_INITIAL_INTENTS import com.android.intentresolver.v2.validation.ValidationResult import com.android.intentresolver.v2.validation.types.IntentOrUri import com.android.intentresolver.v2.validation.types.array import com.android.intentresolver.v2.validation.types.value import com.android.intentresolver.v2.validation.validateFrom +private const val MAX_CHOOSER_ACTIONS = 5 +private const val MAX_INITIAL_INTENTS = 2 + private fun Intent.hasSendAction() = hasAction(ACTION_SEND, ACTION_SEND_MULTIPLE) internal fun Intent.maybeAddSendActionFlags() = @@ -134,7 +135,7 @@ fun readChooserRequest(launch: ActivityLaunch): ValidationResult }, title = customTitle, defaultTitleResource = defaultTitleResource, - referrerFillInIntent = referrerFillIn, + referrer = launch.referrer, filteredComponentNames = filteredComponents, callerChooserTargets = callerChooserTargets, chooserActions = chooserActions, diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestReader.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestReader.kt new file mode 100644 index 00000000..fc9f1e01 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestReader.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2024 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.v2.ui.viewmodel + +import android.os.Bundle +import android.os.UserHandle +import com.android.intentresolver.v2.ResolverActivity.PROFILE_PERSONAL +import com.android.intentresolver.v2.ResolverActivity.PROFILE_WORK +import com.android.intentresolver.v2.domain.model.Profile +import com.android.intentresolver.v2.ui.model.ActivityLaunch +import com.android.intentresolver.v2.ui.model.ResolverRequest +import com.android.intentresolver.v2.validation.Validation +import com.android.intentresolver.v2.validation.ValidationResult +import com.android.intentresolver.v2.validation.types.value +import com.android.intentresolver.v2.validation.validateFrom + +const val EXTRA_CALLING_USER = "com.android.internal.app.ResolverActivity.EXTRA_CALLING_USER" +const val EXTRA_SELECTED_PROFILE = + "com.android.internal.app.ResolverActivity.EXTRA_SELECTED_PROFILE" +const val EXTRA_IS_AUDIO_CAPTURE_DEVICE = "is_audio_capture_device" + +fun readResolverRequest(launch: ActivityLaunch): ValidationResult { + @Suppress("DEPRECATION") + return validateFrom((launch.intent.extras ?: Bundle())::get) { + val callingUser = optional(value(EXTRA_CALLING_USER)) + val selectedProfile = checkSelectedProfile() + val audioDevice = optional(value(EXTRA_IS_AUDIO_CAPTURE_DEVICE)) ?: false + ResolverRequest(launch.intent, selectedProfile, callingUser, audioDevice) + } +} + +private fun Validation.checkSelectedProfile(): Profile.Type? { + return when (val selected = optional(value(EXTRA_SELECTED_PROFILE))) { + null -> null + PROFILE_PERSONAL -> Profile.Type.PERSONAL + PROFILE_WORK -> Profile.Type.WORK + else -> + error( + EXTRA_SELECTED_PROFILE + + " has invalid value ($selected)." + + " Must be either ResolverActivity.PROFILE_PERSONAL ($PROFILE_PERSONAL)" + + " or ResolverActivity.PROFILE_WORK ($PROFILE_WORK)." + ) + } +} diff --git a/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java b/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java index 64c8e49d..07e6e7b4 100644 --- a/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java @@ -40,7 +40,6 @@ import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; import com.android.intentresolver.shortcuts.ShortcutLoader; -import com.android.intentresolver.v2.ui.model.ChooserRequest; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import java.util.List; @@ -55,12 +54,11 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW private UsageStatsManager mUsm; @Override - protected final ChooserActivityLogic createActivityLogic(ChooserRequest chooserRequest) { + protected final ChooserActivityLogic createActivityLogic() { return new TestChooserActivityLogic( "ChooserWrapper", /* activity = */ this, this::onWorkProfileStatusUpdated, - chooserRequest, sOverrides.annotatedUserHandles, sOverrides.mWorkProfileAvailability); } diff --git a/tests/activity/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt b/tests/activity/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt index 3c22254a..fe649819 100644 --- a/tests/activity/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt +++ b/tests/activity/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt @@ -3,14 +3,12 @@ package com.android.intentresolver.v2 import androidx.activity.ComponentActivity import com.android.intentresolver.AnnotatedUserHandles import com.android.intentresolver.WorkProfileAvailabilityManager -import com.android.intentresolver.v2.ui.model.ChooserRequest /** Activity logic for use when testing [ChooserActivity]. */ class TestChooserActivityLogic( tag: String, activity: ComponentActivity, onWorkProfileStatusUpdated: () -> Unit, - chooserRequest: ChooserRequest? = null, private val annotatedUserHandlesOverride: AnnotatedUserHandles?, private val workProfileAvailabilityOverride: WorkProfileAvailabilityManager?, ) : @@ -18,7 +16,6 @@ class TestChooserActivityLogic( tag, activity, onWorkProfileStatusUpdated, - chooserRequest, ) { override val annotatedUserHandles: AnnotatedUserHandles? get() = annotatedUserHandlesOverride ?: super.annotatedUserHandles diff --git a/tests/activity/src/com/android/intentresolver/v2/ui/model/TestActivityLaunchModule.kt b/tests/activity/src/com/android/intentresolver/v2/ui/model/TestActivityLaunchModule.kt index d674bbc2..7dd15dbe 100644 --- a/tests/activity/src/com/android/intentresolver/v2/ui/model/TestActivityLaunchModule.kt +++ b/tests/activity/src/com/android/intentresolver/v2/ui/model/TestActivityLaunchModule.kt @@ -36,6 +36,6 @@ class TestActivityLaunchModule { companion object { const val LAUNCHED_FROM_PACKAGE = "example.com" const val LAUNCHED_FROM_UID = 1234 - val REFERRER: Uri = Uri.parse("android-app://$LAUNCHED_FROM_PACKAGE") + val REFERRER: Uri = Uri.fromParts(ANDROID_APP_SCHEME, LAUNCHED_FROM_PACKAGE, "") } } diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/model/ActivityLaunchTest.kt b/tests/unit/src/com/android/intentresolver/v2/ui/model/ActivityLaunchTest.kt index 3e9f43da..25eac220 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ui/model/ActivityLaunchTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/ui/model/ActivityLaunchTest.kt @@ -21,6 +21,7 @@ import android.content.Intent.ACTION_CHOOSER import android.content.Intent.EXTRA_TEXT import android.net.Uri import com.android.intentresolver.v2.ext.toParcelAndBack +import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage import org.junit.Test @@ -28,7 +29,7 @@ class ActivityLaunchTest { @Test fun testDefaultValues() { - val input = ActivityLaunch(Intent(ACTION_CHOOSER), 0, null, null) + val input = ActivityLaunch(Intent(ACTION_CHOOSER), 0, "example.com", null) val output = input.toParcelAndBack() @@ -46,7 +47,46 @@ class ActivityLaunchTest { assertEquals(input, output) } - fun assertEquals(expected: ActivityLaunch, actual: ActivityLaunch) { + @Test + fun testReferrerPackage_withAppReferrer_usesReferrer() { + val launch1 = + ActivityLaunch( + intent = Intent(), + fromUid = 1000, + fromPackage = "other.example.com", + referrer = Uri.parse("android-app://app.example.com") + ) + + assertThat(launch1.referrerPackage).isEqualTo("app.example.com") + } + + @Test + fun testReferrerPackage_httpReferrer_isNull() { + val launch = + ActivityLaunch( + intent = Intent(), + fromUid = 1000, + fromPackage = "example.com", + referrer = Uri.parse("http://some.other.value") + ) + + assertThat(launch.referrerPackage).isNull() + } + + @Test + fun testReferrerPackage_nullReferrer_isNull() { + val launch = + ActivityLaunch( + intent = Intent(), + fromUid = 1000, + fromPackage = "example.com", + referrer = null + ) + + assertThat(launch.referrerPackage).isNull() + } + + private fun assertEquals(expected: ActivityLaunch, actual: ActivityLaunch) { // Test fields separately: Intent does not override equals() assertWithMessage("%s.filterEquals(%s)", actual.intent, expected.intent) .that(actual.intent.filterEquals(expected.intent)) diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt index 29bb5cbd..3174c5f6 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt @@ -18,7 +18,11 @@ package com.android.intentresolver.v2.ui.viewmodel import android.content.Intent import android.content.Intent.ACTION_CHOOSER import android.content.Intent.ACTION_SEND +import android.content.Intent.ACTION_SEND_MULTIPLE +import android.content.Intent.EXTRA_ALTERNATE_INTENTS import android.content.Intent.EXTRA_INTENT +import android.content.Intent.EXTRA_REFERRER +import android.net.Uri import androidx.core.net.toUri import androidx.core.os.bundleOf import com.android.intentresolver.v2.ui.model.ActivityLaunch @@ -28,21 +32,27 @@ import com.android.intentresolver.v2.validation.ValidationResultSubject.Companio import com.google.common.truth.Truth.assertThat import org.junit.Test -@Suppress("DEPRECATION") -class ChooserRequestTest { +private fun createLaunch( + targetIntent: Intent?, + referrer: Uri? = null, + additionalIntents: List? = null +) = + ActivityLaunch( + Intent(ACTION_CHOOSER).apply { + targetIntent?.also { putExtra(EXTRA_INTENT, it) } + additionalIntents?.also { putExtra(EXTRA_ALTERNATE_INTENTS, it.toTypedArray()) } + }, + fromUid = 10000, + fromPackage = "com.android.example", + referrer = referrer ?: "android-app://com.android.example".toUri() + ) - val intent = Intent(ACTION_CHOOSER) - private val mActivityLaunch = - ActivityLaunch( - intent, - fromUid = 10000, - fromPackage = "com.android.example", - referrer = "android-app://com.android.example".toUri() - ) +class ChooserRequestTest { @Test fun missingIntent() { - val result = readChooserRequest(mActivityLaunch) + val launch = createLaunch(targetIntent = null) + val result = readChooserRequest(launch) assertThat(result).value().isNull() assertThat(result) @@ -51,14 +61,61 @@ class ChooserRequestTest { } @Test - fun minimal() { - intent.putExtras(bundleOf(EXTRA_INTENT to Intent(ACTION_SEND))) + fun referrerFillIn() { + val referrer = Uri.parse("android-app://example.com") + val launch = createLaunch(targetIntent = Intent(ACTION_SEND), referrer) + launch.intent.putExtras(bundleOf(EXTRA_REFERRER to referrer)) + + val result = readChooserRequest(launch) + + val fillIn = result.value?.getReferrerFillInIntent() + assertThat(fillIn?.hasExtra(EXTRA_REFERRER)).isTrue() + assertThat(fillIn?.getParcelableExtra(EXTRA_REFERRER, Uri::class.java)).isEqualTo(referrer) + } + + @Test + fun referrerPackage_isNullWithNonAppReferrer() { + val referrer = Uri.parse("http://example.com") + val intent = Intent().putExtras(bundleOf(EXTRA_INTENT to Intent(ACTION_SEND))) + + val launch = createLaunch(targetIntent = intent, referrer = referrer) + + val result = readChooserRequest(launch) + + assertThat(result.value?.referrerPackage).isNull() + } + + @Test + fun referrerPackage_fromAppReferrer() { + val referrer = Uri.parse("android-app://example.com") + val launch = createLaunch(targetIntent = Intent(ACTION_SEND), referrer) + + launch.intent.putExtras(bundleOf(EXTRA_REFERRER to referrer)) + + val result = readChooserRequest(launch) + + assertThat(result.value?.referrerPackage).isEqualTo(referrer.authority) + } - val result = readChooserRequest(mActivityLaunch) + @Test + fun payloadIntents_includesTargetThenAdditional() { + val intent1 = Intent(ACTION_SEND) + val intent2 = Intent(ACTION_SEND_MULTIPLE) + val launch = createLaunch(targetIntent = intent1, additionalIntents = listOf(intent2)) + val result = readChooserRequest(launch) + + assertThat(result.value?.payloadIntents).containsExactly(intent1, intent2) + } + + @Test + fun testRequest_withOnlyRequiredValues() { + val intent = Intent().putExtras(bundleOf(EXTRA_INTENT to Intent(ACTION_SEND))) + val launch = createLaunch(targetIntent = intent) + val result = readChooserRequest(launch) assertThat(result).value().isNotNull() val value: ChooserRequest = result.getOrThrow() - assertThat(value.launchedFromPackage).isEqualTo(mActivityLaunch.fromPackage) + assertThat(value.launchedFromPackage).isEqualTo(launch.fromPackage) assertThat(result).findings().isEmpty() } } diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestTest.kt b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestTest.kt new file mode 100644 index 00000000..a5acb0d3 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestTest.kt @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2024 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.v2.ui.viewmodel + +import android.content.Intent +import android.content.Intent.ACTION_VIEW +import android.net.Uri +import android.os.UserHandle +import androidx.core.net.toUri +import androidx.core.os.bundleOf +import com.android.intentresolver.v2.ResolverActivity.PROFILE_WORK +import com.android.intentresolver.v2.domain.model.Profile.Type.WORK +import com.android.intentresolver.v2.ui.model.ActivityLaunch +import com.android.intentresolver.v2.ui.model.ResolverRequest +import com.android.intentresolver.v2.validation.UncaughtException +import com.android.intentresolver.v2.validation.ValidationResultSubject.Companion.assertThat +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import org.junit.Test + +private val targetUri = Uri.parse("content://example.com/123") + +private fun createLaunch( + targetIntent: Intent, + referrer: Uri? = null, +) = + ActivityLaunch( + intent = targetIntent, + fromUid = 10000, + fromPackage = "com.android.example", + referrer = referrer ?: "android-app://com.android.example".toUri() + ) + +class ResolverRequestTest { + @Test + fun testDefaults() { + val intent = Intent(ACTION_VIEW).apply { data = targetUri } + val launch = createLaunch(intent) + + val result = readResolverRequest(launch) + assertThat(result).isSuccess() + assertThat(result).findings().isEmpty() + val value: ResolverRequest = result.getOrThrow() + + assertThat(value.intent.filterEquals(launch.intent)).isTrue() + assertThat(value.callingUser).isNull() + assertThat(value.selectedProfile).isNull() + } + + @Test + fun testInvalidSelectedProfile() { + val intent = + Intent(ACTION_VIEW).apply { + data = targetUri + putExtra(EXTRA_SELECTED_PROFILE, -1000) + } + + val launch = createLaunch(intent) + + val result = readResolverRequest(launch) + + assertThat(result).isFailure() + assertWithMessage("the first finding") + .that(result.findings.firstOrNull()) + .isInstanceOf(UncaughtException::class.java) + } + + @Test + fun payloadIntents_includesOnlyTarget() { + val intent2 = Intent(Intent.ACTION_SEND_MULTIPLE) + val intent1 = + Intent(Intent.ACTION_SEND).apply { + putParcelableArrayListExtra(Intent.EXTRA_ALTERNATE_INTENTS, arrayListOf(intent2)) + } + val launch = createLaunch(targetIntent = intent1) + + val result = readResolverRequest(launch) + + // Assert that payloadIntents does NOT include EXTRA_ALTERNATE_INTENTS + // that is only supported for Chooser and should be not be added here. + assertThat(result.value?.payloadIntents).containsExactly(intent1) + } + + @Test + fun testAllValues() { + val intent = Intent(ACTION_VIEW).apply { data = Uri.parse("content://example.com/123") } + val launch = createLaunch(targetIntent = intent) + + launch.intent.putExtras( + bundleOf( + EXTRA_CALLING_USER to UserHandle.of(123), + EXTRA_SELECTED_PROFILE to PROFILE_WORK, + EXTRA_IS_AUDIO_CAPTURE_DEVICE to true, + ) + ) + + val result = readResolverRequest(launch) + + assertThat(result).value().isNotNull() + val value: ResolverRequest = result.getOrThrow() + + assertThat(value.intent.filterEquals(launch.intent)).isTrue() + assertThat(value.isAudioCaptureDevice).isTrue() + assertThat(value.callingUser).isEqualTo(UserHandle.of(123)) + assertThat(value.selectedProfile).isEqualTo(WORK) + } +} -- cgit v1.2.3-59-g8ed1b From a123129f70dc00c71ee9f782bb746a1b5e314658 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Mon, 29 Jan 2024 14:10:07 -0500 Subject: Adds updateState to UserInteractor This allows the domain layer to modify the availability of a profile. Test: atest IntentResolver-tests-unit:UserInteractorTest Bug: 309960444 Change-Id: I70b5eac3e0d58a7b16c09b3814ad71719d3937dd --- .../v2/domain/interactor/UserInteractor.kt | 8 ++++++++ .../v2/domain/interactor/UserInteractorTest.kt | 23 ++++++++++++++++++++++ 2 files changed, 31 insertions(+) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.kt b/java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.kt index e1b3fb36..f12d8197 100644 --- a/java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.kt +++ b/java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.kt @@ -82,6 +82,14 @@ constructor( } } + /** + * Request the profile state be updated. In the case of enabling, the operation could take + * significant time and/or require user input. + */ + suspend fun updateState(profile: Profile, available: Boolean) { + userRepository.requestState(profile.primary, available) + } + private fun profileFromRole(role: Role): Type = when (role) { Role.PERSONAL -> Type.PERSONAL diff --git a/tests/unit/src/com/android/intentresolver/v2/domain/interactor/UserInteractorTest.kt b/tests/unit/src/com/android/intentresolver/v2/domain/interactor/UserInteractorTest.kt index 6fa055ef..4d246b9a 100644 --- a/tests/unit/src/com/android/intentresolver/v2/domain/interactor/UserInteractorTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/domain/interactor/UserInteractorTest.kt @@ -176,4 +176,27 @@ class UserInteractorTest { userRepo.removeUser(workUser) assertWithMessage("workAvailable").that(workAvailable).isFalse() } + + /** + * Similar to the above test in reverse: uses UserInteractor to modify state, and verify the + * state of the UserRepository. + */ + @Test + fun updateState() = runTest { + val userRepo = FakeUserRepository(workUser, personalUser) + val userInteractor = + UserInteractor(userRepository = userRepo, launchedAs = personalUser.handle) + val workProfile = Profile(Profile.Type.WORK, workUser) + + val availability by collectLastValue(userRepo.availability) + + // Default state is enabled in FakeUserManager + assertWithMessage("workAvailable").that(availability?.get(workUser)).isTrue() + + userInteractor.updateState(workProfile, false) + assertWithMessage("workAvailable").that(availability?.get(workUser)).isFalse() + + userInteractor.updateState(workProfile, true) + assertWithMessage("workAvailable").that(availability?.get(workUser)).isTrue() + } } -- cgit v1.2.3-59-g8ed1b From 7cba2e404f593df8eecfde11c765ecdaa2726a00 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Tue, 23 Jan 2024 22:33:14 -0500 Subject: Move ChooserListController out of ChooserActivity Test: n/a - purely mechanical refactor Change-Id: I00995885e0845b8bfff92b113feb80f29867b9a4 --- .../android/intentresolver/v2/ChooserActivity.java | 35 ++---------- .../intentresolver/v2/ChooserListController.java | 66 ++++++++++++++++++++++ .../v2/ChooserActivityOverrideData.java | 8 +-- 3 files changed, 74 insertions(+), 35 deletions(-) create mode 100644 java/src/com/android/intentresolver/v2/ChooserListController.java (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 29a792f6..64c5881c 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -1924,36 +1924,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return mEventLog; } - public class ChooserListController extends ResolverListController { - public ChooserListController( - Context context, - PackageManager pm, - Intent targetIntent, - String referrerPackageName, - int launchedFromUid, - AbstractResolverComparator resolverComparator, - UserHandle queryIntentsAsUser) { - super( - context, - pm, - targetIntent, - referrerPackageName, - launchedFromUid, - resolverComparator, - queryIntentsAsUser); - } - - @Override - public boolean isComponentFiltered(ComponentName name) { - return mViewModel.getChooserRequest().getFilteredComponentNames().contains(name); - } - - @Override - public boolean isComponentPinned(ComponentName name) { - return mPinnedSharedPrefs.getBoolean(name.flattenToString(), false); - } - } - + @VisibleForTesting public ChooserGridAdapter createChooserGridAdapter( Context context, List payloadIntents, @@ -2101,7 +2072,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mViewModel.getChooserRequest().getReferrerPackage(), requireAnnotatedUserHandles().userIdOfCallingApp, resolverComparator, - getQueryIntentsUser(userHandle)); + getQueryIntentsUser(userHandle), + mViewModel.getChooserRequest().getFilteredComponentNames(), + mPinnedSharedPrefs); } @VisibleForTesting diff --git a/java/src/com/android/intentresolver/v2/ChooserListController.java b/java/src/com/android/intentresolver/v2/ChooserListController.java new file mode 100644 index 00000000..467f343b --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ChooserListController.java @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2024 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.v2; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.os.UserHandle; + +import com.android.intentresolver.ResolverListController; +import com.android.intentresolver.model.AbstractResolverComparator; + +import java.util.List; + +public class ChooserListController extends ResolverListController { + private final List mFilteredComponents; + private final SharedPreferences mPinnedComponents; + + public ChooserListController( + Context context, + PackageManager pm, + Intent targetIntent, + String referrerPackageName, + int launchedFromUid, + AbstractResolverComparator resolverComparator, + UserHandle queryIntentsAsUser, + List filteredComponents, + SharedPreferences pinnedComponents) { + super( + context, + pm, + targetIntent, + referrerPackageName, + launchedFromUid, + resolverComparator, + queryIntentsAsUser); + mFilteredComponents = filteredComponents; + mPinnedComponents = pinnedComponents; + } + + @Override + public boolean isComponentFiltered(ComponentName name) { + return mFilteredComponents.contains(name); + } + + @Override + public boolean isComponentPinned(ComponentName name) { + return mPinnedComponents.getBoolean(name.flattenToString(), false); + } +} diff --git a/tests/activity/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java b/tests/activity/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java index df1399d8..d6ee706a 100644 --- a/tests/activity/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java +++ b/tests/activity/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java @@ -55,8 +55,8 @@ public class ChooserActivityOverrideData { public Function onSafelyStartCallback; public Function2, ShortcutLoader> shortcutLoaderFactory = (userHandle, callback) -> null; - public ChooserActivity.ChooserListController resolverListController; - public ChooserActivity.ChooserListController workResolverListController; + public ChooserListController resolverListController; + public ChooserListController workResolverListController; public Boolean isVoiceInteraction; public Cursor resolverCursor; public boolean resolverForceException; @@ -76,8 +76,8 @@ public class ChooserActivityOverrideData { imageLoader = null; resolverCursor = null; resolverForceException = false; - resolverListController = mock(ChooserActivity.ChooserListController.class); - workResolverListController = mock(ChooserActivity.ChooserListController.class); + resolverListController = mock(ChooserListController.class); + workResolverListController = mock(ChooserListController.class); alternateProfileSetting = 0; resources = null; annotatedUserHandles = AnnotatedUserHandles.newBuilder() -- cgit v1.2.3-59-g8ed1b From b386cca4830e69919ffff5a8e506b84b0debd579 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Tue, 6 Feb 2024 11:08:23 -0500 Subject: Fix duplicated User values when availability changes When the availability has updated, the updated user was appended, but the prvious value was never removed, causing the list to continue growing. Test: atest IntentResolver-tests-unit Test: onHandleAvailabilityChange_userStateMaintained Bug: 324073704 Change-Id: I3c51ea9e1a2cf0de9c064b08e7c9f21c4c0f7af1 --- .../v2/data/repository/UserRepository.kt | 5 ++++- .../v2/data/repository/UserRepositoryImplTest.kt | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt b/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt index 91ad6409..b57609e5 100644 --- a/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt +++ b/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt @@ -158,11 +158,14 @@ constructor( } } + private fun List.update(handle: UserHandle, user: UserWithState) = + filter { it.user.id != handle.identifier } + user + private fun handleAvailability(event: UserEvent, current: UserStates): UserStates { val userEntry = current.firstOrNull { it.user.id == event.user.identifier } ?: throw UserStateException("User was not present in the map", event) - return current + userEntry.copy(available = !event.quietMode) + return current.update(event.user, userEntry.copy(available = !event.quietMode)) } private fun handleProfileRemoved(event: UserEvent, current: UserStates): UserStates { diff --git a/tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt b/tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt index 6c61dfd6..16e8c9bb 100644 --- a/tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt @@ -78,6 +78,24 @@ internal class UserRepositoryImplTest { assertThat(available?.get(workUser)).isTrue() } + @Test + fun onHandleAvailabilityChange_userStateMaintained() = runTest { + val repo = createUserRepository(userManager) + val private = userState.createProfile(ProfileType.PRIVATE) + val privateUser = User(private.identifier, Role.PRIVATE) + + val users by collectLastValue(repo.users) + + repo.requestState(privateUser, false) + repo.requestState(privateUser, true) + + assertWithMessage("users.size") + .that(users?.size ?: 0).isEqualTo(2) // personal + private + + assertWithMessage("No duplicate IDs") + .that(users?.count { it.id == private.identifier }).isEqualTo(1) + } + @Test fun requestState() = runTest { val repo = createUserRepository(userManager) -- cgit v1.2.3-59-g8ed1b From 531bb351d0107e912c2d47e5d34fe53831ad11e5 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Tue, 6 Feb 2024 14:19:24 -0500 Subject: Fix usage of wrong resource constant The wrong 'R' class was referenced. Use of com.android.internal.R to fetch resources from the application will result in undefined behavior, crashes or just random strings. This is failing several activity tests due to assertions on text that is expected to appear. Test: atest IntentResolver-tests-activity Bug: n/a Change-Id: I3289b46d71469795a7e03416f29cbf8ad3868c2f --- .../intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java | 2 +- .../intentresolver/v2/emptystate/NoAppsAvailableEmptyStateProvider.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java index 2653c560..5f10cf32 100644 --- a/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java @@ -29,9 +29,9 @@ import android.stats.devicepolicy.nano.DevicePolicyEnums; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.android.intentresolver.R; import com.android.intentresolver.ResolvedComponentInfo; import com.android.intentresolver.ResolverListAdapter; -import com.android.internal.R; import java.util.List; diff --git a/java/src/com/android/intentresolver/v2/emptystate/NoAppsAvailableEmptyStateProvider.java b/java/src/com/android/intentresolver/v2/emptystate/NoAppsAvailableEmptyStateProvider.java index e9d1bb34..dfc46697 100644 --- a/java/src/com/android/intentresolver/v2/emptystate/NoAppsAvailableEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/v2/emptystate/NoAppsAvailableEmptyStateProvider.java @@ -29,11 +29,11 @@ import android.stats.devicepolicy.nano.DevicePolicyEnums; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.android.intentresolver.R; import com.android.intentresolver.ResolvedComponentInfo; import com.android.intentresolver.ResolverListAdapter; import com.android.intentresolver.emptystate.EmptyState; import com.android.intentresolver.emptystate.EmptyStateProvider; -import com.android.internal.R; import java.util.List; -- cgit v1.2.3-59-g8ed1b From 31f5bd90c0fd0bc534dfbf2496ca1166cbea6de7 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Wed, 7 Feb 2024 12:33:49 -0500 Subject: Inject multiple flag libs as uniquely named typealiases Bug: n/a Test: n/a Change-Id: I4763676802bf59a9582d26e467953dcc501dd8d6 --- .../com/android/intentresolver/inject/FeatureFlagsModule.kt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt b/java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt index 05cf2104..67186371 100644 --- a/java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt +++ b/java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt @@ -1,15 +1,21 @@ package com.android.intentresolver.inject -import com.android.intentresolver.FeatureFlags -import com.android.intentresolver.FeatureFlagsImpl +import android.service.chooser.FeatureFlagsImpl as ChooserServiceFlagsImpl +import com.android.intentresolver.FeatureFlagsImpl as IntentResolverFlagsImpl import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +typealias IntentResolverFlags = com.android.intentresolver.FeatureFlags + +typealias ChooserServiceFlags = android.service.chooser.FeatureFlags + @Module @InstallIn(SingletonComponent::class) object FeatureFlagsModule { - @Provides fun featureFlags(): FeatureFlags = FeatureFlagsImpl() + @Provides fun intentResolverFlags(): IntentResolverFlags = IntentResolverFlagsImpl() + + @Provides fun chooserServiceFlags(): ChooserServiceFlags = ChooserServiceFlagsImpl() } -- cgit v1.2.3-59-g8ed1b From af780e790cad2afb2c5bf0a67ac8782e95e94a23 Mon Sep 17 00:00:00 2001 From: Steve Elliott Date: Wed, 7 Feb 2024 18:16:21 -0500 Subject: Shareousel composables and view-model definitions Bug: 302691505 Flag: ACONFIG android.service.chooser.chooser_payload_toggling DEVELOPMENT Test: N/A - code isn't live Change-Id: Ibf197591b238a286b12290ccc33d69ab3efd56d8 --- Android.bp | 9 ++ java/res/drawable/checkbox.xml | 10 ++ java/res/drawable/ic_play_circle_filled_24px.xml | 3 + .../ui/composable/ComposeIconComposable.kt | 48 +++++++ .../ui/composable/ShareouselCardComposable.kt | 129 ++++++++++++++++++ .../ui/composable/ShareouselComposable.kt | 150 +++++++++++++++++++++ .../shareousel/ui/viewmodel/ShareouselViewModel.kt | 38 ++++++ .../com/android/intentresolver/icon/ComposeIcon.kt | 88 ++++++++++++ 8 files changed, 475 insertions(+) create mode 100644 java/res/drawable/checkbox.xml create mode 100644 java/res/drawable/ic_play_circle_filled_24px.xml create mode 100644 java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ComposeIconComposable.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt create mode 100644 java/src/com/android/intentresolver/icon/ComposeIcon.kt (limited to 'java/src') diff --git a/Android.bp b/Android.bp index 2e67398d..4b411efa 100644 --- a/Android.bp +++ b/Android.bp @@ -59,6 +59,15 @@ android_library { "kotlinx-coroutines-android", "//external/kotlinc:kotlin-annotations", "guava", + "PlatformComposeCore", + "PlatformComposeSceneTransitionLayout", + "androidx.compose.runtime_runtime", + "androidx.compose.material3_material3", + "androidx.compose.material_material-icons-extended", + "androidx.activity_activity-compose", + "androidx.compose.animation_animation-graphics", + "androidx.lifecycle_lifecycle-viewmodel-compose", + "androidx.lifecycle_lifecycle-runtime-compose", ], } diff --git a/java/res/drawable/checkbox.xml b/java/res/drawable/checkbox.xml new file mode 100644 index 00000000..189d01ff --- /dev/null +++ b/java/res/drawable/checkbox.xml @@ -0,0 +1,10 @@ + + + diff --git a/java/res/drawable/ic_play_circle_filled_24px.xml b/java/res/drawable/ic_play_circle_filled_24px.xml new file mode 100644 index 00000000..f67127ca --- /dev/null +++ b/java/res/drawable/ic_play_circle_filled_24px.xml @@ -0,0 +1,3 @@ + + + diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ComposeIconComposable.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ComposeIconComposable.kt new file mode 100644 index 00000000..87fb7618 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ComposeIconComposable.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2024 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.shareousel.ui.composable + +import android.content.Context +import android.content.ContextWrapper +import android.content.res.Resources +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import com.android.intentresolver.icon.AdaptiveIcon +import com.android.intentresolver.icon.BitmapIcon +import com.android.intentresolver.icon.ComposeIcon +import com.android.intentresolver.icon.ResourceIcon + +@Composable +fun Image(icon: ComposeIcon) { + when (icon) { + is AdaptiveIcon -> Image(icon.wrapped) + is BitmapIcon -> Image(icon.bitmap.asImageBitmap(), contentDescription = null) + is ResourceIcon -> { + val localContext = LocalContext.current + val wrappedContext: Context = + object : ContextWrapper(localContext) { + override fun getResources(): Resources = icon.res + } + CompositionLocalProvider(LocalContext provides wrappedContext) { + Image(painterResource(icon.resId), contentDescription = null) + } + } + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt new file mode 100644 index 00000000..a1ccd9dd --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2024 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.shareousel.ui.composable + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.android.intentresolver.R + +@Composable +fun ShareouselCard( + image: @Composable () -> Unit, + selected: Boolean, + onActionClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier) { + image() + val topButtonPadding = 12.dp + Box(modifier = Modifier.padding(topButtonPadding).fillMaxSize()) { + SelectionIcon(selected, modifier = Modifier.align(Alignment.TopStart)) + AnimationIcon(modifier = Modifier.align(Alignment.TopEnd)) + ActionButton( + onActionClick, + modifier = + Modifier.background( + MaterialTheme.colorScheme.secondary, + shape = RoundedCornerShape(12.dp), + ) + .size(32.dp) + .align(Alignment.BottomEnd) + ) + } + } +} + +@Composable +private fun ActionButton( + onActionClick: () -> Unit, + modifier: Modifier = Modifier, +) { + IconButton(onClick = { onActionClick() }, modifier = modifier) { + Icon( + Icons.Outlined.Edit, + contentDescription = "edit", + tint = Color(0xFF1B1C14), + modifier = Modifier.padding(8.dp) + ) + } +} + +@Composable +private fun AnimationIcon(modifier: Modifier = Modifier) { + Icon( + painterResource(id = R.drawable.ic_play_circle_filled_24px), + "animating", + tint = Color.White, + modifier = Modifier.size(20.dp).then(modifier) + ) +} + +@Composable +private fun SelectionIcon(selected: Boolean, modifier: Modifier = Modifier) { + if (selected) { + val bgColor = MaterialTheme.colorScheme.primary + Icon( + painter = painterResource(id = R.drawable.checkbox), + tint = Color.White, + contentDescription = "selected", + modifier = + Modifier.shadow( + elevation = 50.dp, + spotColor = Color(0x40000000), + ambientColor = Color(0x40000000) + ) + .size(20.dp) + .drawBehind { + drawCircle(color = bgColor, radius = (this.size.width / 2f) - 1f) + } + .then(modifier) + ) + } else { + Box( + modifier = + Modifier.shadow( + elevation = 50.dp, + spotColor = Color(0x40000000), + ambientColor = Color(0x40000000), + ) + .border(width = 2.dp, color = Color(0xFFFFFFFF), shape = CircleShape) + .clip(CircleShape) + .size(20.dp) + .background(color = Color(0x7DC4C4C4)) + .then(modifier) + ) + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt new file mode 100644 index 00000000..c83c10b0 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt @@ -0,0 +1,150 @@ +/* + * Copyright (C) 2024 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.shareousel.ui.composable + +import android.os.Parcelable +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AssistChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.android.intentresolver.R +import com.android.intentresolver.contentpreview.shareousel.ui.viewmodel.ShareouselImageViewModel +import com.android.intentresolver.contentpreview.shareousel.ui.viewmodel.ShareouselViewModel + +@Composable +fun Shareousel(viewModel: ShareouselViewModel) { + val previewKeys by viewModel.previewKeys.collectAsStateWithLifecycle(initialValue = emptyList()) + val centerIdx by viewModel.centerIndex.collectAsStateWithLifecycle(initialValue = 0) + Column { + // TODO: item needs to be centered, check out ScalingLazyColumn impl or see if + // HorizontalPager works for our use-case + val carouselState = + rememberLazyListState( + initialFirstVisibleItemIndex = centerIdx, + ) + LazyRow( + state = carouselState, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = + Modifier.fillMaxWidth() + .height(dimensionResource(R.dimen.chooser_preview_image_height_tall)) + ) { + items(previewKeys, key = { (it as? Parcelable) ?: Unit }) { key -> + ShareouselCard(viewModel.previewForKey(key)) + } + } + Spacer(modifier = Modifier.height(8.dp)) + + val actions by viewModel.actions.collectAsStateWithLifecycle(initialValue = emptyList()) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(actions) { actionViewModel -> + ShareouselAction( + label = actionViewModel.label, + onClick = actionViewModel.onClick, + ) { + actionViewModel.icon?.let { Image(it) } + } + } + } + } +} + +private const val MIN_ASPECT_RATIO = 0.4f +private const val MAX_ASPECT_RATIO = 2.5f + +@Composable +private fun ShareouselCard(viewModel: ShareouselImageViewModel) { + val bitmap by viewModel.bitmap.collectAsStateWithLifecycle(initialValue = null) + val selected by viewModel.isSelected.collectAsStateWithLifecycle(initialValue = false) + val contentDescription by + viewModel.contentDescription.collectAsStateWithLifecycle(initialValue = null) + val borderColor = MaterialTheme.colorScheme.primary + + ShareouselCard( + image = { + bitmap?.let { bitmap -> + val aspectRatio = + (bitmap.width.toFloat() / bitmap.height.toFloat()) + // TODO: max ratio is actually equal to the viewport ratio + .coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO) + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = contentDescription, + contentScale = ContentScale.Crop, + modifier = Modifier.aspectRatio(aspectRatio), + ) + } + ?: run { + // TODO: look at ScrollableImagePreviewView.setLoading() + Box(modifier = Modifier.aspectRatio(2f / 5f)) + } + }, + selected = selected, + onActionClick = { viewModel.onActionClick() }, + modifier = + Modifier.thenIf(selected) { + Modifier.border( + width = 4.dp, + color = borderColor, + shape = RoundedCornerShape(size = 12.dp) + ) + } + .clip(RoundedCornerShape(size = 12.dp)) + .clickable { viewModel.setSelected(!selected) }, + ) +} + +@Composable +private fun ShareouselAction( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + leadingIcon: (@Composable () -> Unit)? = null, +) { + AssistChip( + onClick = onClick, + label = { Text(label) }, + leadingIcon = leadingIcon, + modifier = modifier + ) +} + +inline fun Modifier.thenIf(condition: Boolean, crossinline factory: () -> Modifier): Modifier = + if (condition) this.then(factory()) else this diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt new file mode 100644 index 00000000..39f2040b --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2024 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.shareousel.ui.viewmodel + +import android.graphics.Bitmap +import com.android.intentresolver.icon.ComposeIcon +import kotlinx.coroutines.flow.Flow + +data class ShareouselViewModel( + val headline: Flow, + val previewKeys: Flow>, + val actions: Flow>, + val centerIndex: Flow, + val previewForKey: (key: Any) -> ShareouselImageViewModel, +) + +data class ActionChipViewModel(val label: String, val icon: ComposeIcon?, val onClick: () -> Unit) + +data class ShareouselImageViewModel( + val bitmap: Flow, + val contentDescription: Flow, + val isSelected: Flow, + val setSelected: (Boolean) -> Unit, + val onActionClick: () -> Unit, +) diff --git a/java/src/com/android/intentresolver/icon/ComposeIcon.kt b/java/src/com/android/intentresolver/icon/ComposeIcon.kt new file mode 100644 index 00000000..dbea1e55 --- /dev/null +++ b/java/src/com/android/intentresolver/icon/ComposeIcon.kt @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2024 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.icon + +import android.content.ContentResolver +import android.content.pm.PackageManager +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.drawable.Icon +import java.io.File +import java.io.FileInputStream + +sealed interface ComposeIcon + +data class BitmapIcon(val bitmap: Bitmap) : ComposeIcon + +data class ResourceIcon(val resId: Int, val res: Resources) : ComposeIcon + +@JvmInline value class AdaptiveIcon(val wrapped: ComposeIcon) : ComposeIcon + +fun Icon.toComposeIcon(pm: PackageManager, resolver: ContentResolver): ComposeIcon? { + return when (type) { + Icon.TYPE_BITMAP -> BitmapIcon(bitmap) + Icon.TYPE_RESOURCE -> pm.resourcesForPackage(resPackage)?.let { ResourceIcon(resId, it) } + Icon.TYPE_DATA -> + BitmapIcon(BitmapFactory.decodeByteArray(dataBytes, dataOffset, dataLength)) + Icon.TYPE_URI -> uriIcon(resolver) + Icon.TYPE_ADAPTIVE_BITMAP -> AdaptiveIcon(BitmapIcon(bitmap)) + Icon.TYPE_URI_ADAPTIVE_BITMAP -> uriIcon(resolver)?.let { AdaptiveIcon(it) } + else -> error("unexpected icon type: $type") + } +} + +fun Icon.toComposeIcon(resources: Resources?, resolver: ContentResolver): ComposeIcon? { + return when (type) { + Icon.TYPE_BITMAP -> BitmapIcon(bitmap) + Icon.TYPE_RESOURCE -> resources?.let { ResourceIcon(resId, resources) } + Icon.TYPE_DATA -> + BitmapIcon(BitmapFactory.decodeByteArray(dataBytes, dataOffset, dataLength)) + Icon.TYPE_URI -> uriIcon(resolver) + Icon.TYPE_ADAPTIVE_BITMAP -> AdaptiveIcon(BitmapIcon(bitmap)) + Icon.TYPE_URI_ADAPTIVE_BITMAP -> uriIcon(resolver)?.let { AdaptiveIcon(it) } + else -> error("unexpected icon type: $type") + } +} + +// TODO: this is probably constant and doesn't need to be re-queried for each icon +fun PackageManager.resourcesForPackage(pkgName: String): Resources? { + return if (pkgName == "android") { + Resources.getSystem() + } else { + runCatching { + this@resourcesForPackage.getApplicationInfo( + pkgName, + PackageManager.MATCH_UNINSTALLED_PACKAGES or + PackageManager.GET_SHARED_LIBRARY_FILES + ) + } + .getOrNull() + ?.let { ai -> getResourcesForApplication(ai) } + } +} + +private fun Icon.uriIcon(resolver: ContentResolver): BitmapIcon? { + return runCatching { + when (uri.scheme) { + ContentResolver.SCHEME_CONTENT, + ContentResolver.SCHEME_FILE -> resolver.openInputStream(uri) + else -> FileInputStream(File(uriString)) + } + } + .getOrNull() + ?.let { inStream -> BitmapIcon(BitmapFactory.decodeStream(inStream)) } +} -- cgit v1.2.3-59-g8ed1b From 8a447de9e360df5e3c1ad7e3defcc84c4d9f4594 Mon Sep 17 00:00:00 2001 From: Steve Elliott Date: Wed, 7 Feb 2024 18:45:25 -0500 Subject: Shareousel content preview ui + view-model Domain is mostly stubbed, coming in later CLs. Bug: 302691505 Flag: ACONFIG android.service.chooser.chooser_payload_toggling DEVELOPMENT Test: N/A - code isn't live Change-Id: Id9041f1e842007aef64653e8f96357d90bb7a657 --- .../android/intentresolver/ChooserActivity.java | 2 +- .../contentpreview/BasePreviewViewModel.kt | 4 +- .../contentpreview/PayloadToggleInteractor.kt | 63 ++++++++++++++++ .../contentpreview/PreviewViewModel.kt | 29 ++++---- .../contentpreview/ShareouselContentPreviewUi.kt | 83 ++++++++++++++++++++++ .../shareousel/ui/viewmodel/ShareouselViewModel.kt | 23 ++++++ .../android/intentresolver/v2/ChooserActivity.java | 2 +- .../intentresolver/TestContentPreviewViewModel.kt | 23 +++--- 8 files changed, 203 insertions(+), 26 deletions(-) create mode 100644 java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt create mode 100644 java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 820fa3b2..c3b13527 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -307,7 +307,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements getCoroutineScope(getLifecycle()), previewViewModel.createOrReuseProvider(mChooserRequest.getTargetIntent()), mChooserRequest.getTargetIntent(), - previewViewModel.createOrReuseImageLoader(), + previewViewModel.getImageLoader(), createChooserActionFactory(), mEnterTransitionAnimationDelegate, new HeadlineGeneratorImpl(this)); diff --git a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt index 4c781a46..3b20a45c 100644 --- a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt @@ -24,5 +24,7 @@ import androidx.lifecycle.ViewModel abstract class BasePreviewViewModel : ViewModel() { @MainThread abstract fun createOrReuseProvider(targetIntent: Intent): PreviewDataProvider - @MainThread abstract fun createOrReuseImageLoader(): ImageLoader + abstract val imageLoader: ImageLoader + + abstract val payloadToggleInteractor: PayloadToggleInteractor? } diff --git a/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt b/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt new file mode 100644 index 00000000..0e6d3869 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2024 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 +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update + +class PayloadToggleInteractor { + + private val storage = MutableStateFlow>(emptyMap()) // TODO: implement + private val selectedKeys = MutableStateFlow>(emptySet()) + + val targetPosition: Flow = flowOf(0) // TODO: implement + val previewKeys: Flow> = flowOf(emptyList()) // TODO: implement + + fun setSelected(key: Any, isSelected: Boolean) { + if (isSelected) { + selectedKeys.update { it + key } + } else { + selectedKeys.update { it - key } + } + } + + fun selected(key: Any): Flow = previewKeys.map { key in it } + + fun previewInteractor(key: Any) = PayloadTogglePreviewInteractor(key, this) + + fun previewUri(key: Any): Flow = storage.map { it[key]?.previewUri } + + private data class Item( + val previewUri: Uri?, + ) +} + +class PayloadTogglePreviewInteractor( + private val key: Any, + private val interactor: PayloadToggleInteractor, +) { + fun setSelected(selected: Boolean) { + interactor.setSelected(key, selected) + } + + val previewUri: Flow = interactor.previewUri(key) + val selected: Flow = interactor.selected(key) +} diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt index 9acc4689..d855ea16 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt @@ -41,7 +41,6 @@ constructor( @Background private val dispatcher: CoroutineDispatcher = Dispatchers.IO, ) : BasePreviewViewModel() { private var previewDataProvider: PreviewDataProvider? = null - private var imageLoader: ImagePreviewImageLoader? = null @MainThread override fun createOrReuseProvider(targetIntent: Intent): PreviewDataProvider = @@ -53,19 +52,21 @@ constructor( ) .also { previewDataProvider = it } - @MainThread - override fun createOrReuseImageLoader(): ImageLoader = - imageLoader - ?: ImagePreviewImageLoader( - viewModelScope + dispatcher, - thumbnailSize = - application.resources.getDimensionPixelSize( - R.dimen.chooser_preview_image_max_dimen - ), - application.contentResolver, - cacheSize = 16 - ) - .also { imageLoader = it } + override val imageLoader by lazy { + ImagePreviewImageLoader( + viewModelScope + dispatcher, + thumbnailSize = + application.resources.getDimensionPixelSize( + R.dimen.chooser_preview_image_max_dimen + ), + application.contentResolver, + cacheSize = 16 + ) + } + + override val payloadToggleInteractor: PayloadToggleInteractor? by lazy { + PayloadToggleInteractor() + } companion object { val Factory: ViewModelProvider.Factory = diff --git a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt new file mode 100644 index 00000000..a10d3272 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2024 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.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.ComposeView +import androidx.lifecycle.viewmodel.compose.viewModel +import com.android.intentresolver.R +import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory +import com.android.intentresolver.contentpreview.shareousel.ui.composable.Shareousel +import com.android.intentresolver.contentpreview.shareousel.ui.viewmodel.toShareouselViewModel + +internal class ShareouselContentPreviewUi( + private val actionFactory: ActionFactory, +) : ContentPreviewUi() { + + override fun getType(): Int = ContentPreviewType.CONTENT_PREVIEW_IMAGE + + override fun display( + resources: Resources, + layoutInflater: LayoutInflater, + parent: ViewGroup, + headlineViewParent: View?, + ): ViewGroup { + return displayInternal(parent, headlineViewParent).also { layout -> + displayModifyShareAction(headlineViewParent ?: layout, actionFactory) + } + } + + private fun displayInternal( + parent: ViewGroup, + headlineViewParent: View?, + ): ViewGroup { + if (headlineViewParent != null) { + inflateHeadline(headlineViewParent) + } + val composeView = + ComposeView(parent.context).apply { + setContent { + val vm: BasePreviewViewModel = viewModel() + val interactor = + requireNotNull(vm.payloadToggleInteractor) { "Should not be null" } + val viewModel = interactor.toShareouselViewModel(vm.imageLoader) + + if (headlineViewParent != null) { + LaunchedEffect(Unit) { + viewModel.headline.collect { headline -> + headlineViewParent.findViewById(R.id.headline)?.apply { + if (headline.isNotBlank()) { + text = headline + visibility = View.VISIBLE + } else { + visibility = View.GONE + } + } + } + } + } + + Shareousel(viewModel = viewModel) + } + } + return composeView + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt index 39f2040b..4592ea6d 100644 --- a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt @@ -16,8 +16,12 @@ package com.android.intentresolver.contentpreview.shareousel.ui.viewmodel import android.graphics.Bitmap +import com.android.intentresolver.contentpreview.ImageLoader +import com.android.intentresolver.contentpreview.PayloadToggleInteractor import com.android.intentresolver.icon.ComposeIcon import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map data class ShareouselViewModel( val headline: Flow, @@ -36,3 +40,22 @@ data class ShareouselImageViewModel( val setSelected: (Boolean) -> Unit, val onActionClick: () -> Unit, ) + +fun PayloadToggleInteractor.toShareouselViewModel(imageLoader: ImageLoader): ShareouselViewModel { + return ShareouselViewModel( + headline = MutableStateFlow("Shareousel"), + previewKeys = previewKeys, + actions = MutableStateFlow(emptyList()), + centerIndex = targetPosition, + previewForKey = { key -> + val previewInteractor = previewInteractor(key) + ShareouselImageViewModel( + bitmap = previewInteractor.previewUri.map { uri -> uri?.let { imageLoader(uri) } }, + contentDescription = MutableStateFlow(""), + isSelected = previewInteractor.selected, + setSelected = { isSelected -> previewInteractor.setSelected(isSelected) }, + onActionClick = {}, + ) + } + ) +} diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 64c5881c..b9cf53b6 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -474,7 +474,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements getCoroutineScope(getLifecycle()), previewViewModel.createOrReuseProvider(chooserRequest.getTargetIntent()), chooserRequest.getTargetIntent(), - previewViewModel.createOrReuseImageLoader(), + previewViewModel.getImageLoader(), createChooserActionFactory(), mEnterTransitionAnimationDelegate, new HeadlineGeneratorImpl(this)); diff --git a/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt b/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt index 888fc161..998c0802 100644 --- a/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt +++ b/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt @@ -22,19 +22,22 @@ 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.PayloadToggleInteractor 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, + private val imageLoaderDelegate: ImageLoader?, ) : BasePreviewViewModel() { - override fun createOrReuseProvider( - targetIntent: Intent - ): PreviewDataProvider = viewModel.createOrReuseProvider(targetIntent) + override fun createOrReuseProvider(targetIntent: Intent): PreviewDataProvider = + viewModel.createOrReuseProvider(targetIntent) - override fun createOrReuseImageLoader(): ImageLoader = - imageLoader ?: viewModel.createOrReuseImageLoader() + override val imageLoader: ImageLoader + get() = imageLoaderDelegate ?: viewModel.imageLoader + + override val payloadToggleInteractor: PayloadToggleInteractor? + get() = viewModel.payloadToggleInteractor companion object { fun wrap( @@ -47,10 +50,12 @@ class TestContentPreviewViewModel( modelClass: Class, extras: CreationExtras ): T { + val wrapped = factory.create(modelClass, extras) as BasePreviewViewModel return TestContentPreviewViewModel( - factory.create(modelClass, extras) as BasePreviewViewModel, - imageLoader, - ) as T + wrapped, + imageLoader ?: wrapped.imageLoader, + ) + as T } } } -- cgit v1.2.3-59-g8ed1b From 9155b17d49ec45bd787b3c45de6324278ebe0928 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Wed, 7 Feb 2024 21:31:28 -0800 Subject: Add new API arguments to ChooserRequest Bug: 302691505 Test: atest IntentResolver-tests-unit Change-Id: I8f7122649f07a56a67df3db513fe73f5870e3eb6 --- .../intentresolver/v2/ui/model/ChooserRequest.kt | 8 +- .../v2/ui/viewmodel/ChooserRequestReader.kt | 23 ++++- .../v2/ui/viewmodel/ChooserRequestTest.kt | 99 ++++++++++++++++++++++ 3 files changed, 128 insertions(+), 2 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt b/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt index d41d0874..5c785675 100644 --- a/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt +++ b/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt @@ -162,7 +162,13 @@ data class ChooserRequest( * query for matching shortcuts. Specifically, only the [dataTypes][IntentFilter.hasDataType] * are considered for matching share shortcuts currently. */ - val shareTargetFilter: IntentFilter? = null + val shareTargetFilter: IntentFilter? = null, + + /** A URI for additional content */ + val additionalContentUri: Uri? = null, + + /** Focused item index (from target intent's STREAM_EXTRA) */ + val focusedItemPosition: Int = 0, ) { val referrerPackage = referrer?.takeIf { it.scheme == ANDROID_APP_SCHEME }?.authority diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt index 45e2ea64..167c441f 100644 --- a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt +++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt @@ -36,9 +36,11 @@ import android.content.Intent.FLAG_ACTIVITY_MULTIPLE_TASK import android.content.Intent.FLAG_ACTIVITY_NEW_DOCUMENT import android.content.IntentFilter import android.content.IntentSender +import android.net.Uri import android.os.Bundle import android.service.chooser.ChooserAction import android.service.chooser.ChooserTarget +import android.service.chooser.Flags import com.android.intentresolver.ChooserActivity import com.android.intentresolver.R import com.android.intentresolver.util.hasValidIcon @@ -55,6 +57,13 @@ import com.android.intentresolver.v2.validation.validateFrom private const val MAX_CHOOSER_ACTIONS = 5 private const val MAX_INITIAL_INTENTS = 2 +// TODO: replace with the new API constant, Intent#EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI +private const val EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI = + "android.intent.extra.CHOOSER_ADDITIONAL_CONTENT_URI" +// TODO: replace with the new API constant, Intent#EXTRA_CHOOSER_FOCUSED_ITEM_POSITION +private const val EXTRA_CHOOSER_FOCUSED_ITEM_POSITION = + "android.intent.extra.CHOOSER_FOCUSED_ITEM_POSITION" + private fun Intent.hasSendAction() = hasAction(ACTION_SEND, ACTION_SEND_MULTIPLE) internal fun Intent.maybeAddSendActionFlags() = @@ -124,6 +133,16 @@ fun readChooserRequest(launch: ActivityLaunch): ValidationResult val referrerFillIn = Intent().putExtra(EXTRA_REFERRER, launch.referrer) + val additionalContentUri: Uri? + val focusedItemPos: Int + if (isSendAction && Flags.chooserPayloadToggling()) { + additionalContentUri = optional(value(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI)) + focusedItemPos = optional(value(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION)) ?: 0 + } else { + additionalContentUri = null + focusedItemPos = 0 + } + ChooserRequest( targetIntent = targetIntent, targetAction = targetIntent.action, @@ -147,7 +166,9 @@ fun readChooserRequest(launch: ActivityLaunch): ValidationResult chosenComponentSender = chosenComponentSender, refinementIntentSender = refinementIntentSender, sharedText = sharedText, - shareTargetFilter = targetIntent.toShareTargetFilter() + shareTargetFilter = targetIntent.toShareTargetFilter(), + additionalContentUri = additionalContentUri, + focusedItemPosition = focusedItemPos, ) } } diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt index 3174c5f6..9ac24c64 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt @@ -19,10 +19,15 @@ import android.content.Intent import android.content.Intent.ACTION_CHOOSER import android.content.Intent.ACTION_SEND import android.content.Intent.ACTION_SEND_MULTIPLE +import android.content.Intent.ACTION_VIEW import android.content.Intent.EXTRA_ALTERNATE_INTENTS import android.content.Intent.EXTRA_INTENT import android.content.Intent.EXTRA_REFERRER import android.net.Uri +import android.platform.test.annotations.RequiresFlagsDisabled +import android.platform.test.annotations.RequiresFlagsEnabled +import android.platform.test.flag.junit.CheckFlagsRule +import android.platform.test.flag.junit.DeviceFlagsValueProvider import androidx.core.net.toUri import androidx.core.os.bundleOf import com.android.intentresolver.v2.ui.model.ActivityLaunch @@ -30,8 +35,16 @@ import com.android.intentresolver.v2.ui.model.ChooserRequest import com.android.intentresolver.v2.validation.RequiredValueMissing import com.android.intentresolver.v2.validation.ValidationResultSubject.Companion.assertThat import com.google.common.truth.Truth.assertThat +import org.junit.Rule import org.junit.Test +// TODO: replace with the new API constant, Intent#EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI +private const val EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI = + "android.intent.extra.CHOOSER_ADDITIONAL_CONTENT_URI" +// TODO: replace with the new API constant, Intent#EXTRA_CHOOSER_FOCUSED_ITEM_POSITION +private const val EXTRA_CHOOSER_FOCUSED_ITEM_POSITION = + "android.intent.extra.CHOOSER_FOCUSED_ITEM_POSITION" + private fun createLaunch( targetIntent: Intent?, referrer: Uri? = null, @@ -48,6 +61,7 @@ private fun createLaunch( ) class ChooserRequestTest { + @get:Rule val checkFlagsRule: CheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule() @Test fun missingIntent() { @@ -118,4 +132,89 @@ class ChooserRequestTest { assertThat(value.launchedFromPackage).isEqualTo(launch.fromPackage) assertThat(result).findings().isEmpty() } + + @Test + @RequiresFlagsEnabled(android.service.chooser.Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING) + fun testRequest_actionSendWithAdditionalContentUri() { + val uri = Uri.parse("content://org.pkg/path") + val position = 10 + val launch = + createLaunch(targetIntent = Intent(ACTION_SEND)).apply { + intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, uri) + intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, position) + } + val result = readChooserRequest(launch) + + assertThat(result).value().isNotNull() + val value: ChooserRequest = result.getOrThrow() + assertThat(value.additionalContentUri).isEqualTo(uri) + assertThat(value.focusedItemPosition).isEqualTo(position) + assertThat(result).findings().isEmpty() + } + + @Test + @RequiresFlagsDisabled(android.service.chooser.Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING) + fun testRequest_actionSendWithAdditionalContentUri_parametersIgnoredWhenFlagDisabled() { + val uri = Uri.parse("content://org.pkg/path") + val position = 10 + val launch = + createLaunch(targetIntent = Intent(ACTION_SEND)).apply { + intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, uri) + intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, position) + } + val result = readChooserRequest(launch) + + assertThat(result).value().isNotNull() + val value: ChooserRequest = result.getOrThrow() + assertThat(value.additionalContentUri).isNull() + assertThat(value.focusedItemPosition).isEqualTo(0) + assertThat(result).findings().isEmpty() + } + + @Test + @RequiresFlagsEnabled(android.service.chooser.Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING) + fun testRequest_actionSendWithInvalidAdditionalContentUri() { + val launch = + createLaunch(targetIntent = Intent(ACTION_SEND)).apply { + intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, "content://org.pkg/path") + intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, "1") + } + val result = readChooserRequest(launch) + + assertThat(result).value().isNotNull() + val value: ChooserRequest = result.getOrThrow() + assertThat(value.additionalContentUri).isNull() + assertThat(value.focusedItemPosition).isEqualTo(0) + } + + @Test + @RequiresFlagsEnabled(android.service.chooser.Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING) + fun testRequest_actionSendWithoutAdditionalContentUri() { + val launch = createLaunch(targetIntent = Intent(ACTION_SEND)) + val result = readChooserRequest(launch) + + assertThat(result).value().isNotNull() + val value: ChooserRequest = result.getOrThrow() + assertThat(value.additionalContentUri).isNull() + assertThat(value.focusedItemPosition).isEqualTo(0) + } + + @Test + @RequiresFlagsEnabled(android.service.chooser.Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING) + fun testRequest_actionViewWithAdditionalContentUri() { + val uri = Uri.parse("content://org.pkg/path") + val position = 10 + val launch = + createLaunch(targetIntent = Intent(ACTION_VIEW)).apply { + intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, uri) + intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, position) + } + val result = readChooserRequest(launch) + + assertThat(result).value().isNotNull() + val value: ChooserRequest = result.getOrThrow() + assertThat(value.additionalContentUri).isNull() + assertThat(value.focusedItemPosition).isEqualTo(0) + assertThat(result).findings().isEmpty() + } } -- cgit v1.2.3-59-g8ed1b From 088ca398b615c202c536bee617a0d6d3d356159d Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Thu, 8 Feb 2024 11:49:57 -0800 Subject: Shareousel cursor reader A building block for the payload toggling functinality -- a bi-directional cursor reaer that starts reading URIs from the cursor starting at a specified position ignoring all items thad does not match a given predicate. Bug: 302691505 Test: IntentResolver-tests-unit Change-Id: I83d40435f4b73c73f6d8b49080645a008d34727b --- .../contentpreview/CursorUriReader.kt | 99 ++++++++++++++++++++++ .../contentpreview/PayloadToggleInteractor.kt | 12 +++ .../contentpreview/CursorUriReaderTest.kt | 94 ++++++++++++++++++++ 3 files changed, 205 insertions(+) create mode 100644 java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt create mode 100644 tests/unit/src/com/android/intentresolver/contentpreview/CursorUriReaderTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt b/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt new file mode 100644 index 00000000..30495b8b --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt @@ -0,0 +1,99 @@ +/* + * Copyright 2024 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.database.Cursor +import android.net.Uri +import android.util.Log +import android.util.SparseArray + +private const val TAG = ContentPreviewUi.TAG + +/** + * A bi-directional cursor reader. Reads URI from the [cursor] starting from the given [startPos], + * filters items by [predicate]. + */ +class CursorUriReader( + private val cursor: Cursor, + startPos: Int, + private val pageSize: Int, + private val predicate: (Uri) -> Boolean, +) : PayloadToggleInteractor.CursorReader { + override val count = cursor.count + // the first position of the next unread page on the right + private var rightPos = startPos.coerceIn(0, count) + // the first position of the next from the leftmost unread page on the left + private var leftPos = rightPos + + override val hasMoreBefore + get() = leftPos > 0 + + override val hasMoreAfter + get() = rightPos < count + + override fun readPageAfter(): SparseArray { + if (!hasMoreAfter) return SparseArray() + if (!cursor.moveToPosition(rightPos)) { + rightPos = count + Log.w(TAG, "Failed to move the cursor to position $rightPos, stop reading the cursor") + return SparseArray() + } + val result = SparseArray(pageSize) + do { + cursor + .getString(0) + ?.let(Uri::parse) + ?.takeIf { predicate(it) } + ?.let { uri -> result.append(rightPos, uri) } + rightPos++ + } while (result.size() < pageSize && cursor.moveToNext()) + maybeCloseCursor() + return result + } + + override fun readPageBefore(): SparseArray { + if (!hasMoreBefore) return SparseArray() + val startPos = maxOf(0, leftPos - pageSize) + if (!cursor.moveToPosition(startPos)) { + leftPos = 0 + Log.w(TAG, "Failed to move the cursor to position $startPos, stop reading cursor") + return SparseArray() + } + val result = SparseArray(leftPos - startPos) + for (pos in startPos ..< leftPos) { + cursor + .getString(0) + ?.let(Uri::parse) + ?.takeIf { predicate(it) } + ?.let { uri -> result.append(pos, uri) } + if (!cursor.moveToNext()) break + } + leftPos = startPos + maybeCloseCursor() + return result + } + + private fun maybeCloseCursor() { + if (!hasMoreBefore && !hasMoreAfter) { + close() + } + } + + override fun close() { + cursor.close() + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt b/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt index 0e6d3869..87f53e85 100644 --- a/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt @@ -17,6 +17,8 @@ package com.android.intentresolver.contentpreview import android.net.Uri +import android.util.SparseArray +import java.io.Closeable import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf @@ -48,6 +50,16 @@ class PayloadToggleInteractor { private data class Item( val previewUri: Uri?, ) + + interface CursorReader : Closeable { + val count: Int + val hasMoreBefore: Boolean + val hasMoreAfter: Boolean + + fun readPageAfter(): SparseArray + + fun readPageBefore(): SparseArray + } } class PayloadTogglePreviewInteractor( diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/CursorUriReaderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/CursorUriReaderTest.kt new file mode 100644 index 00000000..d53a9af7 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/CursorUriReaderTest.kt @@ -0,0 +1,94 @@ +/* + * Copyright 2024 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.database.MatrixCursor +import android.net.Uri +import android.util.SparseArray +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class CursorUriReaderTest { + @Test + fun readEmptyCursor() { + val testSubject = + CursorUriReader( + cursor = MatrixCursor(arrayOf("uri")), + startPos = 0, + pageSize = 128, + ) { + true + } + + assertThat(testSubject.hasMoreBefore).isFalse() + assertThat(testSubject.hasMoreAfter).isFalse() + assertThat(testSubject.count).isEqualTo(0) + assertThat(testSubject.readPageBefore().size()).isEqualTo(0) + assertThat(testSubject.readPageAfter().size()).isEqualTo(0) + } + + @Test + fun readCursorFromTheMiddle() { + val count = 3 + val testSubject = + CursorUriReader( + cursor = + MatrixCursor(arrayOf("uri")).apply { + for (i in 1..count) { + addRow(arrayOf(createUri(i))) + } + }, + startPos = 1, + pageSize = 2, + ) { + true + } + + assertThat(testSubject.hasMoreBefore).isTrue() + assertThat(testSubject.hasMoreAfter).isTrue() + assertThat(testSubject.count).isEqualTo(3) + + testSubject.readPageBefore().let { page -> + assertThat(testSubject.hasMoreBefore).isFalse() + assertThat(testSubject.hasMoreAfter).isTrue() + assertThat(page.size()).isEqualTo(1) + assertThat(page.keyAt(0)).isEqualTo(0) + assertThat(page.valueAt(0)).isEqualTo(createUri(1)) + } + + testSubject.readPageAfter().let { page -> + assertThat(testSubject.hasMoreBefore).isFalse() + assertThat(testSubject.hasMoreAfter).isFalse() + assertThat(page.size()).isEqualTo(2) + assertThat(page.getKeys()).asList().containsExactly(1, 2).inOrder() + assertThat(page.getValues()) + .asList() + .containsExactly(createUri(2), createUri(3)) + .inOrder() + } + } + + // TODO: add tests with filtered-out items + // TODO: add tests with a failing cursor +} + +private fun createUri(id: Int) = Uri.parse("content://org.pkg/$id") + +private fun SparseArray.getKeys(): IntArray = IntArray(size()) { i -> keyAt(i) } + +private inline fun SparseArray.getValues(): Array = + Array(size()) { i -> valueAt(i) } -- cgit v1.2.3-59-g8ed1b From f2b7529ff222db898bc8030cd9b5feb077ae6246 Mon Sep 17 00:00:00 2001 From: Steve Elliott Date: Thu, 8 Feb 2024 18:13:54 -0500 Subject: Correctly color + place Shareousel preview icons Bug: 302691505 Flag: ACONFIG android.service.chooser.chooser_payload_toggling DEVELOPMENT Test: N/A - code isn't live Change-Id: I98bb1fec01e20a5dd61ba8c641f4f7a58639035e --- .../contentpreview/ShareouselContentPreviewUi.kt | 16 +++++++++++++++- .../shareousel/ui/composable/ShareouselCardComposable.kt | 3 +-- 2 files changed, 16 insertions(+), 3 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt index a10d3272..51a3cb14 100644 --- a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt +++ b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt @@ -20,8 +20,13 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.TextView +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.viewmodel.compose.viewModel import com.android.intentresolver.R import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory @@ -75,7 +80,16 @@ internal class ShareouselContentPreviewUi( } } - Shareousel(viewModel = viewModel) + MaterialTheme( + colorScheme = + if (isSystemInDarkTheme()) { + dynamicDarkColorScheme(LocalContext.current) + } else { + dynamicLightColorScheme(LocalContext.current) + }, + ) { + Shareousel(viewModel = viewModel) + } } } return composeView diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt index a1ccd9dd..9f31c0e4 100644 --- a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt @@ -18,7 +18,6 @@ package com.android.intentresolver.contentpreview.shareousel.ui.composable import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -49,7 +48,7 @@ fun ShareouselCard( Box(modifier) { image() val topButtonPadding = 12.dp - Box(modifier = Modifier.padding(topButtonPadding).fillMaxSize()) { + Box(modifier = Modifier.padding(topButtonPadding).matchParentSize()) { SelectionIcon(selected, modifier = Modifier.align(Alignment.TopStart)) AnimationIcon(modifier = Modifier.align(Alignment.TopEnd)) ActionButton( -- cgit v1.2.3-59-g8ed1b From ac931a751ce024dfcd603a4ef9899dfe152927a6 Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Thu, 1 Feb 2024 21:28:50 +0000 Subject: Add album headline override support to Chooser Test: atest TextContentPreviewUiTest HeadlineGeneratorImplTest ChooserRequestParametersTest ChooserRequestTest Test: Manual testing with ShareTest Test: Manual testing with CTS Verifier Flag: intentresolver/android.service.chooser.chooser_album_text Bug: 323380224 Change-Id: I319cb2a402554ee658b91f1139051136742527e5 --- java/res/values/strings.xml | 5 ++ .../android/intentresolver/ChooserActivity.java | 3 +- .../com/android/intentresolver/ContentTypeHint.kt | 25 ++++++++++ .../contentpreview/ChooserContentPreviewUi.java | 22 ++++++--- .../contentpreview/HeadlineGenerator.kt | 6 ++- .../contentpreview/HeadlineGeneratorImpl.kt | 4 ++ .../contentpreview/TextContentPreviewUi.java | 11 ++++- .../android/intentresolver/v2/ChooserActivity.java | 3 +- .../intentresolver/v2/ui/model/ChooserRequest.kt | 4 ++ .../v2/ui/viewmodel/ChooserRequestReader.kt | 21 +++++++-- .../v2/ui/viewmodel/ChooserViewModel.kt | 7 ++- .../intentresolver/ChooserRequestParametersTest.kt | 1 - .../contentpreview/ChooserContentPreviewUiTest.kt | 7 ++- .../contentpreview/HeadlineGeneratorImplTest.kt | 55 +++++++++++++++++----- .../contentpreview/TextContentPreviewUiTest.kt | 39 ++++++++++++++- .../v2/ui/viewmodel/ChooserRequestTest.kt | 43 ++++++++++++----- 16 files changed, 211 insertions(+), 45 deletions(-) create mode 100644 java/src/com/android/intentresolver/ContentTypeHint.kt (limited to 'java/src') diff --git a/java/res/values/strings.xml b/java/res/values/strings.xml index f98f5cd1..5c1210b7 100644 --- a/java/res/values/strings.xml +++ b/java/res/values/strings.xml @@ -210,6 +210,11 @@ } + + + Sharing album + {count, plural, diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index c3b13527..708538de 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -310,7 +310,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements previewViewModel.getImageLoader(), createChooserActionFactory(), mEnterTransitionAnimationDelegate, - new HeadlineGeneratorImpl(this)); + new HeadlineGeneratorImpl(this), + ContentTypeHint.NONE); updateStickyContentPreview(); if (shouldShowStickyContentPreview() diff --git a/java/src/com/android/intentresolver/ContentTypeHint.kt b/java/src/com/android/intentresolver/ContentTypeHint.kt new file mode 100644 index 00000000..f607e4ae --- /dev/null +++ b/java/src/com/android/intentresolver/ContentTypeHint.kt @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2024 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.Intent + +/** Enum reflecting the value of [Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT]. */ +enum class ContentTypeHint { + NONE, + ALBUM, +} diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index a015147d..5b4cb682 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -32,6 +32,7 @@ import android.view.ViewGroup; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; +import com.android.intentresolver.ContentTypeHint; import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; @@ -98,7 +99,8 @@ public final class ChooserContentPreviewUi { ImageLoader imageLoader, ActionFactory actionFactory, TransitionElementStatusCallback transitionElementStatusCallback, - HeadlineGenerator headlineGenerator) { + HeadlineGenerator headlineGenerator, + ContentTypeHint contentTypeHint) { mScope = scope; mContentPreviewUi = createContentPreview( previewData, @@ -107,7 +109,8 @@ public final class ChooserContentPreviewUi { imageLoader, actionFactory, transitionElementStatusCallback, - headlineGenerator); + headlineGenerator, + contentTypeHint); if (mContentPreviewUi.getType() != CONTENT_PREVIEW_IMAGE) { transitionElementStatusCallback.onAllTransitionElementsReady(); } @@ -120,7 +123,8 @@ public final class ChooserContentPreviewUi { ImageLoader imageLoader, ActionFactory actionFactory, TransitionElementStatusCallback transitionElementStatusCallback, - HeadlineGenerator headlineGenerator) { + HeadlineGenerator headlineGenerator, + ContentTypeHint contentTypeHint) { int previewType = previewData.getPreviewType(); if (previewType == CONTENT_PREVIEW_TEXT) { @@ -129,7 +133,8 @@ public final class ChooserContentPreviewUi { targetIntent, actionFactory, imageLoader, - headlineGenerator); + headlineGenerator, + contentTypeHint); } if (previewType == CONTENT_PREVIEW_FILE) { FileContentPreviewUi fileContentPreviewUi = new FileContentPreviewUi( @@ -142,7 +147,7 @@ public final class ChooserContentPreviewUi { return fileContentPreviewUi; } boolean isSingleImageShare = previewData.getUriCount() == 1 - && typeClassifier.isImageType(previewData.getFirstFileInfo().getMimeType()); + && typeClassifier.isImageType(previewData.getFirstFileInfo().getMimeType()); CharSequence text = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); if (!TextUtils.isEmpty(text)) { FilesPlusTextContentPreviewUi previewUi = @@ -200,7 +205,8 @@ public final class ChooserContentPreviewUi { Intent targetIntent, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, - HeadlineGenerator headlineGenerator) { + HeadlineGenerator headlineGenerator, + ContentTypeHint contentTypeHint) { CharSequence sharingText = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); CharSequence previewTitle = targetIntent.getCharSequenceExtra(Intent.EXTRA_TITLE); ClipData previewData = targetIntent.getClipData(); @@ -211,6 +217,7 @@ public final class ChooserContentPreviewUi { previewThumbnail = previewDataItem.getUri(); } } + return new TextContentPreviewUi( scope, sharingText, @@ -218,6 +225,7 @@ public final class ChooserContentPreviewUi { previewThumbnail, actionFactory, imageLoader, - headlineGenerator); + headlineGenerator, + contentTypeHint); } } diff --git a/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt b/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt index 5f87c924..21308341 100644 --- a/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt +++ b/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt @@ -17,12 +17,14 @@ package com.android.intentresolver.contentpreview /** - * HeadlineGenerator generates the text to show at the top of the sharesheet as a brief - * description of the content being shared. + * 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 getAlbumHeadline(): String + fun getImagesWithTextHeadline(text: CharSequence, count: Int): String fun getVideosWithTextHeadline(text: CharSequence, count: Int): String diff --git a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt index ef1e55d8..6e126822 100644 --- a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt +++ b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt @@ -34,6 +34,10 @@ class HeadlineGeneratorImpl(private val context: Context) : HeadlineGenerator { ) } + override fun getAlbumHeadline(): String { + return context.getString(R.string.sharing_album) + } + override fun getImagesWithTextHeadline(text: CharSequence, count: Int): String { return getPluralString( getTemplateResource( diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java index b0dc3c58..df2896d7 100644 --- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java @@ -30,6 +30,7 @@ import android.widget.TextView; import androidx.annotation.Nullable; +import com.android.intentresolver.ContentTypeHint; import com.android.intentresolver.R; import com.android.intentresolver.widget.ActionRow; @@ -46,6 +47,7 @@ class TextContentPreviewUi extends ContentPreviewUi { private final ImageLoader mImageLoader; private final ChooserContentPreviewUi.ActionFactory mActionFactory; private final HeadlineGenerator mHeadlineGenerator; + private final ContentTypeHint mContentTypeHint; TextContentPreviewUi( CoroutineScope scope, @@ -54,7 +56,8 @@ class TextContentPreviewUi extends ContentPreviewUi { @Nullable Uri previewThumbnail, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, - HeadlineGenerator headlineGenerator) { + HeadlineGenerator headlineGenerator, + ContentTypeHint contentTypeHint) { mScope = scope; mSharingText = sharingText; mPreviewTitle = previewTitle; @@ -62,6 +65,7 @@ class TextContentPreviewUi extends ContentPreviewUi { mImageLoader = imageLoader; mActionFactory = actionFactory; mHeadlineGenerator = headlineGenerator; + mContentTypeHint = contentTypeHint; } @Override @@ -139,7 +143,10 @@ class TextContentPreviewUi extends ContentPreviewUi { copyButton.setVisibility(View.GONE); } - displayHeadline(headlineViewParent, mHeadlineGenerator.getTextHeadline(mSharingText)); + String headlineText = (mContentTypeHint == ContentTypeHint.ALBUM) + ? mHeadlineGenerator.getAlbumHeadline() + : mHeadlineGenerator.getTextHeadline(mSharingText); + displayHeadline(headlineViewParent, headlineText); return contentPreviewLayout; } diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index b9cf53b6..247f6529 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -477,7 +477,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements previewViewModel.getImageLoader(), createChooserActionFactory(), mEnterTransitionAnimationDelegate, - new HeadlineGeneratorImpl(this)); + new HeadlineGeneratorImpl(this), + chooserRequest.getContentTypeHint()); updateStickyContentPreview(); if (shouldShowStickyContentPreview() || mChooserMultiProfilePagerAdapter diff --git a/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt b/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt index 5c785675..4fc2e46a 100644 --- a/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt +++ b/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt @@ -27,6 +27,7 @@ import android.os.Bundle import android.service.chooser.ChooserAction import android.service.chooser.ChooserTarget import androidx.annotation.StringRes +import com.android.intentresolver.ContentTypeHint import com.android.intentresolver.v2.ext.hasAction const val ANDROID_APP_SCHEME = "android-app" @@ -169,6 +170,9 @@ data class ChooserRequest( /** Focused item index (from target intent's STREAM_EXTRA) */ val focusedItemPosition: Int = 0, + + /** Value for [Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT] on the incoming chooser intent. */ + val contentTypeHint: ContentTypeHint = ContentTypeHint.NONE ) { val referrerPackage = referrer?.takeIf { it.scheme == ANDROID_APP_SCHEME }?.authority diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt index 167c441f..558e54c9 100644 --- a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt +++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt @@ -40,9 +40,10 @@ import android.net.Uri import android.os.Bundle import android.service.chooser.ChooserAction import android.service.chooser.ChooserTarget -import android.service.chooser.Flags import com.android.intentresolver.ChooserActivity +import com.android.intentresolver.ContentTypeHint import com.android.intentresolver.R +import com.android.intentresolver.inject.ChooserServiceFlags import com.android.intentresolver.util.hasValidIcon import com.android.intentresolver.v2.ext.hasAction import com.android.intentresolver.v2.ext.ifMatch @@ -72,7 +73,10 @@ internal fun Intent.maybeAddSendActionFlags() = addFlags(FLAG_ACTIVITY_MULTIPLE_TASK) } -fun readChooserRequest(launch: ActivityLaunch): ValidationResult { +fun readChooserRequest( + launch: ActivityLaunch, + flags: ChooserServiceFlags +): ValidationResult { val extras = launch.intent.extras ?: Bundle() @Suppress("DEPRECATION") return validateFrom(extras::get) { @@ -135,7 +139,7 @@ fun readChooserRequest(launch: ActivityLaunch): ValidationResult val additionalContentUri: Uri? val focusedItemPos: Int - if (isSendAction && Flags.chooserPayloadToggling()) { + if (isSendAction && flags.chooserPayloadToggling()) { additionalContentUri = optional(value(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI)) focusedItemPos = optional(value(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION)) ?: 0 } else { @@ -143,6 +147,16 @@ fun readChooserRequest(launch: ActivityLaunch): ValidationResult focusedItemPos = 0 } + val contentTypeHint = + if (flags.chooserAlbumText()) { + when (optional(value(Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT))) { + Intent.CHOOSER_CONTENT_TYPE_ALBUM -> ContentTypeHint.ALBUM + else -> ContentTypeHint.NONE + } + } else { + ContentTypeHint.NONE + } + ChooserRequest( targetIntent = targetIntent, targetAction = targetIntent.action, @@ -169,6 +183,7 @@ fun readChooserRequest(launch: ActivityLaunch): ValidationResult shareTargetFilter = targetIntent.toShareTargetFilter(), additionalContentUri = additionalContentUri, focusedItemPosition = focusedItemPos, + contentTypeHint = contentTypeHint, ) } } diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt index 17b1e664..776bc1cb 100644 --- a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt +++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt @@ -18,6 +18,7 @@ package com.android.intentresolver.v2.ui.viewmodel import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel +import com.android.intentresolver.inject.ChooserServiceFlags import com.android.intentresolver.v2.ui.model.ActivityLaunch import com.android.intentresolver.v2.ui.model.ActivityLaunch.Companion.ACTIVITY_LAUNCH_KEY import com.android.intentresolver.v2.ui.model.ChooserRequest @@ -28,7 +29,8 @@ import javax.inject.Inject private const val TAG = "ChooserViewModel" @HiltViewModel -class ChooserViewModel @Inject constructor(args: SavedStateHandle) : ViewModel() { +class ChooserViewModel @Inject constructor(args: SavedStateHandle, flags: ChooserServiceFlags) : + ViewModel() { private val mActivityLaunch: ActivityLaunch = requireNotNull(args[ACTIVITY_LAUNCH_KEY]) { @@ -36,7 +38,8 @@ class ChooserViewModel @Inject constructor(args: SavedStateHandle) : ViewModel() } /** The result of reading and validating the inputs provided in savedState. */ - private val status: ValidationResult = readChooserRequest(mActivityLaunch) + private val status: ValidationResult = + readChooserRequest(mActivityLaunch, flags) val chooserRequest: ChooserRequest by lazy { status.getOrThrow() } diff --git a/tests/unit/src/com/android/intentresolver/ChooserRequestParametersTest.kt b/tests/unit/src/com/android/intentresolver/ChooserRequestParametersTest.kt index 90f6cf93..e721b5bb 100644 --- a/tests/unit/src/com/android/intentresolver/ChooserRequestParametersTest.kt +++ b/tests/unit/src/com/android/intentresolver/ChooserRequestParametersTest.kt @@ -29,7 +29,6 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class ChooserRequestParametersTest { - @Test fun testChooserActions() { val actionCount = 3 diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt index 083ef180..2a6e09f5 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt @@ -18,8 +18,9 @@ package com.android.intentresolver.contentpreview import android.content.Intent import android.net.Uri -import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory +import com.android.intentresolver.ContentTypeHint import com.android.intentresolver.TestPreviewImageLoader +import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory import com.android.intentresolver.mock import com.android.intentresolver.whenever import com.android.intentresolver.widget.ActionRow @@ -62,6 +63,7 @@ class ChooserContentPreviewUiTest { actionFactory, transitionCallback, headlineGenerator, + ContentTypeHint.NONE, ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) @@ -81,6 +83,7 @@ class ChooserContentPreviewUiTest { actionFactory, transitionCallback, headlineGenerator, + ContentTypeHint.NONE, ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) @@ -105,6 +108,7 @@ class ChooserContentPreviewUiTest { actionFactory, transitionCallback, headlineGenerator, + ContentTypeHint.NONE, ) assertThat(testSubject.mContentPreviewUi) .isInstanceOf(FilesPlusTextContentPreviewUi::class.java) @@ -129,6 +133,7 @@ class ChooserContentPreviewUiTest { actionFactory, transitionCallback, headlineGenerator, + ContentTypeHint.NONE, ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt index a65280e5..dbc37b44 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt @@ -18,44 +18,73 @@ package com.android.intentresolver.contentpreview 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 -import com.google.common.truth.Truth.assertThat @RunWith(AndroidJUnit4::class) class HeadlineGeneratorImplTest { - @Test - fun testHeadlineGeneration() { - val generator = HeadlineGeneratorImpl( - InstrumentationRegistry.getInstrumentation().getTargetContext()) - val str = "Some string" - val url = "http://www.google.com" + private val generator = + HeadlineGeneratorImpl(InstrumentationRegistry.getInstrumentation().targetContext) + private val str = "Some string" + private val url = "http://www.google.com" + @Test + fun testTextHeadline() { assertThat(generator.getTextHeadline(str)).isEqualTo("Sharing text") assertThat(generator.getTextHeadline(url)).isEqualTo("Sharing link") + } + @Test + fun testImagesWIthTextHeadline() { 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.getImagesWithTextHeadline(str, 5)) + .isEqualTo("Sharing 5 images with text") + assertThat(generator.getImagesWithTextHeadline(url, 5)) + .isEqualTo("Sharing 5 images with link") + } + @Test + fun testVideosWithTextHeadline() { 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.getVideosWithTextHeadline(str, 5)) + .isEqualTo("Sharing 5 videos with text") + assertThat(generator.getVideosWithTextHeadline(url, 5)) + .isEqualTo("Sharing 5 videos with link") + } + @Test + fun testFilesWithTextHeadline() { 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.getFilesWithTextHeadline(str, 5)) + .isEqualTo("Sharing 5 files with text") + assertThat(generator.getFilesWithTextHeadline(url, 5)) + .isEqualTo("Sharing 5 files with link") + } + @Test + fun testImagesHeadline() { assertThat(generator.getImagesHeadline(1)).isEqualTo("Sharing image") assertThat(generator.getImagesHeadline(4)).isEqualTo("Sharing 4 images") + } + @Test + fun testVideosHeadline() { assertThat(generator.getVideosHeadline(1)).isEqualTo("Sharing video") assertThat(generator.getVideosHeadline(4)).isEqualTo("Sharing 4 videos") + } + @Test + fun testFilesHeadline() { assertThat(generator.getFilesHeadline(1)).isEqualTo("Sharing 1 file") assertThat(generator.getFilesHeadline(4)).isEqualTo("Sharing 4 files") } + + @Test + fun testAlbumHeadline() { + assertThat(generator.getAlbumHeadline()).isEqualTo("Sharing album") + } } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt index 35362401..4f200268 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt @@ -22,6 +22,7 @@ import android.view.ViewGroup import android.widget.TextView import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry +import com.android.intentresolver.ContentTypeHint import com.android.intentresolver.R import com.android.intentresolver.mock import com.android.intentresolver.whenever @@ -38,6 +39,7 @@ import org.junit.runner.RunWith class TextContentPreviewUiTest { private val text = "Shared Text" private val title = "Preview Title" + private val albumHeadline = "Album headline" private val testScope = TestScope(EmptyCoroutineContext + UnconfinedTestDispatcher()) private val actionFactory = object : ChooserContentPreviewUi.ActionFactory { @@ -49,7 +51,10 @@ class TextContentPreviewUiTest { } private val imageLoader = mock() private val headlineGenerator = - mock { whenever(getTextHeadline(text)).thenReturn(text) } + mock { + whenever(getTextHeadline(text)).thenReturn(text) + whenever(getAlbumHeadline()).thenReturn(albumHeadline) + } private val context get() = InstrumentationRegistry.getInstrumentation().context @@ -63,6 +68,7 @@ class TextContentPreviewUiTest { actionFactory, imageLoader, headlineGenerator, + ContentTypeHint.NONE, ) @Test @@ -105,4 +111,35 @@ class TextContentPreviewUiTest { assertThat(headlineView).isNotNull() assertThat(headlineView?.text).isEqualTo(text) } + + @Test + fun test_display_albumHeadlineOverride() { + val layoutInflater = LayoutInflater.from(context) + val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup + + val albumSubject = + TextContentPreviewUi( + testScope, + text, + title, + /*previewThumbnail=*/ null, + actionFactory, + imageLoader, + headlineGenerator, + ContentTypeHint.ALBUM, + ) + + val previewView = + albumSubject.display( + context.resources, + layoutInflater, + gridLayout, + /*headlineViewParent=*/ null + ) + + assertThat(previewView).isNotNull() + val headlineView = previewView?.findViewById(R.id.headline) + assertThat(headlineView).isNotNull() + assertThat(headlineView?.text).isEqualTo(albumHeadline) + } } diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt index 9ac24c64..831d09bf 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt @@ -28,8 +28,10 @@ import android.platform.test.annotations.RequiresFlagsDisabled import android.platform.test.annotations.RequiresFlagsEnabled import android.platform.test.flag.junit.CheckFlagsRule import android.platform.test.flag.junit.DeviceFlagsValueProvider +import android.service.chooser.FeatureFlagsImpl import androidx.core.net.toUri import androidx.core.os.bundleOf +import com.android.intentresolver.ContentTypeHint import com.android.intentresolver.v2.ui.model.ActivityLaunch import com.android.intentresolver.v2.ui.model.ChooserRequest import com.android.intentresolver.v2.validation.RequiredValueMissing @@ -41,6 +43,7 @@ import org.junit.Test // TODO: replace with the new API constant, Intent#EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI private const val EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI = "android.intent.extra.CHOOSER_ADDITIONAL_CONTENT_URI" + // TODO: replace with the new API constant, Intent#EXTRA_CHOOSER_FOCUSED_ITEM_POSITION private const val EXTRA_CHOOSER_FOCUSED_ITEM_POSITION = "android.intent.extra.CHOOSER_FOCUSED_ITEM_POSITION" @@ -63,10 +66,12 @@ private fun createLaunch( class ChooserRequestTest { @get:Rule val checkFlagsRule: CheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule() + private val flags = FeatureFlagsImpl() + @Test fun missingIntent() { val launch = createLaunch(targetIntent = null) - val result = readChooserRequest(launch) + val result = readChooserRequest(launch, flags) assertThat(result).value().isNull() assertThat(result) @@ -80,7 +85,7 @@ class ChooserRequestTest { val launch = createLaunch(targetIntent = Intent(ACTION_SEND), referrer) launch.intent.putExtras(bundleOf(EXTRA_REFERRER to referrer)) - val result = readChooserRequest(launch) + val result = readChooserRequest(launch, flags) val fillIn = result.value?.getReferrerFillInIntent() assertThat(fillIn?.hasExtra(EXTRA_REFERRER)).isTrue() @@ -94,7 +99,7 @@ class ChooserRequestTest { val launch = createLaunch(targetIntent = intent, referrer = referrer) - val result = readChooserRequest(launch) + val result = readChooserRequest(launch, flags) assertThat(result.value?.referrerPackage).isNull() } @@ -106,7 +111,7 @@ class ChooserRequestTest { launch.intent.putExtras(bundleOf(EXTRA_REFERRER to referrer)) - val result = readChooserRequest(launch) + val result = readChooserRequest(launch, flags) assertThat(result.value?.referrerPackage).isEqualTo(referrer.authority) } @@ -116,7 +121,7 @@ class ChooserRequestTest { val intent1 = Intent(ACTION_SEND) val intent2 = Intent(ACTION_SEND_MULTIPLE) val launch = createLaunch(targetIntent = intent1, additionalIntents = listOf(intent2)) - val result = readChooserRequest(launch) + val result = readChooserRequest(launch, flags) assertThat(result.value?.payloadIntents).containsExactly(intent1, intent2) } @@ -125,7 +130,7 @@ class ChooserRequestTest { fun testRequest_withOnlyRequiredValues() { val intent = Intent().putExtras(bundleOf(EXTRA_INTENT to Intent(ACTION_SEND))) val launch = createLaunch(targetIntent = intent) - val result = readChooserRequest(launch) + val result = readChooserRequest(launch, flags) assertThat(result).value().isNotNull() val value: ChooserRequest = result.getOrThrow() @@ -143,7 +148,7 @@ class ChooserRequestTest { intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, uri) intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, position) } - val result = readChooserRequest(launch) + val result = readChooserRequest(launch, flags) assertThat(result).value().isNotNull() val value: ChooserRequest = result.getOrThrow() @@ -162,7 +167,7 @@ class ChooserRequestTest { intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, uri) intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, position) } - val result = readChooserRequest(launch) + val result = readChooserRequest(launch, flags) assertThat(result).value().isNotNull() val value: ChooserRequest = result.getOrThrow() @@ -179,7 +184,7 @@ class ChooserRequestTest { intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, "content://org.pkg/path") intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, "1") } - val result = readChooserRequest(launch) + val result = readChooserRequest(launch, flags) assertThat(result).value().isNotNull() val value: ChooserRequest = result.getOrThrow() @@ -191,7 +196,7 @@ class ChooserRequestTest { @RequiresFlagsEnabled(android.service.chooser.Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING) fun testRequest_actionSendWithoutAdditionalContentUri() { val launch = createLaunch(targetIntent = Intent(ACTION_SEND)) - val result = readChooserRequest(launch) + val result = readChooserRequest(launch, flags) assertThat(result).value().isNotNull() val value: ChooserRequest = result.getOrThrow() @@ -209,7 +214,7 @@ class ChooserRequestTest { intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, uri) intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, position) } - val result = readChooserRequest(launch) + val result = readChooserRequest(launch, flags) assertThat(result).value().isNotNull() val value: ChooserRequest = result.getOrThrow() @@ -217,4 +222,20 @@ class ChooserRequestTest { assertThat(value.focusedItemPosition).isEqualTo(0) assertThat(result).findings().isEmpty() } + + @Test + @RequiresFlagsEnabled(android.service.chooser.Flags.FLAG_CHOOSER_ALBUM_TEXT) + fun testAlbumType() { + val launch = createLaunch(Intent(ACTION_SEND)) + launch.intent.putExtra( + Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT, + Intent.CHOOSER_CONTENT_TYPE_ALBUM + ) + + val result = readChooserRequest(launch, flags) + + val value: ChooserRequest = result.getOrThrow() + assertThat(value.contentTypeHint).isEqualTo(ContentTypeHint.ALBUM) + assertThat(result).findings().isEmpty() + } } -- cgit v1.2.3-59-g8ed1b From a089ad729e83f3a8f840ecb4a0e97a6fab7d7c9a Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Thu, 8 Feb 2024 14:02:48 -0800 Subject: Shareousel target intent modifier component A building block for the payload toggling functinality. Modifies target intent based on the set of selected items. Bug: 302691505 Test: IntentResolver-tests-unit Change-Id: If3b165254fef70cf58718e20d28e874554a9818f --- .../contentpreview/TargetIntentModifier.kt | 66 +++++++++++++++++++ .../contentpreview/TargetIntentModifierTest.kt | 77 ++++++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 java/src/com/android/intentresolver/contentpreview/TargetIntentModifier.kt create mode 100644 tests/unit/src/com/android/intentresolver/contentpreview/TargetIntentModifierTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/TargetIntentModifier.kt b/java/src/com/android/intentresolver/contentpreview/TargetIntentModifier.kt new file mode 100644 index 00000000..99cfc0f8 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/TargetIntentModifier.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2024 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.compareMimeTypes +import android.content.Intent +import android.content.Intent.ACTION_SEND +import android.content.Intent.ACTION_SEND_MULTIPLE +import android.content.Intent.EXTRA_STREAM +import android.net.Uri + +/** Modifies target intent based on current payload selection. */ +class TargetIntentModifier( + private val originalTargetIntent: Intent, + private val getUri: Item.() -> Uri, + private val getMimeType: Item.() -> String?, +) : (List) -> Intent { + fun onSelectionChanged(selection: List): Intent { + val uris = ArrayList(selection.size) + var targetMimeType: String? = null + for (item in selection) { + targetMimeType = updateMimeType(item.getMimeType(), targetMimeType) + uris.add(item.getUri()) + } + val action = if (uris.size == 1) ACTION_SEND else ACTION_SEND_MULTIPLE + return Intent(originalTargetIntent).apply { + this.action = action + this.type = targetMimeType + if (action == ACTION_SEND) { + putExtra(EXTRA_STREAM, uris[0]) + } else { + putParcelableArrayListExtra(EXTRA_STREAM, uris) + } + } + } + + private fun updateMimeType(itemMimeType: String?, unitedMimeType: String?): String { + itemMimeType ?: return "*/*" + unitedMimeType ?: return itemMimeType + if (compareMimeTypes(itemMimeType, unitedMimeType)) return unitedMimeType + val slashIdx = unitedMimeType.indexOf('/') + if (slashIdx >= 0 && unitedMimeType.regionMatches(0, itemMimeType, 0, slashIdx + 1)) { + return buildString { + append(unitedMimeType.substring(0, slashIdx + 1)) + append('*') + } + } + return "*/*" + } + + override fun invoke(selection: List): Intent = onSelectionChanged(selection) +} diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/TargetIntentModifierTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/TargetIntentModifierTest.kt new file mode 100644 index 00000000..b589f566 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/TargetIntentModifierTest.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2024 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.Intent +import android.content.Intent.ACTION_SEND +import android.content.Intent.ACTION_SEND_MULTIPLE +import android.content.Intent.EXTRA_STREAM +import android.net.Uri +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class TargetIntentModifierTest { + @Test + fun testIntentActionChange() { + val testSubject = TargetIntentModifier(Intent(ACTION_SEND), { this }, { "image/png" }) + + val u1 = createUri(1) + val u2 = createUri(2) + testSubject.onSelectionChanged(listOf(u1, u2)).let { intent -> + assertThat(intent.action).isEqualTo(ACTION_SEND_MULTIPLE) + assertThat(intent.getParcelableArrayListExtra(EXTRA_STREAM, Uri::class.java)) + .containsExactly(u1, u2) + .inOrder() + } + + testSubject.onSelectionChanged(listOf(u1)).let { intent -> + assertThat(intent.action).isEqualTo(ACTION_SEND) + assertThat(intent.getParcelableExtra(EXTRA_STREAM, Uri::class.java)).isEqualTo(u1) + } + } + + @Test + fun testMimeTypeChange() { + val testSubject = + TargetIntentModifier>(Intent(ACTION_SEND), { first }, { second }) + + val u1 = createUri(1) + val u2 = createUri(2) + testSubject.onSelectionChanged(listOf(u1 to "image/png", u2 to "image/png")).let { intent -> + assertThat(intent.type).isEqualTo("image/png") + } + + testSubject.onSelectionChanged(listOf(u1 to "image/png", u2 to "image/jpg")).let { intent -> + assertThat(intent.type).isEqualTo("image/*") + } + + testSubject.onSelectionChanged(listOf(u1 to "image/png", u2 to "video/mpeg")).let { intent + -> + assertThat(intent.type).isEqualTo("*/*") + } + + testSubject.onSelectionChanged(listOf(u1 to "image/png", u2 to null)).let { intent -> + assertThat(intent.type).isEqualTo("*/*") + } + } + + // TODO: test that the original intent's extras and flags remains the same +} + +private fun createUri(id: Int) = Uri.parse("content://org.pkg/$id") + +private data class Item(val uri: Uri, val mimeType: String?) -- cgit v1.2.3-59-g8ed1b From 1831ffd4f04f1be892fe6b69ddd4d687cb0f67c8 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Thu, 8 Feb 2024 16:17:56 -0800 Subject: Shareousel selection change callback component A building block for payload toggling functinality. A component that selectin change callback invocation. Bug: 302691505 Test: IntentResolver-tests-unit Change-Id: I02d99dedb6166568ecae1e52973df90598601c9d --- .../contentpreview/PayloadToggleInteractor.kt | 3 + .../contentpreview/SelectionChangeCallback.kt | 70 +++++++++++ .../contentpreview/SelectionChangeCallbackTest.kt | 134 +++++++++++++++++++++ 3 files changed, 207 insertions(+) create mode 100644 java/src/com/android/intentresolver/contentpreview/SelectionChangeCallback.kt create mode 100644 tests/unit/src/com/android/intentresolver/contentpreview/SelectionChangeCallbackTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt b/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt index 87f53e85..ca868226 100644 --- a/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt @@ -17,6 +17,7 @@ package com.android.intentresolver.contentpreview import android.net.Uri +import android.service.chooser.ChooserAction import android.util.SparseArray import java.io.Closeable import kotlinx.coroutines.flow.Flow @@ -51,6 +52,8 @@ class PayloadToggleInteractor { val previewUri: Uri?, ) + data class CallbackResult(val customActions: List?) + interface CursorReader : Closeable { val count: Int val hasMoreBefore: Boolean diff --git a/java/src/com/android/intentresolver/contentpreview/SelectionChangeCallback.kt b/java/src/com/android/intentresolver/contentpreview/SelectionChangeCallback.kt new file mode 100644 index 00000000..2cc58a97 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/SelectionChangeCallback.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2024 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.content.Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS +import android.content.Intent.EXTRA_INTENT +import android.net.Uri +import android.os.Bundle +import android.service.chooser.ChooserAction +import com.android.intentresolver.contentpreview.PayloadToggleInteractor.CallbackResult + +// TODO: replace with the new API AdditionalContentContract$MethodNames#ON_SELECTION_CHANGED +private const val MethodName = "onSelectionChanged" + +/** + * Encapsulates payload change callback invocation to the sharing app; handles callback arguments + * and result format mapping. + */ +class SelectionChangeCallback( + private val uri: Uri, + private val chooserIntent: Intent, + private val contentResolver: ContentInterface, +) : (Intent) -> CallbackResult? { + fun onSelectionChanged(targetIntent: Intent): CallbackResult? = + contentResolver + .call( + requireNotNull(uri.authority) { "URI authority can not be null" }, + MethodName, + uri.toString(), + Bundle().apply { + putParcelable( + EXTRA_INTENT, + Intent(chooserIntent).apply { putExtra(EXTRA_INTENT, targetIntent) } + ) + } + ) + ?.let { bundle -> + val actions = + if (bundle.containsKey(EXTRA_CHOOSER_CUSTOM_ACTIONS)) { + bundle + .getParcelableArray( + EXTRA_CHOOSER_CUSTOM_ACTIONS, + ChooserAction::class.java + ) + ?.filterNotNull() + ?: emptyList() + } else { + null + } + CallbackResult(actions) + } + + override fun invoke(targetIntent: Intent) = onSelectionChanged(targetIntent) +} diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/SelectionChangeCallbackTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/SelectionChangeCallbackTest.kt new file mode 100644 index 00000000..110448bb --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/SelectionChangeCallbackTest.kt @@ -0,0 +1,134 @@ +/* + * Copyright 2024 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.app.PendingIntent +import android.content.ContentInterface +import android.content.Intent +import android.content.Intent.ACTION_CHOOSER +import android.content.Intent.ACTION_SEND_MULTIPLE +import android.content.Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS +import android.content.Intent.EXTRA_INTENT +import android.content.Intent.EXTRA_STREAM +import android.graphics.drawable.Icon +import android.net.Uri +import android.os.Bundle +import android.service.chooser.ChooserAction +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.android.intentresolver.any +import com.android.intentresolver.argumentCaptor +import com.android.intentresolver.capture +import com.android.intentresolver.mock +import com.android.intentresolver.whenever +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.times +import org.mockito.Mockito.verify + +// TODO: replace with the new API AdditionalContentContract$MethodNames#ON_SELECTION_CHANGED +private const val MethodName = "onSelectionChanged" + +@RunWith(AndroidJUnit4::class) +class SelectionChangeCallbackTest { + private val uri = Uri.parse("content://org.pkg/content-provider") + private val chooserIntent = Intent(ACTION_CHOOSER) + private val contentResolver = mock() + private val context = InstrumentationRegistry.getInstrumentation().context + + @Test + fun testCallbackProducesChooserIntentArgument() { + val a1 = + ChooserAction.Builder( + Icon.createWithContentUri(createUri(10)), + "Action 1", + PendingIntent.getBroadcast( + context, + 1, + Intent("test"), + PendingIntent.FLAG_IMMUTABLE + ) + ) + .build() + val a2 = + ChooserAction.Builder( + Icon.createWithContentUri(createUri(11)), + "Action 2", + PendingIntent.getBroadcast( + context, + 1, + Intent("test"), + PendingIntent.FLAG_IMMUTABLE + ) + ) + .build() + whenever(contentResolver.call(any(), any(), any(), any())) + .thenReturn( + Bundle().apply { putParcelableArray(EXTRA_CHOOSER_CUSTOM_ACTIONS, arrayOf(a1, a2)) } + ) + + val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) + + val u1 = createUri(1) + val u2 = createUri(2) + val targetIntent = + Intent(ACTION_SEND_MULTIPLE).apply { + val uris = + ArrayList().apply { + add(u1) + add(u2) + } + putExtra(EXTRA_STREAM, uris) + type = "image/jpg" + } + val result = testSubject.onSelectionChanged(targetIntent) + assertThat(result).isNotNull() + assertThat(result?.customActions).hasSize(2) + assertThat(result?.customActions?.get(0)?.icon).isEqualTo(a1.icon) + assertThat(result?.customActions?.get(0)?.label).isEqualTo(a1.label) + assertThat(result?.customActions?.get(1)?.icon).isEqualTo(a2.icon) + assertThat(result?.customActions?.get(1)?.label).isEqualTo(a2.label) + + val authorityCaptor = argumentCaptor() + val methodCaptor = argumentCaptor() + val argCaptor = argumentCaptor() + val extraCaptor = argumentCaptor() + verify(contentResolver, times(1)) + .call( + capture(authorityCaptor), + capture(methodCaptor), + capture(argCaptor), + capture(extraCaptor) + ) + assertThat(authorityCaptor.value).isEqualTo(uri.authority) + assertThat(methodCaptor.value).isEqualTo(MethodName) + assertThat(argCaptor.value).isEqualTo(uri.toString()) + val extraBundle = extraCaptor.value + assertThat(extraBundle).isNotNull() + val argChooserIntent = extraBundle.getParcelable(EXTRA_INTENT, Intent::class.java) + assertThat(argChooserIntent).isNotNull() + assertThat(argChooserIntent?.action).isEqualTo(chooserIntent.action) + val argTargetIntent = argChooserIntent?.getParcelableExtra(EXTRA_INTENT, Intent::class.java) + assertThat(argTargetIntent?.action).isEqualTo(targetIntent.action) + assertThat(argTargetIntent?.getParcelableArrayListExtra(EXTRA_STREAM, Uri::class.java)) + .containsExactly(u1, u2) + .inOrder() + } +} + +private fun createUri(id: Int) = Uri.parse("content://org.pkg.images/$id.png") -- cgit v1.2.3-59-g8ed1b From ee302948e469578bd7866dd709d7a2136b360648 Mon Sep 17 00:00:00 2001 From: Govinda Wasserman Date: Wed, 7 Feb 2024 09:10:47 -0500 Subject: Makes default sharesheet show metadata text below title This change is guarded by android.service.chooser.Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA. Test: atest com.android.intentresover.v2.ui.viewmodel \ com.android.intentresolver.contentpreview BUG: 318942069 Change-Id: I7530338ce4e8ce1ca4d1b9f6498c27881d31e6a7 --- java/res/layout/chooser_headline_row.xml | 15 ++++ .../android/intentresolver/ChooserActivity.java | 4 +- .../intentresolver/ChooserRequestParameters.java | 14 ++++ .../contentpreview/ChooserContentPreviewUi.java | 33 +++++--- .../contentpreview/ContentPreviewUi.java | 13 ++++ .../contentpreview/FileContentPreviewUi.java | 8 +- .../FilesPlusTextContentPreviewUi.java | 7 +- .../contentpreview/TextContentPreviewUi.java | 5 ++ .../contentpreview/UnifiedContentPreviewUi.java | 7 +- .../intentresolver/inject/FeatureFlagsModule.kt | 4 + .../android/intentresolver/v2/ChooserActivity.java | 4 +- .../intentresolver/v2/ui/model/ChooserRequest.kt | 9 ++- .../v2/ui/viewmodel/ChooserRequestReader.kt | 9 +++ .../v2/ui/viewmodel/ChooserViewModel.kt | 8 +- .../contentpreview/ChooserContentPreviewUiTest.kt | 5 ++ .../contentpreview/FileContentPreviewUiTest.kt | 10 ++- .../FilesPlusTextContentPreviewUiTest.kt | 66 +++++++++++++--- .../contentpreview/TextContentPreviewUiTest.kt | 14 ++++ .../contentpreview/UnifiedContentPreviewUiTest.kt | 47 ++++++++++-- .../v2/ui/viewmodel/ChooserRequestTest.kt | 88 +++++++++++++++------- 20 files changed, 307 insertions(+), 63 deletions(-) (limited to 'java/src') diff --git a/java/res/layout/chooser_headline_row.xml b/java/res/layout/chooser_headline_row.xml index 62781847..97e8552e 100644 --- a/java/res/layout/chooser_headline_row.xml +++ b/java/res/layout/chooser_headline_row.xml @@ -38,6 +38,21 @@ android:textSize="18sp" /> + + 0) { previewData.getFirstFileName(mScope, fileContentPreviewUi::setFirstFileName); } @@ -160,7 +168,9 @@ public final class ChooserContentPreviewUi { actionFactory, imageLoader, typeClassifier, - headlineGenerator); + headlineGenerator, + metadata + ); if (previewData.getUriCount() > 0) { JavaFlowHelper.collectToList( mScope, @@ -180,7 +190,9 @@ public final class ChooserContentPreviewUi { transitionElementStatusCallback, previewData.getImagePreviewFileInfoFlow(), previewData.getUriCount(), - headlineGenerator); + headlineGenerator, + metadata + ); } public int getPreferredContentPreview() { @@ -206,7 +218,9 @@ public final class ChooserContentPreviewUi { ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, HeadlineGenerator headlineGenerator, - ContentTypeHint contentTypeHint) { + ContentTypeHint contentTypeHint, + @Nullable CharSequence metadata + ) { CharSequence sharingText = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); CharSequence previewTitle = targetIntent.getCharSequenceExtra(Intent.EXTRA_TITLE); ClipData previewData = targetIntent.getClipData(); @@ -222,6 +236,7 @@ public final class ChooserContentPreviewUi { scope, sharingText, previewTitle, + metadata, previewThumbnail, actionFactory, imageLoader, diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java index dce146b0..c35f93b4 100644 --- a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java @@ -83,6 +83,19 @@ abstract class ContentPreviewUi { } } + protected static void displayMetadata(View layout, @Nullable CharSequence metadata) { + TextView metadataView = layout == null ? null : layout.findViewById(R.id.metadata); + if (metadataView == null) { + return; + } + if (!TextUtils.isEmpty(metadata)) { + metadataView.setText(metadata); + metadataView.setVisibility(View.VISIBLE); + } else { + metadataView.setVisibility(View.GONE); + } + } + protected static void displayModifyShareAction( View layout, ChooserContentPreviewUi.ActionFactory actionFactory) { ActionRow.Action modifyShareAction = actionFactory.getModifyShareAction(); diff --git a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java index 89e7e528..d4eea8b9 100644 --- a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java @@ -43,15 +43,20 @@ class FileContentPreviewUi extends ContentPreviewUi { private final ChooserContentPreviewUi.ActionFactory mActionFactory; private final HeadlineGenerator mHeadlineGenerator; @Nullable + private final CharSequence mMetadata; + @Nullable private ViewGroup mContentPreview = null; FileContentPreviewUi( int fileCount, ChooserContentPreviewUi.ActionFactory actionFactory, - HeadlineGenerator headlineGenerator) { + HeadlineGenerator headlineGenerator, + @Nullable CharSequence metadata + ) { mFileCount = fileCount; mActionFactory = actionFactory; mHeadlineGenerator = headlineGenerator; + mMetadata = metadata; } @Override @@ -91,6 +96,7 @@ class FileContentPreviewUi extends ContentPreviewUi { inflateHeadline(headlineViewParent); displayHeadline(headlineViewParent, mHeadlineGenerator.getFilesHeadline(mFileCount)); + displayMetadata(headlineViewParent, mMetadata); if (mFileCount == 0) { mContentPreview.setVisibility(View.GONE); diff --git a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java index 78fc6586..6832c5c4 100644 --- a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java @@ -57,6 +57,8 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { private final ImageLoader mImageLoader; private final MimeTypeClassifier mTypeClassifier; private final HeadlineGenerator mHeadlineGenerator; + @Nullable + private final CharSequence mMetadata; private final boolean mIsSingleImage; private final int mFileCount; private ViewGroup mContentPreviewView; @@ -78,7 +80,8 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, MimeTypeClassifier typeClassifier, - HeadlineGenerator headlineGenerator) { + HeadlineGenerator headlineGenerator, + @Nullable CharSequence metadata) { if (isSingleImage && fileCount != 1) { throw new IllegalArgumentException( "fileCount = " + fileCount + " and isSingleImage = true"); @@ -92,6 +95,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { mImageLoader = imageLoader; mTypeClassifier = typeClassifier; mHeadlineGenerator = headlineGenerator; + mMetadata = metadata; } @Override @@ -204,6 +208,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { } displayHeadline(headlineView, headline); + displayMetadata(headlineView, mMetadata); } private void prepareTextPreview( diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java index df2896d7..fbdc5853 100644 --- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java @@ -43,6 +43,8 @@ class TextContentPreviewUi extends ContentPreviewUi { @Nullable private final CharSequence mPreviewTitle; @Nullable + private final CharSequence mMetadata; + @Nullable private final Uri mPreviewThumbnail; private final ImageLoader mImageLoader; private final ChooserContentPreviewUi.ActionFactory mActionFactory; @@ -53,6 +55,7 @@ class TextContentPreviewUi extends ContentPreviewUi { CoroutineScope scope, @Nullable CharSequence sharingText, @Nullable CharSequence previewTitle, + @Nullable CharSequence metadata, @Nullable Uri previewThumbnail, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, @@ -61,6 +64,7 @@ class TextContentPreviewUi extends ContentPreviewUi { mScope = scope; mSharingText = sharingText; mPreviewTitle = previewTitle; + mMetadata = metadata; mPreviewThumbnail = previewThumbnail; mImageLoader = imageLoader; mActionFactory = actionFactory; @@ -147,6 +151,7 @@ class TextContentPreviewUi extends ContentPreviewUi { ? mHeadlineGenerator.getAlbumHeadline() : mHeadlineGenerator.getTextHeadline(mSharingText); displayHeadline(headlineViewParent, headlineText); + displayMetadata(headlineViewParent, mMetadata); return contentPreviewLayout; } diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java index 8ddd5273..0974c79b 100644 --- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -46,6 +46,8 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { private final MimeTypeClassifier mTypeClassifier; private final TransitionElementStatusCallback mTransitionElementStatusCallback; private final HeadlineGenerator mHeadlineGenerator; + @Nullable + private final CharSequence mMetadata; private final Flow mFileInfoFlow; private final int mItemCount; @Nullable @@ -65,7 +67,8 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { TransitionElementStatusCallback transitionElementStatusCallback, Flow fileInfoFlow, int itemCount, - HeadlineGenerator headlineGenerator) { + HeadlineGenerator headlineGenerator, + @Nullable CharSequence metadata) { mShowEditAction = isSingleImage; mIntentMimeType = intentMimeType; mActionFactory = actionFactory; @@ -75,6 +78,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { mFileInfoFlow = fileInfoFlow; mItemCount = itemCount; mHeadlineGenerator = headlineGenerator; + mMetadata = metadata; JavaFlowHelper.collectToList(scope, fileInfoFlow, this::setFiles); } @@ -181,5 +185,6 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { } else { displayHeadline(layout, mHeadlineGenerator.getFilesHeadline(count)); } + displayMetadata(layout, mMetadata); } } diff --git a/java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt b/java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt index 67186371..0f9a18c1 100644 --- a/java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt +++ b/java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt @@ -9,8 +9,12 @@ import dagger.hilt.components.SingletonComponent typealias IntentResolverFlags = com.android.intentresolver.FeatureFlags +typealias FakeIntentResolverFlags = com.android.intentresolver.FakeFeatureFlagsImpl + typealias ChooserServiceFlags = android.service.chooser.FeatureFlags +typealias FakeChooserServiceFlags = android.service.chooser.FakeFeatureFlagsImpl + @Module @InstallIn(SingletonComponent::class) object FeatureFlagsModule { diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 247f6529..35812071 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -478,7 +478,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements createChooserActionFactory(), mEnterTransitionAnimationDelegate, new HeadlineGeneratorImpl(this), - chooserRequest.getContentTypeHint()); + chooserRequest.getContentTypeHint(), + chooserRequest.getMetadataText() + ); updateStickyContentPreview(); if (shouldShowStickyContentPreview() || mChooserMultiProfilePagerAdapter diff --git a/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt b/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt index 4fc2e46a..4f3cf3cd 100644 --- a/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt +++ b/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt @@ -172,7 +172,14 @@ data class ChooserRequest( val focusedItemPosition: Int = 0, /** Value for [Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT] on the incoming chooser intent. */ - val contentTypeHint: ContentTypeHint = ContentTypeHint.NONE + val contentTypeHint: ContentTypeHint = ContentTypeHint.NONE, + + /** + * Metadata to be shown to the user as a part of the sharesheet window. + * + * Specified by the [Intent.EXTRA_METADATA_TEXT] + */ + val metadataText: CharSequence? = null, ) { val referrerPackage = referrer?.takeIf { it.scheme == ANDROID_APP_SCHEME }?.authority diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt index 558e54c9..cb1ef1ae 100644 --- a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt +++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt @@ -28,6 +28,7 @@ import android.content.Intent.EXTRA_CHOSEN_COMPONENT_INTENT_SENDER import android.content.Intent.EXTRA_EXCLUDE_COMPONENTS import android.content.Intent.EXTRA_INITIAL_INTENTS import android.content.Intent.EXTRA_INTENT +import android.content.Intent.EXTRA_METADATA_TEXT import android.content.Intent.EXTRA_REFERRER import android.content.Intent.EXTRA_REPLACEMENT_EXTRAS import android.content.Intent.EXTRA_TEXT @@ -157,6 +158,13 @@ fun readChooserRequest( ContentTypeHint.NONE } + val metadataText = + if (flags.enableSharesheetMetadataExtra()) { + optional(value(EXTRA_METADATA_TEXT)) + } else { + null + } + ChooserRequest( targetIntent = targetIntent, targetAction = targetIntent.action, @@ -184,6 +192,7 @@ fun readChooserRequest( additionalContentUri = additionalContentUri, focusedItemPosition = focusedItemPos, contentTypeHint = contentTypeHint, + metadataText = metadataText, ) } } diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt index 776bc1cb..a03f3769 100644 --- a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt +++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt @@ -29,8 +29,12 @@ import javax.inject.Inject private const val TAG = "ChooserViewModel" @HiltViewModel -class ChooserViewModel @Inject constructor(args: SavedStateHandle, flags: ChooserServiceFlags) : - ViewModel() { +class ChooserViewModel +@Inject +constructor( + args: SavedStateHandle, + flags: ChooserServiceFlags, +) : ViewModel() { private val mActivityLaunch: ActivityLaunch = requireNotNull(args[ACTIVITY_LAUNCH_KEY]) { diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt index 2a6e09f5..560f4be4 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt @@ -41,6 +41,7 @@ class ChooserContentPreviewUiTest { private val previewData = mock() private val headlineGenerator = mock() private val imageLoader = TestPreviewImageLoader(emptyMap()) + private val testMetadataText: CharSequence = "Test metadata text" private val actionFactory = object : ActionFactory { override fun getCopyButtonRunnable(): Runnable? = null @@ -64,6 +65,7 @@ class ChooserContentPreviewUiTest { transitionCallback, headlineGenerator, ContentTypeHint.NONE, + testMetadataText, ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) @@ -84,6 +86,7 @@ class ChooserContentPreviewUiTest { transitionCallback, headlineGenerator, ContentTypeHint.NONE, + testMetadataText, ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) @@ -109,6 +112,7 @@ class ChooserContentPreviewUiTest { transitionCallback, headlineGenerator, ContentTypeHint.NONE, + testMetadataText, ) assertThat(testSubject.mContentPreviewUi) .isInstanceOf(FilesPlusTextContentPreviewUi::class.java) @@ -134,6 +138,7 @@ class ChooserContentPreviewUiTest { transitionCallback, headlineGenerator, ContentTypeHint.NONE, + testMetadataText, ) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/FileContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/FileContentPreviewUiTest.kt index d2d952ae..a540dfa2 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/FileContentPreviewUiTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/FileContentPreviewUiTest.kt @@ -35,6 +35,7 @@ import org.junit.runner.RunWith class FileContentPreviewUiTest { private val fileCount = 2 private val text = "Sharing 2 files" + private val testMetadataText: CharSequence = "Test metadata text" private val actionFactory = object : ChooserContentPreviewUi.ActionFactory { override fun getEditButtonRunnable(): Runnable? = null @@ -54,10 +55,11 @@ class FileContentPreviewUiTest { fileCount, actionFactory, headlineGenerator, + testMetadataText, ) @Test - fun test_display_titleIsDisplayed() { + fun test_display_titleAndMetadataIsDisplayed() { val layoutInflater = LayoutInflater.from(context) val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup @@ -73,6 +75,8 @@ class FileContentPreviewUiTest { val headlineView = previewView?.findViewById(R.id.headline) assertThat(headlineView).isNotNull() assertThat(headlineView?.text).isEqualTo(text) + val metadataView = previewView?.findViewById(R.id.metadata) + assertThat(metadataView?.text).isEqualTo(testMetadataText) } @Test @@ -85,15 +89,19 @@ class FileContentPreviewUiTest { gridLayout.requireViewById(R.id.chooser_headline_row_container) assertThat(externalHeaderView.findViewById(R.id.headline)).isNull() + assertThat(externalHeaderView.findViewById(R.id.metadata)).isNull() val previewView = testSubject.display(context.resources, layoutInflater, gridLayout, externalHeaderView) assertThat(previewView).isNotNull() assertThat(previewView.findViewById(R.id.headline)).isNull() + assertThat(previewView.findViewById(R.id.metadata)).isNull() val headlineView = externalHeaderView.findViewById(R.id.headline) assertThat(headlineView).isNotNull() assertThat(headlineView?.text).isEqualTo(text) + val metadataView = externalHeaderView.findViewById(R.id.metadata) + assertThat(metadataView?.text).isEqualTo(testMetadataText) } } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt index 7cc0b4b2..259ffdac 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt @@ -21,6 +21,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.TextView +import androidx.annotation.IdRes import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import com.android.intentresolver.R @@ -63,6 +64,7 @@ class FilesPlusTextContentPreviewUiTest { whenever(getVideosHeadline(anyInt())).thenReturn(HEADLINE_VIDEOS) whenever(getFilesHeadline(anyInt())).thenReturn(HEADLINE_FILES) } + private val testMetadataText: CharSequence = "Test metadata text" private val context get() = getInstrumentation().context @@ -74,6 +76,7 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount) verifyPreviewHeadline(previewView, HEADLINE_IMAGES) + verifyPreviewMetadata(previewView, testMetadataText) verifySharedText(previewView) } @@ -85,6 +88,7 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount) verifyInternalHeadlineAbsence(previewView) verifyPreviewHeadline(headerParent, HEADLINE_IMAGES) + verifyPreviewMetadata(headerParent, testMetadataText) verifySharedText(previewView) } @@ -95,6 +99,7 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, times(1)).getVideosHeadline(sharedFileCount) verifyPreviewHeadline(previewView, HEADLINE_VIDEOS) + verifyPreviewMetadata(previewView, testMetadataText) verifySharedText(previewView) } @@ -106,6 +111,7 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, times(1)).getVideosHeadline(sharedFileCount) verifyInternalHeadlineAbsence(previewView) verifyPreviewHeadline(headerParent, HEADLINE_VIDEOS) + verifyPreviewMetadata(headerParent, testMetadataText) verifySharedText(previewView) } @@ -116,6 +122,7 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) verifyPreviewHeadline(previewView, HEADLINE_FILES) + verifyPreviewMetadata(previewView, testMetadataText) verifySharedText(previewView) } @@ -128,6 +135,7 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) verifyInternalHeadlineAbsence(previewView) verifyPreviewHeadline(headerParent, HEADLINE_FILES) + verifyPreviewMetadata(headerParent, testMetadataText) verifySharedText(previewView) } @@ -138,6 +146,7 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) verifyPreviewHeadline(previewView, HEADLINE_FILES) + verifyPreviewMetadata(previewView, testMetadataText) verifySharedText(previewView) } @@ -149,6 +158,7 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) verifyInternalHeadlineAbsence(previewView) verifyPreviewHeadline(headerParent, HEADLINE_FILES) + verifyPreviewMetadata(headerParent, testMetadataText) verifySharedText(previewView) } @@ -160,6 +170,7 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount) verifyPreviewHeadline(previewView, HEADLINE_IMAGES) + verifyPreviewMetadata(previewView, testMetadataText) verifySharedText(previewView) } @@ -173,6 +184,7 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount) verifyInternalHeadlineAbsence(previewView) verifyPreviewHeadline(headerParent, HEADLINE_IMAGES) + verifyPreviewMetadata(headerParent, testMetadataText) verifySharedText(previewView) } @@ -184,6 +196,7 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, times(1)).getVideosHeadline(sharedFileCount) verifyPreviewHeadline(previewView, HEADLINE_VIDEOS) + verifyPreviewMetadata(previewView, testMetadataText) verifySharedText(previewView) } @@ -197,6 +210,7 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, times(1)).getVideosHeadline(sharedFileCount) verifyInternalHeadlineAbsence(previewView) verifyPreviewHeadline(headerParent, HEADLINE_VIDEOS) + verifyPreviewMetadata(headerParent, testMetadataText) verifySharedText(previewView) } @@ -208,6 +222,7 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) verifyPreviewHeadline(previewView, HEADLINE_FILES) + verifyPreviewMetadata(previewView, testMetadataText) verifySharedText(previewView) } @@ -221,6 +236,7 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) verifyInternalHeadlineAbsence(previewView) verifyPreviewHeadline(headerParent, HEADLINE_FILES) + verifyPreviewMetadata(headerParent, testMetadataText) verifySharedText(previewView) } @@ -233,6 +249,7 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) verifyPreviewHeadline(previewView, HEADLINE_FILES) + verifyPreviewMetadata(previewView, testMetadataText) verifySharedText(previewView) } @@ -246,6 +263,7 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) verifyInternalHeadlineAbsence(previewView) verifyPreviewHeadline(headerParent, HEADLINE_FILES) + verifyPreviewMetadata(headerParent, testMetadataText) verifySharedText(previewView) } @@ -262,7 +280,8 @@ class FilesPlusTextContentPreviewUiTest { actionFactory, imageLoader, DefaultMimeTypeClassifier, - headlineGenerator + headlineGenerator, + testMetadataText, ) val layoutInflater = LayoutInflater.from(context) val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup @@ -273,12 +292,14 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) verify(headlineGenerator, never()).getImagesHeadline(sharedFileCount) verifyPreviewHeadline(previewView, HEADLINE_FILES) + verifyPreviewMetadata(previewView, testMetadataText) testSubject.updatePreviewMetadata(createFileInfosWithMimeTypes("image/png", "image/jpg")) verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount) verifyPreviewHeadline(previewView, HEADLINE_IMAGES) + verifyPreviewMetadata(previewView, testMetadataText) } @Test @@ -294,7 +315,8 @@ class FilesPlusTextContentPreviewUiTest { actionFactory, imageLoader, DefaultMimeTypeClassifier, - headlineGenerator + headlineGenerator, + testMetadataText, ) val layoutInflater = LayoutInflater.from(context) val gridLayout = @@ -306,6 +328,9 @@ class FilesPlusTextContentPreviewUiTest { assertWithMessage("External headline should not be inflated by default") .that(externalHeaderView.findViewById(R.id.headline)) .isNull() + assertWithMessage("External metadata should not be inflated by default") + .that(externalHeaderView.findViewById(R.id.metadata)) + .isNull() val previewView = testSubject.display( @@ -319,12 +344,14 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, never()).getImagesHeadline(sharedFileCount) verifyInternalHeadlineAbsence(previewView) verifyPreviewHeadline(externalHeaderView, HEADLINE_FILES) + verifyPreviewMetadata(externalHeaderView, testMetadataText) testSubject.updatePreviewMetadata(createFileInfosWithMimeTypes("image/png", "image/jpg")) verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount) verifyPreviewHeadline(externalHeaderView, HEADLINE_IMAGES) + verifyPreviewMetadata(externalHeaderView, testMetadataText) } private fun testLoadingHeadline( @@ -342,7 +369,8 @@ class FilesPlusTextContentPreviewUiTest { actionFactory, imageLoader, DefaultMimeTypeClassifier, - headlineGenerator + headlineGenerator, + testMetadataText, ) val layoutInflater = LayoutInflater.from(context) val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup @@ -371,7 +399,8 @@ class FilesPlusTextContentPreviewUiTest { actionFactory, imageLoader, DefaultMimeTypeClassifier, - headlineGenerator + headlineGenerator, + testMetadataText, ) val layoutInflater = LayoutInflater.from(context) val gridLayout = @@ -384,6 +413,10 @@ class FilesPlusTextContentPreviewUiTest { .that(externalHeaderView.findViewById(R.id.headline)) .isNull() + assertWithMessage("External metadata should not be inflated by default") + .that(externalHeaderView.findViewById(R.id.metadata)) + .isNull() + loadedFileMetadata?.let(testSubject::updatePreviewMetadata) return testSubject.display( context.resources, @@ -398,18 +431,27 @@ class FilesPlusTextContentPreviewUiTest { return mimeTypes.map { mimeType -> FileInfo.Builder(uri).withMimeType(mimeType).build() } } + private fun verifyTextViewText( + parentView: View?, + @IdRes textViewResId: Int, + expectedText: CharSequence, + ) { + assertThat(parentView).isNotNull() + val textView = parentView?.findViewById(textViewResId) + assertThat(textView).isNotNull() + assertThat(textView?.text).isEqualTo(expectedText) + } + private fun verifyPreviewHeadline(headerViewParent: View?, expectedText: String) { - assertThat(headerViewParent).isNotNull() - val headlineView = headerViewParent?.findViewById(R.id.headline) - assertThat(headlineView).isNotNull() - assertThat(headlineView?.text).isEqualTo(expectedText) + verifyTextViewText(headerViewParent, R.id.headline, expectedText) + } + + private fun verifyPreviewMetadata(headerViewParent: View?, expectedText: CharSequence) { + verifyTextViewText(headerViewParent, R.id.metadata, expectedText) } private fun verifySharedText(previewView: ViewGroup?) { - assertThat(previewView).isNotNull() - val textContentView = previewView?.findViewById(R.id.content_preview_text) - assertThat(textContentView).isNotNull() - assertThat(textContentView?.text).isEqualTo(SHARED_TEXT) + verifyTextViewText(previewView, R.id.content_preview_text, SHARED_TEXT) } private fun verifyInternalHeadlineAbsence(previewView: ViewGroup?) { diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt index 4f200268..1c96070c 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt @@ -55,6 +55,7 @@ class TextContentPreviewUiTest { whenever(getTextHeadline(text)).thenReturn(text) whenever(getAlbumHeadline()).thenReturn(albumHeadline) } + private val testMetadataText: CharSequence = "Test metadata text" private val context get() = InstrumentationRegistry.getInstrumentation().context @@ -64,6 +65,7 @@ class TextContentPreviewUiTest { testScope, text, title, + testMetadataText, /*previewThumbnail=*/ null, actionFactory, imageLoader, @@ -88,6 +90,9 @@ class TextContentPreviewUiTest { val headlineView = previewView?.findViewById(R.id.headline) assertThat(headlineView).isNotNull() assertThat(headlineView?.text).isEqualTo(text) + val metadataView = previewView?.findViewById(R.id.metadata) + assertThat(metadataView).isNotNull() + assertThat(metadataView?.text).isEqualTo(testMetadataText) } @Test @@ -100,16 +105,21 @@ class TextContentPreviewUiTest { gridLayout.requireViewById(R.id.chooser_headline_row_container) assertThat(externalHeaderView.findViewById(R.id.headline)).isNull() + assertThat(externalHeaderView.findViewById(R.id.metadata)).isNull() val previewView = testSubject.display(context.resources, layoutInflater, gridLayout, externalHeaderView) assertThat(previewView).isNotNull() assertThat(previewView.findViewById(R.id.headline)).isNull() + assertThat(previewView.findViewById(R.id.metadata)).isNull() val headlineView = externalHeaderView.findViewById(R.id.headline) assertThat(headlineView).isNotNull() assertThat(headlineView?.text).isEqualTo(text) + val metadataView = externalHeaderView.findViewById(R.id.metadata) + assertThat(metadataView).isNotNull() + assertThat(metadataView?.text).isEqualTo(testMetadataText) } @Test @@ -122,6 +132,7 @@ class TextContentPreviewUiTest { testScope, text, title, + testMetadataText, /*previewThumbnail=*/ null, actionFactory, imageLoader, @@ -141,5 +152,8 @@ class TextContentPreviewUiTest { val headlineView = previewView?.findViewById(R.id.headline) assertThat(headlineView).isNotNull() assertThat(headlineView?.text).isEqualTo(albumHeadline) + val metadataView = previewView?.findViewById(R.id.metadata) + assertThat(metadataView).isNotNull() + assertThat(metadataView?.text).isEqualTo(testMetadataText) } } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt index 7e07e0ca..faeaf133 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt @@ -21,13 +21,14 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.TextView +import androidx.annotation.IdRes import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import com.android.intentresolver.R import com.android.intentresolver.mock import com.android.intentresolver.whenever import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback -import com.google.common.truth.Truth +import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.flow.MutableSharedFlow @@ -60,6 +61,7 @@ class UnifiedContentPreviewUiTest { whenever(getVideosHeadline(anyInt())).thenReturn(VIDEO_HEADLINE) whenever(getFilesHeadline(anyInt())).thenReturn(FILES_HEADLINE) } + private val testMetadataText: CharSequence = "Test metadata text" private val context get() = getInstrumentation().context @@ -69,6 +71,7 @@ class UnifiedContentPreviewUiTest { testLoadingHeadline("image/*", files = null) { previewView -> verify(headlineGenerator, times(1)).getImagesHeadline(2) verifyPreviewHeadline(previewView, IMAGE_HEADLINE) + verifyPreviewMetadata(previewView, testMetadataText) } } @@ -77,6 +80,7 @@ class UnifiedContentPreviewUiTest { testLoadingExternalHeadline("image/*", files = null) { externalHeaderView -> verify(headlineGenerator, times(1)).getImagesHeadline(2) verifyPreviewHeadline(externalHeaderView, IMAGE_HEADLINE) + verifyPreviewMetadata(externalHeaderView, testMetadataText) } } @@ -85,6 +89,7 @@ class UnifiedContentPreviewUiTest { testLoadingHeadline("video/*", files = null) { previewView -> verify(headlineGenerator, times(1)).getVideosHeadline(2) verifyPreviewHeadline(previewView, VIDEO_HEADLINE) + verifyPreviewMetadata(previewView, testMetadataText) } } @@ -93,6 +98,7 @@ class UnifiedContentPreviewUiTest { testLoadingExternalHeadline("video/*", files = null) { externalHeaderView -> verify(headlineGenerator, times(1)).getVideosHeadline(2) verifyPreviewHeadline(externalHeaderView, VIDEO_HEADLINE) + verifyPreviewMetadata(externalHeaderView, testMetadataText) } } @@ -101,6 +107,7 @@ class UnifiedContentPreviewUiTest { testLoadingHeadline("application/pdf", files = null) { previewView -> verify(headlineGenerator, times(1)).getFilesHeadline(2) verifyPreviewHeadline(previewView, FILES_HEADLINE) + verifyPreviewMetadata(previewView, testMetadataText) } } @@ -109,6 +116,7 @@ class UnifiedContentPreviewUiTest { testLoadingExternalHeadline("application/pdf", files = null) { externalHeaderView -> verify(headlineGenerator, times(1)).getFilesHeadline(2) verifyPreviewHeadline(externalHeaderView, FILES_HEADLINE) + verifyPreviewMetadata(externalHeaderView, testMetadataText) } } @@ -117,6 +125,7 @@ class UnifiedContentPreviewUiTest { testLoadingHeadline("*/*", files = null) { previewView -> verify(headlineGenerator, times(1)).getFilesHeadline(2) verifyPreviewHeadline(previewView, FILES_HEADLINE) + verifyPreviewMetadata(previewView, testMetadataText) } } @@ -125,6 +134,7 @@ class UnifiedContentPreviewUiTest { testLoadingExternalHeadline("*/*", files = null) { externalHeader -> verify(headlineGenerator, times(1)).getFilesHeadline(2) verifyPreviewHeadline(externalHeader, FILES_HEADLINE) + verifyPreviewMetadata(externalHeader, testMetadataText) } } @@ -262,7 +272,8 @@ class UnifiedContentPreviewUiTest { }, files?.let { it.asFlow() } ?: emptySourceFlow.takeWhile { it !== endMarker }, /*itemCount=*/ 2, - headlineGenerator + headlineGenerator, + testMetadataText, ) val layoutInflater = LayoutInflater.from(context) val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup @@ -302,7 +313,8 @@ class UnifiedContentPreviewUiTest { }, files?.let { it.asFlow() } ?: emptySourceFlow.takeWhile { it !== endMarker }, /*itemCount=*/ 2, - headlineGenerator + headlineGenerator, + testMetadataText, ) val layoutInflater = LayoutInflater.from(context) val gridLayout = @@ -326,15 +338,28 @@ class UnifiedContentPreviewUiTest { emptySourceFlow.tryEmit(endMarker) verifyInternalHeadlineAbsence(previewView) + verifyInternalMetadataAbsence(previewView) verificationBlock(externalHeaderView) } } + private fun verifyTextViewText( + viewParent: View?, + @IdRes textViewResId: Int, + expectedText: CharSequence, + ) { + assertThat(viewParent).isNotNull() + val textView = viewParent?.findViewById(textViewResId) + assertThat(textView).isNotNull() + assertThat(textView?.text).isEqualTo(expectedText) + } + private fun verifyPreviewHeadline(headerViewParent: View?, expectedText: String) { - Truth.assertThat(headerViewParent).isNotNull() - val headlineView = headerViewParent?.findViewById(R.id.headline) - Truth.assertThat(headlineView).isNotNull() - Truth.assertThat(headlineView?.text).isEqualTo(expectedText) + verifyTextViewText(headerViewParent, R.id.headline, expectedText) + } + + private fun verifyPreviewMetadata(headerViewParent: View?, expectedText: CharSequence) { + verifyTextViewText(headerViewParent, R.id.metadata, expectedText) } private fun verifyInternalHeadlineAbsence(previewView: ViewGroup?) { @@ -345,4 +370,12 @@ class UnifiedContentPreviewUiTest { .that(previewView?.findViewById(R.id.headline)) .isNull() } + private fun verifyInternalMetadataAbsence(previewView: ViewGroup?) { + assertWithMessage("Preview parent should not be null").that(previewView).isNotNull() + assertWithMessage( + "Preview metadata should not be inflated when an external metadata is used" + ) + .that(previewView?.findViewById(R.id.metadata)) + .isNull() + } } diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt index 831d09bf..4a33f733 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt @@ -24,20 +24,16 @@ import android.content.Intent.EXTRA_ALTERNATE_INTENTS import android.content.Intent.EXTRA_INTENT import android.content.Intent.EXTRA_REFERRER import android.net.Uri -import android.platform.test.annotations.RequiresFlagsDisabled -import android.platform.test.annotations.RequiresFlagsEnabled -import android.platform.test.flag.junit.CheckFlagsRule -import android.platform.test.flag.junit.DeviceFlagsValueProvider -import android.service.chooser.FeatureFlagsImpl +import android.service.chooser.Flags import androidx.core.net.toUri import androidx.core.os.bundleOf import com.android.intentresolver.ContentTypeHint +import com.android.intentresolver.inject.FakeChooserServiceFlags import com.android.intentresolver.v2.ui.model.ActivityLaunch import com.android.intentresolver.v2.ui.model.ChooserRequest import com.android.intentresolver.v2.validation.RequiredValueMissing import com.android.intentresolver.v2.validation.ValidationResultSubject.Companion.assertThat import com.google.common.truth.Truth.assertThat -import org.junit.Rule import org.junit.Test // TODO: replace with the new API constant, Intent#EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI @@ -64,14 +60,18 @@ private fun createLaunch( ) class ChooserRequestTest { - @get:Rule val checkFlagsRule: CheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule() - private val flags = FeatureFlagsImpl() + private val fakeChooserServiceFlags = + FakeChooserServiceFlags().apply { + setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, false) + setFlag(Flags.FLAG_CHOOSER_ALBUM_TEXT, false) + setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, false) + } @Test fun missingIntent() { val launch = createLaunch(targetIntent = null) - val result = readChooserRequest(launch, flags) + val result = readChooserRequest(launch, fakeChooserServiceFlags) assertThat(result).value().isNull() assertThat(result) @@ -85,7 +85,7 @@ class ChooserRequestTest { val launch = createLaunch(targetIntent = Intent(ACTION_SEND), referrer) launch.intent.putExtras(bundleOf(EXTRA_REFERRER to referrer)) - val result = readChooserRequest(launch, flags) + val result = readChooserRequest(launch, fakeChooserServiceFlags) val fillIn = result.value?.getReferrerFillInIntent() assertThat(fillIn?.hasExtra(EXTRA_REFERRER)).isTrue() @@ -99,7 +99,7 @@ class ChooserRequestTest { val launch = createLaunch(targetIntent = intent, referrer = referrer) - val result = readChooserRequest(launch, flags) + val result = readChooserRequest(launch, fakeChooserServiceFlags) assertThat(result.value?.referrerPackage).isNull() } @@ -111,7 +111,7 @@ class ChooserRequestTest { launch.intent.putExtras(bundleOf(EXTRA_REFERRER to referrer)) - val result = readChooserRequest(launch, flags) + val result = readChooserRequest(launch, fakeChooserServiceFlags) assertThat(result.value?.referrerPackage).isEqualTo(referrer.authority) } @@ -121,7 +121,7 @@ class ChooserRequestTest { val intent1 = Intent(ACTION_SEND) val intent2 = Intent(ACTION_SEND_MULTIPLE) val launch = createLaunch(targetIntent = intent1, additionalIntents = listOf(intent2)) - val result = readChooserRequest(launch, flags) + val result = readChooserRequest(launch, fakeChooserServiceFlags) assertThat(result.value?.payloadIntents).containsExactly(intent1, intent2) } @@ -130,7 +130,7 @@ class ChooserRequestTest { fun testRequest_withOnlyRequiredValues() { val intent = Intent().putExtras(bundleOf(EXTRA_INTENT to Intent(ACTION_SEND))) val launch = createLaunch(targetIntent = intent) - val result = readChooserRequest(launch, flags) + val result = readChooserRequest(launch, fakeChooserServiceFlags) assertThat(result).value().isNotNull() val value: ChooserRequest = result.getOrThrow() @@ -139,8 +139,8 @@ class ChooserRequestTest { } @Test - @RequiresFlagsEnabled(android.service.chooser.Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING) fun testRequest_actionSendWithAdditionalContentUri() { + fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, true) val uri = Uri.parse("content://org.pkg/path") val position = 10 val launch = @@ -148,7 +148,7 @@ class ChooserRequestTest { intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, uri) intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, position) } - val result = readChooserRequest(launch, flags) + val result = readChooserRequest(launch, fakeChooserServiceFlags) assertThat(result).value().isNotNull() val value: ChooserRequest = result.getOrThrow() @@ -158,8 +158,8 @@ class ChooserRequestTest { } @Test - @RequiresFlagsDisabled(android.service.chooser.Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING) fun testRequest_actionSendWithAdditionalContentUri_parametersIgnoredWhenFlagDisabled() { + fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, false) val uri = Uri.parse("content://org.pkg/path") val position = 10 val launch = @@ -167,7 +167,7 @@ class ChooserRequestTest { intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, uri) intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, position) } - val result = readChooserRequest(launch, flags) + val result = readChooserRequest(launch, fakeChooserServiceFlags) assertThat(result).value().isNotNull() val value: ChooserRequest = result.getOrThrow() @@ -177,14 +177,14 @@ class ChooserRequestTest { } @Test - @RequiresFlagsEnabled(android.service.chooser.Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING) fun testRequest_actionSendWithInvalidAdditionalContentUri() { + fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, true) val launch = createLaunch(targetIntent = Intent(ACTION_SEND)).apply { intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, "content://org.pkg/path") intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, "1") } - val result = readChooserRequest(launch, flags) + val result = readChooserRequest(launch, fakeChooserServiceFlags) assertThat(result).value().isNotNull() val value: ChooserRequest = result.getOrThrow() @@ -193,10 +193,10 @@ class ChooserRequestTest { } @Test - @RequiresFlagsEnabled(android.service.chooser.Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING) fun testRequest_actionSendWithoutAdditionalContentUri() { + fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, true) val launch = createLaunch(targetIntent = Intent(ACTION_SEND)) - val result = readChooserRequest(launch, flags) + val result = readChooserRequest(launch, fakeChooserServiceFlags) assertThat(result).value().isNotNull() val value: ChooserRequest = result.getOrThrow() @@ -205,8 +205,8 @@ class ChooserRequestTest { } @Test - @RequiresFlagsEnabled(android.service.chooser.Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING) fun testRequest_actionViewWithAdditionalContentUri() { + fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, true) val uri = Uri.parse("content://org.pkg/path") val position = 10 val launch = @@ -214,7 +214,7 @@ class ChooserRequestTest { intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, uri) intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, position) } - val result = readChooserRequest(launch, flags) + val result = readChooserRequest(launch, fakeChooserServiceFlags) assertThat(result).value().isNotNull() val value: ChooserRequest = result.getOrThrow() @@ -224,18 +224,54 @@ class ChooserRequestTest { } @Test - @RequiresFlagsEnabled(android.service.chooser.Flags.FLAG_CHOOSER_ALBUM_TEXT) fun testAlbumType() { + fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_ALBUM_TEXT, true) val launch = createLaunch(Intent(ACTION_SEND)) launch.intent.putExtra( Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT, Intent.CHOOSER_CONTENT_TYPE_ALBUM ) - val result = readChooserRequest(launch, flags) + val result = readChooserRequest(launch, fakeChooserServiceFlags) val value: ChooserRequest = result.getOrThrow() assertThat(value.contentTypeHint).isEqualTo(ContentTypeHint.ALBUM) assertThat(result).findings().isEmpty() } + + @Test + fun metadataText_whenFlagFalse_isNull() { + // Arrange + fakeChooserServiceFlags.setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, false) + val metadataText: CharSequence = "Test metadata text" + val launch = + createLaunch(targetIntent = Intent()).apply { + intent.putExtra(Intent.EXTRA_METADATA_TEXT, metadataText) + } + + // Act + val result = readChooserRequest(launch, fakeChooserServiceFlags) + + // Assert + assertThat(result).value().isNotNull() + assertThat(result.value?.metadataText).isNull() + } + + @Test + fun metadataText_whenFlagTrue_isPassedText() { + // Arrange + fakeChooserServiceFlags.setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, true) + val metadataText: CharSequence = "Test metadata text" + val launch = + createLaunch(targetIntent = Intent()).apply { + intent.putExtra(Intent.EXTRA_METADATA_TEXT, metadataText) + } + + // Act + val result = readChooserRequest(launch, fakeChooserServiceFlags) + + // Assert + assertThat(result).value().isNotNull() + assertThat(result.value?.metadataText).isEqualTo(metadataText) + } } -- cgit v1.2.3-59-g8ed1b From 6bbc4826920506943bd7a286ff94eceba9b34251 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Thu, 8 Feb 2024 13:07:14 -0800 Subject: Shareousel selection tracker component A building block for the payload toggling functionality. A component to track items selection and their relative order. The order is specified by the set of the initially selected items and can be overriden by the order on items in the cursor (in case the cursor and the set of initilly selected items is not in sync, which is not expected). Bug: 302691505 Test: IntentResolver-tests-unit Change-Id: I092d9b678c7fbcdc8303e04de0cacbb9d125fa5f --- .../contentpreview/CursorUriReader.kt | 2 +- .../contentpreview/SelectionTracker.kt | 175 +++++++++++ .../contentpreview/SelectionTrackerTest.kt | 319 +++++++++++++++++++++ 3 files changed, 495 insertions(+), 1 deletion(-) create mode 100644 java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt create mode 100644 tests/unit/src/com/android/intentresolver/contentpreview/SelectionTrackerTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt b/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt index 30495b8b..91983635 100644 --- a/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt +++ b/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt @@ -74,7 +74,7 @@ class CursorUriReader( return SparseArray() } val result = SparseArray(leftPos - startPos) - for (pos in startPos ..< leftPos) { + for (pos in startPos until leftPos) { cursor .getString(0) ?.let(Uri::parse) diff --git a/java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt b/java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt new file mode 100644 index 00000000..4ce006ec --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt @@ -0,0 +1,175 @@ +/* + * Copyright 2024 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 +import android.util.SparseArray +import android.util.SparseIntArray +import androidx.core.util.containsKey +import androidx.core.util.isNotEmpty + +/** + * Tracks selected items (including those that has not been read frm the cursor) and their relative + * order. + */ +class SelectionTracker( + selectedItems: List, + private val focusedItemIdx: Int, + private val cursorCount: Int, + private val getUri: Item.() -> Uri, +) { + /** Contains selected items keys. */ + private val selections = SparseArray(selectedItems.size) + + /** + * A set of initially selected items that has not yet been observed by the lazy read of the + * cursor and thus has unknown key (cursor position). Initially, all [selectedItems] are put in + * this map with items at the index less than [focusedItemIdx] with negative keys (to the left + * of all cursor items) and items at the index more or equal to [focusedItemIdx] with keys more + * or equal to [cursorCount] (to the right of all cursor items) in their relative order. Upon + * reading the cursor, [onEndItemsAdded]/[onStartItemsAdded], all pending items from that + * collection in the corresponding direction get their key assigned and gets removed from the + * map. Items that were missing from the cursor get removed from the map by + * [getPendingItems] + [onStartItemsAdded]/[onEndItemsAdded] combination. + */ + private val pendingKeys = HashMap() + + init { + selectedItems.forEachIndexed { i, item -> + // all items before focusedItemIdx gets "positioned" before all the cursor items + // and all the reset after all the cursor items in their relative order. + // Also see the comments to pendingKeys property. + val key = + if (i < focusedItemIdx) { + i - focusedItemIdx + } else { + i + cursorCount - focusedItemIdx + } + selections.append(key, item) + pendingKeys.getOrPut(item.getUri()) { SparseIntArray(1) }.append(key, key) + } + } + + /** Update selections based on the set of items read from the end of the cursor */ + fun onEndItemsAdded(items: SparseArray) { + for (i in 0 until items.size()) { + val item = items.valueAt(i) + pendingKeys[item.getUri()] + // if only one pending (unmatched) item with this URI is left, removed this URI + ?.also { + if (it.size() <= 1) { + pendingKeys.remove(item.getUri()) + } + } + // a safeguard, we should not observe empty arrays at this point + ?.takeIf { it.isNotEmpty() } + // pick a matching pending items from the right side + ?.let { pendingUriPositions -> + val key = items.keyAt(i) + val insertPos = + pendingUriPositions + .findBestKeyPosition(key) + .coerceIn(0, pendingUriPositions.size() - 1) + // select next pending item from the right, if not such item exists then + // the data is inconsistent and we pick the closes one from the left + val keyPlaceholder = pendingUriPositions.keyAt(insertPos) + pendingUriPositions.removeAt(insertPos) + selections.remove(keyPlaceholder) + selections[key] = item + } + } + } + + /** Update selections based on the set of items read from the head of the cursor */ + fun onStartItemsAdded(items: SparseArray) { + for (i in (items.size() - 1) downTo 0) { + val item = items.valueAt(i) + pendingKeys[item.getUri()] + // if only one pending (unmatched) item with this URI is left, removed this URI + ?.also { + if (it.size() <= 1) { + pendingKeys.remove(item.getUri()) + } + } + // a safeguard, we should not observe empty arrays at this point + ?.takeIf { it.isNotEmpty() } + // pick a matching pending items from the left side + ?.let { pendingUriPositions -> + val key = items.keyAt(i) + val insertPos = + pendingUriPositions + .findBestKeyPosition(key) + .coerceIn(1, pendingUriPositions.size()) + // select next pending item from the left, if not such item exists then + // the data is inconsistent and we pick the closes one from the right + val keyPlaceholder = pendingUriPositions.keyAt(insertPos - 1) + pendingUriPositions.removeAt(insertPos - 1) + selections.remove(keyPlaceholder) + selections[key] = item + } + } + } + + /** Updated selection status for the given item */ + fun setItemSelection(key: Int, item: Item, isSelected: Boolean): Boolean { + val idx = selections.indexOfKey(key) + if (isSelected && idx < 0) { + selections[key] = item + return true + } + if (!isSelected && idx >= 0) { + selections.removeAt(idx) + return true + } + return false + } + + /** Return selection status for the given item */ + fun isItemSelected(key: Int): Boolean = selections.containsKey(key) + + fun getSelection(): List = + buildList(selections.size()) { + for (i in 0 until selections.size()) { + add(selections.valueAt(i)) + } + } + + /** Return all selected items that has not yet been read from the cursor */ + fun getPendingItems(): List = + if (pendingKeys.isEmpty()) { + emptyList() + } else { + buildList { + for (i in 0 until selections.size()) { + val item = selections.valueAt(i) ?: continue + if (isPending(item, selections.keyAt(i))) { + add(item) + } + } + } + } + + private fun isPending(item: Item, key: Int): Boolean { + val keys = pendingKeys[item.getUri()] ?: return false + return keys.containsKey(key) + } + + private fun SparseIntArray.findBestKeyPosition(key: Int): Int = + // undocumented, but indexOfKey behaves in the same was as + // java.util.Collections#binarySearch() + indexOfKey(key).let { if (it < 0) it.inv() else it } +} diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/SelectionTrackerTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/SelectionTrackerTest.kt new file mode 100644 index 00000000..13f1f44f --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/SelectionTrackerTest.kt @@ -0,0 +1,319 @@ +/* + * Copyright 2024 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 +import android.util.SparseArray +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class SelectionTrackerTest { + @Test + fun noSelectedItems() { + val testSubject = SelectionTracker(emptyList(), 0, 10) { this } + + val items = + (1..5).fold(SparseArray(5)) { acc, i -> + acc.apply { append(i * 2, makeUri(i * 2)) } + } + testSubject.onEndItemsAdded(items) + + assertThat(testSubject.getSelection()).isEmpty() + } + + @Test + fun testNoItems() { + val u1 = makeUri(1) + val u2 = makeUri(2) + val u3 = makeUri(3) + val testSubject = SelectionTracker(listOf(u1, u2, u3), 1, 0) { this } + + assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() + } + + @Test + fun focusedItemInPlaceAllItemsOnTheRight_selectionsInTheInitialOrder() { + val u1 = makeUri(1) + val u2 = makeUri(2) + val u3 = makeUri(3) + val count = 7 + val testSubject = SelectionTracker(listOf(u1, u2, u3), 0, count) { this } + + testSubject.onEndItemsAdded( + SparseArray(3).apply { + append(1, u1) + append(2, makeUri(4)) + append(3, makeUri(5)) + } + ) + assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() + testSubject.onEndItemsAdded( + SparseArray(3).apply { + append(3, makeUri(6)) + append(4, u2) + append(5, u3) + } + ) + assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() + } + + @Test + fun focusedItemInPlaceElementsOnBothSides_selectionsInTheInitialOrder() { + val u1 = makeUri(1) + val u2 = makeUri(2) + val u3 = makeUri(3) + val count = 10 + val testSubject = SelectionTracker(listOf(u1, u2, u3), 1, count) { this } + + testSubject.onEndItemsAdded( + SparseArray(3).apply { + append(4, u2) + append(5, makeUri(4)) + append(6, makeUri(5)) + } + ) + assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() + + testSubject.onStartItemsAdded( + SparseArray(3).apply { + append(1, makeUri(6)) + append(2, u1) + append(3, makeUri(7)) + } + ) + assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() + + testSubject.onEndItemsAdded(SparseArray(3).apply { append(8, u3) }) + assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() + } + + @Test + fun focusedItemInPlaceAllItemsOnTheLeft_selectionsInTheInitialOrder() { + val u1 = makeUri(1) + val u2 = makeUri(2) + val u3 = makeUri(3) + val count = 7 + val testSubject = SelectionTracker(listOf(u1, u2, u3), 2, count) { this } + + testSubject.onEndItemsAdded(SparseArray(3).apply { append(6, u3) }) + + assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() + + testSubject.onStartItemsAdded( + SparseArray(3).apply { + append(3, makeUri(4)) + append(4, u2) + append(5, makeUri(5)) + } + ) + assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() + + testSubject.onStartItemsAdded( + SparseArray(3).apply { + append(1, u1) + append(2, makeUri(6)) + } + ) + assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() + } + + @Test + fun focusedItemInPlaceDuplicatesOnBothSides_selectionsInTheInitialOrder() { + val u1 = makeUri(1) + val u2 = makeUri(2) + val u3 = makeUri(3) + val count = 5 + val testSubject = SelectionTracker(listOf(u1, u2, u1), 1, count) { this } + + testSubject.onEndItemsAdded(SparseArray(3).apply { append(2, u2) }) + assertThat(testSubject.getSelection()).containsExactly(u1, u2, u1).inOrder() + + testSubject.onStartItemsAdded( + SparseArray(3).apply { + append(0, u1) + append(1, u3) + } + ) + assertThat(testSubject.getSelection()).containsExactly(u1, u2, u1).inOrder() + + testSubject.onStartItemsAdded( + SparseArray(3).apply { + append(3, u1) + append(4, u3) + } + ) + assertThat(testSubject.getSelection()).containsExactly(u1, u2, u1).inOrder() + } + + @Test + fun focusedItemInPlaceDuplicatesOnTheRight_selectionsInTheInitialOrder() { + val u1 = makeUri(1) + val u2 = makeUri(2) + val count = 4 + val testSubject = SelectionTracker(listOf(u1, u2), 0, count) { this } + + testSubject.onEndItemsAdded(SparseArray(1).apply { append(0, u1) }) + assertThat(testSubject.getSelection()).containsExactly(u1, u2).inOrder() + + testSubject.onEndItemsAdded( + SparseArray(3).apply { + append(1, u2) + append(2, u1) + append(3, u2) + } + ) + assertThat(testSubject.getSelection()).containsExactly(u1, u2).inOrder() + } + + @Test + fun focusedItemInPlaceDuplicatesOnTheLeft_selectionsInTheInitialOrder() { + val u1 = makeUri(1) + val u2 = makeUri(2) + val count = 4 + val testSubject = SelectionTracker(listOf(u1, u2), 1, count) { this } + + testSubject.onEndItemsAdded(SparseArray(1).apply { append(3, u2) }) + assertThat(testSubject.getSelection()).containsExactly(u1, u2).inOrder() + + testSubject.onStartItemsAdded( + SparseArray(3).apply { + append(0, u1) + append(1, u2) + append(2, u1) + } + ) + assertThat(testSubject.getSelection()).containsExactly(u1, u2).inOrder() + } + + @Test + fun differentItemsOrder_selectionsInTheCursorOrder() { + val u1 = makeUri(1) + val u2 = makeUri(2) + val u3 = makeUri(3) + val u4 = makeUri(3) + val count = 10 + val testSubject = SelectionTracker(listOf(u1, u2, u3, u4), 2, count) { this } + + testSubject.onEndItemsAdded( + SparseArray(3).apply { + append(4, makeUri(5)) + append(5, u1) + append(6, makeUri(6)) + } + ) + testSubject.onStartItemsAdded( + SparseArray(3).apply { + append(2, makeUri(7)) + append(3, u4) + } + ) + testSubject.onEndItemsAdded( + SparseArray(3).apply { + append(7, u3) + append(8, makeUri(8)) + } + ) + testSubject.onStartItemsAdded( + SparseArray(3).apply { + append(0, makeUri(9)) + append(1, u2) + } + ) + assertThat(testSubject.getSelection()).containsExactly(u2, u4, u1, u3).inOrder() + } + + @Test + fun testPendingItems() { + val u1 = makeUri(1) + val u2 = makeUri(2) + val u3 = makeUri(3) + val u4 = makeUri(4) + val u5 = makeUri(5) + + val testSubject = SelectionTracker(listOf(u1, u2, u3, u4, u5), 2, 5) { this } + + testSubject.onEndItemsAdded( + SparseArray(2).apply { + append(2, u3) + append(3, u4) + } + ) + testSubject.onStartItemsAdded(SparseArray(2).apply { append(1, u2) }) + + assertThat(testSubject.getPendingItems()).containsExactly(u1, u5).inOrder() + } + + @Test + fun testItemSelection() { + val u1 = makeUri(1) + val u2 = makeUri(2) + val u3 = makeUri(3) + val u4 = makeUri(4) + val u5 = makeUri(5) + + val testSubject = SelectionTracker(listOf(u1, u2, u3, u4, u5), 2, 10) { this } + + testSubject.onEndItemsAdded( + SparseArray(2).apply { + append(2, u3) + append(3, u4) + } + ) + assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3, u4, u5).inOrder() + + assertThat(testSubject.setItemSelection(2, u3, false)).isTrue() + assertThat(testSubject.setItemSelection(3, u4, true)).isFalse() + assertThat(testSubject.getSelection()).containsExactly(u1, u2, u4, u5).inOrder() + + testSubject.onEndItemsAdded( + SparseArray(1).apply { + append(4, u5) + append(5, u3) + } + ) + testSubject.onStartItemsAdded( + SparseArray(2).apply { + append(0, u1) + append(1, u2) + } + ) + assertThat(testSubject.getSelection()).containsExactly(u1, u2, u4, u5).inOrder() + + assertThat(testSubject.setItemSelection(2, u3, true)).isTrue() + assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3, u4, u5).inOrder() + assertThat(testSubject.setItemSelection(5, u3, true)).isTrue() + assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3, u4, u5, u3).inOrder() + } + + @Test + fun testItemSelectionWithDuplicates() { + val u1 = makeUri(1) + val u2 = makeUri(2) + + val testSubject = SelectionTracker(listOf(u1, u2, u1), 1, 3) { this } + testSubject.onEndItemsAdded( + SparseArray(2).apply { + append(1, u2) + append(2, u1) + } + ) + + assertThat(testSubject.getPendingItems()).containsExactly(u1) + } +} + +private fun makeUri(id: Int) = Uri.parse("content://org.pkg.app/img-$id.png") -- cgit v1.2.3-59-g8ed1b From abc82dc47cbc5e878493b17e5479044c185ed88d Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Thu, 8 Feb 2024 18:18:57 -0800 Subject: Add PayloadToggleInteractor implementation Bug: 302691505 Test: IntentResolver-tests-unit Change-Id: I0d568a6c30781e81a65b33e8e8ae46e3def23bb9 --- .../contentpreview/CursorUriReader.kt | 6 +- .../contentpreview/PayloadToggleInteractor.kt | 357 +++++++++++++++++++-- .../contentpreview/PreviewViewModel.kt | 2 +- .../ui/composable/ShareouselComposable.kt | 3 +- .../shareousel/ui/viewmodel/ShareouselViewModel.kt | 4 +- .../contentpreview/PayloadToggleInteractorTest.kt | 95 ++++++ 6 files changed, 441 insertions(+), 26 deletions(-) create mode 100644 tests/unit/src/com/android/intentresolver/contentpreview/PayloadToggleInteractorTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt b/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt index 91983635..dbf27a88 100644 --- a/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt +++ b/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt @@ -34,9 +34,11 @@ class CursorUriReader( private val predicate: (Uri) -> Boolean, ) : PayloadToggleInteractor.CursorReader { override val count = cursor.count - // the first position of the next unread page on the right + // Unread ranges are: + // - left: [0, leftPos); + // - right: [rightPos, count) + // i.e. read range is: [leftPos, rightPos) private var rightPos = startPos.coerceIn(0, count) - // the first position of the next from the leftmost unread page on the left private var leftPos = rightPos override val hasMoreBefore diff --git a/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt b/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt index ca868226..3393dcfc 100644 --- a/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt @@ -16,44 +16,357 @@ package com.android.intentresolver.contentpreview +import android.content.Intent import android.net.Uri import android.service.chooser.ChooserAction +import android.util.Log import android.util.SparseArray import java.io.Closeable +import java.util.LinkedList +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.channels.BufferOverflow.DROP_LATEST import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch -class PayloadToggleInteractor { +private const val TAG = "PayloadToggleInteractor" - private val storage = MutableStateFlow>(emptyMap()) // TODO: implement - private val selectedKeys = MutableStateFlow>(emptySet()) +class PayloadToggleInteractor( + // must use single-thread dispatcher (or we should enforce it with a lock) + private val scope: CoroutineScope, + private val initiallySharedUris: List, + private val focusedUriIdx: Int, + private val mimeTypeClassifier: MimeTypeClassifier, + private val cursorReaderProvider: suspend () -> CursorReader, + private val uriMetadataReader: (Uri) -> FileInfo, + private val targetIntentModifier: (List) -> Intent, + private val selectionCallback: (Intent) -> CallbackResult?, +) { + private var cursorDataRef = CompletableDeferred() + private val records = LinkedList() + private val prevPageLoadingGate = AtomicBoolean(true) + private val nextPageLoadingGate = AtomicBoolean(true) + private val notifySelectionJobRef = AtomicReference() + private val emptyState = + State( + emptyList(), + hasMoreItemsBefore = false, + hasMoreItemsAfter = false, + allowSelectionChange = false + ) + + private val stateFlowSource = MutableStateFlow(emptyState) + + val customActions = + MutableSharedFlow>(replay = 1, onBufferOverflow = DROP_LATEST) + + val stateFlow: Flow + get() = stateFlowSource.filter { it !== emptyState } + + val targetPosition: Flow = stateFlow.map { it.targetPos } + val previewKeys: Flow> = stateFlow.map { it.items } + + fun getKey(item: Any): Int = (item as Item).key + + fun selected(key: Item): Flow = (key as Record).isSelected - val targetPosition: Flow = flowOf(0) // TODO: implement - val previewKeys: Flow> = flowOf(emptyList()) // TODO: implement + fun previewUri(key: Item): Flow = flow { emit(key.previewUri) } - fun setSelected(key: Any, isSelected: Boolean) { - if (isSelected) { - selectedKeys.update { it + key } + fun previewInteractor(key: Any): PayloadTogglePreviewInteractor { + val state = stateFlowSource.value + if (state === emptyState) { + Log.wtf(TAG, "Requesting item preview before any item has been published") } else { - selectedKeys.update { it - key } + if (state.hasMoreItemsBefore && key === state.items.firstOrNull()) { + loadMorePreviousItems() + } + if (state.hasMoreItemsAfter && key == state.items.lastOrNull()) { + loadMoreNextItems() + } } + return PayloadTogglePreviewInteractor(key as Item, this) } - fun selected(key: Any): Flow = previewKeys.map { key in it } + init { + scope + .launch { awaitCancellation() } + .invokeOnCompletion { + cursorDataRef.cancel() + runCatching { + if (cursorDataRef.isCompleted && !cursorDataRef.isCancelled) { + cursorDataRef.getCompleted() + } else { + null + } + } + .getOrNull() + ?.reader + ?.close() + } + } - fun previewInteractor(key: Any) = PayloadTogglePreviewInteractor(key, this) + fun start() { + scope.launch { + publishInitialState() + val cursorReader = cursorReaderProvider() + val selectedItems = + initiallySharedUris.map { uri -> + val fileInfo = uriMetadataReader(uri) + Record( + 0, // artificial key for the pending record, it should not be used anywhere + uri, + fileInfo.previewUri, + fileInfo.mimeType, + ) + } + val cursorData = + CursorData( + cursorReader, + SelectionTracker(selectedItems, focusedUriIdx, cursorReader.count) { uri }, + ) + if (cursorDataRef.complete(cursorData)) { + doLoadMorePreviousItems() + val startPos = records.size + doLoadMoreNextItems() + prevPageLoadingGate.set(false) + nextPageLoadingGate.set(false) + publishSnapshot(startPos) + } else { + cursorReader.close() + } + } + } - fun previewUri(key: Any): Flow = storage.map { it[key]?.previewUri } + private suspend fun publishInitialState() { + stateFlowSource.emit( + State( + if (0 <= focusedUriIdx && focusedUriIdx < initiallySharedUris.size) { + val fileInfo = uriMetadataReader(initiallySharedUris[focusedUriIdx]) + listOf( + Record( + // a unique key that won't appear anywhere after more items are loaded + -initiallySharedUris.size - 1, + initiallySharedUris[focusedUriIdx], + fileInfo.previewUri, + fileInfo.mimeType, + fileInfo.mimeType?.mimeTypeToItemType() ?: ItemType.File, + ), + ) + } else { + emptyList() + }, + hasMoreItemsBefore = true, + hasMoreItemsAfter = true, + allowSelectionChange = false, + ) + ) + } + + fun loadMorePreviousItems() { + invokeAsyncIfNotRunning(prevPageLoadingGate) { + doLoadMorePreviousItems() + publishSnapshot() + } + } + + fun loadMoreNextItems() { + invokeAsyncIfNotRunning(nextPageLoadingGate) { + doLoadMoreNextItems() + publishSnapshot() + } + } + + fun setSelected(item: Item, isSelected: Boolean) { + val record = item as Record + record.isSelected.value = isSelected + scope.launch { + val (_, selectionTracker) = waitForCursorData() ?: return@launch + selectionTracker.setItemSelection(record.key, record, isSelected) + val targetIntent = targetIntentModifier(selectionTracker.getSelection()) + + val newJob = scope.launch { notifySelectionChanged(targetIntent) } + notifySelectionJobRef.getAndSet(newJob)?.cancel() + } + } + + private fun invokeAsyncIfNotRunning(guardingFlag: AtomicBoolean, block: suspend () -> Unit) { + if (guardingFlag.compareAndSet(false, true)) { + scope.launch { block() }.invokeOnCompletion { guardingFlag.set(false) } + } + } + + private suspend fun doLoadMorePreviousItems() { + val (reader, selectionTracker) = waitForCursorData() ?: return + if (!reader.hasMoreBefore) return + + val newItems = reader.readPageBefore().toRecords() + selectionTracker.onStartItemsAdded(newItems) + for (i in newItems.size() - 1 downTo 0) { + records.add( + 0, + (newItems.valueAt(i) as Record).apply { + isSelected.value = selectionTracker.isItemSelected(key) + } + ) + } + if (!reader.hasMoreBefore && !reader.hasMoreAfter) { + val pendingItems = selectionTracker.getPendingItems() + val newRecords = + pendingItems.foldIndexed(SparseArray()) { idx, acc, item -> + assert(item is Record) { "Unexpected pending item type: ${item.javaClass}" } + val rec = item as Record + val key = idx - pendingItems.size + acc.append( + key, + Record( + key, + rec.uri, + rec.previewUri, + rec.mimeType, + rec.mimeType?.mimeTypeToItemType() ?: ItemType.File + ) + ) + acc + } - private data class Item( - val previewUri: Uri?, + selectionTracker.onStartItemsAdded(newRecords) + for (i in (newRecords.size() - 1) downTo 0) { + records.add(0, (newRecords.valueAt(i) as Record).apply { isSelected.value = true }) + } + } + } + + private suspend fun doLoadMoreNextItems() { + val (reader, selectionTracker) = waitForCursorData() ?: return + if (!reader.hasMoreAfter) return + + val newItems = reader.readPageAfter().toRecords() + selectionTracker.onEndItemsAdded(newItems) + for (i in 0 until newItems.size()) { + val key = newItems.keyAt(i) + records.add( + (newItems.valueAt(i) as Record).apply { + isSelected.value = selectionTracker.isItemSelected(key) + } + ) + } + if (!reader.hasMoreBefore && !reader.hasMoreAfter) { + val items = + selectionTracker.getPendingItems().let { items -> + items.foldIndexed(SparseArray(items.size)) { i, acc, item -> + val key = reader.count + i + val record = item as Record + acc.append( + key, + Record(key, record.uri, record.previewUri, record.mimeType, record.type) + ) + acc + } + } + selectionTracker.onEndItemsAdded(items) + for (i in 0 until items.size()) { + records.add((items.valueAt(i) as Record).apply { isSelected.value = true }) + } + } + } + + private fun SparseArray.toRecords(): SparseArray { + val items = SparseArray(size()) + for (i in 0 until size()) { + val key = keyAt(i) + val uri = valueAt(i) + val fileInfo = uriMetadataReader(uri) + items.append( + key, + Record( + key, + uri, + fileInfo.previewUri, + fileInfo.mimeType, + fileInfo.mimeType?.mimeTypeToItemType() ?: ItemType.File + ) + ) + } + return items + } + + private suspend fun waitForCursorData() = cursorDataRef.await() + + private fun notifySelectionChanged(targetIntent: Intent) { + selectionCallback(targetIntent)?.customActions?.let { customActions.tryEmit(it) } + } + + private suspend fun publishSnapshot(startPos: Int = -1) { + val (reader, _) = waitForCursorData() ?: return + // TODO: publish a view into the list as it can only grow on each side thus a view won't be + // invalidated + val items = ArrayList(records) + stateFlowSource.emit( + State( + items, + reader.hasMoreBefore, + reader.hasMoreAfter, + allowSelectionChange = true, + targetPos = startPos, + ) + ) + } + + private fun String.mimeTypeToItemType(): ItemType = + when { + mimeTypeClassifier.isImageType(this) -> ItemType.Image + mimeTypeClassifier.isVideoType(this) -> ItemType.Video + else -> ItemType.File + } + + class State( + val items: List, + val hasMoreItemsBefore: Boolean, + val hasMoreItemsAfter: Boolean, + val allowSelectionChange: Boolean, + val targetPos: Int = -1, ) + sealed interface Item { + val key: Int + val uri: Uri + val previewUri: Uri? + val mimeType: String? + val type: ItemType + } + + enum class ItemType { + Image, + Video, + File, + } + + private class Record( + override val key: Int, + override val uri: Uri, + override val previewUri: Uri? = uri, + override val mimeType: String?, + override val type: ItemType = ItemType.Image, + ) : Item { + val isSelected = MutableStateFlow(false) + } + data class CallbackResult(val customActions: List?) + private data class CursorData( + val reader: CursorReader, + val selectionTracker: SelectionTracker, + ) + interface CursorReader : Closeable { val count: Int val hasMoreBefore: Boolean @@ -66,13 +379,17 @@ class PayloadToggleInteractor { } class PayloadTogglePreviewInteractor( - private val key: Any, + private val item: PayloadToggleInteractor.Item, private val interactor: PayloadToggleInteractor, ) { fun setSelected(selected: Boolean) { - interactor.setSelected(key, selected) + interactor.setSelected(item, selected) } - val previewUri: Flow = interactor.previewUri(key) - val selected: Flow = interactor.selected(key) + val previewUri: Flow + get() = interactor.previewUri(item) + val selected: Flow + get() = interactor.selected(item) + val key + get() = item.key } diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt index d855ea16..77cf0ac9 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt @@ -65,7 +65,7 @@ constructor( } override val payloadToggleInteractor: PayloadToggleInteractor? by lazy { - PayloadToggleInteractor() + null // TODO: initialize PayloadToggleInteractor() } companion object { diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt index c83c10b0..f636966e 100644 --- a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt @@ -15,7 +15,6 @@ */ package com.android.intentresolver.contentpreview.shareousel.ui.composable -import android.os.Parcelable import androidx.compose.foundation.Image import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -64,7 +63,7 @@ fun Shareousel(viewModel: ShareouselViewModel) { Modifier.fillMaxWidth() .height(dimensionResource(R.dimen.chooser_preview_image_height_tall)) ) { - items(previewKeys, key = { (it as? Parcelable) ?: Unit }) { key -> + items(previewKeys, key = viewModel.previewRowKey) { key -> ShareouselCard(viewModel.previewForKey(key)) } } diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt index 4592ea6d..ff22a6fd 100644 --- a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt @@ -29,6 +29,7 @@ data class ShareouselViewModel( val actions: Flow>, val centerIndex: Flow, val previewForKey: (key: Any) -> ShareouselImageViewModel, + val previewRowKey: (Any) -> Any ) data class ActionChipViewModel(val label: String, val icon: ComposeIcon?, val onClick: () -> Unit) @@ -56,6 +57,7 @@ fun PayloadToggleInteractor.toShareouselViewModel(imageLoader: ImageLoader): Sha setSelected = { isSelected -> previewInteractor.setSelected(isSelected) }, onActionClick = {}, ) - } + }, + previewRowKey = { getKey(it) }, ) } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/PayloadToggleInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/PayloadToggleInteractorTest.kt new file mode 100644 index 00000000..472c2ba4 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/PayloadToggleInteractorTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright 2024 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.Intent +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class PayloadToggleInteractorTest { + private val scheduler = TestCoroutineScheduler() + private val testScope = TestScope(scheduler) + + @Test + fun initialState() = + testScope.runTest { + val cursorReader = CursorUriReader(createCursor(10), 2, 2) { true } + val testSubject = + PayloadToggleInteractor( + scope = testScope.backgroundScope, + initiallySharedUris = listOf(makeUri(0), makeUri(2), makeUri(5)), + focusedUriIdx = 1, + mimeTypeClassifier = DefaultMimeTypeClassifier, + cursorReaderProvider = { cursorReader }, + uriMetadataReader = { uri -> + FileInfo.Builder(uri) + .withMimeType("image/png") + .withPreviewUri(uri) + .build() + }, + selectionCallback = { null }, + targetIntentModifier = { Intent(Intent.ACTION_SEND) }, + ) + .apply { start() } + + scheduler.runCurrent() + + testSubject.stateFlow.first().let { initialState -> + assertThat(initialState.items).hasSize(4) + assertThat(initialState.items.map { it.uri }) + .containsExactly(*Array(4, ::makeUri)) + .inOrder() + assertThat(initialState.hasMoreItemsBefore).isFalse() + assertThat(initialState.hasMoreItemsAfter).isTrue() + assertThat(initialState.allowSelectionChange).isTrue() + } + + testSubject.loadMoreNextItems() + // this one is expected to be deduplicated + testSubject.loadMoreNextItems() + scheduler.runCurrent() + + testSubject.stateFlow.first().let { state -> + assertThat(state.items.map { it.uri }) + .containsExactly(*Array(6, ::makeUri)) + .inOrder() + assertThat(state.hasMoreItemsBefore).isFalse() + assertThat(state.hasMoreItemsAfter).isTrue() + assertThat(state.allowSelectionChange).isTrue() + assertThat(state.items.map { testSubject.selected(it).first() }) + .containsExactly(true, false, true, false, false, true) + .inOrder() + } + } +} + +private fun createCursor(count: Int): Cursor { + return MatrixCursor(arrayOf("uri")).apply { + for (i in 0 until count) { + addRow(arrayOf(makeUri(i))) + } + } +} + +private fun makeUri(id: Int) = Uri.parse("content://org.pkg.app/img-$id.png") -- cgit v1.2.3-59-g8ed1b From 37c9ff0d37ff33babf6ee845aaf4d470660765f7 Mon Sep 17 00:00:00 2001 From: Steve Elliott Date: Mon, 12 Feb 2024 12:18:44 -0500 Subject: Remove shareousel preview action button Bug: 302691505 Flag: ACONFIG android.service.chooser.chooser_payload_toggling DEVELOPMENT Test: N/A - code isn't live Change-Id: I52709bc0740792e1a63cc5a2519559df67b746f7 --- .../ui/composable/ShareouselCardComposable.kt | 30 ---------------------- .../ui/composable/ShareouselComposable.kt | 1 - .../shareousel/ui/viewmodel/ShareouselViewModel.kt | 2 -- 3 files changed, 33 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt index 9f31c0e4..dc96e3c1 100644 --- a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt @@ -21,11 +21,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Edit import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -42,7 +38,6 @@ import com.android.intentresolver.R fun ShareouselCard( image: @Composable () -> Unit, selected: Boolean, - onActionClick: () -> Unit, modifier: Modifier = Modifier, ) { Box(modifier) { @@ -51,35 +46,10 @@ fun ShareouselCard( Box(modifier = Modifier.padding(topButtonPadding).matchParentSize()) { SelectionIcon(selected, modifier = Modifier.align(Alignment.TopStart)) AnimationIcon(modifier = Modifier.align(Alignment.TopEnd)) - ActionButton( - onActionClick, - modifier = - Modifier.background( - MaterialTheme.colorScheme.secondary, - shape = RoundedCornerShape(12.dp), - ) - .size(32.dp) - .align(Alignment.BottomEnd) - ) } } } -@Composable -private fun ActionButton( - onActionClick: () -> Unit, - modifier: Modifier = Modifier, -) { - IconButton(onClick = { onActionClick() }, modifier = modifier) { - Icon( - Icons.Outlined.Edit, - contentDescription = "edit", - tint = Color(0xFF1B1C14), - modifier = Modifier.padding(8.dp) - ) - } -} - @Composable private fun AnimationIcon(modifier: Modifier = Modifier) { Icon( diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt index c83c10b0..eb8c4f88 100644 --- a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt @@ -117,7 +117,6 @@ private fun ShareouselCard(viewModel: ShareouselImageViewModel) { } }, selected = selected, - onActionClick = { viewModel.onActionClick() }, modifier = Modifier.thenIf(selected) { Modifier.border( diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt index 4592ea6d..4a9e1d86 100644 --- a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt @@ -38,7 +38,6 @@ data class ShareouselImageViewModel( val contentDescription: Flow, val isSelected: Flow, val setSelected: (Boolean) -> Unit, - val onActionClick: () -> Unit, ) fun PayloadToggleInteractor.toShareouselViewModel(imageLoader: ImageLoader): ShareouselViewModel { @@ -54,7 +53,6 @@ fun PayloadToggleInteractor.toShareouselViewModel(imageLoader: ImageLoader): Sha contentDescription = MutableStateFlow(""), isSelected = previewInteractor.selected, setSelected = { isSelected -> previewInteractor.setSelected(isSelected) }, - onActionClick = {}, ) } ) -- cgit v1.2.3-59-g8ed1b From a5162406bf48d155d3927c33e51aeee4368a24ff Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Mon, 5 Feb 2024 12:18:13 -0500 Subject: Additional Details for Sharesheet App Callbacks Bug: 263474465 Test: atest ShareResultSenderImplTest Change-Id: Icb61fa49dd2989cc50d7024da19d863e6c2fc189 --- .../intentresolver/v2/ChooserActionFactory.java | 40 ++++- .../android/intentresolver/v2/ChooserActivity.java | 31 ++-- .../intentresolver/v2/ui/ShareResultSender.kt | 163 ++++++++++++++++++ .../intentresolver/v2/ui/model/ShareAction.kt | 23 +++ .../v2/ui/viewmodel/ChooserRequestReader.kt | 4 +- tests/unit/Android.bp | 1 + .../intentresolver/v2/ChooserActionFactoryTest.kt | 92 +++++----- .../v2/ui/ShareResultSenderImplTest.kt | 190 +++++++++++++++++++++ 8 files changed, 477 insertions(+), 67 deletions(-) create mode 100644 java/src/com/android/intentresolver/v2/ui/ShareResultSender.kt create mode 100644 java/src/com/android/intentresolver/v2/ui/model/ShareAction.kt create mode 100644 tests/unit/src/com/android/intentresolver/v2/ui/ShareResultSenderImplTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ChooserActionFactory.java b/java/src/com/android/intentresolver/v2/ChooserActionFactory.java index db840387..70a2b58e 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/v2/ChooserActionFactory.java @@ -40,6 +40,8 @@ import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; import com.android.intentresolver.logging.EventLog; +import com.android.intentresolver.v2.ui.ShareResultSender; +import com.android.intentresolver.v2.ui.model.ShareAction; import com.android.intentresolver.widget.ActionRow; import com.android.internal.annotations.VisibleForTesting; @@ -97,12 +99,12 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio private final Context mContext; - @Nullable - private final Runnable mCopyButtonRunnable; - private final Runnable mEditButtonRunnable; + @Nullable private Runnable mCopyButtonRunnable; + private Runnable mEditButtonRunnable; private final ImmutableList mCustomActions; - private final @Nullable ChooserAction mModifyShareAction; + @Nullable private final ChooserAction mModifyShareAction; private final Consumer mExcludeSharedTextAction; + @Nullable private final ShareResultSender mShareResultSender; private final Consumer mFinishCallback; private final EventLog mLog; @@ -122,12 +124,13 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio Intent targetIntent, String referrerPackageName, List chooserActions, - ChooserAction modifyShareAction, + @Nullable ChooserAction modifyShareAction, Optional imageEditor, EventLog log, Consumer onUpdateSharedTextIsExcluded, Callable firstVisibleImageQuery, ActionActivityStarter activityStarter, + @Nullable ShareResultSender shareResultSender, Consumer finishCallback) { this( context, @@ -149,7 +152,9 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio modifyShareAction, onUpdateSharedTextIsExcluded, log, + shareResultSender, finishCallback); + } @VisibleForTesting @@ -161,6 +166,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio @Nullable ChooserAction modifyShareAction, Consumer onUpdateSharedTextIsExcluded, EventLog log, + @Nullable ShareResultSender shareResultSender, Consumer finishCallback) { mContext = context; mCopyButtonRunnable = copyButtonRunnable; @@ -169,7 +175,22 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio mModifyShareAction = modifyShareAction; mExcludeSharedTextAction = onUpdateSharedTextIsExcluded; mLog = log; + mShareResultSender = shareResultSender; mFinishCallback = finishCallback; + + if (mShareResultSender != null) { + mEditButtonRunnable = () -> { + mShareResultSender.onActionSelected(ShareAction.SYSTEM_EDIT); + mEditButtonRunnable.run(); + }; + if (mCopyButtonRunnable != null) { + mCopyButtonRunnable = () -> { + mShareResultSender.onActionSelected(ShareAction.SYSTEM_COPY); + //noinspection DataFlowIssue + mCopyButtonRunnable.run(); + }; + } + } } @Override @@ -353,12 +374,12 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio } @Nullable - private static ActionRow.Action createCustomAction( + private ActionRow.Action createCustomAction( Context context, - ChooserAction action, + @Nullable ChooserAction action, Consumer finishCallback, Runnable loggingRunnable) { - if (action == null || action.getAction() == null) { + if (action == null) { return null; } Drawable icon = action.getIcon().loadDrawable(context); @@ -388,6 +409,9 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio if (loggingRunnable != null) { loggingRunnable.run(); } + if (mShareResultSender != null) { + mShareResultSender.onActionSelected(ShareAction.APPLICATION_DEFINED); + } finishCallback.accept(Activity.RESULT_OK); } ); diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 35812071..30845818 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -37,7 +37,6 @@ import static java.util.Collections.emptyList; import static java.util.Objects.requireNonNull; import static java.util.Objects.requireNonNullElse; -import android.app.Activity; import android.app.ActivityManager; import android.app.ActivityOptions; import android.app.ActivityThread; @@ -148,6 +147,8 @@ import com.android.intentresolver.v2.platform.AppPredictionAvailable; import com.android.intentresolver.v2.platform.ImageEditor; import com.android.intentresolver.v2.platform.NearbyShare; import com.android.intentresolver.v2.ui.ActionTitle; +import com.android.intentresolver.v2.ui.ShareResultSender; +import com.android.intentresolver.v2.ui.ShareResultSenderFactory; import com.android.intentresolver.v2.ui.model.ActivityLaunch; import com.android.intentresolver.v2.ui.model.ChooserRequest; import com.android.intentresolver.v2.ui.viewmodel.ChooserViewModel; @@ -275,6 +276,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Inject public DevicePolicyResources mDevicePolicyResources; @Inject public PackageManager mPackageManager; @Inject public IntentForwarding mIntentForwarding; + @Inject public ShareResultSenderFactory mShareResultSenderFactory; + @Nullable + private ShareResultSender mShareResultSender; private ChooserRefinementManager mRefinementManager; @@ -354,6 +358,12 @@ public class ChooserActivity extends Hilt_ChooserActivity implements finish(); return; } + IntentSender chosenComponentSender = + mViewModel.getChooserRequest().getChosenComponentSender(); + if (chosenComponentSender != null) { + mShareResultSender = mShareResultSenderFactory + .create(mActivityLaunch.getFromUid(), chosenComponentSender); + } mLogic = createActivityLogic(); init(); } @@ -819,13 +829,13 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } try { if (cti.startAsCaller(this, options, user.getIdentifier())) { - onActivityStarted(cti); + maybeSendShareResult(cti); maybeLogCrossProfileTargetLaunch(cti, user); } } catch (RuntimeException e) { Slog.wtf(TAG, "Unable to launch as uid " + mActivityLaunch.getFromUid() - + " package " + getLaunchedFromPackage() + ", while running in " + + " package " + mActivityLaunch.getFromPackage() + ", while running in " + ActivityThread.currentProcessName(), e); } } @@ -1586,19 +1596,11 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return result; } - public void onActivityStarted(TargetInfo cti) { - ChooserRequest chooserRequest = mViewModel.getChooserRequest(); - if (chooserRequest.getChosenComponentSender() != null) { + private void maybeSendShareResult(TargetInfo cti) { + if (mShareResultSender != null) { final ComponentName target = cti.getResolvedComponentName(); if (target != null) { - final Intent fillIn = new Intent().putExtra(Intent.EXTRA_CHOSEN_COMPONENT, target); - try { - chooserRequest.getChosenComponentSender().sendIntent( - this, Activity.RESULT_OK, fillIn, null, null); - } catch (IntentSender.SendIntentException e) { - Slog.e(TAG, "Unable to launch supplied IntentSender to report " - + "the chosen component: " + e); - } + mShareResultSender.onComponentSelected(target, cti.isChooserTargetInfo()); } } } @@ -2121,6 +2123,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mFinishWhenStopped = true; } }, + mShareResultSender, (status) -> { if (status != null) { setResult(status); diff --git a/java/src/com/android/intentresolver/v2/ui/ShareResultSender.kt b/java/src/com/android/intentresolver/v2/ui/ShareResultSender.kt new file mode 100644 index 00000000..2b01b5e7 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ui/ShareResultSender.kt @@ -0,0 +1,163 @@ +/* + * Copyright (C) 2024 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.v2.ui + +import android.app.Activity +import android.app.compat.CompatChanges +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.IntentSender +import android.service.chooser.ChooserResult +import android.service.chooser.ChooserResult.CHOOSER_RESULT_COPY +import android.service.chooser.ChooserResult.CHOOSER_RESULT_EDIT +import android.service.chooser.ChooserResult.CHOOSER_RESULT_SELECTED_COMPONENT +import android.service.chooser.ChooserResult.CHOOSER_RESULT_UNKNOWN +import android.service.chooser.ChooserResult.ResultType +import android.util.Log +import com.android.intentresolver.inject.Background +import com.android.intentresolver.inject.ChooserServiceFlags +import com.android.intentresolver.inject.Main +import com.android.intentresolver.v2.ui.model.ShareAction +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.qualifiers.ActivityContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private const val TAG = "ShareResultSender" + +/** Reports the result of a share to another process across binder, via an [IntentSender] */ +interface ShareResultSender { + /** Reports user selection of an activity to launch from the provided choices. */ + fun onComponentSelected(component: ComponentName, directShare: Boolean) + + /** Reports user invocation of a built-in system action. See [ShareAction]. */ + fun onActionSelected(action: ShareAction) +} + +@AssistedFactory +interface ShareResultSenderFactory { + fun create(callerUid: Int, chosenComponentSender: IntentSender): ShareResultSenderImpl +} + +/** Dispatches Intents via IntentSender */ +fun interface IntentSenderDispatcher { + fun dispatchIntent(intentSender: IntentSender, intent: Intent) +} + +class ShareResultSenderImpl( + private val flags: ChooserServiceFlags, + @Main private val scope: CoroutineScope, + @Background val backgroundDispatcher: CoroutineDispatcher, + private val callerUid: Int, + private val resultSender: IntentSender, + private val intentDispatcher: IntentSenderDispatcher +) : ShareResultSender { + @AssistedInject + constructor( + @ActivityContext context: Context, + flags: ChooserServiceFlags, + @Main scope: CoroutineScope, + @Background backgroundDispatcher: CoroutineDispatcher, + @Assisted callerUid: Int, + @Assisted chosenComponentSender: IntentSender, + ) : this( + flags, + scope, + backgroundDispatcher, + callerUid, + chosenComponentSender, + IntentSenderDispatcher { sender, intent -> sender.dispatchIntent(context, intent) } + ) + + override fun onComponentSelected(component: ComponentName, directShare: Boolean) { + Log.i(TAG, "onComponentSelected: $component directShare=$directShare") + scope.launch { + val intent = createChosenComponentIntent(component, directShare) + intentDispatcher.dispatchIntent(resultSender, intent) + } + } + + override fun onActionSelected(action: ShareAction) { + Log.i(TAG, "onActionSelected: $action") + scope.launch { + if (flags.enableChooserResult() && chooserResultSupported(callerUid)) { + @ResultType val chosenAction = shareActionToChooserResult(action) + val intent: Intent = createSelectedActionIntent(chosenAction) + intentDispatcher.dispatchIntent(resultSender, intent) + } else { + Log.i(TAG, "Not sending SelectedAction") + } + } + } + + private suspend fun createChosenComponentIntent( + component: ComponentName, + direct: Boolean, + ): Intent { + // Add extra with component name for backwards compatibility. + val intent: Intent = Intent().putExtra(Intent.EXTRA_CHOSEN_COMPONENT, component) + + // Add ChooserResult value for Android V+ + if (flags.enableChooserResult() && chooserResultSupported(callerUid)) { + intent.putExtra( + Intent.EXTRA_CHOOSER_RESULT, + ChooserResult(CHOOSER_RESULT_SELECTED_COMPONENT, component, direct) + ) + } else { + Log.i(TAG, "Not including ${Intent.EXTRA_CHOOSER_RESULT}") + } + return intent + } + + @ResultType + private fun shareActionToChooserResult(action: ShareAction) = + when (action) { + ShareAction.SYSTEM_COPY -> CHOOSER_RESULT_COPY + ShareAction.SYSTEM_EDIT -> CHOOSER_RESULT_EDIT + ShareAction.APPLICATION_DEFINED -> CHOOSER_RESULT_UNKNOWN + } + + private fun createSelectedActionIntent(@ResultType result: Int): Intent { + return Intent().putExtra(Intent.EXTRA_CHOOSER_RESULT, ChooserResult(result, null, false)) + } + + private suspend fun chooserResultSupported(uid: Int): Boolean { + return withContext(backgroundDispatcher) { + // background -> Binder call to system_server + CompatChanges.isChangeEnabled(ChooserResult.SEND_CHOOSER_RESULT, uid) + } + } +} + +private fun IntentSender.dispatchIntent(context: Context, intent: Intent) { + try { + sendIntent( + /* context = */ context, + /* code = */ Activity.RESULT_OK, + /* intent = */ intent, + /* onFinished = */ null, + /* handler = */ null + ) + } catch (e: IntentSender.SendIntentException) { + Log.e(TAG, "Failed to send intent to IntentSender", e) + } +} diff --git a/java/src/com/android/intentresolver/v2/ui/model/ShareAction.kt b/java/src/com/android/intentresolver/v2/ui/model/ShareAction.kt new file mode 100644 index 00000000..e13ef101 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ui/model/ShareAction.kt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2024 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.v2.ui.model + +enum class ShareAction { + SYSTEM_COPY, + SYSTEM_EDIT, + APPLICATION_DEFINED +} diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt index cb1ef1ae..0269168e 100644 --- a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt +++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt @@ -23,6 +23,7 @@ import android.content.Intent.EXTRA_ALTERNATE_INTENTS import android.content.Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS import android.content.Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION import android.content.Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER +import android.content.Intent.EXTRA_CHOOSER_RESULT_INTENT_SENDER import android.content.Intent.EXTRA_CHOOSER_TARGETS import android.content.Intent.EXTRA_CHOSEN_COMPONENT_INTENT_SENDER import android.content.Intent.EXTRA_EXCLUDE_COMPONENTS @@ -111,7 +112,8 @@ fun readChooserRequest( ?: emptyList() val chosenComponentSender = - optional(value(EXTRA_CHOSEN_COMPONENT_INTENT_SENDER)) + optional(value(EXTRA_CHOOSER_RESULT_INTENT_SENDER)) + ?: optional(value(EXTRA_CHOSEN_COMPONENT_INTENT_SENDER)) val refinementIntentSender = optional(value(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER)) diff --git a/tests/unit/Android.bp b/tests/unit/Android.bp index a07af1a4..f8b80c72 100644 --- a/tests/unit/Android.bp +++ b/tests/unit/Android.bp @@ -52,6 +52,7 @@ android_test { "junit", "kotlinx_coroutines_test", "mockito-target-minus-junit4", + "platform-compat-test-rules", // PlatformCompatChangeRule "testables", // TestableContext/TestableResources "truth", "truth-java8-extension", diff --git a/tests/unit/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt b/tests/unit/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt index b3486bb1..717d26bd 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt @@ -134,17 +134,18 @@ class ChooserActionFactoryTest { } val testSubject = ChooserActionFactory( - context, - chooserRequest.targetIntent, - chooserRequest.referrerPackageName, - chooserRequest.chooserActions, - chooserRequest.modifyShareAction, - Optional.empty(), - logger, - {}, - { null }, - mock(), - {}, + /* context = */ context, + /* targetIntent = */ chooserRequest.targetIntent, + /* referrerPackageName = */ chooserRequest.referrerPackageName, + /* chooserActions = */ chooserRequest.chooserActions, + /* modifyShareAction = */ chooserRequest.modifyShareAction, + /* imageEditor = */ Optional.empty(), + /* log = */ logger, + /* onUpdateSharedTextIsExcluded = */ {}, + /* firstVisibleImageQuery = */ { null }, + /* activityStarter = */ mock(), + /* shareResultSender = */ null, + /* finishCallback = */ {}, ) assertThat(testSubject.copyButtonRunnable).isNull() } @@ -160,17 +161,18 @@ class ChooserActionFactoryTest { } val testSubject = ChooserActionFactory( - context, - chooserRequest.targetIntent, - chooserRequest.referrerPackageName, - chooserRequest.chooserActions, - chooserRequest.modifyShareAction, - Optional.empty(), - logger, - {}, - { null }, - mock(), - {}, + /* context = */ context, + /* targetIntent = */ chooserRequest.targetIntent, + /* referrerPackageName = */ chooserRequest.referrerPackageName, + /* chooserActions = */ chooserRequest.chooserActions, + /* modifyShareAction = */ chooserRequest.modifyShareAction, + /* imageEditor = */ Optional.empty(), + /* log = */ logger, + /* onUpdateSharedTextIsExcluded = */ {}, + /* firstVisibleImageQuery = */ { null }, + /* activityStarter = */ mock(), + /* shareResultSender = */ null, + /* finishCallback = */ {}, ) assertThat(testSubject.copyButtonRunnable).isNull() } @@ -186,17 +188,18 @@ class ChooserActionFactoryTest { } val testSubject = ChooserActionFactory( - context, - chooserRequest.targetIntent, - chooserRequest.referrerPackageName, - chooserRequest.chooserActions, - chooserRequest.modifyShareAction, - Optional.empty(), - logger, - {}, - { null }, - mock(), - {}, + /* context = */ context, + /* targetIntent = */ chooserRequest.targetIntent, + /* referrerPackageName = */ chooserRequest.referrerPackageName, + /* chooserActions = */ chooserRequest.chooserActions, + /* modifyShareAction = */ chooserRequest.modifyShareAction, + /* imageEditor = */ Optional.empty(), + /* log = */ logger, + /* onUpdateSharedTextIsExcluded = */ {}, + /* firstVisibleImageQuery = */ { null }, + /* activityStarter = */ mock(), + /* shareResultSender = */ null, + /* finishCallback = */ {}, ) assertThat(testSubject.copyButtonRunnable).isNotNull() } @@ -228,17 +231,18 @@ class ChooserActionFactoryTest { } return ChooserActionFactory( - context, - chooserRequest.targetIntent, - chooserRequest.referrerPackageName, - chooserRequest.chooserActions, - chooserRequest.modifyShareAction, - Optional.empty(), - logger, - {}, - { null }, - mock(), - resultConsumer + /* context = */ context, + /* targetIntent = */ chooserRequest.targetIntent, + /* referrerPackageName = */ chooserRequest.referrerPackageName, + /* chooserActions = */ chooserRequest.chooserActions, + /* modifyShareAction = */ chooserRequest.modifyShareAction, + /* imageEditor = */ Optional.empty(), + /* log = */ logger, + /* onUpdateSharedTextIsExcluded = */ {}, + /* firstVisibleImageQuery = */ { null }, + /* activityStarter = */ mock(), + /* shareResultSender = */ null, + /* finishCallback = */ resultConsumer ) } } diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/ShareResultSenderImplTest.kt b/tests/unit/src/com/android/intentresolver/v2/ui/ShareResultSenderImplTest.kt new file mode 100644 index 00000000..371f9c26 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/v2/ui/ShareResultSenderImplTest.kt @@ -0,0 +1,190 @@ +/* + * Copyright (C) 2024 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.v2.ui + +import android.app.PendingIntent +import android.compat.testing.PlatformCompatChangeRule +import android.content.ComponentName +import android.content.Intent +import android.os.Process +import android.service.chooser.ChooserResult +import android.service.chooser.Flags +import androidx.test.InstrumentationRegistry +import com.android.intentresolver.inject.FakeChooserServiceFlags +import com.android.intentresolver.v2.ui.model.ShareAction +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import libcore.junit.util.compat.CoreCompatChangeRule.DisableCompatChanges +import libcore.junit.util.compat.CoreCompatChangeRule.EnableCompatChanges +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule + +@OptIn(ExperimentalCoroutinesApi::class) +class ShareResultSenderImplTest { + + private val context = InstrumentationRegistry.getInstrumentation().context + + @get:Rule val compatChangeRule: TestRule = PlatformCompatChangeRule() + + val flags = FakeChooserServiceFlags() + + @OptIn(ExperimentalCoroutinesApi::class) + @EnableCompatChanges(ChooserResult.SEND_CHOOSER_RESULT) + @Test + fun onComponentSelected_chooserResultEnabled() = runTest { + val pi = PendingIntent.getBroadcast(context, 0, Intent(), PendingIntent.FLAG_IMMUTABLE) + val deferred = CompletableDeferred() + val intentDispatcher = IntentSenderDispatcher { _, intent -> deferred.complete(intent) } + + flags.setFlag(Flags.FLAG_ENABLE_CHOOSER_RESULT, true) + + val resultSender = + ShareResultSenderImpl( + flags = flags, + scope = this, + backgroundDispatcher = UnconfinedTestDispatcher(testScheduler), + callerUid = Process.myUid(), + resultSender = pi.intentSender, + intentDispatcher = intentDispatcher + ) + + resultSender.onComponentSelected(ComponentName("example.com", "Foo"), true) + runCurrent() + + val intentReceived = deferred.await() + val chooserResult = + intentReceived.getParcelableExtra( + Intent.EXTRA_CHOOSER_RESULT, + ChooserResult::class.java + ) + assertThat(chooserResult).isNotNull() + assertThat(chooserResult?.type).isEqualTo(ChooserResult.CHOOSER_RESULT_SELECTED_COMPONENT) + assertThat(chooserResult?.selectedComponent).isEqualTo(ComponentName("example.com", "Foo")) + assertThat(chooserResult?.isShortcut).isTrue() + } + + @DisableCompatChanges(ChooserResult.SEND_CHOOSER_RESULT) + @Test + fun onComponentSelected_chooserResultDisabled() = runTest { + val pi = PendingIntent.getBroadcast(context, 0, Intent(), PendingIntent.FLAG_IMMUTABLE) + val deferred = CompletableDeferred() + val intentDispatcher = IntentSenderDispatcher { _, intent -> deferred.complete(intent) } + + flags.setFlag(Flags.FLAG_ENABLE_CHOOSER_RESULT, true) + + val resultSender = + ShareResultSenderImpl( + flags = flags, + scope = this, + backgroundDispatcher = UnconfinedTestDispatcher(testScheduler), + callerUid = Process.myUid(), + resultSender = pi.intentSender, + intentDispatcher = intentDispatcher + ) + + resultSender.onComponentSelected(ComponentName("example.com", "Foo"), true) + runCurrent() + + val intentReceived = deferred.await() + val componentName = + intentReceived.getParcelableExtra( + Intent.EXTRA_CHOSEN_COMPONENT, + ComponentName::class.java + ) + + assertWithMessage("EXTRA_CHOSEN_COMPONENT from received intent") + .that(componentName) + .isEqualTo(ComponentName("example.com", "Foo")) + + assertWithMessage("received intent has EXTRA_CHOOSER_RESULT") + .that(intentReceived.hasExtra(Intent.EXTRA_CHOOSER_RESULT)) + .isFalse() + } + + @EnableCompatChanges(ChooserResult.SEND_CHOOSER_RESULT) + @Test + fun onActionSelected_chooserResultEnabled() = runTest { + val pi = PendingIntent.getBroadcast(context, 0, Intent(), PendingIntent.FLAG_IMMUTABLE) + val deferred = CompletableDeferred() + val intentDispatcher = IntentSenderDispatcher { _, intent -> deferred.complete(intent) } + + flags.setFlag(Flags.FLAG_ENABLE_CHOOSER_RESULT, true) + + val resultSender = + ShareResultSenderImpl( + flags = flags, + scope = this, + backgroundDispatcher = UnconfinedTestDispatcher(testScheduler), + callerUid = Process.myUid(), + resultSender = pi.intentSender, + intentDispatcher = intentDispatcher + ) + + resultSender.onActionSelected(ShareAction.SYSTEM_COPY) + runCurrent() + + val intentReceived = deferred.await() + val chosenComponent = + intentReceived.getParcelableExtra( + Intent.EXTRA_CHOSEN_COMPONENT, + ChooserResult::class.java + ) + assertThat(chosenComponent).isNull() + + val chooserResult = + intentReceived.getParcelableExtra( + Intent.EXTRA_CHOOSER_RESULT, + ChooserResult::class.java + ) + assertThat(chooserResult).isNotNull() + assertThat(chooserResult?.type).isEqualTo(ChooserResult.CHOOSER_RESULT_COPY) + assertThat(chooserResult?.selectedComponent).isNull() + assertThat(chooserResult?.isShortcut).isFalse() + } + + @DisableCompatChanges(ChooserResult.SEND_CHOOSER_RESULT) + @Test + fun onActionSelected_chooserResultDisabled() = runTest { + val pi = PendingIntent.getBroadcast(context, 0, Intent(), PendingIntent.FLAG_IMMUTABLE) + val deferred = CompletableDeferred() + val intentDispatcher = IntentSenderDispatcher { _, intent -> deferred.complete(intent) } + + flags.setFlag(Flags.FLAG_ENABLE_CHOOSER_RESULT, true) + + val resultSender = + ShareResultSenderImpl( + flags = flags, + scope = this, + backgroundDispatcher = UnconfinedTestDispatcher(testScheduler), + callerUid = Process.myUid(), + resultSender = pi.intentSender, + intentDispatcher = intentDispatcher + ) + + resultSender.onActionSelected(ShareAction.SYSTEM_COPY) + runCurrent() + + // No result should have been sent, this should never complete + assertWithMessage("deferred result isComplete").that(deferred.isCompleted).isFalse() + } +} -- cgit v1.2.3-59-g8ed1b From 3fa28761c639dfde7dbe1929de42cd015cf57af9 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Thu, 8 Feb 2024 20:19:40 -0800 Subject: Initialize PlayloadToggleInteractor in PreviewViewModel Initialize PlayloadToggleInteractor in PreviewViewModel when the flag is enabled. Bug: 302691505 Test: IntentResolver-tests-unit (Both V2 flag values) Test: IntentResolver-tests-integration (Both V2 flag values) Test: IntentResolver-tests-activity (Both V2 flag values) Test: Functinality smoke test (orentation change, different preview types, target selection flow) for both V2 flag values. Change-Id: I5899b0dd25b9482e56d17bdcad57a0aaa4600734 --- .../android/intentresolver/ChooserActivity.java | 8 +- .../contentpreview/BasePreviewViewModel.kt | 16 +++- .../contentpreview/CursorUriReader.kt | 46 +++++++++ .../contentpreview/PreviewDataProvider.kt | 3 + .../contentpreview/PreviewViewModel.kt | 105 ++++++++++++++++----- .../contentpreview/TargetIntentModifier.kt | 7 ++ .../contentpreview/UriMetadataReader.kt | 40 ++++++++ .../android/intentresolver/v2/ChooserActivity.java | 9 +- .../intentresolver/TestContentPreviewViewModel.kt | 26 +++-- .../contentpreview/CursorUriReaderTest.kt | 31 ++++++ .../contentpreview/PreviewViewModelTest.kt | 81 ++++++++++++++++ 11 files changed, 335 insertions(+), 37 deletions(-) create mode 100644 java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt create mode 100644 tests/unit/src/com/android/intentresolver/contentpreview/PreviewViewModelTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 843ae809..9b4582df 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -303,9 +303,15 @@ public class ChooserActivity extends Hilt_ChooserActivity implements BasePreviewViewModel previewViewModel = new ViewModelProvider(this, createPreviewViewModelFactory()) .get(BasePreviewViewModel.class); + previewViewModel.init( + mChooserRequest.getTargetIntent(), + getIntent(), + /*additionalContentUri = */ null, + /*focusedItemIdx = */ 0, + /*isPayloadTogglingEnabled = */ false); mChooserContentPreviewUi = new ChooserContentPreviewUi( getCoroutineScope(getLifecycle()), - previewViewModel.createOrReuseProvider(mChooserRequest.getTargetIntent()), + previewViewModel.getPreviewDataProvider(), mChooserRequest.getTargetIntent(), previewViewModel.getImageLoader(), createChooserActionFactory(), diff --git a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt index 3b20a45c..21c909ea 100644 --- a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt @@ -17,14 +17,22 @@ package com.android.intentresolver.contentpreview import android.content.Intent +import android.net.Uri import androidx.annotation.MainThread import androidx.lifecycle.ViewModel /** A contract for the preview view model. Added for testing. */ abstract class BasePreviewViewModel : ViewModel() { - @MainThread abstract fun createOrReuseProvider(targetIntent: Intent): PreviewDataProvider - - abstract val imageLoader: ImageLoader - + @get:MainThread abstract val previewDataProvider: PreviewDataProvider + @get:MainThread abstract val imageLoader: ImageLoader abstract val payloadToggleInteractor: PayloadToggleInteractor? + + @MainThread + abstract fun init( + targetIntent: Intent, + chooserIntent: Intent, + additionalContentUri: Uri?, + focusedItemIdx: Int, + isPayloadTogglingEnabled: Boolean, + ) } diff --git a/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt b/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt index dbf27a88..7ff3b49e 100644 --- a/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt +++ b/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt @@ -16,10 +16,22 @@ package com.android.intentresolver.contentpreview +import android.content.ContentInterface +import android.content.Intent import android.database.Cursor +import android.database.MatrixCursor import android.net.Uri +import android.os.Bundle +import android.os.CancellationSignal import android.util.Log import android.util.SparseArray +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.coroutineScope + +// TODO: replace with the new API AdditionalContentContract$Columns#URI +private const val ColumnUri = "uri" +// TODO: replace with the new API AdditionalContentContract$CursorExtraKeys#POSITION +private const val ExtraPosition = "position" private const val TAG = ContentPreviewUi.TAG @@ -98,4 +110,38 @@ class CursorUriReader( override fun close() { cursor.close() } + + companion object { + suspend fun createCursorReader( + contentResolver: ContentInterface, + uri: Uri, + chooserIntent: Intent + ): CursorUriReader { + val cancellationSignal = CancellationSignal() + val cursor = + try { + coroutineScope { + runCatching { + contentResolver.query( + uri, + arrayOf(ColumnUri), + Bundle().apply { + putParcelable(Intent.EXTRA_INTENT, chooserIntent) + }, + cancellationSignal + ) + } + .getOrNull() + ?: MatrixCursor(arrayOf(ColumnUri)) + } + } catch (e: CancellationException) { + cancellationSignal.cancel() + throw e + } + return CursorUriReader(cursor, cursor.extras?.getInt(ExtraPosition, 0) ?: 0, 128) { + // TODO: check that authority is case-sensitive for resolution reasons + it.authority != uri.authority + } + } + } } diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt index 38918d79..659f7dc9 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt @@ -100,6 +100,9 @@ constructor( open val uriCount: Int get() = records.size + val uris: List + get() = records.map { it.uri } + /** * Returns a [Flow] of [FileInfo], for each shared URI in order, with [FileInfo.mimeType] and * [FileInfo.previewUri] set (a data projection tailored for the image preview UI). diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt index 77cf0ac9..7369fa0f 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt @@ -17,7 +17,9 @@ package com.android.intentresolver.contentpreview import android.app.Application +import android.content.ContentResolver import android.content.Intent +import android.net.Uri import androidx.annotation.MainThread import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider @@ -26,46 +28,90 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras import com.android.intentresolver.R import com.android.intentresolver.inject.Background -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject +import java.util.concurrent.Executors import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.plus -/** A trivial view model to keep a [PreviewDataProvider] instance over a configuration change */ -@HiltViewModel -class PreviewViewModel -@Inject -constructor( - private val application: Application, +/** A view model for the preview logic */ +class PreviewViewModel( + private val contentResolver: ContentResolver, + // TODO: inject ImageLoader instead + private val thumbnailSize: Int, @Background private val dispatcher: CoroutineDispatcher = Dispatchers.IO, ) : BasePreviewViewModel() { - private var previewDataProvider: PreviewDataProvider? = null + private var targetIntent: Intent? = null + private var chooserIntent: Intent? = null + private var additionalContentUri: Uri? = null + private var focusedItemIdx: Int = 0 + private var isPayloadTogglingEnabled = false - @MainThread - override fun createOrReuseProvider(targetIntent: Intent): PreviewDataProvider = - previewDataProvider - ?: PreviewDataProvider( - viewModelScope + dispatcher, - targetIntent, - application.contentResolver - ) - .also { previewDataProvider = it } + override val previewDataProvider by lazy { + val targetIntent = requireNotNull(this.targetIntent) { "Not initialized" } + PreviewDataProvider(viewModelScope + dispatcher, targetIntent, contentResolver) + } override val imageLoader by lazy { ImagePreviewImageLoader( viewModelScope + dispatcher, - thumbnailSize = - application.resources.getDimensionPixelSize( - R.dimen.chooser_preview_image_max_dimen - ), - application.contentResolver, + thumbnailSize, + contentResolver, cacheSize = 16 ) } override val payloadToggleInteractor: PayloadToggleInteractor? by lazy { - null // TODO: initialize PayloadToggleInteractor() + val targetIntent = requireNotNull(targetIntent) { "Not initialized" } + // TODO: replace with flags injection + if (!isPayloadTogglingEnabled) return@lazy null + createPayloadToggleInteractor( + additionalContentUri ?: return@lazy null, + targetIntent, + chooserIntent ?: return@lazy null, + ) + .apply { start() } + } + + // TODO: make the view model injectable and inject these dependencies instead + @MainThread + override fun init( + targetIntent: Intent, + chooserIntent: Intent, + additionalContentUri: Uri?, + focusedItemIdx: Int, + isPayloadTogglingEnabled: Boolean, + ) { + if (this.targetIntent != null) return + this.targetIntent = targetIntent + this.chooserIntent = chooserIntent + this.additionalContentUri = additionalContentUri + this.focusedItemIdx = focusedItemIdx + this.isPayloadTogglingEnabled = isPayloadTogglingEnabled + } + + private fun createPayloadToggleInteractor( + contentProviderUri: Uri, + targetIntent: Intent, + chooserIntent: Intent, + ): PayloadToggleInteractor { + return PayloadToggleInteractor( + // TODO: update PayloadToggleInteractor to support multiple threads + viewModelScope + Executors.newSingleThreadScheduledExecutor().asCoroutineDispatcher(), + previewDataProvider.uris, + maxOf(0, minOf(focusedItemIdx, previewDataProvider.uriCount - 1)), + DefaultMimeTypeClassifier, + { + CursorUriReader.createCursorReader( + contentResolver, + contentProviderUri, + chooserIntent + ) + }, + UriMetadataReader(contentResolver, DefaultMimeTypeClassifier), + TargetIntentModifier(targetIntent, getUri = { uri }, getMimeType = { mimeType }), + SelectionChangeCallback(contentProviderUri, chooserIntent, contentResolver) + ) } companion object { @@ -75,7 +121,16 @@ constructor( override fun create( modelClass: Class, extras: CreationExtras - ): T = PreviewViewModel(checkNotNull(extras[APPLICATION_KEY])) as T + ): T { + val application: Application = checkNotNull(extras[APPLICATION_KEY]) + return PreviewViewModel( + application.contentResolver, + application.resources.getDimensionPixelSize( + R.dimen.chooser_preview_image_max_dimen + ) + ) + as T + } } } } diff --git a/java/src/com/android/intentresolver/contentpreview/TargetIntentModifier.kt b/java/src/com/android/intentresolver/contentpreview/TargetIntentModifier.kt index 99cfc0f8..d7e04920 100644 --- a/java/src/com/android/intentresolver/contentpreview/TargetIntentModifier.kt +++ b/java/src/com/android/intentresolver/contentpreview/TargetIntentModifier.kt @@ -16,6 +16,7 @@ package com.android.intentresolver.contentpreview +import android.content.ClipData import android.content.ClipDescription.compareMimeTypes import android.content.Intent import android.content.Intent.ACTION_SEND @@ -45,6 +46,12 @@ class TargetIntentModifier( } else { putParcelableArrayListExtra(EXTRA_STREAM, uris) } + clipData = + ClipData("", arrayOf(targetMimeType), ClipData.Item(uris[0])).also { + for (i in 1 until uris.size) { + it.addItem(ClipData.Item(uris[i])) + } + } } } diff --git a/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt b/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt new file mode 100644 index 00000000..784cefa0 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2024 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.net.Uri + +// TODO: share this logic with PreviewDataProvider +class UriMetadataReader( + private val contentResolver: ContentResolver, + private val mimeTypeClassifier: MimeTypeClassifier, +) : (Uri) -> FileInfo { + fun getMetadata(uri: Uri): FileInfo = + FileInfo.Builder(uri) + .apply { + runCatching { + withMimeType(contentResolver.getType(uri)) + if (mimeTypeClassifier.isImageType(mimeType)) { + withPreviewUri(uri) + } + } + } + .build() + + override fun invoke(uri: Uri): FileInfo = getMetadata(uri) +} diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 30845818..2ffd31d8 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -268,6 +268,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Inject public ActivityLaunch mActivityLaunch; @Inject public FeatureFlags mFeatureFlags; + @Inject public android.service.chooser.FeatureFlags mChooserServiceFeatureFlags; @Inject public EventLog mEventLog; @Inject @AppPredictionAvailable public boolean mAppPredictionAvailable; @Inject @ImageEditor public Optional mImageEditor; @@ -480,9 +481,15 @@ public class ChooserActivity extends Hilt_ChooserActivity implements BasePreviewViewModel previewViewModel = new ViewModelProvider(this, createPreviewViewModelFactory()) .get(BasePreviewViewModel.class); + previewViewModel.init( + chooserRequest.getTargetIntent(), + getIntent(), + chooserRequest.getAdditionalContentUri(), + chooserRequest.getFocusedItemPosition(), + mChooserServiceFeatureFlags.chooserPayloadToggling()); mChooserContentPreviewUi = new ChooserContentPreviewUi( getCoroutineScope(getLifecycle()), - previewViewModel.createOrReuseProvider(chooserRequest.getTargetIntent()), + previewViewModel.getPreviewDataProvider(), chooserRequest.getTargetIntent(), previewViewModel.getImageLoader(), createChooserActionFactory(), diff --git a/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt b/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt index 998c0802..b352f360 100644 --- a/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt +++ b/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt @@ -17,28 +17,42 @@ package com.android.intentresolver import android.content.Intent +import android.net.Uri 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.PayloadToggleInteractor -import com.android.intentresolver.contentpreview.PreviewDataProvider /** A test content preview model that supports image loader override. */ class TestContentPreviewViewModel( private val viewModel: BasePreviewViewModel, - private val imageLoaderDelegate: ImageLoader?, + override val imageLoader: ImageLoader, ) : BasePreviewViewModel() { - override fun createOrReuseProvider(targetIntent: Intent): PreviewDataProvider = - viewModel.createOrReuseProvider(targetIntent) - override val imageLoader: ImageLoader - get() = imageLoaderDelegate ?: viewModel.imageLoader + override val previewDataProvider + get() = viewModel.previewDataProvider override val payloadToggleInteractor: PayloadToggleInteractor? get() = viewModel.payloadToggleInteractor + override fun init( + targetIntent: Intent, + chooserIntent: Intent, + additionalContentUri: Uri?, + focusedItemIdx: Int, + isPayloadTogglingEnabled: Boolean, + ) { + viewModel.init( + targetIntent, + chooserIntent, + additionalContentUri, + focusedItemIdx, + isPayloadTogglingEnabled + ) + } + companion object { fun wrap( factory: ViewModelProvider.Factory, diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/CursorUriReaderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/CursorUriReaderTest.kt index d53a9af7..cd1c503a 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/CursorUriReaderTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/CursorUriReaderTest.kt @@ -16,13 +16,24 @@ package com.android.intentresolver.contentpreview +import android.content.ContentInterface +import android.content.Intent import android.database.MatrixCursor import android.net.Uri import android.util.SparseArray +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 com.google.common.truth.Truth.assertWithMessage +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest import org.junit.Test class CursorUriReaderTest { + private val scope = TestScope() + @Test fun readEmptyCursor() { val testSubject = @@ -84,6 +95,26 @@ class CursorUriReaderTest { // TODO: add tests with filtered-out items // TODO: add tests with a failing cursor + + @Test + fun testFailingQueryCall_emptyCursorCreated() = + scope.runTest { + val contentResolver = + mock { + whenever(query(any(), any(), anyOrNull(), any())) + .thenThrow(SecurityException("Test exception")) + } + val cursorReader = + CursorUriReader.createCursorReader( + contentResolver, + Uri.parse("content://auth"), + Intent(Intent.ACTION_CHOOSER) + ) + + assertWithMessage("Empty cursor reader is expected") + .that(cursorReader.count) + .isEqualTo(0) + } } private fun createUri(id: Int) = Uri.parse("content://org.pkg/$id") diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/PreviewViewModelTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/PreviewViewModelTest.kt new file mode 100644 index 00000000..1a59a930 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/PreviewViewModelTest.kt @@ -0,0 +1,81 @@ +/* + * Copyright 2024 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.Intent +import android.net.Uri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PreviewViewModelTest { + @OptIn(ExperimentalCoroutinesApi::class) private val dispatcher = UnconfinedTestDispatcher() + + private val context + get() = InstrumentationRegistry.getInstrumentation().targetContext + + private val targetIntent = Intent(Intent.ACTION_SEND) + private val chooserIntent = Intent.createChooser(targetIntent, null) + private val additionalContentUri = Uri.parse("content://org.pkg.content") + + @Test + fun featureFlagDisabled_noPayloadToggleInteractorCreated() { + val testSubject = + PreviewViewModel(context.contentResolver, 200, dispatcher).apply { + init( + targetIntent, + chooserIntent, + additionalContentUri, + focusedItemIdx = 0, + isPayloadTogglingEnabled = false + ) + } + + assertThat(testSubject.payloadToggleInteractor).isNull() + } + + @Test + fun noAdditionalContentUri_noPayloadToggleInteractorCreated() { + val testSubject = + PreviewViewModel(context.contentResolver, 200, dispatcher).apply { + init( + targetIntent, + chooserIntent, + additionalContentUri = null, + focusedItemIdx = 0, + true + ) + } + + assertThat(testSubject.payloadToggleInteractor).isNull() + } + + @Test + fun flagEnabledAndAdditionalContentUriProvided_createPayloadToggleInteractor() { + val testSubject = + PreviewViewModel(context.contentResolver, 200, dispatcher).apply { + init(targetIntent, chooserIntent, additionalContentUri, focusedItemIdx = 0, true) + } + + assertThat(testSubject.payloadToggleInteractor).isNotNull() + } +} -- cgit v1.2.3-59-g8ed1b From 3f006c856c4ff60cca6ed9b61e503fae86c64ca5 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Thu, 8 Feb 2024 21:27:02 -0800 Subject: Add observable custom actions to v2 action factory The new code is not yet used. Bug: 302691505 Test: atest IntentResolver-tests-unit Change-Id: I32aa2c2c10cc1c19a534116ebeabd4777b8d47ca --- .../contentpreview/MutableActionFactory.kt | 29 +++++ .../intentresolver/v2/ChooserActionFactory.java | 36 +++--- .../v2/ChooserMutableActionFactory.kt | 54 ++++++++ .../v2/ChooserMutableActionFactoryTest.kt | 138 +++++++++++++++++++++ 4 files changed, 235 insertions(+), 22 deletions(-) create mode 100644 java/src/com/android/intentresolver/contentpreview/MutableActionFactory.kt create mode 100644 java/src/com/android/intentresolver/v2/ChooserMutableActionFactory.kt create mode 100644 tests/unit/src/com/android/intentresolver/v2/ChooserMutableActionFactoryTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/MutableActionFactory.kt b/java/src/com/android/intentresolver/contentpreview/MutableActionFactory.kt new file mode 100644 index 00000000..1cc1a6a6 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/MutableActionFactory.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2024 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.service.chooser.ChooserAction +import com.android.intentresolver.widget.ActionRow +import kotlinx.coroutines.flow.Flow + +interface MutableActionFactory { + /** A flow of custom actions */ + val customActionsFlow: Flow> + + /** Update custom actions */ + fun updateCustomActions(actions: List) +} diff --git a/java/src/com/android/intentresolver/v2/ChooserActionFactory.java b/java/src/com/android/intentresolver/v2/ChooserActionFactory.java index 70a2b58e..f9de9f4b 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/v2/ChooserActionFactory.java @@ -212,13 +212,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio for (int i = 0; i < mCustomActions.size(); i++) { final int position = i; ActionRow.Action actionRow = createCustomAction( - mContext, - mCustomActions.get(i), - mFinishCallback, - () -> { - mLog.logCustomActionSelected(position); - } - ); + mCustomActions.get(i), () -> logCustomAction(position)); if (actionRow != null) { actions.add(actionRow); } @@ -232,13 +226,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio @Override @Nullable public ActionRow.Action getModifyShareAction() { - return createCustomAction( - mContext, - mModifyShareAction, - mFinishCallback, - () -> { - mLog.logActionSelected(EventLog.SELECTION_TYPE_MODIFY_SHARE); - }); + return createCustomAction(mModifyShareAction, this::logModifyShareAction); } /** @@ -374,15 +362,11 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio } @Nullable - private ActionRow.Action createCustomAction( - Context context, - @Nullable ChooserAction action, - Consumer finishCallback, - Runnable loggingRunnable) { + ActionRow.Action createCustomAction(@Nullable ChooserAction action, Runnable loggingRunnable) { if (action == null) { return null; } - Drawable icon = action.getIcon().loadDrawable(context); + Drawable icon = action.getIcon().loadDrawable(mContext); if (icon == null && TextUtils.isEmpty(action.getLabel())) { return null; } @@ -399,7 +383,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio null, null, ActivityOptions.makeCustomAnimation( - context, + mContext, R.anim.slide_in_right, R.anim.slide_out_left) .toBundle()); @@ -412,8 +396,16 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio if (mShareResultSender != null) { mShareResultSender.onActionSelected(ShareAction.APPLICATION_DEFINED); } - finishCallback.accept(Activity.RESULT_OK); + mFinishCallback.accept(Activity.RESULT_OK); } ); } + + void logCustomAction(int position) { + mLog.logCustomActionSelected(position); + } + + private void logModifyShareAction() { + mLog.logActionSelected(EventLog.SELECTION_TYPE_MODIFY_SHARE); + } } diff --git a/java/src/com/android/intentresolver/v2/ChooserMutableActionFactory.kt b/java/src/com/android/intentresolver/v2/ChooserMutableActionFactory.kt new file mode 100644 index 00000000..2f8ccf77 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ChooserMutableActionFactory.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2024 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.v2 + +import android.service.chooser.ChooserAction +import com.android.intentresolver.contentpreview.ChooserContentPreviewUi +import com.android.intentresolver.contentpreview.MutableActionFactory +import com.android.intentresolver.widget.ActionRow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +/** A wrapper around [ChooserActionFactory] that provides observable custom actions */ +class ChooserMutableActionFactory( + private val actionFactory: ChooserActionFactory, +) : MutableActionFactory, ChooserContentPreviewUi.ActionFactory by actionFactory { + private val customActions = + MutableStateFlow>(actionFactory.createCustomActions()) + + override val customActionsFlow: Flow> + get() = customActions + + override fun updateCustomActions(actions: List) { + customActions.tryEmit(mapChooserActions(actions)) + } + + override fun createCustomActions(): List = customActions.value + + private fun mapChooserActions(chooserActions: List): List = + buildList(chooserActions.size) { + chooserActions.forEachIndexed { i, chooserAction -> + val actionRow = + actionFactory.createCustomAction(chooserAction) { + actionFactory.logCustomAction(i) + } + if (actionRow != null) { + add(actionRow) + } + } + } +} diff --git a/tests/unit/src/com/android/intentresolver/v2/ChooserMutableActionFactoryTest.kt b/tests/unit/src/com/android/intentresolver/v2/ChooserMutableActionFactoryTest.kt new file mode 100644 index 00000000..42702cef --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/v2/ChooserMutableActionFactoryTest.kt @@ -0,0 +1,138 @@ +/* + * Copyright 2024 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.v2 + +import android.app.PendingIntent +import android.content.Intent +import android.content.res.Resources +import android.graphics.drawable.Icon +import android.service.chooser.ChooserAction +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.android.intentresolver.ChooserRequestParameters +import com.android.intentresolver.logging.EventLog +import com.android.intentresolver.mock +import com.android.intentresolver.whenever +import com.google.common.collect.ImmutableList +import com.google.common.truth.Truth.assertWithMessage +import java.util.Optional +import java.util.function.Consumer +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ChooserMutableActionFactoryTest { + private val context + get() = InstrumentationRegistry.getInstrumentation().context + + private val logger = mock() + private val testAction = "com.android.intentresolver.testaction" + private val resultConsumer = + object : Consumer { + var latestReturn = Integer.MIN_VALUE + + override fun accept(resultCode: Int) { + latestReturn = resultCode + } + } + + private val scope = TestScope() + + @Test + fun testInitialValue() = + scope.runTest { + val actions = createChooserActions(2) + val actionFactory = createFactory(actions) + val testSubject = ChooserMutableActionFactory(actionFactory) + + val createdActions = testSubject.createCustomActions() + val observedActions = testSubject.customActionsFlow.first() + + assertWithMessage("Unexpected actions") + .that(createdActions.map { it.label }) + .containsExactlyElementsIn(actions.map { it.label }) + .inOrder() + assertWithMessage("Initially created and initially observed actions should be the same") + .that(createdActions) + .containsExactlyElementsIn(observedActions) + .inOrder() + } + + @Test + fun testUpdateActions_newActionsPublished() = + scope.runTest { + val initialActions = createChooserActions(2) + val updatedActions = createChooserActions(3) + val actionFactory = createFactory(initialActions) + val testSubject = ChooserMutableActionFactory(actionFactory) + + testSubject.updateCustomActions(updatedActions) + val observedActions = testSubject.customActionsFlow.first() + + assertWithMessage("Unexpected updated actions") + .that(observedActions.map { it.label }) + .containsAtLeastElementsIn(updatedActions.map { it.label }) + .inOrder() + } + + private fun createFactory(actions: List): ChooserActionFactory { + val targetIntent = Intent() + val chooserRequest = mock() + whenever(chooserRequest.targetIntent).thenReturn(targetIntent) + whenever(chooserRequest.chooserActions).thenReturn(ImmutableList.copyOf(actions)) + + return ChooserActionFactory( + /* context = */ context, + /* targetIntent = */ chooserRequest.targetIntent, + /* referrerPackageName = */ chooserRequest.referrerPackageName, + /* chooserActions = */ chooserRequest.chooserActions, + /* modifyShareAction = */ chooserRequest.modifyShareAction, + /* imageEditor = */ Optional.empty(), + /* log = */ logger, + /* onUpdateSharedTextIsExcluded = */ {}, + /* firstVisibleImageQuery = */ { null }, + /* activityStarter = */ mock(), + /* shareResultSender = */ null, + /* finishCallback = */ resultConsumer + ) + } + + private fun createChooserActions(count: Int): List { + return buildList(count) { + for (i in 1..count) { + val testPendingIntent = + PendingIntent.getBroadcast( + context, + i, + Intent(testAction), + PendingIntent.FLAG_IMMUTABLE + ) + val action = + ChooserAction.Builder( + Icon.createWithResource("", Resources.ID_NULL), + "Label $i", + testPendingIntent + ) + .build() + add(action) + } + } + } +} -- cgit v1.2.3-59-g8ed1b From 7c3a34ea571c1c8571f33d3fe72afac7a175c055 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Thu, 8 Feb 2024 22:18:56 -0800 Subject: Add preview type to payload toggling UI. Bug: 302691505 Test: atest IntentResolver-tests-unit Change-Id: I2341ce0c7129ac9ee454e134c2626c2d6b960ce4 --- .../contentpreview/ContentPreviewType.java | 4 +- .../contentpreview/PreviewDataProvider.kt | 25 ++++ .../contentpreview/PreviewViewModel.kt | 8 +- .../contentpreview/ChooserContentPreviewUiTest.kt | 35 ++++++ .../contentpreview/PreviewDataProviderTest.kt | 137 ++++++++++++++++----- 5 files changed, 177 insertions(+), 32 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java index ad1c6c01..79bb9d3c 100644 --- a/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java +++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java @@ -25,11 +25,13 @@ import java.lang.annotation.Retention; @Retention(SOURCE) @IntDef({ContentPreviewType.CONTENT_PREVIEW_FILE, ContentPreviewType.CONTENT_PREVIEW_IMAGE, - ContentPreviewType.CONTENT_PREVIEW_TEXT}) + ContentPreviewType.CONTENT_PREVIEW_TEXT, + ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION}) public @interface ContentPreviewType { // Starting at 1 since 0 is considered "undefined" for some of the database transformations // of tron logs. int CONTENT_PREVIEW_IMAGE = 1; int CONTENT_PREVIEW_FILE = 2; int CONTENT_PREVIEW_TEXT = 3; + int CONTENT_PREVIEW_PAYLOAD_SELECTION = 4; } diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt index 659f7dc9..8073cfec 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt @@ -31,6 +31,7 @@ import androidx.annotation.OpenForTesting import androidx.annotation.VisibleForTesting 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_PAYLOAD_SELECTION import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_TEXT import com.android.intentresolver.measurements.runTracing import com.android.intentresolver.util.ownedByCurrentUser @@ -74,7 +75,11 @@ open class PreviewDataProvider constructor( private val scope: CoroutineScope, private val targetIntent: Intent, + private val additionalContentUri: Uri?, private val contentResolver: ContentInterface, + // TODO: replace with the ChooserServiceFlags ref when PreviewViewModel dependencies are sorted + // out + private val isPayloadTogglingEnabled: Boolean, private val typeClassifier: MimeTypeClassifier = DefaultMimeTypeClassifier, ) { @@ -125,6 +130,9 @@ constructor( * IMAGE, FILE, TEXT. */ if (!targetIntent.isSend || records.isEmpty()) { CONTENT_PREVIEW_TEXT + } else if (isPayloadTogglingEnabled && shouldShowPayloadSelection()) { + // TODO: replace with the proper flags injection + CONTENT_PREVIEW_PAYLOAD_SELECTION } else { try { runBlocking(scope.coroutineContext) { @@ -143,6 +151,23 @@ constructor( } } + private fun shouldShowPayloadSelection(): Boolean { + val extraContentUri = additionalContentUri ?: return false + return runCatching { + val authority = extraContentUri.authority + // TODO: verify that authority is case-sensitive + records.firstOrNull { authority == it.uri.authority } == null + } + .onFailure { + Log.w( + ContentPreviewUi.TAG, + "Failed to check URI authorities; no payload toggling", + it + ) + } + .getOrDefault(false) + } + /** * 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. diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt index 7369fa0f..d694c6ff 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt @@ -49,7 +49,13 @@ class PreviewViewModel( override val previewDataProvider by lazy { val targetIntent = requireNotNull(this.targetIntent) { "Not initialized" } - PreviewDataProvider(viewModelScope + dispatcher, targetIntent, contentResolver) + PreviewDataProvider( + viewModelScope + dispatcher, + targetIntent, + additionalContentUri, + contentResolver, + isPayloadTogglingEnabled, + ) } override val imageLoader by lazy { diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt index 560f4be4..ad53eef4 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt @@ -18,6 +18,9 @@ package com.android.intentresolver.contentpreview import android.content.Intent import android.net.Uri +import android.platform.test.annotations.RequiresFlagsDisabled +import android.platform.test.flag.junit.CheckFlagsRule +import android.platform.test.flag.junit.DeviceFlagsValueProvider import com.android.intentresolver.ContentTypeHint import com.android.intentresolver.TestPreviewImageLoader import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory @@ -31,6 +34,7 @@ import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.Rule import org.junit.Test import org.mockito.Mockito.never import org.mockito.Mockito.times @@ -51,6 +55,7 @@ class ChooserContentPreviewUiTest { override fun getExcludeSharedTextAction(): Consumer = Consumer {} } private val transitionCallback = mock() + @get:Rule val checkFlagsRule: CheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule() @Test fun test_textPreviewType_useTextPreviewUi() { @@ -146,4 +151,34 @@ class ChooserContentPreviewUiTest { verify(previewData, times(1)).imagePreviewFileInfoFlow verify(transitionCallback, never()).onAllTransitionElementsReady() } + + @Test + @RequiresFlagsDisabled(android.service.chooser.Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING) + fun test_imagePayloadSelectionType_useImagePreviewUi() { + // Event if we returned wrong type due to a bug, we should not use payload selection UI + val uri = Uri.parse("content://org.pkg.app/img.png") + whenever(previewData.previewType) + .thenReturn(ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION) + whenever(previewData.uriCount).thenReturn(2) + whenever(previewData.firstFileInfo) + .thenReturn(FileInfo.Builder(uri).withPreviewUri(uri).withMimeType("image/png").build()) + whenever(previewData.imagePreviewFileInfoFlow).thenReturn(MutableSharedFlow()) + val testSubject = + ChooserContentPreviewUi( + testScope, + previewData, + Intent(Intent.ACTION_SEND), + imageLoader, + actionFactory, + transitionCallback, + headlineGenerator, + ContentTypeHint.NONE, + testMetadataText, + ) + assertThat(testSubject.preferredContentPreview) + .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) + assertThat(testSubject.mContentPreviewUi).isInstanceOf(UnifiedContentPreviewUi::class.java) + verify(previewData, times(1)).imagePreviewFileInfoFlow + verify(transitionCallback, never()).onAllTransitionElementsReady() + } } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt index 4a8c1392..babfaaf5 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt @@ -21,16 +21,20 @@ import android.content.Intent import android.database.MatrixCursor import android.media.MediaMetadata import android.net.Uri +import android.platform.test.flag.junit.CheckFlagsRule +import android.platform.test.flag.junit.DeviceFlagsValueProvider import android.provider.DocumentsContract import com.android.intentresolver.mock import com.android.intentresolver.whenever import com.google.common.truth.Truth.assertThat import kotlin.coroutines.EmptyCoroutineContext +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest +import org.junit.Rule import org.junit.Test import org.mockito.Mockito.any import org.mockito.Mockito.never @@ -42,12 +46,29 @@ class PreviewDataProviderTest { private val contentResolver = mock() private val mimeTypeClassifier = DefaultMimeTypeClassifier private val testScope = TestScope(EmptyCoroutineContext + UnconfinedTestDispatcher()) + @get:Rule val checkFlagsRule: CheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule() + + private fun createDataProvider( + targetIntent: Intent, + scope: CoroutineScope = testScope, + additionalContentUri: Uri? = null, + resolver: ContentInterface = contentResolver, + typeClassifier: MimeTypeClassifier = mimeTypeClassifier, + isPayloadTogglingEnabled: Boolean = false + ) = + PreviewDataProvider( + scope, + targetIntent, + additionalContentUri, + resolver, + isPayloadTogglingEnabled, + typeClassifier, + ) @Test fun test_nonSendIntentAction_resolvesToTextPreviewUiSynchronously() { val targetIntent = Intent(Intent.ACTION_VIEW) - val testSubject = - PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) + val testSubject = createDataProvider(targetIntent) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) verify(contentResolver, never()).getType(any()) @@ -62,8 +83,7 @@ class PreviewDataProviderTest { type = "text/plain" } whenever(contentResolver.getType(uri)).thenReturn("text/plain") - val testSubject = - PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) + val testSubject = createDataProvider(targetIntent) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) assertThat(testSubject.uriCount).isEqualTo(1) @@ -74,8 +94,7 @@ class PreviewDataProviderTest { @Test fun test_sendIntentWithoutUris_resolvesToTextPreviewUiSynchronously() { val targetIntent = Intent(Intent.ACTION_SEND).apply { type = "image/png" } - val testSubject = - PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) + val testSubject = createDataProvider(targetIntent) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) verify(contentResolver, never()).getType(any()) @@ -86,8 +105,7 @@ class PreviewDataProviderTest { 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(testScope, targetIntent, contentResolver, mimeTypeClassifier) + val testSubject = createDataProvider(targetIntent) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) assertThat(testSubject.uriCount).isEqualTo(1) @@ -101,8 +119,7 @@ class PreviewDataProviderTest { 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(testScope, targetIntent, contentResolver, mimeTypeClassifier) + val testSubject = createDataProvider(targetIntent) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) assertThat(testSubject.uriCount).isEqualTo(1) @@ -120,8 +137,7 @@ class PreviewDataProviderTest { putExtra(Intent.EXTRA_STREAM, uri) } whenever(contentResolver.getType(uri)).thenThrow(SecurityException("test failure")) - val testSubject = - PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) + val testSubject = createDataProvider(targetIntent) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) assertThat(testSubject.uriCount).isEqualTo(1) @@ -142,8 +158,7 @@ class PreviewDataProviderTest { .thenThrow(SecurityException("test failure")) whenever(contentResolver.query(uri, METADATA_COLUMNS, null, null)) .thenThrow(SecurityException("test failure")) - val testSubject = - PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) + val testSubject = createDataProvider(targetIntent) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) assertThat(testSubject.uriCount).isEqualTo(1) @@ -158,8 +173,7 @@ class PreviewDataProviderTest { 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(testScope, targetIntent, contentResolver, mimeTypeClassifier) + val testSubject = createDataProvider(targetIntent) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) assertThat(testSubject.uriCount).isEqualTo(1) @@ -195,8 +209,7 @@ class PreviewDataProviderTest { val cursor = MatrixCursor(columns).apply { addRow(values) } whenever(contentResolver.query(uri, METADATA_COLUMNS, null, null)).thenReturn(cursor) - val testSubject = - PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) + val testSubject = createDataProvider(targetIntent) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) assertThat(testSubject.uriCount).isEqualTo(1) @@ -214,8 +227,7 @@ class PreviewDataProviderTest { val cursor = MatrixCursor(emptyArray()) whenever(contentResolver.query(uri, METADATA_COLUMNS, null, null)).thenReturn(cursor) - val testSubject = - PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) + val testSubject = createDataProvider(targetIntent) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) verify(contentResolver, times(1)).query(uri, METADATA_COLUMNS, null, null) @@ -238,8 +250,7 @@ class PreviewDataProviderTest { } whenever(contentResolver.getType(uri1)).thenReturn("image/png") whenever(contentResolver.getType(uri2)).thenReturn("image/jpeg") - val testSubject = - PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) + val testSubject = createDataProvider(targetIntent) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) assertThat(testSubject.uriCount).isEqualTo(2) @@ -265,8 +276,7 @@ class PreviewDataProviderTest { } ) } - val testSubject = - PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) + val testSubject = createDataProvider(targetIntent) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) assertThat(testSubject.uriCount).isEqualTo(2) @@ -293,8 +303,7 @@ class PreviewDataProviderTest { 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(testScope, targetIntent, contentResolver, mimeTypeClassifier) + val testSubject = createDataProvider(targetIntent) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) assertThat(testSubject.uriCount).isEqualTo(2) @@ -319,8 +328,7 @@ class PreviewDataProviderTest { } whenever(contentResolver.getType(uri1)).thenReturn("text/html") whenever(contentResolver.getType(uri2)).thenReturn("application/pdf") - val testSubject = - PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) + val testSubject = createDataProvider(targetIntent) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) assertThat(testSubject.uriCount).isEqualTo(2) @@ -350,8 +358,7 @@ class PreviewDataProviderTest { .thenReturn(arrayOf("text/html", "image/jpeg")) whenever(contentResolver.getStreamTypes(uri2, "*/*")) .thenReturn(arrayOf("application/pdf", "image/png")) - val testSubject = - PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) + val testSubject = createDataProvider(targetIntent) val fileInfoListOne = testSubject.imagePreviewFileInfoFlow.toList() val fileInfoListTwo = testSubject.imagePreviewFileInfoFlow.toList() @@ -364,4 +371,74 @@ class PreviewDataProviderTest { verify(contentResolver, times(1)).getType(uri2) verify(contentResolver, times(1)).getStreamTypes(uri2, "*/*") } + + @Test + fun sendItemsWithAdditionalContentUri_showPayloadTogglingUi() { + 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 = + createDataProvider( + targetIntent, + additionalContentUri = Uri.parse("content://org.pkg.app.extracontent"), + isPayloadTogglingEnabled = true, + ) + + assertThat(testSubject.previewType) + .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION) + 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 sendItemsWithAdditionalContentUri_showImagePreviewUi() { + 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 = + createDataProvider( + targetIntent, + additionalContentUri = Uri.parse("content://org.pkg.app.extracontent"), + ) + + 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 sendItemsWithAdditionalContentUriWithSameAuthority_showImagePreviewUi() { + 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 = + createDataProvider( + targetIntent, + additionalContentUri = Uri.parse("content://org.pkg.app/extracontent"), + isPayloadTogglingEnabled = true, + ) + + 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_nonSendIntentActionWithAdditionalContentUri_resolvesToTextPreviewUiSynchronously() { + val targetIntent = Intent(Intent.ACTION_VIEW) + val testSubject = + createDataProvider( + targetIntent, + additionalContentUri = Uri.parse("content://org.pkg.app/extracontent") + ) + + assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) + verify(contentResolver, never()).getType(any()) + } } -- cgit v1.2.3-59-g8ed1b From fbde775360396f6e9df8d6cb8abd8189b75d0979 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Thu, 8 Feb 2024 23:34:46 -0800 Subject: Show payload toggling UI Show payload toggling UI if the flag is enabled. Bug: 302691505 Test: enable feature flag and verify that payload toggling UI is gets shown Change-Id: If3c70088e58977f5345066d5abec31e298e1f4fe --- .../contentpreview/ChooserContentPreviewUi.java | 9 +++++ .../contentpreview/PayloadToggleInteractor.kt | 2 ++ .../contentpreview/ShareouselContentPreviewUi.kt | 2 +- .../shareousel/ui/viewmodel/ShareouselViewModel.kt | 29 ++++++++++++++-- .../android/intentresolver/v2/ChooserActivity.java | 21 +++++++++++- .../android/intentresolver/v2/JavaFlowHelper.kt | 28 ++++++++++++++++ .../contentpreview/PayloadToggleInteractorTest.kt | 39 ++++++++++++++++------ 7 files changed, 115 insertions(+), 15 deletions(-) create mode 100644 java/src/com/android/intentresolver/v2/JavaFlowHelper.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index 471a33e6..acdf6ec6 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -18,12 +18,14 @@ package com.android.intentresolver.contentpreview; import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_FILE; import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_IMAGE; +import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION; import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_TEXT; import android.content.ClipData; import android.content.Intent; import android.content.res.Resources; import android.net.Uri; +import android.service.chooser.Flags; import android.text.TextUtils; import android.view.LayoutInflater; import android.view.View; @@ -154,6 +156,13 @@ public final class ChooserContentPreviewUi { } return fileContentPreviewUi; } + + //TODO: use flags injection + if (previewType == CONTENT_PREVIEW_PAYLOAD_SELECTION && Flags.chooserPayloadToggling()) { + transitionElementStatusCallback.onAllTransitionElementsReady(); // TODO + return new ShareouselContentPreviewUi(actionFactory); + } + boolean isSingleImageShare = previewData.getUriCount() == 1 && typeClassifier.isImageType(previewData.getFirstFileInfo().getMimeType()); CharSequence text = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); diff --git a/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt b/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt index 3393dcfc..8a34e6a9 100644 --- a/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt @@ -27,6 +27,7 @@ import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.channels.BufferOverflow.DROP_LATEST @@ -40,6 +41,7 @@ import kotlinx.coroutines.launch private const val TAG = "PayloadToggleInteractor" +@OptIn(ExperimentalCoroutinesApi::class) class PayloadToggleInteractor( // must use single-thread dispatcher (or we should enforce it with a lock) private val scope: CoroutineScope, diff --git a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt index 51a3cb14..4dd0d3f5 100644 --- a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt +++ b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt @@ -63,7 +63,7 @@ internal class ShareouselContentPreviewUi( val vm: BasePreviewViewModel = viewModel() val interactor = requireNotNull(vm.payloadToggleInteractor) { "Should not be null" } - val viewModel = interactor.toShareouselViewModel(vm.imageLoader) + val viewModel = interactor.toShareouselViewModel(vm.imageLoader, actionFactory) if (headlineViewParent != null) { LaunchedEffect(Unit) { diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt index 05523c7e..fae439e5 100644 --- a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt @@ -16,11 +16,17 @@ package com.android.intentresolver.contentpreview.shareousel.ui.viewmodel import android.graphics.Bitmap +import androidx.core.graphics.drawable.toBitmap +import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory import com.android.intentresolver.contentpreview.ImageLoader +import com.android.intentresolver.contentpreview.MutableActionFactory import com.android.intentresolver.contentpreview.PayloadToggleInteractor +import com.android.intentresolver.icon.BitmapIcon import com.android.intentresolver.icon.ComposeIcon +import com.android.intentresolver.widget.ActionRow.Action import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map data class ShareouselViewModel( @@ -41,11 +47,23 @@ data class ShareouselImageViewModel( val setSelected: (Boolean) -> Unit, ) -fun PayloadToggleInteractor.toShareouselViewModel(imageLoader: ImageLoader): ShareouselViewModel { +fun PayloadToggleInteractor.toShareouselViewModel( + imageLoader: ImageLoader, + actionFactory: ActionFactory +): ShareouselViewModel { return ShareouselViewModel( headline = MutableStateFlow("Shareousel"), previewKeys = previewKeys, - actions = MutableStateFlow(emptyList()), + actions = + if (actionFactory is MutableActionFactory) { + actionFactory.customActionsFlow.map { actions -> + actions.map { it.toActionChipViewModel() } + } + } else { + flow { + emit(actionFactory.createCustomActions().map { it.toActionChipViewModel() }) + } + }, centerIndex = targetPosition, previewForKey = { key -> val previewInteractor = previewInteractor(key) @@ -59,3 +77,10 @@ fun PayloadToggleInteractor.toShareouselViewModel(imageLoader: ImageLoader): Sha previewRowKey = { getKey(it) }, ) } + +private fun Action.toActionChipViewModel() = + ActionChipViewModel( + label?.toString() ?: "", + icon?.let { BitmapIcon(it.toBitmap()) }, + onClick = { onClicked.run() } + ) diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 2ffd31d8..cdc05b95 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -29,6 +29,7 @@ import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTE import static androidx.lifecycle.LifecycleKt.getCoroutineScope; +import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION; import static com.android.intentresolver.v2.ext.CreationExtrasExtKt.addDefaultArgs; import static com.android.internal.annotations.VisibleForTesting.Visibility.PROTECTED; import static com.android.internal.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET; @@ -122,6 +123,7 @@ 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.PayloadToggleInteractor; import com.android.intentresolver.contentpreview.PreviewViewModel; import com.android.intentresolver.emptystate.CompositeEmptyStateProvider; import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; @@ -487,12 +489,29 @@ public class ChooserActivity extends Hilt_ChooserActivity implements chooserRequest.getAdditionalContentUri(), chooserRequest.getFocusedItemPosition(), mChooserServiceFeatureFlags.chooserPayloadToggling()); + ChooserActionFactory chooserActionFactory = createChooserActionFactory(); + ChooserContentPreviewUi.ActionFactory actionFactory = chooserActionFactory; + if (previewViewModel.getPreviewDataProvider().getPreviewType() + == CONTENT_PREVIEW_PAYLOAD_SELECTION + && android.service.chooser.Flags.chooserPayloadToggling()) { + PayloadToggleInteractor payloadToggleInteractor = + previewViewModel.getPayloadToggleInteractor(); + if (payloadToggleInteractor != null) { + ChooserMutableActionFactory mutableActionFactory = + new ChooserMutableActionFactory(chooserActionFactory); + actionFactory = mutableActionFactory; + JavaFlowHelper.collect( + getCoroutineScope(getLifecycle()), + payloadToggleInteractor.getCustomActions(), + mutableActionFactory::updateCustomActions); + } + } mChooserContentPreviewUi = new ChooserContentPreviewUi( getCoroutineScope(getLifecycle()), previewViewModel.getPreviewDataProvider(), chooserRequest.getTargetIntent(), previewViewModel.getImageLoader(), - createChooserActionFactory(), + actionFactory, mEnterTransitionAnimationDelegate, new HeadlineGeneratorImpl(this), chooserRequest.getContentTypeHint(), diff --git a/java/src/com/android/intentresolver/v2/JavaFlowHelper.kt b/java/src/com/android/intentresolver/v2/JavaFlowHelper.kt new file mode 100644 index 00000000..c6c977f6 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/JavaFlowHelper.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2024 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("JavaFlowHelper") + +package com.android.intentresolver.v2 + +import java.util.function.Consumer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +fun collect(scope: CoroutineScope, flow: Flow, collector: Consumer): Job = + scope.launch { flow.collect { collector.accept(it) } } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/PayloadToggleInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/PayloadToggleInteractorTest.kt index 472c2ba4..88e62a40 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/PayloadToggleInteractorTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/PayloadToggleInteractorTest.kt @@ -20,7 +20,7 @@ import android.content.Intent import android.database.Cursor import android.database.MatrixCursor import android.net.Uri -import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.TestCoroutineScheduler import kotlinx.coroutines.test.TestScope @@ -56,13 +56,22 @@ class PayloadToggleInteractorTest { scheduler.runCurrent() testSubject.stateFlow.first().let { initialState -> - assertThat(initialState.items).hasSize(4) - assertThat(initialState.items.map { it.uri }) + assertWithMessage("Two pages (2 items each) are expected to be initially read") + .that(initialState.items) + .hasSize(4) + assertWithMessage("Unexpected cursor values") + .that(initialState.items.map { it.uri }) .containsExactly(*Array(4, ::makeUri)) .inOrder() - assertThat(initialState.hasMoreItemsBefore).isFalse() - assertThat(initialState.hasMoreItemsAfter).isTrue() - assertThat(initialState.allowSelectionChange).isTrue() + assertWithMessage("No more items are expected to the left") + .that(initialState.hasMoreItemsBefore) + .isFalse() + assertWithMessage("No more items are expected to the right") + .that(initialState.hasMoreItemsAfter) + .isTrue() + assertWithMessage("Selections should no be disabled") + .that(initialState.allowSelectionChange) + .isTrue() } testSubject.loadMoreNextItems() @@ -71,13 +80,21 @@ class PayloadToggleInteractorTest { scheduler.runCurrent() testSubject.stateFlow.first().let { state -> - assertThat(state.items.map { it.uri }) + assertWithMessage("Unexpected cursor values") + .that(state.items.map { it.uri }) .containsExactly(*Array(6, ::makeUri)) .inOrder() - assertThat(state.hasMoreItemsBefore).isFalse() - assertThat(state.hasMoreItemsAfter).isTrue() - assertThat(state.allowSelectionChange).isTrue() - assertThat(state.items.map { testSubject.selected(it).first() }) + assertWithMessage("No more items are expected to the left") + .that(state.hasMoreItemsBefore) + .isFalse() + assertWithMessage("No more items are expected to the right") + .that(state.hasMoreItemsAfter) + .isTrue() + assertWithMessage("Selections should no be disabled") + .that(state.allowSelectionChange) + .isTrue() + assertWithMessage("Wrong selected items") + .that(state.items.map { testSubject.selected(it).first() }) .containsExactly(true, false, true, false, false, true) .inOrder() } -- cgit v1.2.3-59-g8ed1b From 249753fec137c15b09f80712001fa9d78dc10ce7 Mon Sep 17 00:00:00 2001 From: Steve Elliott Date: Mon, 12 Feb 2024 15:57:32 -0500 Subject: Start shareousel with initial selection visible Bug: 302691505 Flag: ACONFIG android.service.chooser.chooser_payload_toggling DEVELOPMENT Test: N/A - code isn't live Change-Id: Ia410469744675be255dab6470a773ec46367d392 --- .../contentpreview/PayloadToggleInteractor.kt | 29 +-------- .../contentpreview/ShareouselContentPreviewUi.kt | 72 ++++++++++++++++------ .../ui/composable/ShareouselComposable.kt | 9 +-- .../shareousel/ui/viewmodel/ShareouselViewModel.kt | 16 +++-- 4 files changed, 67 insertions(+), 59 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt b/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt index 8a34e6a9..003f6884 100644 --- a/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt @@ -118,7 +118,6 @@ class PayloadToggleInteractor( fun start() { scope.launch { - publishInitialState() val cursorReader = cursorReaderProvider() val selectedItems = initiallySharedUris.map { uri -> @@ -148,31 +147,6 @@ class PayloadToggleInteractor( } } - private suspend fun publishInitialState() { - stateFlowSource.emit( - State( - if (0 <= focusedUriIdx && focusedUriIdx < initiallySharedUris.size) { - val fileInfo = uriMetadataReader(initiallySharedUris[focusedUriIdx]) - listOf( - Record( - // a unique key that won't appear anywhere after more items are loaded - -initiallySharedUris.size - 1, - initiallySharedUris[focusedUriIdx], - fileInfo.previewUri, - fileInfo.mimeType, - fileInfo.mimeType?.mimeTypeToItemType() ?: ItemType.File, - ), - ) - } else { - emptyList() - }, - hasMoreItemsBefore = true, - hasMoreItemsAfter = true, - allowSelectionChange = false, - ) - ) - } - fun loadMorePreviousItems() { invokeAsyncIfNotRunning(prevPageLoadingGate) { doLoadMorePreviousItems() @@ -194,7 +168,6 @@ class PayloadToggleInteractor( val (_, selectionTracker) = waitForCursorData() ?: return@launch selectionTracker.setItemSelection(record.key, record, isSelected) val targetIntent = targetIntentModifier(selectionTracker.getSelection()) - val newJob = scope.launch { notifySelectionChanged(targetIntent) } notifySelectionJobRef.getAndSet(newJob)?.cancel() } @@ -390,8 +363,10 @@ class PayloadTogglePreviewInteractor( val previewUri: Flow get() = interactor.previewUri(item) + val selected: Flow get() = interactor.selected(item) + val key get() = item.key } diff --git a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt index 4dd0d3f5..cc89f5bf 100644 --- a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt +++ b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt @@ -21,16 +21,26 @@ import android.view.View import android.view.ViewGroup import android.widget.TextView import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height import androidx.compose.material3.MaterialTheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.android.intentresolver.R import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory import com.android.intentresolver.contentpreview.shareousel.ui.composable.Shareousel +import com.android.intentresolver.contentpreview.shareousel.ui.viewmodel.ShareouselViewModel import com.android.intentresolver.contentpreview.shareousel.ui.viewmodel.toShareouselViewModel internal class ShareouselContentPreviewUi( @@ -63,33 +73,55 @@ internal class ShareouselContentPreviewUi( val vm: BasePreviewViewModel = viewModel() val interactor = requireNotNull(vm.payloadToggleInteractor) { "Should not be null" } - val viewModel = interactor.toShareouselViewModel(vm.imageLoader, actionFactory) - if (headlineViewParent != null) { - LaunchedEffect(Unit) { - viewModel.headline.collect { headline -> - headlineViewParent.findViewById(R.id.headline)?.apply { - if (headline.isNotBlank()) { - text = headline - visibility = View.VISIBLE - } else { - visibility = View.GONE - } + var viewModel by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { + viewModel = + interactor.toShareouselViewModel( + vm.imageLoader, + actionFactory, + vm.viewModelScope + ) + } + + headlineViewParent?.let { + viewModel?.let { viewModel -> + LaunchedEffect(viewModel) { + viewModel.headline.collect { headline -> + headlineViewParent + .findViewById(R.id.headline) + ?.apply { + if (headline.isNotBlank()) { + text = headline + visibility = View.VISIBLE + } else { + visibility = View.GONE + } + } } } } } - MaterialTheme( - colorScheme = - if (isSystemInDarkTheme()) { - dynamicDarkColorScheme(LocalContext.current) - } else { - dynamicLightColorScheme(LocalContext.current) - }, - ) { - Shareousel(viewModel = viewModel) + viewModel?.let { viewModel -> + MaterialTheme( + colorScheme = + if (isSystemInDarkTheme()) { + dynamicDarkColorScheme(LocalContext.current) + } else { + dynamicLightColorScheme(LocalContext.current) + }, + ) { + Shareousel(viewModel = viewModel) + } } + ?: run { + Spacer( + Modifier.height( + dimensionResource(R.dimen.chooser_preview_image_height_tall) + ) + ) + } } } return composeView diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt index 0e6e9d7e..5cf35297 100644 --- a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt @@ -47,15 +47,12 @@ import com.android.intentresolver.contentpreview.shareousel.ui.viewmodel.Shareou @Composable fun Shareousel(viewModel: ShareouselViewModel) { - val previewKeys by viewModel.previewKeys.collectAsStateWithLifecycle(initialValue = emptyList()) - val centerIdx by viewModel.centerIndex.collectAsStateWithLifecycle(initialValue = 0) + val centerIdx = viewModel.centerIndex.value + val carouselState = rememberLazyListState(initialFirstVisibleItemIndex = centerIdx) + val previewKeys by viewModel.previewKeys.collectAsStateWithLifecycle() Column { // TODO: item needs to be centered, check out ScalingLazyColumn impl or see if // HorizontalPager works for our use-case - val carouselState = - rememberLazyListState( - initialFirstVisibleItemIndex = centerIdx, - ) LazyRow( state = carouselState, horizontalArrangement = Arrangement.spacedBy(4.dp), diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt index fae439e5..18ee2539 100644 --- a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt @@ -24,16 +24,19 @@ import com.android.intentresolver.contentpreview.PayloadToggleInteractor import com.android.intentresolver.icon.BitmapIcon import com.android.intentresolver.icon.ComposeIcon import com.android.intentresolver.widget.ActionRow.Action +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn data class ShareouselViewModel( val headline: Flow, - val previewKeys: Flow>, + val previewKeys: StateFlow>, val actions: Flow>, - val centerIndex: Flow, + val centerIndex: StateFlow, val previewForKey: (key: Any) -> ShareouselImageViewModel, val previewRowKey: (Any) -> Any ) @@ -47,13 +50,14 @@ data class ShareouselImageViewModel( val setSelected: (Boolean) -> Unit, ) -fun PayloadToggleInteractor.toShareouselViewModel( +suspend fun PayloadToggleInteractor.toShareouselViewModel( imageLoader: ImageLoader, - actionFactory: ActionFactory + actionFactory: ActionFactory, + scope: CoroutineScope, ): ShareouselViewModel { return ShareouselViewModel( headline = MutableStateFlow("Shareousel"), - previewKeys = previewKeys, + previewKeys = previewKeys.stateIn(scope), actions = if (actionFactory is MutableActionFactory) { actionFactory.customActionsFlow.map { actions -> @@ -64,7 +68,7 @@ fun PayloadToggleInteractor.toShareouselViewModel( emit(actionFactory.createCustomActions().map { it.toActionChipViewModel() }) } }, - centerIndex = targetPosition, + centerIndex = targetPosition.stateIn(scope), previewForKey = { key -> val previewInteractor = previewInteractor(key) ShareouselImageViewModel( -- cgit v1.2.3-59-g8ed1b From df71f480dee389192a080fba6fe417f83506b33b Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Tue, 13 Feb 2024 13:55:54 -0500 Subject: Remove direct reference to Activity getIntent() ActivityLaunch provides activity-scoped values from launch time. This abstraction is important for testability, allowing us to consistently test startup conditions in isolation without involving actually starting an activity, or depending on the state of the system in any way. Test: atest IntentResolver-tests-activity Test: atest IntentResolver-tests-unit Change-Id: Iec8b6b7067c6535f723ab47d712e1a937000e1c4 --- .../src/com/android/intentresolver/v2/ChooserActivity.java | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index cdc05b95..072c56de 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -485,7 +485,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements .get(BasePreviewViewModel.class); previewViewModel.init( chooserRequest.getTargetIntent(), - getIntent(), + mActivityLaunch.getIntent(), chooserRequest.getAdditionalContentUri(), chooserRequest.getFocusedItemPosition(), mChooserServiceFeatureFlags.chooserPayloadToggling()); @@ -807,10 +807,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mChooserMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); } - public boolean super_shouldAutoLaunchSingleChoice(TargetInfo target) { - return !target.isSuspended(); - } - /** 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 @@ -1652,14 +1648,12 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } public boolean shouldAutoLaunchSingleChoice(TargetInfo target) { - // Note that this is only safe because the Intent handled by the ChooserActivity is - // guaranteed to contain no extras unknown to the local ClassLoader. That is why this - // method can not be replaced in the ResolverActivity whole hog. - if (!super_shouldAutoLaunchSingleChoice(target)) { + if (target.isSuspended()) { return false; } - return getIntent().getBooleanExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, true); + return mActivityLaunch.getIntent().getBooleanExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, + true); } private void showTargetDetails(TargetInfo targetInfo) { -- cgit v1.2.3-59-g8ed1b From bf789564b55e260634723921b1065fdd8f4f1147 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Tue, 13 Feb 2024 11:04:39 -0800 Subject: Use new shareousel API constants Bug: 302691505 Test: Manual existing functionality testing with a test app. Change-Id: Ic16c866b866cc5d3645f1f2dbf63170a8023b78e --- .../intentresolver/contentpreview/CursorUriReader.kt | 17 +++++++++-------- .../contentpreview/SelectionChangeCallback.kt | 6 ++---- .../v2/ui/viewmodel/ChooserRequestReader.kt | 11 ++--------- 3 files changed, 13 insertions(+), 21 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt b/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt index 7ff3b49e..e9e60040 100644 --- a/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt +++ b/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt @@ -23,16 +23,13 @@ import android.database.MatrixCursor import android.net.Uri import android.os.Bundle import android.os.CancellationSignal +import android.service.chooser.AdditionalContentContract.Columns +import android.service.chooser.AdditionalContentContract.CursorExtraKeys import android.util.Log import android.util.SparseArray import kotlinx.coroutines.CancellationException import kotlinx.coroutines.coroutineScope -// TODO: replace with the new API AdditionalContentContract$Columns#URI -private const val ColumnUri = "uri" -// TODO: replace with the new API AdditionalContentContract$CursorExtraKeys#POSITION -private const val ExtraPosition = "position" - private const val TAG = ContentPreviewUi.TAG /** @@ -124,7 +121,7 @@ class CursorUriReader( runCatching { contentResolver.query( uri, - arrayOf(ColumnUri), + arrayOf(Columns.URI), Bundle().apply { putParcelable(Intent.EXTRA_INTENT, chooserIntent) }, @@ -132,13 +129,17 @@ class CursorUriReader( ) } .getOrNull() - ?: MatrixCursor(arrayOf(ColumnUri)) + ?: MatrixCursor(arrayOf(Columns.URI)) } } catch (e: CancellationException) { cancellationSignal.cancel() throw e } - return CursorUriReader(cursor, cursor.extras?.getInt(ExtraPosition, 0) ?: 0, 128) { + return CursorUriReader( + cursor, + cursor.extras?.getInt(CursorExtraKeys.POSITION, 0) ?: 0, + 128, + ) { // TODO: check that authority is case-sensitive for resolution reasons it.authority != uri.authority } diff --git a/java/src/com/android/intentresolver/contentpreview/SelectionChangeCallback.kt b/java/src/com/android/intentresolver/contentpreview/SelectionChangeCallback.kt index 2cc58a97..4e2e37b8 100644 --- a/java/src/com/android/intentresolver/contentpreview/SelectionChangeCallback.kt +++ b/java/src/com/android/intentresolver/contentpreview/SelectionChangeCallback.kt @@ -22,12 +22,10 @@ import android.content.Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS import android.content.Intent.EXTRA_INTENT import android.net.Uri import android.os.Bundle +import android.service.chooser.AdditionalContentContract.MethodNames.ON_SELECTION_CHANGED import android.service.chooser.ChooserAction import com.android.intentresolver.contentpreview.PayloadToggleInteractor.CallbackResult -// TODO: replace with the new API AdditionalContentContract$MethodNames#ON_SELECTION_CHANGED -private const val MethodName = "onSelectionChanged" - /** * Encapsulates payload change callback invocation to the sharing app; handles callback arguments * and result format mapping. @@ -41,7 +39,7 @@ class SelectionChangeCallback( contentResolver .call( requireNotNull(uri.authority) { "URI authority can not be null" }, - MethodName, + ON_SELECTION_CHANGED, uri.toString(), Bundle().apply { putParcelable( diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt index 0269168e..565d4de1 100644 --- a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt +++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt @@ -60,13 +60,6 @@ import com.android.intentresolver.v2.validation.validateFrom private const val MAX_CHOOSER_ACTIONS = 5 private const val MAX_INITIAL_INTENTS = 2 -// TODO: replace with the new API constant, Intent#EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI -private const val EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI = - "android.intent.extra.CHOOSER_ADDITIONAL_CONTENT_URI" -// TODO: replace with the new API constant, Intent#EXTRA_CHOOSER_FOCUSED_ITEM_POSITION -private const val EXTRA_CHOOSER_FOCUSED_ITEM_POSITION = - "android.intent.extra.CHOOSER_FOCUSED_ITEM_POSITION" - private fun Intent.hasSendAction() = hasAction(ACTION_SEND, ACTION_SEND_MULTIPLE) internal fun Intent.maybeAddSendActionFlags() = @@ -143,8 +136,8 @@ fun readChooserRequest( val additionalContentUri: Uri? val focusedItemPos: Int if (isSendAction && flags.chooserPayloadToggling()) { - additionalContentUri = optional(value(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI)) - focusedItemPos = optional(value(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION)) ?: 0 + additionalContentUri = optional(value(Intent.EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI)) + focusedItemPos = optional(value(Intent.EXTRA_CHOOSER_FOCUSED_ITEM_POSITION)) ?: 0 } else { additionalContentUri = null focusedItemPos = 0 -- cgit v1.2.3-59-g8ed1b From fd17991ae28fe35bdb785ebd282915ba7388fcd0 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Tue, 13 Feb 2024 13:36:19 -0800 Subject: Cleanup payload selection flag usage Remove direct payload selection flag usages. Bug: 302691505 Test: atest IntentResolver-tests-unit Change-Id: I473e6d3fcbc0dbe8ad61c28e010e72c2b984cbea --- .../android/intentresolver/ChooserActivity.java | 3 +- .../contentpreview/ChooserContentPreviewUi.java | 10 ++- .../contentpreview/ContentPreviewUi.java | 4 +- .../contentpreview/CursorUriReader.kt | 1 - .../contentpreview/PreviewDataProvider.kt | 1 - .../contentpreview/ShareouselContentPreviewUi.kt | 4 +- .../android/intentresolver/v2/ChooserActivity.java | 6 +- .../contentpreview/ChooserContentPreviewUiTest.kt | 98 ++++++++-------------- 8 files changed, 51 insertions(+), 76 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 9b4582df..039fad56 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -318,7 +318,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mEnterTransitionAnimationDelegate, new HeadlineGeneratorImpl(this), ContentTypeHint.NONE, - mChooserRequest.getMetadataText() + mChooserRequest.getMetadataText(), + /*isPayloadTogglingEnabled =*/ false ); updateStickyContentPreview(); diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index acdf6ec6..6f201ad5 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -25,7 +25,6 @@ import android.content.ClipData; import android.content.Intent; import android.content.res.Resources; import android.net.Uri; -import android.service.chooser.Flags; import android.text.TextUtils; import android.view.LayoutInflater; import android.view.View; @@ -51,6 +50,7 @@ import kotlinx.coroutines.CoroutineScope; public final class ChooserContentPreviewUi { private final CoroutineScope mScope; + private final boolean mIsPayloadTogglingEnabled; /** * Delegate to build the default system action buttons to display in the preview layout, if/when @@ -103,8 +103,11 @@ public final class ChooserContentPreviewUi { TransitionElementStatusCallback transitionElementStatusCallback, HeadlineGenerator headlineGenerator, ContentTypeHint contentTypeHint, - @Nullable CharSequence metadata) { + @Nullable CharSequence metadata, + // TODO: replace with the FeatureFlag ref when v1 is gone + boolean isPayloadTogglingEnabled) { mScope = scope; + mIsPayloadTogglingEnabled = isPayloadTogglingEnabled; mContentPreviewUi = createContentPreview( previewData, targetIntent, @@ -157,8 +160,7 @@ public final class ChooserContentPreviewUi { return fileContentPreviewUi; } - //TODO: use flags injection - if (previewType == CONTENT_PREVIEW_PAYLOAD_SELECTION && Flags.chooserPayloadToggling()) { + if (previewType == CONTENT_PREVIEW_PAYLOAD_SELECTION && mIsPayloadTogglingEnabled) { transitionElementStatusCallback.onAllTransitionElementsReady(); // TODO return new ShareouselContentPreviewUi(actionFactory); } diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java index c35f93b4..b0fb278e 100644 --- a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java @@ -30,12 +30,14 @@ import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import com.android.intentresolver.R; import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ScrollableImagePreviewView; -abstract class ContentPreviewUi { +@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) +public abstract class ContentPreviewUi { private static final int IMAGE_FADE_IN_MILLIS = 150; static final String TAG = "ChooserPreview"; diff --git a/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt b/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt index e9e60040..6a12f56c 100644 --- a/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt +++ b/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt @@ -140,7 +140,6 @@ class CursorUriReader( cursor.extras?.getInt(CursorExtraKeys.POSITION, 0) ?: 0, 128, ) { - // TODO: check that authority is case-sensitive for resolution reasons it.authority != uri.authority } } diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt index 8073cfec..3f306a80 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt @@ -155,7 +155,6 @@ constructor( val extraContentUri = additionalContentUri ?: return false return runCatching { val authority = extraContentUri.authority - // TODO: verify that authority is case-sensitive records.firstOrNull { authority == it.uri.authority } == null } .onFailure { diff --git a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt index cc89f5bf..82c09986 100644 --- a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt +++ b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt @@ -20,6 +20,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.TextView +import androidx.annotation.VisibleForTesting import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height @@ -43,7 +44,8 @@ import com.android.intentresolver.contentpreview.shareousel.ui.composable.Shareo import com.android.intentresolver.contentpreview.shareousel.ui.viewmodel.ShareouselViewModel import com.android.intentresolver.contentpreview.shareousel.ui.viewmodel.toShareouselViewModel -internal class ShareouselContentPreviewUi( +@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) +class ShareouselContentPreviewUi( private val actionFactory: ActionFactory, ) : ContentPreviewUi() { diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index cdc05b95..6811d61e 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -493,7 +493,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements ChooserContentPreviewUi.ActionFactory actionFactory = chooserActionFactory; if (previewViewModel.getPreviewDataProvider().getPreviewType() == CONTENT_PREVIEW_PAYLOAD_SELECTION - && android.service.chooser.Flags.chooserPayloadToggling()) { + && mChooserServiceFeatureFlags.chooserPayloadToggling()) { PayloadToggleInteractor payloadToggleInteractor = previewViewModel.getPayloadToggleInteractor(); if (payloadToggleInteractor != null) { @@ -515,8 +515,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mEnterTransitionAnimationDelegate, new HeadlineGeneratorImpl(this), chooserRequest.getContentTypeHint(), - chooserRequest.getMetadataText() - ); + chooserRequest.getMetadataText(), + mChooserServiceFeatureFlags.chooserPayloadToggling()); updateStickyContentPreview(); if (shouldShowStickyContentPreview() || mChooserMultiProfilePagerAdapter diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt index ad53eef4..c7c3c516 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt @@ -18,7 +18,6 @@ package com.android.intentresolver.contentpreview import android.content.Intent import android.net.Uri -import android.platform.test.annotations.RequiresFlagsDisabled import android.platform.test.flag.junit.CheckFlagsRule import android.platform.test.flag.junit.DeviceFlagsValueProvider import com.android.intentresolver.ContentTypeHint @@ -49,29 +48,40 @@ class ChooserContentPreviewUiTest { private val actionFactory = object : ActionFactory { 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 {} } private val transitionCallback = mock() @get:Rule val checkFlagsRule: CheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule() + private fun createContentPreviewUi( + targetIntent: Intent, + isPayloadTogglingEnabled: Boolean = false + ) = + ChooserContentPreviewUi( + testScope, + previewData, + targetIntent, + imageLoader, + actionFactory, + transitionCallback, + headlineGenerator, + ContentTypeHint.NONE, + testMetadataText, + isPayloadTogglingEnabled, + ) + @Test fun test_textPreviewType_useTextPreviewUi() { whenever(previewData.previewType).thenReturn(ContentPreviewType.CONTENT_PREVIEW_TEXT) - val testSubject = - ChooserContentPreviewUi( - testScope, - previewData, - Intent(Intent.ACTION_VIEW), - imageLoader, - actionFactory, - transitionCallback, - headlineGenerator, - ContentTypeHint.NONE, - testMetadataText, - ) + val testSubject = createContentPreviewUi(targetIntent = Intent(Intent.ACTION_VIEW)) + assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) assertThat(testSubject.mContentPreviewUi).isInstanceOf(TextContentPreviewUi::class.java) @@ -81,18 +91,7 @@ class ChooserContentPreviewUiTest { @Test fun test_filePreviewType_useFilePreviewUi() { whenever(previewData.previewType).thenReturn(ContentPreviewType.CONTENT_PREVIEW_FILE) - val testSubject = - ChooserContentPreviewUi( - testScope, - previewData, - Intent(Intent.ACTION_SEND), - imageLoader, - actionFactory, - transitionCallback, - headlineGenerator, - ContentTypeHint.NONE, - testMetadataText, - ) + val testSubject = createContentPreviewUi(targetIntent = Intent(Intent.ACTION_SEND)) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) assertThat(testSubject.mContentPreviewUi).isInstanceOf(FileContentPreviewUi::class.java) @@ -108,16 +107,9 @@ class ChooserContentPreviewUiTest { .thenReturn(FileInfo.Builder(uri).withPreviewUri(uri).withMimeType("image/png").build()) whenever(previewData.imagePreviewFileInfoFlow).thenReturn(MutableSharedFlow()) val testSubject = - ChooserContentPreviewUi( - testScope, - previewData, - Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_TEXT, "Shared text") }, - imageLoader, - actionFactory, - transitionCallback, - headlineGenerator, - ContentTypeHint.NONE, - testMetadataText, + createContentPreviewUi( + targetIntent = + Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_TEXT, "Shared text") } ) assertThat(testSubject.mContentPreviewUi) .isInstanceOf(FilesPlusTextContentPreviewUi::class.java) @@ -133,18 +125,7 @@ class ChooserContentPreviewUiTest { whenever(previewData.firstFileInfo) .thenReturn(FileInfo.Builder(uri).withPreviewUri(uri).withMimeType("image/png").build()) whenever(previewData.imagePreviewFileInfoFlow).thenReturn(MutableSharedFlow()) - val testSubject = - ChooserContentPreviewUi( - testScope, - previewData, - Intent(Intent.ACTION_SEND), - imageLoader, - actionFactory, - transitionCallback, - headlineGenerator, - ContentTypeHint.NONE, - testMetadataText, - ) + val testSubject = createContentPreviewUi(targetIntent = Intent(Intent.ACTION_SEND)) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) assertThat(testSubject.mContentPreviewUi).isInstanceOf(UnifiedContentPreviewUi::class.java) @@ -153,8 +134,7 @@ class ChooserContentPreviewUiTest { } @Test - @RequiresFlagsDisabled(android.service.chooser.Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING) - fun test_imagePayloadSelectionType_useImagePreviewUi() { + fun test_imagePayloadSelectionTypeWithEnabledFlag_usePayloadSelectionPreviewUi() { // Event if we returned wrong type due to a bug, we should not use payload selection UI val uri = Uri.parse("content://org.pkg.app/img.png") whenever(previewData.previewType) @@ -164,21 +144,11 @@ class ChooserContentPreviewUiTest { .thenReturn(FileInfo.Builder(uri).withPreviewUri(uri).withMimeType("image/png").build()) whenever(previewData.imagePreviewFileInfoFlow).thenReturn(MutableSharedFlow()) val testSubject = - ChooserContentPreviewUi( - testScope, - previewData, - Intent(Intent.ACTION_SEND), - imageLoader, - actionFactory, - transitionCallback, - headlineGenerator, - ContentTypeHint.NONE, - testMetadataText, + createContentPreviewUi( + targetIntent = Intent(Intent.ACTION_SEND), + isPayloadTogglingEnabled = true ) - assertThat(testSubject.preferredContentPreview) - .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) - assertThat(testSubject.mContentPreviewUi).isInstanceOf(UnifiedContentPreviewUi::class.java) - verify(previewData, times(1)).imagePreviewFileInfoFlow - verify(transitionCallback, never()).onAllTransitionElementsReady() + assertThat(testSubject.mContentPreviewUi) + .isInstanceOf(ShareouselContentPreviewUi::class.java) } } -- cgit v1.2.3-59-g8ed1b From 98120650d1177cb17e42cd8209d2a26f5f24473f Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Thu, 15 Feb 2024 05:20:19 +0000 Subject: Fix infinite loop in edit/copy callbacks Minor refactor to allow for some testing of the change as well. Test: atest chooserActionFactoryTest Test: Manual verification of bug fix with ShareTest Bug: 325365738 Change-Id: I27a785164ad660a37159d5fb7793d2e2e2b8be5c --- .../intentresolver/v2/ChooserActionFactory.java | 14 +++++------- .../android/intentresolver/v2/ChooserActivity.java | 5 ++++- .../intentresolver/v2/ChooserActionFactoryTest.kt | 26 +++++++++++++++++----- .../v2/ChooserMutableActionFactoryTest.kt | 3 ++- 4 files changed, 32 insertions(+), 16 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ChooserActionFactory.java b/java/src/com/android/intentresolver/v2/ChooserActionFactory.java index f9de9f4b..9077a18d 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/v2/ChooserActionFactory.java @@ -131,11 +131,12 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio Callable firstVisibleImageQuery, ActionActivityStarter activityStarter, @Nullable ShareResultSender shareResultSender, - Consumer finishCallback) { + Consumer finishCallback, + ClipboardManager clipboardManager) { this( context, makeCopyButtonRunnable( - context, + clipboardManager, targetIntent, referrerPackageName, finishCallback, @@ -181,13 +182,12 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio if (mShareResultSender != null) { mEditButtonRunnable = () -> { mShareResultSender.onActionSelected(ShareAction.SYSTEM_EDIT); - mEditButtonRunnable.run(); + editButtonRunnable.run(); }; if (mCopyButtonRunnable != null) { mCopyButtonRunnable = () -> { mShareResultSender.onActionSelected(ShareAction.SYSTEM_COPY); - //noinspection DataFlowIssue - mCopyButtonRunnable.run(); + copyButtonRunnable.run(); }; } } @@ -245,7 +245,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio @Nullable private static Runnable makeCopyButtonRunnable( - Context context, + ClipboardManager clipboardManager, Intent targetIntent, String referrerPackageName, Consumer finishCallback, @@ -261,8 +261,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio return null; } return () -> { - ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService( - Context.CLIPBOARD_SERVICE); clipboardManager.setPrimaryClipAsPackage(clipData, referrerPackageName); log.logActionSelected(EventLog.SELECTION_TYPE_COPY); diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 072c56de..25e2521f 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -47,6 +47,7 @@ import android.app.prediction.AppPredictor; import android.app.prediction.AppTarget; import android.app.prediction.AppTargetEvent; import android.app.prediction.AppTargetId; +import android.content.ClipboardManager; import android.content.ComponentName; import android.content.ContentResolver; import android.content.Context; @@ -278,6 +279,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Inject public TargetDataLoader mTargetDataLoader; @Inject public DevicePolicyResources mDevicePolicyResources; @Inject public PackageManager mPackageManager; + @Inject public ClipboardManager mClipboardManager; @Inject public IntentForwarding mIntentForwarding; @Inject public ShareResultSenderFactory mShareResultSenderFactory; @Nullable @@ -2149,7 +2151,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements setResult(status); } finish(); - }); + }, + mClipboardManager); } /* diff --git a/tests/unit/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt b/tests/unit/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt index 717d26bd..95e4c377 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt @@ -31,6 +31,8 @@ import androidx.test.platform.app.InstrumentationRegistry import com.android.intentresolver.ChooserRequestParameters import com.android.intentresolver.logging.EventLog import com.android.intentresolver.mock +import com.android.intentresolver.v2.ui.ShareResultSender +import com.android.intentresolver.v2.ui.model.ShareAction import com.android.intentresolver.whenever import com.google.common.collect.ImmutableList import com.google.common.truth.Truth.assertThat @@ -45,7 +47,9 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.ArgumentMatchers.eq -import org.mockito.Mockito +import org.mockito.Mockito.eq +import org.mockito.Mockito.times +import org.mockito.Mockito.verify @RunWith(AndroidJUnit4::class) class ChooserActionFactoryTest { @@ -94,7 +98,7 @@ class ChooserActionFactoryTest { // click it customActions[0].onClicked.run() - Mockito.verify(logger).logCustomActionSelected(eq(0)) + verify(logger).logCustomActionSelected(eq(0)) assertEquals(Activity.RESULT_OK, resultConsumer.latestReturn) // Verify the pending intent has been called assertTrue("Timed out waiting for broadcast", countdown.await(2500, TimeUnit.MILLISECONDS)) @@ -114,7 +118,7 @@ class ChooserActionFactoryTest { val action = factory.modifyShareAction ?: error("Modify share action should not be null") action.onClicked.run() - Mockito.verify(logger).logActionSelected(eq(EventLog.SELECTION_TYPE_MODIFY_SHARE)) + verify(logger).logActionSelected(eq(EventLog.SELECTION_TYPE_MODIFY_SHARE)) assertEquals(Activity.RESULT_OK, resultConsumer.latestReturn) // Verify the pending intent has been called assertTrue("Timed out waiting for broadcast", countdown.await(2500, TimeUnit.MILLISECONDS)) @@ -146,6 +150,7 @@ class ChooserActionFactoryTest { /* activityStarter = */ mock(), /* shareResultSender = */ null, /* finishCallback = */ {}, + /* clipboardManager = */ mock(), ) assertThat(testSubject.copyButtonRunnable).isNull() } @@ -173,12 +178,13 @@ class ChooserActionFactoryTest { /* activityStarter = */ mock(), /* shareResultSender = */ null, /* finishCallback = */ {}, + /* clipboardManager = */ mock(), ) assertThat(testSubject.copyButtonRunnable).isNull() } @Test - fun sendActionWithText_nonNullCopyRunnable() { + fun sendActionWithTextCopyRunnable() { val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_TEXT, "Text") } val chooserRequest = @@ -186,6 +192,8 @@ class ChooserActionFactoryTest { whenever(this.targetIntent).thenReturn(targetIntent) whenever(chooserActions).thenReturn(ImmutableList.of()) } + + val resultSender = mock() val testSubject = ChooserActionFactory( /* context = */ context, @@ -198,10 +206,15 @@ class ChooserActionFactoryTest { /* onUpdateSharedTextIsExcluded = */ {}, /* firstVisibleImageQuery = */ { null }, /* activityStarter = */ mock(), - /* shareResultSender = */ null, + /* shareResultSender = */ resultSender, /* finishCallback = */ {}, + /* clipboardManager = */ mock(), ) assertThat(testSubject.copyButtonRunnable).isNotNull() + + testSubject.copyButtonRunnable?.run() + + verify(resultSender, times(1)).onActionSelected(ShareAction.SYSTEM_COPY) } private fun createFactory(includeModifyShare: Boolean = false): ChooserActionFactory { @@ -242,7 +255,8 @@ class ChooserActionFactoryTest { /* firstVisibleImageQuery = */ { null }, /* activityStarter = */ mock(), /* shareResultSender = */ null, - /* finishCallback = */ resultConsumer + /* finishCallback = */ resultConsumer, + /* clipboardManager = */ mock(), ) } } diff --git a/tests/unit/src/com/android/intentresolver/v2/ChooserMutableActionFactoryTest.kt b/tests/unit/src/com/android/intentresolver/v2/ChooserMutableActionFactoryTest.kt index 42702cef..ec2b807d 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ChooserMutableActionFactoryTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/ChooserMutableActionFactoryTest.kt @@ -110,7 +110,8 @@ class ChooserMutableActionFactoryTest { /* firstVisibleImageQuery = */ { null }, /* activityStarter = */ mock(), /* shareResultSender = */ null, - /* finishCallback = */ resultConsumer + /* finishCallback = */ resultConsumer, + mock() ) } -- cgit v1.2.3-59-g8ed1b From 266b298afcc2b64d1b7227ffbe952d6d93b6e53e Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Wed, 14 Feb 2024 16:19:58 -0500 Subject: Move MPPA and subclasses to subpackage 'profiles' This will let us unpack these files a bit, move some inner classes out and make a home for additional code to improve the patterns here. Pure mechanical refactor. Bug: n/a Test: atest IntentResolver-tests-unit Change-Id: I138313fc0e84b603b6f173a27cb3b96c91ecc194 --- .../android/intentresolver/v2/ChooserActivity.java | 12 +- .../v2/ChooserMultiProfilePagerAdapter.java | 202 ----- .../v2/MultiProfilePagerAdapter.java | 835 --------------------- .../intentresolver/v2/ResolverActivity.java | 11 +- .../v2/ResolverMultiProfilePagerAdapter.java | 112 --- .../intentresolver/v2/profiles/AdapterBinder.java | 31 + .../profiles/ChooserMultiProfilePagerAdapter.java | 202 +++++ .../v2/profiles/MultiProfilePagerAdapter.java | 692 +++++++++++++++++ .../v2/profiles/OnProfileSelectedListener.java | 46 ++ .../profiles/OnSwitchOnWorkSelectedListener.java | 27 + .../v2/profiles/ProfileDescriptor.java | 82 ++ .../profiles/ResolverMultiProfilePagerAdapter.java | 112 +++ .../intentresolver/v2/profiles/TabConfig.java | 38 + .../v2/MultiProfilePagerAdapterTest.kt | 343 --------- .../v2/profiles/MultiProfilePagerAdapterTest.kt | 342 +++++++++ 15 files changed, 1587 insertions(+), 1500 deletions(-) delete mode 100644 java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java delete mode 100644 java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java delete mode 100644 java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java create mode 100644 java/src/com/android/intentresolver/v2/profiles/AdapterBinder.java create mode 100644 java/src/com/android/intentresolver/v2/profiles/ChooserMultiProfilePagerAdapter.java create mode 100644 java/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapter.java create mode 100644 java/src/com/android/intentresolver/v2/profiles/OnProfileSelectedListener.java create mode 100644 java/src/com/android/intentresolver/v2/profiles/OnSwitchOnWorkSelectedListener.java create mode 100644 java/src/com/android/intentresolver/v2/profiles/ProfileDescriptor.java create mode 100644 java/src/com/android/intentresolver/v2/profiles/ResolverMultiProfilePagerAdapter.java create mode 100644 java/src/com/android/intentresolver/v2/profiles/TabConfig.java delete mode 100644 tests/unit/src/com/android/intentresolver/v2/MultiProfilePagerAdapterTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapterTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index cdc05b95..62b79995 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -138,8 +138,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.v2.MultiProfilePagerAdapter.ProfileType; -import com.android.intentresolver.v2.MultiProfilePagerAdapter.TabConfig; import com.android.intentresolver.v2.data.repository.DevicePolicyResources; import com.android.intentresolver.v2.emptystate.NoAppsAvailableEmptyStateProvider; import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider; @@ -148,6 +146,12 @@ import com.android.intentresolver.v2.emptystate.WorkProfilePausedEmptyStateProvi import com.android.intentresolver.v2.platform.AppPredictionAvailable; import com.android.intentresolver.v2.platform.ImageEditor; import com.android.intentresolver.v2.platform.NearbyShare; +import com.android.intentresolver.v2.profiles.ChooserMultiProfilePagerAdapter; +import com.android.intentresolver.v2.profiles.MultiProfilePagerAdapter; +import com.android.intentresolver.v2.profiles.MultiProfilePagerAdapter.ProfileType; +import com.android.intentresolver.v2.profiles.OnProfileSelectedListener; +import com.android.intentresolver.v2.profiles.OnSwitchOnWorkSelectedListener; +import com.android.intentresolver.v2.profiles.TabConfig; import com.android.intentresolver.v2.ui.ActionTitle; import com.android.intentresolver.v2.ui.ShareResultSender; import com.android.intentresolver.v2.ui.ShareResultSenderFactory; @@ -245,7 +249,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private ResolverActivity.PickTargetOptionRequest mPickOptionRequest; @Nullable - private MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; + private OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; ////////////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////////////// @@ -1169,7 +1173,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements R.layout.resolver_profile_tab_button, com.android.internal.R.id.profile_pager, () -> onProfileTabSelected(viewPager.getCurrentItem()), - new MultiProfilePagerAdapter.OnProfileSelectedListener() { + new OnProfileSelectedListener() { @Override public void onProfilePageSelected(@ProfileType int profileId, int pageNumber) {} diff --git a/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java deleted file mode 100644 index 42eb077b..00000000 --- a/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java +++ /dev/null @@ -1,202 +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.v2; - -import android.content.Context; -import android.os.UserHandle; -import android.view.LayoutInflater; -import android.view.ViewGroup; - -import androidx.recyclerview.widget.GridLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.viewpager.widget.PagerAdapter; - -import com.android.intentresolver.ChooserListAdapter; -import com.android.intentresolver.ChooserRecyclerViewAccessibilityDelegate; -import com.android.intentresolver.FeatureFlags; -import com.android.intentresolver.R; -import com.android.intentresolver.emptystate.EmptyStateProvider; -import com.android.intentresolver.grid.ChooserGridAdapter; -import com.android.intentresolver.measurements.Tracer; - -import com.google.common.collect.ImmutableList; - -import java.util.Optional; -import java.util.function.Supplier; - -/** - * A {@link PagerAdapter} which describes the work and personal profile share sheet screens. - */ -public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< - RecyclerView, ChooserGridAdapter, ChooserListAdapter> { - private static final int SINGLE_CELL_SPAN_SIZE = 1; - - private final ChooserProfileAdapterBinder mAdapterBinder; - private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier; - - public ChooserMultiProfilePagerAdapter( - Context context, - ImmutableList> tabs, - EmptyStateProvider emptyStateProvider, - Supplier workProfileQuietModeChecker, - @ProfileType int defaultProfile, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle, - int maxTargetsPerRow, - FeatureFlags featureFlags) { - this( - context, - new ChooserProfileAdapterBinder(maxTargetsPerRow), - tabs, - emptyStateProvider, - workProfileQuietModeChecker, - defaultProfile, - workProfileUserHandle, - cloneProfileUserHandle, - new BottomPaddingOverrideSupplier(context), - featureFlags); - } - - private ChooserMultiProfilePagerAdapter( - Context context, - ChooserProfileAdapterBinder adapterBinder, - ImmutableList> tabs, - EmptyStateProvider emptyStateProvider, - Supplier workProfileQuietModeChecker, - @ProfileType int defaultProfile, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle, - BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier, - FeatureFlags featureFlags) { - super( - gridAdapter -> gridAdapter.getListAdapter(), - adapterBinder, - tabs, - emptyStateProvider, - workProfileQuietModeChecker, - defaultProfile, - workProfileUserHandle, - cloneProfileUserHandle, - () -> makeProfileView(context, featureFlags), - bottomPaddingOverrideSupplier); - mAdapterBinder = adapterBinder; - mBottomPaddingOverrideSupplier = bottomPaddingOverrideSupplier; - } - - public void setMaxTargetsPerRow(int maxTargetsPerRow) { - mAdapterBinder.setMaxTargetsPerRow(maxTargetsPerRow); - } - - public void setEmptyStateBottomOffset(int bottomOffset) { - mBottomPaddingOverrideSupplier.setEmptyStateBottomOffset(bottomOffset); - setupContainerPadding(); - } - - /** - * Notify adapter about the drawer's collapse state. This will affect the app divider's - * visibility. - */ - public void setIsCollapsed(boolean isCollapsed) { - for (int i = 0, size = getItemCount(); i < size; i++) { - getPageAdapterForIndex(i).setAzLabelVisibility(!isCollapsed); - } - } - - private static ViewGroup makeProfileView( - Context context, FeatureFlags featureFlags) { - LayoutInflater inflater = LayoutInflater.from(context); - ViewGroup rootView = featureFlags.scrollablePreview() - ? (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile_wrap, null, false) - : (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile, null, false); - RecyclerView recyclerView = rootView.findViewById(com.android.internal.R.id.resolver_list); - recyclerView.setAccessibilityDelegateCompat( - new ChooserRecyclerViewAccessibilityDelegate(recyclerView)); - return rootView; - } - - @Override - public boolean onHandlePackagesChanged( - ChooserListAdapter listAdapter, boolean waitingToEnableWorkProfile) { - // TODO: why do we need to do the extra `notifyDataSetChanged()` in (only) the Chooser case? - getActiveListAdapter().notifyDataSetChanged(); - return super.onHandlePackagesChanged(listAdapter, waitingToEnableWorkProfile); - } - - @Override - protected final boolean rebuildTab(ChooserListAdapter listAdapter, boolean doPostProcessing) { - if (doPostProcessing) { - Tracer.INSTANCE.beginAppTargetLoadingSection(listAdapter.getUserHandle()); - } - return super.rebuildTab(listAdapter, doPostProcessing); - } - - /** Apply the specified {@code height} as the footer in each tab's adapter. */ - public void setFooterHeightInEveryAdapter(int height) { - for (int i = 0; i < getItemCount(); ++i) { - getPageAdapterForIndex(i).setFooterHeight(height); - } - } - - private static class BottomPaddingOverrideSupplier implements Supplier> { - private final Context mContext; - private int mBottomOffset; - - BottomPaddingOverrideSupplier(Context context) { - mContext = context; - } - - public void setEmptyStateBottomOffset(int bottomOffset) { - mBottomOffset = bottomOffset; - } - - @Override - public Optional get() { - int initialBottomPadding = mContext.getResources().getDimensionPixelSize( - R.dimen.resolver_empty_state_container_padding_bottom); - return Optional.of(initialBottomPadding + mBottomOffset); - } - } - - private static class ChooserProfileAdapterBinder implements - AdapterBinder { - private int mMaxTargetsPerRow; - - ChooserProfileAdapterBinder(int maxTargetsPerRow) { - mMaxTargetsPerRow = maxTargetsPerRow; - } - - public void setMaxTargetsPerRow(int maxTargetsPerRow) { - mMaxTargetsPerRow = maxTargetsPerRow; - } - - @Override - public void bind( - RecyclerView recyclerView, ChooserGridAdapter chooserGridAdapter) { - GridLayoutManager glm = (GridLayoutManager) recyclerView.getLayoutManager(); - glm.setSpanCount(mMaxTargetsPerRow); - glm.setSpanSizeLookup( - new GridLayoutManager.SpanSizeLookup() { - @Override - public int getSpanSize(int position) { - return chooserGridAdapter.shouldCellSpan(position) - ? SINGLE_CELL_SPAN_SIZE - : glm.getSpanCount(); - } - }); - } - } -} diff --git a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java deleted file mode 100644 index 79403095..00000000 --- a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java +++ /dev/null @@ -1,835 +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.v2; - -import android.annotation.IntDef; -import android.annotation.Nullable; -import android.os.Trace; -import android.os.UserHandle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.TabHost; -import android.widget.TextView; - -import androidx.viewpager.widget.PagerAdapter; -import androidx.viewpager.widget.ViewPager; - -import com.android.intentresolver.ResolverListAdapter; -import com.android.intentresolver.emptystate.EmptyState; -import com.android.intentresolver.emptystate.EmptyStateProvider; -import com.android.intentresolver.v2.emptystate.EmptyStateUiHelper; -import com.android.internal.annotations.VisibleForTesting; - -import com.google.common.collect.ImmutableList; - -import java.util.HashSet; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.Consumer; -import java.util.function.Function; -import java.util.function.Supplier; - -/** - * Skeletal {@link PagerAdapter} implementation for a UI with per-profile tabs (as in Sharesheet). - *

    - * TODO: attempt to further restrict visibility/improve encapsulation in the methods we expose. - *

    - * TODO: deprecate and audit/fix usages of any methods that refer to the "active" or "inactive" - *

    - * adapters; these were marked {@link VisibleForTesting} and their usage seems like an accident - * waiting to happen since clients seem to make assumptions about which adapter will be "active" in - * a particular context, and more explicit APIs would make sure those were valid. - *

    - * TODO: consider renaming legacy methods (e.g. why do we know it's a "list", not just a "page"?) - *

    - * TODO: this is part of an in-progress refactor to merge with `GenericMultiProfilePagerAdapter`. - * As originally noted there, we've reduced explicit references to the `ResolverListAdapter` base - * type and may be able to drop the type constraint. - * - * @param the type of the widget that represents the contents of a page in this adapter - * @param the type of a "root" adapter class to be instantiated and included in - * the per-profile records. - * @param the concrete type of a {@link ResolverListAdapter} implementation to - * control the contents of a given per-profile list. This is provided for convenience, since it must - * be possible to get the list adapter from the page adapter via our - * mListAdapterExtractor. - */ -class MultiProfilePagerAdapter< - PageViewT extends ViewGroup, - SinglePageAdapterT, - ListAdapterT extends ResolverListAdapter> extends PagerAdapter { - - /** - * Delegate to set up a given adapter and page view to be used together. - * @param (as in {@link MultiProfilePagerAdapter}). - * @param (as in {@link MultiProfilePagerAdapter}). - */ - public interface AdapterBinder { - /** - * The given {@code view} will be associated with the given {@code adapter}. Do any work - * necessary to configure them compatibly, introduce them to each other, etc. - */ - void bind(PageViewT view, SinglePageAdapterT adapter); - } - - public static final int PROFILE_PERSONAL = 0; - public static final int PROFILE_WORK = 1; - - @IntDef({PROFILE_PERSONAL, PROFILE_WORK}) - public @interface ProfileType {} - - private final Function mListAdapterExtractor; - private final AdapterBinder mAdapterBinder; - private final Supplier mPageViewInflater; - - private final ImmutableList> mItems; - - private final EmptyStateProvider mEmptyStateProvider; - private final UserHandle mWorkProfileUserHandle; - private final UserHandle mCloneProfileUserHandle; - private final Supplier mWorkProfileQuietModeChecker; // True when work is quiet. - - private final Set mLoadedPages; - private int mCurrentPage; - private OnProfileSelectedListener mOnProfileSelectedListener; - - public static class TabConfig { - private final @ProfileType int mProfile; - private final String mTabLabel; - private final String mTabAccessibilityLabel; - private final String mTabTag; - private final PageAdapterT mPageAdapter; - - public TabConfig( - @ProfileType int profile, - String tabLabel, - String tabAccessibilityLabel, - String tabTag, - PageAdapterT pageAdapter) { - mProfile = profile; - mTabLabel = tabLabel; - mTabAccessibilityLabel = tabAccessibilityLabel; - mTabTag = tabTag; - mPageAdapter = pageAdapter; - } - } - - protected MultiProfilePagerAdapter( - Function listAdapterExtractor, - AdapterBinder adapterBinder, - ImmutableList> tabs, - EmptyStateProvider emptyStateProvider, - Supplier workProfileQuietModeChecker, - @ProfileType int defaultProfile, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle, - Supplier pageViewInflater, - Supplier> containerBottomPaddingOverrideSupplier) { - mLoadedPages = new HashSet<>(); - mWorkProfileUserHandle = workProfileUserHandle; - mCloneProfileUserHandle = cloneProfileUserHandle; - mEmptyStateProvider = emptyStateProvider; - mWorkProfileQuietModeChecker = workProfileQuietModeChecker; - - mListAdapterExtractor = listAdapterExtractor; - mAdapterBinder = adapterBinder; - mPageViewInflater = pageViewInflater; - - ImmutableList.Builder> items = - new ImmutableList.Builder<>(); - for (TabConfig tab : tabs) { - // TODO: consider representing tabConfig in a different data structure that can ensure - // uniqueness of their profile assignments (while still respecting the client's - // requested tab order). - items.add( - createProfileDescriptor( - tab.mProfile, - tab.mTabLabel, - tab.mTabAccessibilityLabel, - tab.mTabTag, - tab.mPageAdapter, - containerBottomPaddingOverrideSupplier)); - } - mItems = items.build(); - - mCurrentPage = - hasPageForProfile(defaultProfile) ? getPageNumberForProfile(defaultProfile) : 0; - } - - private ProfileDescriptor createProfileDescriptor( - @ProfileType int profile, - String tabLabel, - String tabAccessibilityLabel, - String tabTag, - SinglePageAdapterT adapter, - Supplier> containerBottomPaddingOverrideSupplier) { - return new ProfileDescriptor<>( - profile, - tabLabel, - tabAccessibilityLabel, - tabTag, - mPageViewInflater.get(), - adapter, - containerBottomPaddingOverrideSupplier); - } - - private boolean hasPageForIndex(int pageIndex) { - return (pageIndex >= 0) && (pageIndex < getCount()); - } - - public final boolean hasPageForProfile(@ProfileType int profile) { - return hasPageForIndex(getPageNumberForProfile(profile)); - } - - private @ProfileType int getProfileForPageNumber(int position) { - if (hasPageForIndex(position)) { - return mItems.get(position).mProfile; - } - return -1; - } - - public int getPageNumberForProfile(@ProfileType int profile) { - for (int i = 0; i < mItems.size(); ++i) { - if (profile == mItems.get(i).mProfile) { - return i; - } - } - return -1; - } - - private ListAdapterT getListAdapterForPageNumber(int pageNumber) { - SinglePageAdapterT pageAdapter = getPageAdapterForIndex(pageNumber); - if (pageAdapter == null) { - return null; - } - return mListAdapterExtractor.apply(pageAdapter); - } - - private @ProfileType int getProfileForUserHandle(UserHandle userHandle) { - if (userHandle.equals(getCloneUserHandle())) { - // TODO: can we push this special case elsewhere -- e.g., when we check against each - // list adapter's user handle in the loop below, could we instead ask the list adapter - // whether it "represents" the queried user handle, and have the personal list adapter - // return true because it knows it's also associated with the clone profile? Or if we - // don't want to make modifications to the list adapter, maybe we could at least specify - // it in our per-page configuration data that we use to build our tabs/pages, and then - // maintain the relevant bookkeeping in our own ProfileDescriptor? - return PROFILE_PERSONAL; - } - for (int i = 0; i < mItems.size(); ++i) { - ListAdapterT listAdapter = getListAdapterForPageNumber(i); - if (listAdapter.getUserHandle().equals(userHandle)) { - return mItems.get(i).mProfile; - } - } - return -1; - } - - private int getPageNumberForUserHandle(UserHandle userHandle) { - return getPageNumberForProfile(getProfileForUserHandle(userHandle)); - } - - /** - * Returns the {@link ListAdapterT} instance of the profile that represents - * userHandle. If there is no such adapter for the specified - * userHandle, returns {@code null}. - *

    For example, if there is a work profile on the device with user id 10, calling this method - * with UserHandle.of(10) returns the work profile {@link ListAdapterT}. - */ - @Nullable - public final ListAdapterT getListAdapterForUserHandle(UserHandle userHandle) { - return getListAdapterForPageNumber(getPageNumberForUserHandle(userHandle)); - } - - @Nullable - private ProfileDescriptor getDescriptorForUserHandle( - UserHandle userHandle) { - return getItem(getPageNumberForUserHandle(userHandle)); - } - - private int getPageNumberForTabTag(String tag) { - for (int i = 0; i < mItems.size(); ++i) { - if (Objects.equals(mItems.get(i).mTabTag, tag)) { - return i; - } - } - return -1; - } - - private void updateActiveTabStyle(TabHost tabHost) { - int currentTab = tabHost.getCurrentTab(); - - for (int pageNumber = 0; pageNumber < getItemCount(); ++pageNumber) { - // TODO: can we avoid this downcast by pushing our knowledge of the intended view type - // somewhere else? - TextView tabText = (TextView) tabHost.getTabWidget().getChildAt(pageNumber); - tabText.setSelected(currentTab == pageNumber); - } - } - - public void setupProfileTabs( - LayoutInflater layoutInflater, - TabHost tabHost, - ViewPager viewPager, - int tabButtonLayoutResId, - int tabPageContentViewId, - Runnable onTabChangeListener, - MultiProfilePagerAdapter.OnProfileSelectedListener clientOnProfileSelectedListener) { - tabHost.setup(); - viewPager.setSaveEnabled(false); - - for (int pageNumber = 0; pageNumber < getItemCount(); ++pageNumber) { - ProfileDescriptor descriptor = mItems.get(pageNumber); - Button profileButton = (Button) layoutInflater.inflate( - tabButtonLayoutResId, tabHost.getTabWidget(), false); - profileButton.setText(descriptor.mTabLabel); - profileButton.setContentDescription(descriptor.mTabAccessibilityLabel); - - TabHost.TabSpec profileTabSpec = tabHost.newTabSpec(descriptor.mTabTag) - .setContent(tabPageContentViewId) - .setIndicator(profileButton); - tabHost.addTab(profileTabSpec); - } - - tabHost.getTabWidget().setVisibility(View.VISIBLE); - - updateActiveTabStyle(tabHost); - - tabHost.setOnTabChangedListener(tabTag -> { - updateActiveTabStyle(tabHost); - - int pageNumber = getPageNumberForTabTag(tabTag); - if (pageNumber >= 0) { - viewPager.setCurrentItem(pageNumber); - } - onTabChangeListener.run(); - }); - - viewPager.setVisibility(View.VISIBLE); - tabHost.setCurrentTab(getCurrentPage()); - mOnProfileSelectedListener = - new MultiProfilePagerAdapter.OnProfileSelectedListener() { - @Override - public void onProfilePageSelected(@ProfileType int profileId, int pageNumber) { - tabHost.setCurrentTab(pageNumber); - clientOnProfileSelectedListener.onProfilePageSelected( - profileId, pageNumber); - } - - @Override - public void onProfilePageStateChanged(int state) { - clientOnProfileSelectedListener.onProfilePageStateChanged(state); - } - }; - } - - /** - * Sets this instance of this class as {@link ViewPager}'s {@link PagerAdapter} and sets - * an {@link ViewPager.OnPageChangeListener} where it keeps track of the currently displayed - * page and rebuilds the list. - */ - public void setupViewPager(ViewPager viewPager) { - viewPager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() { - @Override - public void onPageSelected(int position) { - mCurrentPage = position; - if (!mLoadedPages.contains(position)) { - rebuildActiveTab(true); - mLoadedPages.add(position); - } - if (mOnProfileSelectedListener != null) { - mOnProfileSelectedListener.onProfilePageSelected( - getProfileForPageNumber(position), position); - } - } - - @Override - public void onPageScrollStateChanged(int state) { - if (mOnProfileSelectedListener != null) { - mOnProfileSelectedListener.onProfilePageStateChanged(state); - } - } - }); - viewPager.setAdapter(this); - viewPager.setCurrentItem(mCurrentPage); - mLoadedPages.add(mCurrentPage); - } - - public void clearInactiveProfileCache() { - forEachInactivePage(pageNumber -> mLoadedPages.remove(pageNumber)); - } - - @Override - public final ViewGroup instantiateItem(ViewGroup container, int position) { - setupListAdapter(position); - final ProfileDescriptor descriptor = getItem(position); - container.addView(descriptor.mRootView); - return descriptor.mRootView; - } - - @Override - public void destroyItem(ViewGroup container, int position, Object view) { - container.removeView((View) view); - } - - @Override - public int getCount() { - return getItemCount(); - } - - public int getCurrentPage() { - return mCurrentPage; - } - - public final @ProfileType int getActiveProfile() { - return getProfileForPageNumber(getCurrentPage()); - } - - @VisibleForTesting - public UserHandle getCurrentUserHandle() { - return getActiveListAdapter().getUserHandle(); - } - - @Override - public boolean isViewFromObject(View view, Object object) { - return view == object; - } - - @Override - public CharSequence getPageTitle(int position) { - return null; - } - - public UserHandle getCloneUserHandle() { - return mCloneProfileUserHandle; - } - - /** - * Returns the {@link ProfileDescriptor} relevant to the given pageIndex. - *

      - *
    • For a device with only one user, pageIndex value of - * 0 would return the personal profile {@link ProfileDescriptor}.
    • - *
    • For a device with a work profile, pageIndex value of 0 would - * return the personal profile {@link ProfileDescriptor}, and pageIndex value of - * 1 would return the work profile {@link ProfileDescriptor}.
    • - *
    - */ - @Nullable - private ProfileDescriptor getItem(int pageIndex) { - if (!hasPageForIndex(pageIndex)) { - return null; - } - return mItems.get(pageIndex); - } - - private ViewGroup getEmptyStateView(int pageIndex) { - return getItem(pageIndex).getEmptyStateView(); - } - - public ViewGroup getActiveEmptyStateView() { - return getEmptyStateView(getCurrentPage()); - } - - /** - * Returns the number of {@link ProfileDescriptor} objects. - *

    For a normal consumer device with only one user returns 1. - *

    For a device with a work profile returns 2. - */ - public final int getItemCount() { - return mItems.size(); - } - - public final PageViewT getListViewForIndex(int index) { - return getItem(index).mView; - } - - /** - * Returns the adapter of the list view for the relevant page specified by - * pageIndex. - *

    This method is meant to be implemented with an implementation-specific return type - * depending on the adapter type. - */ - @VisibleForTesting - public final SinglePageAdapterT getPageAdapterForIndex(int index) { - if (!hasPageForIndex(index)) { - return null; - } - return getItem(index).mAdapter; - } - - /** - * Performs view-related initialization procedures for the adapter specified - * by pageIndex. - */ - public final void setupListAdapter(int pageIndex) { - mAdapterBinder.bind(getListViewForIndex(pageIndex), getPageAdapterForIndex(pageIndex)); - } - - /** - * Returns the {@link ListAdapterT} instance of the profile that is currently visible - * to the user. - *

    For example, if the user is viewing the work tab in the share sheet, this method returns - * the work profile {@link ListAdapterT}. - */ - @VisibleForTesting - public final ListAdapterT getActiveListAdapter() { - return getListAdapterForPageNumber(getCurrentPage()); - } - - public final ListAdapterT getPersonalListAdapter() { - return getListAdapterForPageNumber(getPageNumberForProfile(PROFILE_PERSONAL)); - } - - @Nullable - public final ListAdapterT getWorkListAdapter() { - if (!hasPageForProfile(PROFILE_WORK)) { - return null; - } - return getListAdapterForPageNumber(getPageNumberForProfile(PROFILE_WORK)); - } - - public final SinglePageAdapterT getCurrentRootAdapter() { - return getPageAdapterForIndex(getCurrentPage()); - } - - public final PageViewT getActiveAdapterView() { - return getListViewForIndex(getCurrentPage()); - } - - private boolean anyAdapterHasItems() { - for (int i = 0; i < mItems.size(); ++i) { - ListAdapterT listAdapter = getListAdapterForPageNumber(i); - if (listAdapter.getCount() > 0) { - return true; - } - } - return false; - } - - public void refreshPackagesInAllTabs() { - // TODO: it's unclear if this legacy logic really requires the active tab to be rebuilt - // first, or if we could just iterate over the tabs in arbitrary order. - getActiveListAdapter().handlePackagesChanged(); - forEachInactivePage(page -> getListAdapterForPageNumber(page).handlePackagesChanged()); - } - - /** - * Notify that there has been a package change which could potentially modify the set of targets - * that should be shown in the specified {@code listAdapter}. This may result in - * "rebuilding" the target list for that adapter. - * - * @param listAdapter an adapter that may need to be updated after the package-change event. - * @param waitingToEnableWorkProfile whether we've turned on the work profile, but haven't yet - * seen an {@code ACTION_USER_UNLOCKED} broadcast. In this case we skip the rebuild of any - * work-profile adapter because we wouldn't expect meaningful results -- but another rebuild - * will be prompted when we eventually get the broadcast. - * - * @return whether we're able to proceed with a Sharesheet session after processing this - * package-change event. If false, we were able to rebuild the targets but determined that there - * aren't any we could present in the UI without the app looking broken, so we should just quit. - */ - public boolean onHandlePackagesChanged( - ListAdapterT listAdapter, boolean waitingToEnableWorkProfile) { - if (listAdapter == getActiveListAdapter()) { - if (listAdapter.getUserHandle().equals(mWorkProfileUserHandle) - && waitingToEnableWorkProfile) { - // We have just turned on the work profile and entered the passcode to start it, - // now we are waiting to receive the ACTION_USER_UNLOCKED broadcast. There is no - // point in reloading the list now, since the work profile user is still turning on. - return true; - } - - boolean listRebuilt = rebuildActiveTab(true); - if (listRebuilt) { - listAdapter.notifyDataSetChanged(); - } - - // TODO: shouldn't we check that the inactive tabs are built before declaring that we - // have to quit for lack of items? - return anyAdapterHasItems(); - } else { - clearInactiveProfileCache(); - return true; - } - } - - /** - * Fully-rebuild the active tab and, if specified, partially-rebuild any other inactive tabs. - */ - public boolean rebuildTabs(boolean includePartialRebuildOfInactiveTabs) { - // TODO: we may be able to determine `includePartialRebuildOfInactiveTabs` ourselves as - // a function of our own instance state. OTOH the purpose of this "partial rebuild" is to - // be able to evaluate the intermediate state of one particular profile tab (i.e. work - // profile) that may not generalize well when we have other "inactive tabs." I.e., either we - // rebuild *all* the inactive tabs just to evaluate some auto-launch conditions that only - // depend on personal and/or work tabs, or we have to explicitly specify the ones we care - // about. It's not the pager-adapter's business to know "which ones we care about," so maybe - // they should be rebuilt lazily when-and-if it comes up (e.g. during the evaluation of - // autolaunch conditions). - boolean rebuildCompleted = rebuildActiveTab(true) || getActiveListAdapter().isTabLoaded(); - if (includePartialRebuildOfInactiveTabs) { - // Per legacy logic, avoid short-circuiting (TODO: why? possibly so that we *start* - // loading the inactive tabs even if we're still waiting on the active tab to finish?). - boolean completedRebuildingInactiveTabs = rebuildInactiveTabs(false); - rebuildCompleted = rebuildCompleted && completedRebuildingInactiveTabs; - } - return rebuildCompleted; - } - - /** - * Rebuilds the tab that is currently visible to the user. - *

    Returns {@code true} if rebuild has completed. - */ - public final boolean rebuildActiveTab(boolean doPostProcessing) { - Trace.beginSection("MultiProfilePagerAdapter#rebuildActiveTab"); - boolean result = rebuildTab(getActiveListAdapter(), doPostProcessing); - Trace.endSection(); - return result; - } - - /** - * Rebuilds any tabs that are not currently visible to the user. - *

    Returns {@code true} if rebuild has completed in all inactive tabs. - */ - private boolean rebuildInactiveTabs(boolean doPostProcessing) { - Trace.beginSection("MultiProfilePagerAdapter#rebuildInactiveTab"); - AtomicBoolean allRebuildsComplete = new AtomicBoolean(true); - forEachInactivePage(pageNumber -> { - // Evaluate the rebuild for every inactive page, even if we've already seen some adapter - // return an "incomplete" status (i.e., even if `allRebuildsComplete` is already false) - // and so we already know we'll end up returning false for the batch. - // TODO: any particular reason the per-page legacy logic was set up in this order, or - // could we possibly short-circuit the rebuild if the tab is already "loaded"? - ListAdapterT inactiveAdapter = getListAdapterForPageNumber(pageNumber); - boolean rebuildInactivePageCompleted = - rebuildTab(inactiveAdapter, doPostProcessing) || inactiveAdapter.isTabLoaded(); - if (!rebuildInactivePageCompleted) { - allRebuildsComplete.set(false); - } - }); - Trace.endSection(); - return allRebuildsComplete.get(); - } - - protected void forEachPage(Consumer pageNumberHandler) { - for (int pageNumber = 0; pageNumber < getItemCount(); ++pageNumber) { - pageNumberHandler.accept(pageNumber); - } - } - - protected void forEachInactivePage(Consumer inactivePageNumberHandler) { - forEachPage(pageNumber -> { - if (pageNumber != getCurrentPage()) { - inactivePageNumberHandler.accept(pageNumber); - } - }); - } - - protected boolean rebuildTab(ListAdapterT activeListAdapter, boolean doPostProcessing) { - if (shouldSkipRebuild(activeListAdapter)) { - activeListAdapter.postListReadyRunnable(doPostProcessing, /* rebuildCompleted */ true); - return false; - } - return activeListAdapter.rebuildList(doPostProcessing); - } - - private boolean shouldSkipRebuild(ListAdapterT activeListAdapter) { - EmptyState emptyState = mEmptyStateProvider.getEmptyState(activeListAdapter); - return emptyState != null && emptyState.shouldSkipDataRebuild(); - } - - /** - * The empty state screens are shown according to their priority: - *

      - *
    1. (highest priority) cross-profile disabled by policy (handled in - * {@link #rebuildTab(ListAdapterT, boolean)})
    2. - *
    3. no apps available
    4. - *
    5. (least priority) work is off
    6. - *
    - * - * The intention is to prevent the user from having to turn - * the work profile on if there will not be any apps resolved - * anyway. - * - * TODO: move this comment to the place where we configure our composite provider. - */ - public void showEmptyResolverListEmptyState(ListAdapterT listAdapter) { - final EmptyState emptyState = mEmptyStateProvider.getEmptyState(listAdapter); - - if (emptyState == null) { - return; - } - - emptyState.onEmptyStateShown(); - - View.OnClickListener clickListener = null; - - if (emptyState.getButtonClickListener() != null) { - clickListener = v -> emptyState.getButtonClickListener().onClick(() -> { - ProfileDescriptor descriptor = - getDescriptorForUserHandle(listAdapter.getUserHandle()); - descriptor.mEmptyStateUi.showSpinner(); - }); - } - - showEmptyState(listAdapter, emptyState, clickListener); - } - - /** - * Class to get user id of the current process - */ - public static class MyUserIdProvider { - /** - * @return user id of the current process - */ - public int getMyUserId() { - return UserHandle.myUserId(); - } - } - - private void showEmptyState( - ListAdapterT activeListAdapter, - EmptyState emptyState, - View.OnClickListener buttonOnClick) { - ProfileDescriptor descriptor = - getDescriptorForUserHandle(activeListAdapter.getUserHandle()); - descriptor.mEmptyStateUi.showEmptyState(emptyState, buttonOnClick); - activeListAdapter.markTabLoaded(); - } - - /** - * Sets up the padding of the view containing the empty state screens for the current adapter - * view. - */ - protected final void setupContainerPadding() { - getItem(getCurrentPage()).setupContainerPadding(); - } - - public void showListView(ListAdapterT activeListAdapter) { - ProfileDescriptor descriptor = - getDescriptorForUserHandle(activeListAdapter.getUserHandle()); - descriptor.mEmptyStateUi.hide(); - } - - /** - * @return whether any "inactive" tab's adapter would show an empty-state screen in our current - * application state. - */ - public final boolean shouldShowEmptyStateScreenInAnyInactiveAdapter() { - AtomicBoolean anyEmpty = new AtomicBoolean(false); - // TODO: The "inactive" condition is legacy logic. Could we simplify and ask "any"? - forEachInactivePage(pageNumber -> { - if (shouldShowEmptyStateScreen(getListAdapterForPageNumber(pageNumber))) { - anyEmpty.set(true); - } - }); - return anyEmpty.get(); - } - - public boolean shouldShowEmptyStateScreen(ListAdapterT listAdapter) { - int count = listAdapter.getUnfilteredCount(); - return (count == 0 && listAdapter.getPlaceholderCount() == 0) - || (listAdapter.getUserHandle().equals(mWorkProfileUserHandle) - && mWorkProfileQuietModeChecker.get()); - } - - // 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 ProfileDescriptor { - final @ProfileType int mProfile; - final String mTabLabel; - final String mTabAccessibilityLabel; - final String mTabTag; - - final ViewGroup mRootView; - final EmptyStateUiHelper mEmptyStateUi; - - // TODO: post-refactoring, we may not need to retain these ivars directly (since they may - // be encapsulated within the `EmptyStateUiHelper`?). - private final ViewGroup mEmptyStateView; - - private final SinglePageAdapterT mAdapter; - private final PageViewT mView; - - ProfileDescriptor( - @ProfileType int forProfile, - String tabLabel, - String tabAccessibilityLabel, - String tabTag, - ViewGroup rootView, - SinglePageAdapterT adapter, - Supplier> containerBottomPaddingOverrideSupplier) { - mProfile = forProfile; - mTabLabel = tabLabel; - mTabAccessibilityLabel = tabAccessibilityLabel; - mTabTag = tabTag; - mRootView = rootView; - mAdapter = adapter; - mEmptyStateView = rootView.findViewById(com.android.internal.R.id.resolver_empty_state); - mView = (PageViewT) rootView.findViewById(com.android.internal.R.id.resolver_list); - mEmptyStateUi = new EmptyStateUiHelper( - rootView, - com.android.internal.R.id.resolver_list, - containerBottomPaddingOverrideSupplier); - } - - protected ViewGroup getEmptyStateView() { - return mEmptyStateView; - } - - private void setupContainerPadding() { - mEmptyStateUi.setupContainerPadding(); - } - } - - /** Listener interface for changes between the per-profile UI tabs. */ - public interface OnProfileSelectedListener { - /** - * Callback for when the user changes the active tab. - *

    This callback is only called when the intent resolver or share sheet shows - * more than one profile. - * @param profileId the ID of the newly-selected profile, e.g. {@link #PROFILE_PERSONAL} - * if the personal profile tab was selected or {@link #PROFILE_WORK} if the work profile tab - * was selected. - */ - void onProfilePageSelected(@ProfileType int profileId, int pageNumber); - - - /** - * Callback for when the scroll state changes. Useful for discovering when the user begins - * dragging, when the pager is automatically settling to the current page, or when it is - * fully stopped/idle. - * @param state {@link ViewPager#SCROLL_STATE_IDLE}, {@link ViewPager#SCROLL_STATE_DRAGGING} - * or {@link ViewPager#SCROLL_STATE_SETTLING} - * @see ViewPager.OnPageChangeListener#onPageScrollStateChanged - */ - void onProfilePageStateChanged(int state); - } - - /** - * Listener for when the user switches on the work profile from the work tab. - */ - public interface OnSwitchOnWorkSelectedListener { - /** - * Callback for when the user switches on the work profile from the work tab. - */ - void onSwitchOnWorkSelected(); - } -} diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index 77d1dbf5..2d26932f 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -100,15 +100,18 @@ import com.android.intentresolver.emptystate.EmptyStateProvider; import com.android.intentresolver.icons.DefaultTargetDataLoader; import com.android.intentresolver.icons.TargetDataLoader; import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; -import com.android.intentresolver.v2.MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; -import com.android.intentresolver.v2.MultiProfilePagerAdapter.ProfileType; -import com.android.intentresolver.v2.MultiProfilePagerAdapter.TabConfig; import com.android.intentresolver.v2.data.repository.DevicePolicyResources; import com.android.intentresolver.v2.domain.model.Profile; import com.android.intentresolver.v2.emptystate.NoAppsAvailableEmptyStateProvider; import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider; import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; import com.android.intentresolver.v2.emptystate.WorkProfilePausedEmptyStateProvider; +import com.android.intentresolver.v2.profiles.MultiProfilePagerAdapter; +import com.android.intentresolver.v2.profiles.OnSwitchOnWorkSelectedListener; +import com.android.intentresolver.v2.profiles.MultiProfilePagerAdapter.ProfileType; +import com.android.intentresolver.v2.profiles.OnProfileSelectedListener; +import com.android.intentresolver.v2.profiles.TabConfig; +import com.android.intentresolver.v2.profiles.ResolverMultiProfilePagerAdapter; import com.android.intentresolver.v2.ui.ActionTitle; import com.android.intentresolver.v2.ui.model.ActivityLaunch; import com.android.intentresolver.v2.ui.model.ResolverRequest; @@ -1865,7 +1868,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements R.layout.resolver_profile_tab_button, com.android.internal.R.id.profile_pager, () -> onProfileTabSelected(viewPager.getCurrentItem()), - new MultiProfilePagerAdapter.OnProfileSelectedListener() { + new OnProfileSelectedListener() { @Override public void onProfilePageSelected(@ProfileType int profileId, int pageNumber) { resetButtonBar(); diff --git a/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java deleted file mode 100644 index c2e1ae07..00000000 --- a/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java +++ /dev/null @@ -1,112 +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.v2; - -import android.content.Context; -import android.os.UserHandle; -import android.view.LayoutInflater; -import android.view.ViewGroup; -import android.widget.ListView; - -import androidx.viewpager.widget.PagerAdapter; - -import com.android.intentresolver.R; -import com.android.intentresolver.ResolverListAdapter; -import com.android.intentresolver.emptystate.EmptyStateProvider; - -import com.google.common.collect.ImmutableList; - -import java.util.Optional; -import java.util.function.Supplier; - -/** - * A {@link PagerAdapter} which describes the work and personal profile intent resolver screens. - */ -public class ResolverMultiProfilePagerAdapter extends - MultiProfilePagerAdapter { - private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier; - - public ResolverMultiProfilePagerAdapter(Context context, - ImmutableList> tabs, - EmptyStateProvider emptyStateProvider, - Supplier workProfileQuietModeChecker, - @ProfileType int defaultProfile, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle) { - this( - context, - tabs, - emptyStateProvider, - workProfileQuietModeChecker, - defaultProfile, - workProfileUserHandle, - cloneProfileUserHandle, - new BottomPaddingOverrideSupplier()); - } - - private ResolverMultiProfilePagerAdapter( - Context context, - ImmutableList> tabs, - EmptyStateProvider emptyStateProvider, - Supplier workProfileQuietModeChecker, - @ProfileType int defaultProfile, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle, - BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) { - super( - listAdapter -> listAdapter, - (listView, bindAdapter) -> listView.setAdapter(bindAdapter), - tabs, - emptyStateProvider, - workProfileQuietModeChecker, - defaultProfile, - workProfileUserHandle, - cloneProfileUserHandle, - () -> (ViewGroup) LayoutInflater.from(context).inflate( - R.layout.resolver_list_per_profile, null, false), - bottomPaddingOverrideSupplier); - mBottomPaddingOverrideSupplier = bottomPaddingOverrideSupplier; - } - - public void setUseLayoutWithDefault(boolean useLayoutWithDefault) { - mBottomPaddingOverrideSupplier.setUseLayoutWithDefault(useLayoutWithDefault); - } - - /** Un-check any item(s) that may be checked in any of our inactive adapter(s). */ - public void clearCheckedItemsInInactiveProfiles() { - // TODO: The "inactive" condition is legacy logic. Could we simplify and clear-all? - forEachInactivePage(pageNumber -> { - ListView inactiveListView = getListViewForIndex(pageNumber); - if (inactiveListView.getCheckedItemCount() > 0) { - inactiveListView.setItemChecked(inactiveListView.getCheckedItemPosition(), false); - } - }); - } - - private static class BottomPaddingOverrideSupplier implements Supplier> { - private boolean mUseLayoutWithDefault; - - public void setUseLayoutWithDefault(boolean useLayoutWithDefault) { - mUseLayoutWithDefault = useLayoutWithDefault; - } - - @Override - public Optional get() { - return mUseLayoutWithDefault ? Optional.empty() : Optional.of(0); - } - } -} diff --git a/java/src/com/android/intentresolver/v2/profiles/AdapterBinder.java b/java/src/com/android/intentresolver/v2/profiles/AdapterBinder.java new file mode 100644 index 00000000..c5b35273 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/profiles/AdapterBinder.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2024 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.v2.profiles; + +/** + * Delegate to set up a given adapter and page view to be used together. + * + * @param (as in {@link MultiProfilePagerAdapter}). + * @param (as in {@link MultiProfilePagerAdapter}). + */ +public interface AdapterBinder { + /** + * The given {@code view} will be associated with the given {@code adapter}. Do any work + * necessary to configure them compatibly, introduce them to each other, etc. + */ + void bind(PageViewT view, SinglePageAdapterT adapter); +} diff --git a/java/src/com/android/intentresolver/v2/profiles/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/profiles/ChooserMultiProfilePagerAdapter.java new file mode 100644 index 00000000..0ee9d141 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/profiles/ChooserMultiProfilePagerAdapter.java @@ -0,0 +1,202 @@ +/* + * Copyright (C) 2024 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.v2.profiles; + +import android.content.Context; +import android.os.UserHandle; +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.viewpager.widget.PagerAdapter; + +import com.android.intentresolver.ChooserListAdapter; +import com.android.intentresolver.ChooserRecyclerViewAccessibilityDelegate; +import com.android.intentresolver.FeatureFlags; +import com.android.intentresolver.R; +import com.android.intentresolver.emptystate.EmptyStateProvider; +import com.android.intentresolver.grid.ChooserGridAdapter; +import com.android.intentresolver.measurements.Tracer; + +import com.google.common.collect.ImmutableList; + +import java.util.Optional; +import java.util.function.Supplier; + +/** + * A {@link PagerAdapter} which describes the work and personal profile share sheet screens. + */ +public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< + RecyclerView, ChooserGridAdapter, ChooserListAdapter> { + private static final int SINGLE_CELL_SPAN_SIZE = 1; + + private final ChooserProfileAdapterBinder mAdapterBinder; + private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier; + + public ChooserMultiProfilePagerAdapter( + Context context, + ImmutableList> tabs, + EmptyStateProvider emptyStateProvider, + Supplier workProfileQuietModeChecker, + @ProfileType int defaultProfile, + UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle, + int maxTargetsPerRow, + FeatureFlags featureFlags) { + this( + context, + new ChooserProfileAdapterBinder(maxTargetsPerRow), + tabs, + emptyStateProvider, + workProfileQuietModeChecker, + defaultProfile, + workProfileUserHandle, + cloneProfileUserHandle, + new BottomPaddingOverrideSupplier(context), + featureFlags); + } + + private ChooserMultiProfilePagerAdapter( + Context context, + ChooserProfileAdapterBinder adapterBinder, + ImmutableList> tabs, + EmptyStateProvider emptyStateProvider, + Supplier workProfileQuietModeChecker, + @ProfileType int defaultProfile, + UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle, + BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier, + FeatureFlags featureFlags) { + super( + gridAdapter -> gridAdapter.getListAdapter(), + adapterBinder, + tabs, + emptyStateProvider, + workProfileQuietModeChecker, + defaultProfile, + workProfileUserHandle, + cloneProfileUserHandle, + () -> makeProfileView(context, featureFlags), + bottomPaddingOverrideSupplier); + mAdapterBinder = adapterBinder; + mBottomPaddingOverrideSupplier = bottomPaddingOverrideSupplier; + } + + public void setMaxTargetsPerRow(int maxTargetsPerRow) { + mAdapterBinder.setMaxTargetsPerRow(maxTargetsPerRow); + } + + public void setEmptyStateBottomOffset(int bottomOffset) { + mBottomPaddingOverrideSupplier.setEmptyStateBottomOffset(bottomOffset); + setupContainerPadding(); + } + + /** + * Notify adapter about the drawer's collapse state. This will affect the app divider's + * visibility. + */ + public void setIsCollapsed(boolean isCollapsed) { + for (int i = 0, size = getItemCount(); i < size; i++) { + getPageAdapterForIndex(i).setAzLabelVisibility(!isCollapsed); + } + } + + private static ViewGroup makeProfileView( + Context context, FeatureFlags featureFlags) { + LayoutInflater inflater = LayoutInflater.from(context); + ViewGroup rootView = featureFlags.scrollablePreview() + ? (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile_wrap, null, false) + : (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile, null, false); + RecyclerView recyclerView = rootView.findViewById(com.android.internal.R.id.resolver_list); + recyclerView.setAccessibilityDelegateCompat( + new ChooserRecyclerViewAccessibilityDelegate(recyclerView)); + return rootView; + } + + @Override + public boolean onHandlePackagesChanged( + ChooserListAdapter listAdapter, boolean waitingToEnableWorkProfile) { + // TODO: why do we need to do the extra `notifyDataSetChanged()` in (only) the Chooser case? + getActiveListAdapter().notifyDataSetChanged(); + return super.onHandlePackagesChanged(listAdapter, waitingToEnableWorkProfile); + } + + @Override + protected final boolean rebuildTab(ChooserListAdapter listAdapter, boolean doPostProcessing) { + if (doPostProcessing) { + Tracer.INSTANCE.beginAppTargetLoadingSection(listAdapter.getUserHandle()); + } + return super.rebuildTab(listAdapter, doPostProcessing); + } + + /** Apply the specified {@code height} as the footer in each tab's adapter. */ + public void setFooterHeightInEveryAdapter(int height) { + for (int i = 0; i < getItemCount(); ++i) { + getPageAdapterForIndex(i).setFooterHeight(height); + } + } + + private static class BottomPaddingOverrideSupplier implements Supplier> { + private final Context mContext; + private int mBottomOffset; + + BottomPaddingOverrideSupplier(Context context) { + mContext = context; + } + + public void setEmptyStateBottomOffset(int bottomOffset) { + mBottomOffset = bottomOffset; + } + + @Override + public Optional get() { + int initialBottomPadding = mContext.getResources().getDimensionPixelSize( + R.dimen.resolver_empty_state_container_padding_bottom); + return Optional.of(initialBottomPadding + mBottomOffset); + } + } + + private static class ChooserProfileAdapterBinder implements + AdapterBinder { + private int mMaxTargetsPerRow; + + ChooserProfileAdapterBinder(int maxTargetsPerRow) { + mMaxTargetsPerRow = maxTargetsPerRow; + } + + public void setMaxTargetsPerRow(int maxTargetsPerRow) { + mMaxTargetsPerRow = maxTargetsPerRow; + } + + @Override + public void bind( + RecyclerView recyclerView, ChooserGridAdapter chooserGridAdapter) { + GridLayoutManager glm = (GridLayoutManager) recyclerView.getLayoutManager(); + glm.setSpanCount(mMaxTargetsPerRow); + glm.setSpanSizeLookup( + new GridLayoutManager.SpanSizeLookup() { + @Override + public int getSpanSize(int position) { + return chooserGridAdapter.shouldCellSpan(position) + ? SINGLE_CELL_SPAN_SIZE + : glm.getSpanCount(); + } + }); + } + } +} diff --git a/java/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapter.java new file mode 100644 index 00000000..43785db3 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapter.java @@ -0,0 +1,692 @@ +/* + * Copyright (C) 2024 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.v2.profiles; + +import android.annotation.IntDef; +import android.annotation.Nullable; +import android.os.Trace; +import android.os.UserHandle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TabHost; +import android.widget.TextView; + +import androidx.viewpager.widget.PagerAdapter; +import androidx.viewpager.widget.ViewPager; + +import com.android.intentresolver.ResolverListAdapter; +import com.android.intentresolver.emptystate.EmptyState; +import com.android.intentresolver.emptystate.EmptyStateProvider; +import com.android.internal.annotations.VisibleForTesting; + +import com.google.common.collect.ImmutableList; + +import java.util.HashSet; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * Skeletal {@link PagerAdapter} implementation for a UI with per-profile tabs (as in Sharesheet). + * + * @param the type of the widget that represents the contents of a page in this adapter + * @param the type of a "root" adapter class to be instantiated and included in + * the per-profile records. + * @param the concrete type of a {@link ResolverListAdapter} implementation to + * control the contents of a given per-profile list. This is provided for convenience, since it must + * be possible to get the list adapter from the page adapter via our + * mListAdapterExtractor. + */ +public class MultiProfilePagerAdapter< + PageViewT extends ViewGroup, + SinglePageAdapterT, + ListAdapterT extends ResolverListAdapter> extends PagerAdapter { + + public static final int PROFILE_PERSONAL = 0; + public static final int PROFILE_WORK = 1; + + @IntDef({PROFILE_PERSONAL, PROFILE_WORK}) + public @interface ProfileType {} + + private final Function mListAdapterExtractor; + private final AdapterBinder mAdapterBinder; + private final Supplier mPageViewInflater; + + private final ImmutableList> mItems; + + private final EmptyStateProvider mEmptyStateProvider; + private final UserHandle mWorkProfileUserHandle; + private final UserHandle mCloneProfileUserHandle; + private final Supplier mWorkProfileQuietModeChecker; // True when work is quiet. + + private final Set mLoadedPages; + private int mCurrentPage; + private OnProfileSelectedListener mOnProfileSelectedListener; + + protected MultiProfilePagerAdapter( + Function listAdapterExtractor, + AdapterBinder adapterBinder, + ImmutableList> tabs, + EmptyStateProvider emptyStateProvider, + Supplier workProfileQuietModeChecker, + @ProfileType int defaultProfile, + UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle, + Supplier pageViewInflater, + Supplier> containerBottomPaddingOverrideSupplier) { + mLoadedPages = new HashSet<>(); + mWorkProfileUserHandle = workProfileUserHandle; + mCloneProfileUserHandle = cloneProfileUserHandle; + mEmptyStateProvider = emptyStateProvider; + mWorkProfileQuietModeChecker = workProfileQuietModeChecker; + + mListAdapterExtractor = listAdapterExtractor; + mAdapterBinder = adapterBinder; + mPageViewInflater = pageViewInflater; + + ImmutableList.Builder> items = + new ImmutableList.Builder<>(); + for (TabConfig tab : tabs) { + // TODO: consider representing tabConfig in a different data structure that can ensure + // uniqueness of their profile assignments (while still respecting the client's + // requested tab order). + items.add( + createProfileDescriptor( + tab.mProfile, + tab.mTabLabel, + tab.mTabAccessibilityLabel, + tab.mTabTag, + tab.mPageAdapter, + containerBottomPaddingOverrideSupplier)); + } + mItems = items.build(); + + mCurrentPage = + hasPageForProfile(defaultProfile) ? getPageNumberForProfile(defaultProfile) : 0; + } + + private ProfileDescriptor createProfileDescriptor( + @ProfileType int profile, + String tabLabel, + String tabAccessibilityLabel, + String tabTag, + SinglePageAdapterT adapter, + Supplier> containerBottomPaddingOverrideSupplier) { + return new ProfileDescriptor<>( + profile, + tabLabel, + tabAccessibilityLabel, + tabTag, + mPageViewInflater.get(), + adapter, + containerBottomPaddingOverrideSupplier); + } + + private boolean hasPageForIndex(int pageIndex) { + return (pageIndex >= 0) && (pageIndex < getCount()); + } + + public final boolean hasPageForProfile(@ProfileType int profile) { + return hasPageForIndex(getPageNumberForProfile(profile)); + } + + private @ProfileType int getProfileForPageNumber(int position) { + if (hasPageForIndex(position)) { + return mItems.get(position).mProfile; + } + return -1; + } + + public int getPageNumberForProfile(@ProfileType int profile) { + for (int i = 0; i < mItems.size(); ++i) { + if (profile == mItems.get(i).mProfile) { + return i; + } + } + return -1; + } + + private ListAdapterT getListAdapterForPageNumber(int pageNumber) { + SinglePageAdapterT pageAdapter = getPageAdapterForIndex(pageNumber); + if (pageAdapter == null) { + return null; + } + return mListAdapterExtractor.apply(pageAdapter); + } + + private @ProfileType int getProfileForUserHandle(UserHandle userHandle) { + if (userHandle.equals(getCloneUserHandle())) { + // TODO: can we push this special case elsewhere -- e.g., when we check against each + // list adapter's user handle in the loop below, could we instead ask the list adapter + // whether it "represents" the queried user handle, and have the personal list adapter + // return true because it knows it's also associated with the clone profile? Or if we + // don't want to make modifications to the list adapter, maybe we could at least specify + // it in our per-page configuration data that we use to build our tabs/pages, and then + // maintain the relevant bookkeeping in our own ProfileDescriptor? + return PROFILE_PERSONAL; + } + for (int i = 0; i < mItems.size(); ++i) { + ListAdapterT listAdapter = getListAdapterForPageNumber(i); + if (listAdapter.getUserHandle().equals(userHandle)) { + return mItems.get(i).mProfile; + } + } + return -1; + } + + private int getPageNumberForUserHandle(UserHandle userHandle) { + return getPageNumberForProfile(getProfileForUserHandle(userHandle)); + } + + /** + * Returns the {@link ListAdapterT} instance of the profile that represents + * userHandle. If there is no such adapter for the specified + * userHandle, returns {@code null}. + *

    For example, if there is a work profile on the device with user id 10, calling this method + * with UserHandle.of(10) returns the work profile {@link ListAdapterT}. + */ + @Nullable + public final ListAdapterT getListAdapterForUserHandle(UserHandle userHandle) { + return getListAdapterForPageNumber(getPageNumberForUserHandle(userHandle)); + } + + @Nullable + private ProfileDescriptor getDescriptorForUserHandle( + UserHandle userHandle) { + return getItem(getPageNumberForUserHandle(userHandle)); + } + + private int getPageNumberForTabTag(String tag) { + for (int i = 0; i < mItems.size(); ++i) { + if (Objects.equals(mItems.get(i).mTabTag, tag)) { + return i; + } + } + return -1; + } + + private void updateActiveTabStyle(TabHost tabHost) { + int currentTab = tabHost.getCurrentTab(); + + for (int pageNumber = 0; pageNumber < getItemCount(); ++pageNumber) { + // TODO: can we avoid this downcast by pushing our knowledge of the intended view type + // somewhere else? + TextView tabText = (TextView) tabHost.getTabWidget().getChildAt(pageNumber); + tabText.setSelected(currentTab == pageNumber); + } + } + + public void setupProfileTabs( + LayoutInflater layoutInflater, + TabHost tabHost, + ViewPager viewPager, + int tabButtonLayoutResId, + int tabPageContentViewId, + Runnable onTabChangeListener, + OnProfileSelectedListener clientOnProfileSelectedListener) { + tabHost.setup(); + viewPager.setSaveEnabled(false); + + for (int pageNumber = 0; pageNumber < getItemCount(); ++pageNumber) { + ProfileDescriptor descriptor = mItems.get(pageNumber); + Button profileButton = (Button) layoutInflater.inflate( + tabButtonLayoutResId, tabHost.getTabWidget(), false); + profileButton.setText(descriptor.mTabLabel); + profileButton.setContentDescription(descriptor.mTabAccessibilityLabel); + + TabHost.TabSpec profileTabSpec = tabHost.newTabSpec(descriptor.mTabTag) + .setContent(tabPageContentViewId) + .setIndicator(profileButton); + tabHost.addTab(profileTabSpec); + } + + tabHost.getTabWidget().setVisibility(View.VISIBLE); + + updateActiveTabStyle(tabHost); + + tabHost.setOnTabChangedListener(tabTag -> { + updateActiveTabStyle(tabHost); + + int pageNumber = getPageNumberForTabTag(tabTag); + if (pageNumber >= 0) { + viewPager.setCurrentItem(pageNumber); + } + onTabChangeListener.run(); + }); + + viewPager.setVisibility(View.VISIBLE); + tabHost.setCurrentTab(getCurrentPage()); + mOnProfileSelectedListener = + new OnProfileSelectedListener() { + @Override + public void onProfilePageSelected(@ProfileType int profileId, int pageNumber) { + tabHost.setCurrentTab(pageNumber); + clientOnProfileSelectedListener.onProfilePageSelected( + profileId, pageNumber); + } + + @Override + public void onProfilePageStateChanged(int state) { + clientOnProfileSelectedListener.onProfilePageStateChanged(state); + } + }; + } + + /** + * Sets this instance of this class as {@link ViewPager}'s {@link PagerAdapter} and sets + * an {@link ViewPager.OnPageChangeListener} where it keeps track of the currently displayed + * page and rebuilds the list. + */ + public void setupViewPager(ViewPager viewPager) { + viewPager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() { + @Override + public void onPageSelected(int position) { + mCurrentPage = position; + if (!mLoadedPages.contains(position)) { + rebuildActiveTab(true); + mLoadedPages.add(position); + } + if (mOnProfileSelectedListener != null) { + mOnProfileSelectedListener.onProfilePageSelected( + getProfileForPageNumber(position), position); + } + } + + @Override + public void onPageScrollStateChanged(int state) { + if (mOnProfileSelectedListener != null) { + mOnProfileSelectedListener.onProfilePageStateChanged(state); + } + } + }); + viewPager.setAdapter(this); + viewPager.setCurrentItem(mCurrentPage); + mLoadedPages.add(mCurrentPage); + } + + public void clearInactiveProfileCache() { + forEachInactivePage(pageNumber -> mLoadedPages.remove(pageNumber)); + } + + @Override + public final ViewGroup instantiateItem(ViewGroup container, int position) { + setupListAdapter(position); + final ProfileDescriptor descriptor = getItem(position); + container.addView(descriptor.mRootView); + return descriptor.mRootView; + } + + @Override + public void destroyItem(ViewGroup container, int position, Object view) { + container.removeView((View) view); + } + + @Override + public int getCount() { + return getItemCount(); + } + + public int getCurrentPage() { + return mCurrentPage; + } + + public final @ProfileType int getActiveProfile() { + return getProfileForPageNumber(getCurrentPage()); + } + + @VisibleForTesting + public UserHandle getCurrentUserHandle() { + return getActiveListAdapter().getUserHandle(); + } + + @Override + public boolean isViewFromObject(View view, Object object) { + return view == object; + } + + @Override + public CharSequence getPageTitle(int position) { + return null; + } + + public UserHandle getCloneUserHandle() { + return mCloneProfileUserHandle; + } + + /** + * Returns the {@link ProfileDescriptor} relevant to the given pageIndex. + *

      + *
    • For a device with only one user, pageIndex value of + * 0 would return the personal profile {@link ProfileDescriptor}.
    • + *
    • For a device with a work profile, pageIndex value of 0 would + * return the personal profile {@link ProfileDescriptor}, and pageIndex value of + * 1 would return the work profile {@link ProfileDescriptor}.
    • + *
    + */ + @Nullable + private ProfileDescriptor getItem(int pageIndex) { + if (!hasPageForIndex(pageIndex)) { + return null; + } + return mItems.get(pageIndex); + } + + private ViewGroup getEmptyStateView(int pageIndex) { + return getItem(pageIndex).getEmptyStateView(); + } + + public ViewGroup getActiveEmptyStateView() { + return getEmptyStateView(getCurrentPage()); + } + + /** + * Returns the number of {@link ProfileDescriptor} objects. + *

    For a normal consumer device with only one user returns 1. + *

    For a device with a work profile returns 2. + */ + public final int getItemCount() { + return mItems.size(); + } + + public final PageViewT getListViewForIndex(int index) { + return getItem(index).getView(); + } + + /** + * Returns the adapter of the list view for the relevant page specified by + * pageIndex. + *

    This method is meant to be implemented with an implementation-specific return type + * depending on the adapter type. + */ + @VisibleForTesting + public final SinglePageAdapterT getPageAdapterForIndex(int index) { + if (!hasPageForIndex(index)) { + return null; + } + return getItem(index).getAdapter(); + } + + /** + * Performs view-related initialization procedures for the adapter specified + * by pageIndex. + */ + public final void setupListAdapter(int pageIndex) { + mAdapterBinder.bind(getListViewForIndex(pageIndex), getPageAdapterForIndex(pageIndex)); + } + + /** + * Returns the {@link ListAdapterT} instance of the profile that is currently visible + * to the user. + *

    For example, if the user is viewing the work tab in the share sheet, this method returns + * the work profile {@link ListAdapterT}. + */ + @VisibleForTesting + public final ListAdapterT getActiveListAdapter() { + return getListAdapterForPageNumber(getCurrentPage()); + } + + public final ListAdapterT getPersonalListAdapter() { + return getListAdapterForPageNumber(getPageNumberForProfile(PROFILE_PERSONAL)); + } + + @Nullable + public final ListAdapterT getWorkListAdapter() { + if (!hasPageForProfile(PROFILE_WORK)) { + return null; + } + return getListAdapterForPageNumber(getPageNumberForProfile(PROFILE_WORK)); + } + + public final SinglePageAdapterT getCurrentRootAdapter() { + return getPageAdapterForIndex(getCurrentPage()); + } + + public final PageViewT getActiveAdapterView() { + return getListViewForIndex(getCurrentPage()); + } + + private boolean anyAdapterHasItems() { + for (int i = 0; i < mItems.size(); ++i) { + ListAdapterT listAdapter = getListAdapterForPageNumber(i); + if (listAdapter.getCount() > 0) { + return true; + } + } + return false; + } + + public void refreshPackagesInAllTabs() { + // TODO: it's unclear if this legacy logic really requires the active tab to be rebuilt + // first, or if we could just iterate over the tabs in arbitrary order. + getActiveListAdapter().handlePackagesChanged(); + forEachInactivePage(page -> getListAdapterForPageNumber(page).handlePackagesChanged()); + } + + /** + * Notify that there has been a package change which could potentially modify the set of targets + * that should be shown in the specified {@code listAdapter}. This may result in + * "rebuilding" the target list for that adapter. + * + * @param listAdapter an adapter that may need to be updated after the package-change event. + * @param waitingToEnableWorkProfile whether we've turned on the work profile, but haven't yet + * seen an {@code ACTION_USER_UNLOCKED} broadcast. In this case we skip the rebuild of any + * work-profile adapter because we wouldn't expect meaningful results -- but another rebuild + * will be prompted when we eventually get the broadcast. + * + * @return whether we're able to proceed with a Sharesheet session after processing this + * package-change event. If false, we were able to rebuild the targets but determined that there + * aren't any we could present in the UI without the app looking broken, so we should just quit. + */ + public boolean onHandlePackagesChanged( + ListAdapterT listAdapter, boolean waitingToEnableWorkProfile) { + if (listAdapter == getActiveListAdapter()) { + if (listAdapter.getUserHandle().equals(mWorkProfileUserHandle) + && waitingToEnableWorkProfile) { + // We have just turned on the work profile and entered the passcode to start it, + // now we are waiting to receive the ACTION_USER_UNLOCKED broadcast. There is no + // point in reloading the list now, since the work profile user is still turning on. + return true; + } + + boolean listRebuilt = rebuildActiveTab(true); + if (listRebuilt) { + listAdapter.notifyDataSetChanged(); + } + + // TODO: shouldn't we check that the inactive tabs are built before declaring that we + // have to quit for lack of items? + return anyAdapterHasItems(); + } else { + clearInactiveProfileCache(); + return true; + } + } + + /** + * Fully-rebuild the active tab and, if specified, partially-rebuild any other inactive tabs. + */ + public boolean rebuildTabs(boolean includePartialRebuildOfInactiveTabs) { + // TODO: we may be able to determine `includePartialRebuildOfInactiveTabs` ourselves as + // a function of our own instance state. OTOH the purpose of this "partial rebuild" is to + // be able to evaluate the intermediate state of one particular profile tab (i.e. work + // profile) that may not generalize well when we have other "inactive tabs." I.e., either we + // rebuild *all* the inactive tabs just to evaluate some auto-launch conditions that only + // depend on personal and/or work tabs, or we have to explicitly specify the ones we care + // about. It's not the pager-adapter's business to know "which ones we care about," so maybe + // they should be rebuilt lazily when-and-if it comes up (e.g. during the evaluation of + // autolaunch conditions). + boolean rebuildCompleted = rebuildActiveTab(true) || getActiveListAdapter().isTabLoaded(); + if (includePartialRebuildOfInactiveTabs) { + // Per legacy logic, avoid short-circuiting (TODO: why? possibly so that we *start* + // loading the inactive tabs even if we're still waiting on the active tab to finish?). + boolean completedRebuildingInactiveTabs = rebuildInactiveTabs(false); + rebuildCompleted = rebuildCompleted && completedRebuildingInactiveTabs; + } + return rebuildCompleted; + } + + /** + * Rebuilds the tab that is currently visible to the user. + *

    Returns {@code true} if rebuild has completed. + */ + public final boolean rebuildActiveTab(boolean doPostProcessing) { + Trace.beginSection("MultiProfilePagerAdapter#rebuildActiveTab"); + boolean result = rebuildTab(getActiveListAdapter(), doPostProcessing); + Trace.endSection(); + return result; + } + + /** + * Rebuilds any tabs that are not currently visible to the user. + *

    Returns {@code true} if rebuild has completed in all inactive tabs. + */ + private boolean rebuildInactiveTabs(boolean doPostProcessing) { + Trace.beginSection("MultiProfilePagerAdapter#rebuildInactiveTab"); + AtomicBoolean allRebuildsComplete = new AtomicBoolean(true); + forEachInactivePage(pageNumber -> { + // Evaluate the rebuild for every inactive page, even if we've already seen some adapter + // return an "incomplete" status (i.e., even if `allRebuildsComplete` is already false) + // and so we already know we'll end up returning false for the batch. + // TODO: any particular reason the per-page legacy logic was set up in this order, or + // could we possibly short-circuit the rebuild if the tab is already "loaded"? + ListAdapterT inactiveAdapter = getListAdapterForPageNumber(pageNumber); + boolean rebuildInactivePageCompleted = + rebuildTab(inactiveAdapter, doPostProcessing) || inactiveAdapter.isTabLoaded(); + if (!rebuildInactivePageCompleted) { + allRebuildsComplete.set(false); + } + }); + Trace.endSection(); + return allRebuildsComplete.get(); + } + + protected void forEachPage(Consumer pageNumberHandler) { + for (int pageNumber = 0; pageNumber < getItemCount(); ++pageNumber) { + pageNumberHandler.accept(pageNumber); + } + } + + protected void forEachInactivePage(Consumer inactivePageNumberHandler) { + forEachPage(pageNumber -> { + if (pageNumber != getCurrentPage()) { + inactivePageNumberHandler.accept(pageNumber); + } + }); + } + + protected boolean rebuildTab(ListAdapterT activeListAdapter, boolean doPostProcessing) { + if (shouldSkipRebuild(activeListAdapter)) { + activeListAdapter.postListReadyRunnable(doPostProcessing, /* rebuildCompleted */ true); + return false; + } + return activeListAdapter.rebuildList(doPostProcessing); + } + + private boolean shouldSkipRebuild(ListAdapterT activeListAdapter) { + EmptyState emptyState = mEmptyStateProvider.getEmptyState(activeListAdapter); + return emptyState != null && emptyState.shouldSkipDataRebuild(); + } + + /** + * The empty state screens are shown according to their priority: + *

      + *
    1. (highest priority) cross-profile disabled by policy (handled in + * {@link #rebuildTab(ListAdapterT, boolean)})
    2. + *
    3. no apps available
    4. + *
    5. (least priority) work is off
    6. + *
    + * + * The intention is to prevent the user from having to turn + * the work profile on if there will not be any apps resolved + * anyway. + * + * TODO: move this comment to the place where we configure our composite provider. + */ + public void showEmptyResolverListEmptyState(ListAdapterT listAdapter) { + final EmptyState emptyState = mEmptyStateProvider.getEmptyState(listAdapter); + + if (emptyState == null) { + return; + } + + emptyState.onEmptyStateShown(); + + View.OnClickListener clickListener = null; + + if (emptyState.getButtonClickListener() != null) { + clickListener = v -> emptyState.getButtonClickListener().onClick(() -> { + ProfileDescriptor descriptor = + getDescriptorForUserHandle(listAdapter.getUserHandle()); + descriptor.mEmptyStateUi.showSpinner(); + }); + } + + showEmptyState(listAdapter, emptyState, clickListener); + } + + private void showEmptyState( + ListAdapterT activeListAdapter, + EmptyState emptyState, + View.OnClickListener buttonOnClick) { + ProfileDescriptor descriptor = + getDescriptorForUserHandle(activeListAdapter.getUserHandle()); + descriptor.mEmptyStateUi.showEmptyState(emptyState, buttonOnClick); + activeListAdapter.markTabLoaded(); + } + + /** + * Sets up the padding of the view containing the empty state screens for the current adapter + * view. + */ + protected final void setupContainerPadding() { + getItem(getCurrentPage()).setupContainerPadding(); + } + + public void showListView(ListAdapterT activeListAdapter) { + ProfileDescriptor descriptor = + getDescriptorForUserHandle(activeListAdapter.getUserHandle()); + descriptor.mEmptyStateUi.hide(); + } + + /** + * @return whether any "inactive" tab's adapter would show an empty-state screen in our current + * application state. + */ + public final boolean shouldShowEmptyStateScreenInAnyInactiveAdapter() { + AtomicBoolean anyEmpty = new AtomicBoolean(false); + // TODO: The "inactive" condition is legacy logic. Could we simplify and ask "any"? + forEachInactivePage(pageNumber -> { + if (shouldShowEmptyStateScreen(getListAdapterForPageNumber(pageNumber))) { + anyEmpty.set(true); + } + }); + return anyEmpty.get(); + } + + public boolean shouldShowEmptyStateScreen(ListAdapterT listAdapter) { + int count = listAdapter.getUnfilteredCount(); + return (count == 0 && listAdapter.getPlaceholderCount() == 0) + || (listAdapter.getUserHandle().equals(mWorkProfileUserHandle) + && mWorkProfileQuietModeChecker.get()); + } + +} diff --git a/java/src/com/android/intentresolver/v2/profiles/OnProfileSelectedListener.java b/java/src/com/android/intentresolver/v2/profiles/OnProfileSelectedListener.java new file mode 100644 index 00000000..7bdbec4c --- /dev/null +++ b/java/src/com/android/intentresolver/v2/profiles/OnProfileSelectedListener.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2024 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.v2.profiles; + +import androidx.viewpager.widget.ViewPager; + +/** Listener interface for changes between the per-profile UI tabs. */ +public interface OnProfileSelectedListener { + /** + * Callback for when the user changes the active tab. + *

    This callback is only called when the intent resolver or share sheet shows + * more than one profile. + * + * @param profileId the ID of the newly-selected profile, e.g. {@link #PROFILE_PERSONAL} + * if the personal profile tab was selected or {@link #PROFILE_WORK} if the + * work profile tab + * was selected. + */ + void onProfilePageSelected(@MultiProfilePagerAdapter.ProfileType int profileId, int pageNumber); + + + /** + * Callback for when the scroll state changes. Useful for discovering when the user begins + * dragging, when the pager is automatically settling to the current page, or when it is + * fully stopped/idle. + * + * @param state {@link ViewPager#SCROLL_STATE_IDLE}, {@link ViewPager#SCROLL_STATE_DRAGGING} + * or {@link ViewPager#SCROLL_STATE_SETTLING} + * @see ViewPager.OnPageChangeListener#onPageScrollStateChanged + */ + void onProfilePageStateChanged(int state); +} diff --git a/java/src/com/android/intentresolver/v2/profiles/OnSwitchOnWorkSelectedListener.java b/java/src/com/android/intentresolver/v2/profiles/OnSwitchOnWorkSelectedListener.java new file mode 100644 index 00000000..3dbbd4d0 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/profiles/OnSwitchOnWorkSelectedListener.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2024 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.v2.profiles; + +/** + * Listener for when the user switches on the work profile from the work tab. + */ +public interface OnSwitchOnWorkSelectedListener { + /** + * Callback for when the user switches on the work profile from the work tab. + */ + void onSwitchOnWorkSelected(); +} diff --git a/java/src/com/android/intentresolver/v2/profiles/ProfileDescriptor.java b/java/src/com/android/intentresolver/v2/profiles/ProfileDescriptor.java new file mode 100644 index 00000000..e2e9c19d --- /dev/null +++ b/java/src/com/android/intentresolver/v2/profiles/ProfileDescriptor.java @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2024 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.v2.profiles; + +import android.view.ViewGroup; + +import com.android.intentresolver.v2.emptystate.EmptyStateUiHelper; + +import java.util.Optional; +import java.util.function.Supplier; + +// 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)? +class ProfileDescriptor { + final @MultiProfilePagerAdapter.ProfileType int mProfile; + final String mTabLabel; + final String mTabAccessibilityLabel; + final String mTabTag; + + final ViewGroup mRootView; + final EmptyStateUiHelper mEmptyStateUi; + + // TODO: post-refactoring, we may not need to retain these ivars directly (since they may + // be encapsulated within the `EmptyStateUiHelper`?). + private final ViewGroup mEmptyStateView; + + private final SinglePageAdapterT mAdapter; + + public SinglePageAdapterT getAdapter() { + return mAdapter; + } + + public PageViewT getView() { + return mView; + } + + private final PageViewT mView; + + ProfileDescriptor( + @MultiProfilePagerAdapter.ProfileType int forProfile, + String tabLabel, + String tabAccessibilityLabel, + String tabTag, + ViewGroup rootView, + SinglePageAdapterT adapter, + Supplier> containerBottomPaddingOverrideSupplier) { + mProfile = forProfile; + mTabLabel = tabLabel; + mTabAccessibilityLabel = tabAccessibilityLabel; + mTabTag = tabTag; + mRootView = rootView; + mAdapter = adapter; + mEmptyStateView = rootView.findViewById(com.android.internal.R.id.resolver_empty_state); + mView = (PageViewT) rootView.findViewById(com.android.internal.R.id.resolver_list); + mEmptyStateUi = new EmptyStateUiHelper( + rootView, + com.android.internal.R.id.resolver_list, + containerBottomPaddingOverrideSupplier); + } + + protected ViewGroup getEmptyStateView() { + return mEmptyStateView; + } + + public void setupContainerPadding() { + mEmptyStateUi.setupContainerPadding(); + } +} diff --git a/java/src/com/android/intentresolver/v2/profiles/ResolverMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/profiles/ResolverMultiProfilePagerAdapter.java new file mode 100644 index 00000000..e44cf8da --- /dev/null +++ b/java/src/com/android/intentresolver/v2/profiles/ResolverMultiProfilePagerAdapter.java @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2024 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.v2.profiles; + +import android.content.Context; +import android.os.UserHandle; +import android.view.LayoutInflater; +import android.view.ViewGroup; +import android.widget.ListView; + +import androidx.viewpager.widget.PagerAdapter; + +import com.android.intentresolver.R; +import com.android.intentresolver.ResolverListAdapter; +import com.android.intentresolver.emptystate.EmptyStateProvider; + +import com.google.common.collect.ImmutableList; + +import java.util.Optional; +import java.util.function.Supplier; + +/** + * A {@link PagerAdapter} which describes the work and personal profile intent resolver screens. + */ +public class ResolverMultiProfilePagerAdapter extends + MultiProfilePagerAdapter { + private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier; + + public ResolverMultiProfilePagerAdapter(Context context, + ImmutableList> tabs, + EmptyStateProvider emptyStateProvider, + Supplier workProfileQuietModeChecker, + @ProfileType int defaultProfile, + UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle) { + this( + context, + tabs, + emptyStateProvider, + workProfileQuietModeChecker, + defaultProfile, + workProfileUserHandle, + cloneProfileUserHandle, + new BottomPaddingOverrideSupplier()); + } + + private ResolverMultiProfilePagerAdapter( + Context context, + ImmutableList> tabs, + EmptyStateProvider emptyStateProvider, + Supplier workProfileQuietModeChecker, + @ProfileType int defaultProfile, + UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle, + BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) { + super( + listAdapter -> listAdapter, + (listView, bindAdapter) -> listView.setAdapter(bindAdapter), + tabs, + emptyStateProvider, + workProfileQuietModeChecker, + defaultProfile, + workProfileUserHandle, + cloneProfileUserHandle, + () -> (ViewGroup) LayoutInflater.from(context).inflate( + R.layout.resolver_list_per_profile, null, false), + bottomPaddingOverrideSupplier); + mBottomPaddingOverrideSupplier = bottomPaddingOverrideSupplier; + } + + public void setUseLayoutWithDefault(boolean useLayoutWithDefault) { + mBottomPaddingOverrideSupplier.setUseLayoutWithDefault(useLayoutWithDefault); + } + + /** Un-check any item(s) that may be checked in any of our inactive adapter(s). */ + public void clearCheckedItemsInInactiveProfiles() { + // TODO: The "inactive" condition is legacy logic. Could we simplify and clear-all? + forEachInactivePage(pageNumber -> { + ListView inactiveListView = getListViewForIndex(pageNumber); + if (inactiveListView.getCheckedItemCount() > 0) { + inactiveListView.setItemChecked(inactiveListView.getCheckedItemPosition(), false); + } + }); + } + + private static class BottomPaddingOverrideSupplier implements Supplier> { + private boolean mUseLayoutWithDefault; + + public void setUseLayoutWithDefault(boolean useLayoutWithDefault) { + mUseLayoutWithDefault = useLayoutWithDefault; + } + + @Override + public Optional get() { + return mUseLayoutWithDefault ? Optional.empty() : Optional.of(0); + } + } +} diff --git a/java/src/com/android/intentresolver/v2/profiles/TabConfig.java b/java/src/com/android/intentresolver/v2/profiles/TabConfig.java new file mode 100644 index 00000000..994f8aff --- /dev/null +++ b/java/src/com/android/intentresolver/v2/profiles/TabConfig.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2024 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.v2.profiles; + +public class TabConfig { + final @MultiProfilePagerAdapter.ProfileType int mProfile; + final String mTabLabel; + final String mTabAccessibilityLabel; + final String mTabTag; + final PageAdapterT mPageAdapter; + + public TabConfig( + @MultiProfilePagerAdapter.ProfileType int profile, + String tabLabel, + String tabAccessibilityLabel, + String tabTag, + PageAdapterT pageAdapter) { + mProfile = profile; + mTabLabel = tabLabel; + mTabAccessibilityLabel = tabAccessibilityLabel; + mTabTag = tabTag; + mPageAdapter = pageAdapter; + } +} diff --git a/tests/unit/src/com/android/intentresolver/v2/MultiProfilePagerAdapterTest.kt b/tests/unit/src/com/android/intentresolver/v2/MultiProfilePagerAdapterTest.kt deleted file mode 100644 index 8e5f00ac..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/MultiProfilePagerAdapterTest.kt +++ /dev/null @@ -1,343 +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.v2 - -import android.os.UserHandle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ListView -import androidx.test.platform.app.InstrumentationRegistry -import com.android.intentresolver.MultiProfilePagerAdapter.PROFILE_PERSONAL -import com.android.intentresolver.MultiProfilePagerAdapter.PROFILE_WORK -import com.android.intentresolver.R -import com.android.intentresolver.ResolverListAdapter -import com.android.intentresolver.emptystate.EmptyStateProvider -import com.android.intentresolver.mock -import com.android.intentresolver.v2.MultiProfilePagerAdapter.TabConfig -import com.android.intentresolver.whenever -import com.google.common.collect.ImmutableList -import com.google.common.truth.Truth.assertThat -import java.util.Optional -import java.util.function.Supplier -import org.junit.Test - -class MultiProfilePagerAdapterTest { - private val PERSONAL_USER_HANDLE = UserHandle.of(10) - private val WORK_USER_HANDLE = UserHandle.of(20) - - private val context = InstrumentationRegistry.getInstrumentation().getContext() - private val inflater = Supplier { - LayoutInflater.from(context).inflate(R.layout.resolver_list_per_profile, null, false) - as ViewGroup - } - - @Test - fun testSinglePageProfileAdapter() { - val personalListAdapter = - mock { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } - val pagerAdapter = - MultiProfilePagerAdapter( - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of( - TabConfig( - PROFILE_PERSONAL, - "personal", - "personal_a11y", - "TAG_PERSONAL", - personalListAdapter - ) - ), - object : EmptyStateProvider {}, - { false }, - PROFILE_PERSONAL, - null, - null, - inflater, - { Optional.empty() } - ) - assertThat(pagerAdapter.count).isEqualTo(1) - assertThat(pagerAdapter.currentPage).isEqualTo(PROFILE_PERSONAL) - assertThat(pagerAdapter.currentUserHandle).isEqualTo(PERSONAL_USER_HANDLE) - assertThat(pagerAdapter.getPageAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.workListAdapter).isNull() - assertThat(pagerAdapter.itemCount).isEqualTo(1) - // TODO: consider covering some of the package-private methods (and making them public?). - // TODO: consider exercising responsibilities as an implementation of a ViewPager adapter. - } - - @Test - fun testTwoProfilePagerAdapter() { - val personalListAdapter = - mock { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } - val workListAdapter = - mock { whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) } - val pagerAdapter = - MultiProfilePagerAdapter( - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of( - TabConfig( - PROFILE_PERSONAL, - "personal", - "personal_a11y", - "TAG_PERSONAL", - personalListAdapter - ), - TabConfig(PROFILE_WORK, "work", "work_a11y", "TAG_WORK", workListAdapter) - ), - object : EmptyStateProvider {}, - { false }, - PROFILE_PERSONAL, - WORK_USER_HANDLE, // TODO: why does this test pass even if this is null? - null, - inflater, - { Optional.empty() } - ) - assertThat(pagerAdapter.count).isEqualTo(2) - assertThat(pagerAdapter.currentPage).isEqualTo(PROFILE_PERSONAL) - assertThat(pagerAdapter.currentUserHandle).isEqualTo(PERSONAL_USER_HANDLE) - assertThat(pagerAdapter.getPageAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.getPageAdapterForIndex(1)).isSameInstanceAs(workListAdapter) - assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.workListAdapter).isSameInstanceAs(workListAdapter) - assertThat(pagerAdapter.itemCount).isEqualTo(2) - // TODO: consider covering some of the package-private methods (and making them public?). - // TODO: consider exercising responsibilities as an implementation of a ViewPager adapter; - // especially matching profiles to ListViews? - // TODO: test ProfileSelectedListener (and getters for "current" state) as the selected - // page changes. Currently there's no API to change the selected page directly; that's - // only possible through manipulation of the bound ViewPager. - } - - @Test - fun testTwoProfilePagerAdapter_workIsDefault() { - val personalListAdapter = - mock { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } - val workListAdapter = - mock { whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) } - val pagerAdapter = - MultiProfilePagerAdapter( - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of( - TabConfig( - PROFILE_PERSONAL, - "personal", - "personal_a11y", - "TAG_PERSONAL", - personalListAdapter - ), - TabConfig(PROFILE_WORK, "work", "work_a11y", "TAG_WORK", workListAdapter) - ), - object : EmptyStateProvider {}, - { false }, - PROFILE_WORK, // <-- This test specifically requests we start on work profile. - WORK_USER_HANDLE, // TODO: why does this test pass even if this is null? - null, - inflater, - { Optional.empty() } - ) - assertThat(pagerAdapter.count).isEqualTo(2) - assertThat(pagerAdapter.currentPage).isEqualTo(PROFILE_WORK) - assertThat(pagerAdapter.currentUserHandle).isEqualTo(WORK_USER_HANDLE) - assertThat(pagerAdapter.getPageAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.getPageAdapterForIndex(1)).isSameInstanceAs(workListAdapter) - assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(workListAdapter) - assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.workListAdapter).isSameInstanceAs(workListAdapter) - assertThat(pagerAdapter.itemCount).isEqualTo(2) - // TODO: consider covering some of the package-private methods (and making them public?). - // TODO: test ProfileSelectedListener (and getters for "current" state) as the selected - // page changes. Currently there's no API to change the selected page directly; that's - // only possible through manipulation of the bound ViewPager. - } - - @Test - fun testBottomPaddingDelegate_default() { - val personalListAdapter = - mock { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } - val pagerAdapter = - MultiProfilePagerAdapter( - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of( - TabConfig( - PROFILE_PERSONAL, - "personal", - "personal_a11y", - "TAG_PERSONAL", - personalListAdapter - ) - ), - object : EmptyStateProvider {}, - { false }, - PROFILE_PERSONAL, - null, - null, - inflater, - { Optional.empty() } - ) - val container = - pagerAdapter - .getActiveEmptyStateView() - .requireViewById(com.android.internal.R.id.resolver_empty_state_container) - container.setPadding(1, 2, 3, 4) - pagerAdapter.setupContainerPadding() - assertThat(container.paddingLeft).isEqualTo(1) - assertThat(container.paddingTop).isEqualTo(2) - assertThat(container.paddingRight).isEqualTo(3) - assertThat(container.paddingBottom).isEqualTo(4) - } - - @Test - fun testBottomPaddingDelegate_override() { - val personalListAdapter = - mock { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } - val pagerAdapter = - MultiProfilePagerAdapter( - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of( - TabConfig( - PROFILE_PERSONAL, - "personal", - "personal_a11y", - "TAG_PERSONAL", - personalListAdapter - ) - ), - object : EmptyStateProvider {}, - { false }, - PROFILE_PERSONAL, - null, - null, - inflater, - { Optional.of(42) } - ) - val container = - pagerAdapter - .getActiveEmptyStateView() - .requireViewById(com.android.internal.R.id.resolver_empty_state_container) - container.setPadding(1, 2, 3, 4) - pagerAdapter.setupContainerPadding() - assertThat(container.paddingLeft).isEqualTo(1) - assertThat(container.paddingTop).isEqualTo(2) - assertThat(container.paddingRight).isEqualTo(3) - assertThat(container.paddingBottom).isEqualTo(42) - } - - @Test - fun testPresumedQuietModeEmptyStateForWorkProfile_whenQuiet() { - // TODO: this is "presumed" because the conditions to determine whether we "should" show an - // empty state aren't enforced to align with the conditions when we actually *would* -- I - // believe `shouldShowEmptyStateScreen` should be implemented in terms of the provider? - val personalListAdapter = - mock { - whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) - whenever(getUnfilteredCount()).thenReturn(1) - } - val workListAdapter = - mock { - whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) - whenever(getUnfilteredCount()).thenReturn(1) - } - val pagerAdapter = - MultiProfilePagerAdapter( - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of( - TabConfig( - PROFILE_PERSONAL, - "personal", - "personal_a11y", - "TAG_PERSONAL", - personalListAdapter - ), - TabConfig(PROFILE_WORK, "work", "work_a11y", "TAG_WORK", workListAdapter) - ), - object : EmptyStateProvider {}, - { true }, // <-- Work mode is quiet. - PROFILE_WORK, - WORK_USER_HANDLE, - null, - inflater, - { Optional.empty() } - ) - assertThat(pagerAdapter.shouldShowEmptyStateScreen(workListAdapter)).isTrue() - assertThat(pagerAdapter.shouldShowEmptyStateScreen(personalListAdapter)).isFalse() - } - - @Test - fun testPresumedQuietModeEmptyStateForWorkProfile_notWhenNotQuiet() { - // TODO: this is "presumed" because the conditions to determine whether we "should" show an - // empty state aren't enforced to align with the conditions when we actually *would* -- I - // believe `shouldShowEmptyStateScreen` should be implemented in terms of the provider? - val personalListAdapter = - mock { - whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) - whenever(getUnfilteredCount()).thenReturn(1) - } - val workListAdapter = - mock { - whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) - whenever(getUnfilteredCount()).thenReturn(1) - } - val pagerAdapter = - MultiProfilePagerAdapter( - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of( - TabConfig( - PROFILE_PERSONAL, - "personal", - "personal_a11y", - "TAG_PERSONAL", - personalListAdapter - ), - TabConfig(PROFILE_WORK, "work", "work_a11y", "TAG_WORK", workListAdapter) - ), - object : EmptyStateProvider {}, - { false }, // <-- Work mode is not quiet. - PROFILE_WORK, - WORK_USER_HANDLE, - null, - inflater, - { Optional.empty() } - ) - assertThat(pagerAdapter.shouldShowEmptyStateScreen(workListAdapter)).isFalse() - assertThat(pagerAdapter.shouldShowEmptyStateScreen(personalListAdapter)).isFalse() - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapterTest.kt b/tests/unit/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapterTest.kt new file mode 100644 index 00000000..5b6b5d99 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapterTest.kt @@ -0,0 +1,342 @@ +/* + * Copyright (C) 2024 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.v2.profiles + +import android.os.UserHandle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ListView +import androidx.test.platform.app.InstrumentationRegistry +import com.android.intentresolver.MultiProfilePagerAdapter.PROFILE_PERSONAL +import com.android.intentresolver.MultiProfilePagerAdapter.PROFILE_WORK +import com.android.intentresolver.R +import com.android.intentresolver.ResolverListAdapter +import com.android.intentresolver.emptystate.EmptyStateProvider +import com.android.intentresolver.mock +import com.android.intentresolver.whenever +import com.google.common.collect.ImmutableList +import com.google.common.truth.Truth.assertThat +import java.util.Optional +import java.util.function.Supplier +import org.junit.Test + +class MultiProfilePagerAdapterTest { + private val PERSONAL_USER_HANDLE = UserHandle.of(10) + private val WORK_USER_HANDLE = UserHandle.of(20) + + private val context = InstrumentationRegistry.getInstrumentation().getContext() + private val inflater = Supplier { + LayoutInflater.from(context).inflate(R.layout.resolver_list_per_profile, null, false) + as ViewGroup + } + + @Test + fun testSinglePageProfileAdapter() { + val personalListAdapter = + mock { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } + val pagerAdapter = + MultiProfilePagerAdapter( + { listAdapter: ResolverListAdapter -> listAdapter }, + { listView: ListView, bindAdapter: ResolverListAdapter -> + listView.setAdapter(bindAdapter) + }, + ImmutableList.of( + TabConfig( + PROFILE_PERSONAL, + "personal", + "personal_a11y", + "TAG_PERSONAL", + personalListAdapter + ) + ), + object : EmptyStateProvider {}, + { false }, + PROFILE_PERSONAL, + null, + null, + inflater, + { Optional.empty() } + ) + assertThat(pagerAdapter.count).isEqualTo(1) + assertThat(pagerAdapter.currentPage).isEqualTo(PROFILE_PERSONAL) + assertThat(pagerAdapter.currentUserHandle).isEqualTo(PERSONAL_USER_HANDLE) + assertThat(pagerAdapter.getPageAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.workListAdapter).isNull() + assertThat(pagerAdapter.itemCount).isEqualTo(1) + // TODO: consider covering some of the package-private methods (and making them public?). + // TODO: consider exercising responsibilities as an implementation of a ViewPager adapter. + } + + @Test + fun testTwoProfilePagerAdapter() { + val personalListAdapter = + mock { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } + val workListAdapter = + mock { whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) } + val pagerAdapter = + MultiProfilePagerAdapter( + { listAdapter: ResolverListAdapter -> listAdapter }, + { listView: ListView, bindAdapter: ResolverListAdapter -> + listView.setAdapter(bindAdapter) + }, + ImmutableList.of( + TabConfig( + PROFILE_PERSONAL, + "personal", + "personal_a11y", + "TAG_PERSONAL", + personalListAdapter + ), + TabConfig(PROFILE_WORK, "work", "work_a11y", "TAG_WORK", workListAdapter) + ), + object : EmptyStateProvider {}, + { false }, + PROFILE_PERSONAL, + WORK_USER_HANDLE, // TODO: why does this test pass even if this is null? + null, + inflater, + { Optional.empty() } + ) + assertThat(pagerAdapter.count).isEqualTo(2) + assertThat(pagerAdapter.currentPage).isEqualTo(PROFILE_PERSONAL) + assertThat(pagerAdapter.currentUserHandle).isEqualTo(PERSONAL_USER_HANDLE) + assertThat(pagerAdapter.getPageAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.getPageAdapterForIndex(1)).isSameInstanceAs(workListAdapter) + assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.workListAdapter).isSameInstanceAs(workListAdapter) + assertThat(pagerAdapter.itemCount).isEqualTo(2) + // TODO: consider covering some of the package-private methods (and making them public?). + // TODO: consider exercising responsibilities as an implementation of a ViewPager adapter; + // especially matching profiles to ListViews? + // TODO: test ProfileSelectedListener (and getters for "current" state) as the selected + // page changes. Currently there's no API to change the selected page directly; that's + // only possible through manipulation of the bound ViewPager. + } + + @Test + fun testTwoProfilePagerAdapter_workIsDefault() { + val personalListAdapter = + mock { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } + val workListAdapter = + mock { whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) } + val pagerAdapter = + MultiProfilePagerAdapter( + { listAdapter: ResolverListAdapter -> listAdapter }, + { listView: ListView, bindAdapter: ResolverListAdapter -> + listView.setAdapter(bindAdapter) + }, + ImmutableList.of( + TabConfig( + PROFILE_PERSONAL, + "personal", + "personal_a11y", + "TAG_PERSONAL", + personalListAdapter + ), + TabConfig(PROFILE_WORK, "work", "work_a11y", "TAG_WORK", workListAdapter) + ), + object : EmptyStateProvider {}, + { false }, + PROFILE_WORK, // <-- This test specifically requests we start on work profile. + WORK_USER_HANDLE, // TODO: why does this test pass even if this is null? + null, + inflater, + { Optional.empty() } + ) + assertThat(pagerAdapter.count).isEqualTo(2) + assertThat(pagerAdapter.currentPage).isEqualTo(PROFILE_WORK) + assertThat(pagerAdapter.currentUserHandle).isEqualTo(WORK_USER_HANDLE) + assertThat(pagerAdapter.getPageAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.getPageAdapterForIndex(1)).isSameInstanceAs(workListAdapter) + assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(workListAdapter) + assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.workListAdapter).isSameInstanceAs(workListAdapter) + assertThat(pagerAdapter.itemCount).isEqualTo(2) + // TODO: consider covering some of the package-private methods (and making them public?). + // TODO: test ProfileSelectedListener (and getters for "current" state) as the selected + // page changes. Currently there's no API to change the selected page directly; that's + // only possible through manipulation of the bound ViewPager. + } + + @Test + fun testBottomPaddingDelegate_default() { + val personalListAdapter = + mock { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } + val pagerAdapter = + MultiProfilePagerAdapter( + { listAdapter: ResolverListAdapter -> listAdapter }, + { listView: ListView, bindAdapter: ResolverListAdapter -> + listView.setAdapter(bindAdapter) + }, + ImmutableList.of( + TabConfig( + PROFILE_PERSONAL, + "personal", + "personal_a11y", + "TAG_PERSONAL", + personalListAdapter + ) + ), + object : EmptyStateProvider {}, + { false }, + PROFILE_PERSONAL, + null, + null, + inflater, + { Optional.empty() } + ) + val container = + pagerAdapter + .getActiveEmptyStateView() + .requireViewById(com.android.internal.R.id.resolver_empty_state_container) + container.setPadding(1, 2, 3, 4) + pagerAdapter.setupContainerPadding() + assertThat(container.paddingLeft).isEqualTo(1) + assertThat(container.paddingTop).isEqualTo(2) + assertThat(container.paddingRight).isEqualTo(3) + assertThat(container.paddingBottom).isEqualTo(4) + } + + @Test + fun testBottomPaddingDelegate_override() { + val personalListAdapter = + mock { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } + val pagerAdapter = + MultiProfilePagerAdapter( + { listAdapter: ResolverListAdapter -> listAdapter }, + { listView: ListView, bindAdapter: ResolverListAdapter -> + listView.setAdapter(bindAdapter) + }, + ImmutableList.of( + TabConfig( + PROFILE_PERSONAL, + "personal", + "personal_a11y", + "TAG_PERSONAL", + personalListAdapter + ) + ), + object : EmptyStateProvider {}, + { false }, + PROFILE_PERSONAL, + null, + null, + inflater, + { Optional.of(42) } + ) + val container = + pagerAdapter + .getActiveEmptyStateView() + .requireViewById(com.android.internal.R.id.resolver_empty_state_container) + container.setPadding(1, 2, 3, 4) + pagerAdapter.setupContainerPadding() + assertThat(container.paddingLeft).isEqualTo(1) + assertThat(container.paddingTop).isEqualTo(2) + assertThat(container.paddingRight).isEqualTo(3) + assertThat(container.paddingBottom).isEqualTo(42) + } + + @Test + fun testPresumedQuietModeEmptyStateForWorkProfile_whenQuiet() { + // TODO: this is "presumed" because the conditions to determine whether we "should" show an + // empty state aren't enforced to align with the conditions when we actually *would* -- I + // believe `shouldShowEmptyStateScreen` should be implemented in terms of the provider? + val personalListAdapter = + mock { + whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) + whenever(getUnfilteredCount()).thenReturn(1) + } + val workListAdapter = + mock { + whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) + whenever(getUnfilteredCount()).thenReturn(1) + } + val pagerAdapter = + MultiProfilePagerAdapter( + { listAdapter: ResolverListAdapter -> listAdapter }, + { listView: ListView, bindAdapter: ResolverListAdapter -> + listView.setAdapter(bindAdapter) + }, + ImmutableList.of( + TabConfig( + PROFILE_PERSONAL, + "personal", + "personal_a11y", + "TAG_PERSONAL", + personalListAdapter + ), + TabConfig(PROFILE_WORK, "work", "work_a11y", "TAG_WORK", workListAdapter) + ), + object : EmptyStateProvider {}, + { true }, // <-- Work mode is quiet. + PROFILE_WORK, + WORK_USER_HANDLE, + null, + inflater, + { Optional.empty() } + ) + assertThat(pagerAdapter.shouldShowEmptyStateScreen(workListAdapter)).isTrue() + assertThat(pagerAdapter.shouldShowEmptyStateScreen(personalListAdapter)).isFalse() + } + + @Test + fun testPresumedQuietModeEmptyStateForWorkProfile_notWhenNotQuiet() { + // TODO: this is "presumed" because the conditions to determine whether we "should" show an + // empty state aren't enforced to align with the conditions when we actually *would* -- I + // believe `shouldShowEmptyStateScreen` should be implemented in terms of the provider? + val personalListAdapter = + mock { + whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) + whenever(getUnfilteredCount()).thenReturn(1) + } + val workListAdapter = + mock { + whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) + whenever(getUnfilteredCount()).thenReturn(1) + } + val pagerAdapter = + MultiProfilePagerAdapter( + { listAdapter: ResolverListAdapter -> listAdapter }, + { listView: ListView, bindAdapter: ResolverListAdapter -> + listView.setAdapter(bindAdapter) + }, + ImmutableList.of( + TabConfig( + PROFILE_PERSONAL, + "personal", + "personal_a11y", + "TAG_PERSONAL", + personalListAdapter + ), + TabConfig(PROFILE_WORK, "work", "work_a11y", "TAG_WORK", workListAdapter) + ), + object : EmptyStateProvider {}, + { false }, // <-- Work mode is not quiet. + PROFILE_WORK, + WORK_USER_HANDLE, + null, + inflater, + { Optional.empty() } + ) + assertThat(pagerAdapter.shouldShowEmptyStateScreen(workListAdapter)).isFalse() + assertThat(pagerAdapter.shouldShowEmptyStateScreen(personalListAdapter)).isFalse() + } +} -- cgit v1.2.3-59-g8ed1b From 14f66cd6f133fff70c5beab7575b25301568fd6e Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Fri, 16 Feb 2024 09:10:17 -0500 Subject: Adds hasComponent and hasSendAction to IntentExt Bug: n/a Test: atest com.android.intentresolver.v2.ext.IntentExtTest Change-Id: I6b2ab5e5f829a6ea1d7c127dcc3dac810e0b6982 --- java/src/com/android/intentresolver/v2/ext/IntentExt.kt | 6 ++++++ .../v2/ui/viewmodel/ChooserRequestReader.kt | 8 ++------ .../com/android/intentresolver/v2/ext/IntentExtTest.kt | 15 +++++++++++++++ 3 files changed, 23 insertions(+), 6 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ext/IntentExt.kt b/java/src/com/android/intentresolver/v2/ext/IntentExt.kt index 7aa8e036..8c2d7277 100644 --- a/java/src/com/android/intentresolver/v2/ext/IntentExt.kt +++ b/java/src/com/android/intentresolver/v2/ext/IntentExt.kt @@ -32,8 +32,14 @@ inline fun Intent.ifMatch( /** True if the Intent has one of the specified actions. */ fun Intent.hasAction(vararg actions: String): Boolean = action in actions +/** True if the Intent has a specific component target */ +fun Intent.hasComponent(): Boolean = (component != null) + /** True if the Intent has a single matching category. */ fun Intent.hasSingleCategory(category: String) = categories.singleOrNull() == category +/** True if the Intent is a SEND or SEND_MULTIPLE action. */ +fun Intent.hasSendAction() = hasAction(Intent.ACTION_SEND, Intent.ACTION_SEND_MULTIPLE) + /** True if the Intent resolves to the special Home (Launcher) component */ fun Intent.isHomeIntent() = hasAction(Intent.ACTION_MAIN) && hasSingleCategory(Intent.CATEGORY_HOME) diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt index 565d4de1..e32d69b0 100644 --- a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt +++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt @@ -17,8 +17,6 @@ package com.android.intentresolver.v2.ui.viewmodel import android.content.ComponentName import android.content.Intent -import android.content.Intent.ACTION_SEND -import android.content.Intent.ACTION_SEND_MULTIPLE import android.content.Intent.EXTRA_ALTERNATE_INTENTS import android.content.Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS import android.content.Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION @@ -47,7 +45,7 @@ import com.android.intentresolver.ContentTypeHint import com.android.intentresolver.R import com.android.intentresolver.inject.ChooserServiceFlags import com.android.intentresolver.util.hasValidIcon -import com.android.intentresolver.v2.ext.hasAction +import com.android.intentresolver.v2.ext.hasSendAction import com.android.intentresolver.v2.ext.ifMatch import com.android.intentresolver.v2.ui.model.ActivityLaunch import com.android.intentresolver.v2.ui.model.ChooserRequest @@ -60,8 +58,6 @@ import com.android.intentresolver.v2.validation.validateFrom private const val MAX_CHOOSER_ACTIONS = 5 private const val MAX_INITIAL_INTENTS = 2 -private fun Intent.hasSendAction() = hasAction(ACTION_SEND, ACTION_SEND_MULTIPLE) - internal fun Intent.maybeAddSendActionFlags() = ifMatch(Intent::hasSendAction) { addFlags(FLAG_ACTIVITY_NEW_DOCUMENT) @@ -77,7 +73,7 @@ fun readChooserRequest( return validateFrom(extras::get) { val targetIntent = required(IntentOrUri(EXTRA_INTENT)).maybeAddSendActionFlags() - val isSendAction = targetIntent.hasAction(ACTION_SEND, ACTION_SEND_MULTIPLE) + val isSendAction = targetIntent.hasSendAction() val additionalTargets = optional(array(EXTRA_ALTERNATE_INTENTS))?.map { it.maybeAddSendActionFlags() } diff --git a/tests/unit/src/com/android/intentresolver/v2/ext/IntentExtTest.kt b/tests/unit/src/com/android/intentresolver/v2/ext/IntentExtTest.kt index 6a16168c..2ccd548a 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ext/IntentExtTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/ext/IntentExtTest.kt @@ -15,6 +15,7 @@ */ package com.android.intentresolver.v2.ext +import android.content.ComponentName import android.content.Intent import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage @@ -35,6 +36,20 @@ class IntentExtTest { assertThat(sendIntent.hasAction(Intent.ACTION_VIEW)).isFalse() } + @Test + fun hasComponent() { + assertThat(Intent().hasComponent()).isFalse() + assertThat(Intent().setComponent(ComponentName("A", "B")).hasComponent()).isTrue() + } + + @Test + fun hasSendAction() { + assertThat(Intent(Intent.ACTION_SEND).hasSendAction()).isTrue() + assertThat(Intent(Intent.ACTION_SEND_MULTIPLE).hasSendAction()).isTrue() + assertThat(Intent(Intent.ACTION_SENDTO).hasSendAction()).isFalse() + assertThat(Intent(Intent.ACTION_VIEW).hasSendAction()).isFalse() + } + @Test fun hasSingleCategory() { val intent = Intent().addCategory(Intent.CATEGORY_HOME) -- cgit v1.2.3-59-g8ed1b From 726a89e9422166e77ceded5849ea9d89e663300d Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Fri, 16 Feb 2024 09:37:11 -0500 Subject: Moves Profile to shared/model for use with some existing View components. It's more of an identifying token than a UI model and is used in logic not for drawing UI. Bug: 300157408 Test: atest IntentResolver-tests-unit Change-Id: I13cf3a7f15beb985f91647a65731950092236f04 --- .../intentresolver/v2/ResolverActivity.java | 2 +- .../v2/domain/interactor/UserInteractor.kt | 4 +- .../intentresolver/v2/domain/model/Profile.kt | 53 ---------------------- .../intentresolver/v2/shared/model/Profile.kt | 52 +++++++++++++++++++++ .../intentresolver/v2/ui/ProfilePagerResources.kt | 2 +- .../intentresolver/v2/ui/model/ResolverRequest.kt | 2 +- .../v2/ui/viewmodel/ResolverRequestReader.kt | 2 +- .../v2/domain/interactor/UserInteractorTest.kt | 8 ++-- .../v2/ui/viewmodel/ResolverRequestTest.kt | 2 +- 9 files changed, 63 insertions(+), 64 deletions(-) delete mode 100644 java/src/com/android/intentresolver/v2/domain/model/Profile.kt create mode 100644 java/src/com/android/intentresolver/v2/shared/model/Profile.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index 2d26932f..241b6735 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -101,7 +101,7 @@ import com.android.intentresolver.icons.DefaultTargetDataLoader; import com.android.intentresolver.icons.TargetDataLoader; import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; import com.android.intentresolver.v2.data.repository.DevicePolicyResources; -import com.android.intentresolver.v2.domain.model.Profile; +import com.android.intentresolver.v2.shared.model.Profile; import com.android.intentresolver.v2.emptystate.NoAppsAvailableEmptyStateProvider; import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider; import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; diff --git a/java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.kt b/java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.kt index f12d8197..c8df9684 100644 --- a/java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.kt +++ b/java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.kt @@ -19,8 +19,8 @@ package com.android.intentresolver.v2.domain.interactor import android.os.UserHandle import com.android.intentresolver.inject.ApplicationUser import com.android.intentresolver.v2.data.repository.UserRepository -import com.android.intentresolver.v2.domain.model.Profile -import com.android.intentresolver.v2.domain.model.Profile.Type +import com.android.intentresolver.v2.shared.model.Profile +import com.android.intentresolver.v2.shared.model.Profile.Type import com.android.intentresolver.v2.shared.model.User import com.android.intentresolver.v2.shared.model.User.Role import javax.inject.Inject diff --git a/java/src/com/android/intentresolver/v2/domain/model/Profile.kt b/java/src/com/android/intentresolver/v2/domain/model/Profile.kt deleted file mode 100644 index 46015c7a..00000000 --- a/java/src/com/android/intentresolver/v2/domain/model/Profile.kt +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright (C) 2024 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.v2.domain.model - -import com.android.intentresolver.v2.domain.model.Profile.Type -import com.android.intentresolver.v2.shared.model.User - -/** - * A domain layer model which associates [users][User] into a [Type] instance. - * - * This is a simple abstraction which combines a primary [user][User] with an optional - * [cloned apps][User.Role.CLONE] user. This encapsulates the cloned app user id, while still being - * available where needed. - */ -data class Profile( - val type: Type, - val primary: User, - /** - * An optional [User] of which contains second instances of some applications installed for the - * personal user. This value may only be supplied when creating the PERSONAL profile. - */ - val clone: User? = null -) { - - init { - clone?.apply { - require(primary.role == User.Role.PERSONAL) { - "clone is not supported for profile=${this@Profile.type} / primary=$primary" - } - require(role == User.Role.CLONE) { "clone is not a clone user ($this)" } - } - } - - enum class Type { - PERSONAL, - WORK, - PRIVATE - } -} diff --git a/java/src/com/android/intentresolver/v2/shared/model/Profile.kt b/java/src/com/android/intentresolver/v2/shared/model/Profile.kt new file mode 100644 index 00000000..6e37174c --- /dev/null +++ b/java/src/com/android/intentresolver/v2/shared/model/Profile.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2024 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.v2.shared.model + +import com.android.intentresolver.v2.shared.model.Profile.Type + +/** + * Associates [users][User] into a [Type] instance. + * + * This is a simple abstraction which combines a primary [user][User] with an optional + * [cloned apps][User.Role.CLONE] user. This encapsulates the cloned app user id, while still being + * available where needed. + */ +data class Profile( + val type: Type, + val primary: User, + /** + * An optional [User] of which contains second instances of some applications installed for the + * personal user. This value may only be supplied when creating the PERSONAL profile. + */ + val clone: User? = null +) { + + init { + clone?.apply { + require(primary.role == User.Role.PERSONAL) { + "clone is not supported for profile=${this@Profile.type} / primary=$primary" + } + require(role == User.Role.CLONE) { "clone is not a clone user ($this)" } + } + } + + enum class Type { + PERSONAL, + WORK, + PRIVATE + } +} diff --git a/java/src/com/android/intentresolver/v2/ui/ProfilePagerResources.kt b/java/src/com/android/intentresolver/v2/ui/ProfilePagerResources.kt index 0d31b23e..1cd72ba5 100644 --- a/java/src/com/android/intentresolver/v2/ui/ProfilePagerResources.kt +++ b/java/src/com/android/intentresolver/v2/ui/ProfilePagerResources.kt @@ -19,7 +19,7 @@ package com.android.intentresolver.v2.ui import android.content.res.Resources import com.android.intentresolver.inject.ApplicationOwned import com.android.intentresolver.v2.data.repository.DevicePolicyResources -import com.android.intentresolver.v2.domain.model.Profile +import com.android.intentresolver.v2.shared.model.Profile import javax.inject.Inject import com.android.intentresolver.R diff --git a/java/src/com/android/intentresolver/v2/ui/model/ResolverRequest.kt b/java/src/com/android/intentresolver/v2/ui/model/ResolverRequest.kt index 5abfb602..a4f74ca9 100644 --- a/java/src/com/android/intentresolver/v2/ui/model/ResolverRequest.kt +++ b/java/src/com/android/intentresolver/v2/ui/model/ResolverRequest.kt @@ -19,7 +19,7 @@ package com.android.intentresolver.v2.ui.model import android.content.Intent import android.content.pm.ResolveInfo import android.os.UserHandle -import com.android.intentresolver.v2.domain.model.Profile +import com.android.intentresolver.v2.shared.model.Profile import com.android.intentresolver.v2.ext.isHomeIntent /** All of the things that are consumed from an incoming Intent Resolution request (+Extras). */ diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestReader.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestReader.kt index fc9f1e01..22d76493 100644 --- a/java/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestReader.kt +++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestReader.kt @@ -20,7 +20,7 @@ import android.os.Bundle import android.os.UserHandle import com.android.intentresolver.v2.ResolverActivity.PROFILE_PERSONAL import com.android.intentresolver.v2.ResolverActivity.PROFILE_WORK -import com.android.intentresolver.v2.domain.model.Profile +import com.android.intentresolver.v2.shared.model.Profile import com.android.intentresolver.v2.ui.model.ActivityLaunch import com.android.intentresolver.v2.ui.model.ResolverRequest import com.android.intentresolver.v2.validation.Validation diff --git a/tests/unit/src/com/android/intentresolver/v2/domain/interactor/UserInteractorTest.kt b/tests/unit/src/com/android/intentresolver/v2/domain/interactor/UserInteractorTest.kt index b2c65867..b66fabfd 100644 --- a/tests/unit/src/com/android/intentresolver/v2/domain/interactor/UserInteractorTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/domain/interactor/UserInteractorTest.kt @@ -18,10 +18,10 @@ package com.android.intentresolver.v2.domain.interactor import com.android.intentresolver.v2.coroutines.collectLastValue import com.android.intentresolver.v2.data.repository.FakeUserRepository -import com.android.intentresolver.v2.domain.model.Profile -import com.android.intentresolver.v2.domain.model.Profile.Type.PERSONAL -import com.android.intentresolver.v2.domain.model.Profile.Type.PRIVATE -import com.android.intentresolver.v2.domain.model.Profile.Type.WORK +import com.android.intentresolver.v2.shared.model.Profile +import com.android.intentresolver.v2.shared.model.Profile.Type.PERSONAL +import com.android.intentresolver.v2.shared.model.Profile.Type.PRIVATE +import com.android.intentresolver.v2.shared.model.Profile.Type.WORK import com.android.intentresolver.v2.shared.model.User import com.android.intentresolver.v2.shared.model.User.Role import com.google.common.truth.Truth.assertThat diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestTest.kt b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestTest.kt index a5acb0d3..e88c46f5 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestTest.kt @@ -22,7 +22,7 @@ import android.os.UserHandle import androidx.core.net.toUri import androidx.core.os.bundleOf import com.android.intentresolver.v2.ResolverActivity.PROFILE_WORK -import com.android.intentresolver.v2.domain.model.Profile.Type.WORK +import com.android.intentresolver.v2.shared.model.Profile.Type.WORK import com.android.intentresolver.v2.ui.model.ActivityLaunch import com.android.intentresolver.v2.ui.model.ResolverRequest import com.android.intentresolver.v2.validation.UncaughtException -- cgit v1.2.3-59-g8ed1b From eb2c6935518e781ca0651bca69ddb2e306daa05a Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Fri, 16 Feb 2024 13:38:30 -0500 Subject: Change UserInteractor availability interface This does the following: 1) Keeps availability information per-Profile, and not per-Type because there isn't a requirement to enforce a single instance per type at this layer, and this requirement may come along in the future. 2) changes `fun isAvailable(Profile.Type: Flow` to `val availability: Flow>` which is easier to consume downstream as a StateFlow without additional bookeeping of maintaining individual flows. Changes can be easily processed by using a runningFold and taking the difference in the two maps. Test: atest UserInteractorTest Bug: n/a Flag: n/a (code is not yet live) Change-Id: I8605c42bead70b4bf41fdf89c195311b85938b1b --- .../v2/domain/interactor/UserInteractor.kt | 16 ++++--------- .../v2/domain/interactor/UserInteractorTest.kt | 28 ++++++++++++---------- 2 files changed, 21 insertions(+), 23 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.kt b/java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.kt index c8df9684..72b604c2 100644 --- a/java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.kt +++ b/java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.kt @@ -26,9 +26,7 @@ import com.android.intentresolver.v2.shared.model.User.Role import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapNotNull /** The high level User interface. */ class UserInteractor @@ -67,20 +65,16 @@ constructor( it.primary.id == launchedAs.identifier || it.clone?.id == launchedAs.identifier } } - /** - * Provides a flow to report on the availability of the profile. An unavailable profile may be + * Provides a flow to report on the availability of profile. An unavailable profile may be * hidden or appear disabled within the app. */ - fun isAvailable(type: Type): Flow { - val profileFlow = profiles.map { list -> list.firstOrNull { it.type == type } } - return combine(profileFlow, userRepository.availability) { profile, availability -> - when (profile) { - null -> false - else -> availability.getOrDefault(profile.primary, false) + val availability: Flow> = + combine(profiles, userRepository.availability) { profiles, availability -> + profiles.associateWith { + availability.getOrDefault(it.primary, false) } } - } /** * Request the profile state be updated. In the case of enabling, the operation could take diff --git a/tests/unit/src/com/android/intentresolver/v2/domain/interactor/UserInteractorTest.kt b/tests/unit/src/com/android/intentresolver/v2/domain/interactor/UserInteractorTest.kt index b66fabfd..a81a315b 100644 --- a/tests/unit/src/com/android/intentresolver/v2/domain/interactor/UserInteractorTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/domain/interactor/UserInteractorTest.kt @@ -41,6 +41,10 @@ class UserInteractorTest { private val workUser = User(id = baseId + 2, role = Role.WORK) private val privateUser = User(id = baseId + 3, role = Role.PRIVATE) + val personalProfile = Profile(PERSONAL, personalUser) + val workProfile = Profile(WORK, workUser) + val privateProfile = Profile(PRIVATE, privateUser) + @Test fun launchedByProfile(): Unit = runTest { val profileInteractor = @@ -146,37 +150,37 @@ class UserInteractorTest { val userRepo = FakeUserRepository(listOf(personalUser)) userRepo.addUser(workUser, false) - val profileInteractor = + val interactor = UserInteractor(userRepository = userRepo, launchedAs = personalUser.handle) - val personalAvailable by collectLastValue(profileInteractor.isAvailable(PERSONAL)) - val workAvailable by collectLastValue(profileInteractor.isAvailable(WORK)) - assertWithMessage("personalAvailable").that(personalAvailable!!).isTrue() + val availability by collectLastValue(interactor.availability) - assertWithMessage("workAvailable").that(workAvailable!!).isFalse() + assertWithMessage("personalAvailable").that(availability?.get(personalProfile)).isTrue() + assertWithMessage("workAvailable").that(availability?.get(workProfile)).isFalse() } @Test fun isAvailable() = runTest { val userRepo = FakeUserRepository(listOf(workUser, personalUser)) - val profileInteractor = + val interactor = UserInteractor(userRepository = userRepo, launchedAs = personalUser.handle) - val workAvailable by collectLastValue(profileInteractor.isAvailable(WORK)) + + val availability by collectLastValue(interactor.availability) // Default state is enabled in FakeUserManager - assertWithMessage("workAvailable").that(workAvailable).isTrue() + assertWithMessage("workAvailable").that(availability?.get(workProfile)).isTrue() // Making user unavailable makes profile unavailable userRepo.requestState(workUser, false) - assertWithMessage("workAvailable").that(workAvailable).isFalse() + assertWithMessage("workAvailable").that(availability?.get(workProfile)).isFalse() // Making user available makes profile available again userRepo.requestState(workUser, true) - assertWithMessage("workAvailable").that(workAvailable).isTrue() + assertWithMessage("workAvailable").that(availability?.get(workProfile)).isTrue() - // When a user is removed availability should update to false + // When a user is removed availability is removed as well. userRepo.removeUser(workUser) - assertWithMessage("workAvailable").that(workAvailable).isFalse() + assertWithMessage("workAvailable").that(availability?.get(workProfile)).isNull() } /** -- cgit v1.2.3-59-g8ed1b From f23537f84b01547adeadf0069a1dc5491bba5bda Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Sat, 17 Feb 2024 13:41:31 -0800 Subject: Shareousel: make the only selected item unselectable Fix: 325496908 Test: atest IntentResolver-tests-unit Test: manual functionality testing Change-Id: Ie7d8a7691d697f3c0aa87ce0a4b144f48bfdbdd5 --- .../contentpreview/PayloadToggleInteractor.kt | 11 ++--- .../contentpreview/SelectionTracker.kt | 2 +- .../contentpreview/TargetIntentModifier.kt | 12 +++--- .../contentpreview/PayloadToggleInteractorTest.kt | 50 ++++++++++++++++++++++ .../contentpreview/SelectionTrackerTest.kt | 11 +++++ 5 files changed, 75 insertions(+), 11 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt b/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt index 003f6884..61eaca77 100644 --- a/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt @@ -163,13 +163,14 @@ class PayloadToggleInteractor( fun setSelected(item: Item, isSelected: Boolean) { val record = item as Record - record.isSelected.value = isSelected scope.launch { val (_, selectionTracker) = waitForCursorData() ?: return@launch - selectionTracker.setItemSelection(record.key, record, isSelected) - val targetIntent = targetIntentModifier(selectionTracker.getSelection()) - val newJob = scope.launch { notifySelectionChanged(targetIntent) } - notifySelectionJobRef.getAndSet(newJob)?.cancel() + if (selectionTracker.setItemSelection(record.key, record, isSelected)) { + val targetIntent = targetIntentModifier(selectionTracker.getSelection()) + val newJob = scope.launch { notifySelectionChanged(targetIntent) } + notifySelectionJobRef.getAndSet(newJob)?.cancel() + record.isSelected.value = selectionTracker.isItemSelected(record.key) + } } } diff --git a/java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt b/java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt index 4ce006ec..c9431731 100644 --- a/java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt +++ b/java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt @@ -131,7 +131,7 @@ class SelectionTracker( selections[key] = item return true } - if (!isSelected && idx >= 0) { + if (!isSelected && idx >= 0 && selections.size() > 1) { selections.removeAt(idx) return true } diff --git a/java/src/com/android/intentresolver/contentpreview/TargetIntentModifier.kt b/java/src/com/android/intentresolver/contentpreview/TargetIntentModifier.kt index d7e04920..58da5bc4 100644 --- a/java/src/com/android/intentresolver/contentpreview/TargetIntentModifier.kt +++ b/java/src/com/android/intentresolver/contentpreview/TargetIntentModifier.kt @@ -46,12 +46,14 @@ class TargetIntentModifier( } else { putParcelableArrayListExtra(EXTRA_STREAM, uris) } - clipData = - ClipData("", arrayOf(targetMimeType), ClipData.Item(uris[0])).also { - for (i in 1 until uris.size) { - it.addItem(ClipData.Item(uris[i])) + if (uris.isNotEmpty()) { + clipData = + ClipData("", arrayOf(targetMimeType), ClipData.Item(uris[0])).also { + for (i in 1 until uris.size) { + it.addItem(ClipData.Item(uris[i])) + } } - } + } } } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/PayloadToggleInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/PayloadToggleInteractorTest.kt index 88e62a40..25c27468 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/PayloadToggleInteractorTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/PayloadToggleInteractorTest.kt @@ -99,6 +99,56 @@ class PayloadToggleInteractorTest { .inOrder() } } + + @Test + fun testItemsSelection() = + testScope.runTest { + val cursorReader = CursorUriReader(createCursor(10), 2, 2) { true } + val testSubject = + PayloadToggleInteractor( + scope = testScope.backgroundScope, + initiallySharedUris = listOf(makeUri(0)), + focusedUriIdx = 1, + mimeTypeClassifier = DefaultMimeTypeClassifier, + cursorReaderProvider = { cursorReader }, + uriMetadataReader = { uri -> + FileInfo.Builder(uri) + .withMimeType("image/png") + .withPreviewUri(uri) + .build() + }, + selectionCallback = { null }, + targetIntentModifier = { Intent(Intent.ACTION_SEND) }, + ) + .apply { start() } + + scheduler.runCurrent() + val items = testSubject.stateFlow.first().items + assertWithMessage("An initially selected item should be selected") + .that(testSubject.selected(items[0]).first()) + .isTrue() + assertWithMessage("An item that was not initially selected should not be selected") + .that(testSubject.selected(items[1]).first()) + .isFalse() + + testSubject.setSelected(items[0], false) + scheduler.runCurrent() + assertWithMessage("The only selected item can not be unselected") + .that(testSubject.selected(items[0]).first()) + .isTrue() + + testSubject.setSelected(items[1], true) + scheduler.runCurrent() + assertWithMessage("An item selection status should be published") + .that(testSubject.selected(items[1]).first()) + .isTrue() + + testSubject.setSelected(items[0], false) + scheduler.runCurrent() + assertWithMessage("An item can be unselected when there's another selected item") + .that(testSubject.selected(items[0]).first()) + .isFalse() + } } private fun createCursor(count: Int): Cursor { diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/SelectionTrackerTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/SelectionTrackerTest.kt index 13f1f44f..6ba18466 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/SelectionTrackerTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/SelectionTrackerTest.kt @@ -314,6 +314,17 @@ class SelectionTrackerTest { assertThat(testSubject.getPendingItems()).containsExactly(u1) } + + @Test + fun testUnselectOnlySelectedItem_itemRemainsSelected() { + val u1 = makeUri(1) + + val testSubject = SelectionTracker(listOf(u1), 0, 1) { this } + testSubject.onEndItemsAdded(SparseArray(1).apply { append(0, u1) }) + assertThat(testSubject.isItemSelected(0)).isTrue() + assertThat(testSubject.setItemSelection(0, u1, false)).isFalse() + assertThat(testSubject.isItemSelected(0)).isTrue() + } } private fun makeUri(id: Int) = Uri.parse("content://org.pkg.app/img-$id.png") -- cgit v1.2.3-59-g8ed1b From 0511077086852d8b987e70eb57ab30dfc0148a7b Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Tue, 20 Feb 2024 09:49:03 -0500 Subject: Check for EXTRA_CHOOSER_RESULT for backcompat until V2 is released This keeps the existing functionality working in V1, with the flag enabled for apps using Intent.createChooser which will begin sending the new extra value. The extra used has no bearing on functionality. With the feature flag disabled, the same functionality continues while also accepting the the extra name from Intent.createChooser. Without this, apps using Intent.createChooser will stop receiving results with the flag[1] disabled. [1] intentresolver/com.android.intentresolver.modular_framework Bug: 325545074 Test: atest android.content.cts.IntentTest#testCreateChooser Change-Id: Id25de6410835a85e71cb58277015d8ede51f4e4d --- .../android/intentresolver/ChooserRequestParameters.java | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java index 968aee2e..6c7f8264 100644 --- a/java/src/com/android/intentresolver/ChooserRequestParameters.java +++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java @@ -16,6 +16,8 @@ package com.android.intentresolver; +import static java.util.Objects.requireNonNullElse; + import android.content.ComponentName; import android.content.Intent; import android.content.IntentFilter; @@ -41,6 +43,8 @@ import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Objects; +import java.util.Optional; import java.util.stream.Collector; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -128,8 +132,14 @@ public class ChooserRequestParameters { mReferrerFillInIntent = new Intent().putExtra(Intent.EXTRA_REFERRER, referrer); - mChosenComponentSender = clientIntent.getParcelableExtra( - Intent.EXTRA_CHOSEN_COMPONENT_INTENT_SENDER); + mChosenComponentSender = + Optional.ofNullable( + clientIntent.getParcelableExtra(Intent.EXTRA_CHOSEN_COMPONENT_INTENT_SENDER, + IntentSender.class)) + .orElse(clientIntent.getParcelableExtra( + Intent.EXTRA_CHOOSER_RESULT_INTENT_SENDER, + IntentSender.class)); + mRefinementIntentSender = clientIntent.getParcelableExtra( Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER); -- cgit v1.2.3-59-g8ed1b From 94f2cf048d55c36745542000f2f3276e2d04448f Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Wed, 14 Feb 2024 15:39:46 -0800 Subject: Parse remaining payload selection callback result arguments Bug: 302691505 Test: atest IntentResolver-tests-unit Change-Id: I93ab3abb9605a32b4ce44572a027c478c3ea1210 --- .../contentpreview/PayloadToggleInteractor.kt | 21 +- .../contentpreview/SelectionChangeCallback.kt | 55 +++- .../v2/ui/viewmodel/ChooserRequestReader.kt | 22 +- .../contentpreview/SelectionChangeCallbackTest.kt | 311 ++++++++++++++++++--- 4 files changed, 334 insertions(+), 75 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt b/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt index 003f6884..6f4f5167 100644 --- a/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt @@ -17,8 +17,10 @@ package com.android.intentresolver.contentpreview import android.content.Intent +import android.content.IntentSender import android.net.Uri import android.service.chooser.ChooserAction +import android.service.chooser.ChooserTarget import android.util.Log import android.util.SparseArray import java.io.Closeable @@ -43,7 +45,7 @@ private const val TAG = "PayloadToggleInteractor" @OptIn(ExperimentalCoroutinesApi::class) class PayloadToggleInteractor( - // must use single-thread dispatcher (or we should enforce it with a lock) + // TODO: a single-thread dispatcher is currently expected. iterate on the synchronization logic. private val scope: CoroutineScope, private val initiallySharedUris: List, private val focusedUriIdx: Int, @@ -51,7 +53,7 @@ class PayloadToggleInteractor( private val cursorReaderProvider: suspend () -> CursorReader, private val uriMetadataReader: (Uri) -> FileInfo, private val targetIntentModifier: (List) -> Intent, - private val selectionCallback: (Intent) -> CallbackResult?, + private val selectionCallback: (Intent) -> ShareouselUpdate?, ) { private var cursorDataRef = CompletableDeferred() private val records = LinkedList() @@ -183,7 +185,7 @@ class PayloadToggleInteractor( val (reader, selectionTracker) = waitForCursorData() ?: return if (!reader.hasMoreBefore) return - val newItems = reader.readPageBefore().toRecords() + val newItems = reader.readPageBefore().toItems() selectionTracker.onStartItemsAdded(newItems) for (i in newItems.size() - 1 downTo 0) { records.add( @@ -224,7 +226,7 @@ class PayloadToggleInteractor( val (reader, selectionTracker) = waitForCursorData() ?: return if (!reader.hasMoreAfter) return - val newItems = reader.readPageAfter().toRecords() + val newItems = reader.readPageAfter().toItems() selectionTracker.onEndItemsAdded(newItems) for (i in 0 until newItems.size()) { val key = newItems.keyAt(i) @@ -254,7 +256,7 @@ class PayloadToggleInteractor( } } - private fun SparseArray.toRecords(): SparseArray { + private fun SparseArray.toItems(): SparseArray { val items = SparseArray(size()) for (i in 0 until size()) { val key = keyAt(i) @@ -335,7 +337,14 @@ class PayloadToggleInteractor( val isSelected = MutableStateFlow(false) } - data class CallbackResult(val customActions: List?) + data class ShareouselUpdate( + // for all properties, null value means no change + val customActions: List? = null, + val modifyShareAction: ChooserAction? = null, + val alternateIntents: List? = null, + val callerTargets: List? = null, + val refinementIntentSender: IntentSender? = null, + ) private data class CursorData( val reader: CursorReader, diff --git a/java/src/com/android/intentresolver/contentpreview/SelectionChangeCallback.kt b/java/src/com/android/intentresolver/contentpreview/SelectionChangeCallback.kt index 4e2e37b8..5c916882 100644 --- a/java/src/com/android/intentresolver/contentpreview/SelectionChangeCallback.kt +++ b/java/src/com/android/intentresolver/contentpreview/SelectionChangeCallback.kt @@ -18,13 +18,25 @@ package com.android.intentresolver.contentpreview import android.content.ContentInterface import android.content.Intent -import android.content.Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS +import android.content.Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION +import android.content.Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER +import android.content.Intent.EXTRA_CHOOSER_TARGETS import android.content.Intent.EXTRA_INTENT +import android.content.IntentSender import android.net.Uri import android.os.Bundle import android.service.chooser.AdditionalContentContract.MethodNames.ON_SELECTION_CHANGED import android.service.chooser.ChooserAction -import com.android.intentresolver.contentpreview.PayloadToggleInteractor.CallbackResult +import android.service.chooser.ChooserTarget +import com.android.intentresolver.contentpreview.PayloadToggleInteractor.ShareouselUpdate +import com.android.intentresolver.v2.ui.viewmodel.readAlternateIntents +import com.android.intentresolver.v2.ui.viewmodel.readChooserActions +import com.android.intentresolver.v2.validation.ValidationResult +import com.android.intentresolver.v2.validation.types.array +import com.android.intentresolver.v2.validation.types.value +import com.android.intentresolver.v2.validation.validateFrom + +private const val TAG = "SelectionChangeCallback" /** * Encapsulates payload change callback invocation to the sharing app; handles callback arguments @@ -34,8 +46,8 @@ class SelectionChangeCallback( private val uri: Uri, private val chooserIntent: Intent, private val contentResolver: ContentInterface, -) : (Intent) -> CallbackResult? { - fun onSelectionChanged(targetIntent: Intent): CallbackResult? = +) : (Intent) -> ShareouselUpdate? { + fun onSelectionChanged(targetIntent: Intent): ShareouselUpdate? = contentResolver .call( requireNotNull(uri.authority) { "URI authority can not be null" }, @@ -49,20 +61,35 @@ class SelectionChangeCallback( } ) ?.let { bundle -> - val actions = - if (bundle.containsKey(EXTRA_CHOOSER_CUSTOM_ACTIONS)) { - bundle - .getParcelableArray( - EXTRA_CHOOSER_CUSTOM_ACTIONS, - ChooserAction::class.java - ) - ?.filterNotNull() - ?: emptyList() + readCallbackResponse(bundle).let { validation -> + if (validation.isSuccess()) { + validation.value } else { + validation.reportToLogcat(TAG) null } - CallbackResult(actions) + } } override fun invoke(targetIntent: Intent) = onSelectionChanged(targetIntent) + + private fun readCallbackResponse(bundle: Bundle): ValidationResult { + return validateFrom(bundle::get) { + val customActions = readChooserActions() + val modifyShareAction = + optional(value(EXTRA_CHOOSER_MODIFY_SHARE_ACTION)) + val alternateIntents = readAlternateIntents() + val callerTargets = optional(array(EXTRA_CHOOSER_TARGETS)) + val refinementIntentSender = + optional(value(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER)) + + ShareouselUpdate( + customActions, + modifyShareAction, + alternateIntents, + callerTargets, + refinementIntentSender, + ) + } + } } diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt index e32d69b0..f8ab6fcb 100644 --- a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt +++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt @@ -28,7 +28,6 @@ import android.content.Intent.EXTRA_EXCLUDE_COMPONENTS import android.content.Intent.EXTRA_INITIAL_INTENTS import android.content.Intent.EXTRA_INTENT import android.content.Intent.EXTRA_METADATA_TEXT -import android.content.Intent.EXTRA_REFERRER import android.content.Intent.EXTRA_REPLACEMENT_EXTRAS import android.content.Intent.EXTRA_TEXT import android.content.Intent.EXTRA_TITLE @@ -49,6 +48,7 @@ import com.android.intentresolver.v2.ext.hasSendAction import com.android.intentresolver.v2.ext.ifMatch import com.android.intentresolver.v2.ui.model.ActivityLaunch import com.android.intentresolver.v2.ui.model.ChooserRequest +import com.android.intentresolver.v2.validation.Validation import com.android.intentresolver.v2.validation.ValidationResult import com.android.intentresolver.v2.validation.types.IntentOrUri import com.android.intentresolver.v2.validation.types.array @@ -75,9 +75,7 @@ fun readChooserRequest( val isSendAction = targetIntent.hasSendAction() - val additionalTargets = - optional(array(EXTRA_ALTERNATE_INTENTS))?.map { it.maybeAddSendActionFlags() } - ?: emptyList() + val additionalTargets = readAlternateIntents() ?: emptyList() val replacementExtras = optional(value(EXTRA_REPLACEMENT_EXTRAS)) @@ -119,16 +117,10 @@ fun readChooserRequest( val sharedText = optional(value(EXTRA_TEXT)) - val chooserActions = - optional(array(EXTRA_CHOOSER_CUSTOM_ACTIONS)) - ?.filter { hasValidIcon(it) } - ?.take(MAX_CHOOSER_ACTIONS) - ?: emptyList() + val chooserActions = readChooserActions() ?: emptyList() val modifyShareAction = optional(value(EXTRA_CHOOSER_MODIFY_SHARE_ACTION)) - val referrerFillIn = Intent().putExtra(EXTRA_REFERRER, launch.referrer) - val additionalContentUri: Uri? val focusedItemPos: Int if (isSendAction && flags.chooserPayloadToggling()) { @@ -188,6 +180,14 @@ fun readChooserRequest( } } +fun Validation.readAlternateIntents(): List? = + optional(array(EXTRA_ALTERNATE_INTENTS))?.map { it.maybeAddSendActionFlags() } + +fun Validation.readChooserActions(): List? = + optional(array(EXTRA_CHOOSER_CUSTOM_ACTIONS)) + ?.filter { hasValidIcon(it) } + ?.take(MAX_CHOOSER_ACTIONS) + private fun Intent.toShareTargetFilter(): IntentFilter? { return type?.let { IntentFilter().apply { diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/SelectionChangeCallbackTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/SelectionChangeCallbackTest.kt index 110448bb..40f2ab26 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/SelectionChangeCallbackTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/SelectionChangeCallbackTest.kt @@ -17,17 +17,25 @@ package com.android.intentresolver.contentpreview import android.app.PendingIntent +import android.content.ComponentName import android.content.ContentInterface import android.content.Intent import android.content.Intent.ACTION_CHOOSER +import android.content.Intent.ACTION_SEND import android.content.Intent.ACTION_SEND_MULTIPLE +import android.content.Intent.EXTRA_ALTERNATE_INTENTS import android.content.Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS +import android.content.Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION +import android.content.Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER +import android.content.Intent.EXTRA_CHOOSER_TARGETS import android.content.Intent.EXTRA_INTENT import android.content.Intent.EXTRA_STREAM import android.graphics.drawable.Icon import android.net.Uri import android.os.Bundle +import android.service.chooser.AdditionalContentContract.MethodNames.ON_SELECTION_CHANGED import android.service.chooser.ChooserAction +import android.service.chooser.ChooserTarget import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.android.intentresolver.any @@ -35,15 +43,15 @@ import com.android.intentresolver.argumentCaptor import com.android.intentresolver.capture import com.android.intentresolver.mock import com.android.intentresolver.whenever +import com.google.common.truth.Correspondence +import com.google.common.truth.Correspondence.BinaryPredicate import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito.times import org.mockito.Mockito.verify -// TODO: replace with the new API AdditionalContentContract$MethodNames#ON_SELECTION_CHANGED -private const val MethodName = "onSelectionChanged" - @RunWith(AndroidJUnit4::class) class SelectionChangeCallbackTest { private val uri = Uri.parse("content://org.pkg/content-provider") @@ -52,7 +60,74 @@ class SelectionChangeCallbackTest { private val context = InstrumentationRegistry.getInstrumentation().context @Test - fun testCallbackProducesChooserIntentArgument() { + fun testPayloadChangeCallbackContact() { + val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) + + val u1 = createUri(1) + val u2 = createUri(2) + val targetIntent = + Intent(ACTION_SEND_MULTIPLE).apply { + val uris = + ArrayList().apply { + add(u1) + add(u2) + } + putExtra(EXTRA_STREAM, uris) + type = "image/jpg" + } + testSubject.onSelectionChanged(targetIntent) + + val authorityCaptor = argumentCaptor() + val methodCaptor = argumentCaptor() + val argCaptor = argumentCaptor() + val extraCaptor = argumentCaptor() + verify(contentResolver, times(1)) + .call( + capture(authorityCaptor), + capture(methodCaptor), + capture(argCaptor), + capture(extraCaptor) + ) + assertWithMessage("Wrong additional content provider authority") + .that(authorityCaptor.value) + .isEqualTo(uri.authority) + assertWithMessage("Wrong additional content provider #call() method name") + .that(methodCaptor.value) + .isEqualTo(ON_SELECTION_CHANGED) + assertWithMessage("Wrong additional content provider argument value") + .that(argCaptor.value) + .isEqualTo(uri.toString()) + val extraBundle = extraCaptor.value + assertWithMessage("Additional content provider #call() should have a non-null extras arg.") + .that(extraBundle) + .isNotNull() + requireNotNull(extraBundle) + val argChooserIntent = extraBundle.getParcelable(EXTRA_INTENT, Intent::class.java) + assertWithMessage("#call() extras arg. should contain Intent#EXTRA_INTENT") + .that(argChooserIntent) + .isNotNull() + requireNotNull(argChooserIntent) + assertWithMessage("#call() extras arg's Intent#EXTRA_INTENT should be a Chooser intent") + .that(argChooserIntent.action) + .isEqualTo(chooserIntent.action) + val argTargetIntent = argChooserIntent.getParcelableExtra(EXTRA_INTENT, Intent::class.java) + assertWithMessage( + "A chooser intent passed into #call() method should contain updated target intent" + ) + .that(argTargetIntent) + .isNotNull() + requireNotNull(argTargetIntent) + assertWithMessage("Incorrect target intent") + .that(argTargetIntent.action) + .isEqualTo(targetIntent.action) + assertWithMessage("Incorrect target intent") + .that(argTargetIntent.getParcelableArrayListExtra(EXTRA_STREAM, Uri::class.java)) + .containsExactly(u1, u2) + .inOrder() + } + + @Test + fun testPayloadChangeCallbackUpdatesCustomActions() { val a1 = ChooserAction.Builder( Icon.createWithContentUri(createUri(10)), @@ -84,50 +159,198 @@ class SelectionChangeCallbackTest { val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) - val u1 = createUri(1) - val u2 = createUri(2) - val targetIntent = - Intent(ACTION_SEND_MULTIPLE).apply { - val uris = - ArrayList().apply { - add(u1) - add(u2) - } - putExtra(EXTRA_STREAM, uris) - type = "image/jpg" - } + val targetIntent = Intent(ACTION_SEND_MULTIPLE) val result = testSubject.onSelectionChanged(targetIntent) - assertThat(result).isNotNull() - assertThat(result?.customActions).hasSize(2) - assertThat(result?.customActions?.get(0)?.icon).isEqualTo(a1.icon) - assertThat(result?.customActions?.get(0)?.label).isEqualTo(a1.label) - assertThat(result?.customActions?.get(1)?.icon).isEqualTo(a2.icon) - assertThat(result?.customActions?.get(1)?.label).isEqualTo(a2.label) + assertWithMessage("Callback result should not be null").that(result).isNotNull() + requireNotNull(result) + assertWithMessage("Unexpected custom actions") + .that(result.customActions?.map { it.icon to it.label }) + .containsExactly(a1.icon to a1.label, a2.icon to a2.label) + .inOrder() - val authorityCaptor = argumentCaptor() - val methodCaptor = argumentCaptor() - val argCaptor = argumentCaptor() - val extraCaptor = argumentCaptor() - verify(contentResolver, times(1)) - .call( - capture(authorityCaptor), - capture(methodCaptor), - capture(argCaptor), - capture(extraCaptor) + assertThat(result.modifyShareAction).isNull() + assertThat(result.alternateIntents).isNull() + assertThat(result.callerTargets).isNull() + assertThat(result.refinementIntentSender).isNull() + } + + @Test + fun testPayloadChangeCallbackUpdatesReselectionAction() { + val modifyShare = + ChooserAction.Builder( + Icon.createWithContentUri(createUri(10)), + "Modify Share", + PendingIntent.getBroadcast( + context, + 1, + Intent("test"), + PendingIntent.FLAG_IMMUTABLE + ) + ) + .build() + whenever(contentResolver.call(any(), any(), any(), any())) + .thenReturn( + Bundle().apply { putParcelable(EXTRA_CHOOSER_MODIFY_SHARE_ACTION, modifyShare) } ) - assertThat(authorityCaptor.value).isEqualTo(uri.authority) - assertThat(methodCaptor.value).isEqualTo(MethodName) - assertThat(argCaptor.value).isEqualTo(uri.toString()) - val extraBundle = extraCaptor.value - assertThat(extraBundle).isNotNull() - val argChooserIntent = extraBundle.getParcelable(EXTRA_INTENT, Intent::class.java) - assertThat(argChooserIntent).isNotNull() - assertThat(argChooserIntent?.action).isEqualTo(chooserIntent.action) - val argTargetIntent = argChooserIntent?.getParcelableExtra(EXTRA_INTENT, Intent::class.java) - assertThat(argTargetIntent?.action).isEqualTo(targetIntent.action) - assertThat(argTargetIntent?.getParcelableArrayListExtra(EXTRA_STREAM, Uri::class.java)) - .containsExactly(u1, u2) + + val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) + + val targetIntent = Intent(ACTION_SEND) + val result = testSubject.onSelectionChanged(targetIntent) + assertWithMessage("Callback result should not be null").that(result).isNotNull() + requireNotNull(result) + assertWithMessage("Unexpected modify share action: wrong icon") + .that(result.modifyShareAction?.icon) + .isEqualTo(modifyShare.icon) + assertWithMessage("Unexpected modify share action: wrong label") + .that(result.modifyShareAction?.label) + .isEqualTo(modifyShare.label) + + assertThat(result.customActions).isNull() + assertThat(result.alternateIntents).isNull() + assertThat(result.callerTargets).isNull() + assertThat(result.refinementIntentSender).isNull() + } + + @Test + fun testPayloadChangeCallbackUpdatesAlternateIntents() { + val alternateIntents = + arrayOf( + Intent(ACTION_SEND_MULTIPLE).apply { + addCategory("test") + type = "" + } + ) + whenever(contentResolver.call(any(), any(), any(), any())) + .thenReturn( + Bundle().apply { putParcelableArray(EXTRA_ALTERNATE_INTENTS, alternateIntents) } + ) + + val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) + + val targetIntent = Intent(ACTION_SEND) + val result = testSubject.onSelectionChanged(targetIntent) + assertWithMessage("Callback result should not be null").that(result).isNotNull() + requireNotNull(result) + assertWithMessage("Wrong number of alternate intents") + .that(result.alternateIntents) + .hasSize(1) + assertWithMessage("Wrong alternate intent: action") + .that(result.alternateIntents?.get(0)?.action) + .isEqualTo(alternateIntents[0].action) + assertWithMessage("Wrong alternate intent: categories") + .that(result.alternateIntents?.get(0)?.categories) + .containsExactlyElementsIn(alternateIntents[0].categories) + assertWithMessage("Wrong alternate intent: mime type") + .that(result.alternateIntents?.get(0)?.type) + .isEqualTo(alternateIntents[0].type) + + assertThat(result.customActions).isNull() + assertThat(result.modifyShareAction).isNull() + assertThat(result.callerTargets).isNull() + assertThat(result.refinementIntentSender).isNull() + } + + @Test + fun testPayloadChangeCallbackUpdatesCallerTargets() { + val t1 = + ChooserTarget( + "Target 1", + Icon.createWithContentUri(createUri(1)), + 0.99f, + ComponentName("org.pkg.app", ".ClassA"), + null + ) + val t2 = + ChooserTarget( + "Target 2", + Icon.createWithContentUri(createUri(1)), + 1f, + ComponentName("org.pkg.app", ".ClassB"), + null + ) + whenever(contentResolver.call(any(), any(), any(), any())) + .thenReturn( + Bundle().apply { putParcelableArray(EXTRA_CHOOSER_TARGETS, arrayOf(t1, t2)) } + ) + + val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) + + val targetIntent = Intent(ACTION_SEND) + val result = testSubject.onSelectionChanged(targetIntent) + assertWithMessage("Callback result should not be null").that(result).isNotNull() + requireNotNull(result) + assertWithMessage("Wrong caller targets") + .that(result.callerTargets) + .comparingElementsUsing( + Correspondence.from( + BinaryPredicate { actual, expected -> + expected.componentName == actual?.componentName && + expected.title == actual?.title && + expected.icon == actual?.icon && + expected.score == actual?.score + }, + "" + ) + ) + .containsExactly(t1, t2) .inOrder() + + assertThat(result.customActions).isNull() + assertThat(result.modifyShareAction).isNull() + assertThat(result.alternateIntents).isNull() + assertThat(result.refinementIntentSender).isNull() + } + + @Test + fun testPayloadChangeCallbackUpdatesRefinementIntentSender() { + val broadcast = + PendingIntent.getBroadcast(context, 1, Intent("test"), PendingIntent.FLAG_IMMUTABLE) + + whenever(contentResolver.call(any(), any(), any(), any())) + .thenReturn( + Bundle().apply { + putParcelable(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER, broadcast.intentSender) + } + ) + + val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) + + val targetIntent = Intent(ACTION_SEND) + val result = testSubject.onSelectionChanged(targetIntent) + assertWithMessage("Callback result should not be null").that(result).isNotNull() + requireNotNull(result) + assertThat(result.customActions).isNull() + assertThat(result.modifyShareAction).isNull() + assertThat(result.alternateIntents).isNull() + assertThat(result.callerTargets).isNull() + assertThat(result.refinementIntentSender).isNotNull() + } + + @Test + fun testPayloadChangeCallbackProvidesInvalidData_invalidDataIgnored() { + whenever(contentResolver.call(any(), any(), any(), any())) + .thenReturn( + Bundle().apply { + putParcelableArrayList(EXTRA_CHOOSER_CUSTOM_ACTIONS, ArrayList()) + putParcelable(EXTRA_CHOOSER_MODIFY_SHARE_ACTION, createUri(1)) + putParcelableArrayList(EXTRA_ALTERNATE_INTENTS, ArrayList()) + putParcelableArrayList(EXTRA_CHOOSER_TARGETS, ArrayList()) + putParcelable(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER, createUri(2)) + } + ) + + val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) + + val targetIntent = Intent(ACTION_SEND) + val result = testSubject.onSelectionChanged(targetIntent) + assertWithMessage("Callback result should not be null").that(result).isNotNull() + requireNotNull(result) + assertThat(result.customActions).isNull() + assertThat(result.modifyShareAction).isNull() + assertThat(result.alternateIntents).isNull() + assertThat(result.callerTargets).isNull() + assertThat(result.refinementIntentSender).isNull() } } -- cgit v1.2.3-59-g8ed1b From 686477182299c6e3780c096f3461267d4f7c4ddd Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Thu, 22 Feb 2024 11:33:14 -0500 Subject: Renames ActivityLaunch to ActivityModel This is a straight automated rename refactor for clarity and based on team feedback and arch guidelines for consistency. Only changing names with no functional changes. Bug: 309960444 Change-Id: Ibe93ae5fc0198432fc65d9c43922d6fb6913364d --- .../android/intentresolver/v2/ChooserActivity.java | 22 +++---- .../intentresolver/v2/ResolverActivity.java | 22 +++---- .../intentresolver/v2/ui/model/ActivityLaunch.kt | 68 ---------------------- .../v2/ui/model/ActivityLaunchModule.kt | 43 -------------- .../intentresolver/v2/ui/model/ActivityModel.kt | 68 ++++++++++++++++++++++ .../v2/ui/model/ActivityModelModule.kt | 43 ++++++++++++++ .../v2/ui/viewmodel/ChooserRequestReader.kt | 6 +- .../v2/ui/viewmodel/ChooserViewModel.kt | 12 ++-- .../v2/ui/viewmodel/ResolverRequestReader.kt | 4 +- .../v2/ui/model/TestActivityLaunchModule.kt | 41 ------------- .../v2/ui/model/TestActivityModelModule.kt | 45 ++++++++++++++ .../v2/ui/model/ActivityLaunchTest.kt | 32 +++++----- .../v2/ui/viewmodel/ChooserRequestTest.kt | 40 ++++++------- .../v2/ui/viewmodel/ResolverRequestTest.kt | 32 +++++----- 14 files changed, 241 insertions(+), 237 deletions(-) delete mode 100644 java/src/com/android/intentresolver/v2/ui/model/ActivityLaunch.kt delete mode 100644 java/src/com/android/intentresolver/v2/ui/model/ActivityLaunchModule.kt create mode 100644 java/src/com/android/intentresolver/v2/ui/model/ActivityModel.kt create mode 100644 java/src/com/android/intentresolver/v2/ui/model/ActivityModelModule.kt delete mode 100644 tests/activity/src/com/android/intentresolver/v2/ui/model/TestActivityLaunchModule.kt create mode 100644 tests/activity/src/com/android/intentresolver/v2/ui/model/TestActivityModelModule.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 68815067..3d8bfac5 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -156,7 +156,7 @@ import com.android.intentresolver.v2.profiles.TabConfig; import com.android.intentresolver.v2.ui.ActionTitle; import com.android.intentresolver.v2.ui.ShareResultSender; import com.android.intentresolver.v2.ui.ShareResultSenderFactory; -import com.android.intentresolver.v2.ui.model.ActivityLaunch; +import com.android.intentresolver.v2.ui.model.ActivityModel; import com.android.intentresolver.v2.ui.model.ChooserRequest; import com.android.intentresolver.v2.ui.viewmodel.ChooserViewModel; import com.android.intentresolver.widget.ImagePreviewView; @@ -273,7 +273,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private static final int SCROLL_STATUS_SCROLLING_VERTICAL = 1; private static final int SCROLL_STATUS_SCROLLING_HORIZONTAL = 2; - @Inject public ActivityLaunch mActivityLaunch; + @Inject public ActivityModel mActivityModel; @Inject public FeatureFlags mFeatureFlags; @Inject public android.service.chooser.FeatureFlags mChooserServiceFeatureFlags; @Inject public EventLog mEventLog; @@ -347,15 +347,15 @@ public class ChooserActivity extends Hilt_ChooserActivity implements public CreationExtras getDefaultViewModelCreationExtras() { return addDefaultArgs( super.getDefaultViewModelCreationExtras(), - new Pair<>(ActivityLaunch.ACTIVITY_LAUNCH_KEY, mActivityLaunch)); + new Pair<>(ActivityModel.ACTIVITY_MODEL_KEY, mActivityModel)); } @Override protected final void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Log.i(TAG, "onCreate"); - Log.i(TAG, "activityLaunch=" + mActivityLaunch.toString()); - int callerUid = mActivityLaunch.getFromUid(); + Log.i(TAG, "activityLaunch=" + mActivityModel.toString()); + int callerUid = mActivityModel.getLaunchedFromUid(); if (callerUid < 0 || UserHandle.isIsolated(callerUid)) { Log.e(TAG, "Can't start a resolver from uid " + callerUid); finish(); @@ -371,7 +371,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mViewModel.getChooserRequest().getChosenComponentSender(); if (chosenComponentSender != null) { mShareResultSender = mShareResultSenderFactory - .create(mActivityLaunch.getFromUid(), chosenComponentSender); + .create(mActivityModel.getLaunchedFromUid(), chosenComponentSender); } mLogic = createActivityLogic(); init(); @@ -491,7 +491,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements .get(BasePreviewViewModel.class); previewViewModel.init( chooserRequest.getTargetIntent(), - mActivityLaunch.getIntent(), + mActivityModel.getIntent(), chooserRequest.getAdditionalContentUri(), chooserRequest.getFocusedItemPosition(), mChooserServiceFeatureFlags.chooserPayloadToggling()); @@ -862,9 +862,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } } catch (RuntimeException e) { Slog.wtf(TAG, - "Unable to launch as uid " + mActivityLaunch.getFromUid() - + " package " + mActivityLaunch.getFromPackage() + ", while running in " - + ActivityThread.currentProcessName(), e); + "Unable to launch as uid " + mActivityModel.getLaunchedFromUid() + + " package " + mActivityModel.getLaunchedFromPackage() + + ", while running in " + ActivityThread.currentProcessName(), e); } } @@ -1658,7 +1658,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return false; } - return mActivityLaunch.getIntent().getBooleanExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, + return mActivityModel.getIntent().getBooleanExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, true); } diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index 241b6735..98e82b00 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -113,7 +113,7 @@ import com.android.intentresolver.v2.profiles.OnProfileSelectedListener; import com.android.intentresolver.v2.profiles.TabConfig; import com.android.intentresolver.v2.profiles.ResolverMultiProfilePagerAdapter; import com.android.intentresolver.v2.ui.ActionTitle; -import com.android.intentresolver.v2.ui.model.ActivityLaunch; +import com.android.intentresolver.v2.ui.model.ActivityModel; import com.android.intentresolver.v2.ui.model.ResolverRequest; import com.android.intentresolver.v2.validation.ValidationResult; import com.android.intentresolver.widget.ResolverDrawerLayout; @@ -149,7 +149,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements ResolverListAdapter.ResolverListCommunicator { @Inject public PackageManager mPackageManager; - @Inject public ActivityLaunch mActivityLaunch; + @Inject public ActivityModel mActivityModel; @Inject public DevicePolicyResources mDevicePolicyResources; @Inject public IntentForwarding mIntentForwarding; private ResolverRequest mResolverRequest; @@ -227,7 +227,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements public CreationExtras getDefaultViewModelCreationExtras() { return addDefaultArgs( super.getDefaultViewModelCreationExtras(), - new Pair<>(ActivityLaunch.ACTIVITY_LAUNCH_KEY, mActivityLaunch)); + new Pair<>(ActivityModel.ACTIVITY_MODEL_KEY, mActivityModel)); } @Override @@ -235,14 +235,14 @@ public class ResolverActivity extends Hilt_ResolverActivity implements super.onCreate(savedInstanceState); setTheme(R.style.Theme_DeviceDefault_Resolver); Log.i(TAG, "onCreate"); - Log.i(TAG, "activityLaunch=" + mActivityLaunch.toString()); - int callerUid = mActivityLaunch.getFromUid(); + Log.i(TAG, "activityLaunch=" + mActivityModel.toString()); + int callerUid = mActivityModel.getLaunchedFromUid(); if (callerUid < 0 || UserHandle.isIsolated(callerUid)) { Log.e(TAG, "Can't start a resolver from uid " + callerUid); finish(); } - ValidationResult result = readResolverRequest(mActivityLaunch); + ValidationResult result = readResolverRequest(mActivityModel); if (!result.isSuccess()) { result.reportToLogcat(TAG); finish(); @@ -748,7 +748,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements new ResolverRankerServiceResolverComparator( this, mResolverRequest.getIntent(), - mActivityLaunch.getReferrerPackage(), + mActivityModel.getReferrerPackage(), null, null, getResolverRankerServiceUserHandleList(userHandle), @@ -756,9 +756,9 @@ public class ResolverActivity extends Hilt_ResolverActivity implements return new ResolverListController( this, mPackageManager, - mActivityLaunch.getIntent(), - mActivityLaunch.getReferrerPackage(), - mActivityLaunch.getFromUid(), + mActivityModel.getIntent(), + mActivityModel.getReferrerPackage(), + mActivityModel.getLaunchedFromUid(), resolverComparator, getQueryIntentsUser(userHandle)); } @@ -1479,7 +1479,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements } } catch (RuntimeException e) { Slog.wtf(TAG, - "Unable to launch as uid " + mActivityLaunch.getFromUid() + "Unable to launch as uid " + mActivityModel.getLaunchedFromUid() + " package " + getLaunchedFromPackage() + ", while running in " + ActivityThread.currentProcessName(), e); } diff --git a/java/src/com/android/intentresolver/v2/ui/model/ActivityLaunch.kt b/java/src/com/android/intentresolver/v2/ui/model/ActivityLaunch.kt deleted file mode 100644 index e5f342d9..00000000 --- a/java/src/com/android/intentresolver/v2/ui/model/ActivityLaunch.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright (C) 2024 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.v2.ui.model - -import android.content.Intent -import android.net.Uri -import android.os.Parcel -import android.os.Parcelable -import com.android.intentresolver.v2.ext.readParcelable -import com.android.intentresolver.v2.ext.requireParcelable - -/** Contains Activity-scope information about the state at launch time. */ -data class ActivityLaunch( - /** The [Intent] received by the app */ - val intent: Intent, - /** The identifier for the sending app and user */ - val fromUid: Int, - /** The package of the sending app */ - val fromPackage: String, - /** The referrer as supplied to the activity. */ - val referrer: Uri? -) : Parcelable { - constructor( - source: Parcel - ) : this( - intent = source.requireParcelable(), - fromUid = source.readInt(), - fromPackage = requireNotNull(source.readString()), - referrer = source.readParcelable() - ) - - /** A package name from referrer, if it is an android-app URI */ - val referrerPackage = referrer?.takeIf { it.scheme == ANDROID_APP_SCHEME }?.authority - - override fun describeContents() = 0 /* flags */ - - override fun writeToParcel(dest: Parcel, flags: Int) { - dest.writeParcelable(intent, flags) - dest.writeInt(fromUid) - dest.writeString(fromPackage) - dest.writeParcelable(referrer, flags) - } - - companion object { - const val ACTIVITY_LAUNCH_KEY = "com.android.intentresolver.ACTIVITY_LAUNCH" - - @JvmField - @Suppress("unused") - val CREATOR = - object : Parcelable.Creator { - override fun newArray(size: Int) = arrayOfNulls(size) - override fun createFromParcel(source: Parcel) = ActivityLaunch(source) - } - } -} diff --git a/java/src/com/android/intentresolver/v2/ui/model/ActivityLaunchModule.kt b/java/src/com/android/intentresolver/v2/ui/model/ActivityLaunchModule.kt deleted file mode 100644 index bb8f3a54..00000000 --- a/java/src/com/android/intentresolver/v2/ui/model/ActivityLaunchModule.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright (C) 2024 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.v2.ui.model - -import android.app.Activity -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.components.ActivityComponent -import dagger.hilt.android.scopes.ActivityScoped - -@Module -@InstallIn(ActivityComponent::class) -object ActivityLaunchModule { - - @Provides - @ActivityScoped - fun callerInfo(activity: Activity): ActivityLaunch { - return ActivityLaunch( - activity.intent, - activity.launchedFromUid, - requireNotNull(activity.launchedFromPackage) { - "activity.launchedFromPackage was null. This is expected to be non-null for " + - "any system-signed application!" - }, - activity.referrer - ) - } -} diff --git a/java/src/com/android/intentresolver/v2/ui/model/ActivityModel.kt b/java/src/com/android/intentresolver/v2/ui/model/ActivityModel.kt new file mode 100644 index 00000000..02bb6640 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ui/model/ActivityModel.kt @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2024 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.v2.ui.model + +import android.content.Intent +import android.net.Uri +import android.os.Parcel +import android.os.Parcelable +import com.android.intentresolver.v2.ext.readParcelable +import com.android.intentresolver.v2.ext.requireParcelable + +/** Contains Activity-scope information about the state when started. */ +data class ActivityModel( + /** The [Intent] received by the app */ + val intent: Intent, + /** The identifier for the sending app and user */ + val launchedFromUid: Int, + /** The package of the sending app */ + val launchedFromPackage: String, + /** The referrer as supplied to the activity. */ + val referrer: Uri? +) : Parcelable { + constructor( + source: Parcel + ) : this( + intent = source.requireParcelable(), + launchedFromUid = source.readInt(), + launchedFromPackage = requireNotNull(source.readString()), + referrer = source.readParcelable() + ) + + /** A package name from referrer, if it is an android-app URI */ + val referrerPackage = referrer?.takeIf { it.scheme == ANDROID_APP_SCHEME }?.authority + + override fun describeContents() = 0 /* flags */ + + override fun writeToParcel(dest: Parcel, flags: Int) { + dest.writeParcelable(intent, flags) + dest.writeInt(launchedFromUid) + dest.writeString(launchedFromPackage) + dest.writeParcelable(referrer, flags) + } + + companion object { + const val ACTIVITY_MODEL_KEY = "com.android.intentresolver.ACTIVITY_MODEL" + + @JvmField + @Suppress("unused") + val CREATOR = + object : Parcelable.Creator { + override fun newArray(size: Int) = arrayOfNulls(size) + override fun createFromParcel(source: Parcel) = ActivityModel(source) + } + } +} diff --git a/java/src/com/android/intentresolver/v2/ui/model/ActivityModelModule.kt b/java/src/com/android/intentresolver/v2/ui/model/ActivityModelModule.kt new file mode 100644 index 00000000..d9fb1fa6 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ui/model/ActivityModelModule.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2024 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.v2.ui.model + +import android.app.Activity +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.android.scopes.ActivityScoped + +@Module +@InstallIn(ActivityComponent::class) +object ActivityModelModule { + + @Provides + @ActivityScoped + fun activityModel(activity: Activity): ActivityModel { + return ActivityModel( + intent = activity.intent, + launchedFromUid = activity.launchedFromUid, + launchedFromPackage = requireNotNull(activity.launchedFromPackage) { + "activity.launchedFromPackage was null. This is expected to be non-null for " + + "any system-signed application!" + }, + referrer = activity.referrer + ) + } +} diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt index e32d69b0..8fe1dba5 100644 --- a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt +++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt @@ -47,7 +47,7 @@ import com.android.intentresolver.inject.ChooserServiceFlags import com.android.intentresolver.util.hasValidIcon import com.android.intentresolver.v2.ext.hasSendAction import com.android.intentresolver.v2.ext.ifMatch -import com.android.intentresolver.v2.ui.model.ActivityLaunch +import com.android.intentresolver.v2.ui.model.ActivityModel import com.android.intentresolver.v2.ui.model.ChooserRequest import com.android.intentresolver.v2.validation.ValidationResult import com.android.intentresolver.v2.validation.types.IntentOrUri @@ -65,7 +65,7 @@ internal fun Intent.maybeAddSendActionFlags() = } fun readChooserRequest( - launch: ActivityLaunch, + launch: ActivityModel, flags: ChooserServiceFlags ): ValidationResult { val extras = launch.intent.extras ?: Bundle() @@ -162,7 +162,7 @@ fun readChooserRequest( isSendActionTarget = isSendAction, targetType = targetIntent.type, launchedFromPackage = - requireNotNull(launch.fromPackage) { + requireNotNull(launch.launchedFromPackage) { "launch.fromPackage was null, See Activity.getLaunchedFromPackage()" }, title = customTitle, diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt index a03f3769..cd1a16e3 100644 --- a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt +++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt @@ -19,8 +19,8 @@ import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import com.android.intentresolver.inject.ChooserServiceFlags -import com.android.intentresolver.v2.ui.model.ActivityLaunch -import com.android.intentresolver.v2.ui.model.ActivityLaunch.Companion.ACTIVITY_LAUNCH_KEY +import com.android.intentresolver.v2.ui.model.ActivityModel +import com.android.intentresolver.v2.ui.model.ActivityModel.Companion.ACTIVITY_MODEL_KEY import com.android.intentresolver.v2.ui.model.ChooserRequest import com.android.intentresolver.v2.validation.ValidationResult import dagger.hilt.android.lifecycle.HiltViewModel @@ -36,14 +36,14 @@ constructor( flags: ChooserServiceFlags, ) : ViewModel() { - private val mActivityLaunch: ActivityLaunch = - requireNotNull(args[ACTIVITY_LAUNCH_KEY]) { - "ActivityLaunch missing in SavedStateHandle! ($ACTIVITY_LAUNCH_KEY)" + private val mActivityModel: ActivityModel = + requireNotNull(args[ACTIVITY_MODEL_KEY]) { + "ActivityModel missing in SavedStateHandle! ($ACTIVITY_MODEL_KEY)" } /** The result of reading and validating the inputs provided in savedState. */ private val status: ValidationResult = - readChooserRequest(mActivityLaunch, flags) + readChooserRequest(mActivityModel, flags) val chooserRequest: ChooserRequest by lazy { status.getOrThrow() } diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestReader.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestReader.kt index 22d76493..bbc376ea 100644 --- a/java/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestReader.kt +++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestReader.kt @@ -21,7 +21,7 @@ import android.os.UserHandle import com.android.intentresolver.v2.ResolverActivity.PROFILE_PERSONAL import com.android.intentresolver.v2.ResolverActivity.PROFILE_WORK import com.android.intentresolver.v2.shared.model.Profile -import com.android.intentresolver.v2.ui.model.ActivityLaunch +import com.android.intentresolver.v2.ui.model.ActivityModel import com.android.intentresolver.v2.ui.model.ResolverRequest import com.android.intentresolver.v2.validation.Validation import com.android.intentresolver.v2.validation.ValidationResult @@ -33,7 +33,7 @@ const val EXTRA_SELECTED_PROFILE = "com.android.internal.app.ResolverActivity.EXTRA_SELECTED_PROFILE" const val EXTRA_IS_AUDIO_CAPTURE_DEVICE = "is_audio_capture_device" -fun readResolverRequest(launch: ActivityLaunch): ValidationResult { +fun readResolverRequest(launch: ActivityModel): ValidationResult { @Suppress("DEPRECATION") return validateFrom((launch.intent.extras ?: Bundle())::get) { val callingUser = optional(value(EXTRA_CALLING_USER)) diff --git a/tests/activity/src/com/android/intentresolver/v2/ui/model/TestActivityLaunchModule.kt b/tests/activity/src/com/android/intentresolver/v2/ui/model/TestActivityLaunchModule.kt deleted file mode 100644 index 7dd15dbe..00000000 --- a/tests/activity/src/com/android/intentresolver/v2/ui/model/TestActivityLaunchModule.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright (C) 2024 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.v2.ui.model - -import android.app.Activity -import android.net.Uri -import dagger.Module -import dagger.Provides -import dagger.hilt.android.components.ActivityComponent -import dagger.hilt.android.scopes.ActivityScoped -import dagger.hilt.testing.TestInstallIn - -@Module -@TestInstallIn(components = [ActivityComponent::class], replaces = [ActivityLaunchModule::class]) -class TestActivityLaunchModule { - - @Provides - @ActivityScoped - fun activityLaunch(activity: Activity): ActivityLaunch { - return ActivityLaunch(activity.intent, LAUNCHED_FROM_UID, LAUNCHED_FROM_PACKAGE, REFERRER) - } - - companion object { - const val LAUNCHED_FROM_PACKAGE = "example.com" - const val LAUNCHED_FROM_UID = 1234 - val REFERRER: Uri = Uri.fromParts(ANDROID_APP_SCHEME, LAUNCHED_FROM_PACKAGE, "") - } -} diff --git a/tests/activity/src/com/android/intentresolver/v2/ui/model/TestActivityModelModule.kt b/tests/activity/src/com/android/intentresolver/v2/ui/model/TestActivityModelModule.kt new file mode 100644 index 00000000..7d05dc0f --- /dev/null +++ b/tests/activity/src/com/android/intentresolver/v2/ui/model/TestActivityModelModule.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2024 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.v2.ui.model + +import android.app.Activity +import android.net.Uri +import dagger.Module +import dagger.Provides +import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.android.scopes.ActivityScoped +import dagger.hilt.testing.TestInstallIn + +@Module +@TestInstallIn(components = [ActivityComponent::class], replaces = [ActivityModelModule::class]) +class TestActivityModelModule { + + @Provides + @ActivityScoped + fun activityModel(activity: Activity): ActivityModel { + return ActivityModel( + intent = activity.intent, + launchedFromUid = LAUNCHED_FROM_UID, + launchedFromPackage = LAUNCHED_FROM_PACKAGE, + referrer = REFERRER) + } + + companion object { + const val LAUNCHED_FROM_PACKAGE = "example.com" + const val LAUNCHED_FROM_UID = 1234 + val REFERRER: Uri = Uri.fromParts(ANDROID_APP_SCHEME, LAUNCHED_FROM_PACKAGE, "") + } +} diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/model/ActivityLaunchTest.kt b/tests/unit/src/com/android/intentresolver/v2/ui/model/ActivityLaunchTest.kt index 25eac220..e30cd81a 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ui/model/ActivityLaunchTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/ui/model/ActivityLaunchTest.kt @@ -29,7 +29,7 @@ class ActivityLaunchTest { @Test fun testDefaultValues() { - val input = ActivityLaunch(Intent(ACTION_CHOOSER), 0, "example.com", null) + val input = ActivityModel(Intent(ACTION_CHOOSER), 0, "example.com", null) val output = input.toParcelAndBack() @@ -40,7 +40,7 @@ class ActivityLaunchTest { fun testCommonValues() { val intent = Intent(ACTION_CHOOSER).apply { putExtra(EXTRA_TEXT, "Test") } val input = - ActivityLaunch(intent, 1234, "com.example", Uri.parse("android-app://example.com")) + ActivityModel(intent, 1234, "com.example", Uri.parse("android-app://example.com")) val output = input.toParcelAndBack() @@ -50,10 +50,10 @@ class ActivityLaunchTest { @Test fun testReferrerPackage_withAppReferrer_usesReferrer() { val launch1 = - ActivityLaunch( + ActivityModel( intent = Intent(), - fromUid = 1000, - fromPackage = "other.example.com", + launchedFromUid = 1000, + launchedFromPackage = "other.example.com", referrer = Uri.parse("android-app://app.example.com") ) @@ -63,10 +63,10 @@ class ActivityLaunchTest { @Test fun testReferrerPackage_httpReferrer_isNull() { val launch = - ActivityLaunch( + ActivityModel( intent = Intent(), - fromUid = 1000, - fromPackage = "example.com", + launchedFromUid = 1000, + launchedFromPackage = "example.com", referrer = Uri.parse("http://some.other.value") ) @@ -76,29 +76,29 @@ class ActivityLaunchTest { @Test fun testReferrerPackage_nullReferrer_isNull() { val launch = - ActivityLaunch( + ActivityModel( intent = Intent(), - fromUid = 1000, - fromPackage = "example.com", + launchedFromUid = 1000, + launchedFromPackage = "example.com", referrer = null ) assertThat(launch.referrerPackage).isNull() } - private fun assertEquals(expected: ActivityLaunch, actual: ActivityLaunch) { + private fun assertEquals(expected: ActivityModel, actual: ActivityModel) { // Test fields separately: Intent does not override equals() assertWithMessage("%s.filterEquals(%s)", actual.intent, expected.intent) .that(actual.intent.filterEquals(expected.intent)) .isTrue() assertWithMessage("actual fromUid is equal to expected") - .that(actual.fromUid) - .isEqualTo(expected.fromUid) + .that(actual.launchedFromUid) + .isEqualTo(expected.launchedFromUid) assertWithMessage("actual fromPackage is equal to expected") - .that(actual.fromPackage) - .isEqualTo(expected.fromPackage) + .that(actual.launchedFromPackage) + .isEqualTo(expected.launchedFromPackage) assertWithMessage("actual referrer is equal to expected") .that(actual.referrer) diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt index 4a33f733..d2ddf680 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt @@ -29,7 +29,7 @@ import androidx.core.net.toUri import androidx.core.os.bundleOf import com.android.intentresolver.ContentTypeHint import com.android.intentresolver.inject.FakeChooserServiceFlags -import com.android.intentresolver.v2.ui.model.ActivityLaunch +import com.android.intentresolver.v2.ui.model.ActivityModel import com.android.intentresolver.v2.ui.model.ChooserRequest import com.android.intentresolver.v2.validation.RequiredValueMissing import com.android.intentresolver.v2.validation.ValidationResultSubject.Companion.assertThat @@ -44,18 +44,18 @@ private const val EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI = private const val EXTRA_CHOOSER_FOCUSED_ITEM_POSITION = "android.intent.extra.CHOOSER_FOCUSED_ITEM_POSITION" -private fun createLaunch( +private fun createActivityModel( targetIntent: Intent?, referrer: Uri? = null, additionalIntents: List? = null ) = - ActivityLaunch( + ActivityModel( Intent(ACTION_CHOOSER).apply { targetIntent?.also { putExtra(EXTRA_INTENT, it) } additionalIntents?.also { putExtra(EXTRA_ALTERNATE_INTENTS, it.toTypedArray()) } }, - fromUid = 10000, - fromPackage = "com.android.example", + launchedFromUid = 10000, + launchedFromPackage = "com.android.example", referrer = referrer ?: "android-app://com.android.example".toUri() ) @@ -70,7 +70,7 @@ class ChooserRequestTest { @Test fun missingIntent() { - val launch = createLaunch(targetIntent = null) + val launch = createActivityModel(targetIntent = null) val result = readChooserRequest(launch, fakeChooserServiceFlags) assertThat(result).value().isNull() @@ -82,7 +82,7 @@ class ChooserRequestTest { @Test fun referrerFillIn() { val referrer = Uri.parse("android-app://example.com") - val launch = createLaunch(targetIntent = Intent(ACTION_SEND), referrer) + val launch = createActivityModel(targetIntent = Intent(ACTION_SEND), referrer) launch.intent.putExtras(bundleOf(EXTRA_REFERRER to referrer)) val result = readChooserRequest(launch, fakeChooserServiceFlags) @@ -97,7 +97,7 @@ class ChooserRequestTest { val referrer = Uri.parse("http://example.com") val intent = Intent().putExtras(bundleOf(EXTRA_INTENT to Intent(ACTION_SEND))) - val launch = createLaunch(targetIntent = intent, referrer = referrer) + val launch = createActivityModel(targetIntent = intent, referrer = referrer) val result = readChooserRequest(launch, fakeChooserServiceFlags) @@ -107,7 +107,7 @@ class ChooserRequestTest { @Test fun referrerPackage_fromAppReferrer() { val referrer = Uri.parse("android-app://example.com") - val launch = createLaunch(targetIntent = Intent(ACTION_SEND), referrer) + val launch = createActivityModel(targetIntent = Intent(ACTION_SEND), referrer) launch.intent.putExtras(bundleOf(EXTRA_REFERRER to referrer)) @@ -120,7 +120,7 @@ class ChooserRequestTest { fun payloadIntents_includesTargetThenAdditional() { val intent1 = Intent(ACTION_SEND) val intent2 = Intent(ACTION_SEND_MULTIPLE) - val launch = createLaunch(targetIntent = intent1, additionalIntents = listOf(intent2)) + val launch = createActivityModel(targetIntent = intent1, additionalIntents = listOf(intent2)) val result = readChooserRequest(launch, fakeChooserServiceFlags) assertThat(result.value?.payloadIntents).containsExactly(intent1, intent2) @@ -129,12 +129,12 @@ class ChooserRequestTest { @Test fun testRequest_withOnlyRequiredValues() { val intent = Intent().putExtras(bundleOf(EXTRA_INTENT to Intent(ACTION_SEND))) - val launch = createLaunch(targetIntent = intent) + val launch = createActivityModel(targetIntent = intent) val result = readChooserRequest(launch, fakeChooserServiceFlags) assertThat(result).value().isNotNull() val value: ChooserRequest = result.getOrThrow() - assertThat(value.launchedFromPackage).isEqualTo(launch.fromPackage) + assertThat(value.launchedFromPackage).isEqualTo(launch.launchedFromPackage) assertThat(result).findings().isEmpty() } @@ -144,7 +144,7 @@ class ChooserRequestTest { val uri = Uri.parse("content://org.pkg/path") val position = 10 val launch = - createLaunch(targetIntent = Intent(ACTION_SEND)).apply { + createActivityModel(targetIntent = Intent(ACTION_SEND)).apply { intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, uri) intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, position) } @@ -163,7 +163,7 @@ class ChooserRequestTest { val uri = Uri.parse("content://org.pkg/path") val position = 10 val launch = - createLaunch(targetIntent = Intent(ACTION_SEND)).apply { + createActivityModel(targetIntent = Intent(ACTION_SEND)).apply { intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, uri) intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, position) } @@ -180,7 +180,7 @@ class ChooserRequestTest { fun testRequest_actionSendWithInvalidAdditionalContentUri() { fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, true) val launch = - createLaunch(targetIntent = Intent(ACTION_SEND)).apply { + createActivityModel(targetIntent = Intent(ACTION_SEND)).apply { intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, "content://org.pkg/path") intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, "1") } @@ -195,7 +195,7 @@ class ChooserRequestTest { @Test fun testRequest_actionSendWithoutAdditionalContentUri() { fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, true) - val launch = createLaunch(targetIntent = Intent(ACTION_SEND)) + val launch = createActivityModel(targetIntent = Intent(ACTION_SEND)) val result = readChooserRequest(launch, fakeChooserServiceFlags) assertThat(result).value().isNotNull() @@ -210,7 +210,7 @@ class ChooserRequestTest { val uri = Uri.parse("content://org.pkg/path") val position = 10 val launch = - createLaunch(targetIntent = Intent(ACTION_VIEW)).apply { + createActivityModel(targetIntent = Intent(ACTION_VIEW)).apply { intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, uri) intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, position) } @@ -226,7 +226,7 @@ class ChooserRequestTest { @Test fun testAlbumType() { fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_ALBUM_TEXT, true) - val launch = createLaunch(Intent(ACTION_SEND)) + val launch = createActivityModel(Intent(ACTION_SEND)) launch.intent.putExtra( Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT, Intent.CHOOSER_CONTENT_TYPE_ALBUM @@ -245,7 +245,7 @@ class ChooserRequestTest { fakeChooserServiceFlags.setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, false) val metadataText: CharSequence = "Test metadata text" val launch = - createLaunch(targetIntent = Intent()).apply { + createActivityModel(targetIntent = Intent()).apply { intent.putExtra(Intent.EXTRA_METADATA_TEXT, metadataText) } @@ -263,7 +263,7 @@ class ChooserRequestTest { fakeChooserServiceFlags.setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, true) val metadataText: CharSequence = "Test metadata text" val launch = - createLaunch(targetIntent = Intent()).apply { + createActivityModel(targetIntent = Intent()).apply { intent.putExtra(Intent.EXTRA_METADATA_TEXT, metadataText) } diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestTest.kt b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestTest.kt index e88c46f5..cc9b9a77 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestTest.kt @@ -23,7 +23,7 @@ import androidx.core.net.toUri import androidx.core.os.bundleOf import com.android.intentresolver.v2.ResolverActivity.PROFILE_WORK import com.android.intentresolver.v2.shared.model.Profile.Type.WORK -import com.android.intentresolver.v2.ui.model.ActivityLaunch +import com.android.intentresolver.v2.ui.model.ActivityModel import com.android.intentresolver.v2.ui.model.ResolverRequest import com.android.intentresolver.v2.validation.UncaughtException import com.android.intentresolver.v2.validation.ValidationResultSubject.Companion.assertThat @@ -33,14 +33,14 @@ import org.junit.Test private val targetUri = Uri.parse("content://example.com/123") -private fun createLaunch( +private fun createActivityModel( targetIntent: Intent, referrer: Uri? = null, ) = - ActivityLaunch( + ActivityModel( intent = targetIntent, - fromUid = 10000, - fromPackage = "com.android.example", + launchedFromUid = 10000, + launchedFromPackage = "com.android.example", referrer = referrer ?: "android-app://com.android.example".toUri() ) @@ -48,14 +48,14 @@ class ResolverRequestTest { @Test fun testDefaults() { val intent = Intent(ACTION_VIEW).apply { data = targetUri } - val launch = createLaunch(intent) + val activity = createActivityModel(intent) - val result = readResolverRequest(launch) + val result = readResolverRequest(activity) assertThat(result).isSuccess() assertThat(result).findings().isEmpty() val value: ResolverRequest = result.getOrThrow() - assertThat(value.intent.filterEquals(launch.intent)).isTrue() + assertThat(value.intent.filterEquals(activity.intent)).isTrue() assertThat(value.callingUser).isNull() assertThat(value.selectedProfile).isNull() } @@ -68,9 +68,9 @@ class ResolverRequestTest { putExtra(EXTRA_SELECTED_PROFILE, -1000) } - val launch = createLaunch(intent) + val activity = createActivityModel(intent) - val result = readResolverRequest(launch) + val result = readResolverRequest(activity) assertThat(result).isFailure() assertWithMessage("the first finding") @@ -85,9 +85,9 @@ class ResolverRequestTest { Intent(Intent.ACTION_SEND).apply { putParcelableArrayListExtra(Intent.EXTRA_ALTERNATE_INTENTS, arrayListOf(intent2)) } - val launch = createLaunch(targetIntent = intent1) + val activity = createActivityModel(targetIntent = intent1) - val result = readResolverRequest(launch) + val result = readResolverRequest(activity) // Assert that payloadIntents does NOT include EXTRA_ALTERNATE_INTENTS // that is only supported for Chooser and should be not be added here. @@ -97,9 +97,9 @@ class ResolverRequestTest { @Test fun testAllValues() { val intent = Intent(ACTION_VIEW).apply { data = Uri.parse("content://example.com/123") } - val launch = createLaunch(targetIntent = intent) + val activity = createActivityModel(targetIntent = intent) - launch.intent.putExtras( + activity.intent.putExtras( bundleOf( EXTRA_CALLING_USER to UserHandle.of(123), EXTRA_SELECTED_PROFILE to PROFILE_WORK, @@ -107,12 +107,12 @@ class ResolverRequestTest { ) ) - val result = readResolverRequest(launch) + val result = readResolverRequest(activity) assertThat(result).value().isNotNull() val value: ResolverRequest = result.getOrThrow() - assertThat(value.intent.filterEquals(launch.intent)).isTrue() + assertThat(value.intent.filterEquals(activity.intent)).isTrue() assertThat(value.isAudioCaptureDevice).isTrue() assertThat(value.callingUser).isEqualTo(UserHandle.of(123)) assertThat(value.selectedProfile).isEqualTo(WORK) -- cgit v1.2.3-59-g8ed1b From cc90909746d6ba96711fcecf6b1cdc5393967e38 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Fri, 16 Feb 2024 17:02:49 -0800 Subject: Shareousel: read preview uri for non-image items. Move some common metadata-related methods out of PreviewDataProvider into a separate file; use those methods in UriMetadataReader to determine preview uri value. Bug: 302691505 Test: IntentResolver-tests-unit Change-Id: Id154fc28dae8127ba41904ba9bf966763dc29320 --- .../contentpreview/PreviewDataProvider.kt | 55 +--------- .../contentpreview/UriMetadataHelpers.kt | 116 +++++++++++++++++++++ .../contentpreview/UriMetadataReader.kt | 56 +++++++--- .../contentpreview/UriMetadataReaderTest.kt | 100 ++++++++++++++++++ 4 files changed, 260 insertions(+), 67 deletions(-) create mode 100644 java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt create mode 100644 tests/unit/src/com/android/intentresolver/contentpreview/UriMetadataReaderTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt index 3f306a80..96bb8258 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt @@ -18,7 +18,6 @@ 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 @@ -277,8 +276,7 @@ constructor( val isImageType: Boolean get() = typeClassifier.isImageType(mimeType) val supportsImageType: Boolean by lazy { - contentResolver.getStreamTypesSafe(uri)?.firstOrNull(typeClassifier::isImageType) != - null + contentResolver.getStreamTypesSafe(uri).firstOrNull(typeClassifier::isImageType) != null } val supportsThumbnail: Boolean get() = query.supportsThumbnail @@ -290,7 +288,8 @@ constructor( private val query by lazy { readQueryResult() } private fun readQueryResult(): QueryResult = - contentResolver.querySafe(uri)?.use { cursor -> + // TODO: rewrite using methods from UiMetadataHelpers.kt + contentResolver.querySafe(uri, METADATA_COLUMNS)?.use { cursor -> if (!cursor.moveToFirst()) return@use null var flagColIdx = -1 @@ -371,51 +370,3 @@ private fun getFileName(uri: Uri): String { 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/UriMetadataHelpers.kt b/java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt new file mode 100644 index 00000000..41638b1f --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt @@ -0,0 +1,116 @@ +/* + * Copyright 2024 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.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 com.android.intentresolver.measurements.runTracing + +internal 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 + } + } + +internal fun ContentInterface.getStreamTypesSafe(uri: Uri): Array = + runTracing("getStreamTypes") { + try { + getStreamTypes(uri, "*/*") ?: emptyArray() + } catch (e: SecurityException) { + logProviderPermissionWarning(uri, "stream types") + emptyArray() + } catch (t: Throwable) { + Log.e(ContentPreviewUi.TAG, "Failed to read stream types, uri: $uri", t) + emptyArray() + } + } + +internal fun ContentInterface.querySafe(uri: Uri, columns: Array): Cursor? = + runTracing("query") { + try { + query(uri, 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 + } + } + +internal fun Cursor.readSupportsThumbnail(): Boolean = + runCatching { + val flagColIdx = columnNames.indexOf(DocumentsContract.Document.COLUMN_FLAGS) + flagColIdx >= 0 && ((getInt(flagColIdx) and FLAG_SUPPORTS_THUMBNAIL) != 0) + } + .getOrDefault(false) + +internal fun Cursor.readPreviewUri(): Uri? = + runCatching { + columnNames + .indexOf(MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI) + .takeIf { it >= 0 } + ?.let { getString(it)?.let(Uri::parse) } + } + .getOrNull() + +internal fun Cursor.readTitle(): String = + runCatching { + var nameColIndex = -1 + var titleColIndex = -1 + // TODO: double-check why Cursor#getColumnInded didn't work + columnNames.forEachIndexed { i, columnName -> + when (columnName) { + OpenableColumns.DISPLAY_NAME -> nameColIndex = i + Downloads.Impl.COLUMN_TITLE -> titleColIndex = i + } + } + + var title = "" + if (nameColIndex >= 0) { + title = getString(nameColIndex) ?: "" + } + if (TextUtils.isEmpty(title) && titleColIndex >= 0) { + title = getString(titleColIndex) ?: "" + } + title + } + .getOrDefault("") + +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/UriMetadataReader.kt b/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt index 784cefa0..45515e25 100644 --- a/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt +++ b/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt @@ -16,25 +16,51 @@ package com.android.intentresolver.contentpreview -import android.content.ContentResolver +import android.content.ContentInterface +import android.media.MediaMetadata import android.net.Uri +import android.provider.DocumentsContract -// TODO: share this logic with PreviewDataProvider class UriMetadataReader( - private val contentResolver: ContentResolver, - private val mimeTypeClassifier: MimeTypeClassifier, + private val contentResolver: ContentInterface, + private val typeClassifier: MimeTypeClassifier, ) : (Uri) -> FileInfo { - fun getMetadata(uri: Uri): FileInfo = - FileInfo.Builder(uri) - .apply { - runCatching { - withMimeType(contentResolver.getType(uri)) - if (mimeTypeClassifier.isImageType(mimeType)) { - withPreviewUri(uri) - } - } - } - .build() + fun getMetadata(uri: Uri): FileInfo { + val builder = FileInfo.Builder(uri) + val mimeType = contentResolver.getTypeSafe(uri) + builder.withMimeType(mimeType) + if ( + typeClassifier.isImageType(mimeType) || + contentResolver.supportsImageType(uri) || + contentResolver.supportsThumbnail(uri) + ) { + builder.withPreviewUri(uri) + return builder.build() + } + val previewUri = contentResolver.readPreviewUri(uri) + if (previewUri != null) { + builder.withPreviewUri(previewUri) + } + return builder.build() + } override fun invoke(uri: Uri): FileInfo = getMetadata(uri) + + private fun ContentInterface.supportsImageType(uri: Uri): Boolean = + getStreamTypesSafe(uri).firstOrNull { typeClassifier.isImageType(it) } != null + + private fun ContentInterface.supportsThumbnail(uri: Uri): Boolean = + querySafe(uri, arrayOf(DocumentsContract.Document.COLUMN_FLAGS))?.use { cursor -> + cursor.moveToFirst() && cursor.readSupportsThumbnail() + } + ?: false + + private fun ContentInterface.readPreviewUri(uri: Uri): Uri? = + querySafe(uri, arrayOf(MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI))?.use { cursor -> + if (cursor.moveToFirst()) { + cursor.readPreviewUri() + } else { + null + } + } } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/UriMetadataReaderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/UriMetadataReaderTest.kt new file mode 100644 index 00000000..f7bf33fd --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/UriMetadataReaderTest.kt @@ -0,0 +1,100 @@ +/* + * Copyright 2024 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.database.MatrixCursor +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.eq +import com.android.intentresolver.mock +import com.android.intentresolver.whenever +import com.google.common.truth.Truth.assertWithMessage +import org.junit.Test + +class UriMetadataReaderTest { + private val uri = Uri.parse("content://org.pkg.app/item") + private val contentResolver = mock() + + @Test + fun testImageUri() { + val mimeType = "image/png" + whenever(contentResolver.getType(uri)).thenReturn(mimeType) + val testSubject = UriMetadataReader(contentResolver, DefaultMimeTypeClassifier) + + testSubject.getMetadata(uri).let { fileInfo -> + assertWithMessage("Wrong uri").that(fileInfo.uri).isEqualTo(uri) + assertWithMessage("Wrong mime type").that(fileInfo.mimeType).isEqualTo(mimeType) + assertWithMessage("Wrong preview URI").that(fileInfo.previewUri).isEqualTo(uri) + } + } + + @Test + fun testFileUriWithImageTypeSupport() { + val mimeType = "application/pdf" + val imageType = "image/png" + whenever(contentResolver.getType(uri)).thenReturn(mimeType) + whenever(contentResolver.getStreamTypes(eq(uri), any())).thenReturn(arrayOf(imageType)) + val testSubject = UriMetadataReader(contentResolver, DefaultMimeTypeClassifier) + + testSubject.getMetadata(uri).let { fileInfo -> + assertWithMessage("Wrong uri").that(fileInfo.uri).isEqualTo(uri) + assertWithMessage("Wrong mime type").that(fileInfo.mimeType).isEqualTo(mimeType) + assertWithMessage("Wrong preview URI").that(fileInfo.previewUri).isEqualTo(uri) + } + } + + @Test + fun testFileUriWithThumbnailSupport() { + val mimeType = "application/pdf" + whenever(contentResolver.getType(uri)).thenReturn(mimeType) + val columns = arrayOf(DocumentsContract.Document.COLUMN_FLAGS) + whenever(contentResolver.query(eq(uri), eq(columns), anyOrNull(), anyOrNull())) + .thenReturn( + MatrixCursor(columns).apply { + addRow(arrayOf(DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL)) + } + ) + val testSubject = UriMetadataReader(contentResolver, DefaultMimeTypeClassifier) + + testSubject.getMetadata(uri).let { fileInfo -> + assertWithMessage("Wrong uri").that(fileInfo.uri).isEqualTo(uri) + assertWithMessage("Wrong mime type").that(fileInfo.mimeType).isEqualTo(mimeType) + assertWithMessage("Wrong preview URI").that(fileInfo.previewUri).isEqualTo(uri) + } + } + + @Test + fun testFileUriWithPreviewUri() { + val mimeType = "application/pdf" + val previewUri = uri.buildUpon().appendQueryParameter("preview", null).build() + whenever(contentResolver.getType(uri)).thenReturn(mimeType) + val columns = arrayOf(MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI) + whenever(contentResolver.query(eq(uri), eq(columns), anyOrNull(), anyOrNull())) + .thenReturn(MatrixCursor(columns).apply { addRow(arrayOf(previewUri.toString())) }) + val testSubject = UriMetadataReader(contentResolver, DefaultMimeTypeClassifier) + + testSubject.getMetadata(uri).let { fileInfo -> + assertWithMessage("Wrong uri").that(fileInfo.uri).isEqualTo(uri) + assertWithMessage("Wrong mime type").that(fileInfo.mimeType).isEqualTo(mimeType) + assertWithMessage("Wrong preview URI").that(fileInfo.previewUri).isEqualTo(previewUri) + } + } +} -- cgit v1.2.3-59-g8ed1b From 18953f7509f92ee224b5e5a9910acdbc1abbe466 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Fri, 23 Feb 2024 13:30:56 -0500 Subject: Creates ChooserHelper, a peer class for code cleanup This is a peer class to ChooserActivity to incrementally assume the tasks of initialization. Code moved through this class will have carefully controlled rules (documented in the class) for control and data flow to prevent additional tangles. This commit makes only a single control flow change, forwarding the call to ChooserActivity#init through this class. This makes no change to functionality yet, but provides a hook for following CLs. Bug: 309960444 Test: atest com.android.intentresolver Change-Id: I4d895adb00a09a9d18117639b2d85e3fe880e067 --- .../android/intentresolver/v2/ChooserActivity.java | 8 ++- .../com/android/intentresolver/v2/ChooserHelper.kt | 81 ++++++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 java/src/com/android/intentresolver/v2/ChooserHelper.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 3d8bfac5..3c9a4247 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -273,6 +273,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private static final int SCROLL_STATUS_SCROLLING_VERTICAL = 1; private static final int SCROLL_STATUS_SCROLLING_HORIZONTAL = 2; + @Inject public ChooserHelper mChooserHelper; @Inject public ActivityModel mActivityModel; @Inject public FeatureFlags mFeatureFlags; @Inject public android.service.chooser.FeatureFlags mChooserServiceFeatureFlags; @@ -354,7 +355,11 @@ public class ChooserActivity extends Hilt_ChooserActivity implements protected final void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Log.i(TAG, "onCreate"); - Log.i(TAG, "activityLaunch=" + mActivityModel.toString()); + Log.i(TAG, "mActivityModel=" + mActivityModel.toString()); + + // The postInit hook is invoked when this function returns, via Lifecycle. + mChooserHelper.setPostCreateCallback(this::init); + int callerUid = mActivityModel.getLaunchedFromUid(); if (callerUid < 0 || UserHandle.isIsolated(callerUid)) { Log.e(TAG, "Can't start a resolver from uid " + callerUid); @@ -374,7 +379,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements .create(mActivityModel.getLaunchedFromUid(), chosenComponentSender); } mLogic = createActivityLogic(); - init(); } private void init() { diff --git a/java/src/com/android/intentresolver/v2/ChooserHelper.kt b/java/src/com/android/intentresolver/v2/ChooserHelper.kt new file mode 100644 index 00000000..17bc2731 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ChooserHelper.kt @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2024 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.v2 + +import android.app.Activity +import androidx.activity.ComponentActivity +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import dagger.hilt.android.scopes.ActivityScoped +import javax.inject.Inject + +/** + * __Purpose__ + * + * Cleanup aid. Provides a pathway to cleaner code. + * + * __Incoming References__ + * + * For use by ChooserActivity only; must not be accessed by any code outside of ChooserActivity. + * This prevents circular dependencies and coupling, and maintains unidirectional flow. This is + * important for maintaining a migration path towards healthier architecture. + * + * __Outgoing References__ + * + * _ChooserActivity_ + * + * This class must only reference it's host as Activity/ComponentActivity; no down-cast to + * [ChooserActivity]. Other components should be passed in and not pulled from other places. This + * prevents circular dependencies from forming. + * + * _Elsewhere_ + * + * Where possible, Singleton and ActivityScoped dependencies should be injected here instead of + * referenced from an existing location. If not available for injection, the value should be + * constructed here, then provided to where it is needed. If existing objects from ChooserActivity + * are required, supply a factory interface which satisfies the necessary dependencies and use it + * during construction. + */ + +@ActivityScoped +class ChooserHelper @Inject constructor( + hostActivity: Activity, +) : DefaultLifecycleObserver { + // This is guaranteed by Hilt, since only a ComponentActivity is injectable. + private val activity: ComponentActivity = hostActivity as ComponentActivity + + private var activityPostCreate: Runnable? = null + + init { + activity.lifecycle.addObserver(this) + } + + /** + * Provides a optional callback to setup state which is not yet possible to do without circular + * dependencies or by moving more code. + */ + fun setPostCreateCallback(onPostCreate: Runnable) { + activityPostCreate = onPostCreate + } + + /** + * Invoked by Lifecycle, after Activity.onCreate() _returns_. + */ + override fun onCreate(owner: LifecycleOwner) { + activityPostCreate?.run() + } +} \ No newline at end of file -- cgit v1.2.3-59-g8ed1b From e2e2f319387004ee50d36a298faa00c90cb9233e Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Mon, 26 Feb 2024 18:57:11 -0800 Subject: Fix app target list footer Fix: 324011248 Test: manual testing (launching chooser while a keyboard is shown) Flag: ACONFIG com.android.intentresolver.fix_target_list_footer DEVELOPMENT Change-Id: I6bbadde2535cd9a6a43563021b37658fc566d19f --- aconfig/FeatureFlags.aconfig | 10 ++++++++++ .../com/android/intentresolver/grid/ChooserGridAdapter.java | 9 ++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) (limited to 'java/src') diff --git a/aconfig/FeatureFlags.aconfig b/aconfig/FeatureFlags.aconfig index 0da543ac..04883baf 100644 --- a/aconfig/FeatureFlags.aconfig +++ b/aconfig/FeatureFlags.aconfig @@ -5,6 +5,16 @@ container: "system" # namespace: intentresolver # bug: "Feature_Bug_#" or "" +flag { + name: "fix_target_list_footer" + namespace: "intentresolver" + description: "Update app target grid footer on window insets change" + bug: "324011248" + metadata { + purpose: PURPOSE_BUGFIX + } +} + flag { name: "scrollable_preview" namespace: "intentresolver" diff --git a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java index 5ed3e67a..036b686b 100644 --- a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java +++ b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java @@ -162,7 +162,14 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter Date: Tue, 27 Feb 2024 13:35:54 -0500 Subject: Moves Activity Lifecycle methods up to the top in order of occurance This helps to keep the execution model of the activity lifecycle in mind while reviewing and debugging. Test: Only reorders methods, no functional change Bug: 309960444 Change-Id: If56514beb1ae790a25d8ec885efbcedaba37c484 --- .../android/intentresolver/v2/ChooserActivity.java | 228 ++++++++++----------- 1 file changed, 114 insertions(+), 114 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 3c9a4247..765d7c2d 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -381,6 +381,120 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mLogic = createActivityLogic(); } + @Override + protected final void onStart() { + super.onStart(); + + this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); + if (hasWorkProfile()) { + mLogic.getWorkProfileAvailabilityManager().registerWorkProfileStateReceiver(this); + } + } + + @Override + protected final void onResume() { + super.onResume(); + Log.d(TAG, "onResume: " + getComponentName().flattenToShortString()); + mFinishWhenStopped = false; + mRefinementManager.onActivityResume(); + } + + @Override + protected final void onStop() { + super.onStop(); + + final Window window = this.getWindow(); + final WindowManager.LayoutParams attrs = window.getAttributes(); + attrs.privateFlags &= ~SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; + window.setAttributes(attrs); + + if (mRegistered) { + mPersonalPackageMonitor.unregister(); + if (mWorkPackageMonitor != null) { + mWorkPackageMonitor.unregister(); + } + mRegistered = false; + } + final Intent intent = getIntent(); + if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction() + && !mRetainInOnStop) { + // This resolver is in the unusual situation where it has been + // launched at the top of a new task. We don't let it be added + // to the recent tasks shown to the user, and we need to make sure + // that each time we are launched we get the correct launching + // uid (not re-using the same resolver from an old launching uid), + // so we will now finish ourself since being no longer visible, + // the user probably can't get back to us. + if (!isChangingConfigurations()) { + finish(); + } + } + mLogic.getWorkProfileAvailabilityManager().unregisterWorkProfileStateReceiver(this); + + if (mRefinementManager != null) { + mRefinementManager.onActivityStop(isChangingConfigurations()); + } + + if (mFinishWhenStopped) { + mFinishWhenStopped = false; + finish(); + } + } + + @Override + protected final void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); + if (viewPager != null) { + outState.putInt(LAST_SHOWN_TAB_KEY, viewPager.getCurrentItem()); + } + } + + @Override + protected final void onRestart() { + super.onRestart(); + if (!mRegistered) { + mPersonalPackageMonitor.register( + this, + getMainLooper(), + requireAnnotatedUserHandles().personalProfileUserHandle, + false); + if (hasWorkProfile()) { + if (mWorkPackageMonitor == null) { + mWorkPackageMonitor = createPackageMonitor( + mChooserMultiProfilePagerAdapter.getWorkListAdapter()); + } + mWorkPackageMonitor.register( + this, + getMainLooper(), + requireAnnotatedUserHandles().workProfileUserHandle, + false); + } + mRegistered = true; + } + WorkProfileAvailabilityManager workProfileAvailabilityManager = + mLogic.getWorkProfileAvailabilityManager(); + if (hasWorkProfile() && workProfileAvailabilityManager.isWaitingToEnableWorkProfile()) { + if (workProfileAvailabilityManager.isQuietModeEnabled()) { + workProfileAvailabilityManager.markWorkProfileEnabledBroadcastReceived(); + } + } + mChooserMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); + } + + @Override + protected final void onDestroy() { + super.onDestroy(); + + if (isFinishing()) { + mLatencyTracker.onActionCancel(ACTION_LOAD_SHARE_SHEET); + } + + mBackgroundThreadPoolExecutor.shutdownNow(); + + destroyProfileRecords(); + } + private void init() { mIntentReceivedTime.set(System.currentTimeMillis()); mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET); @@ -776,47 +890,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mHeaderCreatorUser = listAdapter.getUserHandle(); } - @Override - protected final void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - if (viewPager != null) { - outState.putInt(LAST_SHOWN_TAB_KEY, viewPager.getCurrentItem()); - } - } - - @Override - protected final void onRestart() { - super.onRestart(); - if (!mRegistered) { - mPersonalPackageMonitor.register( - this, - getMainLooper(), - requireAnnotatedUserHandles().personalProfileUserHandle, - false); - if (hasWorkProfile()) { - if (mWorkPackageMonitor == null) { - mWorkPackageMonitor = createPackageMonitor( - mChooserMultiProfilePagerAdapter.getWorkListAdapter()); - } - mWorkPackageMonitor.register( - this, - getMainLooper(), - requireAnnotatedUserHandles().workProfileUserHandle, - false); - } - mRegistered = true; - } - WorkProfileAvailabilityManager workProfileAvailabilityManager = - mLogic.getWorkProfileAvailabilityManager(); - if (hasWorkProfile() && workProfileAvailabilityManager.isWaitingToEnableWorkProfile()) { - if (workProfileAvailabilityManager.isQuietModeEnabled()) { - workProfileAvailabilityManager.markWorkProfileEnabledBroadcastReceived(); - } - } - mChooserMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); - } - /** 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 @@ -948,16 +1021,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } } - @Override - protected final void onStart() { - super.onStart(); - - this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); - if (hasWorkProfile()) { - mLogic.getWorkProfileAvailabilityManager().registerWorkProfileStateReceiver(this); - } - } - private boolean hasManagedProfile() { UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE); if (userManager == null) { @@ -1439,14 +1502,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } } - @Override - protected void onResume() { - super.onResume(); - Log.d(TAG, "onResume: " + getComponentName().flattenToShortString()); - mFinishWhenStopped = false; - mRefinementManager.onActivityResume(); - } - @Override public void onConfigurationChanged(Configuration newConfig) { super_onConfigurationChanged(newConfig); @@ -1542,61 +1597,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return resolver.query(uri, null, null, null, null); } - @Override - protected void onStop() { - super.onStop(); - - final Window window = this.getWindow(); - final WindowManager.LayoutParams attrs = window.getAttributes(); - attrs.privateFlags &= ~SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; - window.setAttributes(attrs); - - if (mRegistered) { - mPersonalPackageMonitor.unregister(); - if (mWorkPackageMonitor != null) { - mWorkPackageMonitor.unregister(); - } - mRegistered = false; - } - final Intent intent = getIntent(); - if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction() - && !mRetainInOnStop) { - // This resolver is in the unusual situation where it has been - // launched at the top of a new task. We don't let it be added - // to the recent tasks shown to the user, and we need to make sure - // that each time we are launched we get the correct launching - // uid (not re-using the same resolver from an old launching uid), - // so we will now finish ourself since being no longer visible, - // the user probably can't get back to us. - if (!isChangingConfigurations()) { - finish(); - } - } - mLogic.getWorkProfileAvailabilityManager().unregisterWorkProfileStateReceiver(this); - - if (mRefinementManager != null) { - mRefinementManager.onActivityStop(isChangingConfigurations()); - } - - if (mFinishWhenStopped) { - mFinishWhenStopped = false; - finish(); - } - } - - @Override - protected void onDestroy() { - super.onDestroy(); - - if (isFinishing()) { - mLatencyTracker.onActionCancel(ACTION_LOAD_SHARE_SHEET); - } - - mBackgroundThreadPoolExecutor.shutdownNow(); - - destroyProfileRecords(); - } - private void destroyProfileRecords() { mProfileRecords.values().forEach(ProfileRecord::destroy); mProfileRecords.clear(); -- cgit v1.2.3-59-g8ed1b From bf8b6d3157f79dfaa1f3d41a7edec79c06e3b990 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Tue, 27 Feb 2024 16:23:56 -0500 Subject: Fix Valid to contain non nullable value This corrects a design mistake: the Valid subclass of ValidationResult should have a non-null value, so the value can be taken directly after a smart-cast. Usage becomes more concise: when (val result = validateInput(source) { is Valid -> processValue(result.value) // also result.warnings is Invalid -> handleInvalid(result.errors) } Bug: 309960444 Test: atest com.android.intentresolver.v2.validation Change-Id: Ia4ee53413d729e551b8b7ec21a8765ae7d4f5e95 --- .../contentpreview/SelectionChangeCallback.kt | 12 +- .../intentresolver/v2/ResolverActivity.java | 16 +- .../v2/ui/viewmodel/ChooserViewModel.kt | 14 +- .../intentresolver/v2/validation/Findings.kt | 17 +- .../intentresolver/v2/validation/Validation.kt | 18 +- .../v2/validation/ValidationResult.kt | 23 +-- .../v2/validation/types/IntentOrUri.kt | 11 +- .../v2/validation/types/ParceledArray.kt | 16 +- .../v2/validation/types/SimpleValue.kt | 13 +- .../v2/validation/types/Validators.kt | 19 -- .../v2/validation/ValidationResultSubject.kt | 22 --- .../v2/ui/viewmodel/ChooserRequestTest.kt | 210 +++++++++++---------- .../v2/ui/viewmodel/ResolverRequestTest.kt | 41 ++-- .../intentresolver/v2/validation/ValidationTest.kt | 61 ++++-- .../v2/validation/types/IntentOrUriTest.kt | 39 ++-- .../v2/validation/types/ParceledArrayTest.kt | 34 ++-- .../v2/validation/types/SimpleValueTest.kt | 57 ++++-- 17 files changed, 354 insertions(+), 269 deletions(-) delete mode 100644 tests/shared/src/com/android/intentresolver/v2/validation/ValidationResultSubject.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/SelectionChangeCallback.kt b/java/src/com/android/intentresolver/contentpreview/SelectionChangeCallback.kt index 5c916882..6b33e1cd 100644 --- a/java/src/com/android/intentresolver/contentpreview/SelectionChangeCallback.kt +++ b/java/src/com/android/intentresolver/contentpreview/SelectionChangeCallback.kt @@ -31,7 +31,10 @@ import android.service.chooser.ChooserTarget import com.android.intentresolver.contentpreview.PayloadToggleInteractor.ShareouselUpdate import com.android.intentresolver.v2.ui.viewmodel.readAlternateIntents import com.android.intentresolver.v2.ui.viewmodel.readChooserActions +import com.android.intentresolver.v2.validation.Invalid +import com.android.intentresolver.v2.validation.Valid import com.android.intentresolver.v2.validation.ValidationResult +import com.android.intentresolver.v2.validation.log import com.android.intentresolver.v2.validation.types.array import com.android.intentresolver.v2.validation.types.value import com.android.intentresolver.v2.validation.validateFrom @@ -61,11 +64,10 @@ class SelectionChangeCallback( } ) ?.let { bundle -> - readCallbackResponse(bundle).let { validation -> - if (validation.isSuccess()) { - validation.value - } else { - validation.reportToLogcat(TAG) + return when (val result = readCallbackResponse(bundle)) { + is Valid -> result.value + is Invalid -> { + result.errors.forEach { it.log(TAG) } null } } diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index 98e82b00..52d5f2de 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -115,6 +115,10 @@ import com.android.intentresolver.v2.profiles.ResolverMultiProfilePagerAdapter; import com.android.intentresolver.v2.ui.ActionTitle; import com.android.intentresolver.v2.ui.model.ActivityModel; import com.android.intentresolver.v2.ui.model.ResolverRequest; +import com.android.intentresolver.v2.validation.Finding; +import com.android.intentresolver.v2.validation.FindingsKt; +import com.android.intentresolver.v2.validation.Invalid; +import com.android.intentresolver.v2.validation.Valid; import com.android.intentresolver.v2.validation.ValidationResult; import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.internal.annotations.VisibleForTesting; @@ -135,6 +139,7 @@ import java.util.Iterator; import java.util.List; import java.util.Objects; import java.util.Set; +import java.util.function.Consumer; import javax.inject.Inject; @@ -243,11 +248,16 @@ public class ResolverActivity extends Hilt_ResolverActivity implements } ValidationResult result = readResolverRequest(mActivityModel); - if (!result.isSuccess()) { - result.reportToLogcat(TAG); + if (result instanceof Invalid) { + ((Invalid) result).getErrors().forEach(new Consumer() { + @Override + public void accept(Finding finding) { + FindingsKt.log(finding, TAG); + } + }); finish(); } - mResolverRequest = result.getOrThrow(); + mResolverRequest = ((Valid) result).getValue(); mLogic = createActivityLogic(); mResolvingHome = mResolverRequest.isResolvingHome(); mTargetDataLoader = new DefaultTargetDataLoader( diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt index cd1a16e3..424f36cd 100644 --- a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt +++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt @@ -22,7 +22,10 @@ import com.android.intentresolver.inject.ChooserServiceFlags import com.android.intentresolver.v2.ui.model.ActivityModel import com.android.intentresolver.v2.ui.model.ActivityModel.Companion.ACTIVITY_MODEL_KEY import com.android.intentresolver.v2.ui.model.ChooserRequest +import com.android.intentresolver.v2.validation.Invalid +import com.android.intentresolver.v2.validation.Valid import com.android.intentresolver.v2.validation.ValidationResult +import com.android.intentresolver.v2.validation.log import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -45,12 +48,17 @@ constructor( private val status: ValidationResult = readChooserRequest(mActivityModel, flags) - val chooserRequest: ChooserRequest by lazy { status.getOrThrow() } + val chooserRequest: ChooserRequest by lazy { + when(status) { + is Valid -> status.value + is Invalid -> error(status.errors) + } + } fun init(): Boolean { Log.i(TAG, "viewModel init") - if (!status.isSuccess()) { - status.reportToLogcat(TAG) + if (status is Invalid) { + status.errors.forEach { finding -> finding.log(TAG) } return false } Log.i(TAG, "request = $chooserRequest") diff --git a/java/src/com/android/intentresolver/v2/validation/Findings.kt b/java/src/com/android/intentresolver/v2/validation/Findings.kt index 9a3cc9c7..bdf2f00a 100644 --- a/java/src/com/android/intentresolver/v2/validation/Findings.kt +++ b/java/src/com/android/intentresolver/v2/validation/Findings.kt @@ -34,9 +34,13 @@ val Finding.logcatPriority get() = when (importance) { CRITICAL -> Log.ERROR - else -> Log.WARN + WARNING -> Log.WARN } +fun Finding.log(tag: String) { + Log.println(logcatPriority, tag, message) +} + private fun formatMessage(key: String? = null, msg: String) = buildString { key?.also { append("['$key']: ") } append(msg) @@ -52,18 +56,21 @@ data class IgnoredValue( get() = formatMessage(key, "Ignored. $reason") } -data class RequiredValueMissing( +data class NoValue( val key: String, + override val importance: Importance, val allowedType: KClass<*>, ) : Finding { - override val importance = CRITICAL - override val message: String get() = formatMessage( key, - "expected value of ${allowedType.simpleName}, " + "but no value was present" + if (importance == CRITICAL) { + "expected value of ${allowedType.simpleName}, " + "but no value was present" + } else { + "no ${allowedType.simpleName} value present" + } ) } diff --git a/java/src/com/android/intentresolver/v2/validation/Validation.kt b/java/src/com/android/intentresolver/v2/validation/Validation.kt index 46939602..6072ec9f 100644 --- a/java/src/com/android/intentresolver/v2/validation/Validation.kt +++ b/java/src/com/android/intentresolver/v2/validation/Validation.kt @@ -90,7 +90,7 @@ fun validateFrom(source: (String) -> Any?, validate: Validation.() -> T): Va is InvalidResultError -> Invalid(validation.findings) // Some other exception was thrown from [validate], - else -> Invalid(findings = listOf(UncaughtException(it))) + else -> Invalid(error = UncaughtException(it)) } } ) @@ -107,8 +107,8 @@ private class ValidationImpl(val source: (String) -> Any?) : Validation { override fun ignored(property: Validator, reason: String) { val result = property.validate(source, WARNING) - if (result.value != null) { - // Note: Any findings about the value (result.findings) are ignored. + if (result is Valid) { + // Note: Any warnings about the value itself (result.findings) are ignored. findings += IgnoredValue(property.key, reason) } } @@ -117,8 +117,16 @@ private class ValidationImpl(val source: (String) -> Any?) : Validation { return runCatching { property.validate(source, importance) } .fold( onSuccess = { result -> - findings += result.findings - result.value + return when (result) { + is Valid -> { + findings += result.warnings + result.value + } + is Invalid -> { + findings += result.errors + null + } + } }, onFailure = { findings += UncaughtException(it, property.key) diff --git a/java/src/com/android/intentresolver/v2/validation/ValidationResult.kt b/java/src/com/android/intentresolver/v2/validation/ValidationResult.kt index 856a521e..f5c467dc 100644 --- a/java/src/com/android/intentresolver/v2/validation/ValidationResult.kt +++ b/java/src/com/android/intentresolver/v2/validation/ValidationResult.kt @@ -15,25 +15,12 @@ */ package com.android.intentresolver.v2.validation -import android.util.Log +sealed interface ValidationResult -sealed interface ValidationResult { - val value: T? - val findings: List - - fun isSuccess() = value != null - - fun getOrThrow(): T = - checkNotNull(value) { "The result was invalid: " + findings.joinToString(separator = "\n") } - - fun reportToLogcat(tag: String) { - findings.forEach { Log.println(it.logcatPriority, tag, it.toString()) } - } +data class Valid(val value: T, val warnings: List = emptyList()) : ValidationResult { + constructor(value: T, warning: Finding) : this(value, listOf(warning)) } -data class Valid(override val value: T?, override val findings: List = emptyList()) : - ValidationResult - -data class Invalid(override val findings: List) : ValidationResult { - override val value: T? = null +data class Invalid(val errors: List = emptyList()) : ValidationResult { + constructor(error: Finding) : this(listOf(error)) } diff --git a/java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt b/java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt index 3cefeb15..050bd895 100644 --- a/java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt +++ b/java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt @@ -18,7 +18,8 @@ package com.android.intentresolver.v2.validation.types import android.content.Intent import android.net.Uri import com.android.intentresolver.v2.validation.Importance -import com.android.intentresolver.v2.validation.RequiredValueMissing +import com.android.intentresolver.v2.validation.Invalid +import com.android.intentresolver.v2.validation.NoValue import com.android.intentresolver.v2.validation.Valid import com.android.intentresolver.v2.validation.ValidationResult import com.android.intentresolver.v2.validation.Validator @@ -40,12 +41,14 @@ class IntentOrUri(override val key: String) : Validator { is Uri -> Valid(Intent.parseUri(value.toString(), Intent.URI_INTENT_SCHEME)) // No value present. - null -> createResult(importance, RequiredValueMissing(key, Intent::class)) + null -> when (importance) { + Importance.WARNING -> Invalid() // No warnings if optional, but missing + Importance.CRITICAL -> Invalid(NoValue(key, importance, Intent::class)) + } // Some other type. else -> { - return createResult( - importance, + return Invalid( ValueIsWrongType( key, importance, diff --git a/java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt b/java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt index c6c4abba..78adfd36 100644 --- a/java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt +++ b/java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt @@ -15,8 +15,10 @@ */ package com.android.intentresolver.v2.validation.types +import android.content.Intent import com.android.intentresolver.v2.validation.Importance -import com.android.intentresolver.v2.validation.RequiredValueMissing +import com.android.intentresolver.v2.validation.Invalid +import com.android.intentresolver.v2.validation.NoValue import com.android.intentresolver.v2.validation.Valid import com.android.intentresolver.v2.validation.ValidationResult import com.android.intentresolver.v2.validation.Validator @@ -37,8 +39,10 @@ class ParceledArray( return when (val value: Any? = source(key)) { // No value present. - null -> createResult(importance, RequiredValueMissing(key, elementType)) - + null -> when (importance) { + Importance.WARNING -> Invalid() // No warnings if optional, but missing + Importance.CRITICAL -> Invalid(NoValue(key, importance, elementType)) + } // A parcel does not transfer the element type information for parcelable // arrays. This leads to a restored type of Array, which is // incompatible with Array. @@ -54,8 +58,7 @@ class ParceledArray( // At least one incorrect element type found. else -> - createResult( - importance, + Invalid( WrongElementType( key, importance, @@ -69,8 +72,7 @@ class ParceledArray( // The value is not an Array at all. else -> - createResult( - importance, + Invalid( ValueIsWrongType( key, importance, diff --git a/java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt b/java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt index 3287b84b..0105541d 100644 --- a/java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt +++ b/java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt @@ -16,7 +16,8 @@ package com.android.intentresolver.v2.validation.types import com.android.intentresolver.v2.validation.Importance -import com.android.intentresolver.v2.validation.RequiredValueMissing +import com.android.intentresolver.v2.validation.Invalid +import com.android.intentresolver.v2.validation.NoValue import com.android.intentresolver.v2.validation.Valid import com.android.intentresolver.v2.validation.ValidationResult import com.android.intentresolver.v2.validation.Validator @@ -36,19 +37,21 @@ class SimpleValue( expected.isInstance(value) -> return Valid(expected.cast(value)) // No value is present. - value == null -> createResult(importance, RequiredValueMissing(key, expected)) + value == null -> when (importance) { + Importance.WARNING -> Invalid() // No warnings if optional, but missing + Importance.CRITICAL -> Invalid(NoValue(key, importance, expected)) + } // The value is some other type. else -> - createResult( - importance, + Invalid(listOf( ValueIsWrongType( key, importance, actualType = value::class, allowedTypes = listOf(expected) ) - ) + )) } } } diff --git a/java/src/com/android/intentresolver/v2/validation/types/Validators.kt b/java/src/com/android/intentresolver/v2/validation/types/Validators.kt index 4e6e5dff..70993b4d 100644 --- a/java/src/com/android/intentresolver/v2/validation/types/Validators.kt +++ b/java/src/com/android/intentresolver/v2/validation/types/Validators.kt @@ -15,13 +15,6 @@ */ package com.android.intentresolver.v2.validation.types -import com.android.intentresolver.v2.validation.Finding -import com.android.intentresolver.v2.validation.Importance -import com.android.intentresolver.v2.validation.Importance.CRITICAL -import com.android.intentresolver.v2.validation.Importance.WARNING -import com.android.intentresolver.v2.validation.Invalid -import com.android.intentresolver.v2.validation.Valid -import com.android.intentresolver.v2.validation.ValidationResult import com.android.intentresolver.v2.validation.Validator inline fun value(key: String): Validator { @@ -31,15 +24,3 @@ inline fun value(key: String): Validator { inline fun array(key: String): Validator> { return ParceledArray(key, T::class) } - -/** - * Convenience function to wrap a finding in an appropriate result type. - * - * An error [finding] is suppressed when [importance] == [WARNING] - */ -internal fun createResult(importance: Importance, finding: Finding): ValidationResult { - return when (importance) { - WARNING -> Valid(null, listOf(finding).filter { it.importance == WARNING }) - CRITICAL -> Invalid(listOf(finding)) - } -} diff --git a/tests/shared/src/com/android/intentresolver/v2/validation/ValidationResultSubject.kt b/tests/shared/src/com/android/intentresolver/v2/validation/ValidationResultSubject.kt deleted file mode 100644 index 1ff0ce8e..00000000 --- a/tests/shared/src/com/android/intentresolver/v2/validation/ValidationResultSubject.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.android.intentresolver.v2.validation - -import com.google.common.truth.FailureMetadata -import com.google.common.truth.IterableSubject -import com.google.common.truth.Subject -import com.google.common.truth.Truth.assertAbout - -class ValidationResultSubject(metadata: FailureMetadata, private val actual: ValidationResult<*>?) : - Subject(metadata, actual) { - - fun isSuccess() = check("isSuccess()").that(actual?.isSuccess()).isTrue() - fun isFailure() = check("isSuccess()").that(actual?.isSuccess()).isFalse() - - fun value(): Subject = check("value").that(actual?.value) - - fun findings(): IterableSubject = check("findings").that(actual?.findings) - - companion object { - fun assertThat(input: ValidationResult<*>): ValidationResultSubject = - assertAbout(::ValidationResultSubject).that(input) - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt index d2ddf680..d3b9f559 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt @@ -21,6 +21,8 @@ import android.content.Intent.ACTION_SEND import android.content.Intent.ACTION_SEND_MULTIPLE import android.content.Intent.ACTION_VIEW import android.content.Intent.EXTRA_ALTERNATE_INTENTS +import android.content.Intent.EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI +import android.content.Intent.EXTRA_CHOOSER_FOCUSED_ITEM_POSITION import android.content.Intent.EXTRA_INTENT import android.content.Intent.EXTRA_REFERRER import android.net.Uri @@ -31,19 +33,13 @@ import com.android.intentresolver.ContentTypeHint import com.android.intentresolver.inject.FakeChooserServiceFlags import com.android.intentresolver.v2.ui.model.ActivityModel import com.android.intentresolver.v2.ui.model.ChooserRequest -import com.android.intentresolver.v2.validation.RequiredValueMissing -import com.android.intentresolver.v2.validation.ValidationResultSubject.Companion.assertThat +import com.android.intentresolver.v2.validation.Importance +import com.android.intentresolver.v2.validation.Invalid +import com.android.intentresolver.v2.validation.NoValue +import com.android.intentresolver.v2.validation.Valid import com.google.common.truth.Truth.assertThat import org.junit.Test -// TODO: replace with the new API constant, Intent#EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI -private const val EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI = - "android.intent.extra.CHOOSER_ADDITIONAL_CONTENT_URI" - -// TODO: replace with the new API constant, Intent#EXTRA_CHOOSER_FOCUSED_ITEM_POSITION -private const val EXTRA_CHOOSER_FOCUSED_ITEM_POSITION = - "android.intent.extra.CHOOSER_FOCUSED_ITEM_POSITION" - private fun createActivityModel( targetIntent: Intent?, referrer: Uri? = null, @@ -70,26 +66,30 @@ class ChooserRequestTest { @Test fun missingIntent() { - val launch = createActivityModel(targetIntent = null) - val result = readChooserRequest(launch, fakeChooserServiceFlags) + val model = createActivityModel(targetIntent = null) + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid - assertThat(result).value().isNull() - assertThat(result) - .findings() - .containsExactly(RequiredValueMissing(EXTRA_INTENT, Intent::class)) + assertThat(result.errors) + .containsExactly(NoValue(EXTRA_INTENT, Importance.CRITICAL, Intent::class)) } @Test fun referrerFillIn() { val referrer = Uri.parse("android-app://example.com") - val launch = createActivityModel(targetIntent = Intent(ACTION_SEND), referrer) - launch.intent.putExtras(bundleOf(EXTRA_REFERRER to referrer)) + val model = createActivityModel(targetIntent = Intent(ACTION_SEND), referrer) + model.intent.putExtras(bundleOf(EXTRA_REFERRER to referrer)) - val result = readChooserRequest(launch, fakeChooserServiceFlags) + val result = readChooserRequest(model, fakeChooserServiceFlags) - val fillIn = result.value?.getReferrerFillInIntent() - assertThat(fillIn?.hasExtra(EXTRA_REFERRER)).isTrue() - assertThat(fillIn?.getParcelableExtra(EXTRA_REFERRER, Uri::class.java)).isEqualTo(referrer) + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + + val fillIn = result.value.getReferrerFillInIntent() + assertThat(fillIn.hasExtra(EXTRA_REFERRER)).isTrue() + assertThat(fillIn.getParcelableExtra(EXTRA_REFERRER, Uri::class.java)).isEqualTo(referrer) } @Test @@ -97,45 +97,59 @@ class ChooserRequestTest { val referrer = Uri.parse("http://example.com") val intent = Intent().putExtras(bundleOf(EXTRA_INTENT to Intent(ACTION_SEND))) - val launch = createActivityModel(targetIntent = intent, referrer = referrer) + val model = createActivityModel(targetIntent = intent, referrer = referrer) + + val result = readChooserRequest(model, fakeChooserServiceFlags) - val result = readChooserRequest(launch, fakeChooserServiceFlags) + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid - assertThat(result.value?.referrerPackage).isNull() + assertThat(result.value.referrerPackage).isNull() } @Test fun referrerPackage_fromAppReferrer() { val referrer = Uri.parse("android-app://example.com") - val launch = createActivityModel(targetIntent = Intent(ACTION_SEND), referrer) + val model = createActivityModel(targetIntent = Intent(ACTION_SEND), referrer) + + model.intent.putExtras(bundleOf(EXTRA_REFERRER to referrer)) - launch.intent.putExtras(bundleOf(EXTRA_REFERRER to referrer)) + val result = readChooserRequest(model, fakeChooserServiceFlags) - val result = readChooserRequest(launch, fakeChooserServiceFlags) + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid - assertThat(result.value?.referrerPackage).isEqualTo(referrer.authority) + assertThat(result.value.referrerPackage).isEqualTo(referrer.authority) } @Test fun payloadIntents_includesTargetThenAdditional() { val intent1 = Intent(ACTION_SEND) val intent2 = Intent(ACTION_SEND_MULTIPLE) - val launch = createActivityModel(targetIntent = intent1, additionalIntents = listOf(intent2)) - val result = readChooserRequest(launch, fakeChooserServiceFlags) + val model = createActivityModel( + targetIntent = intent1, + additionalIntents = listOf(intent2) + ) + + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid - assertThat(result.value?.payloadIntents).containsExactly(intent1, intent2) + assertThat(result.value.payloadIntents).containsExactly(intent1, intent2) } @Test fun testRequest_withOnlyRequiredValues() { val intent = Intent().putExtras(bundleOf(EXTRA_INTENT to Intent(ACTION_SEND))) - val launch = createActivityModel(targetIntent = intent) - val result = readChooserRequest(launch, fakeChooserServiceFlags) + val model = createActivityModel(targetIntent = intent) - assertThat(result).value().isNotNull() - val value: ChooserRequest = result.getOrThrow() - assertThat(value.launchedFromPackage).isEqualTo(launch.launchedFromPackage) - assertThat(result).findings().isEmpty() + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + + assertThat(result.value.launchedFromPackage).isEqualTo(model.launchedFromPackage) } @Test @@ -143,18 +157,19 @@ class ChooserRequestTest { fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, true) val uri = Uri.parse("content://org.pkg/path") val position = 10 - val launch = + val model = createActivityModel(targetIntent = Intent(ACTION_SEND)).apply { intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, uri) intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, position) } - val result = readChooserRequest(launch, fakeChooserServiceFlags) - assertThat(result).value().isNotNull() - val value: ChooserRequest = result.getOrThrow() - assertThat(value.additionalContentUri).isEqualTo(uri) - assertThat(value.focusedItemPosition).isEqualTo(position) - assertThat(result).findings().isEmpty() + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + + assertThat(result.value.additionalContentUri).isEqualTo(uri) + assertThat(result.value.focusedItemPosition).isEqualTo(position) } @Test @@ -162,46 +177,51 @@ class ChooserRequestTest { fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, false) val uri = Uri.parse("content://org.pkg/path") val position = 10 - val launch = + val model = createActivityModel(targetIntent = Intent(ACTION_SEND)).apply { intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, uri) intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, position) } - val result = readChooserRequest(launch, fakeChooserServiceFlags) + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid - assertThat(result).value().isNotNull() - val value: ChooserRequest = result.getOrThrow() - assertThat(value.additionalContentUri).isNull() - assertThat(value.focusedItemPosition).isEqualTo(0) - assertThat(result).findings().isEmpty() + assertThat(result.value.additionalContentUri).isNull() + assertThat(result.value.focusedItemPosition).isEqualTo(0) + assertThat(result.warnings).isEmpty() } @Test fun testRequest_actionSendWithInvalidAdditionalContentUri() { fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, true) - val launch = + val model = createActivityModel(targetIntent = Intent(ACTION_SEND)).apply { - intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, "content://org.pkg/path") - intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, "1") + intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, "__invalid__") + intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, "__invalid__") } - val result = readChooserRequest(launch, fakeChooserServiceFlags) - assertThat(result).value().isNotNull() - val value: ChooserRequest = result.getOrThrow() - assertThat(value.additionalContentUri).isNull() - assertThat(value.focusedItemPosition).isEqualTo(0) + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + + assertThat(result.value.additionalContentUri).isNull() + assertThat(result.value.focusedItemPosition).isEqualTo(0) } @Test fun testRequest_actionSendWithoutAdditionalContentUri() { fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, true) - val launch = createActivityModel(targetIntent = Intent(ACTION_SEND)) - val result = readChooserRequest(launch, fakeChooserServiceFlags) + val model = createActivityModel(targetIntent = Intent(ACTION_SEND)) + + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid - assertThat(result).value().isNotNull() - val value: ChooserRequest = result.getOrThrow() - assertThat(value.additionalContentUri).isNull() - assertThat(value.focusedItemPosition).isEqualTo(0) + assertThat(result.value.additionalContentUri).isNull() + assertThat(result.value.focusedItemPosition).isEqualTo(0) } @Test @@ -209,52 +229,53 @@ class ChooserRequestTest { fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, true) val uri = Uri.parse("content://org.pkg/path") val position = 10 - val launch = - createActivityModel(targetIntent = Intent(ACTION_VIEW)).apply { + val model = createActivityModel(targetIntent = Intent(ACTION_VIEW)).apply { intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, uri) intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, position) } - val result = readChooserRequest(launch, fakeChooserServiceFlags) - assertThat(result).value().isNotNull() - val value: ChooserRequest = result.getOrThrow() - assertThat(value.additionalContentUri).isNull() - assertThat(value.focusedItemPosition).isEqualTo(0) - assertThat(result).findings().isEmpty() + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + + assertThat(result.value.additionalContentUri).isNull() + assertThat(result.value.focusedItemPosition).isEqualTo(0) + assertThat(result.warnings).isEmpty() } @Test fun testAlbumType() { fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_ALBUM_TEXT, true) - val launch = createActivityModel(Intent(ACTION_SEND)) - launch.intent.putExtra( + val model = createActivityModel(Intent(ACTION_SEND)) + model.intent.putExtra( Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT, Intent.CHOOSER_CONTENT_TYPE_ALBUM ) - val result = readChooserRequest(launch, fakeChooserServiceFlags) + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid - val value: ChooserRequest = result.getOrThrow() - assertThat(value.contentTypeHint).isEqualTo(ContentTypeHint.ALBUM) - assertThat(result).findings().isEmpty() + assertThat(result.value.contentTypeHint).isEqualTo(ContentTypeHint.ALBUM) + assertThat(result.warnings).isEmpty() } @Test fun metadataText_whenFlagFalse_isNull() { - // Arrange fakeChooserServiceFlags.setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, false) val metadataText: CharSequence = "Test metadata text" - val launch = - createActivityModel(targetIntent = Intent()).apply { + val model = createActivityModel(targetIntent = Intent()).apply { intent.putExtra(Intent.EXTRA_METADATA_TEXT, metadataText) } - // Act - val result = readChooserRequest(launch, fakeChooserServiceFlags) + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid - // Assert - assertThat(result).value().isNotNull() - assertThat(result.value?.metadataText).isNull() + assertThat(result.value.metadataText).isNull() } @Test @@ -262,16 +283,15 @@ class ChooserRequestTest { // Arrange fakeChooserServiceFlags.setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, true) val metadataText: CharSequence = "Test metadata text" - val launch = - createActivityModel(targetIntent = Intent()).apply { + val model = createActivityModel(targetIntent = Intent()).apply { intent.putExtra(Intent.EXTRA_METADATA_TEXT, metadataText) } - // Act - val result = readChooserRequest(launch, fakeChooserServiceFlags) + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid - // Assert - assertThat(result).value().isNotNull() - assertThat(result.value?.metadataText).isEqualTo(metadataText) + assertThat(result.value.metadataText).isEqualTo(metadataText) } } diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestTest.kt b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestTest.kt index cc9b9a77..6f1ed853 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestTest.kt @@ -24,9 +24,11 @@ import androidx.core.os.bundleOf import com.android.intentresolver.v2.ResolverActivity.PROFILE_WORK import com.android.intentresolver.v2.shared.model.Profile.Type.WORK import com.android.intentresolver.v2.ui.model.ActivityModel +import com.android.intentresolver.v2.ui.model.ChooserRequest import com.android.intentresolver.v2.ui.model.ResolverRequest +import com.android.intentresolver.v2.validation.Invalid import com.android.intentresolver.v2.validation.UncaughtException -import com.android.intentresolver.v2.validation.ValidationResultSubject.Companion.assertThat +import com.android.intentresolver.v2.validation.Valid import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage import org.junit.Test @@ -51,13 +53,15 @@ class ResolverRequestTest { val activity = createActivityModel(intent) val result = readResolverRequest(activity) - assertThat(result).isSuccess() - assertThat(result).findings().isEmpty() - val value: ResolverRequest = result.getOrThrow() - assertThat(value.intent.filterEquals(activity.intent)).isTrue() - assertThat(value.callingUser).isNull() - assertThat(value.selectedProfile).isNull() + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + + assertThat(result.warnings).isEmpty() + + assertThat(result.value.intent.filterEquals(activity.intent)).isTrue() + assertThat(result.value.callingUser).isNull() + assertThat(result.value.selectedProfile).isNull() } @Test @@ -72,9 +76,11 @@ class ResolverRequestTest { val result = readResolverRequest(activity) - assertThat(result).isFailure() + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid + assertWithMessage("the first finding") - .that(result.findings.firstOrNull()) + .that(result.errors.firstOrNull()) .isInstanceOf(UncaughtException::class.java) } @@ -89,9 +95,12 @@ class ResolverRequestTest { val result = readResolverRequest(activity) + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + // Assert that payloadIntents does NOT include EXTRA_ALTERNATE_INTENTS // that is only supported for Chooser and should be not be added here. - assertThat(result.value?.payloadIntents).containsExactly(intent1) + assertThat(result.value.payloadIntents).containsExactly(intent1) } @Test @@ -109,12 +118,12 @@ class ResolverRequestTest { val result = readResolverRequest(activity) - assertThat(result).value().isNotNull() - val value: ResolverRequest = result.getOrThrow() + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid - assertThat(value.intent.filterEquals(activity.intent)).isTrue() - assertThat(value.isAudioCaptureDevice).isTrue() - assertThat(value.callingUser).isEqualTo(UserHandle.of(123)) - assertThat(value.selectedProfile).isEqualTo(WORK) + assertThat(result.value.intent.filterEquals(activity.intent)).isTrue() + assertThat(result.value.isAudioCaptureDevice).isTrue() + assertThat(result.value.callingUser).isEqualTo(UserHandle.of(123)) + assertThat(result.value.selectedProfile).isEqualTo(WORK) } } diff --git a/tests/unit/src/com/android/intentresolver/v2/validation/ValidationTest.kt b/tests/unit/src/com/android/intentresolver/v2/validation/ValidationTest.kt index 43fb448c..dbaa7c4e 100644 --- a/tests/unit/src/com/android/intentresolver/v2/validation/ValidationTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/validation/ValidationTest.kt @@ -1,6 +1,5 @@ package com.android.intentresolver.v2.validation -import com.android.intentresolver.v2.validation.ValidationResultSubject.Companion.assertThat import com.android.intentresolver.v2.validation.types.value import com.google.common.truth.Truth.assertThat import org.junit.Assert.fail @@ -16,8 +15,12 @@ class ValidationTest { val required: Int = required(value("key")) "return value: $required" } - assertThat(result).value().isEqualTo("return value: 1") - assertThat(result).findings().isEmpty() + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + + assertThat(result.value).isEqualTo("return value: 1") + assertThat(result.warnings).isEmpty() } /** Test reporting of absent required values. */ @@ -29,9 +32,12 @@ class ValidationTest { fail("'required' should have thrown an exception") "return value" } - assertThat(result).isFailure() - assertThat(result).findings().containsExactly( - RequiredValueMissing("key", Int::class)) + + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid + + assertThat(result.errors).containsExactly( + NoValue("key", Importance.CRITICAL, Int::class)) } /** Test optional values are ignored when absent. */ @@ -42,20 +48,28 @@ class ValidationTest { val optional: Int? = optional(value("key")) "return value: $optional" } - assertThat(result).value().isEqualTo("return value: 1") - assertThat(result).findings().isEmpty() + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + + assertThat(result.value).isEqualTo("return value: 1") + assertThat(result.warnings).isEmpty() } /** Test optional values are ignored when absent. */ @Test fun optional_valueAbsent() { - val result: ValidationResult = + val result: ValidationResult = validateFrom({ null }) { val optional: String? = optional(value("key")) "return value: $optional" } - assertThat(result).isSuccess() - assertThat(result).findings().isEmpty() + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + + assertThat(result.value).isEqualTo("return value: null") + assertThat(result.warnings).isEmpty() } /** Test reporting of ignored values. */ @@ -66,9 +80,12 @@ class ValidationTest { ignored(value("key"), "no longer supported") "result value" } - assertThat(result).value().isEqualTo("result value") - assertThat(result) - .findings() + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + + assertThat(result.value).isEqualTo("result value") + assertThat(result.warnings) .containsExactly(IgnoredValue("key", "no longer supported")) } @@ -80,8 +97,11 @@ class ValidationTest { ignored(value("key"), "ignored when option foo is set") "result value" } - assertThat(result).value().isEqualTo("result value") - assertThat(result).findings().isEmpty() + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + + assertThat(result.value).isEqualTo("result value") + assertThat(result.warnings).isEmpty() } /** Test handling of exceptions in the validation function. */ @@ -91,9 +111,12 @@ class ValidationTest { validateFrom({ null }) { error("something") } - assertThat(result).isFailure() - val findingTypes = result.findings.map { it::class } - assertThat(findingTypes.first()).isEqualTo(UncaughtException::class) + + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid + + val errorType = result.errors.map { it::class }.first() + assertThat(errorType).isEqualTo(UncaughtException::class) } } diff --git a/tests/unit/src/com/android/intentresolver/v2/validation/types/IntentOrUriTest.kt b/tests/unit/src/com/android/intentresolver/v2/validation/types/IntentOrUriTest.kt index ad230488..03429f4c 100644 --- a/tests/unit/src/com/android/intentresolver/v2/validation/types/IntentOrUriTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/validation/types/IntentOrUriTest.kt @@ -7,8 +7,9 @@ import androidx.core.net.toUri import androidx.test.ext.truth.content.IntentSubject.assertThat import com.android.intentresolver.v2.validation.Importance.CRITICAL import com.android.intentresolver.v2.validation.Importance.WARNING -import com.android.intentresolver.v2.validation.RequiredValueMissing -import com.android.intentresolver.v2.validation.ValidationResultSubject.Companion.assertThat +import com.android.intentresolver.v2.validation.Invalid +import com.android.intentresolver.v2.validation.NoValue +import com.android.intentresolver.v2.validation.Valid import com.android.intentresolver.v2.validation.ValueIsWrongType import com.google.common.truth.Truth.assertThat import org.junit.Test @@ -22,7 +23,9 @@ class IntentOrUriTest { val values = mapOf("key" to Intent("GO")) val result = keyValidator.validate(values::get, CRITICAL) - assertThat(result).findings().isEmpty() + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid assertThat(result.value).hasAction("GO") } @@ -33,7 +36,9 @@ class IntentOrUriTest { val values = mapOf("key" to Intent("GO").toUri(URI_INTENT_SCHEME).toUri()) val result = keyValidator.validate(values::get, CRITICAL) - assertThat(result).findings().isEmpty() + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid assertThat(result.value).hasAction("GO") } @@ -44,8 +49,11 @@ class IntentOrUriTest { val result = keyValidator.validate({ null }, CRITICAL) - assertThat(result).value().isNull() - assertThat(result).findings().containsExactly(RequiredValueMissing("key", Intent::class)) + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid + + assertThat(result.errors) + .containsExactly(NoValue("key", CRITICAL, Intent::class)) } /** Check validation passes when value is null and importance is [WARNING] (optional). */ @@ -55,8 +63,9 @@ class IntentOrUriTest { val result = keyValidator.validate(source = { null }, WARNING) - assertThat(result).findings().isEmpty() - assertThat(result.value).isNull() + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid> + assertThat(result.errors).isEmpty() } /** @@ -69,9 +78,10 @@ class IntentOrUriTest { val result = keyValidator.validate(values::get, CRITICAL) - assertThat(result).value().isNull() - assertThat(result) - .findings() + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid + + assertThat(result.errors) .containsExactly( ValueIsWrongType( "key", @@ -92,9 +102,10 @@ class IntentOrUriTest { val result = keyValidator.validate(values::get, WARNING) - assertThat(result).value().isNull() - assertThat(result) - .findings() + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid + + assertThat(result.errors) .containsExactly( ValueIsWrongType( "key", diff --git a/tests/unit/src/com/android/intentresolver/v2/validation/types/ParceledArrayTest.kt b/tests/unit/src/com/android/intentresolver/v2/validation/types/ParceledArrayTest.kt index d4dca01b..637873ea 100644 --- a/tests/unit/src/com/android/intentresolver/v2/validation/types/ParceledArrayTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/validation/types/ParceledArrayTest.kt @@ -4,8 +4,9 @@ import android.content.Intent import android.graphics.Point import com.android.intentresolver.v2.validation.Importance.CRITICAL import com.android.intentresolver.v2.validation.Importance.WARNING -import com.android.intentresolver.v2.validation.RequiredValueMissing -import com.android.intentresolver.v2.validation.ValidationResultSubject.Companion.assertThat +import com.android.intentresolver.v2.validation.Invalid +import com.android.intentresolver.v2.validation.NoValue +import com.android.intentresolver.v2.validation.Valid import com.android.intentresolver.v2.validation.ValueIsWrongType import com.android.intentresolver.v2.validation.WrongElementType import com.google.common.truth.Truth.assertThat @@ -21,7 +22,8 @@ class ParceledArrayTest { val result = keyValidator.validate(values::get, CRITICAL) - assertThat(result).findings().isEmpty() + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid> assertThat(result.value).containsExactly("String") } @@ -33,9 +35,10 @@ class ParceledArrayTest { val result = keyValidator.validate(values::get, CRITICAL) - assertThat(result).value().isNull() - assertThat(result) - .findings() + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid> + + assertThat(result.errors) .containsExactly( // TODO: report with a new class `WrongElementType` to improve clarity WrongElementType( @@ -55,8 +58,10 @@ class ParceledArrayTest { val result = keyValidator.validate(source = { null }, CRITICAL) - assertThat(result).value().isNull() - assertThat(result).findings().containsExactly(RequiredValueMissing("key", Intent::class)) + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid> + + assertThat(result.errors).containsExactly(NoValue("key", CRITICAL, Intent::class)) } /** Check validation passes when value is null and importance is [WARNING] (optional). */ @@ -66,8 +71,10 @@ class ParceledArrayTest { val result = keyValidator.validate(source = { null }, WARNING) - assertThat(result).findings().isEmpty() - assertThat(result.value).isNull() + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid> + + assertThat(result.errors).isEmpty() } /** Check correct failure result when the array value itself is the wrong type. */ @@ -78,9 +85,10 @@ class ParceledArrayTest { val result = keyValidator.validate(values::get, CRITICAL) - assertThat(result).value().isNull() - assertThat(result) - .findings() + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid> + + assertThat(result.errors) .containsExactly( ValueIsWrongType( "key", diff --git a/tests/unit/src/com/android/intentresolver/v2/validation/types/SimpleValueTest.kt b/tests/unit/src/com/android/intentresolver/v2/validation/types/SimpleValueTest.kt index 13bb4b33..93d76d46 100644 --- a/tests/unit/src/com/android/intentresolver/v2/validation/types/SimpleValueTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/validation/types/SimpleValueTest.kt @@ -1,10 +1,13 @@ package com.android.intentresolver.v2.validation.types import com.android.intentresolver.v2.validation.Importance.CRITICAL -import com.android.intentresolver.v2.validation.RequiredValueMissing -import com.android.intentresolver.v2.validation.ValidationResultSubject.Companion.assertThat +import com.android.intentresolver.v2.validation.Importance.WARNING +import com.android.intentresolver.v2.validation.Invalid +import com.android.intentresolver.v2.validation.NoValue +import com.android.intentresolver.v2.validation.Valid import com.android.intentresolver.v2.validation.ValueIsWrongType import org.junit.Test +import com.google.common.truth.Truth.assertThat class SimpleValueTest { @@ -15,8 +18,11 @@ class SimpleValueTest { val values = mapOf("key" to Math.PI) val result = keyValidator.validate(values::get, CRITICAL) - assertThat(result).findings().isEmpty() - assertThat(result).value().isEqualTo(Math.PI) + + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid + assertThat(result.value).isEqualTo(Math.PI) } /** Test for validation success when the value is present and the correct type. */ @@ -26,17 +32,17 @@ class SimpleValueTest { val values = mapOf("key" to "Apple Pie") val result = keyValidator.validate(values::get, CRITICAL) - assertThat(result).value().isNull() - assertThat(result) - .findings() - .containsExactly( - ValueIsWrongType( - "key", - importance = CRITICAL, - actualType = String::class, - allowedTypes = listOf(Double::class) - ) + + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid + assertThat(result.errors).containsExactly( + ValueIsWrongType( + "key", + importance = CRITICAL, + actualType = String::class, + allowedTypes = listOf(Double::class) ) + ) } /** Test the failure result when the value is missing. */ @@ -46,7 +52,26 @@ class SimpleValueTest { val result = keyValidator.validate(source = { null }, CRITICAL) - assertThat(result).value().isNull() - assertThat(result).findings().containsExactly(RequiredValueMissing("key", Double::class)) + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid + + assertThat(result.errors).containsExactly(NoValue("key", CRITICAL, Double::class)) + } + + + /** Test the failure result when the value is missing. */ + @Test + fun optional() { + val keyValidator = SimpleValue("key", expected = Double::class) + + val result = keyValidator.validate(source = { null }, WARNING) + + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid + + // Note: As single optional validation result, the return must be Invalid + // when there is no value to return, but no errors will be reported because + // an optional value cannot be "missing". + assertThat(result.errors).isEmpty() } } -- cgit v1.2.3-59-g8ed1b From 8f881cd027e8d970ed9e2040f4dbe218038e8b88 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Thu, 29 Feb 2024 15:03:11 -0500 Subject: ProfileHelper: compat helper class for Profile flows in existing Java This is a temporary measure to bring up the app with an initial set of user profile data sourced from UserInteractor. This is effectively a snapshot of current profile state, combined with a wrapper to access the availability StateFlow via an interface (hiding a suspend call). Test: atest IntentResolver-tests-unit:ProfileHelperTest Bug: 311348033 Flag: ACONFIG com.android.intentresolver.ENABLE_PRIVATE_PROFILE Change-Id: I6db6ece3bebbed22d0a6b7cf981873f96dd97745 --- .../intentresolver/v2/ProfileAvailability.kt | 79 ++++++ .../com/android/intentresolver/v2/ProfileHelper.kt | 74 ++++++ .../intentresolver/v2/ProfileAvailabilityTest.kt | 78 ++++++ .../android/intentresolver/v2/ProfileHelperTest.kt | 290 +++++++++++++++++++++ 4 files changed, 521 insertions(+) create mode 100644 java/src/com/android/intentresolver/v2/ProfileAvailability.kt create mode 100644 java/src/com/android/intentresolver/v2/ProfileHelper.kt create mode 100644 tests/unit/src/com/android/intentresolver/v2/ProfileAvailabilityTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/v2/ProfileHelperTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ProfileAvailability.kt b/java/src/com/android/intentresolver/v2/ProfileAvailability.kt new file mode 100644 index 00000000..4d689724 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ProfileAvailability.kt @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2024 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.v2 + +import com.android.intentresolver.v2.domain.interactor.UserInteractor +import com.android.intentresolver.v2.shared.model.Profile +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout + +/** Provides availability status for profiles */ +class ProfileAvailability( + private val scope: CoroutineScope, + private val userInteractor: UserInteractor +) { + private val availability = + userInteractor.availability.stateIn(scope, SharingStarted.Eagerly, mapOf()) + + /** Used by WorkProfilePausedEmptyStateProvider */ + var waitingToEnableProfile = false + private set + + private var waitJob: Job? = null + /** Query current profile availability. An unavailable profile is one which is not active. */ + fun isAvailable(profile: Profile) = availability.value[profile] ?: false + + /** Used by WorkProfilePausedEmptyStateProvider */ + fun requestQuietModeState(profile: Profile, quietMode: Boolean) { + val enableProfile = !quietMode + + // Check if the profile is already in the correct state + if (isAvailable(profile) == enableProfile) { + return // No-op + } + + // Support existing code + if (enableProfile) { + waitingToEnableProfile = true + waitJob?.cancel() + + val job = scope.launch { + // Wait for the profile to become available + // Wait for the profile to be enabled, then clear this flag + userInteractor.availability.filter { it[profile] == true }.first() + waitingToEnableProfile = false + } + job.invokeOnCompletion { + waitingToEnableProfile = false + } + waitJob = job + } + + // Apply the change + scope.launch { userInteractor.updateState(profile, enableProfile) } + } +} \ No newline at end of file diff --git a/java/src/com/android/intentresolver/v2/ProfileHelper.kt b/java/src/com/android/intentresolver/v2/ProfileHelper.kt new file mode 100644 index 00000000..784096b4 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ProfileHelper.kt @@ -0,0 +1,74 @@ +/* +* Copyright (C) 2024 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.v2 + +import android.os.UserHandle +import com.android.intentresolver.inject.IntentResolverFlags +import com.android.intentresolver.v2.domain.interactor.UserInteractor +import com.android.intentresolver.v2.shared.model.Profile +import com.android.intentresolver.v2.shared.model.User +import javax.inject.Inject + +class ProfileHelper @Inject constructor( + interactor: UserInteractor, + private val flags: IntentResolverFlags, + profiles: List, + launchedAsProfile: Profile, +) { + private val launchedByHandle: UserHandle = interactor.launchedAs + + // Map UserHandle back to a user within launchedByProfile + private val launchedByUser = when (launchedByHandle) { + launchedAsProfile.primary.handle -> launchedAsProfile.primary + launchedAsProfile.clone?.handle -> launchedAsProfile.clone + else -> error("launchedByUser must be a member of launchedByProfile") + } + val launchedAsProfileType: Profile.Type = launchedAsProfile.type + + val personalProfile = profiles.single { it.type == Profile.Type.PERSONAL } + val workProfile = profiles.singleOrNull { it.type == Profile.Type.WORK } + val privateProfile = profiles.singleOrNull { it.type == Profile.Type.PRIVATE } + + val personalHandle = personalProfile.primary.handle + val workHandle = workProfile?.primary?.handle + val privateHandle = privateProfile?.primary?.handle?.takeIf { flags.enablePrivateProfile() } + val cloneHandle = personalProfile.clone?.handle + + val isLaunchedAsCloneProfile = launchedByUser == launchedAsProfile.clone + + val cloneUserPresent = personalProfile.clone != null + val workProfilePresent = workProfile != null + val privateProfilePresent = privateProfile != null + + // Name retained for ease of review, to be renamed later + val tabOwnerUserHandleForLaunch = if (launchedByUser.role == User.Role.CLONE) { + // When started by clone user, return the profile owner instead + launchedAsProfile.primary.handle + } else { + // Otherwise the launched user is used + launchedByUser.handle + } + + // Name retained for ease of review, to be renamed later + fun getQueryIntentsHandle(handle: UserHandle): UserHandle? { + return if (isLaunchedAsCloneProfile && handle == personalHandle) { + cloneHandle + } else { + handle + } + } +} diff --git a/tests/unit/src/com/android/intentresolver/v2/ProfileAvailabilityTest.kt b/tests/unit/src/com/android/intentresolver/v2/ProfileAvailabilityTest.kt new file mode 100644 index 00000000..b4df058c --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/v2/ProfileAvailabilityTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2024 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.v2 + +import android.util.Log +import com.android.intentresolver.v2.data.repository.FakeUserRepository +import com.android.intentresolver.v2.domain.interactor.UserInteractor +import com.android.intentresolver.v2.shared.model.Profile +import com.android.intentresolver.v2.shared.model.User +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +private const val TAG = "ProfileAvailabilityTest" + +@OptIn(ExperimentalCoroutinesApi::class) +class ProfileAvailabilityTest { + private val personalUser = User(0, User.Role.PERSONAL) + private val workUser = User(10, User.Role.WORK) + + private val personalProfile = Profile(Profile.Type.PERSONAL, personalUser) + private val workProfile = Profile(Profile.Type.WORK, workUser) + + private val repository = FakeUserRepository(listOf(personalUser, workUser)) + private val interactor = UserInteractor(repository, launchedAs = personalUser.handle) + + @Test + fun testProfileAvailable() = runTest { + val availability = ProfileAvailability(backgroundScope, interactor) + runCurrent() + + assertThat(availability.isAvailable(personalProfile)).isTrue() + assertThat(availability.isAvailable(workProfile)).isTrue() + + availability.requestQuietModeState(workProfile, true) + runCurrent() + + assertThat(availability.isAvailable(workProfile)).isFalse() + + availability.requestQuietModeState(workProfile, false) + runCurrent() + + assertThat(availability.isAvailable(workProfile)).isTrue() + } + + @Test + fun waitingToEnableProfile() = runTest { + val availability = ProfileAvailability(backgroundScope, interactor) + runCurrent() + + availability.requestQuietModeState(workProfile, true) + assertThat(availability.waitingToEnableProfile).isFalse() + runCurrent() + + availability.requestQuietModeState(workProfile, false) + assertThat(availability.waitingToEnableProfile).isTrue() + + runCurrent() + + assertThat(availability.waitingToEnableProfile).isFalse() + } +} \ No newline at end of file diff --git a/tests/unit/src/com/android/intentresolver/v2/ProfileHelperTest.kt b/tests/unit/src/com/android/intentresolver/v2/ProfileHelperTest.kt new file mode 100644 index 00000000..9cbbfcd8 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/v2/ProfileHelperTest.kt @@ -0,0 +1,290 @@ +/* + * Copyright (C) 2024 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.v2 + +import com.android.intentresolver.Flags.FLAG_ENABLE_PRIVATE_PROFILE +import com.android.intentresolver.inject.FakeChooserServiceFlags +import com.android.intentresolver.inject.FakeIntentResolverFlags +import com.android.intentresolver.inject.IntentResolverFlags +import com.android.intentresolver.v2.data.repository.FakeUserRepository +import com.android.intentresolver.v2.domain.interactor.UserInteractor +import com.android.intentresolver.v2.shared.model.Profile +import com.android.intentresolver.v2.shared.model.User +import com.google.common.truth.Truth +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* + +import org.junit.Test + +class ProfileHelperTest { + + private val personalUser = User(0, User.Role.PERSONAL) + private val cloneUser = User(10, User.Role.CLONE) + + private val personalProfile = Profile(Profile.Type.PERSONAL, personalUser) + private val personalWithCloneProfile = Profile(Profile.Type.PERSONAL, personalUser, cloneUser) + + private val workUser = User(11, User.Role.WORK) + private val workProfile = Profile(Profile.Type.WORK, workUser) + + private val privateUser = User(12, User.Role.PRIVATE) + private val privateProfile = Profile(Profile.Type.PRIVATE, privateUser) + + private val flags = FakeIntentResolverFlags().apply { + setFlag(FLAG_ENABLE_PRIVATE_PROFILE, true) + } + + private fun assertProfiles( + helper: ProfileHelper, + personalProfile: Profile, + workProfile: Profile? = null, + privateProfile: Profile? = null) { + + assertThat(helper.personalProfile).isEqualTo(personalProfile) + assertThat(helper.personalHandle).isEqualTo(personalProfile.primary.handle) + + personalProfile.clone?.also { + assertThat(helper.cloneUserPresent).isTrue() + assertThat(helper.cloneHandle).isEqualTo(it.handle) + } ?: { + assertThat(helper.cloneUserPresent).isFalse() + assertThat(helper.cloneHandle).isNull() + } + + workProfile?.also { + assertThat(helper.workProfilePresent).isTrue() + assertThat(helper.workProfile).isEqualTo(it) + assertThat(helper.workHandle).isEqualTo(it.primary.handle) + } ?: { + assertThat(helper.workProfilePresent).isFalse() + assertThat(helper.workProfile).isNull() + assertThat(helper.workHandle).isNull() + } + + privateProfile?.also { + assertThat(helper.privateProfilePresent).isTrue() + assertThat(helper.privateProfile).isEqualTo(it) + assertThat(helper.privateHandle).isEqualTo(it.primary.handle) + } ?: { + assertThat(helper.privateProfilePresent).isFalse() + assertThat(helper.privateProfile).isNull() + assertThat(helper.privateHandle).isNull() + } + } + + + @Test + fun launchedByPersonal() = runTest { + val repository = FakeUserRepository(listOf(personalUser)) + val interactor = UserInteractor(repository, launchedAs = personalUser.handle) + val availability = interactor.availability.first() + val launchedBy = interactor.launchedAsProfile.first() + + val helper = ProfileHelper( + interactor = interactor, + flags = flags, + profiles = interactor.profiles.first(), + launchedAsProfile = launchedBy) + + assertProfiles(helper, personalProfile) + + assertThat(helper.isLaunchedAsCloneProfile).isFalse() + assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL) + assertThat(helper.getQueryIntentsHandle(personalUser.handle)) + .isEqualTo(personalProfile.primary.handle) + assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(personalProfile.primary.handle) + } + + @Test + fun launchedByPersonal_withClone() = runTest { + val repository = FakeUserRepository(listOf(personalUser, cloneUser)) + val interactor = UserInteractor(repository, launchedAs = personalUser.handle) + val availability = interactor.availability.first() + val launchedBy = interactor.launchedAsProfile.first() + + val helper = ProfileHelper( + interactor = interactor, + flags = flags, + profiles = interactor.profiles.first(), + launchedAsProfile = launchedBy) + + assertProfiles(helper, personalWithCloneProfile) + + assertThat(helper.isLaunchedAsCloneProfile).isFalse() + assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL) + assertThat(helper.getQueryIntentsHandle(personalUser.handle)).isEqualTo(personalUser.handle) + assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(personalProfile.primary.handle) + } + + @Test + fun launchedByClone() = runTest { + val repository = FakeUserRepository(listOf(personalUser, cloneUser)) + val interactor = UserInteractor(repository, launchedAs = cloneUser.handle) + val availability = interactor.availability.first() + val launchedBy = interactor.launchedAsProfile.first() + + val helper = ProfileHelper( + interactor = interactor, + flags = flags, + profiles = interactor.profiles.first(), + launchedAsProfile = launchedBy) + + assertProfiles(helper, personalWithCloneProfile) + + assertThat(helper.isLaunchedAsCloneProfile).isTrue() + assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL) + assertThat(helper.getQueryIntentsHandle(personalWithCloneProfile.primary.handle)) + .isEqualTo(personalWithCloneProfile.clone?.handle) + assertThat(helper.tabOwnerUserHandleForLaunch) + .isEqualTo(personalWithCloneProfile.primary.handle) + } + + @Test + fun launchedByPersonal_withWork() = runTest { + val repository = FakeUserRepository(listOf(personalUser, workUser)) + val interactor = UserInteractor(repository, launchedAs = personalUser.handle) + val availability = interactor.availability.first() + val launchedBy = interactor.launchedAsProfile.first() + + val helper = ProfileHelper( + interactor = interactor, + flags = flags, + profiles = interactor.profiles.first(), + launchedAsProfile = launchedBy) + + + assertProfiles(helper, + personalProfile = personalProfile, + workProfile = workProfile) + + assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL) + assertThat(helper.isLaunchedAsCloneProfile).isFalse() + assertThat(helper.getQueryIntentsHandle(personalUser.handle)) + .isEqualTo(personalProfile.primary.handle) + assertThat(helper.getQueryIntentsHandle(workUser.handle)) + .isEqualTo(workProfile.primary.handle) + assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(personalProfile.primary.handle) + } + + @Test + fun launchedByWork() = runTest { + val repository = FakeUserRepository(listOf(personalUser, workUser)) + val interactor = UserInteractor(repository, launchedAs = workUser.handle) + val availability = interactor.availability.first() + val launchedBy = interactor.launchedAsProfile.first() + + val helper = ProfileHelper( + interactor = interactor, + flags = flags, + profiles = interactor.profiles.first(), + launchedAsProfile = launchedBy) + + assertProfiles(helper, + personalProfile = personalProfile, + workProfile = workProfile) + + assertThat(helper.isLaunchedAsCloneProfile).isFalse() + assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.WORK) + assertThat(helper.getQueryIntentsHandle(personalProfile.primary.handle)) + .isEqualTo(personalProfile.primary.handle) + assertThat(helper.getQueryIntentsHandle(workProfile.primary.handle)) + .isEqualTo(workProfile.primary.handle) + assertThat(helper.tabOwnerUserHandleForLaunch) + .isEqualTo(workProfile.primary.handle) + } + + @Test + fun launchedByPersonal_withPrivate() = runTest { + val repository = FakeUserRepository(listOf(personalUser, privateUser)) + val interactor = UserInteractor(repository, launchedAs = personalUser.handle) + val availability = interactor.availability.first() + val launchedBy = interactor.launchedAsProfile.first() + + val helper = ProfileHelper( + interactor = interactor, + flags = flags, + profiles = interactor.profiles.first(), + launchedAsProfile = launchedBy) + + assertProfiles(helper, + personalProfile = personalProfile, + privateProfile = privateProfile) + + assertThat(helper.isLaunchedAsCloneProfile).isFalse() + assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL) + assertThat(helper.getQueryIntentsHandle(personalProfile.primary.handle)) + .isEqualTo(personalProfile.primary.handle) + assertThat(helper.getQueryIntentsHandle(privateProfile.primary.handle)) + .isEqualTo(privateProfile.primary.handle) + assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(personalProfile.primary.handle) + } + + @Test + fun launchedByPrivate() = runTest { + val repository = FakeUserRepository(listOf(personalUser, privateUser)) + val interactor = UserInteractor(repository, launchedAs = privateUser.handle) + val availability = interactor.availability.first() + val launchedBy = interactor.launchedAsProfile.first() + + val helper = ProfileHelper( + interactor = interactor, + flags = flags, + profiles = interactor.profiles.first(), + launchedAsProfile = launchedBy) + + + assertProfiles(helper, + personalProfile = personalProfile, + privateProfile = privateProfile) + + assertThat(helper.isLaunchedAsCloneProfile).isFalse() + assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PRIVATE) + assertThat(helper.getQueryIntentsHandle(personalProfile.primary.handle)) + .isEqualTo(personalProfile.primary.handle) + assertThat(helper.getQueryIntentsHandle(privateProfile.primary.handle)) + .isEqualTo(privateProfile.primary.handle) + assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(privateProfile.primary.handle) + } + + @Test + fun launchedByPersonal_withPrivate_privateDisabled() = runTest { + flags.setFlag(FLAG_ENABLE_PRIVATE_PROFILE, false) + + val repository = FakeUserRepository(listOf(personalUser, privateUser)) + val interactor = UserInteractor(repository, launchedAs = personalUser.handle) + val availability = interactor.availability.first() + val launchedBy = interactor.launchedAsProfile.first() + + val helper = ProfileHelper( + interactor = interactor, + flags = flags, + profiles = interactor.profiles.first(), + launchedAsProfile = launchedBy) + + assertProfiles(helper, + personalProfile = personalProfile, + privateProfile = null) + + assertThat(helper.isLaunchedAsCloneProfile).isFalse() + assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL) + assertThat(helper.getQueryIntentsHandle(personalProfile.primary.handle)) + .isEqualTo(personalProfile.primary.handle) + assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(personalProfile.primary.handle) + } +} \ No newline at end of file -- cgit v1.2.3-59-g8ed1b From c20c32784f927e9198ed2d13e8999a98bd87bdec Mon Sep 17 00:00:00 2001 From: mrenouf Date: Fri, 1 Mar 2024 08:23:31 -0500 Subject: Adds @JavaInterop, a @RequiresOptIn for marking Java interop code "This is a a property, function or class specifically supporting Java interoperability. Usage from Kotlin should be limited to interactions with Java" Bug: 309960444 Test: This is just an annotation. Change-Id: Ia2f21bd5d4359ba22e1292ebebbef77707706ac9 --- .../intentresolver/v2/annotation/JavaInterop.kt | 26 ++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 java/src/com/android/intentresolver/v2/annotation/JavaInterop.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/annotation/JavaInterop.kt b/java/src/com/android/intentresolver/v2/annotation/JavaInterop.kt new file mode 100644 index 00000000..15c5018a --- /dev/null +++ b/java/src/com/android/intentresolver/v2/annotation/JavaInterop.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2024 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.v2.annotation + +/** + * Apply to code which exists specifically to easy integration with existing Java and Java APIs. + * + * The goal is to prevent usage from Kotlin when a more idiomatic alternative is available. + */ +@RequiresOptIn("This is a a property, function or class specifically supporting Java " + + "interoperability. Usage from Kotlin should be limited to interactions with Java.") +annotation class JavaInterop -- cgit v1.2.3-59-g8ed1b From 2b9f86f50bc8b3ee83ffc0be1aed5c87fdd16d72 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Fri, 1 Mar 2024 12:35:53 -0500 Subject: Create a Resolver-specific WorkProfileEmptyStateProvider This is a temporary measure to split upcoming CLs between actively shipping code and future work (ResolverActivity). The changes within ResolverActivity to integrate profile support are independent *except* for this one class which is used by both Activities. After ResolverActivity is updated, this copy can be removed. Bug: 311348033 Test: atest IntentResolver-tests-activity Test: atest IntentResolver-tests-unit Change-Id: I39b3c2fd0c0bd5451d6ded97bd5cbf4dc8404826 --- .../intentresolver/v2/ResolverActivity.java | 10 +- ...esolverWorkProfilePausedEmptyStateProvider.java | 116 +++++++++++++++++++++ 2 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 java/src/com/android/intentresolver/v2/emptystate/ResolverWorkProfilePausedEmptyStateProvider.java (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index 52d5f2de..d985a161 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -101,17 +101,17 @@ import com.android.intentresolver.icons.DefaultTargetDataLoader; import com.android.intentresolver.icons.TargetDataLoader; import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; import com.android.intentresolver.v2.data.repository.DevicePolicyResources; -import com.android.intentresolver.v2.shared.model.Profile; import com.android.intentresolver.v2.emptystate.NoAppsAvailableEmptyStateProvider; import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider; import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; -import com.android.intentresolver.v2.emptystate.WorkProfilePausedEmptyStateProvider; +import com.android.intentresolver.v2.emptystate.ResolverWorkProfilePausedEmptyStateProvider; import com.android.intentresolver.v2.profiles.MultiProfilePagerAdapter; -import com.android.intentresolver.v2.profiles.OnSwitchOnWorkSelectedListener; import com.android.intentresolver.v2.profiles.MultiProfilePagerAdapter.ProfileType; import com.android.intentresolver.v2.profiles.OnProfileSelectedListener; -import com.android.intentresolver.v2.profiles.TabConfig; +import com.android.intentresolver.v2.profiles.OnSwitchOnWorkSelectedListener; import com.android.intentresolver.v2.profiles.ResolverMultiProfilePagerAdapter; +import com.android.intentresolver.v2.profiles.TabConfig; +import com.android.intentresolver.v2.shared.model.Profile; import com.android.intentresolver.v2.ui.ActionTitle; import com.android.intentresolver.v2.ui.model.ActivityModel; import com.android.intentresolver.v2.ui.model.ResolverRequest; @@ -924,7 +924,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements final EmptyStateProvider blockerEmptyStateProvider = createBlockerEmptyStateProvider(); final EmptyStateProvider workProfileOffEmptyStateProvider = - new WorkProfilePausedEmptyStateProvider(this, workProfileUserHandle, + new ResolverWorkProfilePausedEmptyStateProvider(this, workProfileUserHandle, mLogic.getWorkProfileAvailabilityManager(), /* onSwitchOnWorkSelectedListener= */ () -> { diff --git a/java/src/com/android/intentresolver/v2/emptystate/ResolverWorkProfilePausedEmptyStateProvider.java b/java/src/com/android/intentresolver/v2/emptystate/ResolverWorkProfilePausedEmptyStateProvider.java new file mode 100644 index 00000000..eaed35a7 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/emptystate/ResolverWorkProfilePausedEmptyStateProvider.java @@ -0,0 +1,116 @@ +/* + * 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.v2.emptystate; + +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PAUSED_TITLE; + +import android.app.admin.DevicePolicyEventLogger; +import android.app.admin.DevicePolicyManager; +import android.content.Context; +import android.os.UserHandle; +import android.stats.devicepolicy.nano.DevicePolicyEnums; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.android.intentresolver.MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; +import com.android.intentresolver.R; +import com.android.intentresolver.ResolverListAdapter; +import com.android.intentresolver.WorkProfileAvailabilityManager; +import com.android.intentresolver.emptystate.EmptyState; +import com.android.intentresolver.emptystate.EmptyStateProvider; + +/** + * ResolverActivity empty state provider that returns empty state which is shown when + * work profile is paused and we need to show a button to enable it. + */ +public class ResolverWorkProfilePausedEmptyStateProvider implements EmptyStateProvider { + + private final UserHandle mWorkProfileUserHandle; + private final WorkProfileAvailabilityManager mWorkProfileAvailability; + private final String mMetricsCategory; + private final OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; + private final Context mContext; + + public ResolverWorkProfilePausedEmptyStateProvider(@NonNull Context context, + @Nullable UserHandle workProfileUserHandle, + @NonNull WorkProfileAvailabilityManager workProfileAvailability, + @Nullable OnSwitchOnWorkSelectedListener onSwitchOnWorkSelectedListener, + @NonNull String metricsCategory) { + mContext = context; + mWorkProfileUserHandle = workProfileUserHandle; + mWorkProfileAvailability = workProfileAvailability; + mMetricsCategory = metricsCategory; + mOnSwitchOnWorkSelectedListener = onSwitchOnWorkSelectedListener; + } + + @Nullable + @Override + public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { + if (!resolverListAdapter.getUserHandle().equals(mWorkProfileUserHandle) + || !mWorkProfileAvailability.isQuietModeEnabled() + || resolverListAdapter.getCount() == 0) { + return null; + } + + final String title = mContext.getSystemService(DevicePolicyManager.class) + .getResources().getString(RESOLVER_WORK_PAUSED_TITLE, + () -> mContext.getString(R.string.resolver_turn_on_work_apps)); + + return new WorkProfileOffEmptyState(title, (tab) -> { + tab.showSpinner(); + if (mOnSwitchOnWorkSelectedListener != null) { + mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); + } + mWorkProfileAvailability.requestQuietModeEnabled(false); + }, mMetricsCategory); + } + + public static class WorkProfileOffEmptyState implements EmptyState { + + private final String mTitle; + private final ClickListener mOnClick; + private final String mMetricsCategory; + + public WorkProfileOffEmptyState(String title, @NonNull ClickListener onClick, + @NonNull String metricsCategory) { + mTitle = title; + mOnClick = onClick; + mMetricsCategory = metricsCategory; + } + + @Nullable + @Override + public String getTitle() { + return mTitle; + } + + @Nullable + @Override + public ClickListener getButtonClickListener() { + return mOnClick; + } + + @Override + public void onEmptyStateShown() { + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_EMPTY_STATE_WORK_APPS_DISABLED) + .setStrings(mMetricsCategory) + .write(); + } + } +} -- cgit v1.2.3-59-g8ed1b From 39553a1b848ee573acb306e3297688df096f404f Mon Sep 17 00:00:00 2001 From: mrenouf Date: Sun, 3 Mar 2024 12:10:02 -0500 Subject: Fix CreationExtras.addDefaultArgs when args absent addDefaultArgs grabs the existing Bundle from CreationExtras and adds in the additional values. If default args aren't present (whenever the initial intent did not have extras), the updated bundle was never returned within the original instance since it was relying on modifying the Bundle in-place. This corrects the issue by explicitly returning a new instance containing the updated `DEFAULT_ARGS` Bundle. Test: atest com.android.intentresolver.v2.ext.CreationExtrasExtTest Change-Id: I5a7c61baaaeefa2878b4041d809c348bf5ac70c0 --- .../intentresolver/v2/ext/CreationExtrasExt.kt | 13 ++++-- .../intentresolver/v2/ext/CreationExtrasExtTest.kt | 54 ++++++++++++++++++++++ 2 files changed, 62 insertions(+), 5 deletions(-) create mode 100644 tests/unit/src/com/android/intentresolver/v2/ext/CreationExtrasExtTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ext/CreationExtrasExt.kt b/java/src/com/android/intentresolver/v2/ext/CreationExtrasExt.kt index ebd613f1..6c36e6aa 100644 --- a/java/src/com/android/intentresolver/v2/ext/CreationExtrasExt.kt +++ b/java/src/com/android/intentresolver/v2/ext/CreationExtrasExt.kt @@ -18,14 +18,17 @@ package com.android.intentresolver.v2.ext import android.os.Bundle import android.os.Parcelable +import androidx.core.os.bundleOf import androidx.lifecycle.DEFAULT_ARGS_KEY import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.MutableCreationExtras -/** Adds one or more key-value pairs to the default Args bundle in this extras instance. */ +/** + * Returns a new instance with additional [values] added to the existing default args Bundle (if + * present), otherwise adds a new entry with a copy of this bundle. + */ fun CreationExtras.addDefaultArgs(vararg values: Pair): CreationExtras { val defaultArgs: Bundle = get(DEFAULT_ARGS_KEY) ?: Bundle() - for ((key, value) in values) { - defaultArgs.putParcelable(key, value) - } - return this + defaultArgs.putAll(bundleOf(*values)) + return MutableCreationExtras(this).apply { set(DEFAULT_ARGS_KEY, defaultArgs) } } diff --git a/tests/unit/src/com/android/intentresolver/v2/ext/CreationExtrasExtTest.kt b/tests/unit/src/com/android/intentresolver/v2/ext/CreationExtrasExtTest.kt new file mode 100644 index 00000000..5eac6bd6 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/v2/ext/CreationExtrasExtTest.kt @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2024 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.v2.ext + +import android.graphics.Point +import androidx.core.os.bundleOf +import androidx.lifecycle.DEFAULT_ARGS_KEY +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.MutableCreationExtras +import androidx.test.ext.truth.os.BundleSubject.assertThat +import org.junit.Test + +class CreationExtrasExtTest { + @Test + fun addDefaultArgs_addsWhenAbsent() { + val creationExtras: CreationExtras = MutableCreationExtras() // empty + + val updated = creationExtras.addDefaultArgs("POINT" to Point(1, 1)) + + val defaultArgs = updated[DEFAULT_ARGS_KEY] + assertThat(defaultArgs).containsKey("POINT") + assertThat(defaultArgs).parcelable("POINT").marshallsEquallyTo(Point(1, 1)) + } + + @Test + fun addDefaultArgs_addsToExisting() { + val creationExtras: CreationExtras = + MutableCreationExtras().apply { + set(DEFAULT_ARGS_KEY, bundleOf("POINT1" to Point(1, 1))) + } + + val updated = creationExtras.addDefaultArgs("POINT2" to Point(2, 2)) + + val defaultArgs = updated[DEFAULT_ARGS_KEY] + assertThat(defaultArgs).containsKey("POINT1") + assertThat(defaultArgs).containsKey("POINT2") + assertThat(defaultArgs).parcelable("POINT1").marshallsEquallyTo(Point(1, 1)) + assertThat(defaultArgs).parcelable("POINT2").marshallsEquallyTo(Point(2, 2)) + } +} -- cgit v1.2.3-59-g8ed1b From be63632a01b639a7a169da21f5996796c588fa5d Mon Sep 17 00:00:00 2001 From: mrenouf Date: Sun, 3 Mar 2024 14:52:03 -0500 Subject: Create ActivityModel directly instead of injecting There is an initialization order issue with the existing approach: Within the Hilt framework, the ViewModelComponent is initialized prior to injecting the Activity. The ViewModel cannot then depend on any injected values since they will not be available when required. Due to another bug, this was not causing a problem because the value of the injected field was never being checked when the ViewModel was requested in onCreate. The value was inserted on the second request for the ViewModel, after onCreate, and this would mutate the state of the default args Bundle in the already created instance's SavedStateHandle. (Fixed in ag/26434053) The @ActivityScoped Module providing the ActivityModel is replaced with a simple method which creates an instaance from the Activity. The ActivityModel is then available from the ViewModel. This method exists to allows for changing the launchedFromUid, launchedFromPackage, and referrer values without dependence on the system. Bug: 300157408 Test: atest IntentResolver-tests-activity:com.android.intentresolver.v2 Change-Id: I297cbd602c13462f0c0614a279655f2852658dc4 --- .../android/intentresolver/v2/ChooserActivity.java | 22 +++-- .../intentresolver/v2/ResolverActivity.java | 12 ++- .../intentresolver/v2/ui/model/ActivityModel.kt | 12 +++ .../v2/ui/model/ActivityModelModule.kt | 43 --------- .../v2/ui/viewmodel/ChooserViewModel.kt | 8 +- .../v2/ui/model/TestActivityModelModule.kt | 45 --------- .../v2/ui/model/ActivityLaunchTest.kt | 107 --------------------- .../v2/ui/model/ActivityModelTest.kt | 107 +++++++++++++++++++++ 8 files changed, 146 insertions(+), 210 deletions(-) delete mode 100644 java/src/com/android/intentresolver/v2/ui/model/ActivityModelModule.kt delete mode 100644 tests/activity/src/com/android/intentresolver/v2/ui/model/TestActivityModelModule.kt delete mode 100644 tests/unit/src/com/android/intentresolver/v2/ui/model/ActivityLaunchTest.kt create mode 100644 tests/unit/src/com/android/intentresolver/v2/ui/model/ActivityModelTest.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 765d7c2d..8387212a 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -31,6 +31,7 @@ import static androidx.lifecycle.LifecycleKt.getCoroutineScope; import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION; import static com.android.intentresolver.v2.ext.CreationExtrasExtKt.addDefaultArgs; +import static com.android.intentresolver.v2.ui.model.ActivityModel.ACTIVITY_MODEL_KEY; import static com.android.internal.annotations.VisibleForTesting.Visibility.PROTECTED; import static com.android.internal.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET; @@ -274,7 +275,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private static final int SCROLL_STATUS_SCROLLING_HORIZONTAL = 2; @Inject public ChooserHelper mChooserHelper; - @Inject public ActivityModel mActivityModel; @Inject public FeatureFlags mFeatureFlags; @Inject public android.service.chooser.FeatureFlags mChooserServiceFeatureFlags; @Inject public EventLog mEventLog; @@ -333,7 +333,13 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private boolean mFinishWhenStopped = false; private final AtomicLong mIntentReceivedTime = new AtomicLong(-1); + + protected ActivityModel createActivityModel() { + return ActivityModel.createFrom(this); + } + private ChooserViewModel mViewModel; + private ActivityModel mActivityModel; @VisibleForTesting protected ChooserActivityLogic createActivityLogic() { @@ -348,30 +354,32 @@ public class ChooserActivity extends Hilt_ChooserActivity implements public CreationExtras getDefaultViewModelCreationExtras() { return addDefaultArgs( super.getDefaultViewModelCreationExtras(), - new Pair<>(ActivityModel.ACTIVITY_MODEL_KEY, mActivityModel)); + new Pair<>(ACTIVITY_MODEL_KEY, createActivityModel())); } @Override protected final void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Log.i(TAG, "onCreate"); - Log.i(TAG, "mActivityModel=" + mActivityModel.toString()); - - // The postInit hook is invoked when this function returns, via Lifecycle. - mChooserHelper.setPostCreateCallback(this::init); + mViewModel = new ViewModelProvider(this).get(ChooserViewModel.class); + mActivityModel = mViewModel.getActivityModel(); int callerUid = mActivityModel.getLaunchedFromUid(); if (callerUid < 0 || UserHandle.isIsolated(callerUid)) { Log.e(TAG, "Can't start a resolver from uid " + callerUid); finish(); } + setTheme(R.style.Theme_DeviceDefault_Chooser); Tracer.INSTANCE.markLaunched(); - mViewModel = new ViewModelProvider(this).get(ChooserViewModel.class); if (!mViewModel.init()) { finish(); return; } + + // The post-create callback is invoked when this function returns, via Lifecycle. + mChooserHelper.setPostCreateCallback(this::init); + IntentSender chosenComponentSender = mViewModel.getChooserRequest().getChosenComponentSender(); if (chosenComponentSender != null) { diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index d985a161..a9d9f8b1 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -154,11 +154,10 @@ public class ResolverActivity extends Hilt_ResolverActivity implements ResolverListAdapter.ResolverListCommunicator { @Inject public PackageManager mPackageManager; - @Inject public ActivityModel mActivityModel; @Inject public DevicePolicyResources mDevicePolicyResources; @Inject public IntentForwarding mIntentForwarding; private ResolverRequest mResolverRequest; - + private ActivityModel mActivityModel; protected ActivityLogic mLogic; protected TargetDataLoader mTargetDataLoader; private boolean mResolvingHome; @@ -218,6 +217,9 @@ public class ResolverActivity extends Hilt_ResolverActivity implements } }; } + protected ActivityModel createActivityModel() { + return ActivityModel.createFrom(this); + } @VisibleForTesting protected ActivityLogic createActivityLogic() { @@ -232,15 +234,17 @@ public class ResolverActivity extends Hilt_ResolverActivity implements public CreationExtras getDefaultViewModelCreationExtras() { return addDefaultArgs( super.getDefaultViewModelCreationExtras(), - new Pair<>(ActivityModel.ACTIVITY_MODEL_KEY, mActivityModel)); + new Pair<>(ActivityModel.ACTIVITY_MODEL_KEY, ActivityModel.createFrom(this))); } @Override protected final void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setTheme(R.style.Theme_DeviceDefault_Resolver); + mActivityModel = createActivityModel(); + Log.i(TAG, "onCreate"); - Log.i(TAG, "activityLaunch=" + mActivityModel.toString()); + Log.i(TAG, "activityModel=" + mActivityModel.toString()); int callerUid = mActivityModel.getLaunchedFromUid(); if (callerUid < 0 || UserHandle.isIsolated(callerUid)) { Log.e(TAG, "Can't start a resolver from uid " + callerUid); diff --git a/java/src/com/android/intentresolver/v2/ui/model/ActivityModel.kt b/java/src/com/android/intentresolver/v2/ui/model/ActivityModel.kt index 02bb6640..07b17435 100644 --- a/java/src/com/android/intentresolver/v2/ui/model/ActivityModel.kt +++ b/java/src/com/android/intentresolver/v2/ui/model/ActivityModel.kt @@ -15,12 +15,14 @@ */ package com.android.intentresolver.v2.ui.model +import android.app.Activity import android.content.Intent import android.net.Uri import android.os.Parcel import android.os.Parcelable import com.android.intentresolver.v2.ext.readParcelable import com.android.intentresolver.v2.ext.requireParcelable +import java.util.Objects /** Contains Activity-scope information about the state when started. */ data class ActivityModel( @@ -64,5 +66,15 @@ data class ActivityModel( override fun newArray(size: Int) = arrayOfNulls(size) override fun createFromParcel(source: Parcel) = ActivityModel(source) } + + @JvmStatic + fun createFrom(activity: Activity): ActivityModel { + return ActivityModel( + activity.intent, + activity.launchedFromUid, + Objects.requireNonNull(activity.launchedFromPackage), + activity.referrer + ) + } } } diff --git a/java/src/com/android/intentresolver/v2/ui/model/ActivityModelModule.kt b/java/src/com/android/intentresolver/v2/ui/model/ActivityModelModule.kt deleted file mode 100644 index d9fb1fa6..00000000 --- a/java/src/com/android/intentresolver/v2/ui/model/ActivityModelModule.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright (C) 2024 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.v2.ui.model - -import android.app.Activity -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.components.ActivityComponent -import dagger.hilt.android.scopes.ActivityScoped - -@Module -@InstallIn(ActivityComponent::class) -object ActivityModelModule { - - @Provides - @ActivityScoped - fun activityModel(activity: Activity): ActivityModel { - return ActivityModel( - intent = activity.intent, - launchedFromUid = activity.launchedFromUid, - launchedFromPackage = requireNotNull(activity.launchedFromPackage) { - "activity.launchedFromPackage was null. This is expected to be non-null for " + - "any system-signed application!" - }, - referrer = activity.referrer - ) - } -} diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt index 424f36cd..8ed2fa29 100644 --- a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt +++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt @@ -39,17 +39,17 @@ constructor( flags: ChooserServiceFlags, ) : ViewModel() { - private val mActivityModel: ActivityModel = + /** Parcelable-only references provided from the creating Activity */ + val activityModel: ActivityModel = requireNotNull(args[ACTIVITY_MODEL_KEY]) { "ActivityModel missing in SavedStateHandle! ($ACTIVITY_MODEL_KEY)" } /** The result of reading and validating the inputs provided in savedState. */ - private val status: ValidationResult = - readChooserRequest(mActivityModel, flags) + private val status: ValidationResult = readChooserRequest(activityModel, flags) val chooserRequest: ChooserRequest by lazy { - when(status) { + when (status) { is Valid -> status.value is Invalid -> error(status.errors) } diff --git a/tests/activity/src/com/android/intentresolver/v2/ui/model/TestActivityModelModule.kt b/tests/activity/src/com/android/intentresolver/v2/ui/model/TestActivityModelModule.kt deleted file mode 100644 index 7d05dc0f..00000000 --- a/tests/activity/src/com/android/intentresolver/v2/ui/model/TestActivityModelModule.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright (C) 2024 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.v2.ui.model - -import android.app.Activity -import android.net.Uri -import dagger.Module -import dagger.Provides -import dagger.hilt.android.components.ActivityComponent -import dagger.hilt.android.scopes.ActivityScoped -import dagger.hilt.testing.TestInstallIn - -@Module -@TestInstallIn(components = [ActivityComponent::class], replaces = [ActivityModelModule::class]) -class TestActivityModelModule { - - @Provides - @ActivityScoped - fun activityModel(activity: Activity): ActivityModel { - return ActivityModel( - intent = activity.intent, - launchedFromUid = LAUNCHED_FROM_UID, - launchedFromPackage = LAUNCHED_FROM_PACKAGE, - referrer = REFERRER) - } - - companion object { - const val LAUNCHED_FROM_PACKAGE = "example.com" - const val LAUNCHED_FROM_UID = 1234 - val REFERRER: Uri = Uri.fromParts(ANDROID_APP_SCHEME, LAUNCHED_FROM_PACKAGE, "") - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/model/ActivityLaunchTest.kt b/tests/unit/src/com/android/intentresolver/v2/ui/model/ActivityLaunchTest.kt deleted file mode 100644 index e30cd81a..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/ui/model/ActivityLaunchTest.kt +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright (C) 2024 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.v2.ui.model - -import android.content.Intent -import android.content.Intent.ACTION_CHOOSER -import android.content.Intent.EXTRA_TEXT -import android.net.Uri -import com.android.intentresolver.v2.ext.toParcelAndBack -import com.google.common.truth.Truth.assertThat -import com.google.common.truth.Truth.assertWithMessage -import org.junit.Test - -class ActivityLaunchTest { - - @Test - fun testDefaultValues() { - val input = ActivityModel(Intent(ACTION_CHOOSER), 0, "example.com", null) - - val output = input.toParcelAndBack() - - assertEquals(input, output) - } - - @Test - fun testCommonValues() { - val intent = Intent(ACTION_CHOOSER).apply { putExtra(EXTRA_TEXT, "Test") } - val input = - ActivityModel(intent, 1234, "com.example", Uri.parse("android-app://example.com")) - - val output = input.toParcelAndBack() - - assertEquals(input, output) - } - - @Test - fun testReferrerPackage_withAppReferrer_usesReferrer() { - val launch1 = - ActivityModel( - intent = Intent(), - launchedFromUid = 1000, - launchedFromPackage = "other.example.com", - referrer = Uri.parse("android-app://app.example.com") - ) - - assertThat(launch1.referrerPackage).isEqualTo("app.example.com") - } - - @Test - fun testReferrerPackage_httpReferrer_isNull() { - val launch = - ActivityModel( - intent = Intent(), - launchedFromUid = 1000, - launchedFromPackage = "example.com", - referrer = Uri.parse("http://some.other.value") - ) - - assertThat(launch.referrerPackage).isNull() - } - - @Test - fun testReferrerPackage_nullReferrer_isNull() { - val launch = - ActivityModel( - intent = Intent(), - launchedFromUid = 1000, - launchedFromPackage = "example.com", - referrer = null - ) - - assertThat(launch.referrerPackage).isNull() - } - - private fun assertEquals(expected: ActivityModel, actual: ActivityModel) { - // Test fields separately: Intent does not override equals() - assertWithMessage("%s.filterEquals(%s)", actual.intent, expected.intent) - .that(actual.intent.filterEquals(expected.intent)) - .isTrue() - - assertWithMessage("actual fromUid is equal to expected") - .that(actual.launchedFromUid) - .isEqualTo(expected.launchedFromUid) - - assertWithMessage("actual fromPackage is equal to expected") - .that(actual.launchedFromPackage) - .isEqualTo(expected.launchedFromPackage) - - assertWithMessage("actual referrer is equal to expected") - .that(actual.referrer) - .isEqualTo(expected.referrer) - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/model/ActivityModelTest.kt b/tests/unit/src/com/android/intentresolver/v2/ui/model/ActivityModelTest.kt new file mode 100644 index 00000000..049fa001 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/v2/ui/model/ActivityModelTest.kt @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2024 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.v2.ui.model + +import android.content.Intent +import android.content.Intent.ACTION_CHOOSER +import android.content.Intent.EXTRA_TEXT +import android.net.Uri +import com.android.intentresolver.v2.ext.toParcelAndBack +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import org.junit.Test + +class ActivityModelTest { + + @Test + fun testDefaultValues() { + val input = ActivityModel(Intent(ACTION_CHOOSER), 0, "example.com", null) + + val output = input.toParcelAndBack() + + assertEquals(input, output) + } + + @Test + fun testCommonValues() { + val intent = Intent(ACTION_CHOOSER).apply { putExtra(EXTRA_TEXT, "Test") } + val input = + ActivityModel(intent, 1234, "com.example", Uri.parse("android-app://example.com")) + + val output = input.toParcelAndBack() + + assertEquals(input, output) + } + + @Test + fun testReferrerPackage_withAppReferrer_usesReferrer() { + val launch1 = + ActivityModel( + intent = Intent(), + launchedFromUid = 1000, + launchedFromPackage = "other.example.com", + referrer = Uri.parse("android-app://app.example.com") + ) + + assertThat(launch1.referrerPackage).isEqualTo("app.example.com") + } + + @Test + fun testReferrerPackage_httpReferrer_isNull() { + val launch = + ActivityModel( + intent = Intent(), + launchedFromUid = 1000, + launchedFromPackage = "example.com", + referrer = Uri.parse("http://some.other.value") + ) + + assertThat(launch.referrerPackage).isNull() + } + + @Test + fun testReferrerPackage_nullReferrer_isNull() { + val launch = + ActivityModel( + intent = Intent(), + launchedFromUid = 1000, + launchedFromPackage = "example.com", + referrer = null + ) + + assertThat(launch.referrerPackage).isNull() + } + + private fun assertEquals(expected: ActivityModel, actual: ActivityModel) { + // Test fields separately: Intent does not override equals() + assertWithMessage("%s.filterEquals(%s)", actual.intent, expected.intent) + .that(actual.intent.filterEquals(expected.intent)) + .isTrue() + + assertWithMessage("actual fromUid is equal to expected") + .that(actual.launchedFromUid) + .isEqualTo(expected.launchedFromUid) + + assertWithMessage("actual fromPackage is equal to expected") + .that(actual.launchedFromPackage) + .isEqualTo(expected.launchedFromPackage) + + assertWithMessage("actual referrer is equal to expected") + .that(actual.referrer) + .isEqualTo(expected.referrer) + } +} -- cgit v1.2.3-59-g8ed1b