Merge tag 'android-13.0.0_r52' into int/13/fp3

Android 13.0.0 Release 52 (TQ3A.230605.012)

* tag 'android-13.0.0_r52': (52 commits)
  Split ChooserContentPreviewUi into multiple components
  Add image caching to ImagePreviewImageLoader
  Add animation to custom actions
  Allow refinement of any matching source intent.
  Check for nearby component in config
  Fix alternate intents in the intent refinement call.
  Extract ResolvedComponentInfo to top-level.
  Log number of custom actions and presence of modify share action
  Remove UserHandle ivar from ResolverListController.
  Extract component for work-profile availability.
  Add logging for modify share and custom action clicks
  Prevent sharesheet from previewing unowned URIs
  Update variable naming for updated API
  Run Chooser integration tests for the new features
  Handle refinement responses on the main thread.
  Extract a component to handle refinement.
  Extract ChooserActionFactory.
  [intentresolver] Reselection -> modify share
  Clarify ResolverActivity inheritance contract.
  Addjust text colors
  ...

Change-Id: I9de257308d4cd6c0e1e2473a5eed3b754a216644
diff --git a/Android.bp b/Android.bp
index 31d7d6d..19d2a82 100644
--- a/Android.bp
+++ b/Android.bp
@@ -31,13 +31,34 @@
     ],
 }
 
+filegroup {
+    name: "ReleaseSources",
+    srcs: [
+        "java/src-release/**/*.kt",
+    ],
+}
+
+filegroup {
+    name: "DebugSources",
+    srcs: [
+        "java/src-debug/**/*.kt",
+    ],
+}
+
 android_library {
     name: "IntentResolver-core",
     min_sdk_version: "current",
     srcs: [
         "java/src/**/*.java",
         "java/src/**/*.kt",
+        ":ReleaseSources",
     ],
+    product_variables: {
+        debuggable: {
+            srcs: [":DebugSources"],
+            exclude_srcs: [":ReleaseSources"],
+        }
+    },
     resource_dirs: [
         "java/res",
     ],
@@ -58,11 +79,12 @@
         "kotlinx-coroutines-android",
         "//external/kotlinc:kotlin-annotations",
         "guava",
+        "SystemUIFlagsLib",
     ],
 
     plugins: ["java_api_finder"],
     lint: {
-        strict_updatability_linting: true,
+        strict_updatability_linting: false,
     },
 }
 
diff --git a/java/res/anim/slide_in_right.xml b/java/res/anim/slide_in_right.xml
new file mode 100644
index 0000000..3d3cd91
--- /dev/null
+++ b/java/res/anim/slide_in_right.xml
@@ -0,0 +1,22 @@
+<!--
+  ~ 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.
+  -->
+
+<set xmlns:android="http://schemas.android.com/apk/res/android">
+    <translate android:fromXDelta="100%p" android:toXDelta="0"
+        android:duration="@android:integer/config_mediumAnimTime"/>
+    <alpha android:fromAlpha="0.0" android:toAlpha="1.0"
+        android:duration="@android:integer/config_mediumAnimTime" />
+</set>
\ No newline at end of file
diff --git a/java/res/anim/slide_out_left.xml b/java/res/anim/slide_out_left.xml
new file mode 100644
index 0000000..b347151
--- /dev/null
+++ b/java/res/anim/slide_out_left.xml
@@ -0,0 +1,20 @@
+<!--
+  ~ 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.
+  -->
+
+<set xmlns:android="http://schemas.android.com/apk/res/android">
+    <translate android:fromXDelta="0" android:toXDelta="-100%p"
+        android:duration="@android:integer/config_mediumAnimTime"/>
+</set>
\ No newline at end of file
diff --git a/java/res/layout/chooser_dialog.xml b/java/res/layout/chooser_dialog.xml
index e31712c..19ead35 100644
--- a/java/res/layout/chooser_dialog.xml
+++ b/java/res/layout/chooser_dialog.xml
@@ -18,6 +18,7 @@
 <LinearLayout
     xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
+    android:id="@+id/chooser_dialog_content"
     android:background="@drawable/chooser_dialog_background"
     android:orientation="vertical"
     android:paddingBottom="8dp"
diff --git a/java/res/layout/chooser_grid_preview_file.xml b/java/res/layout/chooser_grid_preview_file.xml
index e98c327..095e5d6 100644
--- a/java/res/layout/chooser_grid_preview_file.xml
+++ b/java/res/layout/chooser_grid_preview_file.xml
@@ -65,9 +65,19 @@
         android:ellipsize="middle"
         android:gravity="start|top"
         android:paddingRight="24dp"
-        android:singleLine="true"/>
+        android:singleLine="true"
+        android:textAppearance="@style/TextAppearance.ChooserDefault" />
   </LinearLayout>
 
+  <TextView
+      android:id="@+id/reselection_action"
+      android:layout_width="match_parent"
+      android:layout_height="wrap_content"
+      android:visibility="gone"
+      android:text="@string/select_files"
+      android:gravity="center"
+      style="@style/ReselectionAction" />
+
   <ViewStub
       android:id="@+id/action_row_stub"
       android:layout_width="match_parent"
diff --git a/java/res/layout/chooser_grid_preview_image.xml b/java/res/layout/chooser_grid_preview_image.xml
index 5c32414..792b7d4 100644
--- a/java/res/layout/chooser_grid_preview_image.xml
+++ b/java/res/layout/chooser_grid_preview_image.xml
@@ -16,7 +16,6 @@
 * limitations under the License.
 */
 -->
-<!-- Layout Option: Supporting up to 3 images for preview -->
 <LinearLayout
     xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
@@ -25,13 +24,49 @@
     android:orientation="vertical"
     android:background="?android:attr/colorBackground">
 
-  <com.android.intentresolver.widget.ImagePreviewView
-      android:id="@androidprv:id/content_preview_image_area"
+  <CheckBox
+      android:id="@+id/include_text_action"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
-      android:layout_gravity="center_horizontal"
-      android:paddingBottom="@dimen/chooser_view_spacing"
-      android:background="?android:attr/colorBackground" />
+      android:layout_gravity="end"
+      android:layout_marginEnd="@dimen/chooser_edge_margin_normal"
+      android:visibility="gone" />
+
+  <LinearLayout
+      android:layout_width="match_parent"
+      android:layout_height="wrap_content"
+      android:orientation="horizontal"
+      android:gravity="center_horizontal"
+      android:layout_marginBottom="@dimen/chooser_view_spacing">
+
+      <ViewStub
+          android:id="@+id/image_preview_stub"
+          android:inflatedId="@androidprv:id/content_preview_image_area"
+          android:layout_width="wrap_content"
+          android:layout_height="wrap_content" />
+
+      <TextView
+          android:id="@androidprv:id/content_preview_text"
+          android:layout_width="0dp"
+          android:layout_height="wrap_content"
+          android:layout_weight="1"
+          android:layout_gravity="center_vertical"
+          android:paddingEnd="@dimen/chooser_edge_margin_normal"
+          android:maxLines="6"
+          android:ellipsize="end"
+          android:linksClickable="false"
+          android:visibility="gone"
+          android:textAppearance="@style/TextAppearance.ChooserDefault" />
+  </LinearLayout>
+
+  <TextView
+      android:id="@+id/reselection_action"
+      android:layout_width="match_parent"
+      android:layout_height="wrap_content"
+      android:visibility="gone"
+      android:text="@string/select_images"
+      android:gravity="center"
+      style="@style/ReselectionAction" />
 
   <ViewStub
       android:id="@+id/action_row_stub"
@@ -39,4 +74,3 @@
       android:layout_height="wrap_content" />
 
 </LinearLayout>
-
diff --git a/java/res/layout/chooser_grid_preview_text.xml b/java/res/layout/chooser_grid_preview_text.xml
index db7282e..49a2edf 100644
--- a/java/res/layout/chooser_grid_preview_text.xml
+++ b/java/res/layout/chooser_grid_preview_text.xml
@@ -52,6 +52,15 @@
 
   </RelativeLayout>
 
+  <TextView
+      android:id="@+id/reselection_action"
+      android:layout_width="match_parent"
+      android:layout_height="wrap_content"
+      android:visibility="gone"
+      android:text="@string/select_text"
+      android:gravity="center"
+      style="@style/ReselectionAction" />
+
   <ViewStub
       android:id="@+id/action_row_stub"
       android:layout_width="match_parent"
diff --git a/java/res/layout/chooser_image_preview_view.xml b/java/res/layout/chooser_image_preview_view.xml
new file mode 100644
index 0000000..e81349c
--- /dev/null
+++ b/java/res/layout/chooser_image_preview_view.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<!--
+  ~ 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.
+  -->
+
+<com.android.intentresolver.widget.ChooserImagePreviewView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:layout_gravity="center_horizontal"
+    android:paddingStart="@dimen/chooser_edge_margin_normal"
+    android:paddingEnd="@dimen/chooser_edge_margin_normal"
+    android:paddingBottom="@dimen/chooser_view_spacing"
+    android:background="?android:attr/colorBackground" />
diff --git a/java/res/layout/image_preview_view.xml b/java/res/layout/chooser_image_preview_view_internals.xml
similarity index 85%
rename from java/res/layout/image_preview_view.xml
rename to java/res/layout/chooser_image_preview_view_internals.xml
index d2f9469..2b93edf 100644
--- a/java/res/layout/image_preview_view.xml
+++ b/java/res/layout/chooser_image_preview_view_internals.xml
@@ -25,8 +25,9 @@
 
     <com.android.intentresolver.widget.RoundedRectImageView
         android:id="@androidprv:id/content_preview_image_1_large"
-        android:layout_width="120dp"
-        android:layout_height="104dp"
+        android:transitionName="screenshot_preview_image"
+        android:layout_width="@dimen/chooser_preview_image_width"
+        android:layout_height="@dimen/chooser_preview_image_height"
         android:layout_alignParentTop="true"
         android:adjustViewBounds="true"
         android:gravity="center"
@@ -35,8 +36,8 @@
     <com.android.intentresolver.widget.RoundedRectImageView
         android:id="@androidprv:id/content_preview_image_2_large"
         android:visibility="gone"
-        android:layout_width="120dp"
-        android:layout_height="104dp"
+        android:layout_width="@dimen/chooser_preview_image_width"
+        android:layout_height="@dimen/chooser_preview_image_height"
         android:layout_alignParentTop="true"
         android:layout_toRightOf="@androidprv:id/content_preview_image_1_large"
         android:layout_marginLeft="10dp"
@@ -47,7 +48,7 @@
     <com.android.intentresolver.widget.RoundedRectImageView
         android:id="@androidprv:id/content_preview_image_2_small"
         android:visibility="gone"
-        android:layout_width="120dp"
+        android:layout_width="@dimen/chooser_preview_image_width"
         android:layout_height="65dp"
         android:layout_alignParentTop="true"
         android:layout_toRightOf="@androidprv:id/content_preview_image_1_large"
@@ -59,7 +60,7 @@
     <com.android.intentresolver.widget.RoundedRectImageView
         android:id="@androidprv:id/content_preview_image_3_small"
         android:visibility="gone"
-        android:layout_width="120dp"
+        android:layout_width="@dimen/chooser_preview_image_width"
         android:layout_height="65dp"
         android:layout_below="@androidprv:id/content_preview_image_2_small"
         android:layout_toRightOf="@androidprv:id/content_preview_image_1_large"
diff --git a/java/res/layout/image_preview_image_item.xml b/java/res/layout/image_preview_image_item.xml
new file mode 100644
index 0000000..c18cc27
--- /dev/null
+++ b/java/res/layout/image_preview_image_item.xml
@@ -0,0 +1,24 @@
+<!--
+  ~ 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.
+  -->
+
+<com.android.intentresolver.widget.RoundedRectImageView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/image"
+    android:layout_width="@dimen/chooser_preview_image_width"
+    android:layout_height="@dimen/chooser_preview_image_height"
+    android:layout_alignParentTop="true"
+    android:adjustViewBounds="false"
+    android:scaleType="centerCrop"/>
diff --git a/java/res/layout/resolve_grid_item.xml b/java/res/layout/resolve_grid_item.xml
index db6c7dd..00ca994 100644
--- a/java/res/layout/resolve_grid_item.xml
+++ b/java/res/layout/resolve_grid_item.xml
@@ -18,6 +18,7 @@
 -->
 <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
               xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
+              android:id="@androidprv:id/item"
               android:orientation="vertical"
               android:layout_width="match_parent"
               android:layout_height="wrap_content"
diff --git a/java/res/layout/scrollable_image_preview_view.xml b/java/res/layout/scrollable_image_preview_view.xml
new file mode 100644
index 0000000..c6c310e
--- /dev/null
+++ b/java/res/layout/scrollable_image_preview_view.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<!--
+  ~ 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.
+  -->
+
+<com.android.intentresolver.widget.ScrollableImagePreviewView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:layout_gravity="center_horizontal"
+    android:paddingStart="@dimen/chooser_edge_margin_normal"
+    android:paddingEnd="@dimen/chooser_edge_margin_normal"
+    android:paddingBottom="@dimen/chooser_view_spacing"
+    android:background="?android:attr/colorBackground" />
diff --git a/java/res/values/dimens.xml b/java/res/values/dimens.xml
index 93cb463..87eec7f 100644
--- a/java/res/values/dimens.xml
+++ b/java/res/values/dimens.xml
@@ -25,6 +25,8 @@
     <dimen name="chooser_edge_margin_normal">24dp</dimen>
     <dimen name="chooser_preview_image_font_size">20sp</dimen>
     <dimen name="chooser_preview_image_border">1dp</dimen>
+    <dimen name="chooser_preview_image_width">120dp</dimen>
+    <dimen name="chooser_preview_image_height">104dp</dimen>
     <dimen name="chooser_preview_image_max_dimen">200dp</dimen>
     <dimen name="chooser_preview_width">-1px</dimen>
     <dimen name="chooser_header_scroll_elevation">4dp</dimen>
diff --git a/java/res/values/strings.xml b/java/res/values/strings.xml
index a536d3b..24604ed 100644
--- a/java/res/values/strings.xml
+++ b/java/res/values/strings.xml
@@ -101,4 +101,20 @@
     <string name="miniresolver_use_personal_browser">Use personal browser</string>
     <!-- Button option. Open the link in the work browser. [CHAR LIMIT=NONE] -->
     <string name="miniresolver_use_work_browser">Use work browser</string>
+
+    <!-- Tittle for a button. Launches client-provided content reselection action. -->
+    <string name="select_files">Select Files</string>
+    <!-- Tittle for a button. Launches client-provided content reselection action. -->
+    <string name="select_images">Select Images</string>
+    <!-- Tittle for a button. Launches client-provided content reselection action. -->
+    <string name="select_text">Select Text</string>
+
+    <!-- Title for a button. Excludes a text from the shared content (a media and a text). -->
+    <string name="exclude_text">Exclude text</string>
+    <!-- Title for a button. Adds back a (previously excluded) text into the shared content. -->
+    <string name="include_text">Include text</string>
+    <!-- Title for a button. Excludes a web link from the shared content (a media and a text). -->
+    <string name="exclude_link">Exclude link</string>
+    <!-- Title for a button. Adds back a (previously excluded) web link into the shared content. -->
+    <string name="include_link">Include link</string>
 </resources>
diff --git a/java/res/values/styles.xml b/java/res/values/styles.xml
index cbbf406..ba6418a 100644
--- a/java/res/values/styles.xml
+++ b/java/res/values/styles.xml
@@ -46,4 +46,12 @@
         <item name="*android:iconfactoryIconSize">@dimen/chooser_icon_size</item>
         <item name="*android:iconfactoryBadgeSize">@dimen/chooser_badge_size</item>
     </style>
+
+    <style name="TextAppearance.ChooserDefault"
+            parent="@android:style/TextAppearance.DeviceDefault" />
+
+    <style name="ReselectionAction" parent="TextAppearance.ChooserDefault">
+        <item name="android:paddingTop">5dp</item>
+        <item name="android:paddingBottom">5dp</item>
+    </style>
 </resources>
diff --git a/java/src-debug/com/android/intentresolver/flags/DebugFeatureFlagRepository.kt b/java/src-debug/com/android/intentresolver/flags/DebugFeatureFlagRepository.kt
new file mode 100644
index 0000000..5067c0e
--- /dev/null
+++ b/java/src-debug/com/android/intentresolver/flags/DebugFeatureFlagRepository.kt
@@ -0,0 +1,81 @@
+/*
+ * 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.flags
+
+import android.util.SparseBooleanArray
+import androidx.annotation.GuardedBy
+import com.android.systemui.flags.BooleanFlag
+import com.android.systemui.flags.FlagManager
+import com.android.systemui.flags.ReleasedFlag
+import com.android.systemui.flags.UnreleasedFlag
+import javax.annotation.concurrent.ThreadSafe
+
+@ThreadSafe
+internal class DebugFeatureFlagRepository(
+    private val flagManager: FlagManager,
+    private val deviceConfig: DeviceConfigProxy,
+) : FeatureFlagRepository {
+    @GuardedBy("self")
+    private val cache = hashMapOf<String, Boolean>()
+
+    override fun isEnabled(flag: UnreleasedFlag): Boolean = isFlagEnabled(flag)
+
+    override fun isEnabled(flag: ReleasedFlag): Boolean = isFlagEnabled(flag)
+
+    private fun isFlagEnabled(flag: BooleanFlag): Boolean {
+        synchronized(cache) {
+            cache[flag.name]?.let { return it }
+        }
+        val flagValue = readFlagValue(flag)
+        return synchronized(cache) {
+            // the first read saved in the cache wins
+            cache.getOrPut(flag.name) { flagValue }
+        }
+    }
+
+    private fun readFlagValue(flag: BooleanFlag): Boolean {
+        val localOverride = runCatching {
+            flagManager.isEnabled(flag.name)
+        }.getOrDefault(null)
+        val remoteOverride = deviceConfig.isEnabled(flag)
+
+        // Only check for teamfood if the default is false
+        // and there is no server override.
+        if (remoteOverride == null
+            && !flag.default
+            && localOverride == null
+            && !flag.isTeamfoodFlag
+            && flag.teamfood
+        ) {
+            return flagManager.isTeamfoodEnabled
+        }
+        return localOverride ?: remoteOverride ?: flag.default
+    }
+
+    companion object {
+        /** keep in sync with [com.android.systemui.flags.Flags] */
+        private const val TEAMFOOD_FLAG_NAME = "teamfood"
+
+        private val BooleanFlag.isTeamfoodFlag: Boolean
+            get() = name == TEAMFOOD_FLAG_NAME
+
+        private val FlagManager.isTeamfoodEnabled: Boolean
+            get() = runCatching {
+                isEnabled(TEAMFOOD_FLAG_NAME) ?: false
+            }.getOrDefault(false)
+    }
+}
diff --git a/java/src-debug/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt b/java/src-debug/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt
new file mode 100644
index 0000000..4ddb044
--- /dev/null
+++ b/java/src-debug/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt
@@ -0,0 +1,30 @@
+/*
+ * 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.flags
+
+import android.content.Context
+import android.os.Handler
+import android.os.Looper
+import com.android.systemui.flags.FlagManager
+
+class FeatureFlagRepositoryFactory {
+    fun create(context: Context): FeatureFlagRepository =
+        DebugFeatureFlagRepository(
+            FlagManager(context, Handler(Looper.getMainLooper())),
+            DeviceConfigProxy(),
+        )
+}
diff --git a/java/src-release/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt b/java/src-release/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt
new file mode 100644
index 0000000..6bf7579
--- /dev/null
+++ b/java/src-release/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt
@@ -0,0 +1,24 @@
+/*
+ * 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.flags
+
+import android.content.Context
+
+class FeatureFlagRepositoryFactory {
+    fun create(context: Context): FeatureFlagRepository =
+        ReleaseFeatureFlagRepository(DeviceConfigProxy())
+}
diff --git a/java/src-release/com/android/intentresolver/flags/ReleaseFeatureFlagRepository.kt b/java/src-release/com/android/intentresolver/flags/ReleaseFeatureFlagRepository.kt
new file mode 100644
index 0000000..f9fa2c6
--- /dev/null
+++ b/java/src-release/com/android/intentresolver/flags/ReleaseFeatureFlagRepository.kt
@@ -0,0 +1,31 @@
+/*
+ * 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.flags
+
+import com.android.systemui.flags.ReleasedFlag
+import com.android.systemui.flags.UnreleasedFlag
+import javax.annotation.concurrent.ThreadSafe
+
+@ThreadSafe
+internal class ReleaseFeatureFlagRepository(
+    private val deviceConfig: DeviceConfigProxy,
+) : FeatureFlagRepository {
+    override fun isEnabled(flag: UnreleasedFlag): Boolean = flag.default
+
+    override fun isEnabled(flag: ReleasedFlag): Boolean =
+        deviceConfig.isEnabled(flag) ?: flag.default
+}
diff --git a/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java
index 17dbb8f..e3f1b23 100644
--- a/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java
+++ b/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java
@@ -40,6 +40,7 @@
 import java.util.List;
 import java.util.Objects;
 import java.util.Set;
+import java.util.function.Supplier;
 
 /**
  * Skeletal {@link PagerAdapter} implementation of a work or personal profile page for
@@ -61,22 +62,20 @@
     private Set<Integer> mLoadedPages;
     private final EmptyStateProvider mEmptyStateProvider;
     private final UserHandle mWorkProfileUserHandle;
-    private final QuietModeManager mQuietModeManager;
+    private final Supplier<Boolean> mWorkProfileQuietModeChecker;  // True when work is quiet.
 
-    AbstractMultiProfilePagerAdapter(Context context, int currentPage,
+    AbstractMultiProfilePagerAdapter(
+            Context context,
+            int currentPage,
             EmptyStateProvider emptyStateProvider,
-            QuietModeManager quietModeManager,
+            Supplier<Boolean> workProfileQuietModeChecker,
             UserHandle workProfileUserHandle) {
         mContext = Objects.requireNonNull(context);
         mCurrentPage = currentPage;
         mLoadedPages = new HashSet<>();
         mWorkProfileUserHandle = workProfileUserHandle;
         mEmptyStateProvider = emptyStateProvider;
-        mQuietModeManager = quietModeManager;
-    }
-
-    private boolean isQuietModeEnabled(UserHandle workProfileUserHandle) {
-        return mQuietModeManager.isQuietModeEnabled(workProfileUserHandle);
+        mWorkProfileQuietModeChecker = workProfileQuietModeChecker;
     }
 
     void setOnProfileSelectedListener(OnProfileSelectedListener listener) {
@@ -433,7 +432,7 @@
         int count = listAdapter.getUnfilteredCount();
         return (count == 0 && listAdapter.getPlaceholderCount() == 0)
                 || (listAdapter.getUserHandle().equals(mWorkProfileUserHandle)
-                    && isQuietModeEnabled(mWorkProfileUserHandle));
+                    && mWorkProfileQuietModeChecker.get());
     }
 
     protected static class ProfileDescriptor {
@@ -573,29 +572,4 @@
          */
         void onSwitchOnWorkSelected();
     }
-
-    /**
-     * Describes an injector to be used for cross profile functionality. Overridable for testing.
-     */
-    public interface QuietModeManager {
-        /**
-         * Returns whether the given profile is in quiet mode or not.
-         */
-        boolean isQuietModeEnabled(UserHandle workProfileUserHandle);
-
-        /**
-         * Enables or disables quiet mode for a managed profile.
-         */
-        void requestQuietModeEnabled(boolean enabled, UserHandle workProfileUserHandle);
-
-        /**
-         * Should be called when the work profile enabled broadcast received
-         */
-        void markWorkProfileEnabledBroadcastReceived();
-
-        /**
-         * Returns true if enabling of work profile is in progress
-         */
-        boolean isWaitingToEnableWorkProfile();
-    }
 }
diff --git a/java/src/com/android/intentresolver/AnnotatedUserHandles.java b/java/src/com/android/intentresolver/AnnotatedUserHandles.java
new file mode 100644
index 0000000..b4365b8
--- /dev/null
+++ b/java/src/com/android/intentresolver/AnnotatedUserHandles.java
@@ -0,0 +1,113 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver;
+
+import android.annotation.Nullable;
+import android.app.Activity;
+import android.app.ActivityManager;
+import android.os.UserHandle;
+import android.os.UserManager;
+
+/**
+ * Helper class to precompute the (immutable) designations of various user handles in the system
+ * that may contribute to the current Sharesheet session.
+ */
+public final class AnnotatedUserHandles {
+    /** The user id of the app that started the share activity. */
+    public final int userIdOfCallingApp;
+
+    /**
+     * The {@link UserHandle} that launched Sharesheet.
+     * TODO: I believe this would always be the handle corresponding to {@code userIdOfCallingApp}
+     * except possibly if the caller used {@link Activity#startActivityAsUser()} to launch
+     * Sharesheet as a different user than they themselves were running as. Verify and document.
+     */
+    public final UserHandle userHandleSharesheetLaunchedAs;
+
+    /**
+     * The {@link UserHandle} that owns the "personal tab" in a tabbed share UI (or the *only* 'tab'
+     * in a non-tabbed UI).
+     *
+     * This is never a work or clone user, but may either be the root user (0) or a "secondary"
+     * multi-user profile (i.e., one that's not root, work, nor clone). This is a "secondary"
+     * profile only when that user is the active "foreground" user.
+     *
+     * In the current implementation, we can assert that this is the root user (0) any time we
+     * display a tabbed UI (i.e., any time `workProfileUserHandle` is non-null), or any time that we
+     * have a clone profile. This note is only provided for informational purposes; clients should
+     * avoid making any reliances on that assumption.
+     */
+    public final UserHandle personalProfileUserHandle;
+
+    /**
+     * The {@link UserHandle} that owns the "work tab" in a tabbed share UI. This is (an arbitrary)
+     * one of the "managed" profiles associated with {@link personalProfileUserHandle}.
+     */
+    @Nullable
+    public final UserHandle workProfileUserHandle;
+
+    /**
+     * The {@link UserHandle} of the clone profile belonging to {@link personalProfileUserHandle}.
+     */
+    @Nullable
+    public final UserHandle cloneProfileUserHandle;
+
+    /**
+     * The "tab owner" user handle (i.e., either {@link personalProfileUserHandle} or
+     * {@link workProfileUserHandle}) that either matches or owns the profile of the
+     * {@link userHandleSharesheetLaunchedAs}.
+     *
+     * In the current implementation, we can assert that this is the same as
+     * `userHandleSharesheetLaunchedAs` except when the latter is the clone profile; then this is
+     * the "personal" profile owning that clone profile (which we currently know must belong to
+     * user 0, but clients should avoid making any reliances on that assumption).
+     */
+    public final UserHandle tabOwnerUserHandleForLaunch;
+
+    public AnnotatedUserHandles(Activity forShareActivity) {
+        userIdOfCallingApp = forShareActivity.getLaunchedFromUid();
+        if ((userIdOfCallingApp < 0) || UserHandle.isIsolated(userIdOfCallingApp)) {
+            throw new SecurityException("Can't start a resolver from uid " + userIdOfCallingApp);
+        }
+
+        // TODO: integrate logic for `ResolverActivity.EXTRA_CALLING_USER`.
+        userHandleSharesheetLaunchedAs = UserHandle.of(UserHandle.myUserId());
+
+        personalProfileUserHandle = UserHandle.of(ActivityManager.getCurrentUser());
+
+        UserManager userManager = forShareActivity.getSystemService(UserManager.class);
+        workProfileUserHandle = getWorkProfileForUser(userManager, personalProfileUserHandle);
+        cloneProfileUserHandle = getCloneProfileForUser(userManager, personalProfileUserHandle);
+
+        tabOwnerUserHandleForLaunch = (userHandleSharesheetLaunchedAs == workProfileUserHandle)
+                ? workProfileUserHandle : personalProfileUserHandle;
+    }
+
+    @Nullable
+    private static UserHandle getWorkProfileForUser(
+            UserManager userManager, UserHandle profileOwnerUserHandle) {
+        return userManager.getProfiles(profileOwnerUserHandle.getIdentifier()).stream()
+                .filter(info -> info.isManagedProfile()).findFirst()
+                .map(info -> info.getUserHandle()).orElse(null);
+    }
+
+    @Nullable
+    private static UserHandle getCloneProfileForUser(
+            UserManager userManager, UserHandle profileOwnerUserHandle) {
+        return null;  // Not yet supported in framework.
+    }
+}
diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java
new file mode 100644
index 0000000..947155f
--- /dev/null
+++ b/java/src/com/android/intentresolver/ChooserActionFactory.java
@@ -0,0 +1,515 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver;
+
+import android.annotation.Nullable;
+import android.app.Activity;
+import android.app.ActivityOptions;
+import android.app.PendingIntent;
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.content.pm.ResolveInfo;
+import android.content.res.Resources;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.os.Bundle;
+import android.service.chooser.ChooserAction;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.View;
+
+import com.android.intentresolver.chooser.DisplayResolveInfo;
+import com.android.intentresolver.chooser.TargetInfo;
+import com.android.intentresolver.contentpreview.ChooserContentPreviewUi;
+import com.android.intentresolver.flags.FeatureFlagRepository;
+import com.android.intentresolver.flags.Flags;
+import com.android.intentresolver.widget.ActionRow;
+import com.android.internal.annotations.VisibleForTesting;
+
+import com.google.common.collect.ImmutableList;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.function.Consumer;
+
+/**
+ * Implementation of {@link ChooserContentPreviewUi.ActionFactory} specialized to the application
+ * requirements of Sharesheet / {@link ChooserActivity}.
+ */
+public final class ChooserActionFactory implements ChooserContentPreviewUi.ActionFactory {
+    /** Delegate interface to launch activities when the actions are selected. */
+    public interface ActionActivityStarter {
+        /**
+         * Request an activity launch for the provided target. Implementations may choose to exit
+         * the current activity when the target is launched.
+         */
+        void safelyStartActivityAsPersonalProfileUser(TargetInfo info);
+
+        /**
+         * Request an activity launch for the provided target, optionally employing the specified
+         * shared element transition. Implementations may choose to exit the current activity when
+         * the target is launched.
+         */
+        default void safelyStartActivityAsPersonalProfileUserWithSharedElementTransition(
+                TargetInfo info, View sharedElement, String sharedElementName) {
+            safelyStartActivityAsPersonalProfileUser(info);
+        }
+    }
+
+    private static final String TAG = "ChooserActions";
+
+    private static final int URI_PERMISSION_INTENT_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION
+            | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+            | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
+            | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION;
+
+    private static final String CHIP_LABEL_METADATA_KEY = "android.service.chooser.chip_label";
+    private static final String CHIP_ICON_METADATA_KEY = "android.service.chooser.chip_icon";
+
+    private static final String IMAGE_EDITOR_SHARED_ELEMENT = "screenshot_preview_image";
+
+    private final Context mContext;
+    private final String mCopyButtonLabel;
+    private final Drawable mCopyButtonDrawable;
+    private final Runnable mOnCopyButtonClicked;
+    private final TargetInfo mEditSharingTarget;
+    private final Runnable mOnEditButtonClicked;
+    private final TargetInfo mNearbySharingTarget;
+    private final Runnable mOnNearbyButtonClicked;
+    private final ImmutableList<ChooserAction> mCustomActions;
+    private final Runnable mOnModifyShareClicked;
+    private final Consumer<Boolean> mExcludeSharedTextAction;
+    private final Consumer</* @Nullable */ Integer> mFinishCallback;
+    private final ChooserActivityLogger mLogger;
+
+    /**
+     * @param context
+     * @param chooserRequest data about the invocation of the current Sharesheet session.
+     * @param featureFlagRepository feature flags that may control the eligibility of some actions.
+     * @param integratedDeviceComponents info about other components that are available on this
+     * device to implement the supported action types.
+     * @param onUpdateSharedTextIsExcluded a delegate to be invoked when the "exclude shared text"
+     * setting is updated. The argument is whether the shared text is to be excluded.
+     * @param firstVisibleImageQuery a delegate that provides a reference to the first visible image
+     * View in the Sharesheet UI, if any, or null.
+     * @param activityStarter a delegate to launch activities when actions are selected.
+     * @param finishCallback a delegate to close the Sharesheet UI (e.g. because some action was
+     * completed).
+     */
+    public ChooserActionFactory(
+            Context context,
+            ChooserRequestParameters chooserRequest,
+            FeatureFlagRepository featureFlagRepository,
+            ChooserIntegratedDeviceComponents integratedDeviceComponents,
+            ChooserActivityLogger logger,
+            Consumer<Boolean> onUpdateSharedTextIsExcluded,
+            Callable</* @Nullable */ View> firstVisibleImageQuery,
+            ActionActivityStarter activityStarter,
+            Consumer</* @Nullable */ Integer> finishCallback) {
+        this(
+                context,
+                context.getString(com.android.internal.R.string.copy),
+                context.getDrawable(com.android.internal.R.drawable.ic_menu_copy_material),
+                makeOnCopyRunnable(
+                        context,
+                        chooserRequest.getTargetIntent(),
+                        chooserRequest.getReferrerPackageName(),
+                        finishCallback,
+                        logger),
+                getEditSharingTarget(
+                        context,
+                        chooserRequest.getTargetIntent(),
+                        integratedDeviceComponents),
+                makeOnEditRunnable(
+                        getEditSharingTarget(
+                                context,
+                                chooserRequest.getTargetIntent(),
+                                integratedDeviceComponents),
+                        firstVisibleImageQuery,
+                        activityStarter,
+                        logger),
+                getNearbySharingTarget(
+                        context,
+                        chooserRequest.getTargetIntent(),
+                        integratedDeviceComponents),
+                makeOnNearbyShareRunnable(
+                        getNearbySharingTarget(
+                                context,
+                                chooserRequest.getTargetIntent(),
+                                integratedDeviceComponents),
+                        activityStarter,
+                        finishCallback,
+                        logger),
+                chooserRequest.getChooserActions(),
+                (featureFlagRepository.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION)
+                        ? createModifyShareRunnable(
+                                chooserRequest.getModifyShareAction(),
+                                finishCallback,
+                                logger)
+                        : null),
+                onUpdateSharedTextIsExcluded,
+                logger,
+                finishCallback);
+    }
+
+    @VisibleForTesting
+    ChooserActionFactory(
+            Context context,
+            String copyButtonLabel,
+            Drawable copyButtonDrawable,
+            Runnable onCopyButtonClicked,
+            TargetInfo editSharingTarget,
+            Runnable onEditButtonClicked,
+            TargetInfo nearbySharingTarget,
+            Runnable onNearbyButtonClicked,
+            List<ChooserAction> customActions,
+            @Nullable Runnable onModifyShareClicked,
+            Consumer<Boolean> onUpdateSharedTextIsExcluded,
+            ChooserActivityLogger logger,
+            Consumer</* @Nullable */ Integer> finishCallback) {
+        mContext = context;
+        mCopyButtonLabel = copyButtonLabel;
+        mCopyButtonDrawable = copyButtonDrawable;
+        mOnCopyButtonClicked = onCopyButtonClicked;
+        mEditSharingTarget = editSharingTarget;
+        mOnEditButtonClicked = onEditButtonClicked;
+        mNearbySharingTarget = nearbySharingTarget;
+        mOnNearbyButtonClicked = onNearbyButtonClicked;
+        mCustomActions = ImmutableList.copyOf(customActions);
+        mOnModifyShareClicked = onModifyShareClicked;
+        mExcludeSharedTextAction = onUpdateSharedTextIsExcluded;
+        mLogger = logger;
+        mFinishCallback = finishCallback;
+    }
+
+    /** Create an action that copies the share content to the clipboard. */
+    @Override
+    public ActionRow.Action createCopyButton() {
+        return new ActionRow.Action(
+                com.android.internal.R.id.chooser_copy_button,
+                mCopyButtonLabel,
+                mCopyButtonDrawable,
+                mOnCopyButtonClicked);
+    }
+
+    /** Create an action that opens the share content in a system-default editor. */
+    @Override
+    @Nullable
+    public ActionRow.Action createEditButton() {
+        if (mEditSharingTarget == null) {
+            return null;
+        }
+
+        return new ActionRow.Action(
+                com.android.internal.R.id.chooser_edit_button,
+                mEditSharingTarget.getDisplayLabel(),
+                mEditSharingTarget.getDisplayIconHolder().getDisplayIcon(),
+                mOnEditButtonClicked);
+    }
+
+    /** Create a "Share to Nearby" action. */
+    @Override
+    @Nullable
+    public ActionRow.Action createNearbyButton() {
+        if (mNearbySharingTarget == null) {
+            return null;
+        }
+
+        return new ActionRow.Action(
+                com.android.internal.R.id.chooser_nearby_button,
+                mNearbySharingTarget.getDisplayLabel(),
+                mNearbySharingTarget.getDisplayIconHolder().getDisplayIcon(),
+                mOnNearbyButtonClicked);
+    }
+
+    /** Create custom actions */
+    @Override
+    public List<ActionRow.Action> createCustomActions() {
+        List<ActionRow.Action> actions = new ArrayList<>();
+        for (int i = 0; i < mCustomActions.size(); i++) {
+            ActionRow.Action actionRow = createCustomAction(
+                    mContext, mCustomActions.get(i), mFinishCallback, i, mLogger);
+            if (actionRow != null) {
+                actions.add(actionRow);
+            }
+        }
+        return actions;
+    }
+
+    /**
+     * Provides a share modification action, if any.
+     */
+    @Override
+    @Nullable
+    public Runnable getModifyShareAction() {
+        return mOnModifyShareClicked;
+    }
+
+    private static Runnable createModifyShareRunnable(
+            PendingIntent pendingIntent,
+            Consumer<Integer> finishCallback,
+            ChooserActivityLogger logger) {
+        if (pendingIntent == null) {
+            return null;
+        }
+
+        return () -> {
+            try {
+                pendingIntent.send();
+            } catch (PendingIntent.CanceledException e) {
+                Log.d(TAG, "Payload reselection action has been cancelled");
+            }
+            logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_MODIFY_SHARE);
+            finishCallback.accept(Activity.RESULT_OK);
+        };
+    }
+
+    /**
+     * <p>
+     * Creates an exclude-text action that can be called when the user changes shared text
+     * status in the Media + Text preview.
+     * </p>
+     * <p>
+     * <code>true</code> argument value indicates that the text should be excluded.
+     * </p>
+     */
+    @Override
+    public Consumer<Boolean> getExcludeSharedTextAction() {
+        return mExcludeSharedTextAction;
+    }
+
+    private static Runnable makeOnCopyRunnable(
+            Context context,
+            Intent targetIntent,
+            String referrerPackageName,
+            Consumer<Integer> finishCallback,
+            ChooserActivityLogger logger) {
+        return () -> {
+            if (targetIntent == null) {
+                finishCallback.accept(null);
+                return;
+            }
+
+            final String action = targetIntent.getAction();
+
+            ClipData clipData = null;
+            if (Intent.ACTION_SEND.equals(action)) {
+                String extraText = targetIntent.getStringExtra(Intent.EXTRA_TEXT);
+                Uri extraStream = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM);
+
+                if (extraText != null) {
+                    clipData = ClipData.newPlainText(null, extraText);
+                } else if (extraStream != null) {
+                    clipData = ClipData.newUri(context.getContentResolver(), null, extraStream);
+                } else {
+                    Log.w(TAG, "No data available to copy to clipboard");
+                    return;
+                }
+            } else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
+                final ArrayList<Uri> streams = targetIntent.getParcelableArrayListExtra(
+                        Intent.EXTRA_STREAM);
+                clipData = ClipData.newUri(context.getContentResolver(), null, streams.get(0));
+                for (int i = 1; i < streams.size(); i++) {
+                    clipData.addItem(
+                            context.getContentResolver(),
+                            new ClipData.Item(streams.get(i)));
+                }
+            } else {
+                // expected to only be visible with ACTION_SEND or ACTION_SEND_MULTIPLE
+                // so warn about unexpected action
+                Log.w(TAG, "Action (" + action + ") not supported for copying to clipboard");
+                return;
+            }
+
+            ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService(
+                    Context.CLIPBOARD_SERVICE);
+            clipboardManager.setPrimaryClipAsPackage(clipData, referrerPackageName);
+
+            logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_COPY);
+            finishCallback.accept(Activity.RESULT_OK);
+        };
+    }
+
+    private static TargetInfo getEditSharingTarget(
+            Context context,
+            Intent originalIntent,
+            ChooserIntegratedDeviceComponents integratedComponents) {
+        final ComponentName editorComponent = integratedComponents.getEditSharingComponent();
+
+        final Intent resolveIntent = new Intent(originalIntent);
+        // Retain only URI permission grant flags if present. Other flags may prevent the scene
+        // transition animation from running (i.e FLAG_ACTIVITY_NO_ANIMATION,
+        // FLAG_ACTIVITY_NEW_TASK, FLAG_ACTIVITY_NEW_DOCUMENT) but also not needed.
+        resolveIntent.setFlags(originalIntent.getFlags() & URI_PERMISSION_INTENT_FLAGS);
+        resolveIntent.setComponent(editorComponent);
+        resolveIntent.setAction(Intent.ACTION_EDIT);
+        String originalAction = originalIntent.getAction();
+        if (Intent.ACTION_SEND.equals(originalAction)) {
+            if (resolveIntent.getData() == null) {
+                Uri uri = resolveIntent.getParcelableExtra(Intent.EXTRA_STREAM);
+                if (uri != null) {
+                    String mimeType = context.getContentResolver().getType(uri);
+                    resolveIntent.setDataAndType(uri, mimeType);
+                }
+            }
+        } else {
+            Log.e(TAG, originalAction + " is not supported.");
+            return null;
+        }
+        final ResolveInfo ri = context.getPackageManager().resolveActivity(
+                resolveIntent, PackageManager.GET_META_DATA);
+        if (ri == null || ri.activityInfo == null) {
+            Log.e(TAG, "Device-specified editor (" + editorComponent + ") not available");
+            return null;
+        }
+
+        final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo(
+                originalIntent,
+                ri,
+                context.getString(com.android.internal.R.string.screenshot_edit),
+                "",
+                resolveIntent,
+                null);
+        dri.getDisplayIconHolder().setDisplayIcon(
+                context.getDrawable(com.android.internal.R.drawable.ic_screenshot_edit));
+        return dri;
+    }
+
+    private static Runnable makeOnEditRunnable(
+            TargetInfo editSharingTarget,
+            Callable</* @Nullable */ View> firstVisibleImageQuery,
+            ActionActivityStarter activityStarter,
+            ChooserActivityLogger logger) {
+        return () -> {
+            // Log share completion via edit.
+            logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_EDIT);
+
+            View firstImageView = null;
+            try {
+                firstImageView = firstVisibleImageQuery.call();
+            } catch (Exception e) { /* ignore */ }
+            // Action bar is user-independent; always start as primary.
+            if (firstImageView == null) {
+                activityStarter.safelyStartActivityAsPersonalProfileUser(editSharingTarget);
+            } else {
+                activityStarter.safelyStartActivityAsPersonalProfileUserWithSharedElementTransition(
+                        editSharingTarget, firstImageView, IMAGE_EDITOR_SHARED_ELEMENT);
+            }
+        };
+    }
+
+    private static TargetInfo getNearbySharingTarget(
+            Context context,
+            Intent originalIntent,
+            ChooserIntegratedDeviceComponents integratedComponents) {
+        final ComponentName cn = integratedComponents.getNearbySharingComponent();
+        if (cn == null) {
+            return null;
+        }
+
+        final Intent resolveIntent = new Intent(originalIntent);
+        resolveIntent.setComponent(cn);
+        final ResolveInfo ri = context.getPackageManager().resolveActivity(
+                resolveIntent, PackageManager.GET_META_DATA);
+        if (ri == null || ri.activityInfo == null) {
+            Log.e(TAG, "Device-specified nearby sharing component (" + cn
+                    + ") not available");
+            return null;
+        }
+
+        // Allow the nearby sharing component to provide a more appropriate icon and label
+        // for the chip.
+        CharSequence name = null;
+        Drawable icon = null;
+        final Bundle metaData = ri.activityInfo.metaData;
+        if (metaData != null) {
+            try {
+                final Resources pkgRes = context.getPackageManager().getResourcesForActivity(cn);
+                final int nameResId = metaData.getInt(CHIP_LABEL_METADATA_KEY);
+                name = pkgRes.getString(nameResId);
+                final int resId = metaData.getInt(CHIP_ICON_METADATA_KEY);
+                icon = pkgRes.getDrawable(resId);
+            } catch (NameNotFoundException | Resources.NotFoundException ex) { /* ignore */ }
+        }
+        if (TextUtils.isEmpty(name)) {
+            name = ri.loadLabel(context.getPackageManager());
+        }
+        if (icon == null) {
+            icon = ri.loadIcon(context.getPackageManager());
+        }
+
+        final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo(
+                originalIntent, ri, name, "", resolveIntent, null);
+        dri.getDisplayIconHolder().setDisplayIcon(icon);
+        return dri;
+    }
+
+    private static Runnable makeOnNearbyShareRunnable(
+            TargetInfo nearbyShareTarget,
+            ActionActivityStarter activityStarter,
+            Consumer<Integer> finishCallback,
+            ChooserActivityLogger logger) {
+        return () -> {
+            logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_NEARBY);
+            // Action bar is user-independent; always start as primary.
+            activityStarter.safelyStartActivityAsPersonalProfileUser(nearbyShareTarget);
+        };
+    }
+
+    @Nullable
+    private static ActionRow.Action createCustomAction(
+            Context context,
+            ChooserAction action,
+            Consumer<Integer> finishCallback,
+            int position,
+            ChooserActivityLogger logger) {
+        Drawable icon = action.getIcon().loadDrawable(context);
+        if (icon == null && TextUtils.isEmpty(action.getLabel())) {
+            return null;
+        }
+        return new ActionRow.Action(
+                action.getLabel(),
+                icon,
+                () -> {
+                    try {
+                        action.getAction().send(
+                                null,
+                                0,
+                                null,
+                                null,
+                                null,
+                                null,
+                                ActivityOptions.makeCustomAnimation(
+                                        context,
+                                        R.anim.slide_in_right,
+                                        R.anim.slide_out_left)
+                                                .toBundle());
+                    } catch (PendingIntent.CanceledException e) {
+                        Log.d(TAG, "Custom action, " + action.getLabel() + ", has been cancelled");
+                    }
+                    logger.logCustomActionSelected(position);
+                    finishCallback.accept(Activity.RESULT_OK);
+                }
+        );
+    }
+}
diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java
index ceab62b..ae5be26 100644
--- a/java/src/com/android/intentresolver/ChooserActivity.java
+++ b/java/src/com/android/intentresolver/ChooserActivity.java
@@ -36,44 +36,30 @@
 import android.app.prediction.AppTarget;
 import android.app.prediction.AppTargetEvent;
 import android.app.prediction.AppTargetId;
-import android.content.ClipData;
-import android.content.ClipboardManager;
 import android.content.ComponentName;
 import android.content.ContentResolver;
 import android.content.Context;
 import android.content.Intent;
 import android.content.IntentFilter;
 import android.content.IntentSender;
-import android.content.IntentSender.SendIntentException;
 import android.content.SharedPreferences;
 import android.content.pm.ActivityInfo;
 import android.content.pm.PackageManager;
-import android.content.pm.PackageManager.NameNotFoundException;
 import android.content.pm.ResolveInfo;
 import android.content.pm.ShortcutInfo;
 import android.content.res.Configuration;
-import android.content.res.Resources;
 import android.database.Cursor;
-import android.graphics.Bitmap;
 import android.graphics.Insets;
-import android.graphics.drawable.Drawable;
 import android.net.Uri;
 import android.os.Bundle;
 import android.os.Environment;
-import android.os.Handler;
-import android.os.Parcelable;
-import android.os.PatternMatcher;
-import android.os.ResultReceiver;
 import android.os.SystemClock;
 import android.os.UserHandle;
 import android.os.UserManager;
 import android.os.storage.StorageManager;
 import android.provider.DeviceConfig;
-import android.provider.Settings;
 import android.service.chooser.ChooserTarget;
-import android.text.TextUtils;
 import android.util.Log;
-import android.util.Size;
 import android.util.Slog;
 import android.util.SparseArray;
 import android.view.View;
@@ -97,6 +83,10 @@
 import com.android.intentresolver.chooser.DisplayResolveInfo;
 import com.android.intentresolver.chooser.MultiDisplayResolveInfo;
 import com.android.intentresolver.chooser.TargetInfo;
+import com.android.intentresolver.contentpreview.ChooserContentPreviewUi;
+import com.android.intentresolver.flags.FeatureFlagRepository;
+import com.android.intentresolver.flags.FeatureFlagRepositoryFactory;
+import com.android.intentresolver.flags.Flags;
 import com.android.intentresolver.grid.ChooserGridAdapter;
 import com.android.intentresolver.grid.DirectShareViewHolder;
 import com.android.intentresolver.model.AbstractResolverComparator;
@@ -104,16 +94,13 @@
 import com.android.intentresolver.model.ResolverRankerServiceResolverComparator;
 import com.android.intentresolver.shortcuts.AppPredictorFactory;
 import com.android.intentresolver.shortcuts.ShortcutLoader;
-import com.android.intentresolver.widget.ActionRow;
 import com.android.intentresolver.widget.ResolverDrawerLayout;
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.config.sysui.SystemUiDeviceConfigFlags;
 import com.android.internal.content.PackageMonitor;
 import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
-import com.android.internal.util.FrameworkStatsLog;
 
 import java.io.File;
-import java.io.IOException;
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.text.Collator;
@@ -205,6 +192,8 @@
             | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
             | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION;
 
+    private ChooserIntegratedDeviceComponents mIntegratedDeviceComponents;
+
     /* TODO: this is `nullable` because we have to defer the assignment til onCreate(). We make the
      * only assignment there, and expect it to be ready by the time we ever use it --
      * someday if we move all the usage to a component with a narrower lifecycle (something that
@@ -214,13 +203,15 @@
     @Nullable
     private ChooserRequestParameters mChooserRequest;
 
+    private ChooserRefinementManager mRefinementManager;
+
+    private FeatureFlagRepository mFeatureFlagRepository;
+    private ChooserContentPreviewUi mChooserContentPreviewUi;
+
     private boolean mShouldDisplayLandscape;
     // statsd logger wrapper
     protected ChooserActivityLogger mChooserActivityLogger;
 
-    @Nullable
-    private RefinementResultReceiver mRefinementResultReceiver;
-
     private long mChooserShownTime;
     protected boolean mIsSuccessfullySelected;
 
@@ -240,9 +231,6 @@
 
     private final ExecutorService mBackgroundThreadPoolExecutor = Executors.newFixedThreadPool(5);
 
-    @Nullable
-    private ChooserContentPreviewCoordinator mPreviewCoordinator;
-
     private int mScrollStatus = SCROLL_STATUS_IDLE;
 
     @VisibleForTesting
@@ -254,6 +242,8 @@
 
     private final SparseArray<ProfileRecord> mProfileRecords = new SparseArray<>();
 
+    private boolean mExcludeSharedText = false;
+
     public ChooserActivity() {}
 
     @Override
@@ -263,9 +253,16 @@
 
         getChooserActivityLogger().logSharesheetTriggered();
 
+        mFeatureFlagRepository = createFeatureFlagRepository();
+        mIntegratedDeviceComponents = getIntegratedDeviceComponents();
+
         try {
             mChooserRequest = new ChooserRequestParameters(
-                    getIntent(), getReferrer(), getNearbySharingComponent());
+                    getIntent(),
+                    getReferrerPackageName(),
+                    getReferrer(),
+                    mIntegratedDeviceComponents,
+                    mFeatureFlagRepository);
         } catch (IllegalArgumentException e) {
             Log.e(TAG, "Caller provided invalid Chooser request parameters", e);
             finish();
@@ -273,6 +270,29 @@
             return;
         }
 
+        mRefinementManager = new ChooserRefinementManager(
+                this,
+                mChooserRequest.getRefinementIntentSender(),
+                (validatedRefinedTarget) -> {
+                    maybeRemoveSharedText(validatedRefinedTarget);
+                    if (super.onTargetSelected(validatedRefinedTarget, false)) {
+                        finish();
+                    }
+                },
+                () -> {
+                    mRefinementManager.destroy();
+                    finish();
+                });
+
+        mChooserContentPreviewUi = new ChooserContentPreviewUi(
+                mChooserRequest.getTargetIntent(),
+                getContentResolver(),
+                this::isImageType,
+                createPreviewImageLoader(),
+                createChooserActionFactory(),
+                mEnterTransitionAnimationDelegate,
+                mFeatureFlagRepository);
+
         setAdditionalTargets(mChooserRequest.getAdditionalTargets());
 
         setSafeForwardingMode(true);
@@ -291,11 +311,6 @@
                         mChooserRequest.getTargetIntentFilter()),
                 mChooserRequest.getTargetIntentFilter());
 
-        mPreviewCoordinator = new ChooserContentPreviewCoordinator(
-                mBackgroundThreadPoolExecutor,
-                this,
-                () -> mEnterTransitionAnimationDelegate.markImagePreviewReady(false));
-
         super.onCreate(
                 savedInstanceState,
                 mChooserRequest.getTargetIntent(),
@@ -341,26 +356,35 @@
         }
 
         getChooserActivityLogger().logShareStarted(
-                FrameworkStatsLog.SHARESHEET_STARTED,
                 getReferrerPackageName(),
                 mChooserRequest.getTargetType(),
                 mChooserRequest.getCallerChooserTargets().size(),
                 (mChooserRequest.getInitialIntents() == null)
                         ? 0 : mChooserRequest.getInitialIntents().length,
                 isWorkProfile(),
-                ChooserContentPreviewUi.findPreferredContentPreview(
-                        getTargetIntent(), getContentResolver(), this::isImageType),
-                mChooserRequest.getTargetAction()
+                mChooserContentPreviewUi.getPreferredContentPreview(),
+                mChooserRequest.getTargetAction(),
+                mChooserRequest.getChooserActions().size(),
+                mChooserRequest.getModifyShareAction() != null
         );
 
         mEnterTransitionAnimationDelegate.postponeTransition();
     }
 
+    @VisibleForTesting
+    protected ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() {
+        return ChooserIntegratedDeviceComponents.get(this, new SecureSettings());
+    }
+
     @Override
     protected int appliedThemeResId() {
         return R.style.Theme_DeviceDefault_Chooser;
     }
 
+    protected FeatureFlagRepository createFeatureFlagRepository() {
+        return new FeatureFlagRepositoryFactory().create(getApplicationContext());
+    }
+
     private void createProfileRecords(
             AppPredictorFactory factory, IntentFilter targetIntentFilter) {
         UserHandle mainUserHandle = getPersonalProfileUserHandle();
@@ -489,7 +513,7 @@
                 /* context */ this,
                 adapter,
                 createEmptyStateProvider(/* workProfileUserHandle= */ null),
-                mQuietModeManager,
+                /* workProfileQuietModeChecker= */ () -> false,
                 /* workProfileUserHandle= */ null,
                 mMaxTargetsPerRow);
     }
@@ -518,7 +542,7 @@
                 personalAdapter,
                 workAdapter,
                 createEmptyStateProvider(/* workProfileUserHandle= */ getWorkProfileUserHandle()),
-                mQuietModeManager,
+                () -> mWorkProfileAvailability.isQuietModeEnabled(),
                 selectedProfile,
                 getWorkProfileUserHandle(),
                 mMaxTargetsPerRow);
@@ -539,8 +563,7 @@
                 || mChooserMultiProfilePagerAdapter
                         .getCurrentRootAdapter().getSystemRowCount() != 0) {
             getChooserActivityLogger().logActionShareWithPreview(
-                    ChooserContentPreviewUi.findPreferredContentPreview(
-                            getTargetIntent(), getContentResolver(), this::isImageType));
+                    mChooserContentPreviewUi.getPreferredContentPreview());
         }
         return postRebuildListInternal(rebuildCompleted);
     }
@@ -591,51 +614,6 @@
         updateProfileViewButton();
     }
 
-    private void onCopyButtonClicked() {
-        Intent targetIntent = getTargetIntent();
-        if (targetIntent == null) {
-            finish();
-        } else {
-            final String action = targetIntent.getAction();
-
-            ClipData clipData = null;
-            if (Intent.ACTION_SEND.equals(action)) {
-                String extraText = targetIntent.getStringExtra(Intent.EXTRA_TEXT);
-                Uri extraStream = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM);
-
-                if (extraText != null) {
-                    clipData = ClipData.newPlainText(null, extraText);
-                } else if (extraStream != null) {
-                    clipData = ClipData.newUri(getContentResolver(), null, extraStream);
-                } else {
-                    Log.w(TAG, "No data available to copy to clipboard");
-                    return;
-                }
-            } else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
-                final ArrayList<Uri> streams = targetIntent.getParcelableArrayListExtra(
-                        Intent.EXTRA_STREAM);
-                clipData = ClipData.newUri(getContentResolver(), null, streams.get(0));
-                for (int i = 1; i < streams.size(); i++) {
-                    clipData.addItem(getContentResolver(), new ClipData.Item(streams.get(i)));
-                }
-            } else {
-                // expected to only be visible with ACTION_SEND or ACTION_SEND_MULTIPLE
-                // so warn about unexpected action
-                Log.w(TAG, "Action (" + action + ") not supported for copying to clipboard");
-                return;
-            }
-
-            ClipboardManager clipboardManager = (ClipboardManager) getSystemService(
-                    Context.CLIPBOARD_SERVICE);
-            clipboardManager.setPrimaryClipAsPackage(clipData, getReferrerPackageName());
-
-            getChooserActivityLogger().logActionSelected(ChooserActivityLogger.SELECTION_TYPE_COPY);
-
-            setResult(RESULT_OK);
-            finish();
-        }
-    }
-
     @Override
     protected void onResume() {
         super.onResume();
@@ -707,226 +685,19 @@
      * @param parent reference to the parent container where the view should be attached to
      * @return content preview view
      */
-    protected ViewGroup createContentPreviewView(
-            ViewGroup parent,
-            ChooserContentPreviewUi.ContentPreviewCoordinator previewCoordinator) {
-        Intent targetIntent = getTargetIntent();
-        int previewType = ChooserContentPreviewUi.findPreferredContentPreview(
-                targetIntent, getContentResolver(), this::isImageType);
-
-        ChooserContentPreviewUi.ActionFactory actionFactory =
-                new ChooserContentPreviewUi.ActionFactory() {
-                    @Override
-                    public ActionRow.Action createCopyButton() {
-                        return ChooserActivity.this.createCopyAction();
-                    }
-
-                    @Nullable
-                    @Override
-                    public ActionRow.Action createEditButton() {
-                        return ChooserActivity.this.createEditAction(targetIntent);
-                    }
-
-                    @Nullable
-                    @Override
-                    public ActionRow.Action createNearbyButton() {
-                        return ChooserActivity.this.createNearbyAction(targetIntent);
-                    }
-                };
-
-        ViewGroup layout = ChooserContentPreviewUi.displayContentPreview(
-                previewType,
-                targetIntent,
+    protected ViewGroup createContentPreviewView(ViewGroup parent) {
+        ViewGroup layout = mChooserContentPreviewUi.displayContentPreview(
                 getResources(),
                 getLayoutInflater(),
-                actionFactory,
-                R.layout.chooser_action_row,
-                parent,
-                previewCoordinator,
-                mEnterTransitionAnimationDelegate::markImagePreviewReady,
-                getContentResolver(),
-                this::isImageType);
+                parent);
 
         if (layout != null) {
             adjustPreviewWidth(getResources().getConfiguration().orientation, layout);
         }
-        if (previewType != ChooserContentPreviewUi.CONTENT_PREVIEW_IMAGE) {
-            mEnterTransitionAnimationDelegate.markImagePreviewReady(false);
-        }
 
         return layout;
     }
 
-    @VisibleForTesting
-    protected ComponentName getNearbySharingComponent() {
-        String nearbyComponent = Settings.Secure.getString(
-                getContentResolver(),
-                Settings.Secure.NEARBY_SHARING_COMPONENT);
-        if (TextUtils.isEmpty(nearbyComponent)) {
-            nearbyComponent = getString(R.string.config_defaultNearbySharingComponent);
-        }
-        if (TextUtils.isEmpty(nearbyComponent)) {
-            return null;
-        }
-        return ComponentName.unflattenFromString(nearbyComponent);
-    }
-
-    @VisibleForTesting
-    protected @Nullable ComponentName getEditSharingComponent() {
-        String editorPackage = getApplicationContext().getString(R.string.config_systemImageEditor);
-        if (editorPackage == null || TextUtils.isEmpty(editorPackage)) {
-            return null;
-        }
-        return ComponentName.unflattenFromString(editorPackage);
-    }
-
-    @VisibleForTesting
-    protected TargetInfo getEditSharingTarget(Intent originalIntent) {
-        final ComponentName cn = getEditSharingComponent();
-
-        final Intent resolveIntent = new Intent(originalIntent);
-        // Retain only URI permission grant flags if present. Other flags may prevent the scene
-        // transition animation from running (i.e FLAG_ACTIVITY_NO_ANIMATION,
-        // FLAG_ACTIVITY_NEW_TASK, FLAG_ACTIVITY_NEW_DOCUMENT) but also not needed.
-        resolveIntent.setFlags(originalIntent.getFlags() & URI_PERMISSION_INTENT_FLAGS);
-        resolveIntent.setComponent(cn);
-        resolveIntent.setAction(Intent.ACTION_EDIT);
-        String originalAction = originalIntent.getAction();
-        if (Intent.ACTION_SEND.equals(originalAction)) {
-            if (resolveIntent.getData() == null) {
-                Uri uri = resolveIntent.getParcelableExtra(Intent.EXTRA_STREAM);
-                if (uri != null) {
-                    String mimeType = getContentResolver().getType(uri);
-                    resolveIntent.setDataAndType(uri, mimeType);
-                }
-            }
-        } else {
-            Log.e(TAG, originalAction + " is not supported.");
-            return null;
-        }
-        final ResolveInfo ri = getPackageManager().resolveActivity(
-                resolveIntent, PackageManager.GET_META_DATA);
-        if (ri == null || ri.activityInfo == null) {
-            Log.e(TAG, "Device-specified image edit component (" + cn
-                    + ") not available");
-            return null;
-        }
-
-        final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo(
-                originalIntent,
-                ri,
-                getString(com.android.internal.R.string.screenshot_edit),
-                "",
-                resolveIntent,
-                null);
-        dri.getDisplayIconHolder().setDisplayIcon(
-                getDrawable(com.android.internal.R.drawable.ic_screenshot_edit));
-        return dri;
-    }
-
-    @VisibleForTesting
-    protected TargetInfo getNearbySharingTarget(Intent originalIntent) {
-        final ComponentName cn = getNearbySharingComponent();
-        if (cn == null) return null;
-
-        final Intent resolveIntent = new Intent(originalIntent);
-        resolveIntent.setComponent(cn);
-        final ResolveInfo ri = getPackageManager().resolveActivity(
-                resolveIntent, PackageManager.GET_META_DATA);
-        if (ri == null || ri.activityInfo == null) {
-            Log.e(TAG, "Device-specified nearby sharing component (" + cn
-                    + ") not available");
-            return null;
-        }
-
-        // Allow the nearby sharing component to provide a more appropriate icon and label
-        // for the chip.
-        CharSequence name = null;
-        Drawable icon = null;
-        final Bundle metaData = ri.activityInfo.metaData;
-        if (metaData != null) {
-            try {
-                final Resources pkgRes = getPackageManager().getResourcesForActivity(cn);
-                final int nameResId = metaData.getInt(CHIP_LABEL_METADATA_KEY);
-                name = pkgRes.getString(nameResId);
-                final int resId = metaData.getInt(CHIP_ICON_METADATA_KEY);
-                icon = pkgRes.getDrawable(resId);
-            } catch (Resources.NotFoundException ex) {
-            } catch (NameNotFoundException ex) {
-            }
-        }
-        if (TextUtils.isEmpty(name)) {
-            name = ri.loadLabel(getPackageManager());
-        }
-        if (icon == null) {
-            icon = ri.loadIcon(getPackageManager());
-        }
-
-        final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo(
-                originalIntent, ri, name, "", resolveIntent, null);
-        dri.getDisplayIconHolder().setDisplayIcon(icon);
-        return dri;
-    }
-
-    private ActionRow.Action createCopyAction() {
-        return new ActionRow.Action(
-                com.android.internal.R.id.chooser_copy_button,
-                getString(com.android.internal.R.string.copy),
-                getDrawable(com.android.internal.R.drawable.ic_menu_copy_material),
-                this::onCopyButtonClicked);
-    }
-
-    @Nullable
-    private ActionRow.Action createNearbyAction(Intent originalIntent) {
-        final TargetInfo ti = getNearbySharingTarget(originalIntent);
-        if (ti == null) {
-            return null;
-        }
-
-        return new ActionRow.Action(
-                com.android.internal.R.id.chooser_nearby_button,
-                ti.getDisplayLabel(),
-                ti.getDisplayIconHolder().getDisplayIcon(),
-                () -> {
-                    getChooserActivityLogger().logActionSelected(
-                            ChooserActivityLogger.SELECTION_TYPE_NEARBY);
-                    // Action bar is user-independent, always start as primary
-                    safelyStartActivityAsUser(ti, getPersonalProfileUserHandle());
-                    finish();
-                });
-    }
-
-    @Nullable
-    private ActionRow.Action createEditAction(Intent originalIntent) {
-        final TargetInfo ti = getEditSharingTarget(originalIntent);
-        if (ti == null) {
-            return null;
-        }
-
-        return new ActionRow.Action(
-                com.android.internal.R.id.chooser_edit_button,
-                ti.getDisplayLabel(),
-                ti.getDisplayIconHolder().getDisplayIcon(),
-                () -> {
-                    // Log share completion via edit
-                    getChooserActivityLogger().logActionSelected(
-                            ChooserActivityLogger.SELECTION_TYPE_EDIT);
-                    View firstImgView = getFirstVisibleImgPreviewView();
-                    // Action bar is user-independent, always start as primary
-                    if (firstImgView == null) {
-                        safelyStartActivityAsUser(ti, getPersonalProfileUserHandle());
-                        finish();
-                    } else {
-                        ActivityOptions options = ActivityOptions.makeSceneTransitionAnimation(
-                                this, firstImgView, IMAGE_EDITOR_SHARED_ELEMENT);
-                        safelyStartActivityAsUser(
-                                ti, getPersonalProfileUserHandle(), options.toBundle());
-                        startFinishAnimation();
-                    }
-                }
-        );
-    }
-
     @Nullable
     private View getFirstVisibleImgPreviewView() {
         View firstImage = findViewById(com.android.internal.R.id.content_preview_image_1_large);
@@ -972,9 +743,9 @@
             mLatencyTracker.onActionCancel(ACTION_LOAD_SHARE_SHEET);
         }
 
-        if (mRefinementResultReceiver != null) {
-            mRefinementResultReceiver.destroy();
-            mRefinementResultReceiver = null;
+        if (mRefinementManager != null) {  // TODO: null-checked in case of early-destroy, or skip?
+            mRefinementManager.destroy();
+            mRefinementManager = null;
         }
 
         mBackgroundThreadPoolExecutor.shutdownNow();
@@ -1098,34 +869,11 @@
 
     @Override
     protected boolean onTargetSelected(TargetInfo target, boolean alwaysCheck) {
-        if (mChooserRequest.getRefinementIntentSender() != null) {
-            final Intent fillIn = new Intent();
-            final List<Intent> sourceIntents = target.getAllSourceIntents();
-            if (!sourceIntents.isEmpty()) {
-                fillIn.putExtra(Intent.EXTRA_INTENT, sourceIntents.get(0));
-                if (sourceIntents.size() > 1) {
-                    final Intent[] alts = new Intent[sourceIntents.size() - 1];
-                    for (int i = 1, N = sourceIntents.size(); i < N; i++) {
-                        alts[i - 1] = sourceIntents.get(i);
-                    }
-                    fillIn.putExtra(Intent.EXTRA_ALTERNATE_INTENTS, alts);
-                }
-                if (mRefinementResultReceiver != null) {
-                    mRefinementResultReceiver.destroy();
-                }
-                mRefinementResultReceiver = new RefinementResultReceiver(this, target, null);
-                fillIn.putExtra(Intent.EXTRA_RESULT_RECEIVER,
-                        mRefinementResultReceiver);
-                try {
-                    mChooserRequest.getRefinementIntentSender().sendIntent(
-                            this, 0, fillIn, null, null);
-                    return false;
-                } catch (SendIntentException e) {
-                    Log.e(TAG, "Refinement IntentSender failed to send", e);
-                }
-            }
+        if (mRefinementManager.maybeHandleSelection(target)) {
+            return false;
         }
         updateModelAndChooserCounts(target);
+        maybeRemoveSharedText(target);
         return super.onTargetSelected(target, alwaysCheck);
     }
 
@@ -1237,45 +985,6 @@
         }
     }
 
-    private IntentFilter getTargetIntentFilter() {
-        return getTargetIntentFilter(getTargetIntent());
-    }
-
-    private IntentFilter getTargetIntentFilter(final Intent intent) {
-        try {
-            String dataString = intent.getDataString();
-            if (intent.getType() == null) {
-                if (!TextUtils.isEmpty(dataString)) {
-                    return new IntentFilter(intent.getAction(), dataString);
-                }
-                Log.e(TAG, "Failed to get target intent filter: intent data and type are null");
-                return null;
-            }
-            IntentFilter intentFilter = new IntentFilter(intent.getAction(), intent.getType());
-            List<Uri> contentUris = new ArrayList<>();
-            if (Intent.ACTION_SEND.equals(intent.getAction())) {
-                Uri uri = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM);
-                if (uri != null) {
-                    contentUris.add(uri);
-                }
-            } else {
-                List<Uri> uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
-                if (uris != null) {
-                    contentUris.addAll(uris);
-                }
-            }
-            for (Uri uri : contentUris) {
-                intentFilter.addDataScheme(uri.getScheme());
-                intentFilter.addDataAuthority(uri.getAuthority(), null);
-                intentFilter.addDataPath(uri.getPath(), PatternMatcher.PATTERN_LITERAL);
-            }
-            return intentFilter;
-        } catch (Exception e) {
-            Log.e(TAG, "Failed to get target intent filter", e);
-            return null;
-        }
-    }
-
     private void logDirectShareTargetReceived(UserHandle forUser) {
         ProfileRecord profileRecord = getProfileRecord(forUser);
         if (profileRecord == null) {
@@ -1314,6 +1023,27 @@
         mIsSuccessfullySelected = true;
     }
 
+    private void maybeRemoveSharedText(@androidx.annotation.NonNull TargetInfo targetInfo) {
+        Intent targetIntent = targetInfo.getTargetIntent();
+        if (targetIntent == null) {
+            return;
+        }
+        Intent originalTargetIntent = new Intent(mChooserRequest.getTargetIntent());
+        // Our TargetInfo implementations add associated component to the intent, let's do the same
+        // for the sake of the comparison below.
+        if (targetIntent.getComponent() != null) {
+            originalTargetIntent.setComponent(targetIntent.getComponent());
+        }
+        // Use filterEquals as a way to check that the primary intent is in use (and not an
+        // alternative one). For example, an app is sharing an image and a link with mime type
+        // "image/png" and provides an alternative intent to share only the link with mime type
+        // "text/uri". Should there be a target that accepts only the latter, the alternative intent
+        // will be used and we don't want to exclude the link from it.
+        if (mExcludeSharedText && originalTargetIntent.filterEquals(targetIntent)) {
+            targetIntent.removeExtra(Intent.EXTRA_TEXT);
+        }
+    }
+
     private void sendImpressionToAppPredictor(TargetInfo targetInfo, ChooserListAdapter adapter) {
         // Send DS target impression info to AppPredictor, only when user chooses app share.
         if (targetInfo.isChooserTargetInfo()) {
@@ -1369,46 +1099,6 @@
         return (record == null) ? null : record.appPredictor;
     }
 
-    void onRefinementResult(TargetInfo selectedTarget, Intent matchingIntent) {
-        if (mRefinementResultReceiver != null) {
-            mRefinementResultReceiver.destroy();
-            mRefinementResultReceiver = null;
-        }
-        if (selectedTarget == null) {
-            Log.e(TAG, "Refinement result intent did not match any known targets; canceling");
-        } else if (!checkTargetSourceIntent(selectedTarget, matchingIntent)) {
-            Log.e(TAG, "onRefinementResult: Selected target " + selectedTarget
-                    + " cannot match refined source intent " + matchingIntent);
-        } else {
-            TargetInfo clonedTarget = selectedTarget.cloneFilledIn(matchingIntent, 0);
-            if (super.onTargetSelected(clonedTarget, false)) {
-                updateModelAndChooserCounts(clonedTarget);
-                finish();
-                return;
-            }
-        }
-        onRefinementCanceled();
-    }
-
-    void onRefinementCanceled() {
-        if (mRefinementResultReceiver != null) {
-            mRefinementResultReceiver.destroy();
-            mRefinementResultReceiver = null;
-        }
-        finish();
-    }
-
-    boolean checkTargetSourceIntent(TargetInfo target, Intent matchingIntent) {
-        final List<Intent> targetIntents = target.getAllSourceIntents();
-        for (int i = 0, N = targetIntents.size(); i < N; i++) {
-            final Intent targetIntent = targetIntents.get(i);
-            if (targetIntent.filterEquals(matchingIntent)) {
-                return true;
-            }
-        }
-        return false;
-    }
-
     /**
      * Sort intents alphabetically based on display label.
      */
@@ -1433,14 +1123,19 @@
     }
 
     public class ChooserListController extends ResolverListController {
-        public ChooserListController(Context context,
+        public ChooserListController(
+                Context context,
                 PackageManager pm,
                 Intent targetIntent,
                 String referrerPackageName,
                 int launchedFromUid,
-                UserHandle userId,
                 AbstractResolverComparator resolverComparator) {
-            super(context, pm, targetIntent, referrerPackageName, launchedFromUid, userId,
+            super(
+                    context,
+                    pm,
+                    targetIntent,
+                    referrerPackageName,
+                    launchedFromUid,
                     resolverComparator);
         }
 
@@ -1485,7 +1180,7 @@
 
                     @Override
                     public View buildContentPreview(ViewGroup parent) {
-                        return createContentPreviewView(parent, mPreviewCoordinator);
+                        return createContentPreviewView(parent);
                     }
 
                     @Override
@@ -1500,9 +1195,9 @@
                                 .getActiveListAdapter()
                                 .targetInfoForPosition(
                                         selectedPosition, /* filtered= */ true);
-                        // ItemViewHolder contents should always be "display resolve info"
-                        // targets, but check just to make sure.
-                        if (longPressedTargetInfo.isDisplayResolveInfo()) {
+                        // Only a direct share target or an app target is expected
+                        if (longPressedTargetInfo.isDisplayResolveInfo()
+                                || longPressedTargetInfo.isSelectableTargetInfo()) {
                             showTargetDetails(longPressedTargetInfo);
                         }
                     }
@@ -1576,8 +1271,9 @@
                 maxTargetsPerRow);
     }
 
+    @Override
     @VisibleForTesting
-    protected ResolverListController createListController(UserHandle userHandle) {
+    protected ChooserListController createListController(UserHandle userHandle) {
         AppPredictor appPredictor = getAppPredictor(userHandle);
         AbstractResolverComparator resolverComparator;
         if (appPredictor != null) {
@@ -1594,23 +1290,55 @@
                 mPm,
                 getTargetIntent(),
                 getReferrerPackageName(),
-                mLaunchedFromUid,
-                userHandle,
+                getAnnotatedUserHandles().userIdOfCallingApp,
                 resolverComparator);
     }
 
     @VisibleForTesting
-    protected Bitmap loadThumbnail(Uri uri, Size size) {
-        if (uri == null || size == null) {
-            return null;
+    protected ImageLoader createPreviewImageLoader() {
+        final int cacheSize;
+        if (mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW)) {
+            float chooserWidth = getResources().getDimension(R.dimen.chooser_width);
+            float imageWidth = getResources().getDimension(R.dimen.chooser_preview_image_width);
+            cacheSize = (int) (Math.ceil(chooserWidth / imageWidth) + 2);
+        } else {
+            cacheSize = 3;
         }
+        return new ImagePreviewImageLoader(this, getLifecycle(), cacheSize);
+    }
 
-        try {
-            return getContentResolver().loadThumbnail(uri, size, null);
-        } catch (IOException | NullPointerException | SecurityException ex) {
-            getChooserActivityLogger().logContentPreviewWarning(uri);
-        }
-        return null;
+    private ChooserActionFactory createChooserActionFactory() {
+        return new ChooserActionFactory(
+                this,
+                mChooserRequest,
+                mFeatureFlagRepository,
+                mIntegratedDeviceComponents,
+                getChooserActivityLogger(),
+                (isExcluded) -> mExcludeSharedText = isExcluded,
+                this::getFirstVisibleImgPreviewView,
+                new ChooserActionFactory.ActionActivityStarter() {
+                    @Override
+                    public void safelyStartActivityAsPersonalProfileUser(TargetInfo targetInfo) {
+                        safelyStartActivityAsUser(targetInfo, getPersonalProfileUserHandle());
+                        finish();
+                    }
+
+                    @Override
+                    public void safelyStartActivityAsPersonalProfileUserWithSharedElementTransition(
+                            TargetInfo targetInfo, View sharedElement, String sharedElementName) {
+                        ActivityOptions options = ActivityOptions.makeSceneTransitionAnimation(
+                                ChooserActivity.this, sharedElement, sharedElementName);
+                        safelyStartActivityAsUser(
+                                targetInfo, getPersonalProfileUserHandle(), options.toBundle());
+                        startFinishAnimation();
+                    }
+                },
+                (status) -> {
+                    if (status != null) {
+                        setResult(status);
+                    }
+                    finish();
+                });
     }
 
     private void handleScroll(View view, int x, int y, int oldx, int oldy) {
@@ -1845,21 +1573,20 @@
     }
 
     @MainThread
-    private void onShortcutsLoaded(
-            UserHandle userHandle, ShortcutLoader.Result shortcutsResult) {
+    private void onShortcutsLoaded(UserHandle userHandle, ShortcutLoader.Result result) {
         if (DEBUG) {
             Log.d(TAG, "onShortcutsLoaded for user: " + userHandle);
         }
-        mDirectShareShortcutInfoCache.putAll(shortcutsResult.directShareShortcutInfoCache);
-        mDirectShareAppTargetCache.putAll(shortcutsResult.directShareAppTargetCache);
+        mDirectShareShortcutInfoCache.putAll(result.getDirectShareShortcutInfoCache());
+        mDirectShareAppTargetCache.putAll(result.getDirectShareAppTargetCache());
         ChooserListAdapter adapter =
                 mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle(userHandle);
         if (adapter != null) {
-            for (ShortcutLoader.ShortcutResultInfo resultInfo : shortcutsResult.shortcutsByApp) {
+            for (ShortcutLoader.ShortcutResultInfo resultInfo : result.getShortcutsByApp()) {
                 adapter.addServiceResults(
-                        resultInfo.appTarget,
-                        resultInfo.shortcuts,
-                        shortcutsResult.isFromAppPredictor
+                        resultInfo.getAppTarget(),
+                        resultInfo.getShortcuts(),
+                        result.isFromAppPredictor()
                                 ? TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE
                                 : TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER,
                         mDirectShareShortcutInfoCache,
@@ -1946,12 +1673,24 @@
 
     private boolean shouldShowStickyContentPreviewNoOrientationCheck() {
         return shouldShowTabs()
-                && mMultiProfilePagerAdapter.getListAdapterForUserHandle(
+                && (mMultiProfilePagerAdapter.getListAdapterForUserHandle(
                 UserHandle.of(UserHandle.myUserId())).getCount() > 0
+                || shouldShowContentPreviewWhenEmpty())
                 && shouldShowContentPreview();
     }
 
     /**
+     * This method could be used to override the default behavior when we hide the preview area
+     * when the current tab doesn't have any items.
+     *
+     * @return true if we want to show the content preview area even if the tab for the current
+     *         user is empty
+     */
+    protected boolean shouldShowContentPreviewWhenEmpty() {
+        return false;
+    }
+
+    /**
      * @return true if we want to show the content preview area
      */
     protected boolean shouldShowContentPreview() {
@@ -1964,10 +1703,10 @@
             // We don't show it in landscape as otherwise there is no room for scrolling.
             // If the sticky content preview will be shown at some point with orientation change,
             // then always preload it to avoid subsequent resizing of the share sheet.
-            ViewGroup contentPreviewContainer = findViewById(com.android.internal.R.id.content_preview_container);
+            ViewGroup contentPreviewContainer =
+                    findViewById(com.android.internal.R.id.content_preview_container);
             if (contentPreviewContainer.getChildCount() == 0) {
-                ViewGroup contentPreviewView =
-                        createContentPreviewView(contentPreviewContainer, mPreviewCoordinator);
+                ViewGroup contentPreviewView = createContentPreviewView(contentPreviewContainer);
                 contentPreviewContainer.addView(contentPreviewView);
             }
         }
@@ -2101,66 +1840,6 @@
         }
     }
 
-    static class ChooserTargetRankingInfo {
-        public final List<AppTarget> scores;
-        public final UserHandle userHandle;
-
-        ChooserTargetRankingInfo(List<AppTarget> chooserTargetScores,
-                UserHandle userHandle) {
-            this.scores = chooserTargetScores;
-            this.userHandle = userHandle;
-        }
-    }
-
-    static class RefinementResultReceiver extends ResultReceiver {
-        private ChooserActivity mChooserActivity;
-        private TargetInfo mSelectedTarget;
-
-        public RefinementResultReceiver(ChooserActivity host, TargetInfo target,
-                Handler handler) {
-            super(handler);
-            mChooserActivity = host;
-            mSelectedTarget = target;
-        }
-
-        @Override
-        protected void onReceiveResult(int resultCode, Bundle resultData) {
-            if (mChooserActivity == null) {
-                Log.e(TAG, "Destroyed RefinementResultReceiver received a result");
-                return;
-            }
-            if (resultData == null) {
-                Log.e(TAG, "RefinementResultReceiver received null resultData");
-                return;
-            }
-
-            switch (resultCode) {
-                case RESULT_CANCELED:
-                    mChooserActivity.onRefinementCanceled();
-                    break;
-                case RESULT_OK:
-                    Parcelable intentParcelable = resultData.getParcelable(Intent.EXTRA_INTENT);
-                    if (intentParcelable instanceof Intent) {
-                        mChooserActivity.onRefinementResult(mSelectedTarget,
-                                (Intent) intentParcelable);
-                    } else {
-                        Log.e(TAG, "RefinementResultReceiver received RESULT_OK but no Intent"
-                                + " in resultData with key Intent.EXTRA_INTENT");
-                    }
-                    break;
-                default:
-                    Log.w(TAG, "Unknown result code " + resultCode
-                            + " sent to RefinementResultReceiver");
-                    break;
-            }
-        }
-
-        public void destroy() {
-            mChooserActivity = null;
-            mSelectedTarget = null;
-        }
-    }
-
     /**
      * Used in combination with the scene transition when launching the image editor
      */
diff --git a/java/src/com/android/intentresolver/ChooserActivityLogger.java b/java/src/com/android/intentresolver/ChooserActivityLogger.java
index 9109bf9..1f606f2 100644
--- a/java/src/com/android/intentresolver/ChooserActivityLogger.java
+++ b/java/src/com/android/intentresolver/ChooserActivityLogger.java
@@ -24,6 +24,7 @@
 import android.util.HashedStringCache;
 import android.util.Log;
 
+import com.android.intentresolver.contentpreview.ContentPreviewType;
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.logging.InstanceId;
 import com.android.internal.logging.InstanceIdSequence;
@@ -48,6 +49,8 @@
     public static final int SELECTION_TYPE_COPY = 4;
     public static final int SELECTION_TYPE_NEARBY = 5;
     public static final int SELECTION_TYPE_EDIT = 6;
+    public static final int SELECTION_TYPE_MODIFY_SHARE = 7;
+    public static final int SELECTION_TYPE_CUSTOM_ACTION = 8;
 
     /**
      * This shim is provided only for testing. In production, clients will only ever use a
@@ -66,7 +69,9 @@
                 int numAppProvidedAppTargets,
                 boolean isWorkProfile,
                 int previewType,
-                int intentType);
+                int intentType,
+                int numCustomActions,
+                boolean modifyShareActionProvided);
 
         /** Overload to use for logging {@code FrameworkStatsLog.RANKING_SELECTED}. */
         void write(
@@ -114,9 +119,16 @@
     }
 
     /** Logs a UiEventReported event for the system sharesheet completing initial start-up. */
-    public void logShareStarted(int eventId, String packageName, String mimeType,
-            int appProvidedDirect, int appProvidedApp, boolean isWorkprofile, int previewType,
-            String intent) {
+    public void logShareStarted(
+            String packageName,
+            String mimeType,
+            int appProvidedDirect,
+            int appProvidedApp,
+            boolean isWorkprofile,
+            int previewType,
+            String intent,
+            int customActionCount,
+            boolean modifyShareActionProvided) {
         mFrameworkStatsLogger.write(FrameworkStatsLog.SHARESHEET_STARTED,
                 /* event_id = 1 */ SharesheetStartedEvent.SHARE_STARTED.getId(),
                 /* package_name = 2 */ packageName,
@@ -126,7 +138,24 @@
                 /* num_app_provided_app_targets = 6 */ appProvidedApp,
                 /* is_workprofile = 7 */ isWorkprofile,
                 /* previewType = 8 */ typeFromPreviewInt(previewType),
-                /* intentType = 9 */ typeFromIntentString(intent));
+                /* intentType = 9 */ typeFromIntentString(intent),
+                /* num_provided_custom_actions = 10 */ customActionCount,
+                /* modify_share_action_provided = 11 */ modifyShareActionProvided);
+    }
+
+    /**
+     * Log that a custom action has been tapped by the user.
+     *
+     * @param positionPicked index of the custom action within the list of custom actions.
+     */
+    public void logCustomActionSelected(int positionPicked) {
+        mFrameworkStatsLogger.write(FrameworkStatsLog.RANKING_SELECTED,
+                /* event_id = 1 */
+                SharesheetTargetSelectedEvent.SHARESHEET_CUSTOM_ACTION_SELECTED.getId(),
+                /* package_name = 2 */ null,
+                /* instance_id = 3 */ getInstanceId().getId(),
+                /* position_picked = 4 */ positionPicked,
+                /* is_pinned = 5 */ false);
     }
 
     /**
@@ -328,7 +357,11 @@
         @UiEvent(doc = "User selected the nearby target.")
         SHARESHEET_NEARBY_TARGET_SELECTED(626),
         @UiEvent(doc = "User selected the edit target.")
-        SHARESHEET_EDIT_TARGET_SELECTED(669);
+        SHARESHEET_EDIT_TARGET_SELECTED(669),
+        @UiEvent(doc = "User selected the modify share target.")
+        SHARESHEET_MODIFY_SHARE_SELECTED(1316),
+        @UiEvent(doc = "User selected a custom action.")
+        SHARESHEET_CUSTOM_ACTION_SELECTED(1317);
 
         private final int mId;
         SharesheetTargetSelectedEvent(int id) {
@@ -352,6 +385,10 @@
                     return SHARESHEET_NEARBY_TARGET_SELECTED;
                 case SELECTION_TYPE_EDIT:
                     return SHARESHEET_EDIT_TARGET_SELECTED;
+                case SELECTION_TYPE_MODIFY_SHARE:
+                    return SHARESHEET_MODIFY_SHARE_SELECTED;
+                case SELECTION_TYPE_CUSTOM_ACTION:
+                    return SHARESHEET_CUSTOM_ACTION_SELECTED;
                 default:
                     return INVALID;
             }
@@ -396,11 +433,11 @@
      */
     private static int typeFromPreviewInt(int previewType) {
         switch(previewType) {
-            case ChooserContentPreviewUi.CONTENT_PREVIEW_IMAGE:
+            case ContentPreviewType.CONTENT_PREVIEW_IMAGE:
                 return FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_IMAGE;
-            case ChooserContentPreviewUi.CONTENT_PREVIEW_FILE:
+            case ContentPreviewType.CONTENT_PREVIEW_FILE:
                 return FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_FILE;
-            case ChooserContentPreviewUi.CONTENT_PREVIEW_TEXT:
+            case ContentPreviewType.CONTENT_PREVIEW_TEXT:
             default:
                 return FrameworkStatsLog
                         .SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_TYPE_UNKNOWN;
@@ -463,7 +500,9 @@
                 int numAppProvidedAppTargets,
                 boolean isWorkProfile,
                 int previewType,
-                int intentType) {
+                int intentType,
+                int numCustomActions,
+                boolean modifyShareActionProvided) {
             FrameworkStatsLog.write(
                     frameworkEventId,
                     /* event_id = 1 */ appEventId,
@@ -474,7 +513,9 @@
                     /* num_app_provided_app_targets */ numAppProvidedAppTargets,
                     /* is_workprofile */ isWorkProfile,
                     /* previewType = 8 */ previewType,
-                    /* intentType = 9 */ intentType);
+                    /* intentType = 9 */ intentType,
+                    /* num_provided_custom_actions = 10 */ numCustomActions,
+                    /* modify_share_action_provided = 11 */ modifyShareActionProvided);
         }
 
         @Override
diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java b/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java
deleted file mode 100644
index 0b8dbe3..0000000
--- a/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java
+++ /dev/null
@@ -1,132 +0,0 @@
-/*
- * Copyright (C) 2008 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.intentresolver;
-
-import android.graphics.Bitmap;
-import android.net.Uri;
-import android.os.Handler;
-import android.util.Size;
-
-import androidx.annotation.MainThread;
-import androidx.annotation.Nullable;
-
-import com.google.common.util.concurrent.FutureCallback;
-import com.google.common.util.concurrent.Futures;
-import com.google.common.util.concurrent.ListenableFuture;
-import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.common.util.concurrent.MoreExecutors;
-
-import java.util.concurrent.ExecutorService;
-import java.util.function.Consumer;
-
-/**
- * Delegate to manage deferred resource loads for content preview assets, while
- * implementing Chooser's application logic for determining timeout/success/failure conditions.
- */
-public class ChooserContentPreviewCoordinator implements
-        ChooserContentPreviewUi.ContentPreviewCoordinator {
-    public ChooserContentPreviewCoordinator(
-            ExecutorService backgroundExecutor,
-            ChooserActivity chooserActivity,
-            Runnable onFailCallback) {
-        this.mBackgroundExecutor = MoreExecutors.listeningDecorator(backgroundExecutor);
-        this.mChooserActivity = chooserActivity;
-        this.mOnFailCallback = onFailCallback;
-
-        this.mImageLoadTimeoutMillis =
-                chooserActivity.getResources().getInteger(R.integer.config_shortAnimTime);
-    }
-
-    @Override
-    public void loadImage(final Uri imageUri, final Consumer<Bitmap> callback) {
-        final int size = mChooserActivity.getResources().getDimensionPixelSize(
-                R.dimen.chooser_preview_image_max_dimen);
-
-        // TODO: apparently this timeout is only used for not holding shared element transition
-        //  animation for too long. If so, we already have a better place for it
-        //  EnterTransitionAnimationDelegate.
-        mHandler.postDelayed(this::onWatchdogTimeout, mImageLoadTimeoutMillis);
-
-        ListenableFuture<Bitmap> bitmapFuture = mBackgroundExecutor.submit(
-                () -> mChooserActivity.loadThumbnail(imageUri, new Size(size, size)));
-
-        Futures.addCallback(
-                bitmapFuture,
-                new FutureCallback<Bitmap>() {
-                    @Override
-                    public void onSuccess(Bitmap loadedBitmap) {
-                        try {
-                            callback.accept(loadedBitmap);
-                            onLoadCompleted(loadedBitmap);
-                        } catch (Exception e) { /* unimportant */ }
-                    }
-
-                    @Override
-                    public void onFailure(Throwable t) {
-                        callback.accept(null);
-                    }
-                },
-                mHandler::post);
-    }
-
-    private final ChooserActivity mChooserActivity;
-    private final ListeningExecutorService mBackgroundExecutor;
-    private final Runnable mOnFailCallback;
-    private final int mImageLoadTimeoutMillis;
-
-    // TODO: this uses a `Handler` because there doesn't seem to be a straightforward way to get a
-    // `ScheduledExecutorService` that posts to the UI thread unless we use Dagger. Eventually we'll
-    // use Dagger and can inject this as a `@UiThread ScheduledExecutorService`.
-    private final Handler mHandler = new Handler();
-
-    private boolean mAtLeastOneLoaded = false;
-
-    @MainThread
-    private void onWatchdogTimeout() {
-        if (mChooserActivity.isFinishing()) {
-            return;
-        }
-
-        // If at least one image loads within the timeout period, allow other loads to continue.
-        if (!mAtLeastOneLoaded) {
-            mOnFailCallback.run();
-        }
-    }
-
-    @MainThread
-    private void onLoadCompleted(@Nullable Bitmap loadedBitmap) {
-        if (mChooserActivity.isFinishing()) {
-            return;
-        }
-
-        // TODO: the following logic can be described as "invoke the fail callback when the first
-        //  image loading has failed". Historically, before we had switched from a single-threaded
-        //  pool to a multi-threaded pool, we first loaded the transition element's image (the image
-        //  preview is the only case when those callbacks matter) and aborting the animation on it's
-        //  failure was reasonable. With the multi-thread pool, the first result may belong to any
-        //  image and thus we can falsely abort the animation.
-        //  Now, when we track the transition view state directly and after the timeout logic will
-        //  be moved into ChooserActivity$EnterTransitionAnimationDelegate, we can just get rid of
-        //  the fail callback and the following logic altogether.
-        mAtLeastOneLoaded |= loadedBitmap != null;
-        boolean wholeBatchFailed = !mAtLeastOneLoaded;
-
-        if (wholeBatchFailed) {
-            mOnFailCallback.run();
-        }
-    }
-}
diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java
deleted file mode 100644
index ff88e5e..0000000
--- a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java
+++ /dev/null
@@ -1,566 +0,0 @@
-/*
- * Copyright (C) 2022 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.intentresolver;
-
-import static java.lang.annotation.RetentionPolicy.SOURCE;
-
-import android.animation.ObjectAnimator;
-import android.animation.ValueAnimator;
-import android.annotation.IntDef;
-import android.content.ClipData;
-import android.content.ContentResolver;
-import android.content.Intent;
-import android.content.res.Resources;
-import android.database.Cursor;
-import android.graphics.Bitmap;
-import android.net.Uri;
-import android.provider.DocumentsContract;
-import android.provider.Downloads;
-import android.provider.OpenableColumns;
-import android.text.TextUtils;
-import android.util.Log;
-import android.util.PluralsMessageFormatter;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.view.ViewStub;
-import android.view.animation.DecelerateInterpolator;
-import android.widget.ImageView;
-import android.widget.TextView;
-
-import androidx.annotation.LayoutRes;
-import androidx.annotation.Nullable;
-
-import com.android.intentresolver.widget.ActionRow;
-import com.android.intentresolver.widget.ImagePreviewView;
-import com.android.intentresolver.widget.RoundedRectImageView;
-import com.android.internal.annotations.VisibleForTesting;
-
-import java.lang.annotation.Retention;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.function.Consumer;
-
-/**
- * Collection of helpers for building the content preview UI displayed in {@link ChooserActivity}.
- *
- * TODO: this "namespace" was pulled out of {@link ChooserActivity} as a bucket of static methods
- * to show that they're one-shot procedures with no dependencies back to {@link ChooserActivity}
- * state other than the delegates that are explicitly provided. There may be more appropriate
- * abstractions (e.g., maybe this can be a "widget" added directly to the view hierarchy to show the
- * appropriate preview), or it may at least be safe (and more convenient) to adopt a more "object
- * oriented" design where the static specifiers are removed and some of the dependencies are cached
- * as ivars when this "class" is initialized.
- */
-public final class ChooserContentPreviewUi {
-    private static final int IMAGE_FADE_IN_MILLIS = 150;
-
-    /**
-     * Delegate to handle background resource loads that are dependencies of content previews.
-     */
-    public interface ContentPreviewCoordinator {
-        /**
-         * Request that an image be loaded in the background and set into a view.
-         *
-         * @param imageUri The {@link Uri} of the image to load.
-         *
-         * TODO: it looks like clients are probably capable of passing the view directly, but the
-         * deferred computation here is a closer match to the legacy model for now.
-         */
-        void loadImage(Uri imageUri, Consumer<Bitmap> callback);
-    }
-
-    /**
-     * Delegate to build the default system action buttons to display in the preview layout, if/when
-     * they're determined to be appropriate for the particular preview we display.
-     * TODO: clarify why action buttons are part of preview logic.
-     */
-    public interface ActionFactory {
-        /** Create an action that copies the share content to the clipboard. */
-        ActionRow.Action createCopyButton();
-
-        /** Create an action that opens the share content in a system-default editor. */
-        @Nullable
-        ActionRow.Action createEditButton();
-
-        /** Create an "Share to Nearby" action. */
-        @Nullable
-        ActionRow.Action createNearbyButton();
-    }
-
-    /**
-     * Testing shim to specify whether a given mime type is considered to be an "image."
-     *
-     * TODO: move away from {@link ChooserActivityOverrideData} as a model to configure our tests,
-     * then migrate {@link ChooserActivity#isImageType(String)} into this class.
-     */
-    public interface ImageMimeTypeClassifier {
-        /** @return whether the specified {@code mimeType} is classified as an "image" type. */
-        boolean isImageType(String mimeType);
-    }
-
-    @Retention(SOURCE)
-    @IntDef({CONTENT_PREVIEW_FILE, CONTENT_PREVIEW_IMAGE, CONTENT_PREVIEW_TEXT})
-    private @interface ContentPreviewType {
-    }
-
-    // Starting at 1 since 0 is considered "undefined" for some of the database transformations
-    // of tron logs.
-    @VisibleForTesting
-    public static final int CONTENT_PREVIEW_IMAGE = 1;
-    @VisibleForTesting
-    public static final int CONTENT_PREVIEW_FILE = 2;
-    @VisibleForTesting
-    public static final int CONTENT_PREVIEW_TEXT = 3;
-
-    private static final String TAG = "ChooserPreview";
-
-    private static final String PLURALS_COUNT  = "count";
-    private static final String PLURALS_FILE_NAME = "file_name";
-
-    /** Determine the most appropriate type of preview to show for the provided {@link Intent}. */
-    @ContentPreviewType
-    public static int findPreferredContentPreview(
-            Intent targetIntent,
-            ContentResolver resolver,
-            ImageMimeTypeClassifier imageClassifier) {
-        /* In {@link android.content.Intent#getType}, the app may specify a very general mime type
-         * that broadly covers all data being shared, such as {@literal *}/* when sending an image
-         * and text. We therefore should inspect each item for the preferred type, in order: IMAGE,
-         * FILE, TEXT.  */
-        String action = targetIntent.getAction();
-        if (Intent.ACTION_SEND.equals(action)) {
-            Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM);
-            return findPreferredContentPreview(uri, resolver, imageClassifier);
-        } else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
-            List<Uri> uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
-            if (uris == null || uris.isEmpty()) {
-                return CONTENT_PREVIEW_TEXT;
-            }
-
-            for (Uri uri : uris) {
-                // Defaulting to file preview when there are mixed image/file types is
-                // preferable, as it shows the user the correct number of items being shared
-                int uriPreviewType = findPreferredContentPreview(uri, resolver, imageClassifier);
-                if (uriPreviewType == CONTENT_PREVIEW_FILE) {
-                    return CONTENT_PREVIEW_FILE;
-                }
-            }
-
-            return CONTENT_PREVIEW_IMAGE;
-        }
-
-        return CONTENT_PREVIEW_TEXT;
-    }
-
-    /**
-     * Display a content preview of the specified {@code previewType} to preview the content of the
-     * specified {@code intent}.
-     */
-    public static ViewGroup displayContentPreview(
-            @ContentPreviewType int previewType,
-            Intent targetIntent,
-            Resources resources,
-            LayoutInflater layoutInflater,
-            ActionFactory actionFactory,
-            @LayoutRes int actionRowLayout,
-            ViewGroup parent,
-            ContentPreviewCoordinator previewCoord,
-            Consumer<Boolean> onTransitionTargetReady,
-            ContentResolver contentResolver,
-            ImageMimeTypeClassifier imageClassifier) {
-        ViewGroup layout = null;
-
-        switch (previewType) {
-            case CONTENT_PREVIEW_TEXT:
-                layout = displayTextContentPreview(
-                        targetIntent,
-                        layoutInflater,
-                        createTextPreviewActions(actionFactory),
-                        parent,
-                        previewCoord,
-                        actionRowLayout);
-                break;
-            case CONTENT_PREVIEW_IMAGE:
-                layout = displayImageContentPreview(
-                        targetIntent,
-                        layoutInflater,
-                        createImagePreviewActions(actionFactory),
-                        parent,
-                        previewCoord,
-                        onTransitionTargetReady,
-                        contentResolver,
-                        imageClassifier,
-                        actionRowLayout);
-                break;
-            case CONTENT_PREVIEW_FILE:
-                layout = displayFileContentPreview(
-                        targetIntent,
-                        resources,
-                        layoutInflater,
-                        createFilePreviewActions(actionFactory),
-                        parent,
-                        previewCoord,
-                        contentResolver,
-                        actionRowLayout);
-                break;
-            default:
-                Log.e(TAG, "Unexpected content preview type: " + previewType);
-        }
-
-        return layout;
-    }
-
-    private static Cursor queryResolver(ContentResolver resolver, Uri uri) {
-        return resolver.query(uri, null, null, null, null);
-    }
-
-    @ContentPreviewType
-    private static int findPreferredContentPreview(
-            Uri uri, ContentResolver resolver, ImageMimeTypeClassifier imageClassifier) {
-        if (uri == null) {
-            return CONTENT_PREVIEW_TEXT;
-        }
-
-        String mimeType = resolver.getType(uri);
-        return imageClassifier.isImageType(mimeType) ? CONTENT_PREVIEW_IMAGE : CONTENT_PREVIEW_FILE;
-    }
-
-    private static ViewGroup displayTextContentPreview(
-            Intent targetIntent,
-            LayoutInflater layoutInflater,
-            List<ActionRow.Action> actions,
-            ViewGroup parent,
-            ContentPreviewCoordinator previewCoord,
-            @LayoutRes int actionRowLayout) {
-        ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate(
-                R.layout.chooser_grid_preview_text, parent, false);
-
-        final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout);
-        if (actionRow != null) {
-            actionRow.setActions(actions);
-        }
-
-        CharSequence sharingText = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT);
-        if (sharingText == null) {
-            contentPreviewLayout
-                    .findViewById(com.android.internal.R.id.content_preview_text_layout)
-                    .setVisibility(View.GONE);
-        } else {
-            TextView textView = contentPreviewLayout.findViewById(
-                    com.android.internal.R.id.content_preview_text);
-            textView.setText(sharingText);
-        }
-
-        String previewTitle = targetIntent.getStringExtra(Intent.EXTRA_TITLE);
-        if (TextUtils.isEmpty(previewTitle)) {
-            contentPreviewLayout
-                    .findViewById(com.android.internal.R.id.content_preview_title_layout)
-                    .setVisibility(View.GONE);
-        } else {
-            TextView previewTitleView = contentPreviewLayout.findViewById(
-                    com.android.internal.R.id.content_preview_title);
-            previewTitleView.setText(previewTitle);
-
-            ClipData previewData = targetIntent.getClipData();
-            Uri previewThumbnail = null;
-            if (previewData != null) {
-                if (previewData.getItemCount() > 0) {
-                    ClipData.Item previewDataItem = previewData.getItemAt(0);
-                    previewThumbnail = previewDataItem.getUri();
-                }
-            }
-
-            ImageView previewThumbnailView = contentPreviewLayout.findViewById(
-                    com.android.internal.R.id.content_preview_thumbnail);
-            if (previewThumbnail == null) {
-                previewThumbnailView.setVisibility(View.GONE);
-            } else {
-                previewCoord.loadImage(
-                        previewThumbnail,
-                        (bitmap) -> updateViewWithImage(
-                                contentPreviewLayout.findViewById(
-                                    com.android.internal.R.id.content_preview_thumbnail),
-                                bitmap));
-            }
-        }
-
-        return contentPreviewLayout;
-    }
-
-    private static List<ActionRow.Action> createTextPreviewActions(ActionFactory actionFactory) {
-        ArrayList<ActionRow.Action> actions = new ArrayList<>(2);
-        actions.add(actionFactory.createCopyButton());
-        ActionRow.Action nearbyAction = actionFactory.createNearbyButton();
-        if (nearbyAction != null) {
-            actions.add(nearbyAction);
-        }
-        return actions;
-    }
-
-    private static ViewGroup displayImageContentPreview(
-            Intent targetIntent,
-            LayoutInflater layoutInflater,
-            List<ActionRow.Action> actions,
-            ViewGroup parent,
-            ContentPreviewCoordinator previewCoord,
-            Consumer<Boolean> onTransitionTargetReady,
-            ContentResolver contentResolver,
-            ImageMimeTypeClassifier imageClassifier,
-            @LayoutRes int actionRowLayout) {
-        ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate(
-                R.layout.chooser_grid_preview_image, parent, false);
-        ImagePreviewView imagePreview = contentPreviewLayout.findViewById(
-                com.android.internal.R.id.content_preview_image_area);
-
-        final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout);
-        if (actionRow != null) {
-            actionRow.setActions(actions);
-        }
-
-        final ImagePreviewImageLoader imageLoader = new ImagePreviewImageLoader(previewCoord);
-        final ArrayList<Uri> imageUris = new ArrayList<>();
-        String action = targetIntent.getAction();
-        if (Intent.ACTION_SEND.equals(action)) {
-            // TODO: why don't we use image classifier in this case as well?
-            Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM);
-            imageUris.add(uri);
-        } else {
-            List<Uri> uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
-            for (Uri uri : uris) {
-                if (imageClassifier.isImageType(contentResolver.getType(uri))) {
-                    imageUris.add(uri);
-                }
-            }
-        }
-
-        if (imageUris.size() == 0) {
-            Log.i(TAG, "Attempted to display image preview area with zero"
-                    + " available images detected in EXTRA_STREAM list");
-            imagePreview.setVisibility(View.GONE);
-            onTransitionTargetReady.accept(false);
-            return contentPreviewLayout;
-        }
-
-        imagePreview.setSharedElementTransitionTarget(
-                ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME,
-                onTransitionTargetReady);
-        imagePreview.setImages(imageUris, imageLoader);
-
-        return contentPreviewLayout;
-    }
-
-    private static List<ActionRow.Action> createImagePreviewActions(
-            ActionFactory buttonFactory) {
-        ArrayList<ActionRow.Action> actions = new ArrayList<>(2);
-        //TODO: add copy action;
-        ActionRow.Action action = buttonFactory.createNearbyButton();
-        if (action != null) {
-            actions.add(action);
-        }
-        action = buttonFactory.createEditButton();
-        if (action != null) {
-            actions.add(action);
-        }
-        return actions;
-    }
-
-    private static ViewGroup displayFileContentPreview(
-            Intent targetIntent,
-            Resources resources,
-            LayoutInflater layoutInflater,
-            List<ActionRow.Action> actions,
-            ViewGroup parent,
-            ContentPreviewCoordinator previewCoord,
-            ContentResolver contentResolver,
-            @LayoutRes int actionRowLayout) {
-        ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate(
-                R.layout.chooser_grid_preview_file, parent, false);
-
-        final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout);
-        if (actionRow != null) {
-            actionRow.setActions(actions);
-        }
-
-        String action = targetIntent.getAction();
-        if (Intent.ACTION_SEND.equals(action)) {
-            Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM);
-            loadFileUriIntoView(uri, contentPreviewLayout, previewCoord, contentResolver);
-        } else {
-            List<Uri> uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
-            int uriCount = uris.size();
-
-            if (uriCount == 0) {
-                contentPreviewLayout.setVisibility(View.GONE);
-                Log.i(TAG,
-                        "Appears to be no uris available in EXTRA_STREAM, removing "
-                                + "preview area");
-                return contentPreviewLayout;
-            } else if (uriCount == 1) {
-                loadFileUriIntoView(
-                        uris.get(0), contentPreviewLayout, previewCoord, contentResolver);
-            } else {
-                FileInfo fileInfo = extractFileInfo(uris.get(0), contentResolver);
-                int remUriCount = uriCount - 1;
-                Map<String, Object> arguments = new HashMap<>();
-                arguments.put(PLURALS_COUNT, remUriCount);
-                arguments.put(PLURALS_FILE_NAME, fileInfo.name);
-                String fileName =
-                        PluralsMessageFormatter.format(resources, arguments, R.string.file_count);
-
-                TextView fileNameView = contentPreviewLayout.findViewById(
-                        com.android.internal.R.id.content_preview_filename);
-                fileNameView.setText(fileName);
-
-                View thumbnailView = contentPreviewLayout.findViewById(
-                        com.android.internal.R.id.content_preview_file_thumbnail);
-                thumbnailView.setVisibility(View.GONE);
-
-                ImageView fileIconView = contentPreviewLayout.findViewById(
-                        com.android.internal.R.id.content_preview_file_icon);
-                fileIconView.setVisibility(View.VISIBLE);
-                fileIconView.setImageResource(R.drawable.ic_file_copy);
-            }
-        }
-
-        return contentPreviewLayout;
-    }
-
-    private static List<ActionRow.Action> createFilePreviewActions(ActionFactory actionFactory) {
-        List<ActionRow.Action> actions = new ArrayList<>(1);
-        //TODO(b/120417119):
-        // add action buttonFactory.createCopyButton()
-        ActionRow.Action action = actionFactory.createNearbyButton();
-        if (action != null) {
-            actions.add(action);
-        }
-        return actions;
-    }
-
-    private static ActionRow inflateActionRow(ViewGroup parent, @LayoutRes int actionRowLayout) {
-        final ViewStub stub = parent.findViewById(com.android.intentresolver.R.id.action_row_stub);
-        if (stub != null) {
-            stub.setLayoutResource(actionRowLayout);
-            stub.inflate();
-        }
-        return parent.findViewById(com.android.internal.R.id.chooser_action_row);
-    }
-
-    private static void logContentPreviewWarning(Uri uri) {
-        // The ContentResolver already logs the exception. Log something more informative.
-        Log.w(TAG, "Could not load (" + uri.toString() + ") thumbnail/name for preview. If "
-                + "desired, consider using Intent#createChooser to launch the ChooserActivity, "
-                + "and set your Intent's clipData and flags in accordance with that method's "
-                + "documentation");
-    }
-
-    private static void loadFileUriIntoView(
-            final Uri uri,
-            final View parent,
-            final ContentPreviewCoordinator previewCoord,
-            final ContentResolver contentResolver) {
-        FileInfo fileInfo = extractFileInfo(uri, contentResolver);
-
-        TextView fileNameView = parent.findViewById(
-                com.android.internal.R.id.content_preview_filename);
-        fileNameView.setText(fileInfo.name);
-
-        if (fileInfo.hasThumbnail) {
-            previewCoord.loadImage(
-                    uri,
-                    (bitmap) -> updateViewWithImage(
-                            parent.findViewById(
-                                    com.android.internal.R.id.content_preview_file_thumbnail),
-                            bitmap));
-        } else {
-            View thumbnailView = parent.findViewById(
-                    com.android.internal.R.id.content_preview_file_thumbnail);
-            thumbnailView.setVisibility(View.GONE);
-
-            ImageView fileIconView = parent.findViewById(
-                    com.android.internal.R.id.content_preview_file_icon);
-            fileIconView.setVisibility(View.VISIBLE);
-            fileIconView.setImageResource(R.drawable.chooser_file_generic);
-        }
-    }
-
-    private static void updateViewWithImage(RoundedRectImageView imageView, Bitmap image) {
-        if (image == null) {
-            imageView.setVisibility(View.GONE);
-            return;
-        }
-        imageView.setVisibility(View.VISIBLE);
-        imageView.setAlpha(0.0f);
-        imageView.setImageBitmap(image);
-
-        ValueAnimator fadeAnim = ObjectAnimator.ofFloat(imageView, "alpha", 0.0f, 1.0f);
-        fadeAnim.setInterpolator(new DecelerateInterpolator(1.0f));
-        fadeAnim.setDuration(IMAGE_FADE_IN_MILLIS);
-        fadeAnim.start();
-    }
-
-    private static FileInfo extractFileInfo(Uri uri, ContentResolver resolver) {
-        String fileName = null;
-        boolean hasThumbnail = false;
-
-        try (Cursor cursor = queryResolver(resolver, uri)) {
-            if (cursor != null && cursor.getCount() > 0) {
-                int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
-                int titleIndex = cursor.getColumnIndex(Downloads.Impl.COLUMN_TITLE);
-                int flagsIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_FLAGS);
-
-                cursor.moveToFirst();
-                if (nameIndex != -1) {
-                    fileName = cursor.getString(nameIndex);
-                } else if (titleIndex != -1) {
-                    fileName = cursor.getString(titleIndex);
-                }
-
-                if (flagsIndex != -1) {
-                    hasThumbnail = (cursor.getInt(flagsIndex)
-                            & DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL) != 0;
-                }
-            }
-        } catch (SecurityException | NullPointerException e) {
-            logContentPreviewWarning(uri);
-        }
-
-        if (TextUtils.isEmpty(fileName)) {
-            fileName = uri.getPath();
-            int index = fileName.lastIndexOf('/');
-            if (index != -1) {
-                fileName = fileName.substring(index + 1);
-            }
-        }
-
-        return new FileInfo(fileName, hasThumbnail);
-    }
-
-    private static class FileInfo {
-        public final String name;
-        public final boolean hasThumbnail;
-
-        FileInfo(String name, boolean hasThumbnail) {
-            this.name = name;
-            this.hasThumbnail = hasThumbnail;
-        }
-    }
-
-    private ChooserContentPreviewUi() {}
-}
diff --git a/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java b/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java
new file mode 100644
index 0000000..5fbf03a
--- /dev/null
+++ b/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver;
+
+import android.annotation.Nullable;
+import android.content.ComponentName;
+import android.content.Context;
+import android.provider.Settings;
+import android.text.TextUtils;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+/**
+ * Helper to look up the components available on this device to handle assorted built-in actions
+ * like "Edit" that may be displayed for certain content/preview types. The components are queried
+ * when this record is instantiated, and are then immutable for a given instance.
+ *
+ * Because this describes the app's external execution environment, test methods may prefer to
+ * provide explicit values to override the default lookup logic.
+ */
+public class ChooserIntegratedDeviceComponents {
+    @Nullable
+    private final ComponentName mEditSharingComponent;
+
+    @Nullable
+    private final ComponentName mNearbySharingComponent;
+
+    /** Look up the integrated components available on this device. */
+    public static ChooserIntegratedDeviceComponents get(
+            Context context,
+            SecureSettings secureSettings) {
+        return new ChooserIntegratedDeviceComponents(
+                getEditSharingComponent(context),
+                getNearbySharingComponent(context, secureSettings));
+    }
+
+    @VisibleForTesting
+    ChooserIntegratedDeviceComponents(
+            ComponentName editSharingComponent, ComponentName nearbySharingComponent) {
+        mEditSharingComponent = editSharingComponent;
+        mNearbySharingComponent = nearbySharingComponent;
+    }
+
+    public ComponentName getEditSharingComponent() {
+        return mEditSharingComponent;
+    }
+
+    public ComponentName getNearbySharingComponent() {
+        return mNearbySharingComponent;
+    }
+
+    private static ComponentName getEditSharingComponent(Context context) {
+        String editorComponent = context.getApplicationContext().getString(
+                R.string.config_systemImageEditor);
+        return TextUtils.isEmpty(editorComponent)
+                ? null : ComponentName.unflattenFromString(editorComponent);
+    }
+
+    private static ComponentName getNearbySharingComponent(Context context,
+            SecureSettings secureSettings) {
+        String nearbyComponent = secureSettings.getString(
+                context.getContentResolver(), Settings.Secure.NEARBY_SHARING_COMPONENT);
+        if (TextUtils.isEmpty(nearbyComponent)) {
+            nearbyComponent = context.getString(R.string.config_defaultNearbySharingComponent);
+        }
+        return TextUtils.isEmpty(nearbyComponent)
+                ? null : ComponentName.unflattenFromString(nearbyComponent);
+    }
+}
diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java
index 699190f..f065136 100644
--- a/java/src/com/android/intentresolver/ChooserListAdapter.java
+++ b/java/src/com/android/intentresolver/ChooserListAdapter.java
@@ -49,7 +49,6 @@
 
 import androidx.annotation.WorkerThread;
 
-import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo;
 import com.android.intentresolver.chooser.DisplayResolveInfo;
 import com.android.intentresolver.chooser.MultiDisplayResolveInfo;
 import com.android.intentresolver.chooser.NotSelectableTargetInfo;
@@ -264,7 +263,7 @@
         }
 
         holder.bindLabel(info.getDisplayLabel(), info.getExtendedInfo(), alwaysShowSubLabel());
-        holder.bindIcon(info);
+        holder.bindIcon(info, /*animate =*/ true);
         if (info.isSelectableTargetInfo()) {
             // direct share targets should append the application name for a better readout
             DisplayResolveInfo rInfo = info.getDisplayResolveInfo();
diff --git a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java
index 39d1fab..3e2ea47 100644
--- a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java
+++ b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java
@@ -48,7 +48,7 @@
             Context context,
             ChooserGridAdapter adapter,
             EmptyStateProvider emptyStateProvider,
-            QuietModeManager quietModeManager,
+            Supplier<Boolean> workProfileQuietModeChecker,
             UserHandle workProfileUserHandle,
             int maxTargetsPerRow) {
         this(
@@ -56,7 +56,7 @@
                 new ChooserProfileAdapterBinder(maxTargetsPerRow),
                 ImmutableList.of(adapter),
                 emptyStateProvider,
-                quietModeManager,
+                workProfileQuietModeChecker,
                 /* defaultProfile= */ 0,
                 workProfileUserHandle,
                 new BottomPaddingOverrideSupplier(context));
@@ -67,7 +67,7 @@
             ChooserGridAdapter personalAdapter,
             ChooserGridAdapter workAdapter,
             EmptyStateProvider emptyStateProvider,
-            QuietModeManager quietModeManager,
+            Supplier<Boolean> workProfileQuietModeChecker,
             @Profile int defaultProfile,
             UserHandle workProfileUserHandle,
             int maxTargetsPerRow) {
@@ -76,7 +76,7 @@
                 new ChooserProfileAdapterBinder(maxTargetsPerRow),
                 ImmutableList.of(personalAdapter, workAdapter),
                 emptyStateProvider,
-                quietModeManager,
+                workProfileQuietModeChecker,
                 defaultProfile,
                 workProfileUserHandle,
                 new BottomPaddingOverrideSupplier(context));
@@ -87,7 +87,7 @@
             ChooserProfileAdapterBinder adapterBinder,
             ImmutableList<ChooserGridAdapter> gridAdapters,
             EmptyStateProvider emptyStateProvider,
-            QuietModeManager quietModeManager,
+            Supplier<Boolean> workProfileQuietModeChecker,
             @Profile int defaultProfile,
             UserHandle workProfileUserHandle,
             BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) {
@@ -97,7 +97,7 @@
                 adapterBinder,
                 gridAdapters,
                 emptyStateProvider,
-                quietModeManager,
+                workProfileQuietModeChecker,
                 defaultProfile,
                 workProfileUserHandle,
                         () -> makeProfileView(context),
diff --git a/java/src/com/android/intentresolver/ChooserRefinementManager.java b/java/src/com/android/intentresolver/ChooserRefinementManager.java
new file mode 100644
index 0000000..3ddc1c7
--- /dev/null
+++ b/java/src/com/android/intentresolver/ChooserRefinementManager.java
@@ -0,0 +1,194 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver;
+
+import android.annotation.Nullable;
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentSender;
+import android.content.IntentSender.SendIntentException;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.os.ResultReceiver;
+import android.util.Log;
+
+import com.android.intentresolver.chooser.TargetInfo;
+
+import java.util.List;
+import java.util.function.Consumer;
+
+/**
+ * Helper class to manage Sharesheet's "refinement" flow, where callers supply a "refinement
+ * activity" that will be invoked when a target is selected, allowing the calling app to add
+ * additional extras and other refinements (subject to {@link Intent#filterEquals()}), e.g., to
+ * convert the format of the payload, or lazy-download some data that was deferred in the original
+ * call).
+ */
+public final class ChooserRefinementManager {
+    private static final String TAG = "ChooserRefinement";
+
+    @Nullable
+    private final IntentSender mRefinementIntentSender;
+
+    private final Context mContext;
+    private final Consumer<TargetInfo> mOnSelectionRefined;
+    private final Runnable mOnRefinementCancelled;
+
+    @Nullable
+    private RefinementResultReceiver mRefinementResultReceiver;
+
+    public ChooserRefinementManager(
+            Context context,
+            @Nullable IntentSender refinementIntentSender,
+            Consumer<TargetInfo> onSelectionRefined,
+            Runnable onRefinementCancelled) {
+        mContext = context;
+        mRefinementIntentSender = refinementIntentSender;
+        mOnSelectionRefined = onSelectionRefined;
+        mOnRefinementCancelled = onRefinementCancelled;
+    }
+
+    /**
+     * Delegate the user's {@code selectedTarget} to the refinement flow, if possible.
+     * @return true if the selection should wait for a now-started refinement flow, or false if it
+     * can proceed by the default (non-refinement) logic.
+     */
+    public boolean maybeHandleSelection(TargetInfo selectedTarget) {
+        if (mRefinementIntentSender == null) {
+            return false;
+        }
+        if (selectedTarget.getAllSourceIntents().isEmpty()) {
+            return false;
+        }
+
+        destroy();  // Terminate any prior sessions.
+        mRefinementResultReceiver = new RefinementResultReceiver(
+                refinedIntent -> {
+                    destroy();
+                    TargetInfo refinedTarget =
+                            selectedTarget.tryToCloneWithAppliedRefinement(refinedIntent);
+                    if (refinedTarget != null) {
+                        mOnSelectionRefined.accept(refinedTarget);
+                    } else {
+                        Log.e(TAG, "Failed to apply refinement to any matching source intent");
+                        mOnRefinementCancelled.run();
+                    }
+                },
+                mOnRefinementCancelled,
+                mContext.getMainThreadHandler());
+
+        Intent refinementRequest = makeRefinementRequest(mRefinementResultReceiver, selectedTarget);
+        try {
+            mRefinementIntentSender.sendIntent(mContext, 0, refinementRequest, null, null);
+            return true;
+        } catch (SendIntentException e) {
+            Log.e(TAG, "Refinement IntentSender failed to send", e);
+        }
+        return false;
+    }
+
+    /** Clean up any ongoing refinement session. */
+    public void destroy() {
+        if (mRefinementResultReceiver != null) {
+            mRefinementResultReceiver.destroy();
+            mRefinementResultReceiver = null;
+        }
+    }
+
+    private static Intent makeRefinementRequest(
+            RefinementResultReceiver resultReceiver, TargetInfo originalTarget) {
+        final Intent fillIn = new Intent();
+        final List<Intent> sourceIntents = originalTarget.getAllSourceIntents();
+        fillIn.putExtra(Intent.EXTRA_INTENT, sourceIntents.get(0));
+        final int sourceIntentCount = sourceIntents.size();
+        if (sourceIntentCount > 1) {
+            fillIn.putExtra(
+                    Intent.EXTRA_ALTERNATE_INTENTS,
+                    sourceIntents
+                            .subList(1, sourceIntentCount)
+                            .toArray(new Intent[sourceIntentCount - 1]));
+        }
+        fillIn.putExtra(Intent.EXTRA_RESULT_RECEIVER, resultReceiver.copyForSending());
+        return fillIn;
+    }
+
+    private static class RefinementResultReceiver extends ResultReceiver {
+        private final Consumer<Intent> mOnSelectionRefined;
+        private final Runnable mOnRefinementCancelled;
+
+        private boolean mDestroyed;
+
+        RefinementResultReceiver(
+                Consumer<Intent> onSelectionRefined,
+                Runnable onRefinementCancelled,
+                Handler handler) {
+            super(handler);
+            mOnSelectionRefined = onSelectionRefined;
+            mOnRefinementCancelled = onRefinementCancelled;
+        }
+
+        public void destroy() {
+            mDestroyed = true;
+        }
+
+        @Override
+        protected void onReceiveResult(int resultCode, Bundle resultData) {
+            if (mDestroyed) {
+                Log.e(TAG, "Destroyed RefinementResultReceiver received a result");
+                return;
+            }
+            if (resultData == null) {
+                Log.e(TAG, "RefinementResultReceiver received null resultData");
+                // TODO: treat as cancellation?
+                return;
+            }
+
+            switch (resultCode) {
+                case Activity.RESULT_CANCELED:
+                    mOnRefinementCancelled.run();
+                    break;
+                case Activity.RESULT_OK:
+                    Parcelable intentParcelable = resultData.getParcelable(Intent.EXTRA_INTENT);
+                    if (intentParcelable instanceof Intent) {
+                        mOnSelectionRefined.accept((Intent) intentParcelable);
+                    } else {
+                        Log.e(TAG, "No valid Intent.EXTRA_INTENT in 'OK' refinement result data");
+                    }
+                    break;
+                default:
+                    Log.w(TAG, "Received unknown refinement result " + resultCode);
+                    break;
+            }
+        }
+
+        /**
+         * Apps can't load this class directly, so we need a regular ResultReceiver copy for
+         * sending. Obtain this by parceling and unparceling (one weird trick).
+         */
+        ResultReceiver copyForSending() {
+            Parcel parcel = Parcel.obtain();
+            writeToParcel(parcel, 0);
+            parcel.setDataPosition(0);
+            ResultReceiver receiverForSending = ResultReceiver.CREATOR.createFromParcel(parcel);
+            parcel.recycle();
+            return receiverForSending;
+        }
+    }
+}
diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java
index 81481bf..3d99e47 100644
--- a/java/src/com/android/intentresolver/ChooserRequestParameters.java
+++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java
@@ -18,6 +18,7 @@
 
 import android.annotation.NonNull;
 import android.annotation.Nullable;
+import android.app.PendingIntent;
 import android.content.ComponentName;
 import android.content.Intent;
 import android.content.IntentFilter;
@@ -26,11 +27,15 @@
 import android.os.Bundle;
 import android.os.Parcelable;
 import android.os.PatternMatcher;
+import android.service.chooser.ChooserAction;
 import android.service.chooser.ChooserTarget;
 import android.text.TextUtils;
 import android.util.Log;
 import android.util.Pair;
 
+import com.android.intentresolver.flags.FeatureFlagRepository;
+import com.android.intentresolver.flags.Flags;
+
 import com.google.common.collect.ImmutableList;
 
 import java.net.URISyntaxException;
@@ -66,10 +71,14 @@
             Intent.FLAG_ACTIVITY_NEW_DOCUMENT | Intent.FLAG_ACTIVITY_MULTIPLE_TASK;
 
     private final Intent mTarget;
+    private final ChooserIntegratedDeviceComponents mIntegratedDeviceComponents;
+    private final String mReferrerPackageName;
     private final Pair<CharSequence, Integer> mTitleSpec;
     private final Intent mReferrerFillInIntent;
     private final ImmutableList<ComponentName> mFilteredComponentNames;
     private final ImmutableList<ChooserTarget> mCallerChooserTargets;
+    private final @NonNull ImmutableList<ChooserAction> mChooserActions;
+    private final PendingIntent mModifyShareAction;
     private final boolean mRetainInOnStop;
 
     @Nullable
@@ -95,12 +104,18 @@
 
     public ChooserRequestParameters(
             final Intent clientIntent,
+            String referrerPackageName,
             final Uri referrer,
-            @Nullable final ComponentName nearbySharingComponent) {
+            ChooserIntegratedDeviceComponents integratedDeviceComponents,
+            FeatureFlagRepository featureFlags) {
         final Intent requestedTarget = parseTargetIntentExtra(
                 clientIntent.getParcelableExtra(Intent.EXTRA_INTENT));
         mTarget = intentWithModifiedLaunchFlags(requestedTarget);
 
+        mIntegratedDeviceComponents = integratedDeviceComponents;
+
+        mReferrerPackageName = referrerPackageName;
+
         mAdditionalTargets = intentsWithModifiedLaunchFlagsFromExtraIfPresent(
                 clientIntent, Intent.EXTRA_ALTERNATE_INTENTS);
 
@@ -120,7 +135,8 @@
         mRefinementIntentSender = clientIntent.getParcelableExtra(
                 Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER);
 
-        mFilteredComponentNames = getFilteredComponentNames(clientIntent, nearbySharingComponent);
+        mFilteredComponentNames = getFilteredComponentNames(
+                clientIntent, mIntegratedDeviceComponents.getNearbySharingComponent());
 
         mCallerChooserTargets = parseCallerTargetsFromClientIntent(clientIntent);
 
@@ -130,6 +146,13 @@
         mSharedText = mTarget.getStringExtra(Intent.EXTRA_TEXT);
 
         mTargetIntentFilter = getTargetIntentFilter(mTarget);
+
+        mChooserActions = featureFlags.isEnabled(Flags.SHARESHEET_CUSTOM_ACTIONS)
+                ? getChooserActions(clientIntent)
+                : ImmutableList.of();
+        mModifyShareAction = featureFlags.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION)
+                ? getModifyShareAction(clientIntent)
+                : null;
     }
 
     public Intent getTargetIntent() {
@@ -150,6 +173,10 @@
         return getTargetIntent().getType();
     }
 
+    public String getReferrerPackageName() {
+        return mReferrerPackageName;
+    }
+
     @Nullable
     public CharSequence getTitle() {
         return mTitleSpec.first;
@@ -171,8 +198,18 @@
         return mCallerChooserTargets;
     }
 
+    @NonNull
+    public ImmutableList<ChooserAction> getChooserActions() {
+        return mChooserActions;
+    }
+
+    @Nullable
+    public PendingIntent getModifyShareAction() {
+        return mModifyShareAction;
+    }
+
     /**
-     * Whether the {@link ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP} behavior was requested.
+     * Whether the {@link ChooserActivity#EXTRA_PRIVATE_RETAIN_IN_ON_STOP} behavior was requested.
      */
     public boolean shouldRetainInOnStop() {
         return mRetainInOnStop;
@@ -221,6 +258,10 @@
         return mTargetIntentFilter;
     }
 
+    public ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() {
+        return mIntegratedDeviceComponents;
+    }
+
     private static boolean isSendAction(@Nullable String action) {
         return (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action));
     }
@@ -300,6 +341,32 @@
                 .collect(toImmutableList());
     }
 
+    @NonNull
+    private static ImmutableList<ChooserAction> getChooserActions(Intent intent) {
+        return streamParcelableArrayExtra(
+                intent,
+                Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS,
+                ChooserAction.class,
+                true,
+                true)
+            .collect(toImmutableList());
+    }
+
+    @Nullable
+    private static PendingIntent getModifyShareAction(Intent intent) {
+        try {
+            return intent.getParcelableExtra(
+                    Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION,
+                    PendingIntent.class);
+        } catch (Throwable t) {
+            Log.w(
+                    TAG,
+                    "Unable to retrieve Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION argument",
+                    t);
+            return null;
+        }
+    }
+
     private static <T> Collector<T, ?, ImmutableList<T>> toImmutableList() {
         return Collectors.collectingAndThen(Collectors.toList(), ImmutableList::copyOf);
     }
diff --git a/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt b/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt
index a0bf61b..b1178aa 100644
--- a/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt
+++ b/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt
@@ -15,23 +15,31 @@
  */
 package com.android.intentresolver
 
-import android.app.Activity
 import android.app.SharedElementCallback
 import android.view.View
-import com.android.intentresolver.widget.ResolverDrawerLayout
+import androidx.activity.ComponentActivity
+import androidx.lifecycle.lifecycleScope
+import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback
+import com.android.internal.annotations.VisibleForTesting
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
 import java.util.function.Supplier
 
 /**
  * A helper class to track app's readiness for the scene transition animation.
  * The app is ready when both the image is laid out and the drawer offset is calculated.
  */
-internal class EnterTransitionAnimationDelegate(
-    private val activity: Activity,
-    private val resolverDrawerLayoutSupplier: Supplier<ResolverDrawerLayout?>
-) : View.OnLayoutChangeListener {
-    private var removeSharedElements = false
+@VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)
+class EnterTransitionAnimationDelegate(
+    private val activity: ComponentActivity,
+    private val transitionTargetSupplier: Supplier<View?>,
+) : View.OnLayoutChangeListener, TransitionElementStatusCallback {
+
+    private val transitionElements = HashSet<String>()
     private var previewReady = false
     private var offsetCalculated = false
+    private var timeoutJob: Job? = null
 
     init {
         activity.setEnterSharedElementCallback(
@@ -46,12 +54,27 @@
             })
     }
 
-    fun postponeTransition() = activity.postponeEnterTransition()
-
-    fun markImagePreviewReady(runTransitionAnimation: Boolean) {
-        if (!runTransitionAnimation) {
-            removeSharedElements = true
+    fun postponeTransition() {
+        activity.postponeEnterTransition()
+        timeoutJob = activity.lifecycleScope.launch {
+            delay(activity.resources.getInteger(R.integer.config_shortAnimTime).toLong())
+            onTimeout()
         }
+    }
+
+    private fun onTimeout() {
+        // We only mark the preview readiness and not the offset readiness
+        // (see [#markOffsetCalculated()]) as this is what legacy logic, effectively, did. We might
+        // want to review that aspect separately.
+        onAllTransitionElementsReady()
+    }
+
+    override fun onTransitionElementReady(name: String) {
+        transitionElements.add(name)
+    }
+
+    override fun onAllTransitionElementsReady() {
+        timeoutJob?.cancel()
         if (!previewReady) {
             previewReady = true
             maybeStartListenForLayout()
@@ -69,15 +92,12 @@
         names: MutableList<String>,
         sharedElements: MutableMap<String, View>
     ) {
-        if (removeSharedElements) {
-            names.remove(ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME)
-            sharedElements.remove(ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME)
-        }
-        removeSharedElements = false
+        names.removeAll { !transitionElements.contains(it) }
+        sharedElements.entries.removeAll { !transitionElements.contains(it.key) }
     }
 
     private fun maybeStartListenForLayout() {
-        val drawer = resolverDrawerLayoutSupplier.get()
+        val drawer = transitionTargetSupplier.get()
         if (previewReady && offsetCalculated && drawer != null) {
             if (drawer.isInLayout) {
                 startPostponedEnterTransition()
@@ -98,7 +118,7 @@
     }
 
     private fun startPostponedEnterTransition() {
-        if (!removeSharedElements && activity.isActivityTransitionRunning) {
+        if (transitionElements.isNotEmpty() && activity.isActivityTransitionRunning) {
             // Disable the window animations as it interferes with the transition animation.
             activity.window.setWindowAnimations(0)
         }
diff --git a/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java
index 9bbdf7c..7613f35 100644
--- a/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java
+++ b/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java
@@ -81,7 +81,7 @@
             AdapterBinder<PageViewT, SinglePageAdapterT> adapterBinder,
             ImmutableList<SinglePageAdapterT> adapters,
             EmptyStateProvider emptyStateProvider,
-            QuietModeManager quietModeManager,
+            Supplier<Boolean> workProfileQuietModeChecker,
             @Profile int defaultProfile,
             UserHandle workProfileUserHandle,
             Supplier<ViewGroup> pageViewInflater,
@@ -90,7 +90,7 @@
                 context,
                 /* currentPage= */ defaultProfile,
                 emptyStateProvider,
-                quietModeManager,
+                workProfileQuietModeChecker,
                 workProfileUserHandle);
 
         mListAdapterExtractor = listAdapterExtractor;
diff --git a/java/src/com/android/intentresolver/ImageLoader.kt b/java/src/com/android/intentresolver/ImageLoader.kt
new file mode 100644
index 0000000..0ed8b12
--- /dev/null
+++ b/java/src/com/android/intentresolver/ImageLoader.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver
+
+import android.graphics.Bitmap
+import android.net.Uri
+import java.util.function.Consumer
+
+interface ImageLoader : suspend (Uri) -> Bitmap? {
+    fun loadImage(uri: Uri, callback: Consumer<Bitmap?>)
+    fun prePopulate(uris: List<Uri>)
+}
diff --git a/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt
index e68eb66..7b6651a 100644
--- a/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt
+++ b/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt
@@ -16,23 +16,72 @@
 
 package com.android.intentresolver
 
+import android.content.Context
 import android.graphics.Bitmap
 import android.net.Uri
-import kotlinx.coroutines.suspendCancellableCoroutine
+import android.util.Size
+import androidx.annotation.GuardedBy
+import androidx.annotation.VisibleForTesting
+import androidx.collection.LruCache
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.coroutineScope
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import java.util.function.Consumer
 
-// TODO: convert ChooserContentPreviewCoordinator to Kotlin and merge this class into it.
-internal class ImagePreviewImageLoader(
-    private val previewCoordinator: ChooserContentPreviewUi.ContentPreviewCoordinator
-) : suspend (Uri) -> Bitmap? {
+@VisibleForTesting
+class ImagePreviewImageLoader @JvmOverloads constructor(
+    private val context: Context,
+    private val lifecycle: Lifecycle,
+    cacheSize: Int,
+    private val dispatcher: CoroutineDispatcher = Dispatchers.IO
+) : ImageLoader {
 
-    override suspend fun invoke(uri: Uri): Bitmap? =
-        suspendCancellableCoroutine { continuation ->
-            val callback = java.util.function.Consumer<Bitmap?> { bitmap ->
-                try {
-                    continuation.resumeWith(Result.success(bitmap))
-                } catch (ignored: Exception) {
+    private val thumbnailSize: Size =
+        context.resources.getDimensionPixelSize(R.dimen.chooser_preview_image_max_dimen).let {
+            Size(it, it)
+        }
+
+    @GuardedBy("self")
+    private val cache = LruCache<Uri, CompletableDeferred<Bitmap?>>(cacheSize)
+
+    override suspend fun invoke(uri: Uri): Bitmap? = loadImageAsync(uri)
+
+    override fun loadImage(uri: Uri, callback: Consumer<Bitmap?>) {
+        lifecycle.coroutineScope.launch {
+            val image = loadImageAsync(uri)
+            if (isActive) {
+                callback.accept(image)
+            }
+        }
+    }
+
+    override fun prePopulate(uris: List<Uri>) {
+        uris.asSequence().take(cache.maxSize()).forEach { uri ->
+            lifecycle.coroutineScope.launch {
+                loadImageAsync(uri)
+            }
+        }
+    }
+
+    private suspend fun loadImageAsync(uri: Uri): Bitmap? {
+        return synchronized(cache) {
+            cache.get(uri) ?: CompletableDeferred<Bitmap?>().also { result ->
+                cache.put(uri, result)
+                lifecycle.coroutineScope.launch(dispatcher) {
+                    result.loadBitmap(uri)
                 }
             }
-            previewCoordinator.loadImage(uri, callback)
-        }
+        }.await()
+    }
+
+    private fun CompletableDeferred<Bitmap?>.loadBitmap(uri: Uri) {
+        val bitmap = runCatching {
+            context.contentResolver.loadThumbnail(uri,  thumbnailSize, null)
+        }.getOrNull()
+        complete(bitmap)
+    }
 }
diff --git a/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java b/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java
index 5bf994d..c1373f4 100644
--- a/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java
+++ b/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java
@@ -101,9 +101,9 @@
         if (mWorkProfileUserHandle == null) {
             return false;
         }
-        List<ResolverActivity.ResolvedComponentInfo> resolversForIntent =
+        List<ResolvedComponentInfo> resolversForIntent =
                 adapter.getResolversForUser(UserHandle.of(mMyUserIdProvider.getMyUserId()));
-        for (ResolverActivity.ResolvedComponentInfo info : resolversForIntent) {
+        for (ResolvedComponentInfo info : resolversForIntent) {
             ResolveInfo resolveInfo = info.getResolveInfoAt(0);
             if (resolveInfo.targetUserId != UserHandle.USER_CURRENT) {
                 return true;
@@ -151,4 +151,4 @@
                     .write();
         }
     }
-}
\ No newline at end of file
+}
diff --git a/java/src/com/android/intentresolver/ResolvedComponentInfo.java b/java/src/com/android/intentresolver/ResolvedComponentInfo.java
new file mode 100644
index 0000000..ecb72cb
--- /dev/null
+++ b/java/src/com/android/intentresolver/ResolvedComponentInfo.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver;
+
+import android.content.ComponentName;
+import android.content.Intent;
+import android.content.pm.ResolveInfo;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Record type to store all resolutions that are deduped to a single target component, along with
+ * other metadata about the component (which applies to all of the resolutions in the record).
+ * This record is assembled when we're first processing resolutions, and then later it's used to
+ * derive the {@link TargetInfo} record(s) that specify how the resolutions will be presented as
+ * targets in the UI.
+ */
+public final class ResolvedComponentInfo {
+    public final ComponentName name;
+    private final List<Intent> mIntents = new ArrayList<>();
+    private final List<ResolveInfo> mResolveInfos = new ArrayList<>();
+    private boolean mPinned;
+
+    /**
+     * @param name the name of the component that owns all the resolutions added to this record.
+     * @param intent an initial {@link Intent} to add to this record
+     * @param info the {@link ResolveInfo} associated with the given {@code intent}.
+     */
+    public ResolvedComponentInfo(ComponentName name, Intent intent, ResolveInfo info) {
+        this.name = name;
+        add(intent, info);
+    }
+
+    /**
+     * Add an {@link Intent} and associated {@link ResolveInfo} as resolutions for this component.
+     */
+    public void add(Intent intent, ResolveInfo info) {
+        mIntents.add(intent);
+        mResolveInfos.add(info);
+    }
+
+    /** @return the number of {@link Intent}/{@link ResolveInfo} pairs added to this record. */
+    public int getCount() {
+        return mIntents.size();
+    }
+
+    /** @return the {@link Intent} at the specified {@code index}, if any, or else null. */
+    public Intent getIntentAt(int index) {
+        return (index >= 0) ? mIntents.get(index) : null;
+    }
+
+    /** @return the {@link ResolveInfo} at the specified {@code index}, if any, or else null. */
+    public ResolveInfo getResolveInfoAt(int index) {
+        return (index >= 0) ? mResolveInfos.get(index) : null;
+    }
+
+    /**
+     * @return the index of the provided {@link Intent} among those that have been added to this
+     * {@link ResolvedComponentInfo}, or -1 if it has't been added.
+     */
+    public int findIntent(Intent intent) {
+        return mIntents.indexOf(intent);
+    }
+
+    /**
+     * @return the index of the provided {@link ResolveInfo} among those that have been added to
+     * this {@link ResolvedComponentInfo}, or -1 if it has't been added.
+     */
+    public int findResolveInfo(ResolveInfo info) {
+        return mResolveInfos.indexOf(info);
+    }
+
+    /**
+     * @return whether this component was pinned by a call to {@link #setPinned()}.
+     * TODO: consolidate sources of pinning data and/or document how this differs from other places
+     * we make a "pinning" determination.
+     */
+    public boolean isPinned() {
+        return mPinned;
+    }
+
+    /**
+     * Set whether this component will be considered pinned in future calls to {@link #isPinned()}.
+     * TODO: consolidate sources of pinning data and/or document how this differs from other places
+     * we make a "pinning" determination.
+     */
+    public void setPinned(boolean pinned) {
+        mPinned = pinned;
+    }
+}
diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java
index 5573e18..d224299 100644
--- a/java/src/com/android/intentresolver/ResolverActivity.java
+++ b/java/src/com/android/intentresolver/ResolverActivity.java
@@ -44,7 +44,6 @@
 import android.app.VoiceInteractor.Prompt;
 import android.app.admin.DevicePolicyEventLogger;
 import android.app.admin.DevicePolicyManager;
-import android.content.BroadcastReceiver;
 import android.content.ComponentName;
 import android.content.Context;
 import android.content.Intent;
@@ -61,7 +60,6 @@
 import android.graphics.Insets;
 import android.graphics.drawable.Drawable;
 import android.net.Uri;
-import android.os.AsyncTask;
 import android.os.Build;
 import android.os.Bundle;
 import android.os.PatternMatcher;
@@ -105,7 +103,6 @@
 import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider;
 import com.android.intentresolver.AbstractMultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener;
 import com.android.intentresolver.AbstractMultiProfilePagerAdapter.Profile;
-import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeManager;
 import com.android.intentresolver.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState;
 import com.android.intentresolver.chooser.DisplayResolveInfo;
 import com.android.intentresolver.chooser.TargetInfo;
@@ -163,7 +160,6 @@
     protected boolean mSupportsAlwaysUseOption;
     protected ResolverDrawerLayout mResolverDrawerLayout;
     protected PackageManager mPm;
-    protected int mLaunchedFromUid;
 
     private static final String TAG = "ResolverActivity";
     private static final boolean DEBUG = false;
@@ -192,7 +188,7 @@
     @VisibleForTesting
     protected AbstractMultiProfilePagerAdapter mMultiProfilePagerAdapter;
 
-    protected QuietModeManager mQuietModeManager;
+    protected WorkProfileAvailabilityManager mWorkProfileAvailability;
 
     // Intent extra for connected audio devices
     public static final String EXTRA_IS_AUDIO_CAPTURE_DEVICE = "is_audio_capture_device";
@@ -202,7 +198,7 @@
      * <p>Can only be used if there is a work profile.
      * <p>Possible values can be either {@link #PROFILE_PERSONAL} or {@link #PROFILE_WORK}.
      */
-    static final String EXTRA_SELECTED_PROFILE =
+    protected static final String EXTRA_SELECTED_PROFILE =
             "com.android.internal.app.ResolverActivity.EXTRA_SELECTED_PROFILE";
 
     /**
@@ -217,15 +213,20 @@
     static final String EXTRA_CALLING_USER =
             "com.android.internal.app.ResolverActivity.EXTRA_CALLING_USER";
 
-    static final int PROFILE_PERSONAL = AbstractMultiProfilePagerAdapter.PROFILE_PERSONAL;
-    static final int PROFILE_WORK = AbstractMultiProfilePagerAdapter.PROFILE_WORK;
+    protected static final int PROFILE_PERSONAL = AbstractMultiProfilePagerAdapter.PROFILE_PERSONAL;
+    protected static final int PROFILE_WORK = AbstractMultiProfilePagerAdapter.PROFILE_WORK;
 
-    private BroadcastReceiver mWorkProfileStateReceiver;
     private UserHandle mHeaderCreatorUser;
 
-    private Supplier<UserHandle> mLazyWorkProfileUserHandle = () -> {
-        final UserHandle result = fetchWorkProfileUserProfile();
-        mLazyWorkProfileUserHandle = () -> result;
+    // User handle annotations are lazy-initialized to ensure that they're computed exactly once
+    // (even though they can't be computed prior to activity creation).
+    // TODO: use a less ad-hoc pattern for lazy initialization (by switching to Dagger or
+    // introducing a common `LazySingletonSupplier` API, etc), and/or migrate all dependents to a
+    // new component whose lifecycle is limited to the "created" Activity (so that we can just hold
+    // the annotations as a `final` ivar, which is a better way to show immutability).
+    private Supplier<AnnotatedUserHandles> mLazyAnnotatedUserHandles = () -> {
+        final AnnotatedUserHandles result = new AnnotatedUserHandles(this);
+        mLazyAnnotatedUserHandles = () -> result;
         return result;
     };
 
@@ -234,22 +235,6 @@
 
     protected final LatencyTracker mLatencyTracker = getLatencyTracker();
 
-    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;
-    }
-
     private enum ActionTitle {
         VIEW(Intent.ACTION_VIEW,
                 com.android.internal.R.string.whichViewApplication,
@@ -333,27 +318,6 @@
         };
     }
 
-    private Intent makeMyIntent() {
-        Intent intent = new Intent(getIntent());
-        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.getFlags()&~Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS);
-        return intent;
-    }
-
-    /**
-     * Call {@link Activity#onCreate} without initializing anything further. This should
-     * only be used when the activity is about to be immediately finished to avoid wasting
-     * initializing steps and leaking resources.
-     */
-    protected void super_onCreate(Bundle savedInstanceState) {
-        super.onCreate(savedInstanceState);
-    }
-
     @Override
     protected void onCreate(Bundle savedInstanceState) {
         // Use a specialized prompt when we're handling the 'Home' app startActivity()
@@ -389,18 +353,15 @@
         setTheme(appliedThemeResId());
         super.onCreate(savedInstanceState);
 
-        mQuietModeManager = createQuietModeManager();
-
         // Determine whether we should show that intent is forwarded
         // from managed profile to owner or other way around.
         setProfileSwitchMessage(intent.getContentUserHint());
 
-        mLaunchedFromUid = getLaunchedFromUid();
-        if (mLaunchedFromUid < 0 || UserHandle.isIsolated(mLaunchedFromUid)) {
-            // Gulp!
-            finish();
-            return;
-        }
+        // Force computation of user handle annotations in order to validate the caller ID. (See the
+        // associated TODO comment to explain why this is structured as a lazy computation.)
+        AnnotatedUserHandles unusedReferenceToHandles = mLazyAnnotatedUserHandles.get();
+
+        mWorkProfileAvailability = createWorkProfileAvailabilityManager();
 
         mPm = getPackageManager();
 
@@ -490,48 +451,6 @@
         return resolverMultiProfilePagerAdapter;
     }
 
-    @VisibleForTesting
-    protected MyUserIdProvider createMyUserIdProvider() {
-        return new MyUserIdProvider();
-    }
-
-    @VisibleForTesting
-    protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() {
-        return new CrossProfileIntentsChecker(getContentResolver());
-    }
-
-    @VisibleForTesting
-    protected QuietModeManager createQuietModeManager() {
-        UserManager userManager = getSystemService(UserManager.class);
-        return new QuietModeManager() {
-
-            private boolean mIsWaitingToEnableWorkProfile = false;
-
-            @Override
-            public boolean isQuietModeEnabled(UserHandle workProfileUserHandle) {
-                return userManager.isQuietModeEnabled(workProfileUserHandle);
-            }
-
-            @Override
-            public void requestQuietModeEnabled(boolean enabled, UserHandle workProfileUserHandle) {
-                AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> {
-                    userManager.requestQuietModeEnabled(enabled, workProfileUserHandle);
-                });
-                mIsWaitingToEnableWorkProfile = true;
-            }
-
-            @Override
-            public void markWorkProfileEnabledBroadcastReceived() {
-                mIsWaitingToEnableWorkProfile = false;
-            }
-
-            @Override
-            public boolean isWaitingToEnableWorkProfile() {
-                return mIsWaitingToEnableWorkProfile;
-            }
-        };
-    }
-
     protected EmptyStateProvider createBlockerEmptyStateProvider() {
         final boolean shouldShowNoCrossProfileIntentsEmptyState = getUser().equals(getIntentUser());
 
@@ -549,7 +468,8 @@
                         /* defaultSubtitleResource= */
                         R.string.resolver_cant_access_personal_apps_explanation,
                         /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL,
-                        /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_RESOLVER);
+                        /* devicePolicyEventCategory= */
+                                ResolverActivity.METRICS_CATEGORY_RESOLVER);
 
         final AbstractMultiProfilePagerAdapter.EmptyState noPersonalToWorkEmptyState =
                 new DevicePolicyBlockerEmptyState(/* context= */ this,
@@ -559,193 +479,19 @@
                         /* defaultSubtitleResource= */
                         R.string.resolver_cant_access_work_apps_explanation,
                         /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK,
-                        /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_RESOLVER);
+                        /* devicePolicyEventCategory= */
+                                ResolverActivity.METRICS_CATEGORY_RESOLVER);
 
         return new NoCrossProfileEmptyStateProvider(getPersonalProfileUserHandle(),
                 noWorkToPersonalEmptyState, noPersonalToWorkEmptyState,
                 createCrossProfileIntentsChecker(), createMyUserIdProvider());
     }
 
-    protected EmptyStateProvider createEmptyStateProvider(
-            @Nullable UserHandle workProfileUserHandle) {
-        final EmptyStateProvider blockerEmptyStateProvider = createBlockerEmptyStateProvider();
-
-        final EmptyStateProvider workProfileOffEmptyStateProvider =
-                new WorkProfilePausedEmptyStateProvider(this, workProfileUserHandle,
-                        mQuietModeManager,
-                        /* onSwitchOnWorkSelectedListener= */
-                        () -> { if (mOnSwitchOnWorkSelectedListener != null) {
-                            mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected();
-                        }},
-                        getMetricsCategory());
-
-        final EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider(
-                this,
-                workProfileUserHandle,
-                getPersonalProfileUserHandle(),
-                getMetricsCategory(),
-                createMyUserIdProvider()
-        );
-
-        // Return composite provider, the order matters (the higher, the more priority)
-        return new CompositeEmptyStateProvider(
-                blockerEmptyStateProvider,
-                workProfileOffEmptyStateProvider,
-                noAppsEmptyStateProvider
-        );
-    }
-
-    private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForOneProfile(
-            Intent[] initialIntents,
-            List<ResolveInfo> rList, boolean filterLastUsed) {
-        ResolverListAdapter adapter = createResolverListAdapter(
-                /* context */ this,
-                /* payloadIntents */ mIntents,
-                initialIntents,
-                rList,
-                filterLastUsed,
-                /* userHandle */ UserHandle.of(UserHandle.myUserId()));
-        QuietModeManager quietModeManager = createQuietModeManager();
-        return new ResolverMultiProfilePagerAdapter(
-                /* context */ this,
-                adapter,
-                createEmptyStateProvider(/* workProfileUserHandle= */ null),
-                quietModeManager,
-                /* workProfileUserHandle= */ null);
-    }
-
-    private UserHandle getIntentUser() {
-        return getIntent().hasExtra(EXTRA_CALLING_USER)
-                ? getIntent().getParcelableExtra(EXTRA_CALLING_USER)
-                : getUser();
-    }
-
-    private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForTwoProfiles(
-            Intent[] initialIntents,
-            List<ResolveInfo> rList,
-            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.
-        int selectedProfile = getCurrentProfile();
-        UserHandle intentUser = getIntentUser();
-        if (!getUser().equals(intentUser)) {
-            if (getPersonalProfileUserHandle().equals(intentUser)) {
-                selectedProfile = PROFILE_PERSONAL;
-            } else if (getWorkProfileUserHandle().equals(intentUser)) {
-                selectedProfile = PROFILE_WORK;
-            }
-        } else {
-            int selectedProfileExtra = getSelectedProfileExtra();
-            if (selectedProfileExtra != -1) {
-                selectedProfile = selectedProfileExtra;
-            }
-        }
-        // We only show the default app for the profile of the current user. The filterLastUsed
-        // flag determines whether to show a default app and that app is not shown in the
-        // resolver list. So filterLastUsed should be false for the other profile.
-        ResolverListAdapter personalAdapter = createResolverListAdapter(
-                /* context */ this,
-                /* payloadIntents */ mIntents,
-                selectedProfile == PROFILE_PERSONAL ? initialIntents : null,
-                rList,
-                (filterLastUsed && UserHandle.myUserId()
-                        == getPersonalProfileUserHandle().getIdentifier()),
-                /* userHandle */ getPersonalProfileUserHandle());
-        UserHandle workProfileUserHandle = getWorkProfileUserHandle();
-        ResolverListAdapter workAdapter = createResolverListAdapter(
-                /* context */ this,
-                /* payloadIntents */ mIntents,
-                selectedProfile == PROFILE_WORK ? initialIntents : null,
-                rList,
-                (filterLastUsed && UserHandle.myUserId()
-                        == workProfileUserHandle.getIdentifier()),
-                /* userHandle */ workProfileUserHandle);
-        QuietModeManager quietModeManager = createQuietModeManager();
-        return new ResolverMultiProfilePagerAdapter(
-                /* context */ this,
-                personalAdapter,
-                workAdapter,
-                createEmptyStateProvider(getWorkProfileUserHandle()),
-                quietModeManager,
-                selectedProfile,
-                getWorkProfileUserHandle());
-    }
-
     protected int appliedThemeResId() {
         return R.style.Theme_DeviceDefault_Resolver;
     }
 
     /**
-     * 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}
-     */
-    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.");
-            }
-        }
-        return selectedProfile;
-    }
-
-    protected @Profile int getCurrentProfile() {
-        return (UserHandle.myUserId() == UserHandle.USER_SYSTEM ? PROFILE_PERSONAL : PROFILE_WORK);
-    }
-
-    protected UserHandle getPersonalProfileUserHandle() {
-        return UserHandle.of(ActivityManager.getCurrentUser());
-    }
-
-    @Nullable
-    protected UserHandle getWorkProfileUserHandle() {
-        return mLazyWorkProfileUserHandle.get();
-    }
-
-    @Nullable
-    private UserHandle fetchWorkProfileUserProfile() {
-        UserManager userManager = getSystemService(UserManager.class);
-        if (userManager == null) {
-            return null;
-        }
-        UserHandle result = null;
-        for (final UserInfo userInfo : userManager.getProfiles(ActivityManager.getCurrentUser())) {
-            if (userInfo.isManagedProfile()) {
-                result = userInfo.getUserHandle();
-            }
-        }
-        return result;
-    }
-
-    private boolean hasWorkProfile() {
-        return getWorkProfileUserHandle() != null;
-    }
-
-    protected boolean shouldShowTabs() {
-        return hasWorkProfile();
-    }
-
-    protected void onProfileClick(View v) {
-        final DisplayResolveInfo dri =
-                mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile();
-        if (dri == null) {
-            return;
-        }
-
-        // Do not show the profile switch message anymore.
-        mProfileSwitchMessage = null;
-
-        onTargetSelected(dri, false);
-        finish();
-    }
-
-    /**
      * Numerous layouts are supported, each with optional ViewGroups.
      * Make sure the inset gets added to the correct View, using
      * a footer for Lists so it can properly scroll under the navbar.
@@ -809,216 +555,10 @@
         }
     }
 
-    private void updateIntentPickerPaddings() {
-        View titleCont = findViewById(com.android.internal.R.id.title_container);
-        titleCont.setPadding(
-                titleCont.getPaddingLeft(),
-                titleCont.getPaddingTop(),
-                titleCont.getPaddingRight(),
-                getResources().getDimensionPixelSize(R.dimen.resolver_title_padding_bottom));
-        View buttonBar = findViewById(com.android.internal.R.id.button_bar);
-        buttonBar.setPadding(
-                buttonBar.getPaddingLeft(),
-                getResources().getDimensionPixelSize(R.dimen.resolver_button_bar_spacing),
-                buttonBar.getPaddingRight(),
-                getResources().getDimensionPixelSize(R.dimen.resolver_button_bar_spacing));
-    }
-
-    @Override // ResolverListCommunicator
-    public void sendVoiceChoicesIfNeeded() {
-        if (!isVoiceInteraction()) {
-            // Clearly not needed.
-            return;
-        }
-
-        int count = mMultiProfilePagerAdapter.getActiveListAdapter().getCount();
-        final Option[] options = new Option[count];
-        for (int i = 0, N = options.length; i < N; i++) {
-            TargetInfo target = mMultiProfilePagerAdapter.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 PickTargetOptionRequest(
-                new Prompt(getTitle()), options, null);
-        getVoiceInteractor().submitRequest(mPickOptionRequest);
-    }
-
-    Option optionForChooserTarget(TargetInfo target, int index) {
-        return new Option(target.getDisplayLabel(), index);
-    }
-
-    protected final void setAdditionalTargets(Intent[] intents) {
-        if (intents != null) {
-            for (Intent intent : intents) {
-                mIntents.add(intent);
-            }
-        }
-    }
-
-    public Intent getTargetIntent() {
-        return mIntents.isEmpty() ? null : mIntents.get(0);
-    }
-
-    protected String getReferrerPackageName() {
-        final Uri referrer = getReferrer();
-        if (referrer != null && "android-app".equals(referrer.getScheme())) {
-            return referrer.getHost();
-        }
-        return null;
-    }
-
     public int getLayoutResource() {
         return R.layout.resolver_list;
     }
 
-    @Override // ResolverListCommunicator
-    public 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);
-        }
-    }
-
-    private void setProfileSwitchMessage(int contentUserHint) {
-        if (contentUserHint != UserHandle.USER_CURRENT &&
-                contentUserHint != UserHandle.myUserId()) {
-            UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE);
-            UserInfo originUserInfo = userManager.getUserInfo(contentUserHint);
-            boolean originIsManaged = originUserInfo != null ? originUserInfo.isManagedProfile()
-                    : false;
-            boolean targetIsManaged = userManager.isManagedProfile();
-            if (originIsManaged && !targetIsManaged) {
-                mProfileSwitchMessage = getForwardToPersonalMsg();
-            } else if (!originIsManaged && targetIsManaged) {
-                mProfileSwitchMessage = getForwardToWorkMsg();
-            }
-        }
-    }
-
-    private String getForwardToPersonalMsg() {
-        return getSystemService(DevicePolicyManager.class).getResources().getString(
-                FORWARD_INTENT_TO_PERSONAL,
-                () -> getString(com.android.internal.R.string.forward_intent_to_owner));
-    }
-
-    private String getForwardToWorkMsg() {
-        return getSystemService(DevicePolicyManager.class).getResources().getString(
-                FORWARD_INTENT_TO_WORK,
-                () -> getString(com.android.internal.R.string.forward_intent_to_work));
-    }
-
-    /**
-     * Turn on launch mode that is safe to use when forwarding intents received from
-     * applications and running in system processes.  This mode uses Activity.startActivityAsCaller
-     * instead of the normal Activity.startActivity for launching the activity selected
-     * by the user.
-     *
-     * <p>This mode is set to true by default if the activity is initialized through
-     * {@link #onCreate(android.os.Bundle)}.  If a subclass calls one of the other onCreate
-     * methods, it is set to false by default.  You must set it before calling one of the
-     * more detailed onCreate methods, so that it will be set correctly in the case where
-     * there is only one intent to resolve and it is thus started immediately.</p>
-     */
-    public void setSafeForwardingMode(boolean safeForwarding) {
-        mSafeForwardingMode = safeForwarding;
-    }
-
-    protected CharSequence getTitleForAction(Intent intent, int defaultTitleRes) {
-        final ActionTitle title = mResolvingHome
-                ? ActionTitle.HOME
-                : 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 =
-                mMultiProfilePagerAdapter.getActiveListAdapter().getFilteredPosition() >= 0;
-        if (title == ActionTitle.DEFAULT && defaultTitleRes != 0) {
-            return getString(defaultTitleRes);
-        } else {
-            return named
-                    ? getString(title.namedTitleRes, mMultiProfilePagerAdapter
-                            .getActiveListAdapter().getFilteredItem().getDisplayLabel())
-                    : getString(title.titleRes);
-        }
-    }
-
-    void dismiss() {
-        if (!isFinishing()) {
-            finish();
-        }
-    }
-
-    @Override
-    protected void onRestart() {
-        super.onRestart();
-        if (!mRegistered) {
-            mPersonalPackageMonitor.register(this, getMainLooper(),
-                    getPersonalProfileUserHandle(), false);
-            if (shouldShowTabs()) {
-                if (mWorkPackageMonitor == null) {
-                    mWorkPackageMonitor = createPackageMonitor(
-                            mMultiProfilePagerAdapter.getWorkListAdapter());
-                }
-                mWorkPackageMonitor.register(this, getMainLooper(),
-                        getWorkProfileUserHandle(), false);
-            }
-            mRegistered = true;
-        }
-        if (shouldShowTabs() && mQuietModeManager.isWaitingToEnableWorkProfile()) {
-            if (mQuietModeManager.isQuietModeEnabled(getWorkProfileUserHandle())) {
-                mQuietModeManager.markWorkProfileEnabledBroadcastReceived();
-            }
-        }
-        mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged();
-        updateProfileViewButton();
-    }
-
-    @Override
-    protected void onStart() {
-        super.onStart();
-
-        this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS);
-        if (shouldShowTabs()) {
-            mWorkProfileStateReceiver = createWorkProfileStateReceiver();
-            registerWorkProfileStateReceiver();
-
-            mWorkProfileHasBeenEnabled = isWorkProfileEnabled();
-        }
-    }
-
-    private boolean isWorkProfileEnabled() {
-        UserHandle workUserHandle = getWorkProfileUserHandle();
-        UserManager userManager = getSystemService(UserManager.class);
-
-        return !userManager.isQuietModeEnabled(workUserHandle)
-                && userManager.isUserUnlocked(workUserHandle);
-    }
-
-    private void registerWorkProfileStateReceiver() {
-        IntentFilter filter = new IntentFilter();
-        filter.addAction(Intent.ACTION_USER_UNLOCKED);
-        filter.addAction(Intent.ACTION_MANAGED_PROFILE_AVAILABLE);
-        filter.addAction(Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE);
-        registerReceiverAsUser(mWorkProfileStateReceiver, UserHandle.ALL, filter, null, null);
-    }
-
     @Override
     protected void onStop() {
         super.onStop();
@@ -1049,10 +589,8 @@
                 finish();
             }
         }
-        if (mWorkPackageMonitor != null) {
-            unregisterReceiver(mWorkProfileStateReceiver);
-            mWorkPackageMonitor = null;
-        }
+        // TODO: should we clean up the work-profile manager before we potentially finish() above?
+        mWorkProfileAvailability.unregisterWorkProfileStateReceiver(this);
     }
 
     @Override
@@ -1067,100 +605,6 @@
         }
     }
 
-    @Override
-    protected 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 void onRestoreInstanceState(Bundle savedInstanceState) {
-        super.onRestoreInstanceState(savedInstanceState);
-        resetButtonBar();
-        ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager);
-        if (viewPager != null) {
-            viewPager.setCurrentItem(savedInstanceState.getInt(LAST_SHOWN_TAB_KEY));
-        }
-        mMultiProfilePagerAdapter.clearInactiveProfileCache();
-    }
-
-    private boolean hasManagedProfile() {
-        UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE);
-        if (userManager == null) {
-            return false;
-        }
-
-        try {
-            List<UserInfo> profiles = userManager.getProfiles(getUserId());
-            for (UserInfo userInfo : profiles) {
-                if (userInfo != null && userInfo.isManagedProfile()) {
-                    return true;
-                }
-            }
-        } catch (SecurityException e) {
-            return false;
-        }
-        return false;
-    }
-
-    private boolean supportsManagedProfiles(ResolveInfo resolveInfo) {
-        try {
-            ApplicationInfo appInfo = getPackageManager().getApplicationInfo(
-                    resolveInfo.activityInfo.packageName, 0 /* default flags */);
-            return appInfo.targetSdkVersion >= Build.VERSION_CODES.LOLLIPOP;
-        } catch (NameNotFoundException e) {
-            return false;
-        }
-    }
-
-    private void setAlwaysButtonEnabled(boolean hasValidSelection, int checkedPos,
-            boolean filtered) {
-        if (!mMultiProfilePagerAdapter.getCurrentUserHandle().equals(getUser())) {
-            // Never allow the inactive profile to always open an app.
-            mAlwaysButton.setEnabled(false);
-            return;
-        }
-        boolean enabled = false;
-        ResolveInfo ri = null;
-        if (hasValidSelection) {
-            ri = mMultiProfilePagerAdapter.getActiveListAdapter()
-                    .resolveInfoForPosition(checkedPos, filtered);
-            if (ri == null) {
-                Log.e(TAG, "Invalid position supplied to setAlwaysButtonEnabled");
-                return;
-            } else if (ri.targetUserId != UserHandle.USER_CURRENT) {
-                Log.e(TAG, "Attempted to set selection to resolve info for another user");
-                return;
-            } else {
-                enabled = true;
-            }
-
-            mAlwaysButton.setText(getResources()
-                    .getString(R.string.activity_resolver_use_always));
-        }
-
-        if (ri != null) {
-            ActivityInfo activityInfo = ri.activityInfo;
-
-            boolean hasRecordPermission =
-                    mPm.checkPermission(android.Manifest.permission.RECORD_AUDIO,
-                            activityInfo.packageName)
-                            == android.content.pm.PackageManager.PERMISSION_GRANTED;
-
-            if (!hasRecordPermission) {
-                // OK, we know the record permission, is this a capture device
-                boolean hasAudioCapture =
-                        getIntent().getBooleanExtra(
-                                ResolverActivity.EXTRA_IS_AUDIO_CAPTURE_DEVICE, false);
-                enabled = !hasAudioCapture;
-            }
-        }
-        mAlwaysButton.setEnabled(enabled);
-    }
-
     public void onButtonClick(View v) {
         final int id = v.getId();
         ListView listView = (ListView) mMultiProfilePagerAdapter.getActiveAdapterView();
@@ -1210,15 +654,6 @@
         }
     }
 
-    private String getWorkProfileNotSupportedMsg(String launcherName) {
-        return getSystemService(DevicePolicyManager.class).getResources().getString(
-                RESOLVER_WORK_PROFILE_NOT_SUPPORTED,
-                () -> getString(
-                        com.android.internal.R.string.activity_resolver_work_profiles_support,
-                        launcherName),
-                launcherName);
-    }
-
     /**
      * Replace me in subclasses!
      */
@@ -1227,33 +662,6 @@
         return defIntent;
     }
 
-    @Override // ResolverListCommunicator
-    public final void onPostListReady(ResolverListAdapter listAdapter, boolean doPostProcessing,
-            boolean rebuildCompleted) {
-        if (isAutolaunching()) {
-            return;
-        }
-        if (mIsIntentPicker) {
-            ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter)
-                    .setUseLayoutWithDefault(useLayoutWithDefault());
-        }
-        if (mMultiProfilePagerAdapter.shouldShowEmptyStateScreen(listAdapter)) {
-            mMultiProfilePagerAdapter.showEmptyResolverListEmptyState(listAdapter);
-        } else {
-            mMultiProfilePagerAdapter.showListView(listAdapter);
-        }
-        // showEmptyResolverListEmptyState can mark the tab as loaded,
-        // which is a precondition for auto launching
-        if (rebuildCompleted && maybeAutolaunchActivity()) {
-            return;
-        }
-        if (doPostProcessing) {
-            maybeCreateHeader(listAdapter);
-            resetButtonBar();
-            onListRebuilt(listAdapter, rebuildCompleted);
-        }
-    }
-
     protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildCompleted) {
         final ItemClickListener listener = new ItemClickListener();
         setupAdapterListView((ListView) mMultiProfilePagerAdapter.getActiveAdapterView(), listener);
@@ -1428,6 +836,185 @@
         return true;
     }
 
+    public void onActivityStarted(TargetInfo cti) {
+        // Do nothing
+    }
+
+    @Override // ResolverListCommunicator
+    public boolean shouldGetActivityMetadata() {
+        return false;
+    }
+
+    public boolean shouldAutoLaunchSingleChoice(TargetInfo target) {
+        return !target.isSuspended();
+    }
+
+    // TODO: this method takes an unused `UserHandle` because the override in `ChooserActivity` uses
+    // that data to set up other components as dependencies of the controller. In reality, these
+    // methods don't require polymorphism, because they're only invoked from within their respective
+    // concrete class; `ResolverActivity` will never call this method expecting to get a
+    // `ChooserListController` (subclass) result, because `ResolverActivity` only invokes this
+    // method as part of handling `createMultiProfilePagerAdapter()`, which is itself overridden in
+    // `ChooserActivity`. A future refactoring could better express the coupling between the adapter
+    // and controller types; in the meantime, structuring as an override (with matching signatures)
+    // shows that these methods are *structurally* related, and helps to prevent any regressions in
+    // the future if resolver *were* to make any (non-overridden) calls to a version that used a
+    // different signature (and thus didn't return the subclass type).
+    @VisibleForTesting
+    protected ResolverListController createListController(UserHandle unused) {
+        return new ResolverListController(
+                this,
+                mPm,
+                getTargetIntent(),
+                getReferrerPackageName(),
+                getAnnotatedUserHandles().userIdOfCallingApp);
+    }
+
+    /**
+     * Finishing procedures to be performed after the list has been rebuilt.
+     * </p>Subclasses must call postRebuildListInternal at the end of postRebuildList.
+     * @param rebuildCompleted
+     * @return <code>true</code> if the activity is finishing and creation should halt.
+     */
+    protected boolean postRebuildList(boolean rebuildCompleted) {
+        return postRebuildListInternal(rebuildCompleted);
+    }
+
+    void onHorizontalSwipeStateChanged(int state) {}
+
+    /**
+     * Callback called when user changes the profile tab.
+     * <p>This 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.
+     */
+    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 (shouldShowTabs()) {
+                textView.setGravity(Gravity.CENTER);
+            }
+            stub.addView(textView);
+        }
+    }
+
+    protected void resetButtonBar() {
+        if (!mSupportsAlwaysUseOption) {
+            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");
+            return;
+        }
+        ResolverListAdapter activeListAdapter =
+                mMultiProfilePagerAdapter.getActiveListAdapter();
+        View buttonBarDivider = findViewById(com.android.internal.R.id.resolver_button_bar_divider);
+        if (!useLayoutWithDefault()) {
+            int inset = mSystemWindowInsets != null ? mSystemWindowInsets.bottom : 0;
+            buttonLayout.setPadding(buttonLayout.getPaddingLeft(), buttonLayout.getPaddingTop(),
+                    buttonLayout.getPaddingRight(), getResources().getDimensionPixelSize(
+                            R.dimen.resolver_button_bar_spacing) + inset);
+        }
+        if (activeListAdapter.isTabLoaded()
+                && mMultiProfilePagerAdapter.shouldShowEmptyStateScreen(activeListAdapter)
+                && !useLayoutWithDefault()) {
+            buttonLayout.setVisibility(View.INVISIBLE);
+            if (buttonBarDivider != null) {
+                buttonBarDivider.setVisibility(View.INVISIBLE);
+            }
+            setButtonBarIgnoreOffset(/* ignoreOffset */ false);
+            return;
+        }
+        if (buttonBarDivider != null) {
+            buttonBarDivider.setVisibility(View.VISIBLE);
+        }
+        buttonLayout.setVisibility(View.VISIBLE);
+        setButtonBarIgnoreOffset(/* ignoreOffset */ true);
+
+        mOnceButton = (Button) buttonLayout.findViewById(com.android.internal.R.id.button_once);
+        mAlwaysButton = (Button) buttonLayout.findViewById(com.android.internal.R.id.button_always);
+
+        resetAlwaysOrOnceButtonBar();
+    }
+
+    protected String getMetricsCategory() {
+        return METRICS_CATEGORY_RESOLVER;
+    }
+
+    @Override // ResolverListCommunicator
+    public void onHandlePackagesChanged(ResolverListAdapter listAdapter) {
+        if (listAdapter == mMultiProfilePagerAdapter.getActiveListAdapter()) {
+            if (listAdapter.getUserHandle().equals(getWorkProfileUserHandle())
+                    && mWorkProfileAvailability.isWaitingToEnableWorkProfile()) {
+                // We have just turned on the work profile and entered the pass code 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;
+            }
+            boolean listRebuilt = mMultiProfilePagerAdapter.rebuildActiveTab(true);
+            if (listRebuilt) {
+                ResolverListAdapter activeListAdapter =
+                        mMultiProfilePagerAdapter.getActiveListAdapter();
+                activeListAdapter.notifyDataSetChanged();
+                if (activeListAdapter.getCount() == 0 && !inactiveListAdapterHasItems()) {
+                    // We no longer have any items...  just finish the activity.
+                    finish();
+                }
+            }
+        } else {
+            mMultiProfilePagerAdapter.clearInactiveProfileCache();
+        }
+    }
+
+    protected void maybeLogProfileChange() {}
+
+    // @NonFinalForTesting
+    @VisibleForTesting
+    protected MyUserIdProvider createMyUserIdProvider() {
+        return new MyUserIdProvider();
+    }
+
+    // @NonFinalForTesting
+    @VisibleForTesting
+    protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() {
+        return new CrossProfileIntentsChecker(getContentResolver());
+    }
+
+    // @NonFinalForTesting
+    @VisibleForTesting
+    protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() {
+        final UserHandle workUser = getWorkProfileUserHandle();
+
+        return new WorkProfileAvailabilityManager(
+                getSystemService(UserManager.class),
+                workUser,
+                () -> {
+                    if (mMultiProfilePagerAdapter.getCurrentUserHandle().equals(workUser)) {
+                        mMultiProfilePagerAdapter.rebuildActiveTab(true);
+                    } else {
+                        mMultiProfilePagerAdapter.clearInactiveProfileCache();
+                    }
+                });
+    }
+
+    // TODO: have tests override `getAnnotatedUserHandles()`, and make this method `final`.
+    // @NonFinalForTesting
+    @Nullable
+    protected UserHandle getWorkProfileUserHandle() {
+        return getAnnotatedUserHandles().workProfileUserHandle;
+    }
+
+    // @NonFinalForTesting
     @VisibleForTesting
     public void safelyStartActivity(TargetInfo cti) {
         // We're dispatching intents that might be coming from legacy apps, so
@@ -1441,17 +1028,565 @@
         }
     }
 
+    // @NonFinalForTesting
+    @VisibleForTesting
+    protected ResolverListAdapter createResolverListAdapter(Context context,
+            List<Intent> payloadIntents, Intent[] initialIntents, List<ResolveInfo> rList,
+            boolean filterLastUsed, UserHandle userHandle) {
+        Intent startIntent = getIntent();
+        boolean isAudioCaptureDevice =
+                startIntent.getBooleanExtra(EXTRA_IS_AUDIO_CAPTURE_DEVICE, false);
+        return new ResolverListAdapter(
+                context,
+                payloadIntents,
+                initialIntents,
+                rList,
+                filterLastUsed,
+                createListController(userHandle),
+                userHandle,
+                getTargetIntent(),
+                this,
+                isAudioCaptureDevice);
+    }
+
+    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();
+
+        final EmptyStateProvider workProfileOffEmptyStateProvider =
+                new WorkProfilePausedEmptyStateProvider(this, workProfileUserHandle,
+                        mWorkProfileAvailability,
+                        /* onSwitchOnWorkSelectedListener= */
+                        () -> {
+                            if (mOnSwitchOnWorkSelectedListener != null) {
+                                mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected();
+                            }
+                        },
+                        getMetricsCategory());
+
+        final EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider(
+                this,
+                workProfileUserHandle,
+                getPersonalProfileUserHandle(),
+                getMetricsCategory(),
+                createMyUserIdProvider()
+        );
+
+        // Return composite provider, the order matters (the higher, the more priority)
+        return new CompositeEmptyStateProvider(
+                blockerEmptyStateProvider,
+                workProfileOffEmptyStateProvider,
+                noAppsEmptyStateProvider
+        );
+    }
+
+    private Intent makeMyIntent() {
+        Intent intent = new Intent(getIntent());
+        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.getFlags() & ~Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS);
+        return intent;
+    }
+
+    /**
+     * Call {@link Activity#onCreate} without initializing anything further. This should
+     * only be used when the activity is about to be immediately finished to avoid wasting
+     * initializing steps and leaking resources.
+     */
+    protected final void super_onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+    }
+
+    private ResolverMultiProfilePagerAdapter
+            createResolverMultiProfilePagerAdapterForOneProfile(
+                    Intent[] initialIntents,
+                    List<ResolveInfo> rList,
+                    boolean filterLastUsed) {
+        ResolverListAdapter adapter = createResolverListAdapter(
+                /* context */ this,
+                /* payloadIntents */ mIntents,
+                initialIntents,
+                rList,
+                filterLastUsed,
+                /* userHandle */ UserHandle.of(UserHandle.myUserId()));
+        return new ResolverMultiProfilePagerAdapter(
+                /* context */ this,
+                adapter,
+                createEmptyStateProvider(/* workProfileUserHandle= */ null),
+                /* workProfileQuietModeChecker= */ () -> false,
+                /* workProfileUserHandle= */ null);
+    }
+
+    private UserHandle getIntentUser() {
+        return getIntent().hasExtra(EXTRA_CALLING_USER)
+                ? getIntent().getParcelableExtra(EXTRA_CALLING_USER)
+                : getUser();
+    }
+
+    private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForTwoProfiles(
+            Intent[] initialIntents,
+            List<ResolveInfo> rList,
+            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.
+        int selectedProfile = getCurrentProfile();
+        UserHandle intentUser = getIntentUser();
+        if (!getUser().equals(intentUser)) {
+            if (getPersonalProfileUserHandle().equals(intentUser)) {
+                selectedProfile = PROFILE_PERSONAL;
+            } else if (getWorkProfileUserHandle().equals(intentUser)) {
+                selectedProfile = PROFILE_WORK;
+            }
+        } else {
+            int selectedProfileExtra = getSelectedProfileExtra();
+            if (selectedProfileExtra != -1) {
+                selectedProfile = selectedProfileExtra;
+            }
+        }
+        // We only show the default app for the profile of the current user. The filterLastUsed
+        // flag determines whether to show a default app and that app is not shown in the
+        // resolver list. So filterLastUsed should be false for the other profile.
+        ResolverListAdapter personalAdapter = createResolverListAdapter(
+                /* context */ this,
+                /* payloadIntents */ mIntents,
+                selectedProfile == PROFILE_PERSONAL ? initialIntents : null,
+                rList,
+                (filterLastUsed && UserHandle.myUserId()
+                        == getPersonalProfileUserHandle().getIdentifier()),
+                /* userHandle */ getPersonalProfileUserHandle());
+        UserHandle workProfileUserHandle = getWorkProfileUserHandle();
+        ResolverListAdapter workAdapter = createResolverListAdapter(
+                /* context */ this,
+                /* payloadIntents */ mIntents,
+                selectedProfile == PROFILE_WORK ? initialIntents : null,
+                rList,
+                (filterLastUsed && UserHandle.myUserId()
+                        == workProfileUserHandle.getIdentifier()),
+                /* userHandle */ workProfileUserHandle);
+        return new ResolverMultiProfilePagerAdapter(
+                /* context */ this,
+                personalAdapter,
+                workAdapter,
+                createEmptyStateProvider(getWorkProfileUserHandle()),
+                () -> mWorkProfileAvailability.isQuietModeEnabled(),
+                selectedProfile,
+                getWorkProfileUserHandle());
+    }
+
+    /**
+     * 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.");
+            }
+        }
+        return selectedProfile;
+    }
+
+    protected final @Profile int getCurrentProfile() {
+        return (UserHandle.myUserId() == UserHandle.USER_SYSTEM ? PROFILE_PERSONAL : PROFILE_WORK);
+    }
+
+    protected final AnnotatedUserHandles getAnnotatedUserHandles() {
+        return mLazyAnnotatedUserHandles.get();
+    }
+
+    protected final UserHandle getPersonalProfileUserHandle() {
+        return getAnnotatedUserHandles().personalProfileUserHandle;
+    }
+
+    private boolean hasWorkProfile() {
+        return getWorkProfileUserHandle() != null;
+    }
+
+    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.
+        mProfileSwitchMessage = null;
+
+        onTargetSelected(dri, false);
+        finish();
+    }
+
+    private void updateIntentPickerPaddings() {
+        View titleCont = findViewById(com.android.internal.R.id.title_container);
+        titleCont.setPadding(
+                titleCont.getPaddingLeft(),
+                titleCont.getPaddingTop(),
+                titleCont.getPaddingRight(),
+                getResources().getDimensionPixelSize(R.dimen.resolver_title_padding_bottom));
+        View buttonBar = findViewById(com.android.internal.R.id.button_bar);
+        buttonBar.setPadding(
+                buttonBar.getPaddingLeft(),
+                getResources().getDimensionPixelSize(R.dimen.resolver_button_bar_spacing),
+                buttonBar.getPaddingRight(),
+                getResources().getDimensionPixelSize(R.dimen.resolver_button_bar_spacing));
+    }
+
+    private void maybeLogCrossProfileTargetLaunch(TargetInfo cti, UserHandle currentUserHandle) {
+        if (!hasWorkProfile() || currentUserHandle.equals(getUser())) {
+            return;
+        }
+        DevicePolicyEventLogger
+                .createEvent(DevicePolicyEnums.RESOLVER_CROSS_PROFILE_TARGET_OPENED)
+                .setBoolean(currentUserHandle.equals(getPersonalProfileUserHandle()))
+                .setStrings(getMetricsCategory(),
+                        cti.isInDirectShareMetricsCategory() ? "direct_share" : "other_target")
+                .write();
+    }
+
+    @Override // ResolverListCommunicator
+    public final void sendVoiceChoicesIfNeeded() {
+        if (!isVoiceInteraction()) {
+            // Clearly not needed.
+            return;
+        }
+
+        int count = mMultiProfilePagerAdapter.getActiveListAdapter().getCount();
+        final Option[] options = new Option[count];
+        for (int i = 0; i < options.length; i++) {
+            TargetInfo target = mMultiProfilePagerAdapter.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 PickTargetOptionRequest(
+                new Prompt(getTitle()), options, null);
+        getVoiceInteractor().submitRequest(mPickOptionRequest);
+    }
+
+    final Option optionForChooserTarget(TargetInfo target, int index) {
+        return new Option(target.getDisplayLabel(), index);
+    }
+
+    protected final void setAdditionalTargets(Intent[] intents) {
+        if (intents != null) {
+            for (Intent intent : intents) {
+                mIntents.add(intent);
+            }
+        }
+    }
+
+    public final Intent getTargetIntent() {
+        return mIntents.isEmpty() ? null : mIntents.get(0);
+    }
+
+    protected final String getReferrerPackageName() {
+        final Uri referrer = getReferrer();
+        if (referrer != null && "android-app".equals(referrer.getScheme())) {
+            return referrer.getHost();
+        }
+        return null;
+    }
+
+    @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);
+        }
+    }
+
+    private void setProfileSwitchMessage(int contentUserHint) {
+        if ((contentUserHint != UserHandle.USER_CURRENT)
+                && (contentUserHint != UserHandle.myUserId())) {
+            UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE);
+            UserInfo originUserInfo = userManager.getUserInfo(contentUserHint);
+            boolean originIsManaged = originUserInfo != null ? originUserInfo.isManagedProfile()
+                    : false;
+            boolean targetIsManaged = userManager.isManagedProfile();
+            if (originIsManaged && !targetIsManaged) {
+                mProfileSwitchMessage = getForwardToPersonalMsg();
+            } else if (!originIsManaged && targetIsManaged) {
+                mProfileSwitchMessage = getForwardToWorkMsg();
+            }
+        }
+    }
+
+    private String getForwardToPersonalMsg() {
+        return getSystemService(DevicePolicyManager.class).getResources().getString(
+                FORWARD_INTENT_TO_PERSONAL,
+                () -> getString(com.android.internal.R.string.forward_intent_to_owner));
+    }
+
+    private String getForwardToWorkMsg() {
+        return getSystemService(DevicePolicyManager.class).getResources().getString(
+                FORWARD_INTENT_TO_WORK,
+                () -> getString(com.android.internal.R.string.forward_intent_to_work));
+    }
+
+    /**
+     * Turn on launch mode that is safe to use when forwarding intents received from
+     * applications and running in system processes.  This mode uses Activity.startActivityAsCaller
+     * instead of the normal Activity.startActivity for launching the activity selected
+     * by the user.
+     *
+     * <p>This mode is set to true by default if the activity is initialized through
+     * {@link #onCreate(android.os.Bundle)}.  If a subclass calls one of the other onCreate
+     * methods, it is set to false by default.  You must set it before calling one of the
+     * more detailed onCreate methods, so that it will be set correctly in the case where
+     * there is only one intent to resolve and it is thus started immediately.</p>
+     */
+    public final void setSafeForwardingMode(boolean safeForwarding) {
+        mSafeForwardingMode = safeForwarding;
+    }
+
+    protected final CharSequence getTitleForAction(Intent intent, int defaultTitleRes) {
+        final ActionTitle title = mResolvingHome
+                ? ActionTitle.HOME
+                : 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 =
+                mMultiProfilePagerAdapter.getActiveListAdapter().getFilteredPosition() >= 0;
+        if (title == ActionTitle.DEFAULT && defaultTitleRes != 0) {
+            return getString(defaultTitleRes);
+        } else {
+            return named
+                    ? getString(title.namedTitleRes, mMultiProfilePagerAdapter
+                            .getActiveListAdapter().getFilteredItem().getDisplayLabel())
+                    : getString(title.titleRes);
+        }
+    }
+
+    final void dismiss() {
+        if (!isFinishing()) {
+            finish();
+        }
+    }
+
+    @Override
+    protected final void onRestart() {
+        super.onRestart();
+        if (!mRegistered) {
+            mPersonalPackageMonitor.register(this, getMainLooper(),
+                    getPersonalProfileUserHandle(), false);
+            if (shouldShowTabs()) {
+                if (mWorkPackageMonitor == null) {
+                    mWorkPackageMonitor = createPackageMonitor(
+                            mMultiProfilePagerAdapter.getWorkListAdapter());
+                }
+                mWorkPackageMonitor.register(this, getMainLooper(),
+                        getWorkProfileUserHandle(), false);
+            }
+            mRegistered = true;
+        }
+        if (shouldShowTabs() && mWorkProfileAvailability.isWaitingToEnableWorkProfile()) {
+            if (mWorkProfileAvailability.isQuietModeEnabled()) {
+                mWorkProfileAvailability.markWorkProfileEnabledBroadcastReceived();
+            }
+        }
+        mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged();
+        updateProfileViewButton();
+    }
+
+    @Override
+    protected final void onStart() {
+        super.onStart();
+
+        this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS);
+        if (shouldShowTabs()) {
+            mWorkProfileAvailability.registerWorkProfileStateReceiver(this);
+        }
+    }
+
+    @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 onRestoreInstanceState(Bundle savedInstanceState) {
+        super.onRestoreInstanceState(savedInstanceState);
+        resetButtonBar();
+        ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager);
+        if (viewPager != null) {
+            viewPager.setCurrentItem(savedInstanceState.getInt(LAST_SHOWN_TAB_KEY));
+        }
+        mMultiProfilePagerAdapter.clearInactiveProfileCache();
+    }
+
+    private boolean hasManagedProfile() {
+        UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE);
+        if (userManager == null) {
+            return false;
+        }
+
+        try {
+            List<UserInfo> profiles = userManager.getProfiles(getUserId());
+            for (UserInfo userInfo : profiles) {
+                if (userInfo != null && userInfo.isManagedProfile()) {
+                    return true;
+                }
+            }
+        } catch (SecurityException e) {
+            return false;
+        }
+        return false;
+    }
+
+    private boolean supportsManagedProfiles(ResolveInfo resolveInfo) {
+        try {
+            ApplicationInfo appInfo = getPackageManager().getApplicationInfo(
+                    resolveInfo.activityInfo.packageName, 0 /* default flags */);
+            return appInfo.targetSdkVersion >= Build.VERSION_CODES.LOLLIPOP;
+        } catch (NameNotFoundException e) {
+            return false;
+        }
+    }
+
+    private void setAlwaysButtonEnabled(boolean hasValidSelection, int checkedPos,
+            boolean filtered) {
+        if (!mMultiProfilePagerAdapter.getCurrentUserHandle().equals(getUser())) {
+            // Never allow the inactive profile to always open an app.
+            mAlwaysButton.setEnabled(false);
+            return;
+        }
+        boolean enabled = false;
+        ResolveInfo ri = null;
+        if (hasValidSelection) {
+            ri = mMultiProfilePagerAdapter.getActiveListAdapter()
+                    .resolveInfoForPosition(checkedPos, filtered);
+            if (ri == null) {
+                Log.e(TAG, "Invalid position supplied to setAlwaysButtonEnabled");
+                return;
+            } else if (ri.targetUserId != UserHandle.USER_CURRENT) {
+                Log.e(TAG, "Attempted to set selection to resolve info for another user");
+                return;
+            } else {
+                enabled = true;
+            }
+
+            mAlwaysButton.setText(getResources()
+                    .getString(R.string.activity_resolver_use_always));
+        }
+
+        if (ri != null) {
+            ActivityInfo activityInfo = ri.activityInfo;
+
+            boolean hasRecordPermission =
+                    mPm.checkPermission(android.Manifest.permission.RECORD_AUDIO,
+                            activityInfo.packageName)
+                            == android.content.pm.PackageManager.PERMISSION_GRANTED;
+
+            if (!hasRecordPermission) {
+                // OK, we know the record permission, is this a capture device
+                boolean hasAudioCapture =
+                        getIntent().getBooleanExtra(
+                                ResolverActivity.EXTRA_IS_AUDIO_CAPTURE_DEVICE, false);
+                enabled = !hasAudioCapture;
+            }
+        }
+        mAlwaysButton.setEnabled(enabled);
+    }
+
+    private String getWorkProfileNotSupportedMsg(String launcherName) {
+        return getSystemService(DevicePolicyManager.class).getResources().getString(
+                RESOLVER_WORK_PROFILE_NOT_SUPPORTED,
+                () -> getString(
+                        com.android.internal.R.string.activity_resolver_work_profiles_support,
+                        launcherName),
+                launcherName);
+    }
+
+    @Override // ResolverListCommunicator
+    public final void onPostListReady(ResolverListAdapter listAdapter, boolean doPostProcessing,
+            boolean rebuildCompleted) {
+        if (isAutolaunching()) {
+            return;
+        }
+        if (mIsIntentPicker) {
+            ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter)
+                    .setUseLayoutWithDefault(useLayoutWithDefault());
+        }
+        if (mMultiProfilePagerAdapter.shouldShowEmptyStateScreen(listAdapter)) {
+            mMultiProfilePagerAdapter.showEmptyResolverListEmptyState(listAdapter);
+        } else {
+            mMultiProfilePagerAdapter.showListView(listAdapter);
+        }
+        // showEmptyResolverListEmptyState can mark the tab as loaded,
+        // which is a precondition for auto launching
+        if (rebuildCompleted && maybeAutolaunchActivity()) {
+            return;
+        }
+        if (doPostProcessing) {
+            maybeCreateHeader(listAdapter);
+            resetButtonBar();
+            onListRebuilt(listAdapter, rebuildCompleted);
+        }
+    }
+
     /**
      * Start activity as a fixed user handle.
      * @param cti TargetInfo to be launched.
      * @param user User to launch this activity as.
      */
-    @VisibleForTesting
-    public void safelyStartActivityAsUser(TargetInfo cti, UserHandle user) {
+    @VisibleForTesting(visibility = VisibleForTesting.Visibility.PROTECTED)
+    public final void safelyStartActivityAsUser(TargetInfo cti, UserHandle user) {
         safelyStartActivityAsUser(cti, user, null);
     }
 
-    protected void safelyStartActivityAsUser(
+    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.
@@ -1494,76 +1629,20 @@
                 maybeLogCrossProfileTargetLaunch(cti, user);
             }
         } catch (RuntimeException e) {
-            Slog.wtf(TAG, "Unable to launch as uid " + mLaunchedFromUid
+            Slog.wtf(TAG,
+                    "Unable to launch as uid " + getAnnotatedUserHandles().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(getPersonalProfileUserHandle()))
-                .setStrings(getMetricsCategory(),
-                        cti.isInDirectShareMetricsCategory() ? "direct_share" : "other_target")
-                .write();
-    }
-
-
-    public void onActivityStarted(TargetInfo cti) {
-        // Do nothing
-    }
-
-    @Override // ResolverListCommunicator
-    public boolean shouldGetActivityMetadata() {
-        return false;
-    }
-
-    public boolean shouldAutoLaunchSingleChoice(TargetInfo target) {
-        return !target.isSuspended();
-    }
-
-    void showTargetDetails(ResolveInfo ri) {
+    final void showTargetDetails(ResolveInfo ri) {
         Intent in = new Intent().setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
                 .setData(Uri.fromParts("package", ri.activityInfo.packageName, null))
                 .addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT);
         startActivityAsUser(in, mMultiProfilePagerAdapter.getCurrentUserHandle());
     }
 
-    @VisibleForTesting
-    protected ResolverListAdapter createResolverListAdapter(Context context,
-            List<Intent> payloadIntents, Intent[] initialIntents, List<ResolveInfo> rList,
-            boolean filterLastUsed, UserHandle userHandle) {
-        Intent startIntent = getIntent();
-        boolean isAudioCaptureDevice =
-                startIntent.getBooleanExtra(EXTRA_IS_AUDIO_CAPTURE_DEVICE, false);
-        return new ResolverListAdapter(
-                context,
-                payloadIntents,
-                initialIntents,
-                rList,
-                filterLastUsed,
-                createListController(userHandle),
-                userHandle,
-                getTargetIntent(),
-                this,
-                isAudioCaptureDevice);
-    }
-
-    @VisibleForTesting
-    protected ResolverListController createListController(UserHandle userHandle) {
-        return new ResolverListController(
-                this,
-                mPm,
-                getTargetIntent(),
-                getReferrerPackageName(),
-                mLaunchedFromUid,
-                userHandle);
-    }
-
     /**
      * Sets up the content view.
      * @return <code>true</code> if the activity is finishing and creation should halt.
@@ -1650,8 +1729,7 @@
 
         findViewById(com.android.internal.R.id.button_open).setOnClickListener(v -> {
             Intent intent = otherProfileResolveInfo.getResolvedIntent();
-            safelyStartActivityAsUser(otherProfileResolveInfo,
-                    inactiveAdapter.mResolverListController.getUserHandle());
+            safelyStartActivityAsUser(otherProfileResolveInfo, inactiveAdapter.getUserHandle());
             finish();
         });
     }
@@ -1700,16 +1778,6 @@
 
     /**
      * Finishing procedures to be performed after the list has been rebuilt.
-     * </p>Subclasses must call postRebuildListInternal at the end of postRebuildList.
-     * @param rebuildCompleted
-     * @return <code>true</code> if the activity is finishing and creation should halt.
-     */
-    protected boolean postRebuildList(boolean rebuildCompleted) {
-        return postRebuildListInternal(rebuildCompleted);
-    }
-
-    /**
-     * Finishing procedures to be performed after the list has been rebuilt.
      * @param rebuildCompleted
      * @return <code>true</code> if the activity is finishing and creation should halt.
      */
@@ -1965,8 +2033,6 @@
                 RESOLVER_WORK_TAB, () -> getString(R.string.resolver_work_tab));
     }
 
-    void onHorizontalSwipeStateChanged(int state) {}
-
     private void maybeHideDivider() {
         if (!mIsIntentPicker) {
             return;
@@ -1978,12 +2044,6 @@
         divider.setVisibility(View.GONE);
     }
 
-    /**
-     * Callback called when user changes the profile tab.
-     * <p>This method is intended to be overridden by subclasses.
-     */
-    protected void onProfileTabSelected() { }
-
     private void resetCheckedItem() {
         if (!mIsIntentPicker) {
             return;
@@ -2030,20 +2090,17 @@
     }
 
     /**
-     * Add a label to signify that the user can pick a different app.
-     * @param adapter The adapter used to provide data to item views.
+     * Updates the button bar container {@code ignoreOffset} layout param.
+     * <p>Setting this to {@code true} means that the button bar will be glued to the bottom of
+     * the screen.
      */
-    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 (shouldShowTabs()) {
-                textView.setGravity(Gravity.CENTER);
-            }
-            stub.addView(textView);
+    private void setButtonBarIgnoreOffset(boolean ignoreOffset) {
+        View buttonBarContainer = findViewById(com.android.internal.R.id.button_bar_container);
+        if (buttonBarContainer != null) {
+            ResolverDrawerLayout.LayoutParams layoutParams =
+                    (ResolverDrawerLayout.LayoutParams) buttonBarContainer.getLayoutParams();
+            layoutParams.ignoreOffset = ignoreOffset;
+            buttonBarContainer.setLayoutParams(layoutParams);
         }
     }
 
@@ -2091,61 +2148,6 @@
         mHeaderCreatorUser = listAdapter.getUserHandle();
     }
 
-    protected void resetButtonBar() {
-        if (!mSupportsAlwaysUseOption) {
-            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");
-            return;
-        }
-        ResolverListAdapter activeListAdapter =
-                mMultiProfilePagerAdapter.getActiveListAdapter();
-        View buttonBarDivider = findViewById(com.android.internal.R.id.resolver_button_bar_divider);
-        if (!useLayoutWithDefault()) {
-            int inset = mSystemWindowInsets != null ? mSystemWindowInsets.bottom : 0;
-            buttonLayout.setPadding(buttonLayout.getPaddingLeft(), buttonLayout.getPaddingTop(),
-                    buttonLayout.getPaddingRight(), getResources().getDimensionPixelSize(
-                            R.dimen.resolver_button_bar_spacing) + inset);
-        }
-        if (activeListAdapter.isTabLoaded()
-                && mMultiProfilePagerAdapter.shouldShowEmptyStateScreen(activeListAdapter)
-                && !useLayoutWithDefault()) {
-            buttonLayout.setVisibility(View.INVISIBLE);
-            if (buttonBarDivider != null) {
-                buttonBarDivider.setVisibility(View.INVISIBLE);
-            }
-            setButtonBarIgnoreOffset(/* ignoreOffset */ false);
-            return;
-        }
-        if (buttonBarDivider != null) {
-            buttonBarDivider.setVisibility(View.VISIBLE);
-        }
-        buttonLayout.setVisibility(View.VISIBLE);
-        setButtonBarIgnoreOffset(/* ignoreOffset */ true);
-
-        mOnceButton = (Button) buttonLayout.findViewById(com.android.internal.R.id.button_once);
-        mAlwaysButton = (Button) buttonLayout.findViewById(com.android.internal.R.id.button_always);
-
-        resetAlwaysOrOnceButtonBar();
-    }
-
-    /**
-     * Updates the button bar container {@code ignoreOffset} layout param.
-     * <p>Setting this to {@code true} means that the button bar will be glued to the bottom of
-     * the screen.
-     */
-    private void setButtonBarIgnoreOffset(boolean ignoreOffset) {
-        View buttonBarContainer = findViewById(com.android.internal.R.id.button_bar_container);
-        if (buttonBarContainer != null) {
-            ResolverDrawerLayout.LayoutParams layoutParams =
-                    (ResolverDrawerLayout.LayoutParams) buttonBarContainer.getLayoutParams();
-            layoutParams.ignoreOffset = ignoreOffset;
-            buttonBarContainer.setLayoutParams(layoutParams);
-        }
-    }
-
     private void resetAlwaysOrOnceButtonBar() {
         // Disable both buttons initially
         setAlwaysButtonEnabled(false, ListView.INVALID_POSITION, false);
@@ -2171,7 +2173,7 @@
     }
 
     @Override // ResolverListCommunicator
-    public boolean useLayoutWithDefault() {
+    public final boolean useLayoutWithDefault() {
         // We only use the default app layout when the profile of the active user has a
         // filtered item. We always show the same default app even in the inactive user profile.
         boolean currentUserAdapterHasFilteredItem;
@@ -2190,7 +2192,7 @@
      * 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 void setRetainInOnStop(boolean retainInOnStop) {
+    protected final void setRetainInOnStop(boolean retainInOnStop) {
         mRetainInOnStop = retainInOnStop;
     }
 
@@ -2198,43 +2200,13 @@
      * Check a simple match for the component of two ResolveInfos.
      */
     @Override // ResolverListCommunicator
-    public boolean resolveInfoMatch(ResolveInfo lhs, ResolveInfo rhs) {
+    public final boolean resolveInfoMatch(ResolveInfo lhs, ResolveInfo rhs) {
         return lhs == null ? rhs == null
                 : lhs.activityInfo == null ? rhs.activityInfo == null
                 : Objects.equals(lhs.activityInfo.name, rhs.activityInfo.name)
                 && Objects.equals(lhs.activityInfo.packageName, rhs.activityInfo.packageName);
     }
 
-    protected String getMetricsCategory() {
-        return METRICS_CATEGORY_RESOLVER;
-    }
-
-    @Override // ResolverListCommunicator
-    public void onHandlePackagesChanged(ResolverListAdapter listAdapter) {
-        if (listAdapter == mMultiProfilePagerAdapter.getActiveListAdapter()) {
-            if (listAdapter.getUserHandle().equals(getWorkProfileUserHandle())
-                    && mQuietModeManager.isWaitingToEnableWorkProfile()) {
-                // We have just turned on the work profile and entered the pass code 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;
-            }
-            boolean listRebuilt = mMultiProfilePagerAdapter.rebuildActiveTab(true);
-            if (listRebuilt) {
-                ResolverListAdapter activeListAdapter =
-                        mMultiProfilePagerAdapter.getActiveListAdapter();
-                activeListAdapter.notifyDataSetChanged();
-                if (activeListAdapter.getCount() == 0 && !inactiveListAdapterHasItems()) {
-                    // We no longer have any items...  just finish the activity.
-                    finish();
-                }
-            }
-        } else {
-            mMultiProfilePagerAdapter.clearInactiveProfileCache();
-        }
-    }
-
     private boolean inactiveListAdapterHasItems() {
         if (!shouldShowTabs()) {
             return false;
@@ -2242,101 +2214,7 @@
         return mMultiProfilePagerAdapter.getInactiveListAdapter().getCount() > 0;
     }
 
-    private BroadcastReceiver createWorkProfileStateReceiver() {
-        return new BroadcastReceiver() {
-            @Override
-            public void onReceive(Context context, Intent intent) {
-                String action = intent.getAction();
-                if (!TextUtils.equals(action, Intent.ACTION_USER_UNLOCKED)
-                        && !TextUtils.equals(action, Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE)
-                        && !TextUtils.equals(action, Intent.ACTION_MANAGED_PROFILE_AVAILABLE)) {
-                    return;
-                }
-
-                int userId = intent.getIntExtra(Intent.EXTRA_USER_HANDLE, -1);
-
-                if (userId != getWorkProfileUserHandle().getIdentifier()) {
-                    return;
-                }
-
-                if (isWorkProfileEnabled()) {
-                    if (mWorkProfileHasBeenEnabled) {
-                        return;
-                    }
-
-                    mWorkProfileHasBeenEnabled = true;
-                    mQuietModeManager.markWorkProfileEnabledBroadcastReceived();
-                } else {
-                    // Must be an UNAVAILABLE broadcast, so we watch for the next availability
-                    mWorkProfileHasBeenEnabled = false;
-                }
-
-                if (mMultiProfilePagerAdapter.getCurrentUserHandle()
-                        .equals(getWorkProfileUserHandle())) {
-                    mMultiProfilePagerAdapter.rebuildActiveTab(true);
-                } else {
-                    mMultiProfilePagerAdapter.clearInactiveProfileCache();
-                }
-            }
-        };
-    }
-
-    public static final class ResolvedComponentInfo {
-        public final ComponentName name;
-        private final List<Intent> mIntents = new ArrayList<>();
-        private final List<ResolveInfo> mResolveInfos = new ArrayList<>();
-        private boolean mPinned;
-
-        public ResolvedComponentInfo(ComponentName name, Intent intent, ResolveInfo info) {
-            this.name = name;
-            add(intent, info);
-        }
-
-        public void add(Intent intent, ResolveInfo info) {
-            mIntents.add(intent);
-            mResolveInfos.add(info);
-        }
-
-        public int getCount() {
-            return mIntents.size();
-        }
-
-        public Intent getIntentAt(int index) {
-            return index >= 0 ? mIntents.get(index) : null;
-        }
-
-        public ResolveInfo getResolveInfoAt(int index) {
-            return index >= 0 ? mResolveInfos.get(index) : null;
-        }
-
-        public int findIntent(Intent intent) {
-            for (int i = 0, N = mIntents.size(); i < N; i++) {
-                if (intent.equals(mIntents.get(i))) {
-                    return i;
-                }
-            }
-            return -1;
-        }
-
-        public int findResolveInfo(ResolveInfo info) {
-            for (int i = 0, N = mResolveInfos.size(); i < N; i++) {
-                if (info.equals(mResolveInfos.get(i))) {
-                    return i;
-                }
-            }
-            return -1;
-        }
-
-        public boolean isPinned() {
-            return mPinned;
-        }
-
-        public void setPinned(boolean pinned) {
-            mPinned = pinned;
-        }
-    }
-
-    class ItemClickListener implements AdapterView.OnItemClickListener,
+    final class ItemClickListener implements AdapterView.OnItemClickListener,
             AdapterView.OnItemLongClickListener {
         @Override
         public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
@@ -2397,7 +2275,7 @@
                 && match <= IntentFilter.MATCH_CATEGORY_PATH;
     }
 
-    static class PickTargetOptionRequest extends PickOptionRequest {
+    static final class PickTargetOptionRequest extends PickOptionRequest {
         public PickTargetOptionRequest(@Nullable Prompt prompt, Option[] options,
                 @Nullable Bundle extras) {
             super(prompt, options, extras);
@@ -2433,6 +2311,4 @@
             }
         }
     }
-
-    protected void maybeLogProfileChange() {}
 }
diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java
index eecb914..eac275c 100644
--- a/java/src/com/android/intentresolver/ResolverListAdapter.java
+++ b/java/src/com/android/intentresolver/ResolverListAdapter.java
@@ -18,6 +18,7 @@
 
 import static android.content.Context.ACTIVITY_SERVICE;
 
+import android.animation.ObjectAnimator;
 import android.annotation.NonNull;
 import android.annotation.Nullable;
 import android.app.ActivityManager;
@@ -42,12 +43,12 @@
 import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
+import android.view.animation.DecelerateInterpolator;
 import android.widget.AbsListView;
 import android.widget.BaseAdapter;
 import android.widget.ImageView;
 import android.widget.TextView;
 
-import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo;
 import com.android.intentresolver.chooser.DisplayResolveInfo;
 import com.android.intentresolver.chooser.TargetInfo;
 import com.android.internal.annotations.VisibleForTesting;
@@ -287,11 +288,7 @@
                     mBaseResolveList);
             return currentResolveList;
         } else {
-            return mResolverListController.getResolversForIntent(
-                            /* shouldGetResolvedFilter= */ true,
-                            mResolverListCommunicator.shouldGetActivityMetadata(),
-                            mResolverListCommunicator.shouldGetOnlyDefaultActivities(),
-                            mIntents);
+            return getResolversForUser(mUserHandle);
         }
     }
 
@@ -802,10 +799,12 @@
     }
 
     protected List<ResolvedComponentInfo> getResolversForUser(UserHandle userHandle) {
-        return mResolverListController.getResolversForIntentAsUser(true,
+        return mResolverListController.getResolversForIntentAsUser(
+                /* shouldGetResolvedFilter= */ true,
                 mResolverListCommunicator.shouldGetActivityMetadata(),
                 mResolverListCommunicator.shouldGetOnlyDefaultActivities(),
-                mIntents, userHandle);
+                mIntents,
+                userHandle);
     }
 
     protected List<Intent> getIntents() {
@@ -914,6 +913,7 @@
      */
     @VisibleForTesting
     public static class ViewHolder {
+        private static final long IMAGE_FADE_IN_MILLIS = 150;
         public View itemView;
         public Drawable defaultItemViewBackground;
 
@@ -952,7 +952,22 @@
         }
 
         public void bindIcon(TargetInfo info) {
-            icon.setImageDrawable(info.getDisplayIconHolder().getDisplayIcon());
+            bindIcon(info, false);
+        }
+
+        /**
+         * Bind view holder to a TargetInfo, run icon reveal animation, if required.
+         */
+        public void bindIcon(TargetInfo info, boolean animate) {
+            Drawable displayIcon = info.getDisplayIconHolder().getDisplayIcon();
+            boolean runAnimation = animate && (icon.getDrawable() == null) && (displayIcon != null);
+            icon.setImageDrawable(displayIcon);
+            if (runAnimation) {
+                ObjectAnimator animator = ObjectAnimator.ofFloat(icon, "alpha", 0.0f, 1.0f);
+                animator.setInterpolator(new DecelerateInterpolator(1.0f));
+                animator.setDuration(IMAGE_FADE_IN_MILLIS);
+                animator.start();
+            }
             if (info.isSuspended()) {
                 icon.setColorFilter(getSuspendedColorMatrix());
             } else {
diff --git a/java/src/com/android/intentresolver/ResolverListController.java b/java/src/com/android/intentresolver/ResolverListController.java
index bfffe0d..b4544c4 100644
--- a/java/src/com/android/intentresolver/ResolverListController.java
+++ b/java/src/com/android/intentresolver/ResolverListController.java
@@ -58,7 +58,6 @@
 
     private static final String TAG = "ResolverListController";
     private static final boolean DEBUG = false;
-    private final UserHandle mUserHandle;
 
     private AbstractResolverComparator mResolverComparator;
     private boolean isComputed = false;
@@ -68,9 +67,8 @@
             PackageManager pm,
             Intent targetIntent,
             String referrerPackage,
-            int launchedFromUid,
-            UserHandle userHandle) {
-        this(context, pm, targetIntent, referrerPackage, launchedFromUid, userHandle,
+            int launchedFromUid) {
+        this(context, pm, targetIntent, referrerPackage, launchedFromUid,
                     new ResolverRankerServiceResolverComparator(
                         context, targetIntent, referrerPackage, null, null));
     }
@@ -81,14 +79,12 @@
             Intent targetIntent,
             String referrerPackage,
             int launchedFromUid,
-            UserHandle userHandle,
             AbstractResolverComparator resolverComparator) {
         mContext = context;
         mpm = pm;
         mLaunchedFromUid = launchedFromUid;
         mTargetIntent = targetIntent;
         mReferrerPackage = referrerPackage;
-        mUserHandle = userHandle;
         mResolverComparator = resolverComparator;
     }
 
@@ -108,17 +104,11 @@
                 filter, match, intent.getComponent());
     }
 
-    @VisibleForTesting
-    public List<ResolverActivity.ResolvedComponentInfo> getResolversForIntent(
-            boolean shouldGetResolvedFilter,
-            boolean shouldGetActivityMetadata,
-            boolean shouldGetOnlyDefaultActivities,
-            List<Intent> intents) {
-        return getResolversForIntentAsUser(shouldGetResolvedFilter, shouldGetActivityMetadata,
-                shouldGetOnlyDefaultActivities, intents, mUserHandle);
-    }
-
-    public List<ResolverActivity.ResolvedComponentInfo> getResolversForIntentAsUser(
+    /**
+     * Get data about all the ways the user with the specified handle can resolve any of the
+     * provided {@code intents}.
+     */
+    public List<ResolvedComponentInfo> getResolversForIntentAsUser(
             boolean shouldGetResolvedFilter,
             boolean shouldGetActivityMetadata,
             boolean shouldGetOnlyDefaultActivities,
@@ -132,11 +122,9 @@
         return getResolversForIntentAsUserInternal(intents, userHandle, baseFlags);
     }
 
-    private List<ResolverActivity.ResolvedComponentInfo> getResolversForIntentAsUserInternal(
-            List<Intent> intents,
-            UserHandle userHandle,
-            int baseFlags) {
-        List<ResolverActivity.ResolvedComponentInfo> resolvedComponents = null;
+    private List<ResolvedComponentInfo> getResolversForIntentAsUserInternal(
+            List<Intent> intents, UserHandle userHandle, int baseFlags) {
+        List<ResolvedComponentInfo> resolvedComponents = null;
         for (int i = 0, N = intents.size(); i < N; i++) {
             Intent intent = intents.get(i);
             int flags = baseFlags;
@@ -160,14 +148,8 @@
     }
 
     @VisibleForTesting
-    public UserHandle getUserHandle() {
-        return mUserHandle;
-    }
-
-    @VisibleForTesting
-    public void addResolveListDedupe(List<ResolverActivity.ResolvedComponentInfo> into,
-            Intent intent,
-            List<ResolveInfo> from) {
+    public void addResolveListDedupe(
+            List<ResolvedComponentInfo> into, Intent intent, List<ResolveInfo> from) {
         final int fromCount = from.size();
         final int intoCount = into.size();
         for (int i = 0; i < fromCount; i++) {
@@ -175,7 +157,7 @@
             boolean found = false;
             // Only loop to the end of into as it was before we started; no dupes in from.
             for (int j = 0; j < intoCount; j++) {
-                final ResolverActivity.ResolvedComponentInfo rci = into.get(j);
+                final ResolvedComponentInfo rci = into.get(j);
                 if (isSameResolvedComponent(newInfo, rci)) {
                     found = true;
                     rci.add(intent, newInfo);
@@ -185,8 +167,7 @@
             if (!found) {
                 final ComponentName name = new ComponentName(
                         newInfo.activityInfo.packageName, newInfo.activityInfo.name);
-                final ResolverActivity.ResolvedComponentInfo rci =
-                        new ResolverActivity.ResolvedComponentInfo(name, intent, newInfo);
+                final ResolvedComponentInfo rci = new ResolvedComponentInfo(name, intent, newInfo);
                 rci.setPinned(isComponentPinned(name));
                 into.add(rci);
             }
@@ -206,10 +187,9 @@
     // To preserve the inputList, optionally will return the original list if any modification has
     // been made.
     @VisibleForTesting
-    public ArrayList<ResolverActivity.ResolvedComponentInfo> filterIneligibleActivities(
-            List<ResolverActivity.ResolvedComponentInfo> inputList,
-            boolean returnCopyOfOriginalListIfModified) {
-        ArrayList<ResolverActivity.ResolvedComponentInfo> listToReturn = null;
+    public ArrayList<ResolvedComponentInfo> filterIneligibleActivities(
+            List<ResolvedComponentInfo> inputList, boolean returnCopyOfOriginalListIfModified) {
+        ArrayList<ResolvedComponentInfo> listToReturn = null;
         for (int i = inputList.size()-1; i >= 0; i--) {
             ActivityInfo ai = inputList.get(i)
                     .getResolveInfoAt(0).activityInfo;
@@ -235,13 +215,12 @@
     // To preserve the inputList, optionally will return the original list if any modification has
     // been made.
     @VisibleForTesting
-    public ArrayList<ResolverActivity.ResolvedComponentInfo> filterLowPriority(
-            List<ResolverActivity.ResolvedComponentInfo> inputList,
-            boolean returnCopyOfOriginalListIfModified) {
-        ArrayList<ResolverActivity.ResolvedComponentInfo> listToReturn = null;
+    public ArrayList<ResolvedComponentInfo> filterLowPriority(
+            List<ResolvedComponentInfo> inputList, boolean returnCopyOfOriginalListIfModified) {
+        ArrayList<ResolvedComponentInfo> listToReturn = null;
         // Only display the first matches that are either of equal
         // priority or have asked to be default options.
-        ResolverActivity.ResolvedComponentInfo rci0 = inputList.get(0);
+        ResolvedComponentInfo rci0 = inputList.get(0);
         ResolveInfo r0 = rci0.getResolveInfoAt(0);
         int N = inputList.size();
         for (int i = 1; i < N; i++) {
@@ -266,8 +245,7 @@
         return listToReturn;
     }
 
-    private void compute(List<ResolverActivity.ResolvedComponentInfo> inputList)
-            throws InterruptedException {
+    private void compute(List<ResolvedComponentInfo> inputList) throws InterruptedException {
         if (mResolverComparator == null) {
             Log.d(TAG, "Comparator has already been destroyed; skipped.");
             return;
@@ -281,7 +259,7 @@
 
     @VisibleForTesting
     @WorkerThread
-    public void sort(List<ResolverActivity.ResolvedComponentInfo> inputList) {
+    public void sort(List<ResolvedComponentInfo> inputList) {
         try {
             long beforeRank = System.currentTimeMillis();
             if (!isComputed) {
@@ -300,7 +278,7 @@
 
     @VisibleForTesting
     @WorkerThread
-    public void topK(List<ResolverActivity.ResolvedComponentInfo> inputList, int k) {
+    public void topK(List<ResolvedComponentInfo> inputList, int k) {
         if (inputList == null || inputList.isEmpty() || k <= 0) {
             return;
         }
@@ -317,7 +295,7 @@
             }
 
             // Top of this heap has lowest rank.
-            PriorityQueue<ResolverActivity.ResolvedComponentInfo> minHeap = new PriorityQueue<>(k,
+            PriorityQueue<ResolvedComponentInfo> minHeap = new PriorityQueue<>(k,
                     (o1, o2) -> -mResolverComparator.compare(o1, o2));
             final int size = inputList.size();
             // Use this pointer to keep track of the position of next element
@@ -325,7 +303,7 @@
             int pointer = size - 1;
             minHeap.addAll(inputList.subList(size - k, size));
             for (int i = size - k - 1; i >= 0; --i) {
-                ResolverActivity.ResolvedComponentInfo ci = inputList.get(i);
+                ResolvedComponentInfo ci = inputList.get(i);
                 if (-mResolverComparator.compare(ci, minHeap.peek()) > 0) {
                     // When ranked higher than top of heap, remove top of heap,
                     // update input list with it, add this new element to heap.
@@ -354,8 +332,7 @@
         }
     }
 
-    private static boolean isSameResolvedComponent(ResolveInfo a,
-            ResolverActivity.ResolvedComponentInfo b) {
+    private static boolean isSameResolvedComponent(ResolveInfo a, ResolvedComponentInfo b) {
         final ActivityInfo ai = a.activityInfo;
         return ai.packageName.equals(b.name.getPackageName())
                 && ai.name.equals(b.name.getClassName());
diff --git a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java
index 65de940..48e3b62 100644
--- a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java
+++ b/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java
@@ -43,13 +43,13 @@
             Context context,
             ResolverListAdapter adapter,
             EmptyStateProvider emptyStateProvider,
-            QuietModeManager quietModeManager,
+            Supplier<Boolean> workProfileQuietModeChecker,
             UserHandle workProfileUserHandle) {
         this(
                 context,
                 ImmutableList.of(adapter),
                 emptyStateProvider,
-                quietModeManager,
+                workProfileQuietModeChecker,
                 /* defaultProfile= */ 0,
                 workProfileUserHandle,
                 new BottomPaddingOverrideSupplier());
@@ -59,14 +59,14 @@
             ResolverListAdapter personalAdapter,
             ResolverListAdapter workAdapter,
             EmptyStateProvider emptyStateProvider,
-            QuietModeManager quietModeManager,
+            Supplier<Boolean> workProfileQuietModeChecker,
             @Profile int defaultProfile,
             UserHandle workProfileUserHandle) {
         this(
                 context,
                 ImmutableList.of(personalAdapter, workAdapter),
                 emptyStateProvider,
-                quietModeManager,
+                workProfileQuietModeChecker,
                 defaultProfile,
                 workProfileUserHandle,
                 new BottomPaddingOverrideSupplier());
@@ -76,7 +76,7 @@
             Context context,
             ImmutableList<ResolverListAdapter> listAdapters,
             EmptyStateProvider emptyStateProvider,
-            QuietModeManager quietModeManager,
+            Supplier<Boolean> workProfileQuietModeChecker,
             @Profile int defaultProfile,
             UserHandle workProfileUserHandle,
             BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) {
@@ -86,7 +86,7 @@
                         (listView, bindAdapter) -> listView.setAdapter(bindAdapter),
                 listAdapters,
                 emptyStateProvider,
-                quietModeManager,
+                workProfileQuietModeChecker,
                 defaultProfile,
                 workProfileUserHandle,
                         () -> (ViewGroup) LayoutInflater.from(context).inflate(
diff --git a/java/src/com/android/intentresolver/SecureSettings.kt b/java/src/com/android/intentresolver/SecureSettings.kt
new file mode 100644
index 0000000..a4853fd
--- /dev/null
+++ b/java/src/com/android/intentresolver/SecureSettings.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver
+
+import android.content.ContentResolver
+import android.provider.Settings
+
+/**
+ * A proxy class for secure settings, for easier testing.
+ */
+open class SecureSettings {
+    open fun getString(resolver: ContentResolver, name: String): String? {
+        return Settings.Secure.getString(resolver, name)
+    }
+}
diff --git a/java/src/com/android/intentresolver/WorkProfileAvailabilityManager.java b/java/src/com/android/intentresolver/WorkProfileAvailabilityManager.java
new file mode 100644
index 0000000..6e51520
--- /dev/null
+++ b/java/src/com/android/intentresolver/WorkProfileAvailabilityManager.java
@@ -0,0 +1,166 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.AsyncTask;
+import android.os.UserHandle;
+import android.os.UserManager;
+import android.text.TextUtils;
+
+import androidx.annotation.VisibleForTesting;
+
+/** Monitor for runtime conditions that may disable work profile display. */
+public class WorkProfileAvailabilityManager {
+    private final UserManager mUserManager;
+    private final UserHandle mWorkProfileUserHandle;
+    private final Runnable mOnWorkProfileStateUpdated;
+
+    private BroadcastReceiver mWorkProfileStateReceiver;
+
+    private boolean mIsWaitingToEnableWorkProfile;
+    private boolean mWorkProfileHasBeenEnabled;
+
+    public WorkProfileAvailabilityManager(
+            UserManager userManager,
+            UserHandle workProfileUserHandle,
+            Runnable onWorkProfileStateUpdated) {
+        mUserManager = userManager;
+        mWorkProfileUserHandle = workProfileUserHandle;
+        mWorkProfileHasBeenEnabled = isWorkProfileEnabled();
+        mOnWorkProfileStateUpdated = onWorkProfileStateUpdated;
+    }
+
+    /**
+     * Register a {@link BroadcastReceiver}, if we haven't already, to be notified about work
+     * profile availability changes.
+     *
+     * TODO: this takes the context for testing, because we don't have a context on hand when we
+     * set up this component's default "override" in {@link ChooserActivityOverrideData#reset()}.
+     * The use of these overrides in our testing design is questionable and can hopefully be
+     * improved someday; then this context should be injected in our constructor & held as `final`.
+     *
+     * TODO: consider injecting an optional `Lifecycle` so that this component can automatically
+     * manage its own registration/unregistration. (This would be optional because registration of
+     * the receiver is conditional on having `shouldShowTabs()` in our session.)
+     */
+    public void registerWorkProfileStateReceiver(Context context) {
+        if (mWorkProfileStateReceiver != null) {
+            return;
+        }
+        mWorkProfileStateReceiver = createWorkProfileStateReceiver();
+
+        IntentFilter filter = new IntentFilter();
+        filter.addAction(Intent.ACTION_USER_UNLOCKED);
+        filter.addAction(Intent.ACTION_MANAGED_PROFILE_AVAILABLE);
+        filter.addAction(Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE);
+        context.registerReceiverAsUser(
+                mWorkProfileStateReceiver, UserHandle.ALL, filter, null, null);
+    }
+
+    /**
+     * Unregister any {@link BroadcastReceiver} currently waiting for a work-enabled broadcast.
+     *
+     * TODO: this takes the context for testing, because we don't have a context on hand when we
+     * set up this component's default "override" in {@link ChooserActivityOverrideData#reset()}.
+     * The use of these overrides in our testing design is questionable and can hopefully be
+     * improved someday; then this context should be injected in our constructor & held as `final`.
+     */
+    public void unregisterWorkProfileStateReceiver(Context context) {
+        if (mWorkProfileStateReceiver == null) {
+            return;
+        }
+        context.unregisterReceiver(mWorkProfileStateReceiver);
+        mWorkProfileStateReceiver = null;
+    }
+
+    public boolean isQuietModeEnabled() {
+        return mUserManager.isQuietModeEnabled(mWorkProfileUserHandle);
+    }
+
+    // TODO: why do clients only care about the result of `isQuietModeEnabled()`, even though
+    // internally (in `isWorkProfileEnabled()`) we also check this 'unlocked' condition?
+    @VisibleForTesting
+    public boolean isWorkProfileUserUnlocked() {
+        return mUserManager.isUserUnlocked(mWorkProfileUserHandle);
+    }
+
+    /**
+     * Request that quiet mode be enabled (or disabled) for the work profile.
+     * TODO: this is only used to disable quiet mode; should that be hard-coded?
+     */
+    public void requestQuietModeEnabled(boolean enabled) {
+        AsyncTask.THREAD_POOL_EXECUTOR.execute(
+                () -> mUserManager.requestQuietModeEnabled(enabled, mWorkProfileUserHandle));
+        mIsWaitingToEnableWorkProfile = true;
+    }
+
+    /**
+     * Stop waiting for a work-enabled broadcast.
+     * TODO: this seems strangely low-level to include as part of the public API. Maybe some
+     * responsibilities need to be pulled over from the client?
+     */
+    public void markWorkProfileEnabledBroadcastReceived() {
+        mIsWaitingToEnableWorkProfile = false;
+    }
+
+    public boolean isWaitingToEnableWorkProfile() {
+        return mIsWaitingToEnableWorkProfile;
+    }
+
+    private boolean isWorkProfileEnabled() {
+        return (mWorkProfileUserHandle != null)
+                && !isQuietModeEnabled()
+                && isWorkProfileUserUnlocked();
+    }
+
+    private BroadcastReceiver createWorkProfileStateReceiver() {
+        return new BroadcastReceiver() {
+            @Override
+            public void onReceive(Context context, Intent intent) {
+                String action = intent.getAction();
+                if (!TextUtils.equals(action, Intent.ACTION_USER_UNLOCKED)
+                        && !TextUtils.equals(action, Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE)
+                        && !TextUtils.equals(action, Intent.ACTION_MANAGED_PROFILE_AVAILABLE)) {
+                    return;
+                }
+
+                if (intent.getIntExtra(Intent.EXTRA_USER_HANDLE, -1)
+                        != mWorkProfileUserHandle.getIdentifier()) {
+                    return;
+                }
+
+                if (isWorkProfileEnabled()) {
+                    if (mWorkProfileHasBeenEnabled) {
+                        return;
+                    }
+                    mWorkProfileHasBeenEnabled = true;
+                    mIsWaitingToEnableWorkProfile = false;
+                } else {
+                    // Must be an UNAVAILABLE broadcast, so we watch for the next availability.
+                    // TODO: confirm the above reasoning (& handling of "UNAVAILABLE" in general).
+                    mWorkProfileHasBeenEnabled = false;
+                }
+
+                mOnWorkProfileStateUpdated.run();
+            }
+        };
+    }
+}
diff --git a/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java b/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java
index b7c8990..0333039 100644
--- a/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java
+++ b/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java
@@ -26,11 +26,10 @@
 import android.os.UserHandle;
 import android.stats.devicepolicy.nano.DevicePolicyEnums;
 
-import com.android.internal.R;
 import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState;
 import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider;
 import com.android.intentresolver.AbstractMultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener;
-import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeManager;
+import com.android.internal.R;
 
 /**
  * Chooser/ResolverActivity empty state provider that returns empty state which is shown when
@@ -39,19 +38,19 @@
 public class WorkProfilePausedEmptyStateProvider implements EmptyStateProvider {
 
     private final UserHandle mWorkProfileUserHandle;
-    private final QuietModeManager mQuietModeManager;
+    private final WorkProfileAvailabilityManager mWorkProfileAvailability;
     private final String mMetricsCategory;
     private final OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener;
     private final Context mContext;
 
     public WorkProfilePausedEmptyStateProvider(@NonNull Context context,
             @Nullable UserHandle workProfileUserHandle,
-            @NonNull QuietModeManager quietModeManager,
+            @NonNull WorkProfileAvailabilityManager workProfileAvailability,
             @Nullable OnSwitchOnWorkSelectedListener onSwitchOnWorkSelectedListener,
             @NonNull String metricsCategory) {
         mContext = context;
         mWorkProfileUserHandle = workProfileUserHandle;
-        mQuietModeManager = quietModeManager;
+        mWorkProfileAvailability = workProfileAvailability;
         mMetricsCategory = metricsCategory;
         mOnSwitchOnWorkSelectedListener = onSwitchOnWorkSelectedListener;
     }
@@ -60,7 +59,7 @@
     @Override
     public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) {
         if (!resolverListAdapter.getUserHandle().equals(mWorkProfileUserHandle)
-                || !mQuietModeManager.isQuietModeEnabled(mWorkProfileUserHandle)
+                || !mWorkProfileAvailability.isQuietModeEnabled()
                 || resolverListAdapter.getCount() == 0) {
             return null;
         }
@@ -74,7 +73,7 @@
             if (mOnSwitchOnWorkSelectedListener != null) {
                 mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected();
             }
-            mQuietModeManager.requestQuietModeEnabled(false, mWorkProfileUserHandle);
+            mWorkProfileAvailability.requestQuietModeEnabled(false);
         }, mMetricsCategory);
     }
 
diff --git a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java
index db5ae0b..29be6dc 100644
--- a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java
+++ b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java
@@ -27,7 +27,6 @@
 import android.os.Bundle;
 import android.os.UserHandle;
 
-import com.android.intentresolver.ResolverActivity;
 import com.android.intentresolver.TargetPresentationGetter;
 
 import java.util.ArrayList;
@@ -97,25 +96,22 @@
         final ActivityInfo ai = mResolveInfo.activityInfo;
         mIsSuspended = (ai.applicationInfo.flags & ApplicationInfo.FLAG_SUSPENDED) != 0;
 
-        final Intent intent = new Intent(resolvedIntent);
-        intent.addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT
-                | Intent.FLAG_ACTIVITY_PREVIOUS_IS_TOP);
-        intent.setComponent(new ComponentName(ai.applicationInfo.packageName, ai.name));
-        mResolvedIntent = intent;
+        mResolvedIntent = createResolvedIntent(resolvedIntent, ai);
     }
 
     private DisplayResolveInfo(
             DisplayResolveInfo other,
-            Intent fillInIntent,
-            int flags,
+            @Nullable Intent baseIntentToSend,
             TargetPresentationGetter presentationGetter) {
         mSourceIntents.addAll(other.getAllSourceIntents());
         mResolveInfo = other.mResolveInfo;
         mIsSuspended = other.mIsSuspended;
         mDisplayLabel = other.mDisplayLabel;
         mExtendedInfo = other.mExtendedInfo;
-        mResolvedIntent = new Intent(other.mResolvedIntent);
-        mResolvedIntent.fillIn(fillInIntent, flags);
+
+        mResolvedIntent = createResolvedIntent(
+                baseIntentToSend == null ? other.mResolvedIntent : baseIntentToSend,
+                mResolveInfo.activityInfo);
         mPresentationGetter = presentationGetter;
 
         mDisplayIconHolder.setDisplayIcon(other.mDisplayIconHolder.getDisplayIcon());
@@ -133,6 +129,14 @@
         mDisplayIconHolder.setDisplayIcon(other.mDisplayIconHolder.getDisplayIcon());
     }
 
+    private static Intent createResolvedIntent(Intent resolvedIntent, ActivityInfo ai) {
+        final Intent result = new Intent(resolvedIntent);
+        result.addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT
+                | Intent.FLAG_ACTIVITY_PREVIOUS_IS_TOP);
+        result.setComponent(new ComponentName(ai.applicationInfo.packageName, ai.name));
+        return result;
+    }
+
     @Override
     public final boolean isDisplayResolveInfo() {
         return true;
@@ -168,8 +172,21 @@
     }
 
     @Override
-    public TargetInfo cloneFilledIn(Intent fillInIntent, int flags) {
-        return new DisplayResolveInfo(this, fillInIntent, flags, mPresentationGetter);
+    @Nullable
+    public DisplayResolveInfo tryToCloneWithAppliedRefinement(Intent proposedRefinement) {
+        Intent matchingBase =
+                getAllSourceIntents()
+                        .stream()
+                        .filter(i -> i.filterEquals(proposedRefinement))
+                        .findFirst()
+                        .orElse(null);
+        if (matchingBase == null) {
+            return null;
+        }
+
+        Intent merged = new Intent(matchingBase);
+        merged.fillIn(proposedRefinement, 0);
+        return new DisplayResolveInfo(this, merged, mPresentationGetter);
     }
 
     @Override
@@ -201,13 +218,7 @@
     }
 
     @Override
-    public boolean start(Activity activity, Bundle options) {
-        activity.startActivity(mResolvedIntent, options);
-        return true;
-    }
-
-    @Override
-    public boolean startAsCaller(ResolverActivity activity, Bundle options, int userId) {
+    public boolean startAsCaller(Activity activity, Bundle options, int userId) {
         TargetInfo.prepareIntentForCrossProfileLaunch(mResolvedIntent, userId);
         activity.startActivityAsCaller(mResolvedIntent, options, false, userId);
         return true;
@@ -216,10 +227,21 @@
     @Override
     public boolean startAsUser(Activity activity, Bundle options, UserHandle user) {
         TargetInfo.prepareIntentForCrossProfileLaunch(mResolvedIntent, user.getIdentifier());
+        // TODO: is this equivalent to `startActivityAsCaller` with `ignoreTargetSecurity=true`? If
+        // so, we can consolidate on the one API method to show that this flag is the only
+        // distinction between `startAsCaller` and `startAsUser`. We can even bake that flag into
+        // the `TargetActivityStarter` upfront since it just reflects our "safe forwarding mode" --
+        // which is constant for the duration of our lifecycle, leaving clients no other
+        // responsibilities in this logic.
         activity.startActivityAsUser(mResolvedIntent, options, user);
         return false;
     }
 
+    @Override
+    public Intent getTargetIntent() {
+        return mResolvedIntent;
+    }
+
     public boolean isSuspended() {
         return mIsSuspended;
     }
diff --git a/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java b/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java
new file mode 100644
index 0000000..2d9683e
--- /dev/null
+++ b/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java
@@ -0,0 +1,633 @@
+/*
+ * 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.chooser;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.app.Activity;
+import android.app.prediction.AppTarget;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ResolveInfo;
+import android.content.pm.ShortcutInfo;
+import android.os.Bundle;
+import android.os.UserHandle;
+import android.util.HashedStringCache;
+
+import androidx.annotation.VisibleForTesting;
+
+import com.google.common.collect.ImmutableList;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * An implementation of {@link TargetInfo} with immutable data. Any modifications must be made by
+ * creating a new instance (e.g., via {@link ImmutableTargetInfo#toBuilder()}).
+ */
+public final class ImmutableTargetInfo implements TargetInfo {
+    private static final String TAG = "TargetInfo";
+
+    /** Delegate interface to implement {@link TargetInfo#getHashedTargetIdForMetrics()}. */
+    public interface TargetHashProvider {
+        /** Request a hash for the specified {@code target}. */
+        HashedStringCache.HashResult getHashedTargetIdForMetrics(
+                TargetInfo target, Context context);
+    }
+
+    /** Delegate interface to request that the target be launched by a particular API. */
+    public interface TargetActivityStarter {
+        /**
+         * Request that the delegate use the {@link Activity#startAsCaller()} API to launch the
+         * specified {@code target}.
+         *
+         * @return true if the target was launched successfully.
+         */
+        boolean startAsCaller(TargetInfo target, Activity activity, Bundle options, int userId);
+
+        /**
+         * Request that the delegate use the {@link Activity#startAsUser()} API to launch the
+         * specified {@code target}.
+         *
+         * @return true if the target was launched successfully.
+         */
+        boolean startAsUser(TargetInfo target, Activity activity, Bundle options, UserHandle user);
+    }
+
+    enum LegacyTargetType {
+        NOT_LEGACY_TARGET,
+        EMPTY_TARGET_INFO,
+        PLACEHOLDER_TARGET_INFO,
+        SELECTABLE_TARGET_INFO,
+        DISPLAY_RESOLVE_INFO,
+        MULTI_DISPLAY_RESOLVE_INFO
+    };
+
+    /** Builder API to construct {@code ImmutableTargetInfo} instances. */
+    public static class Builder {
+        @Nullable
+        private ComponentName mResolvedComponentName;
+
+        @Nullable
+        private Intent mResolvedIntent;
+
+        @Nullable
+        private Intent mBaseIntentToSend;
+
+        @Nullable
+        private Intent mTargetIntent;
+
+        @Nullable
+        private ComponentName mChooserTargetComponentName;
+
+        @Nullable
+        private ShortcutInfo mDirectShareShortcutInfo;
+
+        @Nullable
+        private AppTarget mDirectShareAppTarget;
+
+        @Nullable
+        private DisplayResolveInfo mDisplayResolveInfo;
+
+        @Nullable
+        private TargetHashProvider mHashProvider;
+
+        @Nullable
+        private Intent mReferrerFillInIntent;
+
+        @Nullable
+        private TargetActivityStarter mActivityStarter;
+
+        @Nullable
+        private ResolveInfo mResolveInfo;
+
+        @Nullable
+        private CharSequence mDisplayLabel;
+
+        @Nullable
+        private CharSequence mExtendedInfo;
+
+        @Nullable
+        private IconHolder mDisplayIconHolder;
+
+        private boolean mIsSuspended;
+        private boolean mIsPinned;
+        private float mModifiedScore = -0.1f;
+        private LegacyTargetType mLegacyType = LegacyTargetType.NOT_LEGACY_TARGET;
+
+        private ImmutableList<Intent> mAlternateSourceIntents = ImmutableList.of();
+        private ImmutableList<DisplayResolveInfo> mAllDisplayTargets = ImmutableList.of();
+
+        /**
+         * Configure an {@link Intent} to be built in to the output target as the resolution for the
+         * requested target data.
+         */
+        public Builder setResolvedIntent(Intent resolvedIntent) {
+            mResolvedIntent = resolvedIntent;
+            return this;
+        }
+
+        /**
+         * Configure an {@link Intent} to be built in to the output target as the "base intent to
+         * send," which may be a refinement of any of our source targets. This is private because
+         * it's only used internally by {@link #tryToCloneWithAppliedRefinement()}; if it's ever
+         * expanded, the builder should probably be responsible for enforcing the refinement check.
+         */
+        private Builder setBaseIntentToSend(Intent baseIntent) {
+            mBaseIntentToSend = baseIntent;
+            return this;
+        }
+
+        /**
+         * Configure an {@link Intent} to be built in to the output as the "target intent."
+         */
+        public Builder setTargetIntent(Intent targetIntent) {
+            mTargetIntent = targetIntent;
+            return this;
+        }
+
+        /**
+         * Configure a fill-in intent provided by the referrer to be used in populating the launch
+         * intent if the output target is ever selected.
+         *
+         * @see android.content.Intent#fillIn(Intent, int)
+         */
+        public Builder setReferrerFillInIntent(@Nullable Intent referrerFillInIntent) {
+            mReferrerFillInIntent = referrerFillInIntent;
+            return this;
+        }
+
+        /**
+         * Configure a {@link ComponentName} to be built in to the output target, as the real
+         * component we were able to resolve on this device given the available target data.
+         */
+        public Builder setResolvedComponentName(@Nullable ComponentName resolvedComponentName) {
+            mResolvedComponentName = resolvedComponentName;
+            return this;
+        }
+
+        /**
+         * Configure a {@link ComponentName} to be built in to the output target, as the component
+         * supposedly associated with a {@link ChooserTarget} from which the builder data is being
+         * derived.
+         */
+        public Builder setChooserTargetComponentName(@Nullable ComponentName componentName) {
+            mChooserTargetComponentName = componentName;
+            return this;
+        }
+
+        /** Configure the {@link TargetActivityStarter} to be built in to the output target. */
+        public Builder setActivityStarter(TargetActivityStarter activityStarter) {
+            mActivityStarter = activityStarter;
+            return this;
+        }
+
+        /** Configure the {@link ResolveInfo} to be built in to the output target. */
+        public Builder setResolveInfo(ResolveInfo resolveInfo) {
+            mResolveInfo = resolveInfo;
+            return this;
+        }
+
+        /** Configure the display label to be built in to the output target. */
+        public Builder setDisplayLabel(CharSequence displayLabel) {
+            mDisplayLabel = displayLabel;
+            return this;
+        }
+
+        /** Configure the extended info to be built in to the output target. */
+        public Builder setExtendedInfo(CharSequence extendedInfo) {
+            mExtendedInfo = extendedInfo;
+            return this;
+        }
+
+        /** Configure the {@link IconHolder} to be built in to the output target. */
+        public Builder setDisplayIconHolder(IconHolder displayIconHolder) {
+            mDisplayIconHolder = displayIconHolder;
+            return this;
+        }
+
+        /** Configure the list of alternate source intents we could resolve for this target. */
+        public Builder setAlternateSourceIntents(List<Intent> sourceIntents) {
+            mAlternateSourceIntents = immutableCopyOrEmpty(sourceIntents);
+            return this;
+        }
+
+       /**
+        * Configure the full list of source intents we could resolve for this target. This is
+        * effectively the same as calling {@link #setResolvedIntent()} with the first element of
+        * the list, and {@link #setAlternateSourceIntents()} with the remainder (or clearing those
+        * fields on the builder if there are no corresponding elements in the list).
+        */
+        public Builder setAllSourceIntents(List<Intent> sourceIntents) {
+            if ((sourceIntents == null) || sourceIntents.isEmpty()) {
+                setResolvedIntent(null);
+                setAlternateSourceIntents(null);
+                return this;
+            }
+
+            setResolvedIntent(sourceIntents.get(0));
+            setAlternateSourceIntents(sourceIntents.subList(1, sourceIntents.size()));
+            return this;
+        }
+
+        /** Configure the list of display targets to be built in to the output target. */
+        public Builder setAllDisplayTargets(List<DisplayResolveInfo> targets) {
+            mAllDisplayTargets = immutableCopyOrEmpty(targets);
+            return this;
+        }
+
+        /** Configure the is-suspended status to be built in to the output target. */
+        public Builder setIsSuspended(boolean isSuspended) {
+            mIsSuspended = isSuspended;
+            return this;
+        }
+
+        /** Configure the is-pinned status to be built in to the output target. */
+        public Builder setIsPinned(boolean isPinned) {
+            mIsPinned = isPinned;
+            return this;
+        }
+
+        /** Configure the modified score to be built in to the output target. */
+        public Builder setModifiedScore(float modifiedScore) {
+            mModifiedScore = modifiedScore;
+            return this;
+        }
+
+        /** Configure the {@link ShortcutInfo} to be built in to the output target. */
+        public Builder setDirectShareShortcutInfo(@Nullable ShortcutInfo shortcutInfo) {
+            mDirectShareShortcutInfo = shortcutInfo;
+            return this;
+        }
+
+        /** Configure the {@link AppTarget} to be built in to the output target. */
+        public Builder setDirectShareAppTarget(@Nullable AppTarget appTarget) {
+            mDirectShareAppTarget = appTarget;
+            return this;
+        }
+
+        /** Configure the {@link DisplayResolveInfo} to be built in to the output target. */
+        public Builder setDisplayResolveInfo(@Nullable DisplayResolveInfo displayResolveInfo) {
+            mDisplayResolveInfo = displayResolveInfo;
+            return this;
+        }
+
+        /** Configure the {@link TargetHashProvider} to be built in to the output target. */
+        public Builder setHashProvider(@Nullable TargetHashProvider hashProvider) {
+            mHashProvider = hashProvider;
+            return this;
+        }
+
+        Builder setLegacyType(@NonNull LegacyTargetType legacyType) {
+            mLegacyType = legacyType;
+            return this;
+        }
+
+        /** Construct an {@code ImmutableTargetInfo} with the current builder data. */
+        public ImmutableTargetInfo build() {
+            List<Intent> sourceIntents = new ArrayList<>();
+            if (mResolvedIntent != null) {
+                sourceIntents.add(mResolvedIntent);
+            }
+            if (mAlternateSourceIntents != null) {
+                sourceIntents.addAll(mAlternateSourceIntents);
+            }
+
+            Intent baseIntentToSend = mBaseIntentToSend;
+            if ((baseIntentToSend == null) && !sourceIntents.isEmpty()) {
+                baseIntentToSend = sourceIntents.get(0);
+            }
+            if (baseIntentToSend != null) {
+                baseIntentToSend = new Intent(baseIntentToSend);
+                if (mReferrerFillInIntent != null) {
+                    baseIntentToSend.fillIn(mReferrerFillInIntent, 0);
+                }
+            }
+
+            return new ImmutableTargetInfo(
+                    baseIntentToSend,
+                    ImmutableList.copyOf(sourceIntents),
+                    mTargetIntent,
+                    mReferrerFillInIntent,
+                    mResolvedComponentName,
+                    mChooserTargetComponentName,
+                    mActivityStarter,
+                    mResolveInfo,
+                    mDisplayLabel,
+                    mExtendedInfo,
+                    mDisplayIconHolder,
+                    mAllDisplayTargets,
+                    mIsSuspended,
+                    mIsPinned,
+                    mModifiedScore,
+                    mDirectShareShortcutInfo,
+                    mDirectShareAppTarget,
+                    mDisplayResolveInfo,
+                    mHashProvider,
+                    mLegacyType);
+        }
+    }
+
+    @Nullable
+    private final Intent mReferrerFillInIntent;
+
+    @Nullable
+    private final ComponentName mResolvedComponentName;
+
+    @Nullable
+    private final ComponentName mChooserTargetComponentName;
+
+    @Nullable
+    private final ShortcutInfo mDirectShareShortcutInfo;
+
+    @Nullable
+    private final AppTarget mDirectShareAppTarget;
+
+    @Nullable
+    private final DisplayResolveInfo mDisplayResolveInfo;
+
+    @Nullable
+    private final TargetHashProvider mHashProvider;
+
+    private final Intent mBaseIntentToSend;
+    private final ImmutableList<Intent> mSourceIntents;
+    private final Intent mTargetIntent;
+    private final TargetActivityStarter mActivityStarter;
+    private final ResolveInfo mResolveInfo;
+    private final CharSequence mDisplayLabel;
+    private final CharSequence mExtendedInfo;
+    private final IconHolder mDisplayIconHolder;
+    private final ImmutableList<DisplayResolveInfo> mAllDisplayTargets;
+    private final boolean mIsSuspended;
+    private final boolean mIsPinned;
+    private final float mModifiedScore;
+    private final LegacyTargetType mLegacyType;
+
+    /** Construct a {@link Builder}. */
+    public static Builder newBuilder() {
+        return new Builder();
+    }
+
+    /** Construct a {@link Builder} pre-initialized to match this target. */
+    public Builder toBuilder() {
+        return newBuilder()
+                .setBaseIntentToSend(getBaseIntentToSend())
+                .setResolvedIntent(getResolvedIntent())
+                .setTargetIntent(getTargetIntent())
+                .setReferrerFillInIntent(getReferrerFillInIntent())
+                .setResolvedComponentName(getResolvedComponentName())
+                .setChooserTargetComponentName(getChooserTargetComponentName())
+                .setActivityStarter(mActivityStarter)
+                .setResolveInfo(getResolveInfo())
+                .setDisplayLabel(getDisplayLabel())
+                .setExtendedInfo(getExtendedInfo())
+                .setDisplayIconHolder(getDisplayIconHolder())
+                .setAllSourceIntents(getAllSourceIntents())
+                .setAllDisplayTargets(getAllDisplayTargets())
+                .setIsSuspended(isSuspended())
+                .setIsPinned(isPinned())
+                .setModifiedScore(getModifiedScore())
+                .setDirectShareShortcutInfo(getDirectShareShortcutInfo())
+                .setDirectShareAppTarget(getDirectShareAppTarget())
+                .setDisplayResolveInfo(getDisplayResolveInfo())
+                .setHashProvider(getHashProvider())
+                .setLegacyType(mLegacyType);
+    }
+
+    @VisibleForTesting
+    Intent getBaseIntentToSend() {
+        return mBaseIntentToSend;
+    }
+
+    @Override
+    @Nullable
+    public ImmutableTargetInfo tryToCloneWithAppliedRefinement(Intent proposedRefinement) {
+        Intent matchingBase =
+                getAllSourceIntents()
+                        .stream()
+                        .filter(i -> i.filterEquals(proposedRefinement))
+                        .findFirst()
+                        .orElse(null);
+        if (matchingBase == null) {
+            return null;
+        }
+
+        Intent merged = new Intent(matchingBase);
+        merged.fillIn(proposedRefinement, 0);
+        return toBuilder().setBaseIntentToSend(merged).build();
+    }
+
+    @Override
+    public Intent getResolvedIntent() {
+        return (mSourceIntents.isEmpty() ? null : mSourceIntents.get(0));
+    }
+
+    @Override
+    public Intent getTargetIntent() {
+        return mTargetIntent;
+    }
+
+    @Nullable
+    public Intent getReferrerFillInIntent() {
+        return mReferrerFillInIntent;
+    }
+
+    @Override
+    @Nullable
+    public ComponentName getResolvedComponentName() {
+        return mResolvedComponentName;
+    }
+
+    @Override
+    @Nullable
+    public ComponentName getChooserTargetComponentName() {
+        return mChooserTargetComponentName;
+    }
+
+    @Override
+    public boolean startAsCaller(Activity activity, Bundle options, int userId) {
+        // TODO: make sure that the component name is set in all cases
+        return mActivityStarter.startAsCaller(this, activity, options, userId);
+    }
+
+    @Override
+    public boolean startAsUser(Activity activity, Bundle options, UserHandle user) {
+        // TODO: make sure that the component name is set in all cases
+        return mActivityStarter.startAsUser(this, activity, options, user);
+    }
+
+    @Override
+    public ResolveInfo getResolveInfo() {
+        return mResolveInfo;
+    }
+
+    @Override
+    public CharSequence getDisplayLabel() {
+        return mDisplayLabel;
+    }
+
+    @Override
+    public CharSequence getExtendedInfo() {
+        return mExtendedInfo;
+    }
+
+    @Override
+    public IconHolder getDisplayIconHolder() {
+        return mDisplayIconHolder;
+    }
+
+    @Override
+    public List<Intent> getAllSourceIntents() {
+        return mSourceIntents;
+    }
+
+    @Override
+    public ArrayList<DisplayResolveInfo> getAllDisplayTargets() {
+        ArrayList<DisplayResolveInfo> targets = new ArrayList<>();
+        targets.addAll(mAllDisplayTargets);
+        return targets;
+    }
+
+    @Override
+    public boolean isSuspended() {
+        return mIsSuspended;
+    }
+
+    @Override
+    public boolean isPinned() {
+        return mIsPinned;
+    }
+
+    @Override
+    public float getModifiedScore() {
+        return mModifiedScore;
+    }
+
+    @Override
+    @Nullable
+    public ShortcutInfo getDirectShareShortcutInfo() {
+        return mDirectShareShortcutInfo;
+    }
+
+    @Override
+    @Nullable
+    public AppTarget getDirectShareAppTarget() {
+        return mDirectShareAppTarget;
+    }
+
+    @Override
+    @Nullable
+    public DisplayResolveInfo getDisplayResolveInfo() {
+        return mDisplayResolveInfo;
+    }
+
+    @Override
+    public HashedStringCache.HashResult getHashedTargetIdForMetrics(Context context) {
+        return (mHashProvider == null)
+                ? null : mHashProvider.getHashedTargetIdForMetrics(this, context);
+    }
+
+    @VisibleForTesting
+    @Nullable
+    TargetHashProvider getHashProvider() {
+        return mHashProvider;
+    }
+
+    @Override
+    public boolean isEmptyTargetInfo() {
+        return mLegacyType == LegacyTargetType.EMPTY_TARGET_INFO;
+    }
+
+    @Override
+    public boolean isPlaceHolderTargetInfo() {
+        return mLegacyType == LegacyTargetType.PLACEHOLDER_TARGET_INFO;
+    }
+
+    @Override
+    public boolean isNotSelectableTargetInfo() {
+        return isEmptyTargetInfo() || isPlaceHolderTargetInfo();
+    }
+
+    @Override
+    public boolean isSelectableTargetInfo() {
+        return mLegacyType == LegacyTargetType.SELECTABLE_TARGET_INFO;
+    }
+
+    @Override
+    public boolean isChooserTargetInfo() {
+        return isNotSelectableTargetInfo() || isSelectableTargetInfo();
+    }
+
+    @Override
+    public boolean isMultiDisplayResolveInfo() {
+        return mLegacyType == LegacyTargetType.MULTI_DISPLAY_RESOLVE_INFO;
+    }
+
+    @Override
+    public boolean isDisplayResolveInfo() {
+        return (mLegacyType == LegacyTargetType.DISPLAY_RESOLVE_INFO)
+                || isMultiDisplayResolveInfo();
+    }
+
+    private ImmutableTargetInfo(
+            Intent baseIntentToSend,
+            ImmutableList<Intent> sourceIntents,
+            Intent targetIntent,
+            @Nullable Intent referrerFillInIntent,
+            @Nullable ComponentName resolvedComponentName,
+            @Nullable ComponentName chooserTargetComponentName,
+            TargetActivityStarter activityStarter,
+            ResolveInfo resolveInfo,
+            CharSequence displayLabel,
+            CharSequence extendedInfo,
+            IconHolder iconHolder,
+            ImmutableList<DisplayResolveInfo> allDisplayTargets,
+            boolean isSuspended,
+            boolean isPinned,
+            float modifiedScore,
+            @Nullable ShortcutInfo directShareShortcutInfo,
+            @Nullable AppTarget directShareAppTarget,
+            @Nullable DisplayResolveInfo displayResolveInfo,
+            @Nullable TargetHashProvider hashProvider,
+            LegacyTargetType legacyType) {
+        mBaseIntentToSend = baseIntentToSend;
+        mSourceIntents = sourceIntents;
+        mTargetIntent = targetIntent;
+        mReferrerFillInIntent = referrerFillInIntent;
+        mResolvedComponentName = resolvedComponentName;
+        mChooserTargetComponentName = chooserTargetComponentName;
+        mActivityStarter = activityStarter;
+        mResolveInfo = resolveInfo;
+        mDisplayLabel = displayLabel;
+        mExtendedInfo = extendedInfo;
+        mDisplayIconHolder = iconHolder;
+        mAllDisplayTargets = allDisplayTargets;
+        mIsSuspended = isSuspended;
+        mIsPinned = isPinned;
+        mModifiedScore = modifiedScore;
+        mDirectShareShortcutInfo = directShareShortcutInfo;
+        mDirectShareAppTarget = directShareAppTarget;
+        mDisplayResolveInfo = displayResolveInfo;
+        mHashProvider = hashProvider;
+        mLegacyType = legacyType;
+    }
+
+    private static <E> ImmutableList<E> immutableCopyOrEmpty(@Nullable List<E> source) {
+        return (source == null) ? ImmutableList.of() : ImmutableList.copyOf(source);
+    }
+}
diff --git a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java
index 29f00a3..b97e6b4 100644
--- a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java
+++ b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java
@@ -17,12 +17,14 @@
 package com.android.intentresolver.chooser;
 
 import android.app.Activity;
+import android.content.Intent;
 import android.os.Bundle;
 import android.os.UserHandle;
 
-import com.android.intentresolver.ResolverActivity;
+import androidx.annotation.Nullable;
 
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 
 /**
@@ -30,7 +32,7 @@
  */
 public class MultiDisplayResolveInfo extends DisplayResolveInfo {
 
-    ArrayList<DisplayResolveInfo> mTargetInfos = new ArrayList<>();
+    final ArrayList<DisplayResolveInfo> mTargetInfos;
 
     // Index of selected target
     private int mSelected = -1;
@@ -66,8 +68,9 @@
 
     /**
      * List of all {@link DisplayResolveInfo}s included in this target.
-     * TODO: provide as a generic {@code List<DisplayResolveInfo>} once {@link ChooserActivity}
-     * stops requiring the signature to match that of the other "lists" it builds up.
+     * TODO: provide as a generic {@code List<DisplayResolveInfo>} once
+     *  {@link com.android.intentresolver.ChooserActivity} stops requiring the signature to match
+     *  that of the other "lists" it builds up.
      */
     @Override
     public ArrayList<DisplayResolveInfo> getAllDisplayTargets() {
@@ -93,12 +96,27 @@
     }
 
     @Override
-    public boolean start(Activity activity, Bundle options) {
-        return mTargetInfos.get(mSelected).start(activity, options);
+    @Nullable
+    public MultiDisplayResolveInfo tryToCloneWithAppliedRefinement(Intent proposedRefinement) {
+        final int size = mTargetInfos.size();
+        ArrayList<DisplayResolveInfo> targetInfos = new ArrayList<>(size);
+        for (int i = 0; i < size; i++) {
+            DisplayResolveInfo target = mTargetInfos.get(i);
+            DisplayResolveInfo targetClone = (i == mSelected)
+                    ? target.tryToCloneWithAppliedRefinement(proposedRefinement)
+                    : new DisplayResolveInfo(target);
+            if (targetClone == null) {
+                return null;
+            }
+            targetInfos.add(targetClone);
+        }
+        MultiDisplayResolveInfo clone = new MultiDisplayResolveInfo(targetInfos);
+        clone.mSelected = mSelected;
+        return clone;
     }
 
     @Override
-    public boolean startAsCaller(ResolverActivity activity, Bundle options, int userId) {
+    public boolean startAsCaller(Activity activity, Bundle options, int userId) {
         return mTargetInfos.get(mSelected).startAsCaller(activity, options, userId);
     }
 
@@ -106,4 +124,16 @@
     public boolean startAsUser(Activity activity, Bundle options, UserHandle user) {
         return mTargetInfos.get(mSelected).startAsUser(activity, options, user);
     }
+
+    @Override
+    public Intent getTargetIntent() {
+        return mTargetInfos.get(mSelected).getTargetIntent();
+    }
+
+    @Override
+    public List<Intent> getAllSourceIntents() {
+        return hasSelected()
+                ? mTargetInfos.get(mSelected).getAllSourceIntents()
+                : Collections.emptyList();
+    }
 }
diff --git a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java
index d633337..6444e13 100644
--- a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java
+++ b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java
@@ -16,34 +16,30 @@
 
 package com.android.intentresolver.chooser;
 
+import android.annotation.Nullable;
 import android.app.Activity;
-import android.content.ComponentName;
 import android.content.Context;
-import android.content.Intent;
-import android.content.pm.ResolveInfo;
 import android.graphics.drawable.AnimatedVectorDrawable;
 import android.graphics.drawable.Drawable;
 import android.os.Bundle;
 import android.os.UserHandle;
 
 import com.android.intentresolver.R;
-import com.android.intentresolver.ResolverActivity;
 
-import java.util.List;
+import java.util.function.Supplier;
 
 /**
  * Distinguish between targets that selectable by the user, vs those that are
  * placeholders for the system while information is loading in an async manner.
  */
-public abstract class NotSelectableTargetInfo extends ChooserTargetInfo {
+public final class NotSelectableTargetInfo {
     /** Create a non-selectable {@link TargetInfo} with no content. */
     public static TargetInfo newEmptyTargetInfo() {
-        return new NotSelectableTargetInfo() {
-                @Override
-                public boolean isEmptyTargetInfo() {
-                    return true;
-                }
-            };
+        return ImmutableTargetInfo.newBuilder()
+                .setLegacyType(ImmutableTargetInfo.LegacyTargetType.EMPTY_TARGET_INFO)
+                .setDisplayIconHolder(makeReadOnlyIconHolder(() -> null))
+                .setActivityStarter(makeNoOpActivityStarter())
+                .build();
     }
 
     /**
@@ -51,102 +47,51 @@
      * unless/until it can be replaced by the result of a pending asynchronous load.
      */
     public static TargetInfo newPlaceHolderTargetInfo(Context context) {
-        return new NotSelectableTargetInfo() {
-                @Override
-                public boolean isPlaceHolderTargetInfo() {
-                    return true;
-                }
-
-                @Override
-                public IconHolder getDisplayIconHolder() {
-                    return new IconHolder() {
-                        @Override
-                        public Drawable getDisplayIcon() {
-                            AnimatedVectorDrawable avd = (AnimatedVectorDrawable)
-                                    context.getDrawable(
-                                            R.drawable.chooser_direct_share_icon_placeholder);
-                            avd.start();  // Start animation after generation.
-                            return avd;
-                        }
-
-                        @Override
-                        public void setDisplayIcon(Drawable icon) {}
-                    };
-                }
-
-                @Override
-                public boolean hasDisplayIcon() {
-                    return true;
-                }
-            };
+        return ImmutableTargetInfo.newBuilder()
+                .setLegacyType(ImmutableTargetInfo.LegacyTargetType.PLACEHOLDER_TARGET_INFO)
+                .setDisplayIconHolder(
+                        makeReadOnlyIconHolder(() -> makeStartedPlaceholderDrawable(context)))
+                .setActivityStarter(makeNoOpActivityStarter())
+                .build();
     }
 
-    public final boolean isNotSelectableTargetInfo() {
-        return true;
+    private static Drawable makeStartedPlaceholderDrawable(Context context) {
+        AnimatedVectorDrawable avd = (AnimatedVectorDrawable) context.getDrawable(
+                R.drawable.chooser_direct_share_icon_placeholder);
+        avd.start();  // Start animation after generation.
+        return avd;
     }
 
-    public Intent getResolvedIntent() {
-        return null;
-    }
-
-    public ComponentName getResolvedComponentName() {
-        return null;
-    }
-
-    public boolean start(Activity activity, Bundle options) {
-        return false;
-    }
-
-    public boolean startAsCaller(ResolverActivity activity, Bundle options, int userId) {
-        return false;
-    }
-
-    public boolean startAsUser(Activity activity, Bundle options, UserHandle user) {
-        return false;
-    }
-
-    public ResolveInfo getResolveInfo() {
-        return null;
-    }
-
-    public CharSequence getDisplayLabel() {
-        return null;
-    }
-
-    public CharSequence getExtendedInfo() {
-        return null;
-    }
-
-    public TargetInfo cloneFilledIn(Intent fillInIntent, int flags) {
-        return null;
-    }
-
-    public List<Intent> getAllSourceIntents() {
-        return null;
-    }
-
-    public float getModifiedScore() {
-        return -0.1f;
-    }
-
-    public boolean isSuspended() {
-        return false;
-    }
-
-    public boolean isPinned() {
-        return false;
-    }
-
-    @Override
-    public IconHolder getDisplayIconHolder() {
-        return new IconHolder() {
+    private static ImmutableTargetInfo.IconHolder makeReadOnlyIconHolder(
+            Supplier</* @Nullable */ Drawable> iconProvider) {
+        return new ImmutableTargetInfo.IconHolder() {
             @Override
+            @Nullable
             public Drawable getDisplayIcon() {
-                return null;
+                return iconProvider.get();
             }
 
             @Override
             public void setDisplayIcon(Drawable icon) {}
         };
     }
+
+    private static ImmutableTargetInfo.TargetActivityStarter makeNoOpActivityStarter() {
+        return new ImmutableTargetInfo.TargetActivityStarter() {
+            @Override
+            public boolean startAsCaller(
+                    TargetInfo target, Activity activity, Bundle options, int userId) {
+                return false;
+            }
+
+            @Override
+            public boolean startAsUser(
+                    TargetInfo target, Activity activity, Bundle options, UserHandle user) {
+                return false;
+            }
+        };
+    }
+
+    // TODO: merge all the APIs up to a single `TargetInfo` class.
+    private NotSelectableTargetInfo() {}
 }
diff --git a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java
index 3ab5017..1fbe2da 100644
--- a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java
+++ b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java
@@ -33,7 +33,6 @@
 import android.util.HashedStringCache;
 import android.util.Log;
 
-import com.android.intentresolver.ResolverActivity;
 import com.android.internal.config.sysui.SystemUiDeviceConfigFlags;
 
 import java.util.ArrayList;
@@ -79,7 +78,6 @@
     private final CharSequence mChooserTargetUnsanitizedTitle;
     private final Icon mChooserTargetIcon;
     private final Bundle mChooserTargetIntentExtras;
-    private final int mFillInFlags;
     private final boolean mIsPinned;
     private final float mModifiedScore;
     private final boolean mIsSuspended;
@@ -92,12 +90,6 @@
     private final TargetActivityStarter mActivityStarter;
 
     /**
-     * A refinement intent from the caller, if any (see
-     * {@link Intent#EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER})
-     */
-    private final Intent mFillInIntent;
-
-    /**
      * An intent containing referrer URI (see {@link Activity#getReferrer()} (possibly {@code null})
      * in its extended data under the key {@link Intent#EXTRA_REFERRER}.
      */
@@ -160,6 +152,7 @@
                 sourceInfo,
                 backupResolveInfo,
                 resolvedIntent,
+                null,
                 chooserTargetComponentName,
                 chooserTargetUnsanitizedTitle,
                 chooserTargetIcon,
@@ -167,15 +160,14 @@
                 modifiedScore,
                 shortcutInfo,
                 appTarget,
-                referrerFillInIntent,
-                /* fillInIntent = */ null,
-                /* fillInFlags = */ 0);
+                referrerFillInIntent);
     }
 
     private SelectableTargetInfo(
             @Nullable DisplayResolveInfo sourceInfo,
             @Nullable ResolveInfo backupResolveInfo,
             Intent resolvedIntent,
+            @Nullable Intent baseIntentToSend,
             ComponentName chooserTargetComponentName,
             CharSequence chooserTargetUnsanitizedTitle,
             Icon chooserTargetIcon,
@@ -183,9 +175,7 @@
             float modifiedScore,
             @Nullable ShortcutInfo shortcutInfo,
             @Nullable AppTarget appTarget,
-            Intent referrerFillInIntent,
-            @Nullable Intent fillInIntent,
-            int fillInFlags) {
+            Intent referrerFillInIntent) {
         mSourceInfo = sourceInfo;
         mBackupResolveInfo = backupResolveInfo;
         mResolvedIntent = resolvedIntent;
@@ -193,8 +183,6 @@
         mShortcutInfo = shortcutInfo;
         mAppTarget = appTarget;
         mReferrerFillInIntent = referrerFillInIntent;
-        mFillInIntent = fillInIntent;
-        mFillInFlags = fillInFlags;
         mChooserTargetComponentName = chooserTargetComponentName;
         mChooserTargetUnsanitizedTitle = chooserTargetUnsanitizedTitle;
         mChooserTargetIcon = chooserTargetIcon;
@@ -210,9 +198,8 @@
         mAllSourceIntents = getAllSourceIntents(sourceInfo);
 
         mBaseIntentToSend = getBaseIntentToSend(
+                baseIntentToSend,
                 mResolvedIntent,
-                mFillInIntent,
-                mFillInFlags,
                 mReferrerFillInIntent);
 
         mHashProvider = context -> {
@@ -263,11 +250,12 @@
         };
     }
 
-    private SelectableTargetInfo(SelectableTargetInfo other, Intent fillInIntent, int flags) {
+    private SelectableTargetInfo(SelectableTargetInfo other, Intent baseIntentToSend) {
         this(
                 other.mSourceInfo,
                 other.mBackupResolveInfo,
                 other.mResolvedIntent,
+                baseIntentToSend,
                 other.mChooserTargetComponentName,
                 other.mChooserTargetUnsanitizedTitle,
                 other.mChooserTargetIcon,
@@ -275,14 +263,25 @@
                 other.mModifiedScore,
                 other.mShortcutInfo,
                 other.mAppTarget,
-                other.mReferrerFillInIntent,
-                fillInIntent,
-                flags);
+                other.mReferrerFillInIntent);
     }
 
     @Override
-    public TargetInfo cloneFilledIn(Intent fillInIntent, int flags) {
-        return new SelectableTargetInfo(this, fillInIntent, flags);
+    @Nullable
+    public TargetInfo tryToCloneWithAppliedRefinement(Intent proposedRefinement) {
+        Intent matchingBase =
+                getAllSourceIntents()
+                        .stream()
+                        .filter(i -> i.filterEquals(proposedRefinement))
+                        .findFirst()
+                        .orElse(null);
+        if (matchingBase == null) {
+            return null;
+        }
+
+        Intent merged = new Intent(matchingBase);
+        merged.fillIn(proposedRefinement, 0);
+        return new SelectableTargetInfo(this, merged);
     }
 
     @Override
@@ -332,12 +331,7 @@
     }
 
     @Override
-    public boolean start(Activity activity, Bundle options) {
-        return mActivityStarter.start(activity, options);
-    }
-
-    @Override
-    public boolean startAsCaller(ResolverActivity activity, Bundle options, int userId) {
+    public boolean startAsCaller(Activity activity, Bundle options, int userId) {
         return mActivityStarter.startAsCaller(activity, options, userId);
     }
 
@@ -346,6 +340,12 @@
         return mActivityStarter.startAsUser(activity, options, user);
     }
 
+    @Nullable
+    @Override
+    public Intent getTargetIntent() {
+        return mBaseIntentToSend;
+    }
+
     @Override
     public ResolveInfo getResolveInfo() {
         return mResolveInfo;
@@ -418,18 +418,14 @@
 
     @Nullable
     private static Intent getBaseIntentToSend(
-            @Nullable Intent resolvedIntent,
-            Intent fillInIntent,
-            int fillInFlags,
+            @Nullable Intent providedBase,
+            @Nullable Intent fallbackBase,
             Intent referrerFillInIntent) {
-        Intent result = resolvedIntent;
+        Intent result = (providedBase != null) ? providedBase : fallbackBase;
         if (result == null) {
             Log.e(TAG, "ChooserTargetInfo: no base intent available to send");
         } else {
             result = new Intent(result);
-            if (fillInIntent != null) {
-                result.fillIn(fillInIntent, fillInFlags);
-            }
             result.fillIn(referrerFillInIntent, 0);
         }
         return result;
diff --git a/java/src/com/android/intentresolver/chooser/TargetInfo.java b/java/src/com/android/intentresolver/chooser/TargetInfo.java
index 72dd1b0..2f48704 100644
--- a/java/src/com/android/intentresolver/chooser/TargetInfo.java
+++ b/java/src/com/android/intentresolver/chooser/TargetInfo.java
@@ -32,8 +32,6 @@
 import android.text.TextUtils;
 import android.util.HashedStringCache;
 
-import com.android.intentresolver.ResolverActivity;
-
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Objects;
@@ -88,6 +86,12 @@
     Intent getResolvedIntent();
 
     /**
+     * Get the target intent, the one that will be used with one of the <code>start</code> methods.
+     * @return the intent with target will be launced with.
+     */
+    @Nullable Intent getTargetIntent();
+
+    /**
      * Get the resolved component name that represents this target. Note that this may not
      * be the component that will be directly launched by calling one of the <code>start</code>
      * methods provided; this is the component that will be credited with the launch. This may be
@@ -118,24 +122,15 @@
     }
 
     /**
-     * Start the activity referenced by this target.
-     *
-     * @param activity calling Activity performing the launch
-     * @param options ActivityOptions bundle
-     * @return true if the start completed successfully
-     */
-    boolean start(Activity activity, Bundle options);
-
-    /**
-     * Start the activity referenced by this target as if the ResolverActivity's caller
-     * was performing the start operation.
+     * Start the activity referenced by this target as if the Activity's caller was performing the
+     * start operation.
      *
      * @param activity calling Activity (actually) performing the launch
      * @param options ActivityOptions bundle
      * @param userId userId to start as or {@link UserHandle#USER_NULL} for activity's caller
      * @return true if the start completed successfully
      */
-    boolean startAsCaller(ResolverActivity activity, Bundle options, int userId);
+    boolean startAsCaller(Activity activity, Bundle options, int userId);
 
     /**
      * Start the activity referenced by this target as a given user.
@@ -187,10 +182,25 @@
     default boolean hasDisplayIcon() {
         return getDisplayIconHolder().getDisplayIcon() != null;
     }
+
     /**
-     * Clone this target with the given fill-in information.
+     * Attempt to apply a {@code proposedRefinement} that the {@link ChooserRefinementManager}
+     * received from the caller's refinement flow. This may succeed only if the target has a source
+     * intent that matches the filtering parameters of the proposed refinement (according to
+     * {@link Intent#filterEquals()}). Then the first such match is the "base intent," and the
+     * proposed refinement is merged into that base (via {@link Intent#fillIn()}; this can never
+     * result in a change to the {@link Intent#filterEquals()} status of the base, but may e.g. add
+     * new "extras" that weren't previously given in the base intent).
+     *
+     * @return a copy of this {@link TargetInfo} where the "base intent to send" is the result of
+     * merging the refinement into the best-matching source intent, if possible. If there is no
+     * suitable match for the proposed refinement, or if merging fails for any other reason, this
+     * returns null.
+     *
+     * @see android.content.Intent#fillIn(Intent, int)
      */
-    TargetInfo cloneFilledIn(Intent fillInIntent, int flags);
+    @Nullable
+    TargetInfo tryToCloneWithAppliedRefinement(Intent proposedRefinement);
 
     /**
      * @return the list of supported source intents deduped against this single target
diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java
new file mode 100644
index 0000000..205be44
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java
@@ -0,0 +1,310 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.contentpreview;
+
+import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_FILE;
+import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_IMAGE;
+import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_TEXT;
+
+import android.content.ClipData;
+import android.content.ClipDescription;
+import android.content.ContentInterface;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.net.Uri;
+import android.os.RemoteException;
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import androidx.annotation.Nullable;
+
+import com.android.intentresolver.ImageLoader;
+import com.android.intentresolver.flags.FeatureFlagRepository;
+import com.android.intentresolver.widget.ActionRow;
+import com.android.intentresolver.widget.ImagePreviewView;
+import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+
+/**
+ * Collection of helpers for building the content preview UI displayed in
+ * {@link com.android.intentresolver.ChooserActivity}.
+ *
+ * A content preview façade.
+ */
+public final class ChooserContentPreviewUi {
+    /**
+     * Delegate to build the default system action buttons to display in the preview layout, if/when
+     * they're determined to be appropriate for the particular preview we display.
+     * TODO: clarify why action buttons are part of preview logic.
+     */
+    public interface ActionFactory {
+        /** Create an action that copies the share content to the clipboard. */
+        ActionRow.Action createCopyButton();
+
+        /** Create an action that opens the share content in a system-default editor. */
+        @Nullable
+        ActionRow.Action createEditButton();
+
+        /** Create an "Share to Nearby" action. */
+        @Nullable
+        ActionRow.Action createNearbyButton();
+
+        /** Create custom actions */
+        List<ActionRow.Action> createCustomActions();
+
+        /**
+         * Provides a share modification action, if any.
+         */
+        @Nullable
+        Runnable getModifyShareAction();
+
+        /**
+         * <p>
+         * Creates an exclude-text action that can be called when the user changes shared text
+         * status in the Media + Text preview.
+         * </p>
+         * <p>
+         * <code>true</code> argument value indicates that the text should be excluded.
+         * </p>
+         */
+        Consumer<Boolean> getExcludeSharedTextAction();
+    }
+
+    /**
+     * Testing shim to specify whether a given mime type is considered to be an "image."
+     *
+     * TODO: move away from {@link ChooserActivityOverrideData} as a model to configure our tests,
+     * then migrate {@link com.android.intentresolver.ChooserActivity#isImageType(String)} into this
+     * class.
+     */
+    public interface ImageMimeTypeClassifier {
+        /** @return whether the specified {@code mimeType} is classified as an "image" type. */
+        boolean isImageType(String mimeType);
+    }
+
+    private final ContentPreviewUi mContentPreviewUi;
+
+    public ChooserContentPreviewUi(
+            Intent targetIntent,
+            ContentInterface contentResolver,
+            ImageMimeTypeClassifier imageClassifier,
+            ImageLoader imageLoader,
+            ActionFactory actionFactory,
+            TransitionElementStatusCallback transitionElementStatusCallback,
+            FeatureFlagRepository featureFlagRepository) {
+
+        mContentPreviewUi = createContentPreview(
+                targetIntent,
+                contentResolver,
+                imageClassifier,
+                imageLoader,
+                actionFactory,
+                transitionElementStatusCallback,
+                featureFlagRepository);
+        if (mContentPreviewUi.getType() != CONTENT_PREVIEW_IMAGE) {
+            transitionElementStatusCallback.onAllTransitionElementsReady();
+        }
+    }
+
+    private ContentPreviewUi createContentPreview(
+            Intent targetIntent,
+            ContentInterface contentResolver,
+            ImageMimeTypeClassifier imageClassifier,
+            ImageLoader imageLoader,
+            ActionFactory actionFactory,
+            TransitionElementStatusCallback transitionElementStatusCallback,
+            FeatureFlagRepository featureFlagRepository) {
+        int type = findPreferredContentPreview(targetIntent, contentResolver, imageClassifier);
+        switch (type) {
+            case CONTENT_PREVIEW_TEXT:
+                return createTextPreview(
+                        targetIntent, actionFactory, imageLoader, featureFlagRepository);
+
+            case CONTENT_PREVIEW_FILE:
+                return new FileContentPreviewUi(
+                        extractContentUris(targetIntent),
+                        actionFactory,
+                        imageLoader,
+                        contentResolver,
+                        featureFlagRepository);
+
+            case CONTENT_PREVIEW_IMAGE:
+                return createImagePreview(
+                        targetIntent,
+                        actionFactory,
+                        contentResolver,
+                        imageClassifier,
+                        imageLoader,
+                        transitionElementStatusCallback,
+                        featureFlagRepository);
+        }
+
+        return new NoContextPreviewUi(type);
+    }
+
+    public int getPreferredContentPreview() {
+        return mContentPreviewUi.getType();
+    }
+
+    /**
+     * Display a content preview of the specified {@code previewType} to preview the content of the
+     * specified {@code intent}.
+     */
+    public ViewGroup displayContentPreview(
+            Resources resources, LayoutInflater layoutInflater, ViewGroup parent) {
+
+        return mContentPreviewUi.display(resources, layoutInflater, parent);
+    }
+
+    /** Determine the most appropriate type of preview to show for the provided {@link Intent}. */
+    @ContentPreviewType
+    private static int findPreferredContentPreview(
+            Intent targetIntent,
+            ContentInterface resolver,
+            ImageMimeTypeClassifier imageClassifier) {
+        /* In {@link android.content.Intent#getType}, the app may specify a very general mime type
+         * that broadly covers all data being shared, such as {@literal *}/* when sending an image
+         * and text. We therefore should inspect each item for the preferred type, in order: IMAGE,
+         * FILE, TEXT.  */
+        final String action = targetIntent.getAction();
+        final String type = targetIntent.getType();
+        final boolean isSend = Intent.ACTION_SEND.equals(action);
+        final boolean isSendMultiple = Intent.ACTION_SEND_MULTIPLE.equals(action);
+
+        if (!(isSend || isSendMultiple)
+                || (type != null && ClipDescription.compareMimeTypes(type, "text/*"))) {
+            return CONTENT_PREVIEW_TEXT;
+        }
+
+        if (isSend) {
+            Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM);
+            return findPreferredContentPreview(uri, resolver, imageClassifier);
+        }
+
+        List<Uri> uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
+        if (uris == null || uris.isEmpty()) {
+            return CONTENT_PREVIEW_TEXT;
+        }
+
+        for (Uri uri : uris) {
+            // Defaulting to file preview when there are mixed image/file types is
+            // preferable, as it shows the user the correct number of items being shared
+            int uriPreviewType = findPreferredContentPreview(uri, resolver, imageClassifier);
+            if (uriPreviewType == CONTENT_PREVIEW_FILE) {
+                return CONTENT_PREVIEW_FILE;
+            }
+        }
+
+        return CONTENT_PREVIEW_IMAGE;
+    }
+
+    @ContentPreviewType
+    private static int findPreferredContentPreview(
+            Uri uri, ContentInterface resolver, ImageMimeTypeClassifier imageClassifier) {
+        if (uri == null) {
+            return CONTENT_PREVIEW_TEXT;
+        }
+
+        String mimeType = null;
+        try {
+            mimeType = resolver.getType(uri);
+        } catch (RemoteException ignored) {
+        }
+        return imageClassifier.isImageType(mimeType) ? CONTENT_PREVIEW_IMAGE : CONTENT_PREVIEW_FILE;
+    }
+
+    private static TextContentPreviewUi createTextPreview(
+            Intent targetIntent,
+            ChooserContentPreviewUi.ActionFactory actionFactory,
+            ImageLoader imageLoader,
+            FeatureFlagRepository featureFlagRepository) {
+        CharSequence sharingText = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT);
+        String previewTitle = targetIntent.getStringExtra(Intent.EXTRA_TITLE);
+        ClipData previewData = targetIntent.getClipData();
+        Uri previewThumbnail = null;
+        if (previewData != null) {
+            if (previewData.getItemCount() > 0) {
+                ClipData.Item previewDataItem = previewData.getItemAt(0);
+                previewThumbnail = previewDataItem.getUri();
+            }
+        }
+        return new TextContentPreviewUi(
+                sharingText,
+                previewTitle,
+                previewThumbnail,
+                actionFactory,
+                imageLoader,
+                featureFlagRepository);
+    }
+
+    static ImageContentPreviewUi createImagePreview(
+            Intent targetIntent,
+            ChooserContentPreviewUi.ActionFactory actionFactory,
+            ContentInterface contentResolver,
+            ChooserContentPreviewUi.ImageMimeTypeClassifier imageClassifier,
+            ImageLoader imageLoader,
+            ImagePreviewView.TransitionElementStatusCallback transitionElementStatusCallback,
+            FeatureFlagRepository featureFlagRepository) {
+        CharSequence text = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT);
+        String action = targetIntent.getAction();
+        // TODO: why don't we use image classifier for single-element ACTION_SEND?
+        final List<Uri> imageUris = Intent.ACTION_SEND.equals(action)
+                ? extractContentUris(targetIntent)
+                : extractContentUris(targetIntent)
+                        .stream()
+                        .filter(uri -> {
+                            String type = null;
+                            try {
+                                type = contentResolver.getType(uri);
+                            } catch (RemoteException ignored) {
+                            }
+                            return imageClassifier.isImageType(type);
+                        })
+                        .collect(Collectors.toList());
+        return new ImageContentPreviewUi(
+                imageUris,
+                text,
+                actionFactory,
+                imageLoader,
+                transitionElementStatusCallback,
+                featureFlagRepository);
+    }
+
+    private static List<Uri> extractContentUris(Intent targetIntent) {
+        List<Uri> uris = new ArrayList<>();
+        if (Intent.ACTION_SEND.equals(targetIntent.getAction())) {
+            Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM);
+            if (ContentPreviewUi.validForContentPreview(uri)) {
+                uris.add(uri);
+            }
+        } else {
+            List<Uri> receivedUris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
+            if (receivedUris != null) {
+                for (Uri uri : receivedUris) {
+                    if (ContentPreviewUi.validForContentPreview(uri)) {
+                        uris.add(uri);
+                    }
+                }
+            }
+        }
+        return uris;
+    }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java
new file mode 100644
index 0000000..ebab147
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.contentpreview;
+
+import static java.lang.annotation.RetentionPolicy.SOURCE;
+
+import android.annotation.IntDef;
+
+import java.lang.annotation.Retention;
+
+@Retention(SOURCE)
+@IntDef({ContentPreviewType.CONTENT_PREVIEW_FILE,
+        ContentPreviewType.CONTENT_PREVIEW_IMAGE,
+        ContentPreviewType.CONTENT_PREVIEW_TEXT})
+public @interface ContentPreviewType {
+    // Starting at 1 since 0 is considered "undefined" for some of the database transformations
+    // of tron logs.
+    int CONTENT_PREVIEW_IMAGE = 1;
+    int CONTENT_PREVIEW_FILE = 2;
+    int CONTENT_PREVIEW_TEXT = 3;
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java
new file mode 100644
index 0000000..39856e6
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.contentpreview;
+
+import static android.content.ContentProvider.getUserIdFromUri;
+
+import android.animation.ObjectAnimator;
+import android.animation.ValueAnimator;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.os.UserHandle;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewStub;
+import android.view.animation.DecelerateInterpolator;
+
+import androidx.annotation.LayoutRes;
+
+import com.android.intentresolver.R;
+import com.android.intentresolver.flags.FeatureFlagRepository;
+import com.android.intentresolver.flags.Flags;
+import com.android.intentresolver.widget.ActionRow;
+import com.android.intentresolver.widget.RoundedRectImageView;
+
+import java.util.ArrayList;
+import java.util.List;
+
+abstract class ContentPreviewUi {
+    private static final int IMAGE_FADE_IN_MILLIS = 150;
+    static final String TAG = "ChooserPreview";
+
+    @ContentPreviewType
+    public abstract int getType();
+
+    public abstract ViewGroup display(
+            Resources resources, LayoutInflater layoutInflater, ViewGroup parent);
+
+    protected static int getActionRowLayout(FeatureFlagRepository featureFlagRepository) {
+        return featureFlagRepository.isEnabled(Flags.SHARESHEET_CUSTOM_ACTIONS)
+                ? R.layout.scrollable_chooser_action_row
+                : R.layout.chooser_action_row;
+    }
+
+    protected static ActionRow inflateActionRow(ViewGroup parent, @LayoutRes int actionRowLayout) {
+        final ViewStub stub = parent.findViewById(com.android.intentresolver.R.id.action_row_stub);
+        if (stub != null) {
+            stub.setLayoutResource(actionRowLayout);
+            stub.inflate();
+        }
+        return parent.findViewById(com.android.internal.R.id.chooser_action_row);
+    }
+
+    protected static List<ActionRow.Action> createActions(
+            List<ActionRow.Action> systemActions,
+            List<ActionRow.Action> customActions,
+            FeatureFlagRepository featureFlagRepository) {
+        ArrayList<ActionRow.Action> actions =
+                new ArrayList<>(systemActions.size() + customActions.size());
+        actions.addAll(systemActions);
+        if (featureFlagRepository.isEnabled(Flags.SHARESHEET_CUSTOM_ACTIONS)) {
+            actions.addAll(customActions);
+        }
+        return actions;
+    }
+
+    /**
+     * Indicate if the incoming content URI should be allowed.
+     *
+     * @param uri the uri to test
+     * @return true if the URI is allowed for content preview
+     */
+    protected static boolean validForContentPreview(Uri uri) throws SecurityException {
+        if (uri == null) {
+            return false;
+        }
+        int userId = getUserIdFromUri(uri, UserHandle.USER_CURRENT);
+        if (userId != UserHandle.USER_CURRENT && userId != UserHandle.myUserId()) {
+            Log.e(ContentPreviewUi.TAG, "dropped invalid content URI belonging to user " + userId);
+            return false;
+        }
+        return true;
+    }
+
+    protected static void updateViewWithImage(RoundedRectImageView imageView, Bitmap image) {
+        if (image == null) {
+            imageView.setVisibility(View.GONE);
+            return;
+        }
+        imageView.setVisibility(View.VISIBLE);
+        imageView.setAlpha(0.0f);
+        imageView.setImageBitmap(image);
+
+        ValueAnimator fadeAnim = ObjectAnimator.ofFloat(imageView, "alpha", 0.0f, 1.0f);
+        fadeAnim.setInterpolator(new DecelerateInterpolator(1.0f));
+        fadeAnim.setDuration(IMAGE_FADE_IN_MILLIS);
+        fadeAnim.start();
+    }
+
+    protected static void displayPayloadReselectionAction(
+            ViewGroup layout,
+            ChooserContentPreviewUi.ActionFactory actionFactory,
+            FeatureFlagRepository featureFlagRepository) {
+        Runnable modifyShareAction = actionFactory.getModifyShareAction();
+        if (modifyShareAction != null && layout != null
+                && featureFlagRepository.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION)) {
+            View modifyShareView = layout.findViewById(R.id.reselection_action);
+            if (modifyShareView != null) {
+                modifyShareView.setVisibility(View.VISIBLE);
+                modifyShareView.setOnClickListener(view -> modifyShareAction.run());
+            }
+        }
+    }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java
new file mode 100644
index 0000000..7cd7147
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java
@@ -0,0 +1,236 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.contentpreview;
+
+import android.content.ContentInterface;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.RemoteException;
+import android.provider.DocumentsContract;
+import android.provider.Downloads;
+import android.provider.OpenableColumns;
+import android.text.TextUtils;
+import android.util.Log;
+import android.util.PluralsMessageFormatter;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.annotation.LayoutRes;
+
+import com.android.intentresolver.ImageLoader;
+import com.android.intentresolver.R;
+import com.android.intentresolver.flags.FeatureFlagRepository;
+import com.android.intentresolver.widget.ActionRow;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+class FileContentPreviewUi extends ContentPreviewUi {
+    private static final String PLURALS_COUNT  = "count";
+    private static final String PLURALS_FILE_NAME = "file_name";
+
+    private final List<Uri> mUris;
+    private final ChooserContentPreviewUi.ActionFactory mActionFactory;
+    private final ImageLoader mImageLoader;
+    private final ContentInterface mContentResolver;
+    private final FeatureFlagRepository mFeatureFlagRepository;
+
+    FileContentPreviewUi(List<Uri> uris,
+            ChooserContentPreviewUi.ActionFactory actionFactory,
+            ImageLoader imageLoader,
+            ContentInterface contentResolver,
+            FeatureFlagRepository featureFlagRepository) {
+        mUris = uris;
+        mActionFactory = actionFactory;
+        mImageLoader = imageLoader;
+        mContentResolver = contentResolver;
+        mFeatureFlagRepository = featureFlagRepository;
+    }
+
+    @Override
+    public int getType() {
+        return ContentPreviewType.CONTENT_PREVIEW_FILE;
+    }
+
+    @Override
+    public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) {
+        ViewGroup layout = displayInternal(resources, layoutInflater, parent);
+        displayPayloadReselectionAction(layout, mActionFactory, mFeatureFlagRepository);
+        return layout;
+    }
+
+    private ViewGroup displayInternal(
+            Resources resources, LayoutInflater layoutInflater, ViewGroup parent) {
+        @LayoutRes int actionRowLayout = getActionRowLayout(mFeatureFlagRepository);
+        ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate(
+                R.layout.chooser_grid_preview_file, parent, false);
+
+        final int uriCount = mUris.size();
+
+        if (uriCount == 0) {
+            contentPreviewLayout.setVisibility(View.GONE);
+            Log.i(TAG, "Appears to be no uris available in EXTRA_STREAM,"
+                    + " removing preview area");
+            return contentPreviewLayout;
+        }
+
+        if (uriCount == 1) {
+            loadFileUriIntoView(mUris.get(0), contentPreviewLayout, mImageLoader, mContentResolver);
+        } else {
+            FileInfo fileInfo = extractFileInfo(mUris.get(0), mContentResolver);
+            int remUriCount = uriCount - 1;
+            Map<String, Object> arguments = new HashMap<>();
+            arguments.put(PLURALS_COUNT, remUriCount);
+            arguments.put(PLURALS_FILE_NAME, fileInfo.name);
+            String fileName =
+                    PluralsMessageFormatter.format(resources, arguments, R.string.file_count);
+
+            TextView fileNameView = contentPreviewLayout.findViewById(
+                    com.android.internal.R.id.content_preview_filename);
+            fileNameView.setText(fileName);
+
+            View thumbnailView = contentPreviewLayout.findViewById(
+                    com.android.internal.R.id.content_preview_file_thumbnail);
+            thumbnailView.setVisibility(View.GONE);
+
+            ImageView fileIconView = contentPreviewLayout.findViewById(
+                    com.android.internal.R.id.content_preview_file_icon);
+            fileIconView.setVisibility(View.VISIBLE);
+            fileIconView.setImageResource(R.drawable.ic_file_copy);
+        }
+
+        final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout);
+        if (actionRow != null) {
+            actionRow.setActions(
+                    createActions(
+                            createFilePreviewActions(),
+                            mActionFactory.createCustomActions(),
+                            mFeatureFlagRepository));
+        }
+
+        return contentPreviewLayout;
+    }
+
+    private List<ActionRow.Action> createFilePreviewActions() {
+        List<ActionRow.Action> actions = new ArrayList<>(1);
+        //TODO(b/120417119):
+        // add action buttonFactory.createCopyButton()
+        ActionRow.Action action = mActionFactory.createNearbyButton();
+        if (action != null) {
+            actions.add(action);
+        }
+        return actions;
+    }
+
+    private static void loadFileUriIntoView(
+            final Uri uri,
+            final View parent,
+            final ImageLoader imageLoader,
+            final ContentInterface contentResolver) {
+        FileInfo fileInfo = extractFileInfo(uri, contentResolver);
+
+        TextView fileNameView = parent.findViewById(
+                com.android.internal.R.id.content_preview_filename);
+        fileNameView.setText(fileInfo.name);
+
+        if (fileInfo.hasThumbnail) {
+            imageLoader.loadImage(
+                    uri,
+                    (bitmap) -> updateViewWithImage(
+                            parent.findViewById(
+                                    com.android.internal.R.id.content_preview_file_thumbnail),
+                            bitmap));
+        } else {
+            View thumbnailView = parent.findViewById(
+                    com.android.internal.R.id.content_preview_file_thumbnail);
+            thumbnailView.setVisibility(View.GONE);
+
+            ImageView fileIconView = parent.findViewById(
+                    com.android.internal.R.id.content_preview_file_icon);
+            fileIconView.setVisibility(View.VISIBLE);
+            fileIconView.setImageResource(R.drawable.chooser_file_generic);
+        }
+    }
+
+    private static FileInfo extractFileInfo(Uri uri, ContentInterface resolver) {
+        String fileName = null;
+        boolean hasThumbnail = false;
+
+        try (Cursor cursor = queryResolver(resolver, uri)) {
+            if (cursor != null && cursor.getCount() > 0) {
+                int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
+                int titleIndex = cursor.getColumnIndex(Downloads.Impl.COLUMN_TITLE);
+                int flagsIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_FLAGS);
+
+                cursor.moveToFirst();
+                if (nameIndex != -1) {
+                    fileName = cursor.getString(nameIndex);
+                } else if (titleIndex != -1) {
+                    fileName = cursor.getString(titleIndex);
+                }
+
+                if (flagsIndex != -1) {
+                    hasThumbnail = (cursor.getInt(flagsIndex)
+                            & DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL) != 0;
+                }
+            }
+        } catch (SecurityException | NullPointerException e) {
+            // The ContentResolver already logs the exception. Log something more informative.
+            Log.w(
+                    TAG,
+                    "Could not load (" + uri.toString() + ") thumbnail/name for preview. If "
+                    + "desired, consider using Intent#createChooser to launch the ChooserActivity, "
+                    + "and set your Intent's clipData and flags in accordance with that method's "
+                    + "documentation");
+        }
+
+        if (TextUtils.isEmpty(fileName)) {
+            fileName = uri.getPath();
+            fileName = fileName == null ? "" : fileName;
+            int index = fileName.lastIndexOf('/');
+            if (index != -1) {
+                fileName = fileName.substring(index + 1);
+            }
+        }
+
+        return new FileInfo(fileName, hasThumbnail);
+    }
+
+    private static Cursor queryResolver(ContentInterface resolver, Uri uri) {
+        try {
+            return resolver.query(uri, null, null, null);
+        } catch (RemoteException e) {
+            return null;
+        }
+    }
+
+    private static class FileInfo {
+        public final String name;
+        public final boolean hasThumbnail;
+
+        FileInfo(String name, boolean hasThumbnail) {
+            this.name = name;
+            this.hasThumbnail = hasThumbnail;
+        }
+    }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java
new file mode 100644
index 0000000..db26ab1
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java
@@ -0,0 +1,179 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.contentpreview;
+
+import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_IMAGE;
+
+import android.content.res.Resources;
+import android.net.Uri;
+import android.text.TextUtils;
+import android.text.util.Linkify;
+import android.transition.TransitionManager;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewStub;
+import android.widget.CheckBox;
+import android.widget.TextView;
+
+import androidx.annotation.LayoutRes;
+import androidx.annotation.Nullable;
+
+import com.android.intentresolver.ImageLoader;
+import com.android.intentresolver.R;
+import com.android.intentresolver.flags.FeatureFlagRepository;
+import com.android.intentresolver.flags.Flags;
+import com.android.intentresolver.widget.ActionRow;
+import com.android.intentresolver.widget.ImagePreviewView;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+
+class ImageContentPreviewUi extends ContentPreviewUi {
+    private final List<Uri> mImageUris;
+    @Nullable
+    private final CharSequence mText;
+    private final ChooserContentPreviewUi.ActionFactory mActionFactory;
+    private final ImageLoader mImageLoader;
+    private final ImagePreviewView.TransitionElementStatusCallback mTransitionElementStatusCallback;
+    private final FeatureFlagRepository mFeatureFlagRepository;
+
+    ImageContentPreviewUi(
+            List<Uri> imageUris,
+            @Nullable CharSequence text,
+            ChooserContentPreviewUi.ActionFactory actionFactory,
+            ImageLoader imageLoader,
+            ImagePreviewView.TransitionElementStatusCallback transitionElementStatusCallback,
+            FeatureFlagRepository featureFlagRepository) {
+        mImageUris = imageUris;
+        mText = text;
+        mActionFactory = actionFactory;
+        mImageLoader = imageLoader;
+        mTransitionElementStatusCallback = transitionElementStatusCallback;
+        mFeatureFlagRepository = featureFlagRepository;
+
+        mImageLoader.prePopulate(mImageUris);
+    }
+
+    @Override
+    public int getType() {
+        return CONTENT_PREVIEW_IMAGE;
+    }
+
+    @Override
+    public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) {
+        ViewGroup layout = displayInternal(layoutInflater, parent);
+        displayPayloadReselectionAction(layout, mActionFactory, mFeatureFlagRepository);
+        return layout;
+    }
+
+    private ViewGroup displayInternal(LayoutInflater layoutInflater, ViewGroup parent) {
+        @LayoutRes int actionRowLayout = getActionRowLayout(mFeatureFlagRepository);
+        ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate(
+                R.layout.chooser_grid_preview_image, parent, false);
+        ImagePreviewView imagePreview = inflateImagePreviewView(contentPreviewLayout);
+
+        final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout);
+        if (actionRow != null) {
+            actionRow.setActions(
+                    createActions(
+                            createImagePreviewActions(),
+                            mActionFactory.createCustomActions(),
+                            mFeatureFlagRepository));
+        }
+
+        if (mImageUris.size() == 0) {
+            Log.i(
+                    TAG,
+                    "Attempted to display image preview area with zero"
+                        + " available images detected in EXTRA_STREAM list");
+            ((View) imagePreview).setVisibility(View.GONE);
+            mTransitionElementStatusCallback.onAllTransitionElementsReady();
+            return contentPreviewLayout;
+        }
+
+        setTextInImagePreviewVisibility(contentPreviewLayout, mActionFactory);
+        imagePreview.setTransitionElementStatusCallback(mTransitionElementStatusCallback);
+        imagePreview.setImages(mImageUris, mImageLoader);
+
+        return contentPreviewLayout;
+    }
+
+    private List<ActionRow.Action> createImagePreviewActions() {
+        ArrayList<ActionRow.Action> actions = new ArrayList<>(2);
+        //TODO: add copy action;
+        ActionRow.Action action = mActionFactory.createNearbyButton();
+        if (action != null) {
+            actions.add(action);
+        }
+        action = mActionFactory.createEditButton();
+        if (action != null) {
+            actions.add(action);
+        }
+        return actions;
+    }
+
+    private ImagePreviewView inflateImagePreviewView(ViewGroup previewLayout) {
+        ViewStub stub = previewLayout.findViewById(R.id.image_preview_stub);
+        if (stub != null) {
+            int layoutId =
+                    mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW)
+                            ? R.layout.scrollable_image_preview_view
+                            : R.layout.chooser_image_preview_view;
+            stub.setLayoutResource(layoutId);
+            stub.inflate();
+        }
+        return previewLayout.findViewById(
+                com.android.internal.R.id.content_preview_image_area);
+    }
+
+    private void setTextInImagePreviewVisibility(
+            ViewGroup contentPreview, ChooserContentPreviewUi.ActionFactory actionFactory) {
+        int visibility = mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW)
+                && !TextUtils.isEmpty(mText)
+                ? View.VISIBLE
+                : View.GONE;
+
+        final TextView textView = contentPreview
+                .requireViewById(com.android.internal.R.id.content_preview_text);
+        CheckBox actionView = contentPreview
+                .requireViewById(R.id.include_text_action);
+        textView.setVisibility(visibility);
+        boolean isLink = visibility == View.VISIBLE && HttpUriMatcher.isHttpUri(mText.toString());
+        textView.setAutoLinkMask(isLink ? Linkify.WEB_URLS : 0);
+        textView.setText(mText);
+
+        if (visibility == View.VISIBLE) {
+            final int[] actionLabels = isLink
+                    ? new int[] { R.string.include_link, R.string.exclude_link }
+                    : new int[] { R.string.include_text, R.string.exclude_text };
+            final Consumer<Boolean> shareTextAction = actionFactory.getExcludeSharedTextAction();
+            actionView.setChecked(true);
+            actionView.setText(actionLabels[1]);
+            shareTextAction.accept(false);
+            actionView.setOnCheckedChangeListener((view, isChecked) -> {
+                view.setText(actionLabels[isChecked ? 1 : 0]);
+                TransitionManager.beginDelayedTransition((ViewGroup) textView.getParent());
+                textView.setVisibility(isChecked ? View.VISIBLE : View.GONE);
+                shareTextAction.accept(!isChecked);
+            });
+        }
+        actionView.setVisibility(visibility);
+    }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt b/java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt
new file mode 100644
index 0000000..8023253
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:JvmName("HttpUriMatcher")
+package com.android.intentresolver.contentpreview
+
+import java.net.URI
+
+internal fun String.isHttpUri() =
+    kotlin.runCatching {
+        URI(this).scheme.takeIf { scheme ->
+            "http".compareTo(scheme, true) == 0 || "https".compareTo(scheme, true) == 0
+        }
+    }.getOrNull() != null
diff --git a/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt b/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt
new file mode 100644
index 0000000..9001693
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.contentpreview
+
+import android.content.res.Resources
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.ViewGroup
+
+internal class NoContextPreviewUi(private val type: Int) : ContentPreviewUi() {
+    override fun getType(): Int = type
+
+    override fun display(
+        resources: Resources?, layoutInflater: LayoutInflater?, parent: ViewGroup?
+    ): ViewGroup? {
+        Log.e(TAG, "Unexpected content preview type: $type")
+        return null
+    }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java
new file mode 100644
index 0000000..7901e4c
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.contentpreview;
+
+import android.content.res.Resources;
+import android.net.Uri;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.annotation.LayoutRes;
+import androidx.annotation.Nullable;
+
+import com.android.intentresolver.ImageLoader;
+import com.android.intentresolver.R;
+import com.android.intentresolver.flags.FeatureFlagRepository;
+import com.android.intentresolver.widget.ActionRow;
+
+import java.util.ArrayList;
+import java.util.List;
+
+class TextContentPreviewUi extends ContentPreviewUi {
+    @Nullable
+    private final CharSequence mSharingText;
+    @Nullable
+    private final CharSequence mPreviewTitle;
+    @Nullable
+    private final Uri mPreviewThumbnail;
+    private final ImageLoader mImageLoader;
+    private final ChooserContentPreviewUi.ActionFactory mActionFactory;
+    private final FeatureFlagRepository mFeatureFlagRepository;
+
+    TextContentPreviewUi(
+            @Nullable CharSequence sharingText,
+            @Nullable CharSequence previewTitle,
+            @Nullable Uri previewThumbnail,
+            ChooserContentPreviewUi.ActionFactory actionFactory,
+            ImageLoader imageLoader,
+            FeatureFlagRepository featureFlagRepository) {
+        mSharingText = sharingText;
+        mPreviewTitle = previewTitle;
+        mPreviewThumbnail = previewThumbnail;
+        mImageLoader = imageLoader;
+        mActionFactory = actionFactory;
+        mFeatureFlagRepository = featureFlagRepository;
+    }
+
+    @Override
+    public int getType() {
+        return ContentPreviewType.CONTENT_PREVIEW_TEXT;
+    }
+
+    @Override
+    public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) {
+        ViewGroup layout = displayInternal(layoutInflater, parent);
+        displayPayloadReselectionAction(layout, mActionFactory, mFeatureFlagRepository);
+        return layout;
+    }
+
+    private ViewGroup displayInternal(
+            LayoutInflater layoutInflater,
+            ViewGroup parent) {
+        @LayoutRes int actionRowLayout = getActionRowLayout(mFeatureFlagRepository);
+        ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate(
+                R.layout.chooser_grid_preview_text, parent, false);
+
+        final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout);
+        if (actionRow != null) {
+            actionRow.setActions(
+                    createActions(
+                            createTextPreviewActions(),
+                            mActionFactory.createCustomActions(),
+                            mFeatureFlagRepository));
+        }
+
+        if (mSharingText == null) {
+            contentPreviewLayout
+                    .findViewById(com.android.internal.R.id.content_preview_text_layout)
+                    .setVisibility(View.GONE);
+        } else {
+            TextView textView = contentPreviewLayout.findViewById(
+                    com.android.internal.R.id.content_preview_text);
+            textView.setText(mSharingText);
+        }
+
+        if (TextUtils.isEmpty(mPreviewTitle)) {
+            contentPreviewLayout
+                    .findViewById(com.android.internal.R.id.content_preview_title_layout)
+                    .setVisibility(View.GONE);
+        } else {
+            TextView previewTitleView = contentPreviewLayout.findViewById(
+                    com.android.internal.R.id.content_preview_title);
+            previewTitleView.setText(mPreviewTitle);
+
+            ImageView previewThumbnailView = contentPreviewLayout.findViewById(
+                    com.android.internal.R.id.content_preview_thumbnail);
+            if (!validForContentPreview(mPreviewThumbnail)) {
+                previewThumbnailView.setVisibility(View.GONE);
+            } else {
+                mImageLoader.loadImage(
+                        mPreviewThumbnail,
+                        (bitmap) -> updateViewWithImage(
+                                contentPreviewLayout.findViewById(
+                                        com.android.internal.R.id.content_preview_thumbnail),
+                                bitmap));
+            }
+        }
+
+        return contentPreviewLayout;
+    }
+
+    private List<ActionRow.Action> createTextPreviewActions() {
+        ArrayList<ActionRow.Action> actions = new ArrayList<>(2);
+        actions.add(mActionFactory.createCopyButton());
+        ActionRow.Action nearbyAction = mActionFactory.createNearbyButton();
+        if (nearbyAction != null) {
+            actions.add(nearbyAction);
+        }
+        return actions;
+    }
+}
diff --git a/java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt b/java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt
new file mode 100644
index 0000000..d1494fe
--- /dev/null
+++ b/java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt
@@ -0,0 +1,33 @@
+/*
+ * 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.flags
+
+import android.provider.DeviceConfig
+import com.android.systemui.flags.ParcelableFlag
+
+internal class DeviceConfigProxy {
+    fun isEnabled(flag: ParcelableFlag<Boolean>): Boolean? {
+        return runCatching {
+            val hasProperty = DeviceConfig.getProperty(flag.namespace, flag.name) != null
+            if (hasProperty) {
+                DeviceConfig.getBoolean(flag.namespace, flag.name, flag.default)
+            } else {
+                null
+            }
+        }.getOrDefault(null)
+    }
+}
diff --git a/java/src/com/android/intentresolver/flags/FeatureFlagRepository.kt b/java/src/com/android/intentresolver/flags/FeatureFlagRepository.kt
new file mode 100644
index 0000000..5b5d769
--- /dev/null
+++ b/java/src/com/android/intentresolver/flags/FeatureFlagRepository.kt
@@ -0,0 +1,25 @@
+/*
+ * 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.flags
+
+import com.android.systemui.flags.ReleasedFlag
+import com.android.systemui.flags.UnreleasedFlag
+
+interface FeatureFlagRepository {
+    fun isEnabled(flag: UnreleasedFlag): Boolean
+    fun isEnabled(flag: ReleasedFlag): Boolean
+}
diff --git a/java/src/com/android/intentresolver/flags/Flags.kt b/java/src/com/android/intentresolver/flags/Flags.kt
new file mode 100644
index 0000000..f4dbedd
--- /dev/null
+++ b/java/src/com/android/intentresolver/flags/Flags.kt
@@ -0,0 +1,55 @@
+/*
+ * 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.flags
+
+import com.android.systemui.flags.UnreleasedFlag
+
+// Flag id, name and namespace should be kept in sync with [com.android.systemui.flags.Flags] to
+// make the flags available in the flag flipper app (see go/sysui-flags).
+object Flags {
+    const val SHARESHEET_CUSTOM_ACTIONS_NAME = "sharesheet_custom_actions"
+    const val SHARESHEET_RESELECTION_ACTION_NAME = "sharesheet_reselection_action"
+    const val SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME = "sharesheet_image_text_preview"
+    const val SHARESHEET_SCROLLABLE_IMAGE_PREVIEW_NAME = "sharesheet_scrollable_image_preview"
+
+    // TODO(b/266983432) Tracking Bug
+    @JvmField
+    val SHARESHEET_CUSTOM_ACTIONS = unreleasedFlag(
+        1501, SHARESHEET_CUSTOM_ACTIONS_NAME, teamfood = true
+    )
+
+    // TODO(b/266982749) Tracking Bug
+    @JvmField
+    val SHARESHEET_RESELECTION_ACTION = unreleasedFlag(
+        1502, SHARESHEET_RESELECTION_ACTION_NAME, teamfood = true
+    )
+
+    // TODO(b/266983474) Tracking Bug
+    @JvmField
+    val SHARESHEET_IMAGE_AND_TEXT_PREVIEW = unreleasedFlag(
+        id = 1503, name = SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME, teamfood = true
+    )
+
+    // TODO(b/267355521) Tracking Bug
+    @JvmField
+    val SHARESHEET_SCROLLABLE_IMAGE_PREVIEW = unreleasedFlag(
+        1504, SHARESHEET_SCROLLABLE_IMAGE_PREVIEW_NAME, teamfood = true
+    )
+
+    private fun unreleasedFlag(id: Int, name: String, teamfood: Boolean = false) =
+        UnreleasedFlag(id, name, "systemui", teamfood)
+}
diff --git a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java
index 271c6f9..ea76756 100644
--- a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java
+++ b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java
@@ -30,8 +30,8 @@
 import android.util.Log;
 
 import com.android.intentresolver.ChooserActivityLogger;
+import com.android.intentresolver.ResolvedComponentInfo;
 import com.android.intentresolver.ResolverActivity;
-import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo;
 
 import java.text.Collator;
 import java.util.ArrayList;
diff --git a/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java b/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java
index c6bb2b8..c986ef1 100644
--- a/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java
+++ b/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java
@@ -32,7 +32,7 @@
 import android.util.Log;
 
 import com.android.intentresolver.ChooserActivityLogger;
-import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo;
+import com.android.intentresolver.ResolvedComponentInfo;
 
 import java.util.ArrayList;
 import java.util.Comparator;
diff --git a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java
index 4382f10..0431078 100644
--- a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java
+++ b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java
@@ -38,7 +38,7 @@
 import android.util.Log;
 
 import com.android.intentresolver.ChooserActivityLogger;
-import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo;
+import com.android.intentresolver.ResolvedComponentInfo;
 import com.android.internal.logging.MetricsLogger;
 import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
 
diff --git a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.java b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.java
deleted file mode 100644
index 1cfa2c8..0000000
--- a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.java
+++ /dev/null
@@ -1,426 +0,0 @@
-/*
- * Copyright (C) 2022 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.intentresolver.shortcuts;
-
-import android.app.ActivityManager;
-import android.app.prediction.AppPredictor;
-import android.app.prediction.AppTarget;
-import android.content.ComponentName;
-import android.content.Context;
-import android.content.IntentFilter;
-import android.content.pm.ApplicationInfo;
-import android.content.pm.PackageManager;
-import android.content.pm.PackageManager.ApplicationInfoFlags;
-import android.content.pm.PackageManager.NameNotFoundException;
-import android.content.pm.ShortcutInfo;
-import android.content.pm.ShortcutManager;
-import android.os.AsyncTask;
-import android.os.UserHandle;
-import android.os.UserManager;
-import android.service.chooser.ChooserTarget;
-import android.text.TextUtils;
-import android.util.Log;
-
-import androidx.annotation.MainThread;
-import androidx.annotation.Nullable;
-import androidx.annotation.VisibleForTesting;
-import androidx.annotation.WorkerThread;
-
-import com.android.intentresolver.chooser.DisplayResolveInfo;
-
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.Executor;
-import java.util.concurrent.atomic.AtomicReference;
-import java.util.function.Consumer;
-
-/**
- * Encapsulates shortcuts loading logic from either AppPredictor or ShortcutManager.
- * <p>
- *     A ShortcutLoader instance can be viewed as a per-profile singleton hot stream of shortcut
- * updates. The shortcut loading is triggered by the {@link #queryShortcuts(DisplayResolveInfo[])},
- * the processing will happen on the {@link #mBackgroundExecutor} and the result is delivered
- * through the {@link #mCallback} on the {@link #mCallbackExecutor}, the main thread.
- * </p>
- * <p>
- *    The current version does not improve on the legacy in a way that it does not guarantee that
- * each invocation of the {@link #queryShortcuts(DisplayResolveInfo[])} will be matched by an
- * invocation of the callback (there are early terminations of the flow). Also, the fetched
- * shortcuts would be matched against the last known input, i.e. two invocations of
- * {@link #queryShortcuts(DisplayResolveInfo[])} may result in two callbacks where shortcuts are
- * processed against the latest input.
- * </p>
- */
-public class ShortcutLoader {
-    private static final String TAG = "ChooserActivity";
-
-    private static final Request NO_REQUEST = new Request(new DisplayResolveInfo[0]);
-
-    private final Context mContext;
-    @Nullable
-    private final AppPredictorProxy mAppPredictor;
-    private final UserHandle mUserHandle;
-    @Nullable
-    private final IntentFilter mTargetIntentFilter;
-    private final Executor mBackgroundExecutor;
-    private final Executor mCallbackExecutor;
-    private final boolean mIsPersonalProfile;
-    private final ShortcutToChooserTargetConverter mShortcutToChooserTargetConverter =
-            new ShortcutToChooserTargetConverter();
-    private final UserManager mUserManager;
-    private final AtomicReference<Consumer<Result>> mCallback = new AtomicReference<>();
-    private final AtomicReference<Request> mActiveRequest = new AtomicReference<>(NO_REQUEST);
-
-    @Nullable
-    private final AppPredictor.Callback mAppPredictorCallback;
-
-    @MainThread
-    public ShortcutLoader(
-            Context context,
-            @Nullable AppPredictor appPredictor,
-            UserHandle userHandle,
-            @Nullable IntentFilter targetIntentFilter,
-            Consumer<Result> callback) {
-        this(
-                context,
-                appPredictor == null ? null : new AppPredictorProxy(appPredictor),
-                userHandle,
-                userHandle.equals(UserHandle.of(ActivityManager.getCurrentUser())),
-                targetIntentFilter,
-                AsyncTask.SERIAL_EXECUTOR,
-                context.getMainExecutor(),
-                callback);
-    }
-
-    @VisibleForTesting
-    ShortcutLoader(
-            Context context,
-            @Nullable AppPredictorProxy appPredictor,
-            UserHandle userHandle,
-            boolean isPersonalProfile,
-            @Nullable IntentFilter targetIntentFilter,
-            Executor backgroundExecutor,
-            Executor callbackExecutor,
-            Consumer<Result> callback) {
-        mContext = context;
-        mAppPredictor = appPredictor;
-        mUserHandle = userHandle;
-        mTargetIntentFilter = targetIntentFilter;
-        mBackgroundExecutor = backgroundExecutor;
-        mCallbackExecutor = callbackExecutor;
-        mCallback.set(callback);
-        mIsPersonalProfile = isPersonalProfile;
-        mUserManager = (UserManager) mContext.getSystemService(Context.USER_SERVICE);
-
-        if (mAppPredictor != null) {
-            mAppPredictorCallback = createAppPredictorCallback();
-            mAppPredictor.registerPredictionUpdates(mCallbackExecutor, mAppPredictorCallback);
-        } else {
-            mAppPredictorCallback = null;
-        }
-    }
-
-    /**
-     * Unsubscribe from app predictor if one was provided.
-     */
-    @MainThread
-    public void destroy() {
-        if (mCallback.getAndSet(null) != null) {
-            if (mAppPredictor != null) {
-                mAppPredictor.unregisterPredictionUpdates(mAppPredictorCallback);
-            }
-        }
-    }
-
-    private boolean isDestroyed() {
-        return mCallback.get() == null;
-    }
-
-    /**
-     * Set new resolved targets. This will trigger shortcut loading.
-     * @param appTargets a collection of application targets a loaded set of shortcuts will be
-     *                   grouped against
-     */
-    @MainThread
-    public void queryShortcuts(DisplayResolveInfo[] appTargets) {
-        if (isDestroyed()) {
-            return;
-        }
-        mActiveRequest.set(new Request(appTargets));
-        mBackgroundExecutor.execute(this::loadShortcuts);
-    }
-
-    @WorkerThread
-    private void loadShortcuts() {
-        // no need to query direct share for work profile when its locked or disabled
-        if (!shouldQueryDirectShareTargets()) {
-            return;
-        }
-        Log.d(TAG, "querying direct share targets");
-        queryDirectShareTargets(false);
-    }
-
-    @WorkerThread
-    private void queryDirectShareTargets(boolean skipAppPredictionService) {
-        if (isDestroyed()) {
-            return;
-        }
-        if (!skipAppPredictionService && mAppPredictor != null) {
-            mAppPredictor.requestPredictionUpdate();
-            return;
-        }
-        // Default to just querying ShortcutManager if AppPredictor not present.
-        if (mTargetIntentFilter == null) {
-            return;
-        }
-
-        Context selectedProfileContext = mContext.createContextAsUser(mUserHandle, 0 /* flags */);
-        ShortcutManager sm = (ShortcutManager) selectedProfileContext
-                .getSystemService(Context.SHORTCUT_SERVICE);
-        List<ShortcutManager.ShareShortcutInfo> shortcuts =
-                sm.getShareTargets(mTargetIntentFilter);
-        sendShareShortcutInfoList(shortcuts, false, null);
-    }
-
-    private AppPredictor.Callback createAppPredictorCallback() {
-        return appPredictorTargets -> {
-            if (appPredictorTargets.isEmpty() && shouldQueryDirectShareTargets()) {
-                // APS may be disabled, so try querying targets ourselves.
-                queryDirectShareTargets(true);
-                return;
-            }
-
-            final List<ShortcutManager.ShareShortcutInfo> shortcuts = new ArrayList<>();
-            List<AppTarget> shortcutResults = new ArrayList<>();
-            for (AppTarget appTarget : appPredictorTargets) {
-                if (appTarget.getShortcutInfo() == null) {
-                    continue;
-                }
-                shortcutResults.add(appTarget);
-            }
-            appPredictorTargets = shortcutResults;
-            for (AppTarget appTarget : appPredictorTargets) {
-                shortcuts.add(new ShortcutManager.ShareShortcutInfo(
-                        appTarget.getShortcutInfo(),
-                        new ComponentName(appTarget.getPackageName(), appTarget.getClassName())));
-            }
-            sendShareShortcutInfoList(shortcuts, true, appPredictorTargets);
-        };
-    }
-
-    @WorkerThread
-    private void sendShareShortcutInfoList(
-            List<ShortcutManager.ShareShortcutInfo> shortcuts,
-            boolean isFromAppPredictor,
-            @Nullable List<AppTarget> appPredictorTargets) {
-        if (appPredictorTargets != null && appPredictorTargets.size() != shortcuts.size()) {
-            throw new RuntimeException("resultList and appTargets must have the same size."
-                    + " resultList.size()=" + shortcuts.size()
-                    + " appTargets.size()=" + appPredictorTargets.size());
-        }
-        Context selectedProfileContext = mContext.createContextAsUser(mUserHandle, 0 /* flags */);
-        for (int i = shortcuts.size() - 1; i >= 0; i--) {
-            final String packageName = shortcuts.get(i).getTargetComponent().getPackageName();
-            if (!isPackageEnabled(selectedProfileContext, packageName)) {
-                shortcuts.remove(i);
-                if (appPredictorTargets != null) {
-                    appPredictorTargets.remove(i);
-                }
-            }
-        }
-
-        HashMap<ChooserTarget, AppTarget> directShareAppTargetCache = new HashMap<>();
-        HashMap<ChooserTarget, ShortcutInfo> directShareShortcutInfoCache = new HashMap<>();
-        // Match ShareShortcutInfos with DisplayResolveInfos to be able to use the old code path
-        // for direct share targets. After ShareSheet is refactored we should use the
-        // ShareShortcutInfos directly.
-        final DisplayResolveInfo[] appTargets = mActiveRequest.get().appTargets;
-        List<ShortcutResultInfo> resultRecords = new ArrayList<>();
-        for (DisplayResolveInfo displayResolveInfo : appTargets) {
-            List<ShortcutManager.ShareShortcutInfo> matchingShortcuts =
-                    filterShortcutsByTargetComponentName(
-                            shortcuts, displayResolveInfo.getResolvedComponentName());
-            if (matchingShortcuts.isEmpty()) {
-                continue;
-            }
-
-            List<ChooserTarget> chooserTargets = mShortcutToChooserTargetConverter
-                    .convertToChooserTarget(
-                            matchingShortcuts,
-                            shortcuts,
-                            appPredictorTargets,
-                            directShareAppTargetCache,
-                            directShareShortcutInfoCache);
-
-            ShortcutResultInfo resultRecord =
-                    new ShortcutResultInfo(displayResolveInfo, chooserTargets);
-            resultRecords.add(resultRecord);
-        }
-
-        postReport(
-                new Result(
-                        isFromAppPredictor,
-                        appTargets,
-                        resultRecords.toArray(new ShortcutResultInfo[0]),
-                        directShareAppTargetCache,
-                        directShareShortcutInfoCache));
-    }
-
-    private void postReport(Result result) {
-        mCallbackExecutor.execute(() -> report(result));
-    }
-
-    @MainThread
-    private void report(Result result) {
-        Consumer<Result> callback = mCallback.get();
-        if (callback != null) {
-            callback.accept(result);
-        }
-    }
-
-    /**
-     * Returns {@code false} if {@code userHandle} is the work profile and it's either
-     * in quiet mode or not running.
-     */
-    private boolean shouldQueryDirectShareTargets() {
-        return mIsPersonalProfile || isProfileActive();
-    }
-
-    @VisibleForTesting
-    protected boolean isProfileActive() {
-        return mUserManager.isUserRunning(mUserHandle)
-                && mUserManager.isUserUnlocked(mUserHandle)
-                && !mUserManager.isQuietModeEnabled(mUserHandle);
-    }
-
-    private static boolean isPackageEnabled(Context context, String packageName) {
-        if (TextUtils.isEmpty(packageName)) {
-            return false;
-        }
-        ApplicationInfo appInfo;
-        try {
-            appInfo = context.getPackageManager().getApplicationInfo(
-                    packageName,
-                    ApplicationInfoFlags.of(PackageManager.GET_META_DATA));
-        } catch (NameNotFoundException e) {
-            return false;
-        }
-
-        return appInfo != null && appInfo.enabled
-                && (appInfo.flags & ApplicationInfo.FLAG_SUSPENDED) == 0;
-    }
-
-    private static List<ShortcutManager.ShareShortcutInfo> filterShortcutsByTargetComponentName(
-            List<ShortcutManager.ShareShortcutInfo> allShortcuts, ComponentName requiredTarget) {
-        List<ShortcutManager.ShareShortcutInfo> matchingShortcuts = new ArrayList<>();
-        for (ShortcutManager.ShareShortcutInfo shortcut : allShortcuts) {
-            if (requiredTarget.equals(shortcut.getTargetComponent())) {
-                matchingShortcuts.add(shortcut);
-            }
-        }
-        return matchingShortcuts;
-    }
-
-    private static class Request {
-        public final DisplayResolveInfo[] appTargets;
-
-        Request(DisplayResolveInfo[] targets) {
-            appTargets = targets;
-        }
-    }
-
-    /**
-     * Resolved shortcuts with corresponding app targets.
-     */
-    public static class Result {
-        public final boolean isFromAppPredictor;
-        /**
-         * Input app targets (see {@link ShortcutLoader#queryShortcuts(DisplayResolveInfo[])} the
-         * shortcuts were process against.
-         */
-        public final DisplayResolveInfo[] appTargets;
-        /**
-         * Shortcuts grouped by app target.
-         */
-        public final ShortcutResultInfo[] shortcutsByApp;
-        public final Map<ChooserTarget, AppTarget> directShareAppTargetCache;
-        public final Map<ChooserTarget, ShortcutInfo> directShareShortcutInfoCache;
-
-        @VisibleForTesting
-        public Result(
-                boolean isFromAppPredictor,
-                DisplayResolveInfo[] appTargets,
-                ShortcutResultInfo[] shortcutsByApp,
-                Map<ChooserTarget, AppTarget> directShareAppTargetCache,
-                Map<ChooserTarget, ShortcutInfo> directShareShortcutInfoCache) {
-            this.isFromAppPredictor = isFromAppPredictor;
-            this.appTargets = appTargets;
-            this.shortcutsByApp = shortcutsByApp;
-            this.directShareAppTargetCache = directShareAppTargetCache;
-            this.directShareShortcutInfoCache = directShareShortcutInfoCache;
-        }
-    }
-
-    /**
-     * Shortcuts grouped by app.
-     */
-    public static class ShortcutResultInfo {
-        public final DisplayResolveInfo appTarget;
-        public final List<ChooserTarget> shortcuts;
-
-        public ShortcutResultInfo(DisplayResolveInfo appTarget, List<ChooserTarget> shortcuts) {
-            this.appTarget = appTarget;
-            this.shortcuts = shortcuts;
-        }
-    }
-
-    /**
-     * A wrapper around AppPredictor to facilitate unit-testing.
-     */
-    @VisibleForTesting
-    public static class AppPredictorProxy {
-        private final AppPredictor mAppPredictor;
-
-        AppPredictorProxy(AppPredictor appPredictor) {
-            mAppPredictor = appPredictor;
-        }
-
-        /**
-         * {@link AppPredictor#registerPredictionUpdates}
-         */
-        public void registerPredictionUpdates(
-                Executor callbackExecutor, AppPredictor.Callback callback) {
-            mAppPredictor.registerPredictionUpdates(callbackExecutor, callback);
-        }
-
-        /**
-         * {@link AppPredictor#unregisterPredictionUpdates}
-         */
-        public void unregisterPredictionUpdates(AppPredictor.Callback callback) {
-            mAppPredictor.unregisterPredictionUpdates(callback);
-        }
-
-        /**
-         * {@link AppPredictor#requestPredictionUpdate}
-         */
-        public void requestPredictionUpdate() {
-            mAppPredictor.requestPredictionUpdate();
-        }
-    }
-}
diff --git a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt
new file mode 100644
index 0000000..6f7542f
--- /dev/null
+++ b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt
@@ -0,0 +1,326 @@
+/*
+ * 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.shortcuts
+
+import android.app.ActivityManager
+import android.app.prediction.AppPredictor
+import android.app.prediction.AppTarget
+import android.content.ComponentName
+import android.content.Context
+import android.content.IntentFilter
+import android.content.pm.ApplicationInfo
+import android.content.pm.PackageManager
+import android.content.pm.ShortcutInfo
+import android.content.pm.ShortcutManager
+import android.content.pm.ShortcutManager.ShareShortcutInfo
+import android.os.AsyncTask
+import android.os.UserHandle
+import android.os.UserManager
+import android.service.chooser.ChooserTarget
+import android.text.TextUtils
+import android.util.Log
+import androidx.annotation.MainThread
+import androidx.annotation.OpenForTesting
+import androidx.annotation.VisibleForTesting
+import androidx.annotation.WorkerThread
+import com.android.intentresolver.chooser.DisplayResolveInfo
+import java.lang.RuntimeException
+import java.util.ArrayList
+import java.util.HashMap
+import java.util.concurrent.Executor
+import java.util.concurrent.atomic.AtomicReference
+import java.util.function.Consumer
+
+/**
+ * Encapsulates shortcuts loading logic from either AppPredictor or ShortcutManager.
+ *
+ *
+ * A ShortcutLoader instance can be viewed as a per-profile singleton hot stream of shortcut
+ * updates. The shortcut loading is triggered by the [queryShortcuts],
+ * the processing will happen on the [backgroundExecutor] and the result is delivered
+ * through the [callback] on the [callbackExecutor], the main thread.
+ *
+ *
+ * The current version does not improve on the legacy in a way that it does not guarantee that
+ * each invocation of the [queryShortcuts] will be matched by an
+ * invocation of the callback (there are early terminations of the flow). Also, the fetched
+ * shortcuts would be matched against the last known input, i.e. two invocations of
+ * [queryShortcuts] may result in two callbacks where shortcuts are
+ * processed against the latest input.
+ *
+ */
+@OpenForTesting
+open class ShortcutLoader @VisibleForTesting constructor(
+    private val context: Context,
+    private val appPredictor: AppPredictorProxy?,
+    private val userHandle: UserHandle,
+    private val isPersonalProfile: Boolean,
+    private val targetIntentFilter: IntentFilter?,
+    private val backgroundExecutor: Executor,
+    private val callbackExecutor: Executor,
+    private val callback: Consumer<Result>
+) {
+    private val shortcutToChooserTargetConverter = ShortcutToChooserTargetConverter()
+    private val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager
+    private val activeRequest = AtomicReference(NO_REQUEST)
+    private val appPredictorCallback = AppPredictor.Callback { onAppPredictorCallback(it) }
+    private var isDestroyed = false
+
+    @MainThread
+    constructor(
+        context: Context,
+        appPredictor: AppPredictor?,
+        userHandle: UserHandle,
+        targetIntentFilter: IntentFilter?,
+        callback: Consumer<Result>
+    ) : this(
+        context,
+        appPredictor?.let { AppPredictorProxy(it) },
+        userHandle, userHandle == UserHandle.of(ActivityManager.getCurrentUser()),
+        targetIntentFilter,
+        AsyncTask.SERIAL_EXECUTOR,
+        context.mainExecutor,
+        callback
+    )
+
+    init {
+        appPredictor?.registerPredictionUpdates(callbackExecutor, appPredictorCallback)
+    }
+
+    /**
+     * Unsubscribe from app predictor if one was provided.
+     */
+    @OpenForTesting
+    @MainThread
+    open fun destroy() {
+        isDestroyed = true
+        appPredictor?.unregisterPredictionUpdates(appPredictorCallback)
+    }
+
+    /**
+     * Set new resolved targets. This will trigger shortcut loading.
+     * @param appTargets a collection of application targets a loaded set of shortcuts will be
+     * grouped against
+     */
+    @OpenForTesting
+    @MainThread
+    open fun queryShortcuts(appTargets: Array<DisplayResolveInfo>) {
+        if (isDestroyed) return
+        activeRequest.set(Request(appTargets))
+        backgroundExecutor.execute { loadShortcuts() }
+    }
+
+    @WorkerThread
+    private fun loadShortcuts() {
+        // no need to query direct share for work profile when its locked or disabled
+        if (!shouldQueryDirectShareTargets()) return
+        Log.d(TAG, "querying direct share targets")
+        queryDirectShareTargets(false)
+    }
+
+    @WorkerThread
+    private fun queryDirectShareTargets(skipAppPredictionService: Boolean) {
+        if (!skipAppPredictionService && appPredictor != null) {
+            appPredictor.requestPredictionUpdate()
+            return
+        }
+        // Default to just querying ShortcutManager if AppPredictor not present.
+        if (targetIntentFilter == null) return
+        val shortcuts = queryShortcutManager(targetIntentFilter)
+        sendShareShortcutInfoList(shortcuts, false, null)
+    }
+
+    @WorkerThread
+    private fun queryShortcutManager(targetIntentFilter: IntentFilter): List<ShareShortcutInfo> {
+        val selectedProfileContext = context.createContextAsUser(userHandle, 0 /* flags */)
+        val sm = selectedProfileContext
+            .getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager?
+        val pm = context.createContextAsUser(userHandle, 0 /* flags */).packageManager
+        return sm?.getShareTargets(targetIntentFilter)
+            ?.filter { pm.isPackageEnabled(it.targetComponent.packageName) }
+            ?: emptyList()
+    }
+
+    @WorkerThread
+    private fun onAppPredictorCallback(appPredictorTargets: List<AppTarget>) {
+        if (appPredictorTargets.isEmpty() && shouldQueryDirectShareTargets()) {
+            // APS may be disabled, so try querying targets ourselves.
+            queryDirectShareTargets(true)
+            return
+        }
+        val pm = context.createContextAsUser(userHandle, 0).packageManager
+        val pair = appPredictorTargets.toShortcuts(pm)
+        sendShareShortcutInfoList(pair.shortcuts, true, pair.appTargets)
+    }
+
+    @WorkerThread
+    private fun List<AppTarget>.toShortcuts(pm: PackageManager): ShortcutsAppTargetsPair =
+        fold(
+            ShortcutsAppTargetsPair(ArrayList(size), ArrayList(size))
+        ) { acc, appTarget ->
+            val shortcutInfo = appTarget.shortcutInfo
+            val packageName = appTarget.packageName
+            val className = appTarget.className
+            if (shortcutInfo != null && className != null && pm.isPackageEnabled(packageName)) {
+                (acc.shortcuts as ArrayList<ShareShortcutInfo>).add(
+                    ShareShortcutInfo(shortcutInfo, ComponentName(packageName, className))
+                )
+                (acc.appTargets as ArrayList<AppTarget>).add(appTarget)
+            }
+            acc
+        }
+
+    @WorkerThread
+    private fun sendShareShortcutInfoList(
+        shortcuts: List<ShareShortcutInfo>,
+        isFromAppPredictor: Boolean,
+        appPredictorTargets: List<AppTarget>?
+    ) {
+        if (appPredictorTargets != null && appPredictorTargets.size != shortcuts.size) {
+            throw RuntimeException(
+                "resultList and appTargets must have the same size."
+                        + " resultList.size()=" + shortcuts.size
+                        + " appTargets.size()=" + appPredictorTargets.size
+            )
+        }
+        val directShareAppTargetCache = HashMap<ChooserTarget, AppTarget>()
+        val directShareShortcutInfoCache = HashMap<ChooserTarget, ShortcutInfo>()
+        // Match ShareShortcutInfos with DisplayResolveInfos to be able to use the old code path
+        // for direct share targets. After ShareSheet is refactored we should use the
+        // ShareShortcutInfos directly.
+        val appTargets = activeRequest.get().appTargets
+        val resultRecords: MutableList<ShortcutResultInfo> = ArrayList()
+        for (displayResolveInfo in appTargets) {
+            val matchingShortcuts = shortcuts.filter {
+                it.targetComponent == displayResolveInfo.resolvedComponentName
+            }
+            if (matchingShortcuts.isEmpty()) continue
+            val chooserTargets = shortcutToChooserTargetConverter.convertToChooserTarget(
+                matchingShortcuts,
+                shortcuts,
+                appPredictorTargets,
+                directShareAppTargetCache,
+                directShareShortcutInfoCache
+            )
+            val resultRecord = ShortcutResultInfo(displayResolveInfo, chooserTargets)
+            resultRecords.add(resultRecord)
+        }
+        postReport(
+            Result(
+                isFromAppPredictor,
+                appTargets,
+                resultRecords.toTypedArray(),
+                directShareAppTargetCache,
+                directShareShortcutInfoCache
+            )
+        )
+    }
+
+    private fun postReport(result: Result) = callbackExecutor.execute { report(result) }
+
+    @MainThread
+    private fun report(result: Result) {
+        if (isDestroyed) return
+        callback.accept(result)
+    }
+
+    /**
+     * Returns `false` if `userHandle` is the work profile and it's either
+     * in quiet mode or not running.
+     */
+    private fun shouldQueryDirectShareTargets(): Boolean = isPersonalProfile || isProfileActive
+
+    @get:VisibleForTesting
+    protected val isProfileActive: Boolean
+        get() = userManager.isUserRunning(userHandle)
+            && userManager.isUserUnlocked(userHandle)
+            && !userManager.isQuietModeEnabled(userHandle)
+
+    private class Request(val appTargets: Array<DisplayResolveInfo>)
+
+    /**
+     * Resolved shortcuts with corresponding app targets.
+     */
+    class Result(
+        val isFromAppPredictor: Boolean,
+        /**
+         * Input app targets (see [ShortcutLoader.queryShortcuts] the
+         * shortcuts were process against.
+         */
+        val appTargets: Array<DisplayResolveInfo>,
+        /**
+         * Shortcuts grouped by app target.
+         */
+        val shortcutsByApp: Array<ShortcutResultInfo>,
+        val directShareAppTargetCache: Map<ChooserTarget, AppTarget>,
+        val directShareShortcutInfoCache: Map<ChooserTarget, ShortcutInfo>
+    )
+
+    /**
+     * Shortcuts grouped by app.
+     */
+    class ShortcutResultInfo(
+        val appTarget: DisplayResolveInfo,
+        val shortcuts: List<ChooserTarget?>
+    )
+
+    private class ShortcutsAppTargetsPair(
+        val shortcuts: List<ShareShortcutInfo>,
+        val appTargets: List<AppTarget>?
+    )
+
+    /**
+     * A wrapper around AppPredictor to facilitate unit-testing.
+     */
+    @VisibleForTesting
+    open class AppPredictorProxy internal constructor(private val mAppPredictor: AppPredictor) {
+        /**
+         * [AppPredictor.registerPredictionUpdates]
+         */
+        open fun registerPredictionUpdates(
+            callbackExecutor: Executor, callback: AppPredictor.Callback
+        ) = mAppPredictor.registerPredictionUpdates(callbackExecutor, callback)
+
+        /**
+         * [AppPredictor.unregisterPredictionUpdates]
+         */
+        open fun unregisterPredictionUpdates(callback: AppPredictor.Callback) =
+            mAppPredictor.unregisterPredictionUpdates(callback)
+
+        /**
+         * [AppPredictor.requestPredictionUpdate]
+         */
+        open fun requestPredictionUpdate() = mAppPredictor.requestPredictionUpdate()
+    }
+
+    companion object {
+        private const val TAG = "ShortcutLoader"
+        private val NO_REQUEST = Request(arrayOf())
+
+        private fun PackageManager.isPackageEnabled(packageName: String): Boolean {
+            if (TextUtils.isEmpty(packageName)) {
+                return false
+            }
+            return runCatching {
+                val appInfo = getApplicationInfo(
+                    packageName,
+                    PackageManager.ApplicationInfoFlags.of(PackageManager.GET_META_DATA.toLong())
+                )
+                appInfo.enabled && (appInfo.flags and ApplicationInfo.FLAG_SUSPENDED) == 0
+            }.getOrDefault(false)
+        }
+    }
+}
diff --git a/java/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt
new file mode 100644
index 0000000..ca94a95
--- /dev/null
+++ b/java/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt
@@ -0,0 +1,163 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.widget
+
+import android.animation.ObjectAnimator
+import android.content.Context
+import android.net.Uri
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.view.animation.DecelerateInterpolator
+import android.widget.RelativeLayout
+import androidx.core.view.isVisible
+import com.android.intentresolver.R
+import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+import com.android.internal.R as IntR
+
+private const val IMAGE_FADE_IN_MILLIS = 150L
+
+class ChooserImagePreviewView : RelativeLayout, ImagePreviewView {
+
+    constructor(context: Context) : this(context, null)
+    constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
+
+    constructor(
+        context: Context, attrs: AttributeSet?, defStyleAttr: Int
+    ) : this(context, attrs, defStyleAttr, 0)
+
+    constructor(
+        context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int
+    ) : super(context, attrs, defStyleAttr, defStyleRes)
+
+    private val coroutineScope = MainScope()
+    private lateinit var mainImage: RoundedRectImageView
+    private lateinit var secondLargeImage: RoundedRectImageView
+    private lateinit var secondSmallImage: RoundedRectImageView
+    private lateinit var thirdImage: RoundedRectImageView
+
+    private var loadImageJob: Job? = null
+    private var transitionStatusElementCallback: TransitionElementStatusCallback? = null
+
+    override fun onFinishInflate() {
+        LayoutInflater.from(context)
+            .inflate(R.layout.chooser_image_preview_view_internals, this, true)
+        mainImage = requireViewById(IntR.id.content_preview_image_1_large)
+        secondLargeImage = requireViewById(IntR.id.content_preview_image_2_large)
+        secondSmallImage = requireViewById(IntR.id.content_preview_image_2_small)
+        thirdImage = requireViewById(IntR.id.content_preview_image_3_small)
+    }
+
+    /**
+     * Specifies a transition animation target readiness callback. The callback will be
+     * invoked once when views preparation is done.
+     * Should be called before [setImages].
+     */
+    override fun setTransitionElementStatusCallback(callback: TransitionElementStatusCallback?) {
+        transitionStatusElementCallback = callback
+    }
+
+    override fun setImages(uris: List<Uri>, imageLoader: ImageLoader) {
+        loadImageJob?.cancel()
+        loadImageJob = coroutineScope.launch {
+            when (uris.size) {
+                0 -> hideAllViews()
+                1 -> showOneImage(uris, imageLoader)
+                2 -> showTwoImages(uris, imageLoader)
+                else -> showThreeImages(uris, imageLoader)
+            }
+        }
+    }
+
+    private fun hideAllViews() {
+        mainImage.isVisible = false
+        secondLargeImage.isVisible = false
+        secondSmallImage.isVisible = false
+        thirdImage.isVisible = false
+        invokeTransitionViewReadyCallback()
+    }
+
+    private suspend fun showOneImage(uris: List<Uri>, imageLoader: ImageLoader) {
+        secondLargeImage.isVisible = false
+        secondSmallImage.isVisible = false
+        thirdImage.isVisible = false
+        showImages(uris, imageLoader, mainImage)
+    }
+
+    private suspend fun showTwoImages(uris: List<Uri>, imageLoader: ImageLoader) {
+        secondSmallImage.isVisible = false
+        thirdImage.isVisible = false
+        showImages(uris, imageLoader, mainImage, secondLargeImage)
+    }
+
+    private suspend fun showThreeImages(uris: List<Uri>, imageLoader: ImageLoader) {
+        secondLargeImage.isVisible = false
+        showImages(uris, imageLoader, mainImage, secondSmallImage, thirdImage)
+        thirdImage.setExtraImageCount(uris.size - 3)
+    }
+
+    private suspend fun showImages(
+        uris: List<Uri>, imageLoader: ImageLoader, vararg views: RoundedRectImageView
+    ) = coroutineScope {
+        for (i in views.indices) {
+            launch {
+                loadImageIntoView(views[i], uris[i], imageLoader)
+            }
+        }
+    }
+
+    private suspend fun loadImageIntoView(
+        view: RoundedRectImageView, uri: Uri, imageLoader: ImageLoader
+    ) {
+        val bitmap = runCatching {
+            imageLoader(uri)
+        }.getOrDefault(null)
+        if (bitmap == null) {
+            view.isVisible = false
+            if (view === mainImage) {
+                invokeTransitionViewReadyCallback()
+            }
+        } else {
+            view.isVisible = true
+            view.setImageBitmap(bitmap)
+
+            view.alpha = 0f
+            ObjectAnimator.ofFloat(view, "alpha", 0.0f, 1.0f).apply {
+                interpolator = DecelerateInterpolator(1.0f)
+                duration = IMAGE_FADE_IN_MILLIS
+                start()
+            }
+            if (view === mainImage && transitionStatusElementCallback != null) {
+                view.waitForPreDraw()
+                invokeTransitionViewReadyCallback()
+            }
+        }
+    }
+
+    private fun invokeTransitionViewReadyCallback() {
+        transitionStatusElementCallback?.apply {
+            if (mainImage.isVisible && mainImage.drawable != null) {
+                mainImage.transitionName?.let { onTransitionElementReady(it) }
+            }
+            onAllTransitionElementsReady()
+        }
+        transitionStatusElementCallback = null
+    }
+}
diff --git a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt
index a37ef95..a166ef2 100644
--- a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt
+++ b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt
@@ -16,163 +16,34 @@
 
 package com.android.intentresolver.widget
 
-import android.animation.ObjectAnimator
-import android.content.Context
 import android.graphics.Bitmap
 import android.net.Uri
-import android.util.AttributeSet
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewTreeObserver
-import android.view.animation.DecelerateInterpolator
-import android.widget.RelativeLayout
-import androidx.core.view.isVisible
-import com.android.intentresolver.R
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.MainScope
-import kotlinx.coroutines.coroutineScope
-import kotlinx.coroutines.launch
-import java.util.function.Consumer
-import com.android.internal.R as IntR
 
-typealias ImageLoader = suspend (Uri) -> Bitmap?
+internal typealias ImageLoader = suspend (Uri) -> Bitmap?
 
-private const val IMAGE_FADE_IN_MILLIS = 150L
-
-class ImagePreviewView : RelativeLayout {
-
-    constructor(context: Context) : this(context, null)
-    constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
-
-    constructor(
-        context: Context, attrs: AttributeSet?, defStyleAttr: Int
-    ) : this(context, attrs, defStyleAttr, 0)
-
-    constructor(
-        context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int
-    ) : super(context, attrs, defStyleAttr, defStyleRes)
-
-    private val coroutineScope = MainScope()
-    private lateinit var mainImage: RoundedRectImageView
-    private lateinit var secondLargeImage: RoundedRectImageView
-    private lateinit var secondSmallImage: RoundedRectImageView
-    private lateinit var thirdImage: RoundedRectImageView
-
-    private var loadImageJob: Job? = null
-    private var onTransitionViewReadyCallback: Consumer<Boolean>? = null
-
-    override fun onFinishInflate() {
-        LayoutInflater.from(context).inflate(R.layout.image_preview_view, this, true)
-        mainImage = requireViewById(IntR.id.content_preview_image_1_large)
-        secondLargeImage = requireViewById(IntR.id.content_preview_image_2_large)
-        secondSmallImage = requireViewById(IntR.id.content_preview_image_2_small)
-        thirdImage = requireViewById(IntR.id.content_preview_image_3_small)
-    }
+interface ImagePreviewView {
+    fun setTransitionElementStatusCallback(callback: TransitionElementStatusCallback?)
+    fun setImages(uris: List<Uri>, imageLoader: ImageLoader)
 
     /**
-     * Specifies a transition animation target name and a readiness callback. The callback will be
-     * invoked once when the view preparation is done i.e. either when an image is loaded into it
-     * and it is laid out (and it is ready to be draw) or image loading has failed.
-     * Should be called before [setImages].
-     * @param name, transition name
-     * @param onViewReady, a callback that will be invoked with `true` if the view is ready to
-     * receive transition animation (the image was loaded successfully) and with `false` otherwise.
+     * [ImagePreviewView] progressively prepares views for shared element transition and reports
+     * each successful preparation with [onTransitionElementReady] call followed by
+     * closing [onAllTransitionElementsReady] invocation. Thus the overall invocation pattern is
+     * zero or more [onTransitionElementReady] calls followed by the final
+     * [onAllTransitionElementsReady] call.
      */
-    fun setSharedElementTransitionTarget(name: String, onViewReady: Consumer<Boolean>) {
-        mainImage.transitionName = name
-        onTransitionViewReadyCallback = onViewReady
-    }
+    interface TransitionElementStatusCallback {
+        /**
+         * Invoked when a view for a shared transition animation element is ready i.e. the image
+         * is loaded and the view is laid out.
+         * @param name shared element name.
+         */
+        fun onTransitionElementReady(name: String)
 
-    fun setImages(uris: List<Uri>, imageLoader: ImageLoader) {
-        loadImageJob?.cancel()
-        loadImageJob = coroutineScope.launch {
-            when (uris.size) {
-                0 -> hideAllViews()
-                1 -> showOneImage(uris, imageLoader)
-                2 -> showTwoImages(uris, imageLoader)
-                else -> showThreeImages(uris, imageLoader)
-            }
-        }
-    }
-
-    private fun hideAllViews() {
-        mainImage.isVisible = false
-        secondLargeImage.isVisible = false
-        secondSmallImage.isVisible = false
-        thirdImage.isVisible = false
-        invokeTransitionViewReadyCallback(runTransitionAnimation = false)
-    }
-
-    private suspend fun showOneImage(uris: List<Uri>, imageLoader: ImageLoader) {
-        secondLargeImage.isVisible = false
-        secondSmallImage.isVisible = false
-        thirdImage.isVisible = false
-        showImages(uris, imageLoader, mainImage)
-    }
-
-    private suspend fun showTwoImages(uris: List<Uri>, imageLoader: ImageLoader) {
-        secondSmallImage.isVisible = false
-        thirdImage.isVisible = false
-        showImages(uris, imageLoader, mainImage, secondLargeImage)
-    }
-
-    private suspend fun showThreeImages(uris: List<Uri>, imageLoader: ImageLoader) {
-        secondLargeImage.isVisible = false
-        showImages(uris, imageLoader, mainImage, secondSmallImage, thirdImage)
-        thirdImage.setExtraImageCount(uris.size - 3)
-    }
-
-    private suspend fun showImages(
-        uris: List<Uri>, imageLoader: ImageLoader, vararg views: RoundedRectImageView
-    ) = coroutineScope {
-        for (i in views.indices) {
-            launch {
-                loadImageIntoView(views[i], uris[i], imageLoader)
-            }
-        }
-    }
-
-    private suspend fun loadImageIntoView(
-        view: RoundedRectImageView, uri: Uri, imageLoader: ImageLoader
-    ) {
-        val bitmap = runCatching {
-            imageLoader(uri)
-        }.getOrDefault(null)
-        if (bitmap == null) {
-            view.isVisible = false
-            if (view === mainImage) {
-                invokeTransitionViewReadyCallback(runTransitionAnimation = false)
-            }
-        } else {
-            view.isVisible = true
-            view.setImageBitmap(bitmap)
-
-            view.alpha = 0f
-            ObjectAnimator.ofFloat(view, "alpha", 0.0f, 1.0f).apply {
-                interpolator = DecelerateInterpolator(1.0f)
-                duration = IMAGE_FADE_IN_MILLIS
-                start()
-            }
-            if (view === mainImage && onTransitionViewReadyCallback != null) {
-                setupPreDrawListener(mainImage)
-            }
-        }
-    }
-
-    private fun setupPreDrawListener(view: View) {
-        view.viewTreeObserver.addOnPreDrawListener(
-            object : ViewTreeObserver.OnPreDrawListener {
-                override fun onPreDraw(): Boolean {
-                    view.viewTreeObserver.removeOnPreDrawListener(this)
-                    invokeTransitionViewReadyCallback(runTransitionAnimation = true)
-                    return true
-                }
-            }
-        )
-    }
-
-    private fun invokeTransitionViewReadyCallback(runTransitionAnimation: Boolean) {
-        onTransitionViewReadyCallback?.accept(runTransitionAnimation)
-        onTransitionViewReadyCallback = null
+        /**
+         * Indicates that all supported transition elements have been reported with
+         * [onTransitionElementReady].
+         */
+        fun onAllTransitionElementsReady()
     }
 }
diff --git a/java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt b/java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt
new file mode 100644
index 0000000..a790600
--- /dev/null
+++ b/java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.widget
+
+import android.view.View
+import androidx.recyclerview.widget.RecyclerView
+
+internal val RecyclerView.areAllChildrenVisible: Boolean
+    get() {
+        val count = getChildCount()
+        if (count == 0) return true
+        val first = getChildAt(0)
+        val last = getChildAt(count - 1)
+        val itemCount = adapter?.itemCount ?: 0
+        return getChildAdapterPosition(first) == 0
+                && getChildAdapterPosition(last) == itemCount - 1
+                && isFullyVisible(first)
+                && isFullyVisible(last)
+    }
+
+private fun RecyclerView.isFullyVisible(view: View): Boolean =
+    view.left >= paddingLeft && view.right <= width - paddingRight
diff --git a/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt b/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt
index a941b97..f2a8b9e 100644
--- a/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt
+++ b/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt
@@ -50,21 +50,6 @@
         )
     }
 
-    private val areAllChildrenVisible: Boolean
-        get() {
-            val count = getChildCount()
-            if (count == 0) return true
-            val first = getChildAt(0)
-            val last = getChildAt(count - 1)
-            return getChildAdapterPosition(first) == 0
-                && getChildAdapterPosition(last) == actionsAdapter.itemCount - 1
-                && isFullyVisible(first)
-                && isFullyVisible(last)
-        }
-
-    private fun isFullyVisible(view: View): Boolean =
-        view.left >= paddingLeft && view.right <= width - paddingRight
-
     private class Adapter(private val context: Context) : RecyclerView.Adapter<ViewHolder>() {
         private val iconSize: Int =
             context.resources.getDimensionPixelSize(R.dimen.chooser_action_view_icon_size)
@@ -103,11 +88,12 @@
     ) : RecyclerView.ViewHolder(view) {
 
         fun bind(action: ActionRow.Action) {
-            if (action.icon != null) {
-                action.icon.setBounds(0, 0, iconSize, iconSize)
+            action.icon?.let { icon ->
+                icon.setBounds(0, 0, iconSize, iconSize)
                 // some drawables (edit) does not gets tinted when set to the top of the text
                 // with TextView#setCompoundDrawableRelative
-                view.setCompoundDrawablesRelative(null, action.icon, null, null)
+                tintIcon(icon, view)
+                view.setCompoundDrawablesRelative(null, icon, null, null)
             }
             view.text = action.label ?: ""
             view.setOnClickListener {
diff --git a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt
new file mode 100644
index 0000000..467c404
--- /dev/null
+++ b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt
@@ -0,0 +1,178 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.widget
+
+import android.content.Context
+import android.graphics.Rect
+import android.net.Uri
+import android.util.AttributeSet
+import android.util.TypedValue
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.android.intentresolver.R
+import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.plus
+
+private const val TRANSITION_NAME = "screenshot_preview_image"
+
+class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
+    constructor(context: Context) : this(context, null)
+    constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
+    constructor(
+        context: Context, attrs: AttributeSet?, defStyleAttr: Int
+    ) : super(context, attrs, defStyleAttr) {
+        layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
+        adapter = Adapter(context)
+        val spacing = TypedValue.applyDimension(
+            TypedValue.COMPLEX_UNIT_DIP, 5f, context.resources.displayMetrics
+        ).toInt()
+        addItemDecoration(SpacingDecoration(spacing))
+    }
+
+    private val previewAdapter get() = adapter as Adapter
+
+    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
+        super.onLayout(changed, l, t, r, b)
+        setOverScrollMode(
+            if (areAllChildrenVisible) View.OVER_SCROLL_NEVER else View.OVER_SCROLL_ALWAYS
+        )
+    }
+
+    override fun setTransitionElementStatusCallback(callback: TransitionElementStatusCallback?) {
+        previewAdapter.transitionStatusElementCallback = callback
+    }
+
+    override fun setImages(uris: List<Uri>, imageLoader: ImageLoader) {
+        previewAdapter.setImages(uris, imageLoader)
+    }
+
+    private class Adapter(private val context: Context) : RecyclerView.Adapter<ViewHolder>() {
+        private val uris = ArrayList<Uri>()
+        private var imageLoader: ImageLoader? = null
+        var transitionStatusElementCallback: TransitionElementStatusCallback? = null
+
+        fun setImages(uris: List<Uri>, imageLoader: ImageLoader) {
+            this.uris.clear()
+            this.uris.addAll(uris)
+            this.imageLoader = imageLoader
+            notifyDataSetChanged()
+        }
+
+        override fun onCreateViewHolder(parent: ViewGroup, itemType: Int): ViewHolder {
+            return ViewHolder(
+                LayoutInflater.from(context)
+                    .inflate(R.layout.image_preview_image_item, parent, false)
+            )
+        }
+
+        override fun getItemCount(): Int = uris.size
+
+        override fun onBindViewHolder(vh: ViewHolder, position: Int) {
+            vh.bind(
+                uris[position],
+                imageLoader ?: error("ImageLoader is missing"),
+                if (position == 0 && transitionStatusElementCallback != null) {
+                    this::onTransitionElementReady
+                } else {
+                    null
+                }
+            )
+        }
+
+        override fun onViewRecycled(vh: ViewHolder) {
+            vh.unbind()
+        }
+
+        override fun onFailedToRecycleView(vh: ViewHolder): Boolean {
+            vh.unbind()
+            return super.onFailedToRecycleView(vh)
+        }
+
+        private fun onTransitionElementReady(name: String) {
+            transitionStatusElementCallback?.apply {
+                onTransitionElementReady(name)
+                onAllTransitionElementsReady()
+            }
+            transitionStatusElementCallback = null
+        }
+    }
+
+    private class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
+        private val image = view.requireViewById<ImageView>(R.id.image)
+        private var scope: CoroutineScope? = null
+
+        fun bind(
+            uri: Uri,
+            imageLoader: ImageLoader,
+            previewReadyCallback: ((String) -> Unit)?
+        ) {
+            image.setImageDrawable(null)
+            image.transitionName = if (previewReadyCallback != null) {
+                TRANSITION_NAME
+            } else {
+                null
+            }
+            resetScope().launch {
+                loadImage(uri, imageLoader, previewReadyCallback)
+            }
+        }
+
+        private suspend fun loadImage(
+            uri: Uri,
+            imageLoader: ImageLoader,
+            previewReadyCallback: ((String) -> Unit)?
+        ) {
+            val bitmap = runCatching {
+                // it's expected for all loading/caching optimizations to be implemented by the
+                // loader
+                imageLoader(uri)
+            }.getOrNull()
+            image.setImageBitmap(bitmap)
+            previewReadyCallback?.let { callback ->
+                image.waitForPreDraw()
+                callback(TRANSITION_NAME)
+            }
+        }
+
+        private fun resetScope(): CoroutineScope =
+            (MainScope() + Dispatchers.Main.immediate).also {
+                scope?.cancel()
+                scope = it
+            }
+
+        fun unbind() {
+            scope?.cancel()
+            scope = null
+        }
+    }
+
+    private class SpacingDecoration(private val margin: Int) : RecyclerView.ItemDecoration() {
+        override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: State) {
+            outRect.set(margin, 0, margin, 0)
+        }
+    }
+}
diff --git a/java/src/com/android/intentresolver/widget/ViewExtensions.kt b/java/src/com/android/intentresolver/widget/ViewExtensions.kt
new file mode 100644
index 0000000..11b7c14
--- /dev/null
+++ b/java/src/com/android/intentresolver/widget/ViewExtensions.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.widget
+
+import android.util.Log
+import android.view.View
+import androidx.core.view.OneShotPreDrawListener
+import kotlinx.coroutines.suspendCancellableCoroutine
+import java.util.concurrent.atomic.AtomicBoolean
+
+internal suspend fun View.waitForPreDraw(): Unit = suspendCancellableCoroutine { continuation ->
+    val isResumed = AtomicBoolean(false)
+    val callback = OneShotPreDrawListener.add(
+        this,
+        Runnable {
+            if (isResumed.compareAndSet(false, true)) {
+                continuation.resumeWith(Result.success(Unit))
+            } else {
+                // it's not really expected but in some unknown corner-case let's not crash
+                Log.e("waitForPreDraw", "An attempt to resume a completed coroutine", Exception())
+            }
+        }
+    )
+    continuation.invokeOnCancellation { callback.removeListener() }
+}
diff --git a/java/tests/Android.bp b/java/tests/Android.bp
index 2913d12..4e835ec 100644
--- a/java/tests/Android.bp
+++ b/java/tests/Android.bp
@@ -21,11 +21,16 @@
         "IntentResolver-core",
         "androidx.test.rules",
         "androidx.test.ext.junit",
+        "androidx.test.espresso.contrib",
         "mockito-target-minus-junit4",
         "androidx.test.espresso.core",
+        "androidx.lifecycle_lifecycle-common-java8",
+        "androidx.lifecycle_lifecycle-extensions",
+        "androidx.lifecycle_lifecycle-runtime-ktx",
         "truth-prebuilt",
         "testables",
         "testng",
+        "kotlinx_coroutines_test",
     ],
     test_suites: ["general-tests"],
     sdk_version: "core_platform",
diff --git a/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt b/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt
new file mode 100644
index 0000000..af134fc
--- /dev/null
+++ b/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt
@@ -0,0 +1,154 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver
+
+import android.app.Activity
+import android.app.PendingIntent
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.res.Resources
+import android.graphics.drawable.Icon
+import android.service.chooser.ChooserAction
+import android.view.View
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.intentresolver.flags.FeatureFlagRepository
+import com.android.intentresolver.flags.Flags
+import com.google.common.collect.ImmutableList
+import com.google.common.truth.Truth.assertThat
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito
+import java.util.concurrent.Callable
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.function.Consumer
+
+@RunWith(AndroidJUnit4::class)
+class ChooserActionFactoryTest {
+    private val context = InstrumentationRegistry.getInstrumentation().getContext()
+
+    private val logger = mock<ChooserActivityLogger>()
+    private val flags = mock<FeatureFlagRepository>()
+    private val actionLabel = "Action label"
+    private val testAction = "com.android.intentresolver.testaction"
+    private val countdown = CountDownLatch(1)
+    private val testReceiver: BroadcastReceiver = object : BroadcastReceiver() {
+        override fun onReceive(context: Context, intent: Intent) {
+            // Just doing at most a single countdown per test.
+            countdown.countDown()
+        }
+    }
+    private object resultConsumer : Consumer<Int> {
+        var latestReturn = Integer.MIN_VALUE
+
+        override fun accept(resultCode: Int) {
+            latestReturn = resultCode
+        }
+
+    }
+
+    @Before
+    fun setup() {
+        whenever(flags.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION)).thenReturn(true)
+        context.registerReceiver(testReceiver, IntentFilter(testAction))
+    }
+
+    @After
+    fun teardown() {
+        context.unregisterReceiver(testReceiver)
+    }
+
+    @Test
+    fun testCreateCustomActions() {
+        val factory = createFactory()
+
+        val customActions = factory.createCustomActions()
+
+        assertThat(customActions.size).isEqualTo(1)
+        assertThat(customActions[0].label).isEqualTo(actionLabel)
+
+        // click it
+        customActions[0].onClicked.run()
+
+        Mockito.verify(logger).logCustomActionSelected(eq(0))
+        assertEquals(Activity.RESULT_OK, resultConsumer.latestReturn)
+        // Verify the pendingintent has been called
+        countdown.await(500, TimeUnit.MILLISECONDS)
+    }
+
+    @Test
+    fun testNoModifyShareAction() {
+        val factory = createFactory(includeModifyShare = false)
+
+        assertThat(factory.modifyShareAction).isNull()
+    }
+
+    @Test
+    fun testNoModifyShareAction_flagDisabled() {
+        whenever(flags.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION)).thenReturn(false)
+        val factory = createFactory(includeModifyShare = true)
+
+        assertThat(factory.modifyShareAction).isNull()
+    }
+
+    @Test
+    fun testModifyShareAction() {
+        val factory = createFactory(includeModifyShare = true)
+
+        factory.modifyShareAction!!.run()
+
+        Mockito.verify(logger).logActionSelected(
+            eq(ChooserActivityLogger.SELECTION_TYPE_MODIFY_SHARE))
+        assertEquals(Activity.RESULT_OK, resultConsumer.latestReturn)
+        // Verify the pendingintent has been called
+        countdown.await(500, TimeUnit.MILLISECONDS)
+    }
+
+    private fun createFactory(includeModifyShare: Boolean = false): ChooserActionFactory {
+        val testPendingIntent = PendingIntent.getActivity(context, 0, Intent(testAction),0)
+        val targetIntent = Intent()
+        val action = ChooserAction.Builder(
+            Icon.createWithResource("", Resources.ID_NULL),
+            actionLabel,
+            testPendingIntent
+        ).build()
+        val chooserRequest = mock<ChooserRequestParameters>()
+        whenever(chooserRequest.targetIntent).thenReturn(targetIntent)
+        whenever(chooserRequest.chooserActions).thenReturn(ImmutableList.of(action))
+
+        if (includeModifyShare) {
+            whenever(chooserRequest.modifyShareAction).thenReturn(testPendingIntent)
+        }
+
+        return ChooserActionFactory(
+            context,
+            chooserRequest,
+            flags,
+            mock<ChooserIntegratedDeviceComponents>(),
+            logger,
+            Consumer<Boolean>{},
+            Callable<View?>{null},
+            mock<ChooserActionFactory.ActionActivityStarter>(),
+            resultConsumer)
+    }
+}
\ No newline at end of file
diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java b/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java
index 705a322..aa42c24 100644
--- a/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java
+++ b/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java
@@ -36,6 +36,7 @@
 import com.android.intentresolver.ChooserActivityLogger.SharesheetStandardEvent;
 import com.android.intentresolver.ChooserActivityLogger.SharesheetStartedEvent;
 import com.android.intentresolver.ChooserActivityLogger.SharesheetTargetSelectedEvent;
+import com.android.intentresolver.contentpreview.ContentPreviewType;
 import com.android.internal.logging.InstanceId;
 import com.android.internal.logging.MetricsLogger;
 import com.android.internal.logging.UiEventLogger;
@@ -112,24 +113,26 @@
 
     @Test
     public void testLogShareStarted() {
-        final int eventId = -1;  // Passed-in eventId is unused. TODO: remove from method signature.
         final String packageName = "com.test.foo";
         final String mimeType = "text/plain";
         final int appProvidedDirectTargets = 123;
         final int appProvidedAppTargets = 456;
         final boolean workProfile = true;
-        final int previewType = ChooserContentPreviewUi.CONTENT_PREVIEW_FILE;
+        final int previewType = ContentPreviewType.CONTENT_PREVIEW_FILE;
         final String intentAction = Intent.ACTION_SENDTO;
+        final int numCustomActions = 3;
+        final boolean modifyShareProvided = true;
 
         mChooserLogger.logShareStarted(
-                eventId,
                 packageName,
                 mimeType,
                 appProvidedDirectTargets,
                 appProvidedAppTargets,
                 workProfile,
                 previewType,
-                intentAction);
+                intentAction,
+                numCustomActions,
+                modifyShareProvided);
 
         verify(mFrameworkLog).write(
                 eq(FrameworkStatsLog.SHARESHEET_STARTED),
@@ -141,7 +144,9 @@
                 eq(appProvidedAppTargets),
                 eq(workProfile),
                 eq(FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_FILE),
-                eq(FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_SENDTO));
+                eq(FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_SENDTO),
+                /* custom actions provided */ eq(numCustomActions),
+                /* reselection action provided */ eq(modifyShareProvided));
     }
 
     @Test
@@ -203,6 +208,17 @@
     }
 
     @Test
+    public void testLogCustomActionSelected() {
+        final int position = 4;
+        mChooserLogger.logCustomActionSelected(position);
+
+        verify(mFrameworkLog).write(
+                eq(FrameworkStatsLog.RANKING_SELECTED),
+                eq(SharesheetTargetSelectedEvent.SHARESHEET_CUSTOM_ACTION_SELECTED.getId()),
+                any(), anyInt(), eq(position), eq(false));
+    }
+
+    @Test
     public void testLogDirectShareTargetReceived() {
         final int category = MetricsEvent.ACTION_DIRECT_SHARE_TARGETS_LOADED_SHORTCUT_MANAGER;
         final int latency = 123;
@@ -218,7 +234,7 @@
 
     @Test
     public void testLogActionShareWithPreview() {
-        final int previewType = ChooserContentPreviewUi.CONTENT_PREVIEW_TEXT;
+        final int previewType = ContentPreviewType.CONTENT_PREVIEW_TEXT;
 
         mChooserLogger.logActionShareWithPreview(previewType);
 
diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java
index 5df0d4a..f0c459e 100644
--- a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java
+++ b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java
@@ -29,8 +29,8 @@
 
 import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker;
 import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider;
-import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeManager;
 import com.android.intentresolver.chooser.TargetInfo;
+import com.android.intentresolver.flags.FeatureFlagRepository;
 import com.android.intentresolver.shortcuts.ShortcutLoader;
 
 import java.util.function.Consumer;
@@ -58,8 +58,8 @@
     public Function<TargetInfo, Boolean> onSafelyStartCallback;
     public Function2<UserHandle, Consumer<ShortcutLoader.Result>, ShortcutLoader>
             shortcutLoaderFactory = (userHandle, callback) -> null;
-    public ResolverListController resolverListController;
-    public ResolverListController workResolverListController;
+    public ChooserActivity.ChooserListController resolverListController;
+    public ChooserActivity.ChooserListController workResolverListController;
     public Boolean isVoiceInteraction;
     public boolean isImageType;
     public Cursor resolverCursor;
@@ -72,10 +72,11 @@
     public boolean hasCrossProfileIntents;
     public boolean isQuietModeEnabled;
     public Integer myUserId;
-    public QuietModeManager mQuietModeManager;
+    public WorkProfileAvailabilityManager mWorkProfileAvailability;
     public MyUserIdProvider mMyUserIdProvider;
     public CrossProfileIntentsChecker mCrossProfileIntentsChecker;
     public PackageManager packageManager;
+    public FeatureFlagRepository featureFlagRepository;
 
     public void reset() {
         onSafelyStartCallback = null;
@@ -85,8 +86,8 @@
         isImageType = false;
         resolverCursor = null;
         resolverForceException = false;
-        resolverListController = mock(ResolverListController.class);
-        workResolverListController = mock(ResolverListController.class);
+        resolverListController = mock(ChooserActivity.ChooserListController.class);
+        workResolverListController = mock(ChooserActivity.ChooserListController.class);
         chooserActivityLogger = mock(ChooserActivityLogger.class);
         alternateProfileSetting = 0;
         resources = null;
@@ -95,21 +96,24 @@
         isQuietModeEnabled = false;
         myUserId = null;
         packageManager = null;
-        mQuietModeManager = new QuietModeManager() {
+        mWorkProfileAvailability = new WorkProfileAvailabilityManager(null, null, null) {
             @Override
-            public boolean isQuietModeEnabled(UserHandle workProfileUserHandle) {
+            public boolean isQuietModeEnabled() {
                 return isQuietModeEnabled;
             }
 
             @Override
-            public void requestQuietModeEnabled(boolean enabled,
-                    UserHandle workProfileUserHandle) {
+            public boolean isWorkProfileUserUnlocked() {
+                return true;
+            }
+
+            @Override
+            public void requestQuietModeEnabled(boolean enabled) {
                 isQuietModeEnabled = enabled;
             }
 
             @Override
-            public void markWorkProfileEnabledBroadcastReceived() {
-            }
+            public void markWorkProfileEnabledBroadcastReceived() {}
 
             @Override
             public boolean isWaitingToEnableWorkProfile() {
@@ -128,6 +132,7 @@
         mCrossProfileIntentsChecker = mock(CrossProfileIntentsChecker.class);
         when(mCrossProfileIntentsChecker.hasCrossProfileIntents(any(), anyInt(), anyInt()))
                 .thenAnswer(invocation -> hasCrossProfileIntents);
+        featureFlagRepository = null;
     }
 
     private ChooserActivityOverrideData() {}
diff --git a/java/tests/src/com/android/intentresolver/ChooserIntegratedDeviceComponentsTest.kt b/java/tests/src/com/android/intentresolver/ChooserIntegratedDeviceComponentsTest.kt
new file mode 100644
index 0000000..9a5dabd
--- /dev/null
+++ b/java/tests/src/com/android/intentresolver/ChooserIntegratedDeviceComponentsTest.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver
+
+import android.content.ComponentName
+import android.provider.Settings
+import android.testing.TestableContext
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ChooserIntegratedDeviceComponentsTest {
+    private val secureSettings = mock<SecureSettings>()
+    private val testableContext =
+        TestableContext(InstrumentationRegistry.getInstrumentation().getContext())
+
+    @Test
+    fun testEditorAndNearby() {
+        val resources = testableContext.getOrCreateTestableResources()
+
+        resources.addOverride(R.string.config_systemImageEditor, "")
+        resources.addOverride(R.string.config_defaultNearbySharingComponent, "")
+
+        var components = ChooserIntegratedDeviceComponents.get(testableContext, secureSettings)
+
+        assertThat(components.editSharingComponent).isNull()
+        assertThat(components.nearbySharingComponent).isNull()
+
+        val editor = ComponentName.unflattenFromString("com.android/com.android.Editor")
+        val nearby = ComponentName.unflattenFromString("com.android/com.android.nearby")
+
+        resources.addOverride(R.string.config_systemImageEditor, editor?.flattenToString())
+        resources.addOverride(
+            R.string.config_defaultNearbySharingComponent, nearby?.flattenToString())
+
+        components = ChooserIntegratedDeviceComponents.get(testableContext, secureSettings)
+
+        assertThat(components.editSharingComponent).isEqualTo(editor)
+        assertThat(components.nearbySharingComponent).isEqualTo(nearby)
+
+        val anotherNearby =
+            ComponentName.unflattenFromString("com.android/com.android.another_nearby")
+        whenever(
+            secureSettings.getString(
+                any(),
+                eq(Settings.Secure.NEARBY_SHARING_COMPONENT)
+            )
+        ).thenReturn(anotherNearby?.flattenToString())
+
+        components = ChooserIntegratedDeviceComponents.get(testableContext, secureSettings)
+
+        assertThat(components.nearbySharingComponent).isEqualTo(anotherNearby)
+    }
+}
diff --git a/java/tests/src/com/android/intentresolver/ChooserRefinementManagerTest.kt b/java/tests/src/com/android/intentresolver/ChooserRefinementManagerTest.kt
new file mode 100644
index 0000000..50c37c7
--- /dev/null
+++ b/java/tests/src/com/android/intentresolver/ChooserRefinementManagerTest.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver
+
+import android.content.Context
+import android.content.Intent
+import android.content.IntentSender
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.android.intentresolver.chooser.TargetInfo
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentCaptor
+import org.mockito.Mockito
+import java.util.function.Consumer
+import org.junit.Assert.assertEquals
+
+@RunWith(AndroidJUnit4::class)
+class ChooserRefinementManagerTest {
+    @Test
+    fun testMaybeHandleSelection() {
+        val intentSender = mock<IntentSender>()
+        val refinementManager = ChooserRefinementManager(
+            mock<Context>(),
+            intentSender,
+            Consumer<TargetInfo>{},
+            Runnable{})
+
+        val intents = listOf(Intent(Intent.ACTION_VIEW), Intent(Intent.ACTION_EDIT))
+        val targetInfo = mock<TargetInfo>{
+            whenever(allSourceIntents).thenReturn(intents)
+        }
+
+        refinementManager.maybeHandleSelection(targetInfo)
+
+        val intentCaptor = ArgumentCaptor.forClass(Intent::class.java)
+        Mockito.verify(intentSender).sendIntent(
+            any(), eq(0), intentCaptor.capture(), eq(null), eq(null))
+
+        val intent = intentCaptor.value
+        assertEquals(intents[0], intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java))
+
+        val alternates =
+            intent.getParcelableArrayExtra(Intent.EXTRA_ALTERNATE_INTENTS, Intent::class.java)
+        assertEquals(1, alternates?.size)
+        assertEquals(intents[1], alternates?.get(0))
+    }
+}
diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java
index 97de97f..d4ae666 100644
--- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java
+++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java
@@ -16,8 +16,6 @@
 
 package com.android.intentresolver;
 
-import static org.mockito.Mockito.when;
-
 import android.annotation.Nullable;
 import android.app.prediction.AppPredictor;
 import android.app.usage.UsageStatsManager;
@@ -30,17 +28,14 @@
 import android.content.pm.ResolveInfo;
 import android.content.res.Resources;
 import android.database.Cursor;
-import android.graphics.Bitmap;
 import android.net.Uri;
 import android.os.UserHandle;
-import android.util.Size;
 
 import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker;
 import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider;
-import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeManager;
 import com.android.intentresolver.chooser.DisplayResolveInfo;
-import com.android.intentresolver.chooser.NotSelectableTargetInfo;
 import com.android.intentresolver.chooser.TargetInfo;
+import com.android.intentresolver.flags.FeatureFlagRepository;
 import com.android.intentresolver.grid.ChooserGridAdapter;
 import com.android.intentresolver.shortcuts.ShortcutLoader;
 import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
@@ -121,15 +116,13 @@
     }
 
     @Override
-    protected ComponentName getNearbySharingComponent() {
-        // an arbitrary pre-installed activity that handles this type of intent
-        return ComponentName.unflattenFromString("com.google.android.apps.messaging/"
-                + "com.google.android.apps.messaging.ui.conversationlist.ShareIntentActivity");
-    }
-
-    @Override
-    protected TargetInfo getNearbySharingTarget(Intent originalIntent) {
-        return NotSelectableTargetInfo.newEmptyTargetInfo();
+    protected ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() {
+        return new ChooserIntegratedDeviceComponents(
+                /* editSharingComponent=*/ null,
+                // An arbitrary pre-installed activity that handles this type of intent:
+                /* nearbySharingComponent=*/ new ComponentName(
+                        "com.google.android.apps.messaging",
+                        ".ui.conversationlist.ShareIntentActivity"));
     }
 
     @Override
@@ -165,15 +158,15 @@
     }
 
     @Override
-    protected QuietModeManager createQuietModeManager() {
-        if (sOverrides.mQuietModeManager != null) {
-            return sOverrides.mQuietModeManager;
+    protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() {
+        if (sOverrides.mWorkProfileAvailability != null) {
+            return sOverrides.mWorkProfileAvailability;
         }
-        return super.createQuietModeManager();
+        return super.createWorkProfileAvailabilityManager();
     }
 
     @Override
-    public void safelyStartActivity(com.android.intentresolver.chooser.TargetInfo cti) {
+    public void safelyStartActivity(TargetInfo cti) {
         if (sOverrides.onSafelyStartCallback != null
                 && sOverrides.onSafelyStartCallback.apply(cti)) {
             return;
@@ -182,12 +175,10 @@
     }
 
     @Override
-    protected ResolverListController createListController(UserHandle userHandle) {
+    protected ChooserListController createListController(UserHandle userHandle) {
         if (userHandle == UserHandle.SYSTEM) {
-            when(sOverrides.resolverListController.getUserHandle()).thenReturn(UserHandle.SYSTEM);
             return sOverrides.resolverListController;
         }
-        when(sOverrides.workResolverListController.getUserHandle()).thenReturn(userHandle);
         return sOverrides.workResolverListController;
     }
 
@@ -208,11 +199,10 @@
     }
 
     @Override
-    protected Bitmap loadThumbnail(Uri uri, Size size) {
-        if (sOverrides.previewThumbnail != null) {
-            return sOverrides.previewThumbnail;
-        }
-        return super.loadThumbnail(uri, size);
+    protected ImageLoader createPreviewImageLoader() {
+        return new TestPreviewImageLoader(
+                super.createPreviewImageLoader(),
+                () -> sOverrides.previewThumbnail);
     }
 
     @Override
@@ -290,4 +280,12 @@
         return super.createShortcutLoader(
                 context, appPredictor, userHandle, targetIntentFilter, callback);
     }
+
+    @Override
+    protected FeatureFlagRepository createFeatureFlagRepository() {
+        if (sOverrides.featureFlagRepository != null) {
+            return sOverrides.featureFlagRepository;
+        }
+        return super.createFeatureFlagRepository();
+    }
 }
diff --git a/java/tests/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt b/java/tests/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt
new file mode 100644
index 0000000..9ea9dfa
--- /dev/null
+++ b/java/tests/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt
@@ -0,0 +1,112 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver
+
+import android.content.res.Resources
+import android.view.View
+import android.view.Window
+import androidx.activity.ComponentActivity
+import androidx.lifecycle.Lifecycle
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestCoroutineScheduler
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.mockito.Mockito.anyInt
+import org.mockito.Mockito.never
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+
+private const val TIMEOUT_MS = 200
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class EnterTransitionAnimationDelegateTest {
+    private val elementName = "shared-element"
+    private val scheduler = TestCoroutineScheduler()
+    private val dispatcher = StandardTestDispatcher(scheduler)
+    private val lifecycleOwner = TestLifecycleOwner()
+
+    private val transitionTargetView = mock<View> {
+        // avoid the request-layout path in the delegate
+        whenever(isInLayout).thenReturn(true)
+    }
+
+    private val windowMock = mock<Window>()
+    private val resourcesMock = mock<Resources> {
+        whenever(getInteger(anyInt())).thenReturn(TIMEOUT_MS)
+    }
+    private val activity = mock<ComponentActivity> {
+        whenever(lifecycle).thenReturn(lifecycleOwner.lifecycle)
+        whenever(resources).thenReturn(resourcesMock)
+        whenever(isActivityTransitionRunning).thenReturn(true)
+        whenever(window).thenReturn(windowMock)
+    }
+
+    private val testSubject = EnterTransitionAnimationDelegate(activity) {
+        transitionTargetView
+    }
+
+    @Before
+    fun setup() {
+        Dispatchers.setMain(dispatcher)
+        lifecycleOwner.state = Lifecycle.State.CREATED
+    }
+
+    @After
+    fun cleanup() {
+        lifecycleOwner.state = Lifecycle.State.DESTROYED
+        Dispatchers.resetMain()
+    }
+
+    @Test
+    fun test_postponeTransition_timeout() {
+        testSubject.postponeTransition()
+        testSubject.markOffsetCalculated()
+
+        scheduler.advanceTimeBy(TIMEOUT_MS + 1L)
+        verify(activity, times(1)).startPostponedEnterTransition()
+        verify(windowMock, never()).setWindowAnimations(anyInt())
+    }
+
+    @Test
+    fun test_postponeTransition_animation_resumes_only_once() {
+        testSubject.postponeTransition()
+        testSubject.markOffsetCalculated()
+        testSubject.onTransitionElementReady(elementName)
+        testSubject.markOffsetCalculated()
+        testSubject.onTransitionElementReady(elementName)
+
+        scheduler.advanceTimeBy(TIMEOUT_MS + 1L)
+        verify(activity, times(1)).startPostponedEnterTransition()
+    }
+
+    @Test
+    fun test_postponeTransition_resume_animation_conditions() {
+        testSubject.postponeTransition()
+        verify(activity, never()).startPostponedEnterTransition()
+
+        testSubject.markOffsetCalculated()
+        verify(activity, never()).startPostponedEnterTransition()
+
+        testSubject.onAllTransitionElementsReady()
+        verify(activity, times(1)).startPostponedEnterTransition()
+    }
+}
diff --git a/java/tests/src/com/android/intentresolver/FeatureFlagRule.kt b/java/tests/src/com/android/intentresolver/FeatureFlagRule.kt
new file mode 100644
index 0000000..3fa01bc
--- /dev/null
+++ b/java/tests/src/com/android/intentresolver/FeatureFlagRule.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver
+
+import com.android.systemui.flags.BooleanFlag
+import org.junit.rules.TestRule
+import org.junit.runner.Description
+import org.junit.runners.model.Statement
+
+/**
+ * Ignores tests annotated with [RequireFeatureFlags] which flag requirements does not
+ * meet in the active flag set.
+ * @param flags active flag set
+ */
+internal class FeatureFlagRule(flags: Map<BooleanFlag, Boolean>) : TestRule {
+    private val flags = flags.entries.fold(HashMap<String, Boolean>()) { map, (key, value) ->
+        map.apply {
+            put(key.name, value)
+        }
+    }
+    private val skippingStatement = object : Statement() {
+        override fun evaluate() = Unit
+    }
+
+    override fun apply(base: Statement, description: Description): Statement {
+        val annotation = description.annotations.firstOrNull {
+                it is RequireFeatureFlags
+            } as? RequireFeatureFlags
+            ?: return base
+
+        if (annotation.flags.size != annotation.values.size) {
+            error("${description.className}#${description.methodName}: inconsistent number of" +
+                    " flags and values in $annotation")
+        }
+        for (i in annotation.flags.indices) {
+            val flag = annotation.flags[i]
+            val value = annotation.values[i]
+            if (flags.getOrDefault(flag, !value) != value) return skippingStatement
+        }
+        return base
+    }
+}
diff --git a/java/tests/src/com/android/intentresolver/ImagePreviewImageLoaderTest.kt b/java/tests/src/com/android/intentresolver/ImagePreviewImageLoaderTest.kt
new file mode 100644
index 0000000..f327e19
--- /dev/null
+++ b/java/tests/src/com/android/intentresolver/ImagePreviewImageLoaderTest.kt
@@ -0,0 +1,101 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver
+
+import android.content.ContentResolver
+import android.content.Context
+import android.content.res.Resources
+import android.net.Uri
+import android.util.Size
+import androidx.lifecycle.Lifecycle
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestCoroutineScheduler
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.mockito.Mockito.never
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ImagePreviewImageLoaderTest {
+    private val imageSize = Size(300, 300)
+    private val uriOne = Uri.parse("content://org.package.app/image-1.png")
+    private val uriTwo = Uri.parse("content://org.package.app/image-2.png")
+    private val contentResolver = mock<ContentResolver>()
+    private val resources = mock<Resources> {
+        whenever(getDimensionPixelSize(R.dimen.chooser_preview_image_max_dimen))
+            .thenReturn(imageSize.width)
+    }
+    private val context = mock<Context> {
+        whenever(this.resources).thenReturn(this@ImagePreviewImageLoaderTest.resources)
+        whenever(this.contentResolver).thenReturn(this@ImagePreviewImageLoaderTest.contentResolver)
+    }
+    private val scheduler = TestCoroutineScheduler()
+    private val lifecycleOwner = TestLifecycleOwner()
+    private val dispatcher = UnconfinedTestDispatcher(scheduler)
+    private val testSubject = ImagePreviewImageLoader(
+        context, lifecycleOwner.lifecycle, 1, dispatcher
+    )
+
+    @Before
+    fun setup() {
+        Dispatchers.setMain(dispatcher)
+        lifecycleOwner.state = Lifecycle.State.CREATED
+    }
+
+    @After
+    fun cleanup() {
+        lifecycleOwner.state = Lifecycle.State.DESTROYED
+        Dispatchers.resetMain()
+    }
+
+    @Test
+    fun test_prePopulate() = runTest {
+        testSubject.prePopulate(listOf(uriOne, uriTwo))
+
+        verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null)
+        verify(contentResolver, never()).loadThumbnail(uriTwo, imageSize, null)
+
+        testSubject(uriOne)
+        verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null)
+    }
+
+    @Test
+    fun test_invoke_return_cached_image() = runTest {
+        testSubject(uriOne)
+        testSubject(uriOne)
+
+        verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull())
+    }
+
+    @Test
+    fun test_invoke_old_records_evicted_from_the_cache() = runTest {
+        testSubject(uriOne)
+        testSubject(uriTwo)
+        testSubject(uriTwo)
+        testSubject(uriOne)
+
+        verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null)
+        verify(contentResolver, times(1)).loadThumbnail(uriTwo, imageSize, null)
+    }
+}
diff --git a/java/tests/src/com/android/intentresolver/MockitoKotlinHelpers.kt b/java/tests/src/com/android/intentresolver/MockitoKotlinHelpers.kt
index 159c6d6..aaa7a28 100644
--- a/java/tests/src/com/android/intentresolver/MockitoKotlinHelpers.kt
+++ b/java/tests/src/com/android/intentresolver/MockitoKotlinHelpers.kt
@@ -26,6 +26,7 @@
 
 import org.mockito.ArgumentCaptor
 import org.mockito.ArgumentMatcher
+import org.mockito.ArgumentMatchers
 import org.mockito.Mockito
 import org.mockito.stubbing.OngoingStubbing
 
@@ -144,3 +145,5 @@
  */
 inline fun <reified T : Any> captureMany(block: KotlinArgumentCaptor<T>.() -> Unit): List<T> =
     kotlinArgumentCaptor<T>().apply{ block() }.allValues
+
+inline fun <reified T> anyOrNull() = ArgumentMatchers.argThat(ArgumentMatcher<T?> { true })
diff --git a/java/tests/src/com/android/intentresolver/RequireFeatureFlags.kt b/java/tests/src/com/android/intentresolver/RequireFeatureFlags.kt
new file mode 100644
index 0000000..1ddf746
--- /dev/null
+++ b/java/tests/src/com/android/intentresolver/RequireFeatureFlags.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver
+
+/**
+ * Specifies expected feature flag values for a test.
+ */
+@Target(AnnotationTarget.FUNCTION)
+annotation class RequireFeatureFlags(val flags: Array<String>, val values: BooleanArray)
diff --git a/java/tests/src/com/android/intentresolver/ResolverActivityTest.java b/java/tests/src/com/android/intentresolver/ResolverActivityTest.java
index 62c16ff..ae1b99f 100644
--- a/java/tests/src/com/android/intentresolver/ResolverActivityTest.java
+++ b/java/tests/src/com/android/intentresolver/ResolverActivityTest.java
@@ -54,7 +54,6 @@
 import androidx.test.rule.ActivityTestRule;
 import androidx.test.runner.AndroidJUnit4;
 
-import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo;
 import com.android.intentresolver.widget.ResolverDrawerLayout;
 import com.android.internal.R;
 
@@ -101,10 +100,7 @@
         Intent sendIntent = createSendImageIntent();
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
 
-        when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(),
-                Mockito.anyBoolean(),
-                Mockito.anyBoolean(),
-                Mockito.isA(List.class))).thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
 
         final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
         Espresso.registerIdlingResources(activity.getAdapter().getLabelIdlingResource());
@@ -133,10 +129,7 @@
         Intent sendIntent = createSendImageIntent();
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
 
-        when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(),
-                Mockito.anyBoolean(),
-                Mockito.anyBoolean(),
-                Mockito.isA(List.class))).thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
         waitForIdle();
 
         final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
@@ -178,10 +171,7 @@
         Intent sendIntent = createSendImageIntent();
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
 
-        when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(),
-                Mockito.anyBoolean(),
-                Mockito.anyBoolean(),
-                Mockito.isA(List.class))).thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
         waitForIdle();
 
         final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
@@ -211,10 +201,7 @@
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
         ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0);
 
-        when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(),
-                Mockito.anyBoolean(),
-                Mockito.anyBoolean(),
-                Mockito.isA(List.class))).thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
         when(sOverrides.resolverListController.getLastChosen())
                 .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0));
 
@@ -277,10 +264,7 @@
                 createResolvedComponentsForTestWithOtherProfile(3);
         ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0);
 
-        when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(),
-                Mockito.anyBoolean(),
-                Mockito.anyBoolean(),
-                Mockito.isA(List.class))).thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
 
         final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
         Espresso.registerIdlingResources(activity.getAdapter().getLabelIdlingResource());
@@ -319,10 +303,7 @@
                 createResolvedComponentsForTestWithOtherProfile(3);
         ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0);
 
-        when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(),
-                Mockito.anyBoolean(),
-                Mockito.anyBoolean(),
-                Mockito.isA(List.class))).thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
         when(sOverrides.resolverListController.getLastChosen())
                 .thenReturn(resolvedComponentInfos.get(1).getResolveInfoAt(0));
 
@@ -761,10 +742,7 @@
         List<ResolvedComponentInfo> resolvedComponentInfos =
                 createResolvedComponentsForTest(2);
 
-        when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(),
-                Mockito.anyBoolean(),
-                Mockito.anyBoolean(),
-                Mockito.isA(List.class))).thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
         when(sOverrides.resolverListController.getLastChosen())
                 .thenReturn(resolvedComponentInfos.get(1).getResolveInfoAt(0));
 
@@ -837,22 +815,33 @@
     }
 
     private void setupResolverControllers(
+            List<ResolvedComponentInfo> personalResolvedComponentInfos) {
+        setupResolverControllers(personalResolvedComponentInfos, new ArrayList<>());
+    }
+
+    private void setupResolverControllers(
             List<ResolvedComponentInfo> personalResolvedComponentInfos,
             List<ResolvedComponentInfo> workResolvedComponentInfos) {
-        when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(),
+        when(sOverrides.resolverListController.getResolversForIntentAsUser(
                 Mockito.anyBoolean(),
                 Mockito.anyBoolean(),
-                Mockito.isA(List.class)))
-                .thenReturn(new ArrayList<>(personalResolvedComponentInfos));
-        when(sOverrides.workResolverListController.getResolversForIntent(Mockito.anyBoolean(),
-                Mockito.anyBoolean(),
-                Mockito.anyBoolean(),
-                Mockito.isA(List.class))).thenReturn(workResolvedComponentInfos);
-        when(sOverrides.workResolverListController.getResolversForIntentAsUser(Mockito.anyBoolean(),
-                Mockito.anyBoolean(),
                 Mockito.anyBoolean(),
                 Mockito.isA(List.class),
                 eq(UserHandle.SYSTEM)))
-                .thenReturn(new ArrayList<>(personalResolvedComponentInfos));
+                        .thenReturn(new ArrayList<>(personalResolvedComponentInfos));
+        when(sOverrides.workResolverListController.getResolversForIntentAsUser(
+                Mockito.anyBoolean(),
+                Mockito.anyBoolean(),
+                Mockito.anyBoolean(),
+                Mockito.isA(List.class),
+                eq(UserHandle.SYSTEM)))
+                        .thenReturn(new ArrayList<>(personalResolvedComponentInfos));
+        when(sOverrides.workResolverListController.getResolversForIntentAsUser(
+                Mockito.anyBoolean(),
+                Mockito.anyBoolean(),
+                Mockito.anyBoolean(),
+                Mockito.isA(List.class),
+                eq(UserHandle.of(10))))
+                        .thenReturn(new ArrayList<>(workResolvedComponentInfos));
     }
 }
diff --git a/java/tests/src/com/android/intentresolver/ResolverDataProvider.java b/java/tests/src/com/android/intentresolver/ResolverDataProvider.java
index fb928e0..b6b32b5 100644
--- a/java/tests/src/com/android/intentresolver/ResolverDataProvider.java
+++ b/java/tests/src/com/android/intentresolver/ResolverDataProvider.java
@@ -36,20 +36,33 @@
 
     static private int USER_SOMEONE_ELSE = 10;
 
-    static ResolverActivity.ResolvedComponentInfo createResolvedComponentInfo(int i) {
-        return new ResolverActivity.ResolvedComponentInfo(createComponentName(i),
-                createResolverIntent(i), createResolveInfo(i, UserHandle.USER_CURRENT));
+    static ResolvedComponentInfo createResolvedComponentInfo(int i) {
+        return new ResolvedComponentInfo(
+                createComponentName(i),
+                createResolverIntent(i),
+                createResolveInfo(i, UserHandle.USER_CURRENT));
     }
 
-    static ResolverActivity.ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i) {
-        return new ResolverActivity.ResolvedComponentInfo(createComponentName(i),
-                createResolverIntent(i), createResolveInfo(i, USER_SOMEONE_ELSE));
+    static ResolvedComponentInfo createResolvedComponentInfo(
+            ComponentName componentName, Intent intent) {
+        return new ResolvedComponentInfo(
+                componentName,
+                intent,
+                createResolveInfo(componentName, UserHandle.USER_CURRENT));
     }
 
-    static ResolverActivity.ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i,
-            int userId) {
-        return new ResolverActivity.ResolvedComponentInfo(createComponentName(i),
-                createResolverIntent(i), createResolveInfo(i, userId));
+    static ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i) {
+        return new ResolvedComponentInfo(
+                createComponentName(i),
+                createResolverIntent(i),
+                createResolveInfo(i, USER_SOMEONE_ELSE));
+    }
+
+    static ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i, int userId) {
+        return new ResolvedComponentInfo(
+                createComponentName(i),
+                createResolverIntent(i),
+                createResolveInfo(i, userId));
     }
 
     public static ComponentName createComponentName(int i) {
@@ -64,6 +77,13 @@
         return resolveInfo;
     }
 
+    public static ResolveInfo createResolveInfo(ComponentName componentName, int userId) {
+        final ResolveInfo resolveInfo = new ResolveInfo();
+        resolveInfo.activityInfo = createActivityInfo(componentName);
+        resolveInfo.targetUserId = userId;
+        return resolveInfo;
+    }
+
     static ActivityInfo createActivityInfo(int i) {
         ActivityInfo ai = new ActivityInfo();
         ai.name = "activity_name" + i;
@@ -75,6 +95,18 @@
         return ai;
     }
 
+    static ActivityInfo createActivityInfo(ComponentName componentName) {
+        ActivityInfo ai = new ActivityInfo();
+        ai.name = componentName.getClassName();
+        ai.packageName = componentName.getPackageName();
+        ai.enabled = true;
+        ai.exported = true;
+        ai.permission = null;
+        ai.applicationInfo = createApplicationInfo();
+        ai.applicationInfo.packageName = componentName.getPackageName();
+        return ai;
+    }
+
     static ApplicationInfo createApplicationInfo() {
         ApplicationInfo ai = new ApplicationInfo();
         ai.name = "app_name";
diff --git a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java
index 239bffe..d67b73a 100644
--- a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java
+++ b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java
@@ -31,7 +31,6 @@
 
 import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker;
 import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider;
-import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeManager;
 import com.android.intentresolver.chooser.TargetInfo;
 
 import java.util.List;
@@ -88,11 +87,11 @@
     }
 
     @Override
-    protected QuietModeManager createQuietModeManager() {
-        if (sOverrides.mQuietModeManager != null) {
-            return sOverrides.mQuietModeManager;
+    protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() {
+        if (sOverrides.mWorkProfileAvailability != null) {
+            return sOverrides.mWorkProfileAvailability;
         }
-        return super.createQuietModeManager();
+        return super.createWorkProfileAvailabilityManager();
     }
 
     ResolverWrapperAdapter getAdapter() {
@@ -130,10 +129,8 @@
     @Override
     protected ResolverListController createListController(UserHandle userHandle) {
         if (userHandle == UserHandle.SYSTEM) {
-            when(sOverrides.resolverListController.getUserHandle()).thenReturn(UserHandle.SYSTEM);
             return sOverrides.resolverListController;
         }
-        when(sOverrides.workResolverListController.getUserHandle()).thenReturn(userHandle);
         return sOverrides.workResolverListController;
     }
 
@@ -175,7 +172,7 @@
         public Integer myUserId;
         public boolean hasCrossProfileIntents;
         public boolean isQuietModeEnabled;
-        public QuietModeManager mQuietModeManager;
+        public WorkProfileAvailabilityManager mWorkProfileAvailability;
         public MyUserIdProvider mMyUserIdProvider;
         public CrossProfileIntentsChecker mCrossProfileIntentsChecker;
 
@@ -190,21 +187,24 @@
             hasCrossProfileIntents = true;
             isQuietModeEnabled = false;
 
-            mQuietModeManager = new QuietModeManager() {
+            mWorkProfileAvailability = new WorkProfileAvailabilityManager(null, null, null) {
                 @Override
-                public boolean isQuietModeEnabled(UserHandle workProfileUserHandle) {
+                public boolean isQuietModeEnabled() {
                     return isQuietModeEnabled;
                 }
 
                 @Override
-                public void requestQuietModeEnabled(boolean enabled,
-                        UserHandle workProfileUserHandle) {
+                public boolean isWorkProfileUserUnlocked() {
+                    return true;
+                }
+
+                @Override
+                public void requestQuietModeEnabled(boolean enabled) {
                     isQuietModeEnabled = enabled;
                 }
 
                 @Override
-                public void markWorkProfileEnabledBroadcastReceived() {
-                }
+                public void markWorkProfileEnabledBroadcastReceived() {}
 
                 @Override
                 public boolean isWaitingToEnableWorkProfile() {
diff --git a/java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt b/java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt
new file mode 100644
index 0000000..b904771
--- /dev/null
+++ b/java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver
+
+import com.android.intentresolver.flags.FeatureFlagRepository
+import com.android.systemui.flags.BooleanFlag
+import com.android.systemui.flags.ReleasedFlag
+import com.android.systemui.flags.UnreleasedFlag
+
+class TestFeatureFlagRepository(
+    private val overrides: Map<BooleanFlag, Boolean>
+) : FeatureFlagRepository {
+    override fun isEnabled(flag: UnreleasedFlag): Boolean = getValue(flag)
+    override fun isEnabled(flag: ReleasedFlag): Boolean = getValue(flag)
+
+    private fun getValue(flag: BooleanFlag) = overrides.getOrDefault(flag, flag.default)
+}
diff --git a/java/tests/src/com/android/intentresolver/TestLifecycleOwner.kt b/java/tests/src/com/android/intentresolver/TestLifecycleOwner.kt
new file mode 100644
index 0000000..f47e343
--- /dev/null
+++ b/java/tests/src/com/android/intentresolver/TestLifecycleOwner.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver
+
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+
+internal class TestLifecycleOwner : LifecycleOwner {
+    private val lifecycleRegistry = LifecycleRegistry.createUnsafe(this)
+
+    override fun getLifecycle(): Lifecycle = lifecycleRegistry
+
+    var state: Lifecycle.State
+        get() = lifecycle.currentState
+        set(value) {
+            lifecycleRegistry.currentState = value
+        }
+}
\ No newline at end of file
diff --git a/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt b/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt
new file mode 100644
index 0000000..cfe041d
--- /dev/null
+++ b/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver
+
+import android.graphics.Bitmap
+import android.net.Uri
+import java.util.function.Consumer
+
+internal class TestPreviewImageLoader(
+    private val imageLoader: ImageLoader,
+    private val imageOverride: () -> Bitmap?
+) : ImageLoader {
+    override fun loadImage(uri: Uri, callback: Consumer<Bitmap?>) {
+        val override = imageOverride()
+        if (override != null) {
+            callback.accept(override)
+        } else {
+            imageLoader.loadImage(uri, callback)
+        }
+    }
+
+    override suspend fun invoke(uri: Uri): Bitmap? = imageOverride() ?: imageLoader(uri)
+    override fun prePopulate(uris: List<Uri>) = Unit
+}
diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java
index af2557e..9ffd02d 100644
--- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java
+++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java
@@ -20,6 +20,7 @@
 
 import static androidx.test.espresso.Espresso.onView;
 import static androidx.test.espresso.action.ViewActions.click;
+import static androidx.test.espresso.action.ViewActions.longClick;
 import static androidx.test.espresso.action.ViewActions.swipeUp;
 import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist;
 import static androidx.test.espresso.assertion.ViewAssertions.matches;
@@ -55,13 +56,16 @@
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
+import android.app.PendingIntent;
 import android.app.usage.UsageStatsManager;
+import android.content.BroadcastReceiver;
 import android.content.ClipData;
 import android.content.ClipDescription;
 import android.content.ClipboardManager;
 import android.content.ComponentName;
 import android.content.Context;
 import android.content.Intent;
+import android.content.IntentFilter;
 import android.content.pm.ActivityInfo;
 import android.content.pm.ApplicationInfo;
 import android.content.pm.PackageManager;
@@ -69,6 +73,7 @@
 import android.content.pm.ShortcutInfo;
 import android.content.pm.ShortcutManager.ShareShortcutInfo;
 import android.content.res.Configuration;
+import android.content.res.Resources;
 import android.database.Cursor;
 import android.graphics.Bitmap;
 import android.graphics.Canvas;
@@ -79,25 +84,29 @@
 import android.os.Bundle;
 import android.os.UserHandle;
 import android.provider.DeviceConfig;
+import android.service.chooser.ChooserAction;
 import android.service.chooser.ChooserTarget;
 import android.util.HashedStringCache;
 import android.util.Pair;
 import android.util.SparseArray;
 import android.view.View;
+import android.view.ViewGroup;
 
 import androidx.annotation.CallSuper;
 import androidx.annotation.NonNull;
 import androidx.recyclerview.widget.GridLayoutManager;
 import androidx.recyclerview.widget.RecyclerView;
+import androidx.test.espresso.contrib.RecyclerViewActions;
 import androidx.test.espresso.matcher.BoundedDiagnosingMatcher;
 import androidx.test.platform.app.InstrumentationRegistry;
 import androidx.test.rule.ActivityTestRule;
 
-import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo;
 import com.android.intentresolver.chooser.DisplayResolveInfo;
+import com.android.intentresolver.flags.Flags;
 import com.android.intentresolver.shortcuts.ShortcutLoader;
 import com.android.internal.config.sysui.SystemUiDeviceConfigFlags;
 import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
+import com.android.systemui.flags.BooleanFlag;
 
 import org.hamcrest.Description;
 import org.hamcrest.Matcher;
@@ -106,6 +115,8 @@
 import org.junit.Ignore;
 import org.junit.Rule;
 import org.junit.Test;
+import org.junit.rules.RuleChain;
+import org.junit.rules.TestRule;
 import org.junit.runner.RunWith;
 import org.junit.runners.Parameterized;
 import org.mockito.ArgumentCaptor;
@@ -117,6 +128,8 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.Consumer;
 import java.util.function.Function;
 
@@ -150,14 +163,40 @@
                 return mock;
             };
 
+    private static final List<BooleanFlag> ALL_FLAGS =
+            Arrays.asList(
+                    Flags.SHARESHEET_CUSTOM_ACTIONS,
+                    Flags.SHARESHEET_RESELECTION_ACTION,
+                    Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW,
+                    Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW);
+
+    private static final Map<BooleanFlag, Boolean> ALL_FLAGS_OFF =
+            createAllFlagsOverride(false);
+    private static final Map<BooleanFlag, Boolean> ALL_FLAGS_ON =
+            createAllFlagsOverride(true);
+
     @Parameterized.Parameters
     public static Collection packageManagers() {
         return Arrays.asList(new Object[][] {
-                {0, "Default PackageManager", DEFAULT_PM},
-                {1, "No App Prediction Service", NO_APP_PREDICTION_SERVICE_PM}
+                // Default PackageManager and all flags off
+                { DEFAULT_PM, ALL_FLAGS_OFF },
+                // Default PackageManager and all flags on
+                { DEFAULT_PM, ALL_FLAGS_ON },
+                // No App Prediction Service and all flags off
+                { NO_APP_PREDICTION_SERVICE_PM, ALL_FLAGS_OFF },
+                // No App Prediction Service and all flags on
+                { NO_APP_PREDICTION_SERVICE_PM, ALL_FLAGS_ON }
         });
     }
 
+    private static Map<BooleanFlag, Boolean> createAllFlagsOverride(boolean value) {
+        HashMap<BooleanFlag, Boolean> overrides = new HashMap<>(ALL_FLAGS.size());
+        for (BooleanFlag flag : ALL_FLAGS) {
+            overrides.put(flag, value);
+        }
+        return overrides;
+    }
+
     /* --------
      * Subclasses can override the following methods to customize test behavior.
      * --------
@@ -177,6 +216,8 @@
                 .adoptShellPermissionIdentity();
 
         cleanOverrideData();
+        ChooserActivityOverrideData.getInstance().featureFlagRepository =
+                new TestFeatureFlagRepository(mFlags);
     }
 
     /**
@@ -209,11 +250,13 @@
      * --------
      */
 
+    @Rule
+    public final TestRule mRule;
+
     // Shared test code references the activity under test as ChooserActivity, the common ancestor
     // of any (inheritance-based) chooser implementation. For testing purposes, that activity will
     // usually be cast to IChooserWrapper to expose instrumentation.
-    @Rule
-    public ActivityTestRule<ChooserActivity> mActivityRule =
+    private ActivityTestRule<ChooserActivity> mActivityRule =
             new ActivityTestRule<>(ChooserActivity.class, false, false) {
                 @Override
                 public ChooserActivity launchActivity(Intent clientIntent) {
@@ -240,16 +283,20 @@
     private static final int CONTENT_PREVIEW_IMAGE = 1;
     private static final int CONTENT_PREVIEW_FILE = 2;
     private static final int CONTENT_PREVIEW_TEXT = 3;
-    private Function<PackageManager, PackageManager> mPackageManagerOverride;
-    private int mTestNum;
+
+    private final Function<PackageManager, PackageManager> mPackageManagerOverride;
+    private final Map<BooleanFlag, Boolean> mFlags;
 
 
     public UnbundledChooserActivityTest(
-                int testNum,
-                String testName,
-                Function<PackageManager, PackageManager> packageManagerOverride) {
+                Function<PackageManager, PackageManager> packageManagerOverride,
+                Map<BooleanFlag, Boolean> flags) {
         mPackageManagerOverride = packageManagerOverride;
-        mTestNum = testNum;
+        mFlags = flags;
+
+        mRule = RuleChain
+                .outerRule(new FeatureFlagRule(flags))
+                .around(mActivityRule);
     }
 
     private void setDeviceConfigProperty(
@@ -284,16 +331,7 @@
         Intent viewIntent = createViewTextIntent();
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
 
-        when(
-                ChooserActivityOverrideData
-                        .getInstance()
-                        .resolverListController
-                        .getResolversForIntent(
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
-                .thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
         final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(
                 Intent.createChooser(viewIntent, "chooser test"));
 
@@ -308,16 +346,7 @@
         Intent sendIntent = createSendTextIntent();
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
 
-        when(
-                ChooserActivityOverrideData
-                        .getInstance()
-                        .resolverListController
-                        .getResolversForIntent(
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
-                .thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
         mActivityRule.launchActivity(Intent.createChooser(sendIntent, "chooser test"));
         waitForIdle();
         onView(withId(android.R.id.title))
@@ -329,16 +358,7 @@
         Intent sendIntent = createSendTextIntent();
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
 
-        when(
-                ChooserActivityOverrideData
-                        .getInstance()
-                        .resolverListController
-                        .getResolversForIntent(
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
-                .thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
         mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
         waitForIdle();
         onView(withId(android.R.id.title))
@@ -350,16 +370,7 @@
         Intent sendIntent = createSendTextIntentWithPreview(null, null);
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
 
-        when(
-                ChooserActivityOverrideData
-                        .getInstance()
-                        .resolverListController
-                        .getResolversForIntent(
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
-                .thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
         mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
         waitForIdle();
         onView(withId(com.android.internal.R.id.content_preview_title))
@@ -374,16 +385,7 @@
         Intent sendIntent = createSendTextIntentWithPreview(previewTitle, null);
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
 
-        when(
-                ChooserActivityOverrideData
-                        .getInstance()
-                        .resolverListController
-                        .getResolversForIntent(
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
-                .thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
         mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
         waitForIdle();
         onView(withId(com.android.internal.R.id.content_preview_title))
@@ -401,16 +403,7 @@
                 Uri.parse("tel:(+49)12345789"));
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
 
-        when(
-                ChooserActivityOverrideData
-                        .getInstance()
-                        .resolverListController
-                        .getResolversForIntent(
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
-                .thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
         mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
         waitForIdle();
         onView(withId(com.android.internal.R.id.content_preview_title))
@@ -428,16 +421,7 @@
         ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap();
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
 
-        when(
-                ChooserActivityOverrideData
-                        .getInstance()
-                        .resolverListController
-                        .getResolversForIntent(
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
-                .thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
         mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
         waitForIdle();
         onView(withId(com.android.internal.R.id.content_preview_title))
@@ -451,16 +435,7 @@
         Intent sendIntent = createSendTextIntent();
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
 
-        when(
-                ChooserActivityOverrideData
-                        .getInstance()
-                        .resolverListController
-                        .getResolversForIntent(
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
-                .thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
 
         final IChooserWrapper activity = (IChooserWrapper)
                 mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
@@ -506,16 +481,7 @@
         }
         resolvedComponentInfos.addAll(infosToStack);
 
-        when(
-                ChooserActivityOverrideData
-                        .getInstance()
-                        .resolverListController
-                        .getResolversForIntent(
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
-                .thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
 
         final IChooserWrapper activity = (IChooserWrapper)
                 mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
@@ -547,16 +513,7 @@
         Intent sendIntent = createSendTextIntent();
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
 
-        when(
-                ChooserActivityOverrideData
-                        .getInstance()
-                        .resolverListController
-                        .getResolversForIntent(
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
-                .thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
 
         final IChooserWrapper activity = (IChooserWrapper)
                 mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
@@ -582,16 +539,7 @@
     @Ignore // b/148158199
     @Test
     public void noResultsFromPackageManager() {
-        when(
-                ChooserActivityOverrideData
-                        .getInstance()
-                        .resolverListController
-                        .getResolversForIntent(
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
-                .thenReturn(null);
+        setupResolverControllers(null);
         Intent sendIntent = createSendTextIntent();
         final ChooserActivity activity =
                 mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
@@ -618,16 +566,7 @@
         };
 
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(1);
-        when(
-                ChooserActivityOverrideData
-                        .getInstance()
-                        .resolverListController
-                        .getResolversForIntent(
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
-                .thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
 
         Intent sendIntent = createSendTextIntent();
         final ChooserActivity activity =
@@ -679,11 +618,7 @@
                 createResolvedComponentsForTestWithOtherProfile(3);
         ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0);
 
-        when(ChooserActivityOverrideData.getInstance().resolverListController.getResolversForIntent(
-                Mockito.anyBoolean(),
-                Mockito.anyBoolean(),
-                Mockito.anyBoolean(),
-                Mockito.isA(List.class))).thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
         when(ChooserActivityOverrideData.getInstance().resolverListController.getLastChosen())
                 .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0));
 
@@ -716,11 +651,7 @@
                 createResolvedComponentsForTestWithOtherProfile(3);
         ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0);
 
-        when(ChooserActivityOverrideData.getInstance().resolverListController.getResolversForIntent(
-                Mockito.anyBoolean(),
-                Mockito.anyBoolean(),
-                Mockito.anyBoolean(),
-                Mockito.isA(List.class))).thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
 
         final IChooserWrapper activity = (IChooserWrapper)
                 mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
@@ -745,15 +676,148 @@
     }
 
     @Test
+    @RequireFeatureFlags(
+            flags = { Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME },
+            values = { true })
+    public void testImagePlusTextSharing_ExcludeText() {
+        Intent sendIntent = createSendImageIntent(
+                Uri.parse("android.resource://com.android.frameworks.coretests/"
+                        + R.drawable.test320x240));
+        ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap();
+        ChooserActivityOverrideData.getInstance().isImageType = true;
+        sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google");
+
+        List<ResolvedComponentInfo> resolvedComponentInfos = Arrays.asList(
+                ResolverDataProvider.createResolvedComponentInfo(
+                        new ComponentName("org.imageviewer", "ImageTarget"),
+                        sendIntent),
+                ResolverDataProvider.createResolvedComponentInfo(
+                        new ComponentName("org.textviewer", "UriTarget"),
+                        new Intent("VIEW_TEXT"))
+        );
+
+        setupResolverControllers(resolvedComponentInfos);
+
+        mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
+        waitForIdle();
+
+        onView(withId(R.id.include_text_action))
+                .check(matches(isDisplayed()))
+                .perform(click());
+        waitForIdle();
+
+        AtomicReference<Intent> launchedIntentRef = new AtomicReference<>();
+        ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> {
+            launchedIntentRef.set(targetInfo.getTargetIntent());
+            return true;
+        };
+
+        onView(withText(resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.name))
+                .perform(click());
+        waitForIdle();
+        assertThat(launchedIntentRef.get().hasExtra(Intent.EXTRA_TEXT)).isFalse();
+    }
+
+    @Test
+    @RequireFeatureFlags(
+            flags = { Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME },
+            values = { true })
+    public void testImagePlusTextSharing_RemoveAndAddBackText() {
+        Intent sendIntent = createSendImageIntent(
+                Uri.parse("android.resource://com.android.frameworks.coretests/"
+                        + R.drawable.test320x240));
+        ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap();
+        ChooserActivityOverrideData.getInstance().isImageType = true;
+        final String text = "https://google.com/search?q=google";
+        sendIntent.putExtra(Intent.EXTRA_TEXT, text);
+
+        List<ResolvedComponentInfo> resolvedComponentInfos = Arrays.asList(
+                ResolverDataProvider.createResolvedComponentInfo(
+                        new ComponentName("org.imageviewer", "ImageTarget"),
+                        sendIntent),
+                ResolverDataProvider.createResolvedComponentInfo(
+                        new ComponentName("org.textviewer", "UriTarget"),
+                        new Intent("VIEW_TEXT"))
+        );
+
+        setupResolverControllers(resolvedComponentInfos);
+
+        mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
+        waitForIdle();
+
+        onView(withId(R.id.include_text_action))
+                .check(matches(isDisplayed()))
+                .perform(click());
+        waitForIdle();
+        onView(withId(R.id.include_text_action))
+                .perform(click());
+        waitForIdle();
+
+        AtomicReference<Intent> launchedIntentRef = new AtomicReference<>();
+        ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> {
+            launchedIntentRef.set(targetInfo.getTargetIntent());
+            return true;
+        };
+
+        onView(withText(resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.name))
+                .perform(click());
+        waitForIdle();
+        assertThat(launchedIntentRef.get().getStringExtra(Intent.EXTRA_TEXT)).isEqualTo(text);
+    }
+
+    @Test
+    @RequireFeatureFlags(
+            flags = { Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME },
+            values = { true })
+    public void testImagePlusTextSharing_TextExclusionDoesNotAffectAlternativeIntent() {
+        Intent sendIntent = createSendImageIntent(
+                Uri.parse("android.resource://com.android.frameworks.coretests/"
+                        + R.drawable.test320x240));
+        ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap();
+        ChooserActivityOverrideData.getInstance().isImageType = true;
+        sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google");
+
+        Intent alternativeIntent = createSendTextIntent();
+        final String text = "alternative intent";
+        alternativeIntent.putExtra(Intent.EXTRA_TEXT, text);
+
+        List<ResolvedComponentInfo> resolvedComponentInfos = Arrays.asList(
+                ResolverDataProvider.createResolvedComponentInfo(
+                        new ComponentName("org.imageviewer", "ImageTarget"),
+                        sendIntent),
+                ResolverDataProvider.createResolvedComponentInfo(
+                        new ComponentName("org.textviewer", "UriTarget"),
+                        alternativeIntent)
+        );
+
+        setupResolverControllers(resolvedComponentInfos);
+
+        mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
+        waitForIdle();
+
+        onView(withId(R.id.include_text_action))
+                .check(matches(isDisplayed()))
+                .perform(click());
+        waitForIdle();
+
+        AtomicReference<Intent> launchedIntentRef = new AtomicReference<>();
+        ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> {
+            launchedIntentRef.set(targetInfo.getTargetIntent());
+            return true;
+        };
+
+        onView(withText(resolvedComponentInfos.get(1).getResolveInfoAt(0).activityInfo.name))
+                .perform(click());
+        waitForIdle();
+        assertThat(launchedIntentRef.get().getStringExtra(Intent.EXTRA_TEXT)).isEqualTo(text);
+    }
+
+    @Test
     public void copyTextToClipboard() throws Exception {
         Intent sendIntent = createSendTextIntent();
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
 
-        when(ChooserActivityOverrideData.getInstance().resolverListController.getResolversForIntent(
-            Mockito.anyBoolean(),
-            Mockito.anyBoolean(),
-            Mockito.anyBoolean(),
-            Mockito.isA(List.class))).thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
 
         final ChooserActivity activity =
                 mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
@@ -777,11 +841,7 @@
         Intent sendIntent = createSendTextIntent();
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
 
-        when(ChooserActivityOverrideData.getInstance().resolverListController.getResolversForIntent(
-            Mockito.anyBoolean(),
-            Mockito.anyBoolean(),
-            Mockito.anyBoolean(),
-            Mockito.isA(List.class))).thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
 
         final IChooserWrapper activity = (IChooserWrapper)
                 mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
@@ -800,11 +860,7 @@
         Intent sendIntent = createSendTextIntent();
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
 
-        when(ChooserActivityOverrideData.getInstance().resolverListController.getResolversForIntent(
-                Mockito.anyBoolean(),
-                Mockito.anyBoolean(),
-                Mockito.anyBoolean(),
-                Mockito.isA(List.class))).thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
 
         final IChooserWrapper activity = (IChooserWrapper)
                 mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
@@ -830,11 +886,7 @@
 
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
 
-        when(ChooserActivityOverrideData.getInstance().resolverListController.getResolversForIntent(
-                Mockito.anyBoolean(),
-                Mockito.anyBoolean(),
-                Mockito.anyBoolean(),
-                Mockito.isA(List.class))).thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
 
         final IChooserWrapper activity = (IChooserWrapper)
                 mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
@@ -848,7 +900,7 @@
 
 
     @Test
-    public void oneVisibleImagePreview() throws InterruptedException {
+    public void oneVisibleImagePreview() {
         Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/"
                 + R.drawable.test320x240);
 
@@ -861,30 +913,34 @@
 
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
 
-        when(
-                ChooserActivityOverrideData
-                        .getInstance()
-                        .resolverListController
-                        .getResolversForIntent(
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
-                .thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
         mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
         waitForIdle();
-        onView(withId(com.android.internal.R.id.content_preview_image_1_large))
-                .check(matches(isDisplayed()));
-        onView(withId(com.android.internal.R.id.content_preview_image_2_large))
-                .check(matches(not(isDisplayed())));
-        onView(withId(com.android.internal.R.id.content_preview_image_2_small))
-                .check(matches(not(isDisplayed())));
-        onView(withId(com.android.internal.R.id.content_preview_image_3_small))
-                .check(matches(not(isDisplayed())));
+        onView(withId(com.android.internal.R.id.content_preview_image_area))
+                .check((view, exception) -> {
+                    if (exception != null) {
+                        throw exception;
+                    }
+                    ViewGroup parent = (ViewGroup) view;
+                    ArrayList<View> visibleViews = new ArrayList<>();
+                    for (int i = 0, count = parent.getChildCount(); i < count; i++) {
+                        View child = parent.getChildAt(i);
+                        if (child.getVisibility() == View.VISIBLE) {
+                            visibleViews.add(child);
+                        }
+                    }
+                    assertThat(visibleViews.size(), is(1));
+                    assertThat(
+                            "image preview view is fully visible",
+                            isDisplayed().matches(visibleViews.get(0)));
+                });
     }
 
     @Test
-    public void twoVisibleImagePreview() throws InterruptedException {
+    @RequireFeatureFlags(
+            flags = { Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW_NAME },
+            values = { false })
+    public void twoVisibleImagePreview() {
         Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/"
                 + R.drawable.test320x240);
 
@@ -898,16 +954,7 @@
 
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
 
-        when(
-                ChooserActivityOverrideData
-                        .getInstance()
-                        .resolverListController
-                        .getResolversForIntent(
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
-                .thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
         mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
         waitForIdle();
         onView(withId(com.android.internal.R.id.content_preview_image_1_large))
@@ -921,7 +968,10 @@
     }
 
     @Test
-    public void threeOrMoreVisibleImagePreview() throws InterruptedException {
+    @RequireFeatureFlags(
+            flags = { Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW_NAME },
+            values = { false })
+    public void threeOrMoreVisibleImagePreview() {
         Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/"
                 + R.drawable.test320x240);
 
@@ -938,16 +988,7 @@
 
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
 
-        when(
-                ChooserActivityOverrideData
-                        .getInstance()
-                        .resolverListController
-                        .getResolversForIntent(
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
-                .thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
         mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
         waitForIdle();
         onView(withId(com.android.internal.R.id.content_preview_image_1_large))
@@ -961,6 +1002,72 @@
     }
 
     @Test
+    @RequireFeatureFlags(
+            flags = { Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW_NAME },
+            values = { true })
+    public void testManyVisibleImagePreview_ScrollableImagePreview() {
+        Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/"
+                + R.drawable.test320x240);
+
+        ArrayList<Uri> uris = new ArrayList<>();
+        uris.add(uri);
+        uris.add(uri);
+        uris.add(uri);
+        uris.add(uri);
+        uris.add(uri);
+        uris.add(uri);
+        uris.add(uri);
+        uris.add(uri);
+        uris.add(uri);
+        uris.add(uri);
+
+        Intent sendIntent = createSendUriIntentWithPreview(uris);
+        ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap();
+        ChooserActivityOverrideData.getInstance().isImageType = true;
+
+        List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
+
+        setupResolverControllers(resolvedComponentInfos);
+        mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
+        waitForIdle();
+        onView(withId(com.android.internal.R.id.content_preview_image_area))
+                .perform(RecyclerViewActions.scrollToLastPosition())
+                .check((view, exception) -> {
+                    if (exception != null) {
+                        throw exception;
+                    }
+                    RecyclerView recyclerView = (RecyclerView) view;
+                    assertThat(recyclerView.getAdapter().getItemCount(), is(uris.size()));
+                });
+    }
+
+    @Test
+    @RequireFeatureFlags(
+            flags = { Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME },
+            values = { true })
+    public void testImageAndTextPreview() {
+        final Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/"
+                + R.drawable.test320x240);
+        final String sharedText = "text-" + System.currentTimeMillis();
+
+        ArrayList<Uri> uris = new ArrayList<>();
+        uris.add(uri);
+
+        Intent sendIntent = createSendUriIntentWithPreview(uris);
+        sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText);
+        ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap();
+        ChooserActivityOverrideData.getInstance().isImageType = true;
+
+        List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
+
+        setupResolverControllers(resolvedComponentInfos);
+        mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
+        waitForIdle();
+        onView(withText(sharedText))
+                .check(matches(isDisplayed()));
+    }
+
+    @Test
     public void testOnCreateLogging() {
         Intent sendIntent = createSendTextIntent();
         sendIntent.setType(TEST_MIME_TYPE);
@@ -1007,11 +1114,7 @@
 
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
 
-        when(ChooserActivityOverrideData.getInstance().resolverListController.getResolversForIntent(
-            Mockito.anyBoolean(),
-            Mockito.anyBoolean(),
-            Mockito.anyBoolean(),
-            Mockito.isA(List.class))).thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
 
         final IChooserWrapper activity = (IChooserWrapper)
                 mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
@@ -1036,16 +1139,7 @@
 
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
 
-        when(
-                ChooserActivityOverrideData
-                        .getInstance()
-                        .resolverListController
-                        .getResolversForIntent(
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
-                .thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
 
         final IChooserWrapper activity = (IChooserWrapper)
                 mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
@@ -1065,16 +1159,7 @@
 
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
 
-        when(
-                ChooserActivityOverrideData
-                    .getInstance()
-                    .resolverListController
-                    .getResolversForIntent(
-                            Mockito.anyBoolean(),
-                            Mockito.anyBoolean(),
-                            Mockito.anyBoolean(),
-                            Mockito.isA(List.class)))
-                .thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
         mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
         waitForIdle();
         onView(withId(com.android.internal.R.id.content_preview_filename))
@@ -1099,16 +1184,7 @@
 
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
 
-        when(
-                ChooserActivityOverrideData
-                        .getInstance()
-                        .resolverListController
-                        .getResolversForIntent(
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
-                .thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
         mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
         waitForIdle();
         onView(withId(com.android.internal.R.id.content_preview_filename))
@@ -1129,16 +1205,7 @@
         Intent sendIntent = createSendUriIntentWithPreview(uris);
 
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
-        when(
-                ChooserActivityOverrideData
-                        .getInstance()
-                        .resolverListController
-                        .getResolversForIntent(
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
-                .thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
 
         ChooserActivityOverrideData.getInstance().resolverForceException = true;
 
@@ -1163,16 +1230,7 @@
         Intent sendIntent = createSendUriIntentWithPreview(uris);
 
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
-        when(
-                ChooserActivityOverrideData
-                        .getInstance()
-                        .resolverListController
-                        .getResolversForIntent(
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
-                .thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
 
         Cursor cursor = mock(Cursor.class);
         when(cursor.getCount()).thenReturn(1);
@@ -1199,16 +1257,8 @@
         Intent sendIntent = createSendTextIntent();
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
 
-        when(
-                ChooserActivityOverrideData
-                        .getInstance()
-                        .resolverListController
-                        .getResolversForIntent(
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
-                .thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
+
         when(
                 ChooserActivityOverrideData
                         .getInstance()
@@ -1241,16 +1291,7 @@
         Intent sendIntent = createSendTextIntent();
         // We need app targets for direct targets to get displayed
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
-        when(
-                ChooserActivityOverrideData
-                        .getInstance()
-                        .resolverListController
-                        .getResolversForIntent(
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
-                .thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
 
         // create test shortcut loader factory, remember loaders and their callbacks
         SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders =
@@ -1331,16 +1372,7 @@
         Intent sendIntent = createSendTextIntent();
         // We need app targets for direct targets to get displayed
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
-        when(
-                ChooserActivityOverrideData
-                        .getInstance()
-                        .resolverListController
-                        .getResolversForIntent(
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
-                .thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
 
         // create test shortcut loader factory, remember loaders and their callbacks
         SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders =
@@ -1425,16 +1457,7 @@
         Intent sendIntent = createSendTextIntent();
         // We need app targets for direct targets to get displayed
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
-        when(
-                ChooserActivityOverrideData
-                        .getInstance()
-                        .resolverListController
-                        .getResolversForIntent(
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
-                .thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
 
         // create test shortcut loader factory, remember loaders and their callbacks
         SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders =
@@ -1509,16 +1532,7 @@
         Intent sendIntent = createSendTextIntent();
         // We need app targets for direct targets to get displayed
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
-        when(
-                ChooserActivityOverrideData
-                        .getInstance()
-                        .resolverListController
-                        .getResolversForIntent(
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
-                .thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
 
         // create test shortcut loader factory, remember loaders and their callbacks
         SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders =
@@ -1597,16 +1611,7 @@
 
         // We need app targets for direct targets to get displayed
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
-        when(
-                ChooserActivityOverrideData
-                        .getInstance()
-                        .resolverListController
-                        .getResolversForIntent(
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
-                .thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
 
         // set caller-provided target
         Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null);
@@ -1665,6 +1670,91 @@
     }
 
     @Test
+    @RequireFeatureFlags(
+            flags = { Flags.SHARESHEET_CUSTOM_ACTIONS_NAME },
+            values = { true })
+    public void testLaunchWithCustomAction() throws InterruptedException {
+        List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
+        setupResolverControllers(resolvedComponentInfos);
+
+        Context testContext = InstrumentationRegistry.getInstrumentation().getContext();
+        final String customActionLabel = "Custom Action";
+        final String testAction = "test-broadcast-receiver-action";
+        Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null);
+        chooserIntent.putExtra(
+                Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS,
+                new ChooserAction[] {
+                        new ChooserAction.Builder(
+                                Icon.createWithResource("", Resources.ID_NULL),
+                                customActionLabel,
+                                PendingIntent.getBroadcast(
+                                        testContext,
+                                        123,
+                                        new Intent(testAction),
+                                        PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT))
+                                .build()
+                });
+        // Start activity
+        mActivityRule.launchActivity(chooserIntent);
+        waitForIdle();
+
+        final CountDownLatch broadcastInvoked = new CountDownLatch(1);
+        BroadcastReceiver testReceiver = new BroadcastReceiver() {
+            @Override
+            public void onReceive(Context context, Intent intent) {
+                broadcastInvoked.countDown();
+            }
+        };
+        testContext.registerReceiver(testReceiver, new IntentFilter(testAction));
+
+        try {
+            onView(withText(customActionLabel)).perform(click());
+            broadcastInvoked.await();
+        } finally {
+            testContext.unregisterReceiver(testReceiver);
+        }
+    }
+
+    @Test
+    @RequireFeatureFlags(
+            flags = { Flags.SHARESHEET_RESELECTION_ACTION_NAME },
+            values = { true })
+    public void testLaunchWithShareModification() throws InterruptedException {
+        List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
+        setupResolverControllers(resolvedComponentInfos);
+
+        Context testContext = InstrumentationRegistry.getInstrumentation().getContext();
+        final String modifyShareAction = "test-broadcast-receiver-action";
+        Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null);
+        chooserIntent.putExtra(
+                Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION,
+                PendingIntent.getBroadcast(
+                        testContext,
+                        123,
+                        new Intent(modifyShareAction),
+                        PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT));
+        // Start activity
+        mActivityRule.launchActivity(chooserIntent);
+        waitForIdle();
+
+        final CountDownLatch broadcastInvoked = new CountDownLatch(1);
+        BroadcastReceiver testReceiver = new BroadcastReceiver() {
+            @Override
+            public void onReceive(Context context, Intent intent) {
+                broadcastInvoked.countDown();
+            }
+        };
+        testContext.registerReceiver(testReceiver, new IntentFilter(modifyShareAction));
+
+        try {
+            onView(withText(R.string.select_text)).perform(click());
+            broadcastInvoked.await();
+        } finally {
+            testContext.unregisterReceiver(testReceiver);
+        }
+    }
+
+    @Test
     public void testUpdateMaxTargetsPerRow_columnCountIsUpdated() throws InterruptedException {
         updateMaxTargetsPerRowResource(/* targetsPerRow= */ 4);
         givenAppTargets(/* appCount= */ 16);
@@ -1715,16 +1805,7 @@
         Intent sendIntent = createSendTextIntent();
         // We need app targets for direct targets to get displayed
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(15);
-        when(
-                ChooserActivityOverrideData
-                        .getInstance()
-                        .resolverListController
-                        .getResolversForIntent(
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
-                .thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
 
         // Create direct share target
         List<ChooserTarget> serviceTargets = createDirectShareTargets(1,
@@ -2004,16 +2085,7 @@
         Intent sendIntent = createSendTextIntent();
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
 
-        when(
-                ChooserActivityOverrideData
-                        .getInstance()
-                        .resolverListController
-                        .getResolversForIntent(
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
-                .thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
 
         final IChooserWrapper activity = (IChooserWrapper)
                 mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
@@ -2045,16 +2117,7 @@
         Intent sendIntent = createSendTextIntent();
         // We need app targets for direct targets to get displayed
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
-        when(
-                ChooserActivityOverrideData
-                        .getInstance()
-                        .resolverListController
-                        .getResolversForIntent(
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
-                .thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
 
         // create test shortcut loader factory, remember loaders and their callbacks
         SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders =
@@ -2130,21 +2193,69 @@
                 /* selectionCost= */ anyLong());
     }
 
+    @Test
+    public void testDirectTargetPinningDialog() {
+        Intent sendIntent = createSendTextIntent();
+        // We need app targets for direct targets to get displayed
+        List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
+        setupResolverControllers(resolvedComponentInfos);
+
+        // create test shortcut loader factory, remember loaders and their callbacks
+        SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders =
+                new SparseArray<>();
+        ChooserActivityOverrideData.getInstance().shortcutLoaderFactory =
+                (userHandle, callback) -> {
+                    Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>> pair =
+                            new Pair<>(mock(ShortcutLoader.class), callback);
+                    shortcutLoaders.put(userHandle.getIdentifier(), pair);
+                    return pair.first;
+                };
+
+        // Start activity
+        final IChooserWrapper activity = (IChooserWrapper)
+                mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
+        waitForIdle();
+
+        // verify that ShortcutLoader was queried
+        ArgumentCaptor<DisplayResolveInfo[]> appTargets =
+                ArgumentCaptor.forClass(DisplayResolveInfo[].class);
+        verify(shortcutLoaders.get(0).first, times(1))
+                .queryShortcuts(appTargets.capture());
+
+        // send shortcuts
+        List<ChooserTarget> serviceTargets = createDirectShareTargets(
+                1,
+                resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName);
+        ShortcutLoader.Result result = new ShortcutLoader.Result(
+                // TODO: test another value as well
+                false,
+                appTargets.getValue(),
+                new ShortcutLoader.ShortcutResultInfo[] {
+                        new ShortcutLoader.ShortcutResultInfo(
+                                appTargets.getValue()[0],
+                                serviceTargets
+                        )
+                },
+                new HashMap<>(),
+                new HashMap<>()
+        );
+        activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result));
+        waitForIdle();
+
+        // Long-click on the direct target
+        String name = serviceTargets.get(0).getTitle().toString();
+        onView(withText(name)).perform(longClick());
+        waitForIdle();
+
+        onView(withId(R.id.chooser_dialog_content)).check(matches(isDisplayed()));
+    }
+
     @Test @Ignore
     public void testEmptyDirectRowLogging() throws InterruptedException {
         Intent sendIntent = createSendTextIntent();
         // We need app targets for direct targets to get displayed
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
-        when(
-                ChooserActivityOverrideData
-                        .getInstance()
-                        .resolverListController
-                        .getResolversForIntent(
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
-                .thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
 
         // Start activity
         final IChooserWrapper activity = (IChooserWrapper)
@@ -2168,16 +2279,7 @@
         Intent sendIntent = createSendTextIntent();
         List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
 
-        when(
-                ChooserActivityOverrideData
-                        .getInstance()
-                        .resolverListController
-                        .getResolversForIntent(
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
-                .thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
 
         final IChooserWrapper activity = (IChooserWrapper)
                 mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
@@ -2239,16 +2341,7 @@
     public void testOneInitialIntent_noAutolaunch() {
         List<ResolvedComponentInfo> personalResolvedComponentInfos =
                 createResolvedComponentsForTest(1);
-        when(
-                ChooserActivityOverrideData
-                        .getInstance()
-                        .resolverListController
-                        .getResolversForIntent(
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
-                .thenReturn(new ArrayList<>(personalResolvedComponentInfos));
+        setupResolverControllers(personalResolvedComponentInfos);
         Intent chooserIntent = createChooserIntent(createSendTextIntent(),
                 new Intent[] {new Intent("action.fake")});
         ResolveInfo[] chosen = new ResolveInfo[1];
@@ -2374,12 +2467,7 @@
         // Create 4 ranked app targets.
         List<ResolvedComponentInfo> personalResolvedComponentInfos =
                 createResolvedComponentsForTest(4);
-        when(ChooserActivityOverrideData.getInstance().resolverListController.getResolversForIntent(
-                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
-                .thenReturn(new ArrayList<>(personalResolvedComponentInfos));
+        setupResolverControllers(personalResolvedComponentInfos);
         // Create caller target which is duplicate with one of app targets
         Intent chooserIntent = createChooserIntent(createSendTextIntent(),
                 new Intent[] {new Intent("action.fake")});
@@ -2646,32 +2734,28 @@
     }
 
     private void setupResolverControllers(
+            List<ResolvedComponentInfo> personalResolvedComponentInfos) {
+        setupResolverControllers(personalResolvedComponentInfos, new ArrayList<>());
+    }
+
+    private void setupResolverControllers(
             List<ResolvedComponentInfo> personalResolvedComponentInfos,
             List<ResolvedComponentInfo> workResolvedComponentInfos) {
         when(
                 ChooserActivityOverrideData
                         .getInstance()
                         .resolverListController
-                        .getResolversForIntent(
+                        .getResolversForIntentAsUser(
                                 Mockito.anyBoolean(),
                                 Mockito.anyBoolean(),
                                 Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
+                                Mockito.isA(List.class),
+                                eq(UserHandle.SYSTEM)))
                 .thenReturn(new ArrayList<>(personalResolvedComponentInfos));
         when(
                 ChooserActivityOverrideData
                         .getInstance()
                         .workResolverListController
-                        .getResolversForIntent(
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
-                .thenReturn(new ArrayList<>(workResolvedComponentInfos));
-        when(
-                ChooserActivityOverrideData
-                        .getInstance()
-                        .workResolverListController
                         .getResolversForIntentAsUser(
                                 Mockito.anyBoolean(),
                                 Mockito.anyBoolean(),
@@ -2679,6 +2763,17 @@
                                 Mockito.isA(List.class),
                                 eq(UserHandle.SYSTEM)))
                 .thenReturn(new ArrayList<>(personalResolvedComponentInfos));
+        when(
+                ChooserActivityOverrideData
+                        .getInstance()
+                        .workResolverListController
+                        .getResolversForIntentAsUser(
+                                Mockito.anyBoolean(),
+                                Mockito.anyBoolean(),
+                                Mockito.anyBoolean(),
+                                Mockito.isA(List.class),
+                                eq(UserHandle.of(10))))
+                .thenReturn(new ArrayList<>(workResolvedComponentInfos));
     }
 
     private static GridRecyclerSpanCountMatcher withGridColumnCount(int columnCount) {
@@ -2717,16 +2812,7 @@
     private void givenAppTargets(int appCount) {
         List<ResolvedComponentInfo> resolvedComponentInfos =
                 createResolvedComponentsForTest(appCount);
-        when(
-                ChooserActivityOverrideData
-                        .getInstance()
-                        .resolverListController
-                        .getResolversForIntent(
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.anyBoolean(),
-                                Mockito.isA(List.class)))
-                .thenReturn(resolvedComponentInfos);
+        setupResolverControllers(resolvedComponentInfos);
     }
 
     private void updateMaxTargetsPerRowResource(int targetsPerRow) {
diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java
index f1febed..87dc1b9 100644
--- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java
+++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java
@@ -48,7 +48,6 @@
 import androidx.test.espresso.NoMatchingViewException;
 import androidx.test.rule.ActivityTestRule;
 
-import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo;
 import com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.Tab;
 import com.android.internal.R;
 
@@ -274,21 +273,27 @@
     private void setupResolverControllers(
             List<ResolvedComponentInfo> personalResolvedComponentInfos,
             List<ResolvedComponentInfo> workResolvedComponentInfos) {
-        when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(),
+        when(sOverrides.resolverListController.getResolversForIntentAsUser(
                 Mockito.anyBoolean(),
                 Mockito.anyBoolean(),
-                Mockito.isA(List.class)))
-                .thenReturn(new ArrayList<>(personalResolvedComponentInfos));
-        when(sOverrides.workResolverListController.getResolversForIntent(Mockito.anyBoolean(),
-                Mockito.anyBoolean(),
-                Mockito.anyBoolean(),
-                Mockito.isA(List.class))).thenReturn(workResolvedComponentInfos);
-        when(sOverrides.workResolverListController.getResolversForIntentAsUser(Mockito.anyBoolean(),
-                Mockito.anyBoolean(),
                 Mockito.anyBoolean(),
                 Mockito.isA(List.class),
                 eq(UserHandle.SYSTEM)))
-                .thenReturn(new ArrayList<>(personalResolvedComponentInfos));
+                        .thenReturn(new ArrayList<>(personalResolvedComponentInfos));
+        when(sOverrides.workResolverListController.getResolversForIntentAsUser(
+                Mockito.anyBoolean(),
+                Mockito.anyBoolean(),
+                Mockito.anyBoolean(),
+                Mockito.isA(List.class),
+                eq(UserHandle.SYSTEM)))
+                        .thenReturn(new ArrayList<>(personalResolvedComponentInfos));
+        when(sOverrides.workResolverListController.getResolversForIntentAsUser(
+                Mockito.anyBoolean(),
+                Mockito.anyBoolean(),
+                Mockito.anyBoolean(),
+                Mockito.isA(List.class),
+                eq(WORK_USER_HANDLE)))
+                        .thenReturn(new ArrayList<>(workResolvedComponentInfos));
     }
 
     private void waitForIdle() {
diff --git a/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt b/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt
new file mode 100644
index 0000000..e9c755d
--- /dev/null
+++ b/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt
@@ -0,0 +1,497 @@
+/*
+ * 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
+ *3
+ * 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.chooser
+
+import android.app.Activity
+import android.app.prediction.AppTarget
+import android.app.prediction.AppTargetId
+import android.content.ComponentName
+import android.content.Intent
+import android.content.pm.ResolveInfo
+import android.os.Bundle
+import android.os.UserHandle
+import com.android.intentresolver.createShortcutInfo
+import com.android.intentresolver.mock
+import com.android.intentresolver.ResolverActivity
+import com.android.intentresolver.ResolverDataProvider
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class ImmutableTargetInfoTest {
+    private val resolvedIntent = Intent("resolved")
+    private val targetIntent = Intent("target")
+    private val referrerFillInIntent = Intent("referrer_fillin")
+    private val resolvedComponentName = ComponentName("resolved", "component")
+    private val chooserTargetComponentName = ComponentName("chooser", "target")
+    private val resolveInfo = ResolverDataProvider.createResolveInfo(1, 0)
+    private val displayLabel: CharSequence = "Display Label"
+    private val extendedInfo: CharSequence = "Extended Info"
+    private val displayIconHolder: TargetInfo.IconHolder = mock()
+    private val sourceIntent1 = Intent("source1")
+    private val sourceIntent2 = Intent("source2")
+    private val displayTarget1 = DisplayResolveInfo.newDisplayResolveInfo(
+        Intent("display1"),
+        ResolverDataProvider.createResolveInfo(2, 0),
+        "display1 label",
+        "display1 extended info",
+        Intent("display1_resolved"),
+        /* resolveInfoPresentationGetter= */ null)
+    private val displayTarget2 = DisplayResolveInfo.newDisplayResolveInfo(
+        Intent("display2"),
+        ResolverDataProvider.createResolveInfo(3, 0),
+        "display2 label",
+        "display2 extended info",
+        Intent("display2_resolved"),
+        /* resolveInfoPresentationGetter= */ null)
+    private val directShareShortcutInfo = createShortcutInfo(
+        "shortcutid", ResolverDataProvider.createComponentName(4), 4)
+    private val directShareAppTarget = AppTarget(
+        AppTargetId("apptargetid"),
+        "test.directshare",
+        "target",
+        UserHandle.CURRENT)
+    private val displayResolveInfo = DisplayResolveInfo.newDisplayResolveInfo(
+        Intent("displayresolve"),
+        ResolverDataProvider.createResolveInfo(5, 0),
+        "displayresolve label",
+        "displayresolve extended info",
+        Intent("display_resolved"),
+        /* resolveInfoPresentationGetter= */ null)
+    private val hashProvider: ImmutableTargetInfo.TargetHashProvider = mock()
+
+    @Test
+    fun testBasicProperties() {  // Fields that are reflected back w/o logic.
+        // TODO: we could consider passing copies of all the values into the builder so that we can
+        // verify that they're not mutated (e.g. no extras added to the intents). For now that
+        // should be obvious from the implementation.
+        val info = ImmutableTargetInfo.newBuilder()
+            .setResolvedIntent(resolvedIntent)
+            .setTargetIntent(targetIntent)
+            .setReferrerFillInIntent(referrerFillInIntent)
+            .setResolvedComponentName(resolvedComponentName)
+            .setChooserTargetComponentName(chooserTargetComponentName)
+            .setResolveInfo(resolveInfo)
+            .setDisplayLabel(displayLabel)
+            .setExtendedInfo(extendedInfo)
+            .setDisplayIconHolder(displayIconHolder)
+            .setAlternateSourceIntents(listOf(sourceIntent1, sourceIntent2))
+            .setAllDisplayTargets(listOf(displayTarget1, displayTarget2))
+            .setIsSuspended(true)
+            .setIsPinned(true)
+            .setModifiedScore(42.0f)
+            .setDirectShareShortcutInfo(directShareShortcutInfo)
+            .setDirectShareAppTarget(directShareAppTarget)
+            .setDisplayResolveInfo(displayResolveInfo)
+            .setHashProvider(hashProvider)
+            .build()
+
+        assertThat(info.resolvedIntent).isEqualTo(resolvedIntent)
+        assertThat(info.targetIntent).isEqualTo(targetIntent)
+        assertThat(info.referrerFillInIntent).isEqualTo(referrerFillInIntent)
+        assertThat(info.resolvedComponentName).isEqualTo(resolvedComponentName)
+        assertThat(info.chooserTargetComponentName).isEqualTo(chooserTargetComponentName)
+        assertThat(info.resolveInfo).isEqualTo(resolveInfo)
+        assertThat(info.displayLabel).isEqualTo(displayLabel)
+        assertThat(info.extendedInfo).isEqualTo(extendedInfo)
+        assertThat(info.displayIconHolder).isEqualTo(displayIconHolder)
+        assertThat(info.allSourceIntents).containsExactly(
+            resolvedIntent, sourceIntent1, sourceIntent2)
+        assertThat(info.allDisplayTargets).containsExactly(displayTarget1, displayTarget2)
+        assertThat(info.isSuspended).isTrue()
+        assertThat(info.isPinned).isTrue()
+        assertThat(info.modifiedScore).isEqualTo(42.0f)
+        assertThat(info.directShareShortcutInfo).isEqualTo(directShareShortcutInfo)
+        assertThat(info.directShareAppTarget).isEqualTo(directShareAppTarget)
+        assertThat(info.displayResolveInfo).isEqualTo(displayResolveInfo)
+        assertThat(info.isEmptyTargetInfo).isFalse()
+        assertThat(info.isPlaceHolderTargetInfo).isFalse()
+        assertThat(info.isNotSelectableTargetInfo).isFalse()
+        assertThat(info.isSelectableTargetInfo).isFalse()
+        assertThat(info.isChooserTargetInfo).isFalse()
+        assertThat(info.isMultiDisplayResolveInfo).isFalse()
+        assertThat(info.isDisplayResolveInfo).isFalse()
+        assertThat(info.hashProvider).isEqualTo(hashProvider)
+    }
+
+    @Test
+    fun testToBuilderPreservesBasicProperties() {
+        // Note this is set up exactly as in `testBasicProperties`, but the assertions will be made
+        // against a *copy* of the object instead.
+        val infoToCopyFrom = ImmutableTargetInfo.newBuilder()
+            .setResolvedIntent(resolvedIntent)
+            .setTargetIntent(targetIntent)
+            .setReferrerFillInIntent(referrerFillInIntent)
+            .setResolvedComponentName(resolvedComponentName)
+            .setChooserTargetComponentName(chooserTargetComponentName)
+            .setResolveInfo(resolveInfo)
+            .setDisplayLabel(displayLabel)
+            .setExtendedInfo(extendedInfo)
+            .setDisplayIconHolder(displayIconHolder)
+            .setAlternateSourceIntents(listOf(sourceIntent1, sourceIntent2))
+            .setAllDisplayTargets(listOf(displayTarget1, displayTarget2))
+            .setIsSuspended(true)
+            .setIsPinned(true)
+            .setModifiedScore(42.0f)
+            .setDirectShareShortcutInfo(directShareShortcutInfo)
+            .setDirectShareAppTarget(directShareAppTarget)
+            .setDisplayResolveInfo(displayResolveInfo)
+            .setHashProvider(hashProvider)
+            .build()
+
+        val info = infoToCopyFrom.toBuilder().build()
+
+        assertThat(info.resolvedIntent).isEqualTo(resolvedIntent)
+        assertThat(info.targetIntent).isEqualTo(targetIntent)
+        assertThat(info.referrerFillInIntent).isEqualTo(referrerFillInIntent)
+        assertThat(info.resolvedComponentName).isEqualTo(resolvedComponentName)
+        assertThat(info.chooserTargetComponentName).isEqualTo(chooserTargetComponentName)
+        assertThat(info.resolveInfo).isEqualTo(resolveInfo)
+        assertThat(info.displayLabel).isEqualTo(displayLabel)
+        assertThat(info.extendedInfo).isEqualTo(extendedInfo)
+        assertThat(info.displayIconHolder).isEqualTo(displayIconHolder)
+        assertThat(info.allSourceIntents).containsExactly(
+            resolvedIntent, sourceIntent1, sourceIntent2)
+        assertThat(info.allDisplayTargets).containsExactly(displayTarget1, displayTarget2)
+        assertThat(info.isSuspended).isTrue()
+        assertThat(info.isPinned).isTrue()
+        assertThat(info.modifiedScore).isEqualTo(42.0f)
+        assertThat(info.directShareShortcutInfo).isEqualTo(directShareShortcutInfo)
+        assertThat(info.directShareAppTarget).isEqualTo(directShareAppTarget)
+        assertThat(info.displayResolveInfo).isEqualTo(displayResolveInfo)
+        assertThat(info.isEmptyTargetInfo).isFalse()
+        assertThat(info.isPlaceHolderTargetInfo).isFalse()
+        assertThat(info.isNotSelectableTargetInfo).isFalse()
+        assertThat(info.isSelectableTargetInfo).isFalse()
+        assertThat(info.isChooserTargetInfo).isFalse()
+        assertThat(info.isMultiDisplayResolveInfo).isFalse()
+        assertThat(info.isDisplayResolveInfo).isFalse()
+        assertThat(info.hashProvider).isEqualTo(hashProvider)
+    }
+
+    @Test
+    fun testBaseIntentToSend_defaultsToResolvedIntent() {
+        val info = ImmutableTargetInfo.newBuilder().setResolvedIntent(resolvedIntent).build()
+        assertThat(info.baseIntentToSend.filterEquals(resolvedIntent)).isTrue()
+    }
+
+    @Test
+    fun testBaseIntentToSend_fillsInFromReferrerIntent() {
+        val originalIntent = Intent()
+        originalIntent.setPackage("original")
+
+        val referrerFillInIntent = Intent("REFERRER_FILL_IN")
+        referrerFillInIntent.setPackage("referrer")
+
+        val info = ImmutableTargetInfo.newBuilder()
+            .setResolvedIntent(originalIntent)
+            .setReferrerFillInIntent(referrerFillInIntent)
+            .build()
+
+        assertThat(info.baseIntentToSend.getPackage()).isEqualTo("original")  // Only fill if empty.
+        assertThat(info.baseIntentToSend.action).isEqualTo("REFERRER_FILL_IN")
+    }
+
+    @Test
+    fun testBaseIntentToSend_fillsInFromRefinementIntent() {
+        val originalIntent = Intent()
+        originalIntent.putExtra("ORIGINAL", true)
+
+        val refinementIntent = Intent()
+        refinementIntent.putExtra("REFINEMENT", true)
+
+        val originalInfo = ImmutableTargetInfo.newBuilder()
+            .setResolvedIntent(originalIntent)
+            .build()
+        val info = originalInfo.tryToCloneWithAppliedRefinement(refinementIntent)
+
+        assertThat(info.baseIntentToSend.getBooleanExtra("ORIGINAL", false)).isTrue()
+        assertThat(info.baseIntentToSend.getBooleanExtra("REFINEMENT", false)).isTrue()
+    }
+
+    @Test
+    fun testBaseIntentToSend_twoFillInSourcesFavorsRefinementRequest() {
+        val originalIntent = Intent("REFINE_ME")
+        originalIntent.setPackage("original")
+
+        val referrerFillInIntent = Intent("REFERRER_FILL_IN")
+        referrerFillInIntent.setPackage("referrer_pkg")
+        referrerFillInIntent.setType("test/referrer")
+
+        val infoWithReferrerFillIn = ImmutableTargetInfo.newBuilder()
+            .setResolvedIntent(originalIntent)
+            .setReferrerFillInIntent(referrerFillInIntent)
+            .build()
+
+        val refinementIntent = Intent("REFINE_ME")
+        refinementIntent.setPackage("original")  // Has to match for refinement.
+
+        val info = infoWithReferrerFillIn.tryToCloneWithAppliedRefinement(refinementIntent)
+
+        assertThat(info.baseIntentToSend.getPackage()).isEqualTo("original")  // Set all along.
+        assertThat(info.baseIntentToSend.action).isEqualTo("REFINE_ME")  // Refinement wins.
+        assertThat(info.baseIntentToSend.type).isEqualTo("test/referrer")  // Left for referrer.
+    }
+
+    @Test
+    fun testBaseIntentToSend_doubleRefinementPreservesReferrerFillInButNotOriginalRefinement() {
+        val originalIntent = Intent("REFINE_ME")
+        val referrerFillInIntent = Intent("REFERRER_FILL_IN")
+        referrerFillInIntent.putExtra("TEST", "REFERRER")
+        val refinementIntent1 = Intent("REFINE_ME")
+        refinementIntent1.putExtra("TEST1", "1")
+        val refinementIntent2 = Intent("REFINE_ME")
+        refinementIntent2.putExtra("TEST2", "2")
+
+        val originalInfo = ImmutableTargetInfo.newBuilder()
+            .setResolvedIntent(originalIntent)
+            .setReferrerFillInIntent(referrerFillInIntent)
+            .build()
+
+        val refined1 = originalInfo.tryToCloneWithAppliedRefinement(refinementIntent1)
+        val refined2 = refined1.tryToCloneWithAppliedRefinement(refinementIntent2)  // Cloned clone.
+
+        // Both clones get the same values filled in from the referrer intent.
+        assertThat(refined1.baseIntentToSend.getStringExtra("TEST")).isEqualTo("REFERRER")
+        assertThat(refined2.baseIntentToSend.getStringExtra("TEST")).isEqualTo("REFERRER")
+        // Each clone has the respective value that was set in their own refinement request.
+        assertThat(refined1.baseIntentToSend.getStringExtra("TEST1")).isEqualTo("1")
+        assertThat(refined2.baseIntentToSend.getStringExtra("TEST2")).isEqualTo("2")
+        // The clones don't have the data from each other's refinements, even though the intent
+        // field is empty (thus able to be populated by filling-in).
+        assertThat(refined1.baseIntentToSend.getStringExtra("TEST2")).isNull()
+        assertThat(refined2.baseIntentToSend.getStringExtra("TEST1")).isNull()
+    }
+
+    @Test
+    fun testBaseIntentToSend_refinementToAlternateSourceIntent() {
+        val originalIntent = Intent("DONT_REFINE_ME")
+        originalIntent.putExtra("originalIntent", true)
+        val mismatchedAlternate = Intent("DOESNT_MATCH")
+        mismatchedAlternate.putExtra("mismatchedAlternate", true)
+        val targetAlternate = Intent("REFINE_ME")
+        targetAlternate.putExtra("targetAlternate", true)
+        val extraMatch = Intent("REFINE_ME")
+        extraMatch.putExtra("extraMatch", true)
+
+        val originalInfo = ImmutableTargetInfo.newBuilder()
+            .setResolvedIntent(originalIntent)
+            .setAllSourceIntents(listOf(
+                    originalIntent, mismatchedAlternate, targetAlternate, extraMatch))
+            .build()
+
+        val refinement = Intent("REFINE_ME")  // First match is `targetAlternate`
+        refinement.putExtra("refinement", true)
+
+        val refinedResult = originalInfo.tryToCloneWithAppliedRefinement(refinement)
+        assertThat(refinedResult.baseIntentToSend.getBooleanExtra("refinement", false)).isTrue()
+        assertThat(refinedResult.baseIntentToSend.getBooleanExtra("targetAlternate", false))
+            .isTrue()
+        // None of the other source intents got merged in (not even the later one that matched):
+        assertThat(refinedResult.baseIntentToSend.getBooleanExtra("originalIntent", false))
+            .isFalse()
+        assertThat(refinedResult.baseIntentToSend.getBooleanExtra("mismatchedAlternate", false))
+            .isFalse()
+        assertThat(refinedResult.baseIntentToSend.getBooleanExtra("extraMatch", false)).isFalse()
+    }
+
+    @Test
+    fun testBaseIntentToSend_noSourceIntentMatchingProposedRefinement() {
+        val originalIntent = Intent("DONT_REFINE_ME")
+        originalIntent.putExtra("originalIntent", true)
+        val mismatchedAlternate = Intent("DOESNT_MATCH")
+        mismatchedAlternate.putExtra("mismatchedAlternate", true)
+
+        val originalInfo = ImmutableTargetInfo.newBuilder()
+            .setResolvedIntent(originalIntent)
+            .setAllSourceIntents(listOf(originalIntent, mismatchedAlternate))
+            .build()
+
+        val refinement = Intent("PROPOSED_REFINEMENT")
+        assertThat(originalInfo.tryToCloneWithAppliedRefinement(refinement)).isNull()
+    }
+
+    @Test
+    fun testLegacySubclassRelationships_empty() {
+        val info = ImmutableTargetInfo.newBuilder()
+            .setLegacyType(ImmutableTargetInfo.LegacyTargetType.EMPTY_TARGET_INFO)
+            .build()
+
+        assertThat(info.isEmptyTargetInfo).isTrue()
+        assertThat(info.isPlaceHolderTargetInfo).isFalse()
+        assertThat(info.isNotSelectableTargetInfo).isTrue()
+        assertThat(info.isSelectableTargetInfo).isFalse()
+        assertThat(info.isChooserTargetInfo).isTrue()
+        assertThat(info.isMultiDisplayResolveInfo).isFalse()
+        assertThat(info.isDisplayResolveInfo).isFalse()
+    }
+
+    @Test
+    fun testLegacySubclassRelationships_placeholder() {
+        val info = ImmutableTargetInfo.newBuilder()
+            .setLegacyType(ImmutableTargetInfo.LegacyTargetType.PLACEHOLDER_TARGET_INFO)
+            .build()
+
+        assertThat(info.isEmptyTargetInfo).isFalse()
+        assertThat(info.isPlaceHolderTargetInfo).isTrue()
+        assertThat(info.isNotSelectableTargetInfo).isTrue()
+        assertThat(info.isSelectableTargetInfo).isFalse()
+        assertThat(info.isChooserTargetInfo).isTrue()
+        assertThat(info.isMultiDisplayResolveInfo).isFalse()
+        assertThat(info.isDisplayResolveInfo).isFalse()
+    }
+
+    @Test
+    fun testLegacySubclassRelationships_selectable() {
+        val info = ImmutableTargetInfo.newBuilder()
+            .setLegacyType(ImmutableTargetInfo.LegacyTargetType.SELECTABLE_TARGET_INFO)
+            .build()
+
+        assertThat(info.isEmptyTargetInfo).isFalse()
+        assertThat(info.isPlaceHolderTargetInfo).isFalse()
+        assertThat(info.isNotSelectableTargetInfo).isFalse()
+        assertThat(info.isSelectableTargetInfo).isTrue()
+        assertThat(info.isChooserTargetInfo).isTrue()
+        assertThat(info.isMultiDisplayResolveInfo).isFalse()
+        assertThat(info.isDisplayResolveInfo).isFalse()
+    }
+
+    @Test
+    fun testLegacySubclassRelationships_displayResolveInfo() {
+        val info = ImmutableTargetInfo.newBuilder()
+            .setLegacyType(ImmutableTargetInfo.LegacyTargetType.DISPLAY_RESOLVE_INFO)
+            .build()
+
+        assertThat(info.isEmptyTargetInfo).isFalse()
+        assertThat(info.isPlaceHolderTargetInfo).isFalse()
+        assertThat(info.isNotSelectableTargetInfo).isFalse()
+        assertThat(info.isSelectableTargetInfo).isFalse()
+        assertThat(info.isChooserTargetInfo).isFalse()
+        assertThat(info.isMultiDisplayResolveInfo).isFalse()
+        assertThat(info.isDisplayResolveInfo).isTrue()
+    }
+
+    @Test
+    fun testLegacySubclassRelationships_multiDisplayResolveInfo() {
+        val info = ImmutableTargetInfo.newBuilder()
+            .setLegacyType(ImmutableTargetInfo.LegacyTargetType.MULTI_DISPLAY_RESOLVE_INFO)
+            .build()
+
+        assertThat(info.isEmptyTargetInfo).isFalse()
+        assertThat(info.isPlaceHolderTargetInfo).isFalse()
+        assertThat(info.isNotSelectableTargetInfo).isFalse()
+        assertThat(info.isSelectableTargetInfo).isFalse()
+        assertThat(info.isChooserTargetInfo).isFalse()
+        assertThat(info.isMultiDisplayResolveInfo).isTrue()
+        assertThat(info.isDisplayResolveInfo).isTrue()
+    }
+
+    @Test
+    fun testActivityStarter_correctNumberOfInvocations_startAsCaller() {
+        val activityStarter = object : TestActivityStarter() {
+            override fun startAsUser(
+                target: TargetInfo, activity: Activity, options: Bundle, user: UserHandle
+            ): Boolean {
+                throw RuntimeException("Wrong API used: startAsUser")
+            }
+        }
+
+        val info = ImmutableTargetInfo.newBuilder().setActivityStarter(activityStarter).build()
+        val activity: ResolverActivity = mock()
+        val options = Bundle()
+        options.putInt("TEST_KEY", 1)
+
+        info.startAsCaller(activity, options, 42)
+
+        assertThat(activityStarter.totalInvocations).isEqualTo(1)
+        assertThat(activityStarter.lastInvocationTargetInfo).isEqualTo(info)
+        assertThat(activityStarter.lastInvocationActivity).isEqualTo(activity)
+        assertThat(activityStarter.lastInvocationOptions).isEqualTo(options)
+        assertThat(activityStarter.lastInvocationUserId).isEqualTo(42)
+        assertThat(activityStarter.lastInvocationAsCaller).isTrue()
+    }
+
+    @Test
+    fun testActivityStarter_correctNumberOfInvocations_startAsUser() {
+        val activityStarter = object : TestActivityStarter() {
+            override fun startAsCaller(
+                target: TargetInfo, activity: Activity, options: Bundle, userId: Int): Boolean {
+                throw RuntimeException("Wrong API used: startAsCaller")
+            }
+        }
+
+        val info = ImmutableTargetInfo.newBuilder().setActivityStarter(activityStarter).build()
+        val activity: Activity = mock()
+        val options = Bundle()
+        options.putInt("TEST_KEY", 1)
+
+        info.startAsUser(activity, options, UserHandle.of(42))
+
+        assertThat(activityStarter.totalInvocations).isEqualTo(1)
+        assertThat(activityStarter.lastInvocationTargetInfo).isEqualTo(info)
+        assertThat(activityStarter.lastInvocationActivity).isEqualTo(activity)
+        assertThat(activityStarter.lastInvocationOptions).isEqualTo(options)
+        assertThat(activityStarter.lastInvocationUserId).isEqualTo(42)
+        assertThat(activityStarter.lastInvocationAsCaller).isFalse()
+    }
+
+    @Test
+    fun testActivityStarter_invokedWithRespectiveTargetInfoAfterCopy() {
+        val activityStarter = TestActivityStarter()
+        val info1 = ImmutableTargetInfo.newBuilder().setActivityStarter(activityStarter).build()
+        val info2 = info1.toBuilder().build()
+
+        info1.startAsCaller(mock(), Bundle(), 42)
+        assertThat(activityStarter.lastInvocationTargetInfo).isEqualTo(info1)
+        info2.startAsCaller(mock(), Bundle(), 42)
+        assertThat(activityStarter.lastInvocationTargetInfo).isEqualTo(info2)
+        info2.startAsUser(mock(), Bundle(), UserHandle.of(42))
+        assertThat(activityStarter.lastInvocationTargetInfo).isEqualTo(info2)
+
+        assertThat(activityStarter.totalInvocations).isEqualTo(3)  // Instance is still shared.
+    }
+}
+
+private open class TestActivityStarter : ImmutableTargetInfo.TargetActivityStarter {
+    var totalInvocations = 0
+    var lastInvocationTargetInfo: TargetInfo? = null
+    var lastInvocationActivity: Activity? = null
+    var lastInvocationOptions: Bundle? = null
+    var lastInvocationUserId: Integer? = null
+    var lastInvocationAsCaller = false
+
+    override fun startAsCaller(
+            target: TargetInfo, activity: Activity, options: Bundle, userId: Int): Boolean {
+        ++totalInvocations
+        lastInvocationTargetInfo = target
+        lastInvocationActivity = activity
+        lastInvocationOptions = options
+        lastInvocationUserId = Integer(userId)
+        lastInvocationAsCaller = true
+        return true
+    }
+
+    override fun startAsUser(
+            target: TargetInfo, activity: Activity, options: Bundle, user: UserHandle): Boolean {
+        ++totalInvocations
+        lastInvocationTargetInfo = target
+        lastInvocationActivity = activity
+        lastInvocationOptions = options
+        lastInvocationUserId = Integer(user.identifier)
+        lastInvocationAsCaller = false
+        return true
+    }
+}
diff --git a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt
index 7c2b07a..dddbccc 100644
--- a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt
+++ b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt
@@ -22,18 +22,36 @@
 import android.content.Intent
 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.platform.app.InstrumentationRegistry
+import com.android.intentresolver.ResolverDataProvider
 import com.android.intentresolver.createChooserTarget
 import com.android.intentresolver.createShortcutInfo
 import com.android.intentresolver.mock
-import com.android.intentresolver.ResolverDataProvider
+import com.android.intentresolver.whenever
 import com.google.common.truth.Truth.assertThat
+import org.junit.Before
 import org.junit.Test
+import org.mockito.Mockito.any
+import org.mockito.Mockito.never
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
 
 class TargetInfoTest {
     private val context = InstrumentationRegistry.getInstrumentation().getContext()
 
+    @Before
+    fun setup() {
+        // SelectableTargetInfo reads DeviceConfig and needs a permission for that.
+        InstrumentationRegistry
+            .getInstrumentation()
+            .getUiAutomation()
+            .adoptShellPermissionIdentity("android.permission.READ_DEVICE_CONFIG")
+    }
+
     @Test
     fun testNewEmptyTargetInfo() {
         val info = NotSelectableTargetInfo.newEmptyTargetInfo()
@@ -43,13 +61,19 @@
         assertThat(info.getDisplayIconHolder().getDisplayIcon()).isNull()
     }
 
+    @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.isPlaceHolderTargetInfo).isTrue()
+        assertThat(info.isChooserTargetInfo).isTrue()  // From legacy inheritance model.
         assertThat(info.hasDisplayIcon()).isTrue()
-        // TODO: test infrastructure isn't set up to assert anything about the icon itself.
+        assertThat(info.displayIconHolder.displayIcon)
+                .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
+        // it working myself.
     }
 
     @Test
@@ -125,6 +149,42 @@
     }
 
     @Test
+    fun testSelectableTargetInfo_noSourceIntentMatchingProposedRefinement() {
+        val resolvedIntent = Intent("DONT_REFINE_ME")
+        resolvedIntent.putExtra("resolvedIntent", true)
+
+        val baseDisplayInfo = DisplayResolveInfo.newDisplayResolveInfo(
+            resolvedIntent,
+            ResolverDataProvider.createResolveInfo(1, 0),
+            "label",
+            "extended info",
+            resolvedIntent,
+            /* resolveInfoPresentationGetter= */ null)
+        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 refinement = Intent("PROPOSED_REFINEMENT")
+        assertThat(targetInfo.tryToCloneWithAppliedRefinement(refinement)).isNull()
+    }
+
+    @Test
     fun testNewDisplayResolveInfo() {
         val intent = Intent(Intent.ACTION_SEND)
         intent.putExtra(Intent.EXTRA_TEXT, "testing intent sending")
@@ -145,6 +205,64 @@
     }
 
     @Test
+    fun test_DisplayResolveInfo_refinementToAlternateSourceIntent() {
+        val originalIntent = Intent("DONT_REFINE_ME")
+        originalIntent.putExtra("originalIntent", true)
+        val mismatchedAlternate = Intent("DOESNT_MATCH")
+        mismatchedAlternate.putExtra("mismatchedAlternate", true)
+        val targetAlternate = Intent("REFINE_ME")
+        targetAlternate.putExtra("targetAlternate", true)
+        val extraMatch = Intent("REFINE_ME")
+        extraMatch.putExtra("extraMatch", true)
+
+        val originalInfo = DisplayResolveInfo.newDisplayResolveInfo(
+            originalIntent,
+            ResolverDataProvider.createResolveInfo(3, 0),
+            "label",
+            "extended info",
+            originalIntent,
+            /* resolveInfoPresentationGetter= */ null)
+        originalInfo.addAlternateSourceIntent(mismatchedAlternate)
+        originalInfo.addAlternateSourceIntent(targetAlternate)
+        originalInfo.addAlternateSourceIntent(extraMatch)
+
+        val refinement = Intent("REFINE_ME")  // First match is `targetAlternate`
+        refinement.putExtra("refinement", true)
+
+        val refinedResult = originalInfo.tryToCloneWithAppliedRefinement(refinement)
+        // Note `DisplayResolveInfo` targets merge refinements directly into their `resolvedIntent`.
+        assertThat(refinedResult.resolvedIntent.getBooleanExtra("refinement", false)).isTrue()
+        assertThat(refinedResult.resolvedIntent.getBooleanExtra("targetAlternate", false))
+            .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))
+            .isFalse()
+        assertThat(refinedResult.resolvedIntent.getBooleanExtra("extraMatch", false)).isFalse()
+    }
+
+    @Test
+    fun testDisplayResolveInfo_noSourceIntentMatchingProposedRefinement() {
+        val originalIntent = Intent("DONT_REFINE_ME")
+        originalIntent.putExtra("originalIntent", true)
+        val mismatchedAlternate = Intent("DOESNT_MATCH")
+        mismatchedAlternate.putExtra("mismatchedAlternate", true)
+
+        val originalInfo = DisplayResolveInfo.newDisplayResolveInfo(
+            originalIntent,
+            ResolverDataProvider.createResolveInfo(3, 0),
+            "label",
+            "extended info",
+            originalIntent,
+            /* resolveInfoPresentationGetter= */ null)
+        originalInfo.addAlternateSourceIntent(mismatchedAlternate)
+
+        val refinement = Intent("PROPOSED_REFINEMENT")
+        assertThat(originalInfo.tryToCloneWithAppliedRefinement(refinement)).isNull()
+    }
+
+    @Test
     fun testNewMultiDisplayResolveInfo() {
         val intent = Intent(Intent.ACTION_SEND)
         intent.putExtra(Intent.EXTRA_TEXT, "testing intent sending")
@@ -186,7 +304,93 @@
         assertThat(multiTargetInfo.hasSelected()).isTrue()
         assertThat(multiTargetInfo.getSelectedTarget()).isEqualTo(secondTargetInfo)
 
+        val refined = multiTargetInfo.tryToCloneWithAppliedRefinement(intent)
+        assertThat(refined).isInstanceOf(MultiDisplayResolveInfo::class.java)
+        assertThat((refined as MultiDisplayResolveInfo).hasSelected())
+            .isEqualTo(multiTargetInfo.hasSelected())
+
         // TODO: consider exercising activity-start behavior.
         // TODO: consider exercising DisplayResolveInfo base class behavior.
     }
+
+    @Test
+    fun testNewMultiDisplayResolveInfo_getAllSourceIntents_fromSelectedTarget() {
+        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,
+            /* resolveInfoPresentationGetter= */ null)
+
+        val textOnlyTarget = DisplayResolveInfo.newDisplayResolveInfo(
+            sendUri,
+            resolveInfo,
+            "Send Text",
+            "Sends only text",
+            sendUri,
+            /* resolveInfoPresentationGetter= */ null)
+
+        val imageOrTextTarget = DisplayResolveInfo.newDisplayResolveInfo(
+            sendImage,
+            resolveInfo,
+            "Send Image or Text",
+            "Sends images or text",
+            sendImage,
+            /* resolveInfoPresentationGetter= */ null
+        ).apply {
+            addAlternateSourceIntent(sendUri)
+        }
+
+        val multiTargetInfo = MultiDisplayResolveInfo.newMultiDisplayResolveInfo(
+            listOf(imageOnlyTarget, textOnlyTarget, imageOrTextTarget)
+        )
+
+        multiTargetInfo.setSelected(0)
+        assertThat(multiTargetInfo.selectedTarget).isEqualTo(imageOnlyTarget)
+        assertThat(multiTargetInfo.allSourceIntents).isEqualTo(imageOnlyTarget.allSourceIntents)
+
+        multiTargetInfo.setSelected(1)
+        assertThat(multiTargetInfo.selectedTarget).isEqualTo(textOnlyTarget)
+        assertThat(multiTargetInfo.allSourceIntents).isEqualTo(textOnlyTarget.allSourceIntents)
+
+        multiTargetInfo.setSelected(2)
+        assertThat(multiTargetInfo.selectedTarget).isEqualTo(imageOrTextTarget)
+        assertThat(multiTargetInfo.allSourceIntents).isEqualTo(imageOrTextTarget.allSourceIntents)
+    }
+
+    @Test
+    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,
+                /* resolveInfoPresentationGetter= */ null
+            )
+        )
+        val targetTwo = mock<DisplayResolveInfo> {
+            whenever(tryToCloneWithAppliedRefinement(any())).thenReturn(this)
+        }
+
+        val multiTargetInfo = MultiDisplayResolveInfo.newMultiDisplayResolveInfo(
+            listOf(targetOne, targetTwo)
+        )
+
+        multiTargetInfo.setSelected(1)
+        assertThat(multiTargetInfo.selectedTarget).isEqualTo(targetTwo)
+
+        multiTargetInfo.tryToCloneWithAppliedRefinement(refined)
+        verify(targetTwo, times(1)).tryToCloneWithAppliedRefinement(refined)
+        verify(targetOne, never()).tryToCloneWithAppliedRefinement(any())
+    }
 }
diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt
new file mode 100644
index 0000000..d870a8c
--- /dev/null
+++ b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt
@@ -0,0 +1,203 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.contentpreview
+
+import android.content.ClipDescription
+import android.content.ContentInterface
+import android.content.Intent
+import android.graphics.Bitmap
+import android.net.Uri
+import com.android.intentresolver.ImageLoader
+import com.android.intentresolver.TestFeatureFlagRepository
+import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory
+import com.android.intentresolver.flags.Flags
+import com.android.intentresolver.mock
+import com.android.intentresolver.whenever
+import com.android.intentresolver.widget.ActionRow
+import com.android.intentresolver.widget.ImagePreviewView
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.mockito.Mockito.never
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+import java.util.function.Consumer
+
+private const val PROVIDER_NAME = "org.pkg.app"
+class ChooserContentPreviewUiTest {
+    private val contentResolver = mock<ContentInterface>()
+    private val imageClassifier = ChooserContentPreviewUi.ImageMimeTypeClassifier { mimeType ->
+        mimeType != null && ClipDescription.compareMimeTypes(mimeType, "image/*")
+    }
+    private val imageLoader = object : ImageLoader {
+        override fun loadImage(uri: Uri, callback: Consumer<Bitmap?>) {
+            callback.accept(null)
+        }
+        override fun prePopulate(uris: List<Uri>) = Unit
+        override suspend fun invoke(uri: Uri): Bitmap? = null
+    }
+    private val actionFactory = object : ActionFactory {
+        override fun createCopyButton() = ActionRow.Action(label = "Copy", icon = null) {}
+        override fun createEditButton(): ActionRow.Action? = null
+        override fun createNearbyButton(): ActionRow.Action? = null
+        override fun createCustomActions(): List<ActionRow.Action> = emptyList()
+        override fun getModifyShareAction(): Runnable? = null
+        override fun getExcludeSharedTextAction(): Consumer<Boolean> = Consumer<Boolean> {}
+    }
+    private val transitionCallback = mock<ImagePreviewView.TransitionElementStatusCallback>()
+    private val featureFlagRepository = TestFeatureFlagRepository(
+        mapOf(
+            Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW to true
+        )
+    )
+
+    @Test
+    fun test_ChooserContentPreview_non_send_intent_action_to_text_preview() {
+        val targetIntent = Intent(Intent.ACTION_VIEW)
+        val testSubject = ChooserContentPreviewUi(
+            targetIntent,
+            contentResolver,
+            imageClassifier,
+            imageLoader,
+            actionFactory,
+            transitionCallback,
+            featureFlagRepository
+        )
+        assertThat(testSubject.preferredContentPreview)
+            .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT)
+        verify(transitionCallback, times(1)).onAllTransitionElementsReady()
+    }
+
+    @Test
+    fun test_ChooserContentPreview_text_mime_type_to_text_preview() {
+        val targetIntent = Intent(Intent.ACTION_SEND).apply {
+            type = "text/plain"
+            putExtra(Intent.EXTRA_TEXT, "Text Extra")
+        }
+        val testSubject = ChooserContentPreviewUi(
+            targetIntent,
+            contentResolver,
+            imageClassifier,
+            imageLoader,
+            actionFactory,
+            transitionCallback,
+            featureFlagRepository
+        )
+        assertThat(testSubject.preferredContentPreview)
+            .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT)
+        verify(transitionCallback, times(1)).onAllTransitionElementsReady()
+    }
+
+    @Test
+    fun test_ChooserContentPreview_single_image_uri_to_image_preview() {
+        val uri = Uri.parse("content://$PROVIDER_NAME/test.png")
+        val targetIntent = Intent(Intent.ACTION_SEND).apply {
+            putExtra(Intent.EXTRA_STREAM, uri)
+        }
+        whenever(contentResolver.getType(uri)).thenReturn("image/png")
+        val testSubject = ChooserContentPreviewUi(
+            targetIntent,
+            contentResolver,
+            imageClassifier,
+            imageLoader,
+            actionFactory,
+            transitionCallback,
+            featureFlagRepository
+        )
+        assertThat(testSubject.preferredContentPreview)
+            .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE)
+        verify(transitionCallback, never()).onAllTransitionElementsReady()
+    }
+
+    @Test
+    fun test_ChooserContentPreview_single_non_image_uri_to_file_preview() {
+        val uri = Uri.parse("content://$PROVIDER_NAME/test.pdf")
+        val targetIntent = Intent(Intent.ACTION_SEND).apply {
+            putExtra(Intent.EXTRA_STREAM, uri)
+        }
+        whenever(contentResolver.getType(uri)).thenReturn("application/pdf")
+        val testSubject = ChooserContentPreviewUi(
+            targetIntent,
+            contentResolver,
+            imageClassifier,
+            imageLoader,
+            actionFactory,
+            transitionCallback,
+            featureFlagRepository
+        )
+        assertThat(testSubject.preferredContentPreview)
+            .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE)
+        verify(transitionCallback, times(1)).onAllTransitionElementsReady()
+    }
+
+    @Test
+    fun test_ChooserContentPreview_multiple_image_uri_to_image_preview() {
+        val uri1 = Uri.parse("content://$PROVIDER_NAME/test.png")
+        val uri2 = Uri.parse("content://$PROVIDER_NAME/test.jpg")
+        val targetIntent = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
+            putExtra(
+                Intent.EXTRA_STREAM,
+                ArrayList<Uri>().apply {
+                    add(uri1)
+                    add(uri2)
+                }
+            )
+        }
+        whenever(contentResolver.getType(uri1)).thenReturn("image/png")
+        whenever(contentResolver.getType(uri2)).thenReturn("image/jpeg")
+        val testSubject = ChooserContentPreviewUi(
+            targetIntent,
+            contentResolver,
+            imageClassifier,
+            imageLoader,
+            actionFactory,
+            transitionCallback,
+            featureFlagRepository
+        )
+        assertThat(testSubject.preferredContentPreview)
+            .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE)
+        verify(transitionCallback, never()).onAllTransitionElementsReady()
+    }
+
+    @Test
+    fun test_ChooserContentPreview_some_non_image_uri_to_file_preview() {
+        val uri1 = Uri.parse("content://$PROVIDER_NAME/test.png")
+        val uri2 = Uri.parse("content://$PROVIDER_NAME/test.pdf")
+        val targetIntent = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
+            putExtra(
+                Intent.EXTRA_STREAM,
+                ArrayList<Uri>().apply {
+                    add(uri1)
+                    add(uri2)
+                }
+            )
+        }
+        whenever(contentResolver.getType(uri1)).thenReturn("image/png")
+        whenever(contentResolver.getType(uri2)).thenReturn("application/pdf")
+        val testSubject = ChooserContentPreviewUi(
+            targetIntent,
+            contentResolver,
+            imageClassifier,
+            imageLoader,
+            actionFactory,
+            transitionCallback,
+            featureFlagRepository
+        )
+        assertThat(testSubject.preferredContentPreview)
+            .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE)
+        verify(transitionCallback, times(1)).onAllTransitionElementsReady()
+    }
+}
diff --git a/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java b/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java
index 448718c..006f3b2 100644
--- a/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java
+++ b/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java
@@ -27,7 +27,7 @@
 
 import androidx.test.InstrumentationRegistry;
 
-import com.android.intentresolver.ResolverActivity;
+import com.android.intentresolver.ResolvedComponentInfo;
 
 import org.junit.Test;
 
@@ -37,12 +37,12 @@
 
     @Test
     public void testPinned() {
-        ResolverActivity.ResolvedComponentInfo r1 = new ResolverActivity.ResolvedComponentInfo(
+        ResolvedComponentInfo r1 = new ResolvedComponentInfo(
                 new ComponentName("package", "class"), new Intent(), new ResolveInfo()
         );
         r1.setPinned(true);
 
-        ResolverActivity.ResolvedComponentInfo r2 = new ResolverActivity.ResolvedComponentInfo(
+        ResolvedComponentInfo r2 = new ResolvedComponentInfo(
                 new ComponentName("zackage", "zlass"), new Intent(), new ResolveInfo()
         );
 
@@ -60,14 +60,14 @@
         pmInfo1.activityInfo = new ActivityInfo();
         pmInfo1.activityInfo.packageName = "aaa";
 
-        ResolverActivity.ResolvedComponentInfo r1 = new ResolverActivity.ResolvedComponentInfo(
+        ResolvedComponentInfo r1 = new ResolvedComponentInfo(
                 new ComponentName("package", "class"), new Intent(), pmInfo1);
         r1.setPinned(true);
 
         ResolveInfo pmInfo2 = new ResolveInfo();
         pmInfo2.activityInfo = new ActivityInfo();
         pmInfo2.activityInfo.packageName = "zzz";
-        ResolverActivity.ResolvedComponentInfo r2 = new ResolverActivity.ResolvedComponentInfo(
+        ResolvedComponentInfo r2 = new ResolvedComponentInfo(
                 new ComponentName("zackage", "zlass"), new Intent(), pmInfo2);
         r2.setPinned(true);
 
@@ -91,7 +91,7 @@
                     }
 
                     @Override
-                    void doCompute(List<ResolverActivity.ResolvedComponentInfo> targets) {}
+                    void doCompute(List<ResolvedComponentInfo> targets) {}
 
                     @Override
                     public float getScore(ComponentName name) {
diff --git a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt
index 5756a0c..0c817cb 100644
--- a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt
+++ b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt
@@ -28,6 +28,8 @@
 import android.os.UserManager
 import androidx.test.filters.SmallTest
 import com.android.intentresolver.any
+import com.android.intentresolver.argumentCaptor
+import com.android.intentresolver.capture
 import com.android.intentresolver.chooser.DisplayResolveInfo
 import com.android.intentresolver.createAppTarget
 import com.android.intentresolver.createShareShortcutInfo
@@ -39,8 +41,8 @@
 import org.junit.Assert.assertFalse
 import org.junit.Assert.assertTrue
 import org.junit.Test
-import org.mockito.ArgumentCaptor
 import org.mockito.Mockito.anyInt
+import org.mockito.Mockito.atLeastOnce
 import org.mockito.Mockito.never
 import org.mockito.Mockito.times
 import org.mockito.Mockito.verify
@@ -56,9 +58,15 @@
     private val pm = mock<PackageManager> {
         whenever(getApplicationInfo(any(), any<ApplicationInfoFlags>())).thenReturn(appInfo)
     }
+    val userManager = mock<UserManager> {
+        whenever(isUserRunning(any<UserHandle>())).thenReturn(true)
+        whenever(isUserUnlocked(any<UserHandle>())).thenReturn(true)
+        whenever(isQuietModeEnabled(any<UserHandle>())).thenReturn(false)
+    }
     private val context = mock<Context> {
         whenever(packageManager).thenReturn(pm)
         whenever(createContextAsUser(any(), anyInt())).thenReturn(this)
+        whenever(getSystemService(Context.USER_SERVICE)).thenReturn(userManager)
     }
     private val executor = ImmediateExecutor()
     private val intentFilter = mock<IntentFilter>()
@@ -66,7 +74,7 @@
     private val callback = mock<Consumer<ShortcutLoader.Result>>()
 
     @Test
-    fun test_app_predictor_result() {
+    fun test_queryShortcuts_result_consistency_with_AppPredictor() {
         val componentName = ComponentName("pkg", "Class")
         val appTarget = mock<DisplayResolveInfo> {
             whenever(resolvedComponentName).thenReturn(componentName)
@@ -85,24 +93,22 @@
 
         testSubject.queryShortcuts(appTargets)
 
-        verify(appPredictor, times(1)).requestPredictionUpdate()
-        val appPredictorCallbackCaptor = ArgumentCaptor.forClass(AppPredictor.Callback::class.java)
-        verify(appPredictor, times(1))
-            .registerPredictionUpdates(any(), appPredictorCallbackCaptor.capture())
-
         val matchingShortcutInfo = createShortcutInfo("id-0", componentName, 1)
         val matchingAppTarget = createAppTarget(matchingShortcutInfo)
         val shortcuts = listOf(
             matchingAppTarget,
-            // mismatching shortcut
+            // an AppTarget that does not belong to any resolved application; should be ignored
             createAppTarget(
                 createShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1)
             )
         )
+        val appPredictorCallbackCaptor = argumentCaptor<AppPredictor.Callback>()
+        verify(appPredictor, atLeastOnce())
+            .registerPredictionUpdates(any(), capture(appPredictorCallbackCaptor))
         appPredictorCallbackCaptor.value.onTargetsAvailable(shortcuts)
 
-        val resultCaptor = ArgumentCaptor.forClass(ShortcutLoader.Result::class.java)
-        verify(callback, times(1)).accept(resultCaptor.capture())
+        val resultCaptor = argumentCaptor<ShortcutLoader.Result>()
+        verify(callback, times(1)).accept(capture(resultCaptor))
 
         val result = resultCaptor.value
         assertTrue("An app predictor result is expected", result.isFromAppPredictor)
@@ -124,7 +130,7 @@
     }
 
     @Test
-    fun test_shortcut_manager_result() {
+    fun test_queryShortcuts_result_consistency_with_ShortcutManager() {
         val componentName = ComponentName("pkg", "Class")
         val appTarget = mock<DisplayResolveInfo> {
             whenever(resolvedComponentName).thenReturn(componentName)
@@ -153,8 +159,8 @@
 
         testSubject.queryShortcuts(appTargets)
 
-        val resultCaptor = ArgumentCaptor.forClass(ShortcutLoader.Result::class.java)
-        verify(callback, times(1)).accept(resultCaptor.capture())
+        val resultCaptor = argumentCaptor<ShortcutLoader.Result>()
+        verify(callback, times(1)).accept(capture(resultCaptor))
 
         val result = resultCaptor.value
         assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor)
@@ -175,7 +181,7 @@
     }
 
     @Test
-    fun test_fallback_to_shortcut_manager() {
+    fun test_queryShortcuts_falls_back_to_ShortcutManager_on_empty_reply() {
         val componentName = ComponentName("pkg", "Class")
         val appTarget = mock<DisplayResolveInfo> {
             whenever(resolvedComponentName).thenReturn(componentName)
@@ -205,13 +211,13 @@
         testSubject.queryShortcuts(appTargets)
 
         verify(appPredictor, times(1)).requestPredictionUpdate()
-        val appPredictorCallbackCaptor = ArgumentCaptor.forClass(AppPredictor.Callback::class.java)
+        val appPredictorCallbackCaptor = argumentCaptor<AppPredictor.Callback>()
         verify(appPredictor, times(1))
-            .registerPredictionUpdates(any(), appPredictorCallbackCaptor.capture())
+            .registerPredictionUpdates(any(), capture(appPredictorCallbackCaptor))
         appPredictorCallbackCaptor.value.onTargetsAvailable(emptyList())
 
-        val resultCaptor = ArgumentCaptor.forClass(ShortcutLoader.Result::class.java)
-        verify(callback, times(1)).accept(resultCaptor.capture())
+        val resultCaptor = argumentCaptor<ShortcutLoader.Result>()
+        verify(callback, times(1)).accept(capture(resultCaptor))
 
         val result = resultCaptor.value
         assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor)
@@ -232,32 +238,32 @@
     }
 
     @Test
-    fun test_do_not_call_services_for_not_running_work_profile() {
+    fun test_queryShortcuts_do_not_call_services_for_not_running_work_profile() {
         testDisabledWorkProfileDoNotCallSystem(isUserRunning = false)
     }
 
     @Test
-    fun test_do_not_call_services_for_locked_work_profile() {
+    fun test_queryShortcuts_do_not_call_services_for_locked_work_profile() {
         testDisabledWorkProfileDoNotCallSystem(isUserUnlocked = false)
     }
 
     @Test
-    fun test_do_not_call_services_if_quite_mode_is_enabled_for_work_profile() {
+    fun test_queryShortcuts_do_not_call_services_if_quite_mode_is_enabled_for_work_profile() {
         testDisabledWorkProfileDoNotCallSystem(isQuietModeEnabled = true)
     }
 
     @Test
-    fun test_call_services_for_not_running_main_profile() {
+    fun test_queryShortcuts_call_services_for_not_running_main_profile() {
         testAlwaysCallSystemForMainProfile(isUserRunning = false)
     }
 
     @Test
-    fun test_call_services_for_locked_main_profile() {
+    fun test_queryShortcuts_call_services_for_locked_main_profile() {
         testAlwaysCallSystemForMainProfile(isUserUnlocked = false)
     }
 
     @Test
-    fun test_call_services_if_quite_mode_is_enabled_for_main_profile() {
+    fun test_queryShortcuts_call_services_if_quite_mode_is_enabled_for_main_profile() {
         testAlwaysCallSystemForMainProfile(isQuietModeEnabled = true)
     }
 
@@ -267,7 +273,7 @@
         isQuietModeEnabled: Boolean = false
     ) {
         val userHandle = UserHandle.of(10)
-        val userManager = mock<UserManager> {
+        with(userManager) {
             whenever(isUserRunning(userHandle)).thenReturn(isUserRunning)
             whenever(isUserUnlocked(userHandle)).thenReturn(isUserUnlocked)
             whenever(isQuietModeEnabled(userHandle)).thenReturn(isQuietModeEnabled)
@@ -297,7 +303,7 @@
         isQuietModeEnabled: Boolean = false
     ) {
         val userHandle = UserHandle.of(10)
-        val userManager = mock<UserManager> {
+        with(userManager) {
             whenever(isUserRunning(userHandle)).thenReturn(isUserRunning)
             whenever(isUserUnlocked(userHandle)).thenReturn(isUserUnlocked)
             whenever(isQuietModeEnabled(userHandle)).thenReturn(isQuietModeEnabled)