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
diff --git a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt
index 10ee5af..4c781a4 100644
--- a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt
+++ b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt
@@ -19,14 +19,10 @@
 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 6350756..9acc468 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.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 @@
     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 7062da3..b968641 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 @@
 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 e093058..a71de19 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.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.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.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 @@
     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.
+        setTheme(R.style.Theme_DeviceDefault_Chooser);
+        Tracer.INSTANCE.markLaunched();
+        mViewModel = new ViewModelProvider(this).get(ChooserViewModel.class);
+        if (!mViewModel.init()) {
+            finish();
             return;
         }
-        setTheme(R.style.Theme_DeviceDefault_Chooser);
-        mLogic = createActivityLogic();
-        Tracer.INSTANCE.markLaunched();
+        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 @@
         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 @@
                 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 @@
                 chooserRequest.getModifyShareAction() != null
         );
         mEnterTransitionAnimationDelegate.postponeTransition();
-
-        restore(savedInstanceState);
     }
 
     private void restore(@Nullable Bundle savedInstanceState) {
@@ -1151,15 +1162,6 @@
     //////////////////////////////////////////////////////////////////////////////////////////////
     //////////////////////////////////////////////////////////////////////////////////////////////
 
-    @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 @@
     }
 
     protected EmptyStateProvider createBlockerEmptyStateProvider() {
-        final boolean isSendAction = requireChooserRequest().isSendActionTarget();
+        final boolean isSendAction = mViewModel.getChooserRequest().isSendActionTarget();
 
         final EmptyState noWorkToPersonalEmptyState =
                 new DevicePolicyBlockerEmptyState(
@@ -1504,7 +1506,7 @@
         }
         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 @@
 
     @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 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 @@
     }
 
     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 @@
         // 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 @@
     protected boolean onTargetSelected(TargetInfo target) {
         if (mRefinementManager.maybeHandleSelection(
                 target,
-                requireChooserRequest().getRefinementIntentSender(),
+                mViewModel.getChooserRequest().getRefinementIntentSender(),
                 getApplication(),
                 getMainThreadHandler())) {
             return false;
@@ -1732,7 +1732,7 @@
                             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 @@
         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 @@
 
         @Override
         public boolean isComponentFiltered(ComponentName name) {
-            return requireChooserRequest().getFilteredComponentNames().contains(name);
+            return mViewModel.getChooserRequest().getFilteredComponentNames().contains(name);
         }
 
         @Override
@@ -1955,7 +1955,7 @@
             List<ResolveInfo> rList,
             boolean filterLastUsed,
             UserHandle userHandle) {
-        ChooserRequestParameters parameters = requireChooserRequest();
+        ChooserRequest parameters = mViewModel.getChooserRequest();
         ChooserListAdapter chooserListAdapter = createChooserListAdapter(
                 context,
                 payloadIntents,
@@ -2104,11 +2104,11 @@
     }
 
     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 @@
      * @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 a8150f5..f605488 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 @@
  * 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 @@
         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<Intent>? = chooserRequestParameters?.initialIntents?.toList()
+    override val initialIntents: List<Intent>? = chooserRequest?.initialIntents?.toList()
 
     override val payloadIntents: List<Intent> = 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 9672e9d..0e526b4 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.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 @@
 
     protected ActivityLogic mLogic;
     protected TargetDataLoader mTargetDataLoader;
+    private boolean mResolvingHome;
 
     private Button mAlwaysButton;
     private Button mOnceButton;
@@ -223,7 +225,7 @@
     }
 
     @VisibleForTesting
-    protected ActivityLogic createActivityLogic() {
+    protected ResolverActivityLogic createActivityLogic() {
         return  new ResolverActivityLogic(
                 TAG,
                 /* activity = */ this,
@@ -235,6 +237,7 @@
         super.onCreate(savedInstanceState);
         setTheme(R.style.Theme_DeviceDefault_Resolver);
         mLogic = createActivityLogic();
+        mResolvingHome = IntentExtKt.isHomeIntent(getIntent());
         mTargetDataLoader = new DefaultTargetDataLoader(
                 this,
                 getLifecycle(),
@@ -242,11 +245,6 @@
                         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 @@
         }
         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 @@
         }
         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 @@
     }
 
     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 @@
     @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 cf84304..1335304 100644
--- a/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt
+++ b/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt
@@ -36,10 +36,6 @@
         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 0000000..7aa8e03
--- /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<Intent>,
+    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 0000000..9addeef
--- /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<CallerInfo> {
+                override fun newArray(size: Int) = arrayOfNulls<CallerInfo>(size)
+                override fun createFromParcel(source: Parcel) = CallerInfo(source)
+            }
+    }
+}
+
+inline fun <reified T> 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 0000000..2fbf94a
--- /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<ComponentName> = 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<ChooserTarget> = 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<ChooserAction> = 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<Intent> = 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<String, Bundle>
+     */
+    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<Intent> = 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 0000000..6878be5
--- /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<Intent>(EXTRA_ALTERNATE_INTENTS))?.map { it.maybeAddSendActionFlags() }
+                ?: emptyList()
+
+        val replacementExtras = optional(value<Bundle>(EXTRA_REPLACEMENT_EXTRAS))
+
+        val (customTitle, defaultTitleResource) =
+            if (isSendAction) {
+                ignored(
+                    value<CharSequence>(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<CharSequence>(EXTRA_TITLE))
+                custom to (custom?.let { 0 } ?: R.string.chooseActivity)
+            }
+
+        val initialIntents =
+            optional(array<Intent>(EXTRA_INITIAL_INTENTS))?.take(MAX_INITIAL_INTENTS)?.map {
+                it.maybeAddSendActionFlags()
+            }
+                ?: emptyList()
+
+        val chosenComponentSender =
+            optional(value<IntentSender>(EXTRA_CHOSEN_COMPONENT_INTENT_SENDER))
+
+        val refinementIntentSender =
+            optional(value<IntentSender>(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER))
+
+        val filteredComponents =
+            optional(array<ComponentName>(EXTRA_EXCLUDE_COMPONENTS)) ?: emptyList()
+
+        @Suppress("DEPRECATION")
+        val callerChooserTargets =
+            optional(array<ChooserTarget>(EXTRA_CHOOSER_TARGETS)) ?: emptyList()
+
+        val retainInOnStop =
+            optional(value<Boolean>(ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP)) ?: false
+
+        val sharedText = optional(value<CharSequence>(EXTRA_TEXT))
+
+        val chooserActions =
+            optional(array<ChooserAction>(EXTRA_CHOOSER_CUSTOM_ACTIONS))
+                ?.filter { hasValidIcon(it) }
+                ?.take(MAX_CHOOSER_ACTIONS)
+                ?: emptyList()
+
+        val modifyShareAction = optional(value<ChooserAction>(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 0000000..663235c
--- /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<ChooserRequest> = 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 092cabe..856a521 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 @@
     fun getOrThrow(): T =
         checkNotNull(value) { "The result was invalid: " + findings.joinToString(separator = "\n") }
 
-    fun <T> 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 0b26890..e7c8cce 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.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 @@
     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 d06b792..9eaf926 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 @@
             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 0849e51..3c22254 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 @@
 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 0000000..6a16168
--- /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<Intent> {
+            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 0000000..bcc1054
--- /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()
+    }
+}