Merge "Add custom action and reselection logging params to IntentResolver" into tm-qpr-dev
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..9475511 100644
--- a/java/res/layout/chooser_grid_preview_file.xml
+++ b/java/res/layout/chooser_grid_preview_file.xml
@@ -68,6 +68,15 @@
         android:singleLine="true"/>
   </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..23bc25d 100644
--- a/java/res/layout/chooser_grid_preview_image.xml
+++ b/java/res/layout/chooser_grid_preview_image.xml
@@ -25,7 +25,7 @@
     android:orientation="vertical"
     android:background="?android:attr/colorBackground">
 
-  <com.android.intentresolver.widget.ImagePreviewView
+  <com.android.intentresolver.widget.ChooserImagePreviewView
       android:id="@androidprv:id/content_preview_image_area"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
@@ -33,6 +33,15 @@
       android:paddingBottom="@dimen/chooser_view_spacing"
       android:background="?android:attr/colorBackground" />
 
+  <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"
       android:layout_width="match_parent"
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/values/strings.xml b/java/res/values/strings.xml
index a536d3b..5917950 100644
--- a/java/res/values/strings.xml
+++ b/java/res/values/strings.xml
@@ -101,4 +101,11 @@
     <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>
 </resources>
diff --git a/java/res/values/styles.xml b/java/res/values/styles.xml
index cbbf406..229512f 100644
--- a/java/res/values/styles.xml
+++ b/java/res/values/styles.xml
@@ -46,4 +46,10 @@
         <item name="*android:iconfactoryIconSize">@dimen/chooser_icon_size</item>
         <item name="*android:iconfactoryBadgeSize">@dimen/chooser_badge_size</item>
     </style>
+
+    <style name="ReselectionAction">
+        <item name="android:paddingTop">5dp</item>
+        <item name="android:paddingBottom">5dp</item>
+        <item name="android:textColor">?android:attr/textColorPrimary</item>
+    </style>
 </resources>
diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java
index 6a94d56..f7f131a 100644
--- a/java/src/com/android/intentresolver/ChooserActivity.java
+++ b/java/src/com/android/intentresolver/ChooserActivity.java
@@ -160,6 +160,7 @@
 
     private static final boolean DEBUG = true;
     static final boolean ENABLE_CUSTOM_ACTIONS = false;
+    static final boolean ENABLE_RESELECTION_ACTION = false;
 
     public static final String LAUNCH_LOCATION_DIRECT_SHARE = "direct_share";
     private static final String SHORTCUT_TARGET = "shortcut_target";
@@ -267,7 +268,11 @@
 
         try {
             mChooserRequest = new ChooserRequestParameters(
-                    getIntent(), getReferrer(), getNearbySharingComponent(), ENABLE_CUSTOM_ACTIONS);
+                    getIntent(),
+                    getReferrer(),
+                    getNearbySharingComponent(),
+                    ENABLE_CUSTOM_ACTIONS,
+                    ENABLE_RESELECTION_ACTION);
         } catch (IllegalArgumentException e) {
             Log.e(TAG, "Caller provided invalid Chooser request parameters", e);
             finish();
@@ -743,6 +748,18 @@
                         }
                         return actions;
                     }
+
+                    @Nullable
+                    @Override
+                    public Runnable getReselectionAction() {
+                        if (!ENABLE_RESELECTION_ACTION) {
+                            return null;
+                        }
+                        PendingIntent reselectionAction = mChooserRequest.getReselectionAction();
+                        return reselectionAction == null
+                                ? null
+                                : createReselectionRunnable(reselectionAction);
+                    }
                 };
 
         ViewGroup layout = ChooserContentPreviewUi.displayContentPreview(
@@ -962,6 +979,19 @@
         );
     }
 
+    private Runnable createReselectionRunnable(PendingIntent pendingIntent) {
+        return () -> {
+            try {
+                pendingIntent.send();
+            } catch (PendingIntent.CanceledException e) {
+                Log.d(TAG, "Payload reselection action has been cancelled");
+            }
+            // TODO: add reporting
+            setResult(RESULT_OK);
+            finish();
+        };
+    }
+
     @Nullable
     private View getFirstVisibleImgPreviewView() {
         View firstImage = findViewById(com.android.internal.R.id.content_preview_image_1_large);
@@ -1535,9 +1565,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);
                         }
                     }
@@ -1871,21 +1901,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,
diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java
index 88f9006..390c47c 100644
--- a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java
+++ b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java
@@ -90,6 +90,12 @@
 
         /** Create custom actions */
         List<ActionRow.Action> createCustomActions();
+
+        /**
+         * Provides a re-selection action, if any.
+         */
+        @Nullable
+        Runnable getReselectionAction();
     }
 
     /**
@@ -218,6 +224,15 @@
             default:
                 Log.e(TAG, "Unexpected content preview type: " + previewType);
         }
+        Runnable reselectionAction = actionFactory.getReselectionAction();
+        if (reselectionAction != null && layout != null
+                && ChooserActivity.ENABLE_RESELECTION_ACTION) {
+            View reselectionView = layout.findViewById(R.id.reselection_action);
+            if (reselectionView != null) {
+                reselectionView.setVisibility(View.VISIBLE);
+                reselectionView.setOnClickListener(view -> reselectionAction.run());
+            }
+        }
 
         return layout;
     }
@@ -358,7 +373,7 @@
         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);
+            ((View) imagePreview).setVisibility(View.GONE);
             onTransitionTargetReady.accept(false);
             return contentPreviewLayout;
         }
diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java
index a7e543a..97bee82 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;
@@ -72,6 +73,7 @@
     private final ImmutableList<ComponentName> mFilteredComponentNames;
     private final ImmutableList<ChooserTarget> mCallerChooserTargets;
     private final ImmutableList<ChooserAction> mChooserActions;
+    private final PendingIntent mReselectionAction;
     private final boolean mRetainInOnStop;
 
     @Nullable
@@ -99,7 +101,8 @@
             final Intent clientIntent,
             final Uri referrer,
             @Nullable final ComponentName nearbySharingComponent,
-            boolean extractCustomActions) {
+            boolean extractCustomActions,
+            boolean extractReslectionAction) {
         final Intent requestedTarget = parseTargetIntentExtra(
                 clientIntent.getParcelableExtra(Intent.EXTRA_INTENT));
         mTarget = intentWithModifiedLaunchFlags(requestedTarget);
@@ -137,6 +140,9 @@
         mChooserActions = extractCustomActions
                 ? getChooserActions(clientIntent)
                 : ImmutableList.of();
+        mReselectionAction = extractReslectionAction
+                ? getReselectionActionExtra(clientIntent)
+                : null;
     }
 
     public Intent getTargetIntent() {
@@ -182,6 +188,11 @@
         return mChooserActions;
     }
 
+    @Nullable
+    public PendingIntent getReselectionAction() {
+        return mReselectionAction;
+    }
+
     /**
      * Whether the {@link ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP} behavior was requested.
      */
@@ -321,6 +332,21 @@
             .collect(toImmutableList());
     }
 
+    @Nullable
+    private static PendingIntent getReselectionActionExtra(Intent intent) {
+        try {
+            return intent.getParcelableExtra(
+                    Intent.EXTRA_CHOOSER_PAYLOAD_RESELECTION_ACTION,
+                    PendingIntent.class);
+        } catch (Throwable t) {
+            Log.w(
+                    TAG,
+                    "Unable to retrieve Intent.EXTRA_CHOOSER_PAYLOAD_RESELECTION_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/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..dd1dd28
--- /dev/null
+++ b/java/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt
@@ -0,0 +1,175 @@
+/*
+ * 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.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
+
+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 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)
+    }
+
+    /**
+     * 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.
+     */
+    override fun setSharedElementTransitionTarget(name: String, onViewReady: Consumer<Boolean>) {
+        mainImage.transitionName = name
+        onTransitionViewReadyCallback = onViewReady
+    }
+
+    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(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
+    }
+}
diff --git a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt
index c61c7c7..a575605 100644
--- a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt
+++ b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt
@@ -16,58 +16,13 @@
 
 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
 
-private 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 {
 
     /**
      * Specifies a transition animation target name and a readiness callback. The callback will be
@@ -78,101 +33,7 @@
      * @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.
      */
-    fun setSharedElementTransitionTarget(name: String, onViewReady: Consumer<Boolean>) {
-        mainImage.transitionName = name
-        onTransitionViewReadyCallback = onViewReady
-    }
+    fun setSharedElementTransitionTarget(name: String, onViewReady: Consumer<Boolean>)
 
-    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
-    }
+    fun setImages(uris: List<Uri>, imageLoader: ImageLoader)
 }
diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java
index c2d3f21..d7af892 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;
@@ -2191,6 +2192,72 @@
                 /* selectionCost= */ anyLong());
     }
 
+    @Test
+    public void testDirectTargetPinningDialog() {
+        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);
+
+        // 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();
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)