Merge "Cleanup SHOW_DOT_PAGINATION flag" into main
diff --git a/quickstep/src/com/android/launcher3/model/PredictionHelper.java b/quickstep/src/com/android/launcher3/model/PredictionHelper.java
index 738dd83..dbd99e1 100644
--- a/quickstep/src/com/android/launcher3/model/PredictionHelper.java
+++ b/quickstep/src/com/android/launcher3/model/PredictionHelper.java
@@ -67,6 +67,9 @@
         } else if (info.itemType == LauncherSettings.Favorites.ITEM_TYPE_FOLDER) {
             return new AppTarget.Builder(new AppTargetId("folder:" + info.id),
                     context.getPackageName(), info.user).build();
+        } else if (info.itemType == LauncherSettings.Favorites.ITEM_TYPE_APP_PAIR) {
+            return new AppTarget.Builder(new AppTargetId("app_pair:" + info.id),
+                    context.getPackageName(), info.user).build();
         }
         return null;
     }
diff --git a/quickstep/src/com/android/launcher3/taskbar/navbutton/PhoneLandscapeNavLayoutter.kt b/quickstep/src/com/android/launcher3/taskbar/navbutton/PhoneLandscapeNavLayoutter.kt
index 21bbca5..efe9d4b 100644
--- a/quickstep/src/com/android/launcher3/taskbar/navbutton/PhoneLandscapeNavLayoutter.kt
+++ b/quickstep/src/com/android/launcher3/taskbar/navbutton/PhoneLandscapeNavLayoutter.kt
@@ -90,6 +90,17 @@
             }
         }
 
+        repositionContextualButtons()
+    }
+
+    open fun addThreeButtons() {
+        // Swap recents and back button
+        navButtonContainer.addView(recentsButton)
+        navButtonContainer.addView(homeButton)
+        navButtonContainer.addView(backButton)
+    }
+
+    open fun repositionContextualButtons() {
         endContextualContainer.removeAllViews()
         startContextualContainer.removeAllViews()
 
@@ -108,11 +119,4 @@
             rotationButton.currentView.layoutParams = getParamsToCenterView()
         }
     }
-
-    open fun addThreeButtons() {
-        // Swap recents and back button
-        navButtonContainer.addView(recentsButton)
-        navButtonContainer.addView(homeButton)
-        navButtonContainer.addView(backButton)
-    }
 }
diff --git a/quickstep/src/com/android/launcher3/taskbar/navbutton/PhoneSeascapeNavLayoutter.kt b/quickstep/src/com/android/launcher3/taskbar/navbutton/PhoneSeascapeNavLayoutter.kt
index cde39f3..fd6e991 100644
--- a/quickstep/src/com/android/launcher3/taskbar/navbutton/PhoneSeascapeNavLayoutter.kt
+++ b/quickstep/src/com/android/launcher3/taskbar/navbutton/PhoneSeascapeNavLayoutter.kt
@@ -48,7 +48,9 @@
         navButtonContainer.addView(backButton)
         navButtonContainer.addView(homeButton)
         navButtonContainer.addView(recentsButton)
+    }
 
+    override fun repositionContextualButtons() {
         endContextualContainer.removeAllViews()
         startContextualContainer.removeAllViews()
 
diff --git a/quickstep/src/com/android/quickstep/SystemUiProxy.java b/quickstep/src/com/android/quickstep/SystemUiProxy.java
index 419824a..56765e5 100644
--- a/quickstep/src/com/android/quickstep/SystemUiProxy.java
+++ b/quickstep/src/com/android/quickstep/SystemUiProxy.java
@@ -78,7 +78,7 @@
 import com.android.wm.shell.back.IBackAnimation;
 import com.android.wm.shell.bubbles.IBubbles;
 import com.android.wm.shell.bubbles.IBubblesListener;
-import com.android.wm.shell.common.split.SplitScreenConstants.SnapPosition;
+import com.android.wm.shell.common.split.SplitScreenConstants.PersistentSnapPosition;
 import com.android.wm.shell.desktopmode.IDesktopMode;
 import com.android.wm.shell.desktopmode.IDesktopTaskListener;
 import com.android.wm.shell.draganddrop.IDragAndDrop;
@@ -799,7 +799,7 @@
 
     /** Start multiple tasks in split-screen simultaneously. */
     public void startTasks(int taskId1, Bundle options1, int taskId2, Bundle options2,
-            @StagePosition int splitPosition, @SnapPosition int snapPosition,
+            @StagePosition int splitPosition, @PersistentSnapPosition int snapPosition,
             RemoteTransition remoteTransition, InstanceId instanceId) {
         if (mSystemUiProxy != null) {
             try {
@@ -813,7 +813,7 @@
 
     public void startIntentAndTask(PendingIntent pendingIntent, int userId1, Bundle options1,
             int taskId, Bundle options2, @StagePosition int splitPosition,
-            @SnapPosition int snapPosition, RemoteTransition remoteTransition,
+            @PersistentSnapPosition int snapPosition, RemoteTransition remoteTransition,
             InstanceId instanceId) {
         if (mSystemUiProxy != null) {
             try {
@@ -828,7 +828,7 @@
     public void startIntents(PendingIntent pendingIntent1, int userId1,
             @Nullable ShortcutInfo shortcutInfo1, Bundle options1, PendingIntent pendingIntent2,
             int userId2, @Nullable ShortcutInfo shortcutInfo2, Bundle options2,
-            @StagePosition int splitPosition, @SnapPosition int snapPosition,
+            @StagePosition int splitPosition, @PersistentSnapPosition int snapPosition,
             RemoteTransition remoteTransition, InstanceId instanceId) {
         if (mSystemUiProxy != null) {
             try {
@@ -842,8 +842,9 @@
     }
 
     public void startShortcutAndTask(ShortcutInfo shortcutInfo, Bundle options1, int taskId,
-            Bundle options2, @StagePosition int splitPosition, @SnapPosition int snapPosition,
-            RemoteTransition remoteTransition, InstanceId instanceId) {
+            Bundle options2, @StagePosition int splitPosition,
+            @PersistentSnapPosition int snapPosition, RemoteTransition remoteTransition,
+            InstanceId instanceId) {
         if (mSystemUiProxy != null) {
             try {
                 mSplitScreen.startShortcutAndTask(shortcutInfo, options1, taskId, options2,
@@ -858,8 +859,9 @@
      * Start multiple tasks in split-screen simultaneously.
      */
     public void startTasksWithLegacyTransition(int taskId1, Bundle options1, int taskId2,
-            Bundle options2, @StagePosition int splitPosition, @SnapPosition int snapPosition,
-            RemoteAnimationAdapter adapter, InstanceId instanceId) {
+            Bundle options2, @StagePosition int splitPosition,
+            @PersistentSnapPosition int snapPosition, RemoteAnimationAdapter adapter,
+            InstanceId instanceId) {
         if (mSystemUiProxy != null) {
             try {
                 mSplitScreen.startTasksWithLegacyTransition(taskId1, options1, taskId2, options2,
@@ -873,7 +875,8 @@
 
     public void startIntentAndTaskWithLegacyTransition(PendingIntent pendingIntent, int userId1,
             Bundle options1, int taskId, Bundle options2, @StagePosition int splitPosition,
-            @SnapPosition int snapPosition, RemoteAnimationAdapter adapter, InstanceId instanceId) {
+            @PersistentSnapPosition int snapPosition, RemoteAnimationAdapter adapter,
+            InstanceId instanceId) {
         if (mSystemUiProxy != null) {
             try {
                 mSplitScreen.startIntentAndTaskWithLegacyTransition(pendingIntent, userId1,
@@ -888,7 +891,8 @@
 
     public void startShortcutAndTaskWithLegacyTransition(ShortcutInfo shortcutInfo, Bundle options1,
             int taskId, Bundle options2, @StagePosition int splitPosition,
-            @SnapPosition int snapPosition, RemoteAnimationAdapter adapter, InstanceId instanceId) {
+            @PersistentSnapPosition int snapPosition, RemoteAnimationAdapter adapter,
+            InstanceId instanceId) {
         if (mSystemUiProxy != null) {
             try {
                 mSplitScreen.startShortcutAndTaskWithLegacyTransition(shortcutInfo, options1,
@@ -908,7 +912,8 @@
             @Nullable ShortcutInfo shortcutInfo1, @Nullable Bundle options1,
             PendingIntent pendingIntent2, int userId2, @Nullable ShortcutInfo shortcutInfo2,
             @Nullable Bundle options2, @StagePosition int sidePosition,
-            @SnapPosition int snapPosition, RemoteAnimationAdapter adapter, InstanceId instanceId) {
+            @PersistentSnapPosition int snapPosition, RemoteAnimationAdapter adapter,
+            InstanceId instanceId) {
         if (mSystemUiProxy != null) {
             try {
                 mSplitScreen.startIntentsWithLegacyTransition(pendingIntent1, userId1,
diff --git a/quickstep/src/com/android/quickstep/TaskShortcutFactory.java b/quickstep/src/com/android/quickstep/TaskShortcutFactory.java
index 2adc790..b3b7be4 100644
--- a/quickstep/src/com/android/quickstep/TaskShortcutFactory.java
+++ b/quickstep/src/com/android/quickstep/TaskShortcutFactory.java
@@ -50,6 +50,7 @@
 import com.android.launcher3.touch.PagedOrientationHandler;
 import com.android.launcher3.util.InstantAppResolver;
 import com.android.launcher3.util.SplitConfigurationOptions.SplitPositionOption;
+import com.android.quickstep.views.GroupedTaskView;
 import com.android.quickstep.views.RecentsView;
 import com.android.quickstep.views.TaskThumbnailView;
 import com.android.quickstep.views.TaskView;
@@ -128,12 +129,12 @@
 
     /**
      * A menu item, "Save app pair", that allows the user to preserve the current app combination as
-     * a single persistent icon on the Home screen, allowing for quick split screen initialization.
+     * one persistent icon on the Home screen, allowing for quick split screen launching.
      */
     class SaveAppPairSystemShortcut extends SystemShortcut<BaseDraggingActivity> {
-        private final TaskView mTaskView;
+        private final GroupedTaskView mTaskView;
 
-        public SaveAppPairSystemShortcut(BaseDraggingActivity activity, TaskView taskView) {
+        public SaveAppPairSystemShortcut(BaseDraggingActivity activity, GroupedTaskView taskView) {
             super(R.drawable.ic_save_app_pair, R.string.save_app_pair, activity,
                     taskView.getItemInfo(), taskView);
             mTaskView = taskView;
@@ -318,7 +319,8 @@
                 return null;
             }
 
-            return Collections.singletonList(new SaveAppPairSystemShortcut(activity, taskView));
+            return Collections.singletonList(
+                    new SaveAppPairSystemShortcut(activity, (GroupedTaskView) taskView));
         }
 
         @Override
diff --git a/quickstep/src/com/android/quickstep/util/AppPairsController.java b/quickstep/src/com/android/quickstep/util/AppPairsController.java
index 1a7099d..8888831 100644
--- a/quickstep/src/com/android/quickstep/util/AppPairsController.java
+++ b/quickstep/src/com/android/quickstep/util/AppPairsController.java
@@ -20,46 +20,46 @@
 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 static com.android.wm.shell.common.split.SplitScreenConstants.SPLIT_POSITION_BOTTOM_OR_RIGHT;
+import static com.android.wm.shell.common.split.SplitScreenConstants.SPLIT_POSITION_TOP_OR_LEFT;
+import static com.android.wm.shell.common.split.SplitScreenConstants.isPersistentSnapPosition;
 
 import android.app.ActivityTaskManager;
 import android.content.Context;
 import android.content.Intent;
 
 import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
 
 import com.android.launcher3.Launcher;
 import com.android.launcher3.LauncherAppState;
 import com.android.launcher3.LauncherSettings;
+import com.android.launcher3.R;
 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.GroupedTaskView;
 import com.android.quickstep.views.TaskView;
 import com.android.systemui.shared.recents.model.Task;
+import com.android.wm.shell.common.split.SplitScreenConstants.PersistentSnapPosition;
 
 import java.util.Arrays;
 
 /**
- * Mini controller class that handles app pair interactions: saving, modifying, deleting, etc.
+ * Controller class that handles app pair interactions: saving, modifying, deleting, etc.
+ * <br>
+ * App pairs contain two "member" apps, which are determined at the time of app pair creation
+ * and never modified. The member apps are WorkspaceItemInfos, but use the "rank" attribute
+ * differently from other ItemInfos -- we use it to store information about the split position and
+ * ratio.
  */
 public class AppPairsController {
-
-    private static final int POINT_THREE_RATIO = 0;
-    private static final int POINT_FIVE_RATIO = 1;
-    private static final int POINT_SEVEN_RATIO = 2;
-    /**
-     * Used to calculate {@link #complement(int)}
-     */
-    private static final int FULL_RATIO = 2;
-
-    private static final int LEFT_TOP = 0;
-    private static final int RIGHT_BOTTOM = 1 << 2;
-
-    // TODO (jeremysim b/274189428): Support saving different ratios in future.
-    public int DEFAULT_RATIO = POINT_FIVE_RATIO;
+    // Used for encoding and decoding the "rank" attribute
+    private static final int BITMASK_SIZE = 16;
+    private static final int BITMASK_FOR_SNAP_POSITION = (1 << BITMASK_SIZE) - 1;
 
     private final Context mContext;
     private final SplitSelectStateController mSplitSelectStateController;
@@ -75,17 +75,21 @@
     /**
      * Creates a new app pair ItemInfo and adds it to the workspace
      */
-    public void saveAppPair(TaskView taskView) {
-        TaskView.TaskIdAttributeContainer[] attributes = taskView.getTaskIdAttributeContainers();
+    public void saveAppPair(GroupedTaskView gtv) {
+        TaskView.TaskIdAttributeContainer[] attributes = gtv.getTaskIdAttributeContainers();
         WorkspaceItemInfo app1 = attributes[0].getItemInfo().clone();
         WorkspaceItemInfo app2 = attributes[1].getItemInfo().clone();
         app1.itemType = LauncherSettings.Favorites.ITEM_TYPE_APPLICATION;
         app2.itemType = LauncherSettings.Favorites.ITEM_TYPE_APPLICATION;
-        app1.rank = DEFAULT_RATIO + LEFT_TOP;
-        app2.rank = complement(DEFAULT_RATIO) + RIGHT_BOTTOM;
+
+        @PersistentSnapPosition int snapPosition = gtv.getSnapPosition();
+        if (!isPersistentSnapPosition(snapPosition)) {
+            throw new RuntimeException("tried to save an app pair with illegal snapPosition");
+        }
+
+        app1.rank = encodeRank(SPLIT_POSITION_TOP_OR_LEFT, snapPosition);
+        app2.rank = encodeRank(SPLIT_POSITION_BOTTOM_OR_RIGHT, snapPosition);
         FolderInfo newAppPair = FolderInfo.createAppPair(app1, app2);
-        // TODO (jeremysim b/274189428): Generate default title here.
-        newAppPair.title = "App pair 1";
 
         IconCache iconCache = LauncherAppState.getInstance(mContext).getIconCache();
         MODEL_EXECUTOR.execute(() -> {
@@ -94,6 +98,8 @@
                 member.bitmap = iconCache.getDefaultIcon(newAppPair.user);
                 iconCache.getTitleAndIcon(member, member.usingLowResIcon());
             });
+            newAppPair.title = getDefaultTitle(newAppPair.contents.get(0).title,
+                    newAppPair.contents.get(1).title);
             MAIN_EXECUTOR.execute(() -> {
                 LauncherAccessibilityDelegate delegate =
                         Launcher.getLauncher(mContext).getAccessibilityDelegate();
@@ -128,7 +134,7 @@
                     }
 
                     mSplitSelectStateController.setInitialTaskSelect(task1Intent,
-                            SplitConfigurationOptions.STAGE_POSITION_TOP_OR_LEFT,
+                            AppPairsController.convertRankToStagePosition(app1.rank),
                             app1,
                             LAUNCHER_APP_PAIR_LAUNCH,
                             task1Id);
@@ -141,18 +147,42 @@
                                 app2.intent, app2.user);
                     }
 
-                    mSplitSelectStateController.launchSplitTasks();
+                    mSplitSelectStateController.launchSplitTasks(
+                            AppPairsController.convertRankToSnapPosition(app1.rank));
                 });
     }
 
     /**
-     * Used to calculate the "opposite" side of the split ratio, so we can know how big the split
-     * apps are supposed to be. This math works because POINT_THREE_RATIO is internally represented
-     * by 0, POINT_FIVE_RATIO is represented by 1, and POINT_SEVEN_RATIO is represented by 2. There
-     * are no other supported ratios for now.
+     * App pair members have a "rank" attribute that contains information about the split position
+     * and ratio. We implement this by splitting the int in half (e.g. 16 bits each), then use one
+     * half to store splitPosition (left vs right) and the other half to store snapPosition
+     * (30-70 split vs 50-50 split)
      */
-    private int complement(int ratio1) {
-        int ratio2 = FULL_RATIO - ratio1;
-        return ratio2;
+    @VisibleForTesting
+    public int encodeRank(int splitPosition, int snapPosition) {
+        return (splitPosition << BITMASK_SIZE) + snapPosition;
+    }
+
+    /**
+     * Returns the desired stage position for the app pair to be launched in (decoded from the
+     * "rank" integer).
+     */
+    public static int convertRankToStagePosition(int rank) {
+        return rank >> BITMASK_SIZE;
+    }
+
+    /**
+     * Returns the desired split ratio for the app pair to be launched in (decoded from the "rank"
+     * integer).
+     */
+    public static int convertRankToSnapPosition(int rank) {
+        return rank & BITMASK_FOR_SNAP_POSITION;
+    }
+
+    /**
+     * Returns a formatted default title for the app pair.
+     */
+    public String getDefaultTitle(CharSequence app1, CharSequence app2) {
+        return mContext.getString(R.string.app_pair_default_title, app1, app2);
     }
 }
diff --git a/quickstep/src/com/android/quickstep/util/SplitSelectStateController.java b/quickstep/src/com/android/quickstep/util/SplitSelectStateController.java
index c8831c7..b9829f7 100644
--- a/quickstep/src/com/android/quickstep/util/SplitSelectStateController.java
+++ b/quickstep/src/com/android/quickstep/util/SplitSelectStateController.java
@@ -98,7 +98,7 @@
 import com.android.systemui.shared.recents.model.Task;
 import com.android.systemui.shared.system.ActivityManagerWrapper;
 import com.android.systemui.shared.system.RemoteAnimationRunnerCompat;
-import com.android.wm.shell.common.split.SplitScreenConstants.SnapPosition;
+import com.android.wm.shell.common.split.SplitScreenConstants.PersistentSnapPosition;
 import com.android.wm.shell.splitscreen.ISplitSelectListener;
 
 import java.io.PrintWriter;
@@ -289,7 +289,7 @@
      * To be called when the both split tasks are ready to be launched. Call after launcher side
      * animations are complete.
      */
-    public void launchSplitTasks(@SnapPosition int snapPosition,
+    public void launchSplitTasks(@PersistentSnapPosition int snapPosition,
             @Nullable Consumer<Boolean> callback) {
         Pair<InstanceId, com.android.launcher3.logging.InstanceId> instanceIds =
                 LogUtils.getShellShareableInstanceId();
@@ -302,6 +302,14 @@
     }
 
     /**
+     * A version of {@link #launchTasks(Consumer, boolean, int, InstanceId)} with no success
+     * callback.
+     */
+    public void launchSplitTasks(@PersistentSnapPosition int snapPosition) {
+        launchSplitTasks(snapPosition, /* callback */ null);
+    }
+
+    /**
      * A version of {@link #launchSplitTasks(int, Consumer)} that launches with default split ratio.
      */
     public void launchSplitTasks(@Nullable Consumer<Boolean> callback) {
@@ -350,7 +358,7 @@
      *                   foreground (quickswitch, launching previous pairs from overview)
      */
     public void launchTasks(@Nullable Consumer<Boolean> callback, boolean freezeTaskList,
-            @SnapPosition int snapPosition, @Nullable InstanceId shellInstanceId) {
+            @PersistentSnapPosition int snapPosition, @Nullable InstanceId shellInstanceId) {
         TestLogging.recordEvent(
                 TestProtocol.SEQUENCE_MAIN, "launchSplitTasks");
         final ActivityOptions options1 = ActivityOptions.makeBasic();
@@ -455,7 +463,8 @@
      */
     public void launchExistingSplitPair(@Nullable GroupedTaskView groupedTaskView,
             int firstTaskId, int secondTaskId, @StagePosition int stagePosition,
-            Consumer<Boolean> callback, boolean freezeTaskList, @SnapPosition int snapPosition) {
+            Consumer<Boolean> callback, boolean freezeTaskList,
+            @PersistentSnapPosition int snapPosition) {
         mLaunchingTaskView = groupedTaskView;
         final ActivityOptions options1 = ActivityOptions.makeBasic();
         if (freezeTaskList) {
diff --git a/quickstep/src/com/android/quickstep/views/GroupedTaskView.java b/quickstep/src/com/android/quickstep/views/GroupedTaskView.java
index 3d33c87..71758ad 100644
--- a/quickstep/src/com/android/quickstep/views/GroupedTaskView.java
+++ b/quickstep/src/com/android/quickstep/views/GroupedTaskView.java
@@ -35,7 +35,7 @@
 import com.android.systemui.shared.recents.model.ThumbnailData;
 import com.android.systemui.shared.recents.utilities.PreviewPositionHelper;
 import com.android.systemui.shared.system.InteractionJankMonitorWrapper;
-import com.android.wm.shell.common.split.SplitScreenConstants.SnapPosition;
+import com.android.wm.shell.common.split.SplitScreenConstants.PersistentSnapPosition;
 
 import java.util.HashMap;
 import java.util.function.Consumer;
@@ -200,9 +200,9 @@
     }
 
     /**
-     * Returns the {@link SnapPosition} of this pair of tasks.
+     * Returns the {@link PersistentSnapPosition} of this pair of tasks.
      */
-    public int getSnapPosition() {
+    public @PersistentSnapPosition int getSnapPosition() {
         if (mSplitBoundsConfig == null) {
             throw new IllegalStateException("mSplitBoundsConfig is null");
         }
diff --git a/quickstep/tests/src/com/android/quickstep/util/AppPairsControllerTest.kt b/quickstep/tests/src/com/android/quickstep/util/AppPairsControllerTest.kt
new file mode 100644
index 0000000..1723844
--- /dev/null
+++ b/quickstep/tests/src/com/android/quickstep/util/AppPairsControllerTest.kt
@@ -0,0 +1,147 @@
+/*
+ * 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.quickstep.util
+
+import android.content.Context
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.android.launcher3.logging.StatsLogManager
+import com.android.launcher3.util.SplitConfigurationOptions.STAGE_POSITION_BOTTOM_OR_RIGHT
+import com.android.launcher3.util.SplitConfigurationOptions.STAGE_POSITION_TOP_OR_LEFT
+import com.android.wm.shell.common.split.SplitScreenConstants.SNAP_TO_30_70
+import com.android.wm.shell.common.split.SplitScreenConstants.SNAP_TO_50_50
+import com.android.wm.shell.common.split.SplitScreenConstants.SNAP_TO_70_30
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.MockitoAnnotations
+
+@RunWith(AndroidJUnit4::class)
+class AppPairsControllerTest {
+    @Mock lateinit var context: Context
+    @Mock lateinit var splitSelectStateController: SplitSelectStateController
+    @Mock lateinit var statsLogManager: StatsLogManager
+
+    private lateinit var appPairsController: AppPairsController
+
+    private val left30: Int by lazy {
+        appPairsController.encodeRank(STAGE_POSITION_TOP_OR_LEFT, SNAP_TO_30_70)
+    }
+    private val left50: Int by lazy {
+        appPairsController.encodeRank(STAGE_POSITION_TOP_OR_LEFT, SNAP_TO_50_50)
+    }
+    private val left70: Int by lazy {
+        appPairsController.encodeRank(STAGE_POSITION_TOP_OR_LEFT, SNAP_TO_70_30)
+    }
+    private val right30: Int by lazy {
+        appPairsController.encodeRank(STAGE_POSITION_BOTTOM_OR_RIGHT, SNAP_TO_30_70)
+    }
+    private val right50: Int by lazy {
+        appPairsController.encodeRank(STAGE_POSITION_BOTTOM_OR_RIGHT, SNAP_TO_50_50)
+    }
+    private val right70: Int by lazy {
+        appPairsController.encodeRank(STAGE_POSITION_BOTTOM_OR_RIGHT, SNAP_TO_70_30)
+    }
+
+    @Before
+    fun setup() {
+        MockitoAnnotations.initMocks(this)
+        appPairsController =
+            AppPairsController(context, splitSelectStateController, statsLogManager)
+    }
+
+    @Test
+    fun shouldEncodeRankCorrectly() {
+        assertEquals("left + 30-70 should encode as 0 (0b0)", 0, left30)
+        assertEquals("left + 50-50 should encode as 1 (0b1)", 1, left50)
+        assertEquals("left + 70-30 should encode as 2 (0b10)", 2, left70)
+        // See AppPairsController#BITMASK_SIZE and BITMASK_FOR_SNAP_POSITION for context
+        assertEquals("right + 30-70 should encode as 1 followed by 16 0s", 1 shl 16, right30)
+        assertEquals("right + 50-50 should encode as the above value + 1", (1 shl 16) + 1, right50)
+        assertEquals("right + 70-30 should encode as the above value + 2", (1 shl 16) + 2, right70)
+    }
+
+    @Test
+    fun shouldDecodeRankCorrectly() {
+        assertEquals(
+            "left + 30-70 should decode to left",
+            STAGE_POSITION_TOP_OR_LEFT,
+            AppPairsController.convertRankToStagePosition(left30),
+        )
+        assertEquals(
+            "left + 30-70 should decode to 30-70",
+            SNAP_TO_30_70,
+            AppPairsController.convertRankToSnapPosition(left30),
+        )
+
+        assertEquals(
+            "left + 50-50 should decode to left",
+            STAGE_POSITION_TOP_OR_LEFT,
+            AppPairsController.convertRankToStagePosition(left50),
+        )
+        assertEquals(
+            "left + 50-50 should decode to 50-50",
+            SNAP_TO_50_50,
+            AppPairsController.convertRankToSnapPosition(left50),
+        )
+
+        assertEquals(
+            "left + 70-30 should decode to left",
+            STAGE_POSITION_TOP_OR_LEFT,
+            AppPairsController.convertRankToStagePosition(left70),
+        )
+        assertEquals(
+            "left + 70-30 should decode to 70-30",
+            SNAP_TO_70_30,
+            AppPairsController.convertRankToSnapPosition(left70),
+        )
+
+        assertEquals(
+            "right + 30-70 should decode to right",
+            STAGE_POSITION_BOTTOM_OR_RIGHT,
+            AppPairsController.convertRankToStagePosition(right30),
+        )
+        assertEquals(
+            "right + 30-70 should decode to 30-70",
+            SNAP_TO_30_70,
+            AppPairsController.convertRankToSnapPosition(right30),
+        )
+
+        assertEquals(
+            "right + 50-50 should decode to right",
+            STAGE_POSITION_BOTTOM_OR_RIGHT,
+            AppPairsController.convertRankToStagePosition(right50),
+        )
+        assertEquals(
+            "right + 50-50 should decode to 50-50",
+            SNAP_TO_50_50,
+            AppPairsController.convertRankToSnapPosition(right50),
+        )
+
+        assertEquals(
+            "right + 70-30 should decode to right",
+            STAGE_POSITION_BOTTOM_OR_RIGHT,
+            AppPairsController.convertRankToStagePosition(right70),
+        )
+        assertEquals(
+            "right + 70-30 should decode to 70-30",
+            SNAP_TO_70_30,
+            AppPairsController.convertRankToSnapPosition(right70),
+        )
+    }
+}
diff --git a/res/values/strings.xml b/res/values/strings.xml
index a2f4a61..37bd4f1 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -44,6 +44,8 @@
 
     <!-- App pairs -->
     <string name="save_app_pair">Save app pair</string>
+    <!-- App pair default title -->
+    <string name="app_pair_default_title"><xliff:g id="app1" example="Chrome">%1$s</xliff:g> | <xliff:g id="app2" example="YouTube">%2$s</xliff:g></string>
 
     <!-- Widgets -->
     <!-- Message to tell the user to press and hold on a widget to add it [CHAR_LIMIT=50] -->
diff --git a/src/com/android/launcher3/BubbleTextView.java b/src/com/android/launcher3/BubbleTextView.java
index 879000a..6d14b31 100644
--- a/src/com/android/launcher3/BubbleTextView.java
+++ b/src/com/android/launcher3/BubbleTextView.java
@@ -386,10 +386,12 @@
         setTag(itemInfo);
     }
 
+    @VisibleForTesting
     @UiThread
-    protected void applyIconAndLabel(ItemInfoWithIcon info) {
+    public void applyIconAndLabel(ItemInfoWithIcon info) {
         int flags = shouldUseTheme() ? FLAG_THEMED : 0;
-        if (mHideBadge) {
+        // Remove badge on icons smaller than 48dp.
+        if (mHideBadge || mDisplay == DISPLAY_SEARCH_RESULT_SMALL) {
             flags |= FLAG_NO_BADGE;
         }
         FastBitmapDrawable iconDrawable = info.newIcon(getContext(), flags);
diff --git a/src/com/android/launcher3/Workspace.java b/src/com/android/launcher3/Workspace.java
index e7e6c92..c20d602 100644
--- a/src/com/android/launcher3/Workspace.java
+++ b/src/com/android/launcher3/Workspace.java
@@ -72,6 +72,7 @@
 import com.android.launcher3.accessibility.AccessibleDragListenerAdapter;
 import com.android.launcher3.accessibility.WorkspaceAccessibilityHelper;
 import com.android.launcher3.anim.PendingAnimation;
+import com.android.launcher3.apppairs.AppPairIcon;
 import com.android.launcher3.celllayout.CellLayoutLayoutParams;
 import com.android.launcher3.celllayout.CellPosMapper;
 import com.android.launcher3.celllayout.CellPosMapper.CellPos;
@@ -2862,6 +2863,10 @@
                     view = FolderIcon.inflateFolderAndIcon(R.layout.folder_icon, mLauncher, cellLayout,
                             (FolderInfo) info);
                     break;
+                case LauncherSettings.Favorites.ITEM_TYPE_APP_PAIR:
+                    view = AppPairIcon.inflateIcon(R.layout.app_pair_icon, mLauncher, cellLayout,
+                            (FolderInfo) info);
+                    break;
                 default:
                     throw new IllegalStateException("Unknown item type: " + info.itemType);
             }
diff --git a/src/com/android/launcher3/config/FeatureFlags.java b/src/com/android/launcher3/config/FeatureFlags.java
index 0e193d4..92d7a3c 100644
--- a/src/com/android/launcher3/config/FeatureFlags.java
+++ b/src/com/android/launcher3/config/FeatureFlags.java
@@ -137,7 +137,7 @@
 
     // TODO(Block 6): Clean up flags
     public static final BooleanFlag ENABLE_ALL_APPS_SEARCH_IN_TASKBAR = getDebugFlag(270393900,
-            "ENABLE_ALL_APPS_SEARCH_IN_TASKBAR", TEAMFOOD,
+            "ENABLE_ALL_APPS_SEARCH_IN_TASKBAR", ENABLED,
             "Enables Search box in Taskbar All Apps.");
 
     public static final BooleanFlag SECONDARY_DRAG_N_DROP_TO_PIN = getDebugFlag(270395140,
diff --git a/src/com/android/launcher3/model/GridSizeMigrationUtil.java b/src/com/android/launcher3/model/GridSizeMigrationUtil.java
index c06ab8c..eed4ee6 100644
--- a/src/com/android/launcher3/model/GridSizeMigrationUtil.java
+++ b/src/com/android/launcher3/model/GridSizeMigrationUtil.java
@@ -468,6 +468,13 @@
                             }
                             break;
                         }
+                        case LauncherSettings.Favorites.ITEM_TYPE_APP_PAIR: {
+                            int total = getFolderItemsCount(entry);
+                            if (total != 2) {
+                                throw new Exception("App pair contains fewer or more than 2 items");
+                            }
+                            break;
+                        }
                         default:
                             throw new Exception("Invalid item type");
                     }
@@ -565,6 +572,13 @@
                             }
                             break;
                         }
+                        case LauncherSettings.Favorites.ITEM_TYPE_APP_PAIR: {
+                            int total = getFolderItemsCount(entry);
+                            if (total != 2) {
+                                throw new Exception("App pair contains fewer or more than 2 items");
+                            }
+                            break;
+                        }
                         default:
                             throw new Exception("Invalid item type");
                     }
@@ -682,6 +696,7 @@
         public String getEntryMigrationId() {
             switch (itemType) {
                 case LauncherSettings.Favorites.ITEM_TYPE_FOLDER:
+                case LauncherSettings.Favorites.ITEM_TYPE_APP_PAIR:
                     return getFolderMigrationId();
                 case LauncherSettings.Favorites.ITEM_TYPE_APPWIDGET:
                     return mProvider;
diff --git a/tests/src/com/android/launcher3/ui/BubbleTextViewTest.java b/tests/src/com/android/launcher3/ui/BubbleTextViewTest.java
index 2cdcf24..6c2950c 100644
--- a/tests/src/com/android/launcher3/ui/BubbleTextViewTest.java
+++ b/tests/src/com/android/launcher3/ui/BubbleTextViewTest.java
@@ -20,21 +20,30 @@
 
 import static com.android.launcher3.BubbleTextView.DISPLAY_ALL_APPS;
 import static com.android.launcher3.BubbleTextView.DISPLAY_PREDICTION_ROW;
+import static com.android.launcher3.BubbleTextView.DISPLAY_SEARCH_RESULT;
+import static com.android.launcher3.BubbleTextView.DISPLAY_SEARCH_RESULT_SMALL;
+
+import static com.google.common.truth.Truth.assertThat;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNotEquals;
 
+import android.content.ComponentName;
 import android.content.Context;
+import android.content.Intent;
 import android.graphics.Typeface;
 import android.platform.test.flag.junit.SetFlagsRule;
+import android.os.UserHandle;
 import android.view.ViewGroup;
 
 import com.android.launcher3.BubbleTextView;
 import com.android.launcher3.Flags;
 import com.android.launcher3.Utilities;
+import com.android.launcher3.model.data.AppInfo;
 import com.android.launcher3.model.data.ItemInfoWithIcon;
 import com.android.launcher3.search.StringMatcherUtility;
 import com.android.launcher3.util.ActivityContextWrapper;
+import com.android.launcher3.util.FlagOp;
 import com.android.launcher3.util.IntArray;
 import com.android.launcher3.views.BaseDragLayer;
 
@@ -55,6 +64,8 @@
     @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
     private static final StringMatcherUtility.StringMatcher
             MATCHER = StringMatcherUtility.StringMatcher.getInstance();
+    private static final UserHandle WORK_HANDLE = new UserHandle(13);
+    private static final int WORK_FLAG = 1;
     private static final int ONE_LINE = 1;
     private static final int TWO_LINE = 2;
     private static final String TEST_STRING_WITH_SPACE_LONGER_THAN_CHAR_LIMIT = "Battery Stats";
@@ -81,6 +92,7 @@
     private ItemInfoWithIcon mItemInfoWithIcon;
     private Context mContext;
     private int mLimitedWidth;
+    private AppInfo mGmailAppInfo;
 
     @Before
     public void setUp() throws Exception {
@@ -110,6 +122,9 @@
                 return null;
             }
         };
+        ComponentName componentName = new ComponentName(mContext,
+                "com.android.launcher3.tests.Activity" + "Gmail");
+        mGmailAppInfo = new AppInfo(componentName, "Gmail", WORK_HANDLE, new Intent());
     }
 
     @Test
@@ -359,4 +374,28 @@
 
         assertEquals(TWO_LINE, mBubbleTextView.getLineCount());
     }
+
+    @Test
+    public void applyIconAndLabel_whenDisplay_DISPLAY_SEARCH_RESULT_SMALL_noBadge() {
+        FlagOp op = FlagOp.NO_OP;
+        // apply the WORK bitmap flag to show work badge
+        mGmailAppInfo.bitmap.flags = op.apply(WORK_FLAG);
+        mBubbleTextView.setDisplay(DISPLAY_SEARCH_RESULT_SMALL);
+
+        mBubbleTextView.applyIconAndLabel(mGmailAppInfo);
+
+        assertThat(mBubbleTextView.getIcon().hasBadge()).isEqualTo(false);
+    }
+
+    @Test
+    public void applyIconAndLabel_whenDisplay_DISPLAY_SEARCH_RESULT_hasBadge() {
+        FlagOp op = FlagOp.NO_OP;
+        // apply the WORK bitmap flag to show work badge
+        mGmailAppInfo.bitmap.flags = op.apply(WORK_FLAG);
+        mBubbleTextView.setDisplay(DISPLAY_SEARCH_RESULT);
+
+        mBubbleTextView.applyIconAndLabel(mGmailAppInfo);
+
+        assertThat(mBubbleTextView.getIcon().hasBadge()).isEqualTo(true);
+    }
 }