App Pairs: Implement save, inflate, launch, and delete

This is the third of several patches implementing the App Pairs feature behind a flag.

This patch includes:
- AppPairIcon and associated XML. Actual icon asset is placeholder for now
- Ability to launch split pair on click
- Icon can be moved around, incl. to Taskbar
- App pair can be deleted by dragging to "Remove" drop zone
- Icon persists on Launcher reload

Change-Id: I88aec6fbc814be98f9ef048bbc5af889d0797970
Flag: ENABLE_APP_PAIRS (set to false)
Bug: 274835596
Test: Not included in this CL, but will follow
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java
index 42cb290..9810ab9 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java
@@ -117,6 +117,7 @@
 import com.android.systemui.unfold.util.ScopedUnfoldTransitionProgressProvider;
 
 import java.io.PrintWriter;
+import java.util.Collections;
 import java.util.Optional;
 
 /**
@@ -992,9 +993,10 @@
         if (recents == null) {
             return;
         }
-        recents.getSplitSelectController().findLastActiveTaskAndRunCallback(
-                info.getComponentKey(),
-                foundTask -> {
+        recents.getSplitSelectController().findLastActiveTasksAndRunCallback(
+                Collections.singletonList(info.getComponentKey()),
+                foundTasks -> {
+                    @Nullable Task foundTask = foundTasks.get(0);
                     if (foundTask != null) {
                         TaskView foundTaskView =
                                 recents.getTaskViewByTaskId(foundTask.key.id);
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarUIController.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarUIController.java
index 7154731..6fad655 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarUIController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarUIController.java
@@ -40,8 +40,11 @@
 import com.android.quickstep.views.RecentsView;
 import com.android.quickstep.views.TaskView;
 import com.android.quickstep.views.TaskView.TaskIdAttributeContainer;
+import com.android.systemui.shared.recents.model.Task;
 
 import java.io.PrintWriter;
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.stream.Stream;
 
 /**
@@ -204,9 +207,10 @@
             return;
         }
 
-        recentsView.getSplitSelectController().findLastActiveTaskAndRunCallback(
-                splitSelectSource.itemInfo.getComponentKey(),
-                foundTask -> {
+        recentsView.getSplitSelectController().findLastActiveTasksAndRunCallback(
+                Collections.singletonList(splitSelectSource.itemInfo.getComponentKey()),
+                foundTasks -> {
+                    @Nullable Task foundTask = foundTasks.get(0);
                     splitSelectSource.alreadyRunningTaskId = foundTask == null
                             ? INVALID_TASK_ID
                             : foundTask.key.id;
@@ -221,9 +225,10 @@
      */
     public void triggerSecondAppForSplit(ItemInfoWithIcon info, Intent intent, View startingView) {
         RecentsView recents = getRecentsView();
-        recents.getSplitSelectController().findLastActiveTaskAndRunCallback(
-                info.getComponentKey(),
-                foundTask -> {
+        recents.getSplitSelectController().findLastActiveTasksAndRunCallback(
+                Collections.singletonList(info.getComponentKey()),
+                foundTasks -> {
+                    @Nullable Task foundTask = foundTasks.get(0);
                     if (foundTask != null) {
                         TaskView foundTaskView = recents.getTaskViewByTaskId(foundTask.key.id);
                         // TODO (b/266482558): This additional null check is needed because there
diff --git a/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java b/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java
index ffd22b8..f90b210 100644
--- a/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java
+++ b/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java
@@ -117,6 +117,7 @@
 import com.android.launcher3.model.BgDataModel.FixedContainerItems;
 import com.android.launcher3.model.WellbeingModel;
 import com.android.launcher3.model.data.ItemInfo;
+import com.android.launcher3.model.data.WorkspaceItemInfo;
 import com.android.launcher3.popup.SystemShortcut;
 import com.android.launcher3.proxy.ProxyActivityStarter;
 import com.android.launcher3.statehandlers.DepthController;
@@ -173,6 +174,7 @@
 import com.android.quickstep.views.OverviewActionsView;
 import com.android.quickstep.views.RecentsView;
 import com.android.quickstep.views.TaskView;
+import com.android.systemui.shared.recents.model.Task;
 import com.android.systemui.shared.system.ActivityManagerWrapper;
 import com.android.systemui.unfold.RemoteUnfoldSharedComponent;
 import com.android.systemui.unfold.UnfoldSharedComponent;
@@ -618,9 +620,10 @@
         RecentsView recentsView = getOverviewPanel();
         // Check if there is already an instance of this app running, if so, initiate the split
         // using that.
-        mSplitSelectStateController.findLastActiveTaskAndRunCallback(
-                splitSelectSource.itemInfo.getComponentKey(),
-                foundTask -> {
+        mSplitSelectStateController.findLastActiveTasksAndRunCallback(
+                Collections.singletonList(splitSelectSource.itemInfo.getComponentKey()),
+                foundTasks -> {
+                    @Nullable Task foundTask = foundTasks.get(0);
                     boolean taskWasFound = foundTask != null;
                     splitSelectSource.alreadyRunningTaskId = taskWasFound
                             ? foundTask.key.id
@@ -1324,6 +1327,13 @@
                                 : groupTask.mSplitBounds.leftTaskPercent);
     }
 
+    /**
+     * Launches two apps as an app pair.
+     */
+    public void launchAppPair(WorkspaceItemInfo app1, WorkspaceItemInfo app2) {
+        mSplitSelectStateController.getAppPairsController().launchAppPair(app1, app2);
+    }
+
     public boolean canStartHomeSafely() {
         OverviewCommandHelper overviewCommandHelper = mTISBindHelper.getOverviewCommandHelper();
         return overviewCommandHelper == null || overviewCommandHelper.canStartHomeSafely();
diff --git a/quickstep/src/com/android/quickstep/TaskShortcutFactory.java b/quickstep/src/com/android/quickstep/TaskShortcutFactory.java
index 56f407c..901690b 100644
--- a/quickstep/src/com/android/quickstep/TaskShortcutFactory.java
+++ b/quickstep/src/com/android/quickstep/TaskShortcutFactory.java
@@ -140,6 +140,7 @@
 
         @Override
         public void onClick(View view) {
+            dismissTaskMenuView(mTarget);
             ((RecentsView) mTarget.getOverviewPanel())
                     .getSplitSelectController().getAppPairsController().saveAppPair(mTaskView);
         }
diff --git a/quickstep/src/com/android/quickstep/util/AppPairsController.java b/quickstep/src/com/android/quickstep/util/AppPairsController.java
index cbde257..1a7099d 100644
--- a/quickstep/src/com/android/quickstep/util/AppPairsController.java
+++ b/quickstep/src/com/android/quickstep/util/AppPairsController.java
@@ -17,19 +17,30 @@
 
 package com.android.quickstep.util;
 
+import static com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_APP_PAIR_LAUNCH;
 import static com.android.launcher3.util.Executors.MAIN_EXECUTOR;
 import static com.android.launcher3.util.Executors.MODEL_EXECUTOR;
 
+import android.app.ActivityTaskManager;
 import android.content.Context;
+import android.content.Intent;
+
+import androidx.annotation.Nullable;
 
 import com.android.launcher3.Launcher;
 import com.android.launcher3.LauncherAppState;
 import com.android.launcher3.LauncherSettings;
 import com.android.launcher3.accessibility.LauncherAccessibilityDelegate;
 import com.android.launcher3.icons.IconCache;
+import com.android.launcher3.logging.StatsLogManager;
 import com.android.launcher3.model.data.FolderInfo;
 import com.android.launcher3.model.data.WorkspaceItemInfo;
+import com.android.launcher3.util.ComponentKey;
+import com.android.launcher3.util.SplitConfigurationOptions;
 import com.android.quickstep.views.TaskView;
+import com.android.systemui.shared.recents.model.Task;
+
+import java.util.Arrays;
 
 /**
  * Mini controller class that handles app pair interactions: saving, modifying, deleting, etc.
@@ -52,10 +63,13 @@
 
     private final Context mContext;
     private final SplitSelectStateController mSplitSelectStateController;
+    private final StatsLogManager mStatsLogManager;
     public AppPairsController(Context context,
-            SplitSelectStateController splitSelectStateController) {
+            SplitSelectStateController splitSelectStateController,
+            StatsLogManager statsLogManager) {
         mContext = context;
         mSplitSelectStateController = splitSelectStateController;
+        mStatsLogManager = statsLogManager;
     }
 
     /**
@@ -84,11 +98,51 @@
                 LauncherAccessibilityDelegate delegate =
                         Launcher.getLauncher(mContext).getAccessibilityDelegate();
                 if (delegate != null) {
-                    MAIN_EXECUTOR.execute(() -> delegate.addToWorkspace(newAppPair, true));
+                    delegate.addToWorkspace(newAppPair, true);
+                    mStatsLogManager.logger().withItemInfo(newAppPair)
+                            .log(StatsLogManager.LauncherEvent.LAUNCHER_APP_PAIR_SAVE);
                 }
             });
         });
+    }
 
+    /**
+     * Launches an app pair by searching the RecentsModel for running instances of each app, and
+     * staging either those running instances or launching the apps as new Intents.
+     */
+    public void launchAppPair(WorkspaceItemInfo app1, WorkspaceItemInfo app2) {
+        ComponentKey app1Key = new ComponentKey(app1.getTargetComponent(), app1.user);
+        ComponentKey app2Key = new ComponentKey(app2.getTargetComponent(), app2.user);
+        mSplitSelectStateController.findLastActiveTasksAndRunCallback(
+                Arrays.asList(app1Key, app2Key),
+                foundTasks -> {
+                    @Nullable Task foundTask1 = foundTasks.get(0);
+                    Intent task1Intent;
+                    int task1Id;
+                    if (foundTask1 != null) {
+                        task1Id = foundTask1.key.id;
+                        task1Intent = null;
+                    } else {
+                        task1Id = ActivityTaskManager.INVALID_TASK_ID;
+                        task1Intent = app1.intent;
+                    }
+
+                    mSplitSelectStateController.setInitialTaskSelect(task1Intent,
+                            SplitConfigurationOptions.STAGE_POSITION_TOP_OR_LEFT,
+                            app1,
+                            LAUNCHER_APP_PAIR_LAUNCH,
+                            task1Id);
+
+                    @Nullable Task foundTask2 = foundTasks.get(1);
+                    if (foundTask2 != null) {
+                        mSplitSelectStateController.setSecondTask(foundTask2);
+                    } else {
+                        mSplitSelectStateController.setSecondTask(
+                                app2.intent, app2.user);
+                    }
+
+                    mSplitSelectStateController.launchSplitTasks();
+                });
     }
 
     /**
diff --git a/quickstep/src/com/android/quickstep/util/SplitSelectStateController.java b/quickstep/src/com/android/quickstep/util/SplitSelectStateController.java
index 7ba6d42..c42b834 100644
--- a/quickstep/src/com/android/quickstep/util/SplitSelectStateController.java
+++ b/quickstep/src/com/android/quickstep/util/SplitSelectStateController.java
@@ -78,6 +78,7 @@
 
 import java.io.PrintWriter;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 import java.util.function.Consumer;
 
@@ -126,7 +127,7 @@
         mDepthController = depthController;
         mRecentTasksModel = recentsModel;
         mSplitAnimationController = new SplitAnimationController(this);
-        mAppPairsController = new AppPairsController(context, this);
+        mAppPairsController = new AppPairsController(context, this, statsLogManager);
         mSplitSelectDataHolder = new SplitSelectDataHolder(mContext);
     }
 
@@ -153,37 +154,46 @@
     }
 
     /**
-     * Pulls the list of active Tasks from RecentsModel, and finds the most recently active Task
-     * matching a given ComponentName. Then uses that Task (which could be null) with the given
-     * callback.
+     * 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.
      */
-    public void findLastActiveTaskAndRunCallback(
-            @Nullable ComponentKey componentKey, Consumer<Task> callback) {
+    public void findLastActiveTasksAndRunCallback(
+            @Nullable List<ComponentKey> componentKeys, Consumer<List<Task>> callback) {
         mRecentTasksModel.getTasks(taskGroups -> {
-            if (componentKey == null) {
-                callback.accept(null);
+            if (componentKeys == null || componentKeys.isEmpty()) {
+                callback.accept(Collections.emptyList());
                 return;
             }
-            Task lastActiveTask = null;
-            // 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;
-                if (isInstanceOfComponent(task1, componentKey)) {
-                    lastActiveTask = task1;
-                    break;
+
+            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;
+                // 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;
+                        break;
+                    }
                 }
-                Task task2 = groupTask.task2;
-                if (isInstanceOfComponent(task2, componentKey)) {
-                    lastActiveTask = task2;
-                    break;
-                }
+
+                lastActiveTasks.add(lastActiveTask);
             }
 
-            callback.accept(lastActiveTask);
+            callback.accept(lastActiveTasks);
         });
     }
 
@@ -226,7 +236,7 @@
      * To be called when the both split tasks are ready to be launched. Call after launcher side
      * animations are complete.
      */
-    public void launchSplitTasks(Consumer<Boolean> callback) {
+    public void launchSplitTasks(@Nullable Consumer<Boolean> callback) {
         Pair<InstanceId, com.android.launcher3.logging.InstanceId> instanceIds =
                 LogUtils.getShellShareableInstanceId();
         launchTasks(callback, false /* freezeTaskList */, DEFAULT_SPLIT_RATIO,
@@ -239,6 +249,14 @@
     }
 
     /**
+     * A version of {@link #launchTasks(Consumer, boolean, float, InstanceId)} with no success
+     * callback.
+     */
+    public void launchSplitTasks() {
+        launchSplitTasks(null);
+    }
+
+    /**
      * To be called as soon as user selects the second task (even if animations aren't complete)
      * @param task The second task that will be launched.
      */
@@ -271,8 +289,8 @@
      *                   create a split instance, null for cases that bring existing instaces to the
      *                   foreground (quickswitch, launching previous pairs from overview)
      */
-    public void launchTasks(Consumer<Boolean> callback, boolean freezeTaskList, float splitRatio,
-            @Nullable InstanceId shellInstanceId) {
+    public void launchTasks(@Nullable Consumer<Boolean> callback, boolean freezeTaskList,
+            float splitRatio, @Nullable InstanceId shellInstanceId) {
         TestLogging.recordEvent(
                 TestProtocol.SEQUENCE_MAIN, "launchSplitTasks");
         final ActivityOptions options1 = ActivityOptions.makeBasic();
@@ -457,7 +475,7 @@
     }
 
     private RemoteTransition getShellRemoteTransition(int firstTaskId, int secondTaskId,
-            Consumer<Boolean> callback, String transitionName) {
+            @Nullable Consumer<Boolean> callback, String transitionName) {
         final RemoteSplitLaunchTransitionRunner animationRunner =
                 new RemoteSplitLaunchTransitionRunner(firstTaskId, secondTaskId, callback);
         return new RemoteTransition(animationRunner,
@@ -465,7 +483,7 @@
     }
 
     private RemoteAnimationAdapter getLegacyRemoteAdapter(int firstTaskId, int secondTaskId,
-            Consumer<Boolean> callback) {
+            @Nullable Consumer<Boolean> callback) {
         final RemoteSplitLaunchAnimationRunner animationRunner =
                 new RemoteSplitLaunchAnimationRunner(firstTaskId, secondTaskId, callback);
         return new RemoteAnimationAdapter(animationRunner, 300, 150,
@@ -514,7 +532,7 @@
         private final Consumer<Boolean> mSuccessCallback;
 
         RemoteSplitLaunchTransitionRunner(int initialTaskId, int secondTaskId,
-                Consumer<Boolean> callback) {
+                @Nullable Consumer<Boolean> callback) {
             mInitialTaskId = initialTaskId;
             mSecondTaskId = secondTaskId;
             mSuccessCallback = callback;
@@ -563,7 +581,7 @@
         private final Consumer<Boolean> mSuccessCallback;
 
         RemoteSplitLaunchAnimationRunner(int initialTaskId, int secondTaskId,
-                Consumer<Boolean> successCallback) {
+                @Nullable Consumer<Boolean> successCallback) {
             mInitialTaskId = initialTaskId;
             mSecondTaskId = secondTaskId;
             mSuccessCallback = successCallback;
diff --git a/quickstep/tests/src/com/android/quickstep/util/SplitSelectStateControllerTest.kt b/quickstep/tests/src/com/android/quickstep/util/SplitSelectStateControllerTest.kt
index 65542cf..69109c2 100644
--- a/quickstep/tests/src/com/android/quickstep/util/SplitSelectStateControllerTest.kt
+++ b/quickstep/tests/src/com/android/quickstep/util/SplitSelectStateControllerTest.kt
@@ -37,6 +37,7 @@
 import com.android.quickstep.RecentsModel
 import com.android.quickstep.SystemUiProxy
 import com.android.systemui.shared.recents.model.Task
+import java.util.function.Consumer
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertFalse
 import org.junit.Assert.assertNull
@@ -48,7 +49,6 @@
 import org.mockito.Mockito.verify
 import org.mockito.Mockito.`when`
 import org.mockito.MockitoAnnotations
-import java.util.function.Consumer
 
 @RunWith(AndroidJUnit4::class)
 class SplitSelectStateControllerTest {
@@ -67,6 +67,9 @@
     private val primaryUserHandle = UserHandle(ActivityManager.RunningTaskInfo().userId)
     private val nonPrimaryUserHandle = UserHandle(ActivityManager.RunningTaskInfo().userId + 10)
 
+    private var taskIdCounter = 0
+    private fun getUniqueId(): Int { return ++taskIdCounter }
+
     @Before
     fun setup() {
         MockitoAnnotations.initMocks(this)
@@ -100,15 +103,15 @@
         tasks.add(groupTask2)
 
         // Assertions happen in the callback we get from what we pass into
-        // #findLastActiveTaskAndRunCallback
+        // #findLastActiveTasksAndRunCallback
         val taskConsumer =
-            Consumer<Task> { assertNull("No tasks should have matched", it /*task*/) }
+            Consumer<List<Task>> { assertNull("No tasks should have matched", it[0] /*task*/) }
 
         // Capture callback from recentsModel#getTasks()
         val consumer =
             withArgCaptor<Consumer<ArrayList<GroupTask>>> {
-                splitSelectStateController.findLastActiveTaskAndRunCallback(
-                    nonMatchingComponent,
+                splitSelectStateController.findLastActiveTasksAndRunCallback(
+                    listOf(nonMatchingComponent),
                     taskConsumer
                 )
                 verify(recentsModel).getTasks(capture())
@@ -139,27 +142,27 @@
         tasks.add(groupTask2)
 
         // Assertions happen in the callback we get from what we pass into
-        // #findLastActiveTaskAndRunCallback
+        // #findLastActiveTasksAndRunCallback
         val taskConsumer =
-            Consumer<Task> {
+            Consumer<List<Task>> {
                 assertEquals(
                     "ComponentName package mismatched",
-                    it.key.baseIntent.component.packageName,
+                    it[0].key.baseIntent.component?.packageName,
                     matchingPackage
                 )
                 assertEquals(
                     "ComponentName class mismatched",
-                    it.key.baseIntent.component.className,
+                    it[0].key.baseIntent.component?.className,
                     matchingClass
                 )
-                assertEquals(it, groupTask1.task1)
+                assertEquals(it[0], groupTask1.task1)
             }
 
         // Capture callback from recentsModel#getTasks()
         val consumer =
             withArgCaptor<Consumer<ArrayList<GroupTask>>> {
-                splitSelectStateController.findLastActiveTaskAndRunCallback(
-                    matchingComponent,
+                splitSelectStateController.findLastActiveTasksAndRunCallback(
+                    listOf(matchingComponent),
                     taskConsumer
                 )
                 verify(recentsModel).getTasks(capture())
@@ -190,15 +193,15 @@
         tasks.add(groupTask2)
 
         // Assertions happen in the callback we get from what we pass into
-        // #findLastActiveTaskAndRunCallback
+        // #findLastActiveTasksAndRunCallback
         val taskConsumer =
-            Consumer<Task> { assertNull("No tasks should have matched", it /*task*/) }
+            Consumer<List<Task>> { assertNull("No tasks should have matched", it[0] /*task*/) }
 
         // Capture callback from recentsModel#getTasks()
         val consumer =
             withArgCaptor<Consumer<ArrayList<GroupTask>>> {
-                splitSelectStateController.findLastActiveTaskAndRunCallback(
-                    nonPrimaryUserComponent,
+                splitSelectStateController.findLastActiveTasksAndRunCallback(
+                    listOf(nonPrimaryUserComponent),
                     taskConsumer
                 )
                 verify(recentsModel).getTasks(capture())
@@ -231,28 +234,28 @@
         tasks.add(groupTask2)
 
         // Assertions happen in the callback we get from what we pass into
-        // #findLastActiveTaskAndRunCallback
+        // #findLastActiveTasksAndRunCallback
         val taskConsumer =
-            Consumer<Task> {
+            Consumer<List<Task>> {
                 assertEquals(
                     "ComponentName package mismatched",
-                    it.key.baseIntent.component.packageName,
+                    it[0].key.baseIntent.component?.packageName,
                     matchingPackage
                 )
                 assertEquals(
                     "ComponentName class mismatched",
-                    it.key.baseIntent.component.className,
+                    it[0].key.baseIntent.component?.className,
                     matchingClass
                 )
-                assertEquals("userId mismatched", it.key.userId, nonPrimaryUserHandle.identifier)
-                assertEquals(it, groupTask1.task1)
+                assertEquals("userId mismatched", it[0].key.userId, nonPrimaryUserHandle.identifier)
+                assertEquals(it[0], groupTask1.task1)
             }
 
         // Capture callback from recentsModel#getTasks()
         val consumer =
             withArgCaptor<Consumer<ArrayList<GroupTask>>> {
-                splitSelectStateController.findLastActiveTaskAndRunCallback(
-                    nonPrimaryUserComponent,
+                splitSelectStateController.findLastActiveTasksAndRunCallback(
+                    listOf(nonPrimaryUserComponent),
                     taskConsumer
                 )
                 verify(recentsModel).getTasks(capture())
@@ -283,27 +286,200 @@
         tasks.add(groupTask1)
 
         // Assertions happen in the callback we get from what we pass into
-        // #findLastActiveTaskAndRunCallback
+        // #findLastActiveTasksAndRunCallback
         val taskConsumer =
-            Consumer<Task> {
+            Consumer<List<Task>> {
                 assertEquals(
                     "ComponentName package mismatched",
-                    it.key.baseIntent.component.packageName,
+                    it[0].key.baseIntent.component?.packageName,
                     matchingPackage
                 )
                 assertEquals(
                     "ComponentName class mismatched",
-                    it.key.baseIntent.component.className,
+                    it[0].key.baseIntent.component?.className,
                     matchingClass
                 )
-                assertEquals(it, groupTask2.task2)
+                assertEquals(it[0], groupTask1.task1)
             }
 
         // Capture callback from recentsModel#getTasks()
         val consumer =
             withArgCaptor<Consumer<ArrayList<GroupTask>>> {
-                splitSelectStateController.findLastActiveTaskAndRunCallback(
-                    matchingComponent,
+                splitSelectStateController.findLastActiveTasksAndRunCallback(
+                    listOf(matchingComponent),
+                    taskConsumer
+                )
+                verify(recentsModel).getTasks(capture())
+            }
+
+        // Send our mocked tasks
+        consumer.accept(tasks)
+    }
+
+    @Test
+    fun activeTasks_multipleSearchShouldFindTask() {
+        val nonMatchingComponent = ComponentKey(ComponentName("no", "match"), primaryUserHandle)
+        val matchingPackage = "hotdog"
+        val matchingClass = "juice"
+        val matchingComponent =
+            ComponentKey(ComponentName(matchingPackage, matchingClass), primaryUserHandle)
+
+        val groupTask1 =
+            generateGroupTask(
+                ComponentName("hotdog", "pie"),
+                ComponentName("pumpkin", "pie")
+            )
+        val groupTask2 =
+            generateGroupTask(
+                ComponentName("pomegranate", "juice"),
+                ComponentName(matchingPackage, matchingClass)
+            )
+        val tasks: ArrayList<GroupTask> = ArrayList()
+        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 2", 2, it.size)
+                assertNull("No tasks should have matched", it[0] /*task*/)
+                assertEquals(
+                    "ComponentName package mismatched",
+                    it[1].key.baseIntent.component?.packageName,
+                    matchingPackage
+                )
+                assertEquals(
+                    "ComponentName class mismatched",
+                    it[1].key.baseIntent.component?.className,
+                    matchingClass
+                )
+                assertEquals(it[1], groupTask2.task2)
+            }
+
+        // Capture callback from recentsModel#getTasks()
+        val consumer =
+            withArgCaptor<Consumer<ArrayList<GroupTask>>> {
+                splitSelectStateController.findLastActiveTasksAndRunCallback(
+                    listOf(nonMatchingComponent, matchingComponent),
+                    taskConsumer
+                )
+                verify(recentsModel).getTasks(capture())
+            }
+
+        // Send our mocked tasks
+        consumer.accept(tasks)
+    }
+
+    @Test
+    fun activeTasks_multipleSearchShouldNotFindSameTaskTwice() {
+        val matchingPackage = "hotdog"
+        val matchingClass = "juice"
+        val matchingComponent =
+            ComponentKey(ComponentName(matchingPackage, matchingClass), primaryUserHandle)
+
+        val groupTask1 =
+            generateGroupTask(
+                ComponentName("hotdog", "pie"),
+                ComponentName("pumpkin", "pie")
+            )
+        val groupTask2 =
+            generateGroupTask(
+                ComponentName("pomegranate", "juice"),
+                ComponentName(matchingPackage, matchingClass)
+            )
+        val tasks: ArrayList<GroupTask> = ArrayList()
+        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 2", 2, it.size)
+                assertEquals(
+                    "ComponentName package mismatched",
+                    it[0].key.baseIntent.component?.packageName,
+                    matchingPackage
+                )
+                assertEquals(
+                    "ComponentName class mismatched",
+                    it[0].key.baseIntent.component?.className,
+                    matchingClass
+                )
+                assertEquals(it[0], groupTask2.task2)
+                assertNull("No tasks should have matched", it[1] /*task*/)
+            }
+
+        // Capture callback from recentsModel#getTasks()
+        val consumer =
+            withArgCaptor<Consumer<ArrayList<GroupTask>>> {
+                splitSelectStateController.findLastActiveTasksAndRunCallback(
+                    listOf(matchingComponent, matchingComponent),
+                    taskConsumer
+                )
+                verify(recentsModel).getTasks(capture())
+            }
+
+        // Send our mocked tasks
+        consumer.accept(tasks)
+    }
+
+    @Test
+    fun activeTasks_multipleSearchShouldFindDifferentInstancesOfSameTask() {
+        val matchingPackage = "hotdog"
+        val matchingClass = "juice"
+        val matchingComponent =
+            ComponentKey(ComponentName(matchingPackage, matchingClass), primaryUserHandle)
+
+        val groupTask1 =
+            generateGroupTask(
+                ComponentName(matchingPackage, matchingClass),
+                ComponentName("pumpkin", "pie")
+            )
+        val groupTask2 =
+            generateGroupTask(
+                ComponentName("pomegranate", "juice"),
+                ComponentName(matchingPackage, matchingClass)
+            )
+        val tasks: ArrayList<GroupTask> = ArrayList()
+        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 2", 2, it.size)
+                assertEquals(
+                    "ComponentName package mismatched",
+                    it[0].key.baseIntent.component?.packageName,
+                    matchingPackage
+                )
+                assertEquals(
+                    "ComponentName class mismatched",
+                    it[0].key.baseIntent.component?.className,
+                    matchingClass
+                )
+                assertEquals(it[0], groupTask1.task1)
+                assertEquals(
+                    "ComponentName package mismatched",
+                    it[1].key.baseIntent.component?.packageName,
+                    matchingPackage
+                )
+                assertEquals(
+                    "ComponentName class mismatched",
+                    it[1].key.baseIntent.component?.className,
+                    matchingClass
+                )
+                assertEquals(it[1], groupTask2.task2)
+            }
+
+        // Capture callback from recentsModel#getTasks()
+        val consumer =
+            withArgCaptor<Consumer<ArrayList<GroupTask>>> {
+                splitSelectStateController.findLastActiveTasksAndRunCallback(
+                    listOf(matchingComponent, matchingComponent),
                     taskConsumer
                 )
                 verify(recentsModel).getTasks(capture())
@@ -366,6 +542,7 @@
     ): GroupTask {
         val task1 = Task()
         var taskInfo = ActivityManager.RunningTaskInfo()
+        taskInfo.taskId = getUniqueId()
         var intent = Intent()
         intent.component = task1ComponentName
         taskInfo.baseIntent = intent
@@ -373,6 +550,7 @@
 
         val task2 = Task()
         taskInfo = ActivityManager.RunningTaskInfo()
+        taskInfo.taskId = getUniqueId()
         intent = Intent()
         intent.component = task2ComponentName
         taskInfo.baseIntent = intent
@@ -393,6 +571,7 @@
     ): GroupTask {
         val task1 = Task()
         var taskInfo = ActivityManager.RunningTaskInfo()
+        taskInfo.taskId = getUniqueId()
         // Apply custom userHandle1
         taskInfo.userId = userHandle1.identifier
         var intent = Intent()
@@ -401,6 +580,7 @@
         task1.key = Task.TaskKey(taskInfo)
         val task2 = Task()
         taskInfo = ActivityManager.RunningTaskInfo()
+        taskInfo.taskId = getUniqueId()
         // Apply custom userHandle2
         taskInfo.userId = userHandle2.identifier
         intent = Intent()
diff --git a/res/layout/app_pair_icon.xml b/res/layout/app_pair_icon.xml
new file mode 100644
index 0000000..2b9a98b
--- /dev/null
+++ b/res/layout/app_pair_icon.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2023 The Android Open Source Project
+
+     Licensed under the Apache License, Version 2.0 (the "License");
+     you may not use this file except in compliance with the License.
+     You may obtain a copy of the License at
+
+          http://www.apache.org/licenses/LICENSE-2.0
+
+     Unless required by applicable law or agreed to in writing, software
+     distributed under the License is distributed on an "AS IS" BASIS,
+     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+     See the License for the specific language governing permissions and
+     limitations under the License.
+-->
+
+<com.android.launcher3.apppairs.AppPairIcon
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:orientation="vertical"
+    android:focusable="true" >
+    <com.android.launcher3.views.DoubleShadowBubbleTextView
+        style="@style/BaseIcon.Workspace"
+        android:id="@+id/app_pair_icon_name"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:focusable="false"
+        android:layout_gravity="top" />
+</com.android.launcher3.apppairs.AppPairIcon>
diff --git a/src/com/android/launcher3/Launcher.java b/src/com/android/launcher3/Launcher.java
index bfbd660..93c0e7e 100644
--- a/src/com/android/launcher3/Launcher.java
+++ b/src/com/android/launcher3/Launcher.java
@@ -145,6 +145,7 @@
 import com.android.launcher3.allapps.BaseSearchConfig;
 import com.android.launcher3.allapps.DiscoveryBounce;
 import com.android.launcher3.anim.PropertyListBuilder;
+import com.android.launcher3.apppairs.AppPairIcon;
 import com.android.launcher3.celllayout.CellPosMapper;
 import com.android.launcher3.celllayout.CellPosMapper.CellPos;
 import com.android.launcher3.celllayout.CellPosMapper.TwoPanelCellPosMapper;
@@ -2445,9 +2446,9 @@
                     break;
                 }
                 case LauncherSettings.Favorites.ITEM_TYPE_APP_PAIR: {
-                    FolderInfo info = (FolderInfo) item;
-                    // TODO (jeremysim b/274189428): Create app pair icon
-                    view = null;
+                    view = AppPairIcon.inflateIcon(R.layout.app_pair_icon, this,
+                            (ViewGroup) workspace.getChildAt(workspace.getCurrentPage()),
+                            (FolderInfo) item);
                     break;
                 }
                 case LauncherSettings.Favorites.ITEM_TYPE_APPWIDGET:
@@ -3389,4 +3390,12 @@
     public View.OnLongClickListener getAllAppsItemLongClickListener() {
         return ItemLongClickListener.INSTANCE_ALL_APPS;
     }
+
+    /**
+     * Handles an app pair launch; overridden in
+     * {@link com.android.launcher3.uioverrides.QuickstepLauncher}
+     */
+    public void launchAppPair(WorkspaceItemInfo app1, WorkspaceItemInfo app2) {
+        // Overridden
+    }
 }
diff --git a/src/com/android/launcher3/apppairs/AppPairIcon.java b/src/com/android/launcher3/apppairs/AppPairIcon.java
new file mode 100644
index 0000000..1dc4ad2
--- /dev/null
+++ b/src/com/android/launcher3/apppairs/AppPairIcon.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.launcher3.apppairs;
+
+import android.content.Context;
+import android.graphics.Rect;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+
+import androidx.annotation.Nullable;
+
+import com.android.launcher3.BubbleTextView;
+import com.android.launcher3.R;
+import com.android.launcher3.dragndrop.DraggableView;
+import com.android.launcher3.model.data.FolderInfo;
+import com.android.launcher3.model.data.WorkspaceItemInfo;
+import com.android.launcher3.views.ActivityContext;
+
+import java.util.Collections;
+import java.util.Comparator;
+
+/**
+ * A {@link android.widget.FrameLayout} used to represent an app pair icon on the workspace.
+ */
+public class AppPairIcon extends FrameLayout implements DraggableView {
+
+    private ActivityContext mActivity;
+    private BubbleTextView mAppPairName;
+    private FolderInfo mInfo;
+
+    public AppPairIcon(Context context, AttributeSet attrs) {
+        super(context, attrs);
+    }
+
+    public AppPairIcon(Context context) {
+        super(context);
+    }
+
+    /**
+     * Builds an AppPairIcon to be added to the Launcher
+     */
+    public static AppPairIcon inflateIcon(int resId, ActivityContext activity,
+            @Nullable ViewGroup group, FolderInfo appPairInfo) {
+
+        LayoutInflater inflater = (group != null)
+                ? LayoutInflater.from(group.getContext())
+                : activity.getLayoutInflater();
+        AppPairIcon icon = (AppPairIcon) inflater.inflate(resId, group, false);
+
+        // Sort contents, so that left-hand app comes first
+        Collections.sort(appPairInfo.contents, Comparator.comparingInt(a -> a.rank));
+
+        icon.setClipToPadding(false);
+        icon.mAppPairName = icon.findViewById(R.id.app_pair_icon_name);
+
+        // TODO (jeremysim b/274189428): Replace this placeholder icon
+        WorkspaceItemInfo placeholder = new WorkspaceItemInfo();
+        placeholder.newIcon(icon.getContext());
+        icon.mAppPairName.applyFromWorkspaceItem(placeholder);
+
+        icon.mAppPairName.setText(appPairInfo.title);
+
+        icon.setTag(appPairInfo);
+        icon.setOnClickListener(activity.getItemOnClickListener());
+        icon.mInfo = appPairInfo;
+        icon.mActivity = activity;
+
+        icon.setAccessibilityDelegate(activity.getAccessibilityDelegate());
+
+        return icon;
+    }
+
+    @Override
+    public int getViewType() {
+        return DRAGGABLE_ICON;
+    }
+
+    @Override
+    public void getWorkspaceVisualDragBounds(Rect bounds) {
+        mAppPairName.getIconBounds(bounds);
+    }
+
+    public FolderInfo getInfo() {
+        return mInfo;
+    }
+}
diff --git a/src/com/android/launcher3/graphics/LauncherPreviewRenderer.java b/src/com/android/launcher3/graphics/LauncherPreviewRenderer.java
index 7241b17..68106c4 100644
--- a/src/com/android/launcher3/graphics/LauncherPreviewRenderer.java
+++ b/src/com/android/launcher3/graphics/LauncherPreviewRenderer.java
@@ -51,6 +51,7 @@
 import android.view.ViewGroup;
 import android.view.WindowInsets;
 import android.view.WindowManager;
+import android.widget.FrameLayout;
 import android.widget.TextClock;
 
 import androidx.annotation.NonNull;
@@ -70,6 +71,7 @@
 import com.android.launcher3.Utilities;
 import com.android.launcher3.Workspace;
 import com.android.launcher3.WorkspaceLayoutManager;
+import com.android.launcher3.apppairs.AppPairIcon;
 import com.android.launcher3.celllayout.CellLayoutLayoutParams;
 import com.android.launcher3.celllayout.CellPosMapper;
 import com.android.launcher3.config.FeatureFlags;
@@ -358,12 +360,13 @@
         addInScreenFromBind(icon, info);
     }
 
-    private void inflateAndAddFolder(FolderInfo info) {
+    private void inflateAndAddCollectionIcon(FolderInfo info) {
         CellLayout screen = info.container == Favorites.CONTAINER_DESKTOP
                 ? mWorkspaceScreens.get(info.screenId)
                 : mHotseat;
-        FolderIcon folderIcon = FolderIcon.inflateIcon(R.layout.folder_icon, this, screen,
-                info);
+        FrameLayout folderIcon = info.itemType == Favorites.ITEM_TYPE_FOLDER
+                ? FolderIcon.inflateIcon(R.layout.folder_icon, this, screen, info)
+                : AppPairIcon.inflateIcon(R.layout.app_pair_icon, this, screen, info);
         addInScreenFromBind(folderIcon, info);
     }
 
@@ -467,7 +470,8 @@
                     inflateAndAddIcon((WorkspaceItemInfo) itemInfo);
                     break;
                 case Favorites.ITEM_TYPE_FOLDER:
-                    inflateAndAddFolder((FolderInfo) itemInfo);
+                case Favorites.ITEM_TYPE_APP_PAIR:
+                    inflateAndAddCollectionIcon((FolderInfo) itemInfo);
                     break;
                 default:
                     break;
diff --git a/src/com/android/launcher3/logging/StatsLogManager.java b/src/com/android/launcher3/logging/StatsLogManager.java
index 2a7cd9a..8bb06c1 100644
--- a/src/com/android/launcher3/logging/StatsLogManager.java
+++ b/src/com/android/launcher3/logging/StatsLogManager.java
@@ -641,11 +641,17 @@
         @UiEvent(doc = "User has swiped upwards from the gesture handle to show transient taskbar.")
         LAUNCHER_TRANSIENT_TASKBAR_SHOW(1331),
 
+        @UiEvent(doc = "User has clicked an app pair and launched directly into split screen.")
+        LAUNCHER_APP_PAIR_LAUNCH(1374),
+
+        @UiEvent(doc = "User saved an app pair.")
+        LAUNCHER_APP_PAIR_SAVE(1456),
+
         @UiEvent(doc = "App launched through pending intent")
-        LAUNCHER_APP_LAUNCH_PENDING_INTENT(1394),
-        ;
+        LAUNCHER_APP_LAUNCH_PENDING_INTENT(1394)
 
         // ADD MORE
+        ;
 
         private final int mId;
 
diff --git a/src/com/android/launcher3/model/LoaderTask.java b/src/com/android/launcher3/model/LoaderTask.java
index 787ac38..933468c 100644
--- a/src/com/android/launcher3/model/LoaderTask.java
+++ b/src/com/android/launcher3/model/LoaderTask.java
@@ -690,9 +690,11 @@
                     break;
 
                 case Favorites.ITEM_TYPE_FOLDER:
+                case Favorites.ITEM_TYPE_APP_PAIR:
                     FolderInfo folderInfo = mBgDataModel.findOrMakeFolder(c.id);
                     c.applyCommonProperties(folderInfo);
 
+                    folderInfo.itemType = c.itemType;
                     // Do not trim the folder label, as is was set by the user.
                     folderInfo.title = c.getString(c.mTitleIndex);
                     folderInfo.spanX = 1;
diff --git a/src/com/android/launcher3/model/ModelWriter.java b/src/com/android/launcher3/model/ModelWriter.java
index a6b4d59..2358a9f 100644
--- a/src/com/android/launcher3/model/ModelWriter.java
+++ b/src/com/android/launcher3/model/ModelWriter.java
@@ -489,6 +489,7 @@
                         case Favorites.ITEM_TYPE_APPLICATION:
                         case Favorites.ITEM_TYPE_DEEP_SHORTCUT:
                         case Favorites.ITEM_TYPE_FOLDER:
+                        case Favorites.ITEM_TYPE_APP_PAIR:
                             if (!mBgDataModel.workspaceItems.contains(modelItem)) {
                                 mBgDataModel.workspaceItems.add(modelItem);
                             }
diff --git a/src/com/android/launcher3/model/data/FolderInfo.java b/src/com/android/launcher3/model/data/FolderInfo.java
index e5a0eb1..9bf6d43 100644
--- a/src/com/android/launcher3/model/data/FolderInfo.java
+++ b/src/com/android/launcher3/model/data/FolderInfo.java
@@ -119,8 +119,8 @@
     public static FolderInfo createAppPair(WorkspaceItemInfo app1, WorkspaceItemInfo app2) {
         FolderInfo newAppPair = new FolderInfo();
         newAppPair.itemType = LauncherSettings.Favorites.ITEM_TYPE_APP_PAIR;
-        newAppPair.contents.add(app1);
-        newAppPair.contents.add(app2);
+        newAppPair.add(app1, /* animate */ false);
+        newAppPair.add(app2, /* animate */ false);
         return newAppPair;
     }
 
diff --git a/src/com/android/launcher3/touch/ItemClickHandler.java b/src/com/android/launcher3/touch/ItemClickHandler.java
index 790c226..8c12547 100644
--- a/src/com/android/launcher3/touch/ItemClickHandler.java
+++ b/src/com/android/launcher3/touch/ItemClickHandler.java
@@ -42,6 +42,7 @@
 import com.android.launcher3.LauncherSettings;
 import com.android.launcher3.R;
 import com.android.launcher3.Utilities;
+import com.android.launcher3.apppairs.AppPairIcon;
 import com.android.launcher3.folder.Folder;
 import com.android.launcher3.folder.FolderIcon;
 import com.android.launcher3.logging.InstanceId;
@@ -95,6 +96,8 @@
         } else if (tag instanceof FolderInfo) {
             if (v instanceof FolderIcon) {
                 onClickFolderIcon(v);
+            } else if (v instanceof AppPairIcon) {
+                onClickAppPairIcon(v);
             }
         } else if (tag instanceof AppInfo) {
             startAppShortcutOrInfoActivity(v, (AppInfo) tag, launcher);
@@ -123,6 +126,17 @@
     }
 
     /**
+     * Event handler for an app pair icon click.
+     *
+     * @param v The view that was clicked. Must be an instance of {@link AppPairIcon}.
+     */
+    private static void onClickAppPairIcon(View v) {
+        Launcher launcher = Launcher.getLauncher(v.getContext());
+        FolderInfo folderInfo = ((AppPairIcon) v).getInfo();
+        launcher.launchAppPair(folderInfo.contents.get(0), folderInfo.contents.get(1));
+    }
+
+    /**
      * Event handler for the app widget view which has not fully restored.
      */
     private static void onClickPendingWidget(PendingAppWidgetHostView v, Launcher launcher) {