App Pairs: Implement Taskbar functionality

[App Pairs 8/?]

This CL adds taskbar functionality for app pairs:
- Ability to drag an app pair icon to Taskbar
- App pair launch from Home Taskbar
- App pair launch from Overview Taskbar
- App pair launch from in-app Taskbar

KNOWN ISSUES:
- Bug (b/315190686): if user is inside a running split pair and attempts to launch the same pair from an app pair icon on taskbar, Overview tiles get temporarily messed up (recoverable by leaving Overview)
- User can attempt to split with an app pair icon on the Taskbar. This should result in a "can't split with this" bounce animation on the SplitInstructionsView (to be implemented). Currently does nothing.

Bug: 274835596
Flag: ACONFIG com.android.wm.shell.enable_app_pairs DEVELOPMENT
Test: Manual
Change-Id: I5256547af236fc2deeb192d60bfe1f2b7ddc5647
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java
index 7128603..e106506 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java
@@ -106,6 +106,7 @@
 import com.android.launcher3.touch.ItemClickHandler;
 import com.android.launcher3.touch.ItemClickHandler.ItemClickProxy;
 import com.android.launcher3.util.ActivityOptionsWrapper;
+import com.android.launcher3.util.ComponentKey;
 import com.android.launcher3.util.DisplayController;
 import com.android.launcher3.util.Executors;
 import com.android.launcher3.util.NavigationMode;
@@ -127,6 +128,7 @@
 
 import java.io.PrintWriter;
 import java.util.Collections;
+import java.util.List;
 import java.util.Optional;
 import java.util.function.Consumer;
 
@@ -975,6 +977,8 @@
     }
 
     protected void onTaskbarIconClicked(View view) {
+        TaskbarUIController taskbarUIController = mControllers.uiController;
+        RecentsView recents = taskbarUIController.getRecentsView();
         boolean shouldCloseAllOpenViews = true;
         Object tag = view.getTag();
         if (tag instanceof Task) {
@@ -982,46 +986,26 @@
             ActivityManagerWrapper.getInstance().startActivityFromRecents(task.key,
                     ActivityOptions.makeBasic());
             mControllers.taskbarStashController.updateAndAnimateTransientTaskbar(true);
-        } else if (tag instanceof FolderInfo) {
+        } else if (tag instanceof FolderInfo fi && fi.itemType == Favorites.ITEM_TYPE_FOLDER) {
+            // Tapping an expandable folder icon on Taskbar
             shouldCloseAllOpenViews = false;
-            FolderIcon folderIcon = (FolderIcon) view;
-            Folder folder = folderIcon.getFolder();
-
-            folder.setPriorityOnFolderStateChangedListener(
-                    new Folder.OnFolderStateChangedListener() {
-                        @Override
-                        public void onFolderStateChanged(int newState) {
-                            if (newState == Folder.STATE_OPEN) {
-                                setTaskbarWindowFocusableForIme(true);
-                            } else if (newState == Folder.STATE_CLOSED) {
-                                // Defer by a frame to ensure we're no longer fullscreen and thus
-                                // won't jump.
-                                getDragLayer().post(() -> setTaskbarWindowFocusableForIme(false));
-                                folder.setPriorityOnFolderStateChangedListener(null);
-                            }
-                        }
-                    });
-
-            setTaskbarWindowFullscreen(true);
-
-            getDragLayer().post(() -> {
-                folder.animateOpen();
-                getStatsLogManager().logger().withItemInfo(folder.mInfo).log(LAUNCHER_FOLDER_OPEN);
-
-                folder.iterateOverItems((itemInfo, itemView) -> {
-                    mControllers.taskbarViewController
-                            .setClickAndLongClickListenersForIcon(itemView);
-                    // To play haptic when dragging, like other Taskbar items do.
-                    itemView.setHapticFeedbackEnabled(true);
-                    return false;
-                });
-            });
+            expandFolder((FolderIcon) view);
+        } else if (tag instanceof FolderInfo fi && fi.itemType == Favorites.ITEM_TYPE_APP_PAIR) {
+            // Tapping an app pair icon on Taskbar
+            if (recents != null && recents.isSplitSelectionActive()) {
+                // TODO (b/274835596): Implement "can't split with this" bounce animation
+                Toast.makeText(this, "Unable to split with an app pair. Select another app.",
+                        Toast.LENGTH_SHORT).show();
+            } else {
+                // Else launch the selected app pair
+                launchFromTaskbarPreservingSplitIfVisible(recents, fi.contents);
+                mControllers.uiController.onTaskbarIconLaunched(fi);
+                mControllers.taskbarStashController.updateAndAnimateTransientTaskbar(true);
+            }
         } else if (tag instanceof WorkspaceItemInfo) {
             // Tapping a launchable icon on Taskbar
             WorkspaceItemInfo info = (WorkspaceItemInfo) tag;
             if (!info.isDisabled() || !ItemClickHandler.handleDisabledItemClicked(info, this)) {
-                TaskbarUIController taskbarUIController = mControllers.uiController;
-                RecentsView recents = taskbarUIController.getRecentsView();
                 if (recents != null && recents.isSplitSelectionActive()) {
                     // If we are selecting a second app for split, launch the split tasks
                     taskbarUIController.triggerSecondAppForSplit(info, info.intent, view);
@@ -1049,7 +1033,8 @@
                             getSystemService(LauncherApps.class)
                                     .startShortcut(packageName, id, null, null, info.user);
                         } else {
-                            launchFromTaskbarPreservingSplitIfVisible(recents, info);
+                            launchFromTaskbarPreservingSplitIfVisible(
+                                    recents, Collections.singletonList(info));
                         }
 
                     } catch (NullPointerException
@@ -1082,14 +1067,12 @@
         } else if (tag instanceof AppInfo) {
             // Tapping an item in AllApps
             AppInfo info = (AppInfo) tag;
-            TaskbarUIController taskbarUIController = mControllers.uiController;
-            RecentsView recents = taskbarUIController.getRecentsView();
             if (recents != null
                     && taskbarUIController.getRecentsView().isSplitSelectionActive()) {
                 // If we are selecting a second app for split, launch the split tasks
                 taskbarUIController.triggerSecondAppForSplit(info, info.intent, view);
             } else {
-                launchFromTaskbarPreservingSplitIfVisible(recents, info);
+                launchFromTaskbarPreservingSplitIfVisible(recents, Collections.singletonList(info));
             }
             mControllers.uiController.onTaskbarIconLaunched(info);
             mControllers.taskbarStashController.updateAndAnimateTransientTaskbar(true);
@@ -1111,17 +1094,22 @@
      * (potentially breaking a split pair).
      */
     private void launchFromTaskbarPreservingSplitIfVisible(@Nullable RecentsView recents,
-            ItemInfo info) {
+            List<? extends ItemInfo> itemInfos) {
         if (recents == null) {
             return;
         }
+
+        boolean findExactPairMatch = itemInfos.size() == 2;
+        // Convert the list of ItemInfo instances to a list of ComponentKeys
+        List<ComponentKey> componentKeys =
+                itemInfos.stream().map(ItemInfo::getComponentKey).toList();
         recents.getSplitSelectController().findLastActiveTasksAndRunCallback(
-                Collections.singletonList(info.getComponentKey()),
+                componentKeys,
+                findExactPairMatch,
                 foundTasks -> {
                     @Nullable Task foundTask = foundTasks.get(0);
                     if (foundTask != null) {
-                        TaskView foundTaskView =
-                                recents.getTaskViewByTaskId(foundTask.key.id);
+                        TaskView foundTaskView = recents.getTaskViewByTaskId(foundTask.key.id);
                         if (foundTaskView != null
                                 && foundTaskView.isVisibleToUser()) {
                             TestLogging.recordEvent(
@@ -1130,8 +1118,17 @@
                             return;
                         }
                     }
-                    startItemInfoActivity(info);
-                });
+
+                    if (findExactPairMatch) {
+                        // We did not find the app pair we were looking for, so launch one.
+                        recents.getSplitSelectController().getAppPairsController().launchAppPair(
+                                (WorkspaceItemInfo) itemInfos.get(0),
+                                (WorkspaceItemInfo) itemInfos.get(1));
+                    } else {
+                        startItemInfoActivity(itemInfos.get(0));
+                    }
+                }
+        );
     }
 
     private void startItemInfoActivity(ItemInfo info) {
@@ -1153,6 +1150,41 @@
         }
     }
 
+    /** Expands a folder icon when it is clicked */
+    private void expandFolder(FolderIcon folderIcon) {
+        Folder folder = folderIcon.getFolder();
+
+        folder.setPriorityOnFolderStateChangedListener(
+                new Folder.OnFolderStateChangedListener() {
+                    @Override
+                    public void onFolderStateChanged(int newState) {
+                        if (newState == Folder.STATE_OPEN) {
+                            setTaskbarWindowFocusableForIme(true);
+                        } else if (newState == Folder.STATE_CLOSED) {
+                            // Defer by a frame to ensure we're no longer fullscreen and thus
+                            // won't jump.
+                            getDragLayer().post(() -> setTaskbarWindowFocusableForIme(false));
+                            folder.setPriorityOnFolderStateChangedListener(null);
+                        }
+                    }
+                });
+
+        setTaskbarWindowFullscreen(true);
+
+        getDragLayer().post(() -> {
+            folder.animateOpen();
+            getStatsLogManager().logger().withItemInfo(folder.mInfo).log(LAUNCHER_FOLDER_OPEN);
+
+            folder.iterateOverItems((itemInfo, itemView) -> {
+                mControllers.taskbarViewController
+                        .setClickAndLongClickListenersForIcon(itemView);
+                // To play haptic when dragging, like other Taskbar items do.
+                itemView.setHapticFeedbackEnabled(true);
+                return false;
+            });
+        });
+    }
+
     /**
      * Returns whether the taskbar is currently visually stashed.
      */
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarUIController.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarUIController.java
index aee3c6f..a29a25c 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarUIController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarUIController.java
@@ -216,6 +216,7 @@
 
         recentsView.getSplitSelectController().findLastActiveTasksAndRunCallback(
                 Collections.singletonList(splitSelectSource.itemInfo.getComponentKey()),
+                false /* findExactPairMatch */,
                 foundTasks -> {
                     @Nullable Task foundTask = foundTasks.get(0);
                     splitSelectSource.alreadyRunningTaskId = foundTask == null
@@ -234,6 +235,7 @@
         RecentsView recents = getRecentsView();
         recents.getSplitSelectController().findLastActiveTasksAndRunCallback(
                 Collections.singletonList(info.getComponentKey()),
+                false /* findExactPairMatch */,
                 foundTasks -> {
                     @Nullable Task foundTask = foundTasks.get(0);
                     if (foundTask != null) {
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarView.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarView.java
index bfbc896..2ab0066 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarView.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarView.java
@@ -19,6 +19,8 @@
 import static android.view.accessibility.AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED;
 
 import static com.android.launcher3.Flags.enableCursorHoverStates;
+import static com.android.launcher3.LauncherSettings.Favorites.ITEM_TYPE_APP_PAIR;
+import static com.android.launcher3.LauncherSettings.Favorites.ITEM_TYPE_FOLDER;
 import static com.android.launcher3.config.FeatureFlags.ENABLE_ALL_APPS_SEARCH_IN_TASKBAR;
 import static com.android.launcher3.config.FeatureFlags.enableTaskbarPinning;
 import static com.android.launcher3.icons.IconNormalizer.ICON_VISIBLE_AREA_FACTOR;
@@ -47,6 +49,7 @@
 import com.android.launcher3.Insettable;
 import com.android.launcher3.R;
 import com.android.launcher3.Utilities;
+import com.android.launcher3.apppairs.AppPairIcon;
 import com.android.launcher3.folder.FolderIcon;
 import com.android.launcher3.icons.ThemedIconDrawable;
 import com.android.launcher3.model.data.FolderInfo;
@@ -307,12 +310,14 @@
 
             // Replace any Hotseat views with the appropriate type if it's not already that type.
             final int expectedLayoutResId;
-            boolean isFolder = false;
+            boolean isCollection = false;
             if (hotseatItemInfo.isPredictedItem()) {
                 expectedLayoutResId = R.layout.taskbar_predicted_app_icon;
-            } else if (hotseatItemInfo instanceof FolderInfo) {
-                expectedLayoutResId = R.layout.folder_icon;
-                isFolder = true;
+            } else if (hotseatItemInfo instanceof FolderInfo fi) {
+                expectedLayoutResId = fi.itemType == ITEM_TYPE_APP_PAIR
+                        ? R.layout.app_pair_icon
+                        : R.layout.folder_icon;
+                isCollection = true;
             } else {
                 expectedLayoutResId = R.layout.taskbar_app_icon;
             }
@@ -323,7 +328,7 @@
 
                 // see if the view can be reused
                 if ((hotseatView.getSourceLayoutResId() != expectedLayoutResId)
-                        || (isFolder && (hotseatView.getTag() != hotseatItemInfo))) {
+                        || (isCollection && (hotseatView.getTag() != hotseatItemInfo))) {
                     // Unlike for BubbleTextView, we can't reapply a new FolderInfo after inflation,
                     // so if the info changes we need to reinflate. This should only happen if a new
                     // folder is dragged to the position that another folder previously existed.
@@ -336,12 +341,23 @@
             }
 
             if (hotseatView == null) {
-                if (isFolder) {
+                if (isCollection) {
                     FolderInfo folderInfo = (FolderInfo) hotseatItemInfo;
-                    FolderIcon folderIcon = FolderIcon.inflateFolderAndIcon(expectedLayoutResId,
-                            mActivityContext, this, folderInfo);
-                    folderIcon.setTextVisible(false);
-                    hotseatView = folderIcon;
+                    switch (hotseatItemInfo.itemType) {
+                        case ITEM_TYPE_FOLDER:
+                            hotseatView = FolderIcon.inflateFolderAndIcon(
+                                    expectedLayoutResId, mActivityContext, this, folderInfo);
+                            ((FolderIcon) hotseatView).setTextVisible(false);
+                            break;
+                        case ITEM_TYPE_APP_PAIR:
+                            hotseatView = AppPairIcon.inflateIcon(
+                                    expectedLayoutResId, mActivityContext, this, folderInfo);
+                            ((AppPairIcon) hotseatView).setTextVisible(false);
+                            break;
+                        default:
+                            throw new IllegalStateException(
+                                    "Unexpected item type: " + hotseatItemInfo.itemType);
+                    }
                 } else {
                     hotseatView = inflate(expectedLayoutResId);
                 }
diff --git a/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java b/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java
index 89b7fa4..b685d3c 100644
--- a/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java
+++ b/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java
@@ -642,6 +642,7 @@
         // using that.
         mSplitSelectStateController.findLastActiveTasksAndRunCallback(
                 Collections.singletonList(splitSelectSource.itemInfo.getComponentKey()),
+                false /* findExactPairMatch */,
                 foundTasks -> {
                     @Nullable Task foundTask = foundTasks.get(0);
                     boolean taskWasFound = foundTask != null;
diff --git a/quickstep/src/com/android/quickstep/util/AppPairsController.java b/quickstep/src/com/android/quickstep/util/AppPairsController.java
index cc3b54b..b6a8797 100644
--- a/quickstep/src/com/android/quickstep/util/AppPairsController.java
+++ b/quickstep/src/com/android/quickstep/util/AppPairsController.java
@@ -125,6 +125,7 @@
         ComponentKey app2Key = new ComponentKey(app2.getTargetComponent(), app2.user);
         mSplitSelectStateController.findLastActiveTasksAndRunCallback(
                 Arrays.asList(app1Key, app2Key),
+                false /* findExactPairMatch */,
                 foundTasks -> {
                     @Nullable Task foundTask1 = foundTasks.get(0);
                     Intent task1Intent;
@@ -153,7 +154,8 @@
 
                     mSplitSelectStateController.launchSplitTasks(
                             AppPairsController.convertRankToSnapPosition(app1.rank));
-                });
+                }
+        );
     }
 
     /**
diff --git a/quickstep/src/com/android/quickstep/util/SplitSelectStateController.java b/quickstep/src/com/android/quickstep/util/SplitSelectStateController.java
index 82c4125..24d6d27 100644
--- a/quickstep/src/com/android/quickstep/util/SplitSelectStateController.java
+++ b/quickstep/src/com/android/quickstep/util/SplitSelectStateController.java
@@ -214,15 +214,16 @@
     }
 
     /**
-     * Maps a List<ComponentKey> to List<@Nullable Task>, searching through active Tasks in
-     * RecentsModel. If found, the Task will be the most recently-interacted-with instance of that
-     * Task. Then runs the given callback on that List.
-     * <p>
-     * Used in various task-switching or splitscreen operations when we need to check if there is a
-     * currently running Task of a certain type and use the most recent one.
+     * Given a list of task keys, searches through active Tasks in RecentsModel to find the last
+     * active instances of these tasks. Returns an empty array if there is no such running task.
+     *
+     * @param componentKeys The list of ComponentKeys to search for.
+     * @param callback The callback that will be executed on the list of found tasks.
+     * @param findExactPairMatch If {@code true}, only finds tasks that contain BOTH of the wanted
+     *                           tasks (i.e. searching for a running pair of tasks.)
      */
-    public void findLastActiveTasksAndRunCallback(
-            @Nullable List<ComponentKey> componentKeys, Consumer<List<Task>> callback) {
+    public void findLastActiveTasksAndRunCallback(@Nullable List<ComponentKey> componentKeys,
+            boolean findExactPairMatch, Consumer<List<Task>> callback) {
         mRecentTasksModel.getTasks(taskGroups -> {
             if (componentKeys == null || componentKeys.isEmpty()) {
                 callback.accept(Collections.emptyList());
@@ -230,27 +231,40 @@
             }
 
             List<Task> lastActiveTasks = new ArrayList<>();
-            // For each key we are looking for, add to lastActiveTasks with the corresponding Task
-            // (or null if not found).
-            for (ComponentKey key : componentKeys) {
-                Task lastActiveTask = null;
+
+            if (findExactPairMatch) {
                 // Loop through tasks in reverse, since they are ordered with most-recent tasks last
                 for (int i = taskGroups.size() - 1; i >= 0; i--) {
                     GroupTask groupTask = taskGroups.get(i);
-                    Task task1 = groupTask.task1;
-                    // Don't add duplicate Tasks
-                    if (isInstanceOfComponent(task1, key) && !lastActiveTasks.contains(task1)) {
-                        lastActiveTask = task1;
-                        break;
-                    }
-                    Task task2 = groupTask.task2;
-                    if (isInstanceOfComponent(task2, key) && !lastActiveTasks.contains(task2)) {
-                        lastActiveTask = task2;
+                    if (isInstanceOfAppPair(
+                            groupTask, componentKeys.get(0), componentKeys.get(1))) {
+                        lastActiveTasks.add(groupTask.task1);
                         break;
                     }
                 }
+            } else {
+                // For each key we are looking for, add to lastActiveTasks with the corresponding
+                // Task (or do nothing if not found).
+                for (ComponentKey key : componentKeys) {
+                    Task lastActiveTask = null;
+                    // Loop through tasks in reverse, since they are ordered with recent tasks last
+                    for (int i = taskGroups.size() - 1; i >= 0; i--) {
+                        GroupTask groupTask = taskGroups.get(i);
+                        Task task1 = groupTask.task1;
+                        // Don't add duplicate Tasks
+                        if (isInstanceOfComponent(task1, key) && !lastActiveTasks.contains(task1)) {
+                            lastActiveTask = task1;
+                            break;
+                        }
+                        Task task2 = groupTask.task2;
+                        if (isInstanceOfComponent(task2, key) && !lastActiveTasks.contains(task2)) {
+                            lastActiveTask = task2;
+                            break;
+                        }
+                    }
 
-                lastActiveTasks.add(lastActiveTask);
+                    lastActiveTasks.add(lastActiveTask);
+                }
             }
 
             callback.accept(lastActiveTasks);
@@ -272,6 +286,19 @@
     }
 
     /**
+     * Checks if a given GroupTask is a pair of apps that matches two given ComponentKeys. We check
+     * both permutations because task order is not guaranteed in GroupTasks.
+     */
+    public boolean isInstanceOfAppPair(GroupTask groupTask, @NonNull ComponentKey componentKey1,
+            @NonNull ComponentKey componentKey2) {
+        return ((isInstanceOfComponent(groupTask.task1, componentKey1)
+                && isInstanceOfComponent(groupTask.task2, componentKey2))
+                ||
+                (isInstanceOfComponent(groupTask.task1, componentKey2)
+                        && isInstanceOfComponent(groupTask.task2, componentKey1)));
+    }
+
+    /**
      * Listener will only get callbacks going forward from the point of registration. No
      * methods will be fired upon registering.
      */
diff --git a/quickstep/tests/src/com/android/quickstep/util/SplitSelectStateControllerTest.kt b/quickstep/tests/src/com/android/quickstep/util/SplitSelectStateControllerTest.kt
index f292f9a..f41ac48 100644
--- a/quickstep/tests/src/com/android/quickstep/util/SplitSelectStateControllerTest.kt
+++ b/quickstep/tests/src/com/android/quickstep/util/SplitSelectStateControllerTest.kt
@@ -114,6 +114,7 @@
             argumentCaptor<Consumer<ArrayList<GroupTask>>> {
                     splitSelectStateController.findLastActiveTasksAndRunCallback(
                         listOf(nonMatchingComponent),
+                        false /* findExactPairMatch */,
                         taskConsumer
                     )
                     verify(recentsModel).getTasks(capture())
@@ -166,6 +167,7 @@
             argumentCaptor<Consumer<ArrayList<GroupTask>>> {
                     splitSelectStateController.findLastActiveTasksAndRunCallback(
                         listOf(matchingComponent),
+                        false /* findExactPairMatch */,
                         taskConsumer
                     )
                     verify(recentsModel).getTasks(capture())
@@ -206,6 +208,7 @@
             argumentCaptor<Consumer<ArrayList<GroupTask>>> {
                     splitSelectStateController.findLastActiveTasksAndRunCallback(
                         listOf(nonPrimaryUserComponent),
+                        false /* findExactPairMatch */,
                         taskConsumer
                     )
                     verify(recentsModel).getTasks(capture())
@@ -261,6 +264,7 @@
             argumentCaptor<Consumer<ArrayList<GroupTask>>> {
                     splitSelectStateController.findLastActiveTasksAndRunCallback(
                         listOf(nonPrimaryUserComponent),
+                        false /* findExactPairMatch */,
                         taskConsumer
                     )
                     verify(recentsModel).getTasks(capture())
@@ -313,6 +317,7 @@
             argumentCaptor<Consumer<ArrayList<GroupTask>>> {
                     splitSelectStateController.findLastActiveTasksAndRunCallback(
                         listOf(matchingComponent),
+                        false /* findExactPairMatch */,
                         taskConsumer
                     )
                     verify(recentsModel).getTasks(capture())
@@ -366,6 +371,7 @@
             argumentCaptor<Consumer<ArrayList<GroupTask>>> {
                     splitSelectStateController.findLastActiveTasksAndRunCallback(
                         listOf(nonMatchingComponent, matchingComponent),
+                        false /* findExactPairMatch */,
                         taskConsumer
                     )
                     verify(recentsModel).getTasks(capture())
@@ -418,6 +424,7 @@
             argumentCaptor<Consumer<ArrayList<GroupTask>>> {
                     splitSelectStateController.findLastActiveTasksAndRunCallback(
                         listOf(matchingComponent, matchingComponent),
+                        false /* findExactPairMatch */,
                         taskConsumer
                     )
                     verify(recentsModel).getTasks(capture())
@@ -483,6 +490,59 @@
             argumentCaptor<Consumer<ArrayList<GroupTask>>> {
                     splitSelectStateController.findLastActiveTasksAndRunCallback(
                         listOf(matchingComponent, matchingComponent),
+                        false /* findExactPairMatch */,
+                        taskConsumer
+                    )
+                    verify(recentsModel).getTasks(capture())
+                }
+                .lastValue
+
+        // Send our mocked tasks
+        consumer.accept(tasks)
+    }
+
+    @Test
+    fun activeTasks_multipleSearchShouldFindExactPairMatch() {
+        val matchingPackage = "hotdog"
+        val matchingClass = "juice"
+        val matchingComponent =
+            ComponentKey(ComponentName(matchingPackage, matchingClass), primaryUserHandle)
+        val matchingPackage2 = "pomegranate"
+        val matchingClass2 = "juice"
+        val matchingComponent2 =
+            ComponentKey(ComponentName(matchingPackage2, matchingClass2), primaryUserHandle)
+
+        val groupTask1 =
+            generateGroupTask(ComponentName("hotdog", "pie"), ComponentName("pumpkin", "pie"))
+        val groupTask2 =
+            generateGroupTask(
+                ComponentName(matchingPackage2, matchingClass2),
+                ComponentName(matchingPackage, matchingClass)
+            )
+        val groupTask3 =
+            generateGroupTask(
+                ComponentName("hotdog", "pie"),
+                ComponentName(matchingPackage, matchingClass)
+            )
+        val tasks: ArrayList<GroupTask> = ArrayList()
+        tasks.add(groupTask3)
+        tasks.add(groupTask2)
+        tasks.add(groupTask1)
+
+        // Assertions happen in the callback we get from what we pass into
+        // #findLastActiveTasksAndRunCallback
+        val taskConsumer =
+            Consumer<List<Task>> {
+                assertEquals("Expected array length 1", 1, it.size)
+                assertEquals("Found wrong task", it[0], groupTask2.task1)
+            }
+
+        // Capture callback from recentsModel#getTasks()
+        val consumer =
+            argumentCaptor<Consumer<ArrayList<GroupTask>>> {
+                    splitSelectStateController.findLastActiveTasksAndRunCallback(
+                        listOf(matchingComponent2, matchingComponent),
+                        true /* findExactPairMatch */,
                         taskConsumer
                     )
                     verify(recentsModel).getTasks(capture())
diff --git a/src/com/android/launcher3/apppairs/AppPairIcon.java b/src/com/android/launcher3/apppairs/AppPairIcon.java
index 4cf6471..46932fb 100644
--- a/src/com/android/launcher3/apppairs/AppPairIcon.java
+++ b/src/com/android/launcher3/apppairs/AppPairIcon.java
@@ -30,9 +30,11 @@
 import com.android.launcher3.BubbleTextView;
 import com.android.launcher3.DeviceProfile;
 import com.android.launcher3.R;
+import com.android.launcher3.Reorderable;
 import com.android.launcher3.dragndrop.DraggableView;
 import com.android.launcher3.model.data.FolderInfo;
 import com.android.launcher3.model.data.WorkspaceItemInfo;
+import com.android.launcher3.util.MultiTranslateDelegate;
 import com.android.launcher3.views.ActivityContext;
 
 import java.util.Collections;
@@ -44,7 +46,7 @@
  * The app pair icon is two parallel background rectangles with rounded corners. Icons of the two
  * member apps are set into these rectangles.
  */
-public class AppPairIcon extends FrameLayout implements DraggableView {
+public class AppPairIcon extends FrameLayout implements DraggableView, Reorderable {
     /**
      * Design specs -- the below ratios are in relation to the size of a standard app icon.
      */
@@ -77,6 +79,10 @@
     // The underlying ItemInfo that stores info about the app pair members, etc.
     private FolderInfo mInfo;
 
+    // Required for Reorderable -- handles translation and bouncing movements
+    private final MultiTranslateDelegate mTranslateDelegate = new MultiTranslateDelegate(this);
+    private float mScaleForReorderBounce = 1f;
+
     public AppPairIcon(Context context, AttributeSet attrs) {
         super(context, attrs);
     }
@@ -207,16 +213,47 @@
         return getContext().getString(R.string.app_pair_name_format, app1, app2);
     }
 
+    // Required for DraggableView
     @Override
     public int getViewType() {
         return DRAGGABLE_ICON;
     }
 
+    // Required for DraggableView
     @Override
     public void getWorkspaceVisualDragBounds(Rect bounds) {
         mAppPairName.getIconBounds(bounds);
     }
 
+    /** Sets the visibility of the icon's title text */
+    public void setTextVisible(boolean visible) {
+        if (visible) {
+            mAppPairName.setVisibility(VISIBLE);
+        } else {
+            mAppPairName.setVisibility(INVISIBLE);
+        }
+    }
+
+    // Required for Reorderable
+    @Override
+    public MultiTranslateDelegate getTranslateDelegate() {
+        return mTranslateDelegate;
+    }
+
+    // Required for Reorderable
+    @Override
+    public void setReorderBounceScale(float scale) {
+        mScaleForReorderBounce = scale;
+        super.setScaleX(scale);
+        super.setScaleY(scale);
+    }
+
+    // Required for Reorderable
+    @Override
+    public float getReorderBounceScale() {
+        return mScaleForReorderBounce;
+    }
+
     public FolderInfo getInfo() {
         return mInfo;
     }
diff --git a/src/com/android/launcher3/folder/FolderIcon.java b/src/com/android/launcher3/folder/FolderIcon.java
index cb1dc4f..2cea974 100644
--- a/src/com/android/launcher3/folder/FolderIcon.java
+++ b/src/com/android/launcher3/folder/FolderIcon.java
@@ -86,7 +86,6 @@
 import java.util.List;
 import java.util.function.Predicate;
 
-
 /**
  * An icon that can appear on in the workspace representing an {@link Folder}.
  */
@@ -634,6 +633,7 @@
         }
     }
 
+    /** Sets the visibility of the icon's title text */
     public void setTextVisible(boolean visible) {
         if (visible) {
             mFolderName.setVisibility(VISIBLE);
diff --git a/src/com/android/launcher3/model/data/FolderInfo.java b/src/com/android/launcher3/model/data/FolderInfo.java
index 9bf6d43..5b541d0 100644
--- a/src/com/android/launcher3/model/data/FolderInfo.java
+++ b/src/com/android/launcher3/model/data/FolderInfo.java
@@ -46,7 +46,6 @@
 import java.util.OptionalInt;
 import java.util.stream.IntStream;
 
-
 /**
  * Represents a folder containing shortcuts or apps.
  */