Merge "Removing debbug comments" into main
diff --git a/protos/launcher_atom.proto b/protos/launcher_atom.proto
index 8240f11..7c648b6 100644
--- a/protos/launcher_atom.proto
+++ b/protos/launcher_atom.proto
@@ -138,7 +138,7 @@
   }
 }
 
-// Next value 55
+// Next value 54
 enum Attribute {
   option allow_alias = true;
 
@@ -183,6 +183,7 @@
   ALL_APPS_SEARCH_RESULT_CHROMETAB = 24;
   ALL_APPS_SEARCH_RESULT_NAVVYSITE = 25 [deprecated = true];
   ALL_APPS_SEARCH_RESULT_TIPS = 26;
+  ALL_APPS_SEARCH_RESULT_QS_TILE = 27;
   ALL_APPS_SEARCH_RESULT_PEOPLE_TILE = 27 [deprecated = true];
   ALL_APPS_SEARCH_RESULT_LEGACY_SHORTCUT = 30;
   ALL_APPS_SEARCH_RESULT_ASSISTANT_MEMORY = 31;
@@ -192,7 +193,6 @@
   ALL_APPS_SEARCH_RESULT_LOCATION = 50;
   ALL_APPS_SEARCH_RESULT_TEXT_HEADER = 51;
   ALL_APPS_SEARCH_RESULT_NO_FULFILLMENT = 52;
-  ALL_APPS_SEARCH_RESULT_QS_TILE = 53;
 
   // Result sources
   DATA_SOURCE_APPSEARCH_APP_PREVIEW = 45;
@@ -200,7 +200,7 @@
   DATA_SOURCE_APPSEARCH_CATEGORY_SRP_PREVIEW = 48;
   DATA_SOURCE_APPSEARCH_ENTITY_SRP_PREVIEW = 49;
   DATA_SOURCE_AIAI_SEARCH_ROOT = 47;
-  DATA_SOURCE_LAUNCHER = 54;
+  DATA_SOURCE_LAUNCHER = 53;
 
   // Web suggestions provided by AGA
   ALL_APPS_SEARCH_RESULT_WEB_SUGGEST = 39;
diff --git a/quickstep/src/com/android/launcher3/QuickstepTransitionManager.java b/quickstep/src/com/android/launcher3/QuickstepTransitionManager.java
index 8db63e3..4898761 100644
--- a/quickstep/src/com/android/launcher3/QuickstepTransitionManager.java
+++ b/quickstep/src/com/android/launcher3/QuickstepTransitionManager.java
@@ -1654,9 +1654,10 @@
             addCujInstrumentation(anim, playFallBackAnimation
                     ? CUJ_APP_CLOSE_TO_HOME_FALLBACK : CUJ_APP_CLOSE_TO_HOME);
 
-            anim.addListener(new AnimationSuccessListener() {
+            anim.addListener(new AnimatorListenerAdapter() {
                 @Override
-                public void onAnimationSuccess(Animator animator) {
+                public void onAnimationEnd(Animator animation) {
+                    super.onAnimationEnd(animation);
                     AccessibilityManagerCompat.sendTestProtocolEventToTest(
                             mLauncher, WALLPAPER_OPEN_ANIMATION_FINISHED_MESSAGE);
                 }
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java
index be1d0b6..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,41 +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.setOnFolderStateChangedListener(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.setOnFolderStateChangedListener(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);
@@ -1044,7 +1033,8 @@
                             getSystemService(LauncherApps.class)
                                     .startShortcut(packageName, id, null, null, info.user);
                         } else {
-                            launchFromTaskbarPreservingSplitIfVisible(recents, info);
+                            launchFromTaskbarPreservingSplitIfVisible(
+                                    recents, Collections.singletonList(info));
                         }
 
                     } catch (NullPointerException
@@ -1055,7 +1045,21 @@
                         Log.e(TAG, "Unable to launch. tag=" + info + " intent=" + intent, e);
                         return;
                     }
+                }
 
+                // If the app was launched from a folder, stash the taskbar after it closes
+                Folder f = Folder.getOpen(this);
+                if (f != null && f.getInfo().id == info.container) {
+                    f.addOnFolderStateChangedListener(new Folder.OnFolderStateChangedListener() {
+                        @Override
+                        public void onFolderStateChanged(int newState) {
+                            if (newState == Folder.STATE_CLOSED) {
+                                f.removeOnFolderStateChangedListener(this);
+                                mControllers.taskbarStashController
+                                        .updateAndAnimateTransientTaskbar(true);
+                            }
+                        }
+                    });
                 }
                 mControllers.uiController.onTaskbarIconLaunched(info);
                 mControllers.taskbarStashController.updateAndAnimateTransientTaskbar(true);
@@ -1063,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);
@@ -1092,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(
@@ -1111,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) {
@@ -1134,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 8b27a85..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.
      */
@@ -634,7 +661,11 @@
             };
 
             MAIN_EXECUTOR.execute(() -> {
-                TaskViewUtils.composeRecentsSplitLaunchAnimator(mLaunchingTaskView, mStateManager,
+                // Only animate from taskView if it's already visible
+                boolean shouldLaunchFromTaskView = mLaunchingTaskView != null &&
+                        mLaunchingTaskView.getRecentsView().isTaskViewVisible(mLaunchingTaskView);
+                TaskViewUtils.composeRecentsSplitLaunchAnimator(shouldLaunchFromTaskView
+                                ? mLaunchingTaskView : null, mStateManager,
                         mDepthController, mInitialTaskId, mSecondTaskId, info, t, () -> {
                             finishAdapter.run();
                             cleanup(true /*success*/);
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/res/values/attrs.xml b/res/values/attrs.xml
index 20e7089..8d84c90 100644
--- a/res/values/attrs.xml
+++ b/res/values/attrs.xml
@@ -164,7 +164,19 @@
 
         <!-- numFolderRows & numFolderColumns defaults to numRows & numColumns, if not specified -->
         <attr name="numFolderRows" format="integer" />
+        <!-- defaults to numFolderRows, if not specified -->
+        <attr name="numFolderRowsLandscape" format="integer" />
+        <!-- defaults to numFolderRows, if not specified -->
+        <attr name="numFolderRowsTwoPanelLandscape" format="integer" />
+        <!-- defaults to numFolderRows, if not specified -->
+        <attr name="numFolderRowsTwoPanelPortrait" format="integer" />
         <attr name="numFolderColumns" format="integer" />
+        <!-- defaults to numFolderColumns, if not specified -->
+        <attr name="numFolderColumnsLandscape" format="integer" />
+        <!-- defaults to numFolderColumns, if not specified -->
+        <attr name="numFolderColumnsTwoPanelLandscape" format="integer" />
+        <!-- defaults to numFolderColumns, if not specified -->
+        <attr name="numFolderColumnsTwoPanelPortrait" format="integer" />
         <!-- Support attributes in FolderStyle -->
         <attr name="folderStyle" format="reference" />
 
diff --git a/src/com/android/launcher3/DeviceProfile.java b/src/com/android/launcher3/DeviceProfile.java
index 8aa8c1e..b6e8ec3 100644
--- a/src/com/android/launcher3/DeviceProfile.java
+++ b/src/com/android/launcher3/DeviceProfile.java
@@ -182,6 +182,8 @@
     public int cellYPaddingPx = -1;
 
     // Folder
+    public final int numFolderRows;
+    public final int numFolderColumns;
     public final float folderLabelTextScale;
     public int folderLabelTextSizePx;
     public int folderFooterHeightPx;
@@ -439,6 +441,8 @@
         }
 
         folderLabelTextScale = res.getFloat(R.dimen.folder_label_text_scale);
+        numFolderRows = inv.numFolderRows[mTypeIndex];
+        numFolderColumns = inv.numFolderColumns[mTypeIndex];
 
         if (mIsScalableGrid && inv.folderStyle != INVALID_RESOURCE_HANDLE) {
             TypedArray folderStyle = context.obtainStyledAttributes(inv.folderStyle,
@@ -645,11 +649,11 @@
                             isTwoPanels ? inv.folderSpecsTwoPanelId : inv.folderSpecsId),
                     ResponsiveSpecType.Folder);
             mResponsiveFolderWidthSpec = folderSpecs.getCalculatedSpec(responsiveAspectRatio,
-                    DimensionType.WIDTH, inv.numFolderColumns,
+                    DimensionType.WIDTH, numFolderColumns,
                     mResponsiveWorkspaceWidthSpec.getAvailableSpace(),
                     mResponsiveWorkspaceWidthSpec);
             mResponsiveFolderHeightSpec = folderSpecs.getCalculatedSpec(responsiveAspectRatio,
-                    DimensionType.HEIGHT, inv.numFolderRows,
+                    DimensionType.HEIGHT, numFolderRows,
                     mResponsiveWorkspaceHeightSpec.getAvailableSpace(),
                     mResponsiveWorkspaceHeightSpec);
 
@@ -1406,16 +1410,16 @@
         Point totalWorkspacePadding = getTotalWorkspacePadding();
 
         // Check if the folder fit within the available height.
-        float contentUsedHeight = folderCellHeightPx * inv.numFolderRows
-                + ((inv.numFolderRows - 1) * folderCellLayoutBorderSpacePx.y)
+        float contentUsedHeight = folderCellHeightPx * numFolderRows
+                + ((numFolderRows - 1) * folderCellLayoutBorderSpacePx.y)
                 + folderFooterHeightPx
                 + folderContentPaddingTop;
         int contentMaxHeight = availableHeightPx - totalWorkspacePadding.y;
         float scaleY = contentMaxHeight / contentUsedHeight;
 
         // Check if the folder fit within the available width.
-        float contentUsedWidth = folderCellWidthPx * inv.numFolderColumns
-                + ((inv.numFolderColumns - 1) * folderCellLayoutBorderSpacePx.x)
+        float contentUsedWidth = folderCellWidthPx * numFolderColumns
+                + ((numFolderColumns - 1) * folderCellLayoutBorderSpacePx.x)
                 + folderContentPaddingLeftRight * 2;
         int contentMaxWidth = availableWidthPx - totalWorkspacePadding.x;
         float scaleX = contentMaxWidth / contentUsedWidth;
@@ -2045,8 +2049,8 @@
         writer.println(prefix + pxToDpStr("iconTextSizePx", iconTextSizePx));
         writer.println(prefix + pxToDpStr("iconDrawablePaddingPx", iconDrawablePaddingPx));
 
-        writer.println(prefix + "\tinv.numFolderRows: " + inv.numFolderRows);
-        writer.println(prefix + "\tinv.numFolderColumns: " + inv.numFolderColumns);
+        writer.println(prefix + "\tnumFolderRows: " + numFolderRows);
+        writer.println(prefix + "\tnumFolderColumns: " + numFolderColumns);
         writer.println(prefix + pxToDpStr("folderCellWidthPx", folderCellWidthPx));
         writer.println(prefix + pxToDpStr("folderCellHeightPx", folderCellHeightPx));
         writer.println(prefix + pxToDpStr("folderChildIconSizePx", folderChildIconSizePx));
diff --git a/src/com/android/launcher3/InvariantDeviceProfile.java b/src/com/android/launcher3/InvariantDeviceProfile.java
index 567d0c5..dfbbcaa 100644
--- a/src/com/android/launcher3/InvariantDeviceProfile.java
+++ b/src/com/android/launcher3/InvariantDeviceProfile.java
@@ -122,8 +122,8 @@
     /**
      * Number of icons per row and column in the folder.
      */
-    public int numFolderRows;
-    public int numFolderColumns;
+    public int[] numFolderRows;
+    public int[] numFolderColumns;
     public float[] iconSize;
     public float[] iconTextSize;
     public int iconBitmapSize;
@@ -810,8 +810,8 @@
         public final int numSearchContainerColumns;
         public final int deviceCategory;
 
-        private final int numFolderRows;
-        private final int numFolderColumns;
+        private final int[] numFolderRows = new int[COUNT_SIZES];
+        private final int[] numFolderColumns = new int[COUNT_SIZES];
         private final @StyleRes int folderStyle;
         private final @StyleRes int cellStyle;
 
@@ -888,11 +888,39 @@
                     a.getResourceId(R.styleable.GridDisplayOption_inlineNavButtonsEndSpacing,
                             R.dimen.taskbar_button_margin_default);
 
-            numFolderRows = a.getInt(
+            numFolderRows[INDEX_DEFAULT] = a.getInt(
                     R.styleable.GridDisplayOption_numFolderRows, numRows);
-            numFolderColumns = a.getInt(
+            numFolderColumns[INDEX_DEFAULT] = a.getInt(
                     R.styleable.GridDisplayOption_numFolderColumns, numColumns);
 
+            if (FeatureFlags.enableResponsiveWorkspace()) {
+                numFolderRows[INDEX_LANDSCAPE] = a.getInt(
+                        R.styleable.GridDisplayOption_numFolderRowsLandscape,
+                        numFolderRows[INDEX_DEFAULT]);
+                numFolderColumns[INDEX_LANDSCAPE] = a.getInt(
+                        R.styleable.GridDisplayOption_numFolderColumnsLandscape,
+                        numFolderColumns[INDEX_DEFAULT]);
+                numFolderRows[INDEX_TWO_PANEL_PORTRAIT] = a.getInt(
+                        R.styleable.GridDisplayOption_numFolderRowsTwoPanelPortrait,
+                        numFolderRows[INDEX_DEFAULT]);
+                numFolderColumns[INDEX_TWO_PANEL_PORTRAIT] = a.getInt(
+                        R.styleable.GridDisplayOption_numFolderColumnsTwoPanelPortrait,
+                        numFolderColumns[INDEX_DEFAULT]);
+                numFolderRows[INDEX_TWO_PANEL_LANDSCAPE] = a.getInt(
+                        R.styleable.GridDisplayOption_numFolderRowsTwoPanelLandscape,
+                        numFolderRows[INDEX_DEFAULT]);
+                numFolderColumns[INDEX_TWO_PANEL_LANDSCAPE] = a.getInt(
+                        R.styleable.GridDisplayOption_numFolderColumnsTwoPanelLandscape,
+                        numFolderColumns[INDEX_DEFAULT]);
+            } else {
+                numFolderRows[INDEX_LANDSCAPE] = numFolderRows[INDEX_DEFAULT];
+                numFolderColumns[INDEX_LANDSCAPE] = numFolderColumns[INDEX_DEFAULT];
+                numFolderRows[INDEX_TWO_PANEL_PORTRAIT] = numFolderRows[INDEX_DEFAULT];
+                numFolderColumns[INDEX_TWO_PANEL_PORTRAIT] = numFolderColumns[INDEX_DEFAULT];
+                numFolderRows[INDEX_TWO_PANEL_LANDSCAPE] = numFolderRows[INDEX_DEFAULT];
+                numFolderColumns[INDEX_TWO_PANEL_LANDSCAPE] = numFolderColumns[INDEX_DEFAULT];
+            }
+
             folderStyle = a.getResourceId(R.styleable.GridDisplayOption_folderStyle,
                     INVALID_RESOURCE_HANDLE);
 
diff --git a/src/com/android/launcher3/Launcher.java b/src/com/android/launcher3/Launcher.java
index e301bdb..17bcfa4 100644
--- a/src/com/android/launcher3/Launcher.java
+++ b/src/com/android/launcher3/Launcher.java
@@ -80,7 +80,6 @@
 import static com.android.launcher3.logging.StatsLogManager.LauncherLatencyEvent.LAUNCHER_LATENCY_STARTUP_ACTIVITY_ON_CREATE;
 import static com.android.launcher3.logging.StatsLogManager.LauncherLatencyEvent.LAUNCHER_LATENCY_STARTUP_TOTAL_DURATION;
 import static com.android.launcher3.logging.StatsLogManager.LauncherLatencyEvent.LAUNCHER_LATENCY_STARTUP_VIEW_INFLATION;
-import static com.android.launcher3.logging.StatsLogManager.LauncherLatencyEvent.LAUNCHER_LATENCY_STARTUP_WORKSPACE_LOADER_ASYNC;
 import static com.android.launcher3.logging.StatsLogManager.StatsLatencyLogger.LatencyType.COLD;
 import static com.android.launcher3.logging.StatsLogManager.StatsLatencyLogger.LatencyType.COLD_DEVICE_REBOOTING;
 import static com.android.launcher3.logging.StatsLogManager.StatsLatencyLogger.LatencyType.WARM;
@@ -139,7 +138,6 @@
 import android.view.MotionEvent;
 import android.view.View;
 import android.view.ViewGroup;
-import android.view.ViewTreeObserver;
 import android.view.ViewTreeObserver.OnPreDrawListener;
 import android.view.WindowManager.LayoutParams;
 import android.view.accessibility.AccessibilityEvent;
@@ -159,7 +157,6 @@
 import com.android.launcher3.accessibility.LauncherAccessibilityDelegate;
 import com.android.launcher3.allapps.ActivityAllAppsContainerView;
 import com.android.launcher3.allapps.AllAppsRecyclerView;
-import com.android.launcher3.allapps.AllAppsStore;
 import com.android.launcher3.allapps.AllAppsTransitionController;
 import com.android.launcher3.allapps.DiscoveryBounce;
 import com.android.launcher3.anim.AnimationSuccessListener;
@@ -189,6 +186,7 @@
 import com.android.launcher3.logging.InstanceIdSequence;
 import com.android.launcher3.logging.StartupLatencyLogger;
 import com.android.launcher3.logging.StatsLogManager;
+import com.android.launcher3.logging.StatsLogManager.LauncherLatencyEvent;
 import com.android.launcher3.model.BgDataModel.Callbacks;
 import com.android.launcher3.model.ItemInstallQueue;
 import com.android.launcher3.model.ModelWriter;
@@ -791,7 +789,7 @@
         if (info.container >= 0) {
             View folderIcon = getWorkspace().getHomescreenIconByItemId(info.container);
             if (folderIcon instanceof FolderIcon && folderIcon.getTag() instanceof FolderInfo) {
-                if (new FolderGridOrganizer(getDeviceProfile().inv)
+                if (new FolderGridOrganizer(getDeviceProfile())
                         .setFolderInfo((FolderInfo) folderIcon.getTag())
                         .isItemInPreview(info.rank)) {
                     folderIcon.invalidate();
@@ -2475,39 +2473,20 @@
         }
     }
 
-    @Override
+    /**
+     * Call back when ModelCallbacks finish binding the Launcher data.
+     */
     @TargetApi(Build.VERSION_CODES.S)
-    public void onInitialBindComplete(IntSet boundPages, RunnableList pendingTasks,
-            int workspaceItemCount, boolean isBindSync) {
-        mModelCallbacks.setSynchronouslyBoundPages(boundPages);
-        mModelCallbacks.setPagesToBindSynchronously(new IntSet());
-
-        mModelCallbacks.clearPendingBinds();
-        ViewOnDrawExecutor executor = new ViewOnDrawExecutor(pendingTasks);
-        mModelCallbacks.setPendingExecutor(executor);
-        if (!isInState(ALL_APPS)) {
-            mAppsView.getAppsStore().enableDeferUpdates(AllAppsStore.DEFER_UPDATES_NEXT_DRAW);
-            pendingTasks.add(() -> mAppsView.getAppsStore().disableDeferUpdates(
-                    AllAppsStore.DEFER_UPDATES_NEXT_DRAW));
-        }
-
+    public void bindComplete(int workspaceItemCount, boolean isBindSync) {
         if (mOnInitialBindListener != null) {
             getRootView().getViewTreeObserver().removeOnPreDrawListener(mOnInitialBindListener);
             mOnInitialBindListener = null;
         }
-
-        executor.onLoadAnimationCompleted();
-        executor.attachTo(this);
-        if (Utilities.ATLEAST_S) {
-            Trace.endAsyncSection(DISPLAY_WORKSPACE_TRACE_METHOD_NAME,
-                    DISPLAY_WORKSPACE_TRACE_COOKIE);
-        }
         if (!isBindSync) {
             mStartupLatencyLogger
                     .logCardinality(workspaceItemCount)
-                    .logEnd(LAUNCHER_LATENCY_STARTUP_WORKSPACE_LOADER_ASYNC);
+                    .logEnd(LauncherLatencyEvent.LAUNCHER_LATENCY_STARTUP_WORKSPACE_LOADER_ASYNC);
         }
-
         MAIN_EXECUTOR.getHandler().postAtFrontOfQueue(() -> {
             mStartupLatencyLogger
                     .logEnd(LAUNCHER_LATENCY_STARTUP_TOTAL_DURATION)
@@ -2518,15 +2497,13 @@
                         COLD_STARTUP_TRACE_COOKIE);
             }
         });
-        getRootView().getViewTreeObserver().addOnDrawListener(
-                new ViewTreeObserver.OnDrawListener() {
-                    @Override
-                    public void onDraw() {
-                        MAIN_EXECUTOR.getHandler().postAtFrontOfQueue(
-                                () -> getRootView().getViewTreeObserver()
-                                        .removeOnDrawListener(this));
-                    }
-                });
+    }
+
+    @Override
+    public void onInitialBindComplete(IntSet boundPages, RunnableList pendingTasks,
+            int workspaceItemCount, boolean isBindSync) {
+        mModelCallbacks.onInitialBindComplete(boundPages, pendingTasks, workspaceItemCount,
+                isBindSync);
     }
 
     /**
diff --git a/src/com/android/launcher3/ModelCallbacks.kt b/src/com/android/launcher3/ModelCallbacks.kt
index c05158b..5172999 100644
--- a/src/com/android/launcher3/ModelCallbacks.kt
+++ b/src/com/android/launcher3/ModelCallbacks.kt
@@ -1,6 +1,11 @@
 package com.android.launcher3
 
+import android.annotation.TargetApi
+import android.os.Build
+import android.os.Trace
+import android.view.ViewTreeObserver.OnDrawListener
 import androidx.annotation.UiThread
+import com.android.launcher3.LauncherConstants.TraceEvents
 import com.android.launcher3.WorkspaceLayoutManager.FIRST_SCREEN_ID
 import com.android.launcher3.allapps.AllAppsStore
 import com.android.launcher3.config.FeatureFlags
@@ -13,11 +18,12 @@
 import com.android.launcher3.model.data.WorkspaceItemInfo
 import com.android.launcher3.popup.PopupContainerWithArrow
 import com.android.launcher3.util.ComponentKey
+import com.android.launcher3.util.Executors
 import com.android.launcher3.util.IntArray as LIntArray
 import com.android.launcher3.util.IntSet as LIntSet
-import com.android.launcher3.util.IntSet
 import com.android.launcher3.util.PackageUserKey
 import com.android.launcher3.util.Preconditions
+import com.android.launcher3.util.RunnableList
 import com.android.launcher3.util.TraceHelper
 import com.android.launcher3.util.ViewOnDrawExecutor
 import com.android.launcher3.widget.PendingAddWidgetInfo
@@ -64,6 +70,46 @@
         TraceHelper.INSTANCE.endSection()
     }
 
+    @TargetApi(Build.VERSION_CODES.S)
+    override fun onInitialBindComplete(
+        boundPages: LIntSet,
+        pendingTasks: RunnableList,
+        workspaceItemCount: Int,
+        isBindSync: Boolean
+    ) {
+        synchronouslyBoundPages = boundPages
+        pagesToBindSynchronously = LIntSet()
+        clearPendingBinds()
+        val executor = ViewOnDrawExecutor(pendingTasks)
+        pendingExecutor = executor
+        if (!launcher.isInState(LauncherState.ALL_APPS)) {
+            launcher.appsView.appsStore.enableDeferUpdates(AllAppsStore.DEFER_UPDATES_NEXT_DRAW)
+            pendingTasks.add {
+                launcher.appsView.appsStore.disableDeferUpdates(
+                    AllAppsStore.DEFER_UPDATES_NEXT_DRAW
+                )
+            }
+        }
+        executor.onLoadAnimationCompleted()
+        executor.attachTo(launcher)
+        if (Utilities.ATLEAST_S) {
+            Trace.endAsyncSection(
+                TraceEvents.DISPLAY_WORKSPACE_TRACE_METHOD_NAME,
+                TraceEvents.DISPLAY_WORKSPACE_TRACE_COOKIE
+            )
+        }
+        launcher.bindComplete(workspaceItemCount, isBindSync)
+        launcher.rootView.viewTreeObserver.addOnDrawListener(
+            object : OnDrawListener {
+                override fun onDraw() {
+                    Executors.MAIN_EXECUTOR.handler.postAtFrontOfQueue {
+                        launcher.rootView.getViewTreeObserver().removeOnDrawListener(this)
+                    }
+                }
+            }
+        )
+    }
+
     /**
      * Callback saying that there aren't any more items to bind.
      *
@@ -83,12 +129,12 @@
         // Since we are just resetting the current page without user interaction,
         // override the previous page so we don't log the page switch.
         launcher.workspace.setCurrentPage(currentPage, currentPage /* overridePrevPage */)
-        pagesToBindSynchronously = IntSet()
+        pagesToBindSynchronously = LIntSet()
 
         // Cache one page worth of icons
         launcher.viewCache.setCacheSize(
             R.layout.folder_application,
-            deviceProfile.inv.numFolderColumns * deviceProfile.inv.numFolderRows
+            deviceProfile.numFolderColumns * deviceProfile.numFolderRows
         )
         launcher.viewCache.setCacheSize(R.layout.folder_page, 2)
         TraceHelper.INSTANCE.endSection()
@@ -319,7 +365,7 @@
             } else {
                 // Some empty pages might have been removed while the phone was in a single panel
                 // mode, so we want to add those empty pages back.
-                val screenIds = IntSet.wrap(orderedScreenIds)
+                val screenIds = LIntSet.wrap(orderedScreenIds)
                 orderedScreenIds.forEach { screenId: Int ->
                     screenIds.add(launcher.workspace.getScreenPair(screenId))
                 }
@@ -343,7 +389,7 @@
      * if not present.
      */
     private fun filterTwoPanelScreenIds(orderedScreenIds: LIntArray): LIntArray {
-        val screenIds = IntSet.wrap(orderedScreenIds)
+        val screenIds = LIntSet.wrap(orderedScreenIds)
         orderedScreenIds
             .filter { screenId -> screenId % 2 == 1 }
             .forEach { screenId ->
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/celllayout/ReorderAlgorithm.java b/src/com/android/launcher3/celllayout/ReorderAlgorithm.java
index 42b6991..8754b74 100644
--- a/src/com/android/launcher3/celllayout/ReorderAlgorithm.java
+++ b/src/com/android/launcher3/celllayout/ReorderAlgorithm.java
@@ -285,6 +285,11 @@
         return foundSolution;
     }
 
+    private void revertDir(int[] direction) {
+        direction[0] *= -1;
+        direction[1] *= -1;
+    }
+
     // This method tries to find a reordering solution which satisfies the push mechanic by trying
     // to push items in each of the cardinal directions, in an order based on the direction vector
     // passed.
@@ -293,91 +298,36 @@
         if ((Math.abs(direction[0]) + Math.abs(direction[1])) > 1) {
             // If the direction vector has two non-zero components, we try pushing
             // separately in each of the components.
-            int temp = direction[1];
-            direction[1] = 0;
-
-            if (pushViewsToTempLocation(intersectingViews, occupied, direction, ignoreView,
-                    solution)) {
-                return true;
+            int temp;
+            for (int j = 0; j < 2; j++) {
+                for (int i = 1; i >= 0; i--) {
+                    temp = direction[i];
+                    direction[i] = 0;
+                    if (pushViewsToTempLocation(intersectingViews, occupied, direction, ignoreView,
+                            solution)) {
+                        return true;
+                    }
+                    direction[i] = temp;
+                }
+                revertDir(direction);
             }
-            direction[1] = temp;
-            temp = direction[0];
-            direction[0] = 0;
-
-            if (pushViewsToTempLocation(intersectingViews, occupied, direction, ignoreView,
-                    solution)) {
-                return true;
-            }
-            // Revert the direction
-            direction[0] = temp;
-
-            // Now we try pushing in each component of the opposite direction
-            direction[0] *= -1;
-            direction[1] *= -1;
-            temp = direction[1];
-            direction[1] = 0;
-            if (pushViewsToTempLocation(intersectingViews, occupied, direction, ignoreView,
-                    solution)) {
-                return true;
-            }
-
-            direction[1] = temp;
-            temp = direction[0];
-            direction[0] = 0;
-            if (pushViewsToTempLocation(intersectingViews, occupied, direction, ignoreView,
-                    solution)) {
-                return true;
-            }
-            // revert the direction
-            direction[0] = temp;
-            direction[0] *= -1;
-            direction[1] *= -1;
-
         } else {
             // If the direction vector has a single non-zero component, we push first in the
             // direction of the vector
-            if (pushViewsToTempLocation(intersectingViews, occupied, direction, ignoreView,
-                    solution)) {
-                return true;
+            int temp;
+            for (int j = 0; j < 2; j++) {
+                for (int i = 0; i < 2; i++) {
+                    if (pushViewsToTempLocation(intersectingViews, occupied, direction, ignoreView,
+                            solution)) {
+                        return true;
+                    }
+                    revertDir(direction);
+                }
+                // Swap the components
+                temp = direction[1];
+                direction[1] = direction[0];
+                direction[0] = temp;
             }
-            // Then we try the opposite direction
-            direction[0] *= -1;
-            direction[1] *= -1;
-            if (pushViewsToTempLocation(intersectingViews, occupied, direction, ignoreView,
-                    solution)) {
-                return true;
-            }
-            // Switch the direction back
-            direction[0] *= -1;
-            direction[1] *= -1;
-
-            // If we have failed to find a push solution with the above, then we try
-            // to find a solution by pushing along the perpendicular axis.
-
-            // Swap the components
-            int temp = direction[1];
-            direction[1] = direction[0];
-            direction[0] = temp;
-            if (pushViewsToTempLocation(intersectingViews, occupied, direction, ignoreView,
-                    solution)) {
-                return true;
-            }
-
-            // Then we try the opposite direction
-            direction[0] *= -1;
-            direction[1] *= -1;
-            if (pushViewsToTempLocation(intersectingViews, occupied, direction, ignoreView,
-                    solution)) {
-                return true;
-            }
-            // Switch the direction back
-            direction[0] *= -1;
-            direction[1] *= -1;
-
-            // Swap the components back
-            temp = direction[1];
-            direction[1] = direction[0];
-            direction[0] = temp;
         }
         return false;
     }
diff --git a/src/com/android/launcher3/folder/Folder.java b/src/com/android/launcher3/folder/Folder.java
index 8bf7ec2..084f829 100644
--- a/src/com/android/launcher3/folder/Folder.java
+++ b/src/com/android/launcher3/folder/Folder.java
@@ -210,7 +210,9 @@
                     @ViewDebug.IntToString(from = STATE_OPEN, to = "STATE_OPEN"),
             })
     private int mState = STATE_CLOSED;
-    private OnFolderStateChangedListener mOnFolderStateChangedListener;
+    private final List<OnFolderStateChangedListener> mOnFolderStateChangedListeners =
+            new ArrayList<>();
+    private OnFolderStateChangedListener mPriorityOnFolderStateChangedListener;
     @ViewDebug.ExportedProperty(category = "launcher")
     private boolean mRearrangeOnClose = false;
     boolean mItemsInvalidated = false;
@@ -1082,7 +1084,7 @@
 
     private void updateItemLocationsInDatabaseBatch(boolean isBind) {
         FolderGridOrganizer verifier = new FolderGridOrganizer(
-                mActivityContext.getDeviceProfile().inv).setFolderInfo(mInfo);
+                mActivityContext.getDeviceProfile()).setFolderInfo(mInfo);
 
         ArrayList<ItemInfo> items = new ArrayList<>();
         int total = mInfo.contents.size();
@@ -1381,7 +1383,7 @@
     @Override
     public void onAdd(WorkspaceItemInfo item, int rank) {
         FolderGridOrganizer verifier = new FolderGridOrganizer(
-                mActivityContext.getDeviceProfile().inv).setFolderInfo(mInfo);
+                mActivityContext.getDeviceProfile()).setFolderInfo(mInfo);
         verifier.updateRankAndPos(item, rank);
         mLauncherDelegate.getModelWriter().addOrMoveItemInDatabase(item, mInfo.id, 0, item.cellX,
                 item.cellY);
@@ -1665,18 +1667,43 @@
         return windowBottomPx - folderBottomPx;
     }
 
+    /**
+     * Save this listener for the special case of when we update the state and concurrently
+     * add another listener to {@link #mOnFolderStateChangedListeners} to avoid a
+     * ConcurrentModificationException
+     */
+    public void setPriorityOnFolderStateChangedListener(OnFolderStateChangedListener listener) {
+        mPriorityOnFolderStateChangedListener = listener;
+    }
+
     private void setState(@FolderState int newState) {
         mState = newState;
-        if (mOnFolderStateChangedListener != null) {
-            mOnFolderStateChangedListener.onFolderStateChanged(mState);
+        if (mPriorityOnFolderStateChangedListener != null) {
+            mPriorityOnFolderStateChangedListener.onFolderStateChanged(mState);
+        }
+        for (OnFolderStateChangedListener listener : mOnFolderStateChangedListeners) {
+            if (listener != null) {
+                listener.onFolderStateChanged(mState);
+            }
         }
     }
 
-    public void setOnFolderStateChangedListener(@Nullable OnFolderStateChangedListener listener) {
-        mOnFolderStateChangedListener = listener;
+    /**
+     * Adds the provided listener to the running list of Folder listeners
+     * {@link #mOnFolderStateChangedListeners}
+     */
+    public void addOnFolderStateChangedListener(@Nullable OnFolderStateChangedListener listener) {
+        if (listener != null) {
+            mOnFolderStateChangedListeners.add(listener);
+        }
     }
 
-    /** Listener that can be registered via {@link Folder#setOnFolderStateChangedListener} */
+    /** Removes the provided listener from the running list of Folder listeners */
+    public void removeOnFolderStateChangedListener(OnFolderStateChangedListener listener) {
+        mOnFolderStateChangedListeners.remove(listener);
+    }
+
+    /** Listener that can be registered via {@link #addOnFolderStateChangedListener} */
     public interface OnFolderStateChangedListener {
         /** See {@link Folder.FolderState} */
         void onFolderStateChanged(@FolderState int newState);
diff --git a/src/com/android/launcher3/folder/FolderAnimationManager.java b/src/com/android/launcher3/folder/FolderAnimationManager.java
index 9e2e2bf..a91373b 100644
--- a/src/com/android/launcher3/folder/FolderAnimationManager.java
+++ b/src/com/android/launcher3/folder/FolderAnimationManager.java
@@ -96,7 +96,7 @@
 
         mContext = folder.getContext();
         mDeviceProfile = folder.mActivityContext.getDeviceProfile();
-        mPreviewVerifier = new FolderGridOrganizer(mDeviceProfile.inv);
+        mPreviewVerifier = new FolderGridOrganizer(mDeviceProfile);
 
         mIsOpening = isOpening;
 
diff --git a/src/com/android/launcher3/folder/FolderGridOrganizer.java b/src/com/android/launcher3/folder/FolderGridOrganizer.java
index 4be82ed..cc24761 100644
--- a/src/com/android/launcher3/folder/FolderGridOrganizer.java
+++ b/src/com/android/launcher3/folder/FolderGridOrganizer.java
@@ -20,7 +20,7 @@
 
 import android.graphics.Point;
 
-import com.android.launcher3.InvariantDeviceProfile;
+import com.android.launcher3.DeviceProfile;
 import com.android.launcher3.model.data.FolderInfo;
 import com.android.launcher3.model.data.ItemInfo;
 
@@ -41,11 +41,13 @@
     private int mCountX;
     private int mCountY;
     private boolean mDisplayingUpperLeftQuadrant = false;
+    private static final int PREVIEW_MAX_ROWS = 2;
+    private static final int PREVIEW_MAX_COLUMNS = 2;
 
     /**
      * Note: must call {@link #setFolderInfo(FolderInfo)} manually for verifier to work.
      */
-    public FolderGridOrganizer(InvariantDeviceProfile profile) {
+    public FolderGridOrganizer(DeviceProfile profile) {
         mMaxCountX = profile.numFolderColumns;
         mMaxCountY = profile.numFolderRows;
         mMaxItemsPerPage = mMaxCountX * mMaxCountY;
@@ -127,6 +129,7 @@
 
     /**
      * Updates the item's cellX, cellY and rank corresponding to the provided rank.
+     *
      * @return true if there was any change
      */
     public boolean updateRankAndPos(ItemInfo item, int rank) {
@@ -189,7 +192,7 @@
         if (page > 0 || mDisplayingUpperLeftQuadrant) {
             int col = rank % mCountX;
             int row = rank / mCountX;
-            return col < 2 && row < 2;
+            return col < PREVIEW_MAX_COLUMNS && row < PREVIEW_MAX_ROWS;
         }
         return rank < MAX_NUM_ITEMS_IN_PREVIEW;
     }
diff --git a/src/com/android/launcher3/folder/FolderIcon.java b/src/com/android/launcher3/folder/FolderIcon.java
index cb1dc4f..f058ae4 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}.
  */
@@ -221,7 +220,7 @@
 
         icon.setAccessibilityDelegate(activity.getAccessibilityDelegate());
 
-        icon.mPreviewVerifier = new FolderGridOrganizer(activity.getDeviceProfile().inv);
+        icon.mPreviewVerifier = new FolderGridOrganizer(activity.getDeviceProfile());
         icon.mPreviewVerifier.setFolderInfo(folderInfo);
         icon.updatePreviewItems(false);
 
@@ -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/folder/FolderPagedView.java b/src/com/android/launcher3/folder/FolderPagedView.java
index 36e5e1b..f2bed92 100644
--- a/src/com/android/launcher3/folder/FolderPagedView.java
+++ b/src/com/android/launcher3/folder/FolderPagedView.java
@@ -37,8 +37,6 @@
 import com.android.launcher3.BubbleTextView;
 import com.android.launcher3.CellLayout;
 import com.android.launcher3.DeviceProfile;
-import com.android.launcher3.InvariantDeviceProfile;
-import com.android.launcher3.LauncherAppState;
 import com.android.launcher3.PagedView;
 import com.android.launcher3.R;
 import com.android.launcher3.ShortcutAndWidgetContainer;
@@ -101,14 +99,15 @@
 
     public FolderPagedView(Context context, AttributeSet attrs) {
         super(context, attrs);
-        InvariantDeviceProfile profile = LauncherAppState.getIDP(context);
+        ActivityContext activityContext = ActivityContext.lookupContext(context);
+        DeviceProfile profile = activityContext.getDeviceProfile();
         mOrganizer = new FolderGridOrganizer(profile);
 
         mIsRtl = Utilities.isRtl(getResources());
         setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
 
         mFocusIndicatorHelper = new ViewGroupFocusHelper(this);
-        mViewCache = ActivityContext.lookupContext(context).getViewCache();
+        mViewCache = activityContext.getViewCache();
     }
 
     public void setFolder(Folder folder) {
diff --git a/src/com/android/launcher3/model/LoaderTask.java b/src/com/android/launcher3/model/LoaderTask.java
index f4ce360..6ea3e8a 100644
--- a/src/com/android/launcher3/model/LoaderTask.java
+++ b/src/com/android/launcher3/model/LoaderTask.java
@@ -481,16 +481,17 @@
             mItemsDeleted = c.commitDeleted();
 
             // Sort the folder items, update ranks, and make sure all preview items are high res.
-            FolderGridOrganizer verifier =
-                    new FolderGridOrganizer(mApp.getInvariantDeviceProfile());
+            List<FolderGridOrganizer> verifiers =
+                    mApp.getInvariantDeviceProfile().supportedProfiles.stream().map(
+                            FolderGridOrganizer::new).toList();
             for (FolderInfo folder : mBgDataModel.folders) {
                 Collections.sort(folder.contents, Folder.ITEM_POS_COMPARATOR);
-                verifier.setFolderInfo(folder);
+                verifiers.forEach(verifier -> verifier.setFolderInfo(folder));
                 int size = folder.contents.size();
 
                 // Update ranks here to ensure there are no gaps caused by removed folder items.
-                // Ranks are the source of truth for folder items, so cellX and cellY can be ignored
-                // for now. Database will be updated once user manually modifies folder.
+                // Ranks are the source of truth for folder items, so cellX and cellY can be
+                // ignored for now. Database will be updated once user manually modifies folder.
                 for (int rank = 0; rank < size; ++rank) {
                     WorkspaceItemInfo info = folder.contents.get(rank);
                     // rank is used differently in app pairs, so don't reset
@@ -498,9 +499,9 @@
                         info.rank = rank;
                     }
 
-                    if (info.usingLowResIcon()
-                            && info.itemType == Favorites.ITEM_TYPE_APPLICATION
-                            && verifier.isItemInPreview(info.rank)) {
+                    if (info.usingLowResIcon() && info.itemType == Favorites.ITEM_TYPE_APPLICATION
+                            && verifiers.stream().anyMatch(
+                                verifier -> verifier.isItemInPreview(info.rank))) {
                         mIconCache.getTitleAndIcon(info, false);
                     }
                 }
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.
  */
diff --git a/tests/assets/dumpTests/DeviceProfileDumpTest/phonePortrait.txt b/tests/assets/dumpTests/DeviceProfileDumpTest/phonePortrait.txt
index 5f5cf5e..18752e9 100644
--- a/tests/assets/dumpTests/DeviceProfileDumpTest/phonePortrait.txt
+++ b/tests/assets/dumpTests/DeviceProfileDumpTest/phonePortrait.txt
@@ -38,8 +38,8 @@
 	iconSizePx: 147.0px (56.0dp)
 	iconTextSizePx: 38.0px (14.476191dp)
 	iconDrawablePaddingPx: 12.0px (4.571429dp)
-	inv.numFolderRows: 4
-	inv.numFolderColumns: 4
+	numFolderRows: 4
+	numFolderColumns: 4
 	folderCellWidthPx: 195.0px (74.28571dp)
 	folderCellHeightPx: 230.0px (87.61905dp)
 	folderChildIconSizePx: 147.0px (56.0dp)
diff --git a/tests/assets/dumpTests/DeviceProfileDumpTest/phonePortrait3Button.txt b/tests/assets/dumpTests/DeviceProfileDumpTest/phonePortrait3Button.txt
index 02bab0e..c0de8d8 100644
--- a/tests/assets/dumpTests/DeviceProfileDumpTest/phonePortrait3Button.txt
+++ b/tests/assets/dumpTests/DeviceProfileDumpTest/phonePortrait3Button.txt
@@ -38,8 +38,8 @@
 	iconSizePx: 147.0px (56.0dp)
 	iconTextSizePx: 38.0px (14.476191dp)
 	iconDrawablePaddingPx: 12.0px (4.571429dp)
-	inv.numFolderRows: 4
-	inv.numFolderColumns: 4
+	numFolderRows: 4
+	numFolderColumns: 4
 	folderCellWidthPx: 195.0px (74.28571dp)
 	folderCellHeightPx: 230.0px (87.61905dp)
 	folderChildIconSizePx: 147.0px (56.0dp)
diff --git a/tests/assets/dumpTests/DeviceProfileDumpTest/phoneVerticalBar.txt b/tests/assets/dumpTests/DeviceProfileDumpTest/phoneVerticalBar.txt
index 1ade779..920ba6f 100644
--- a/tests/assets/dumpTests/DeviceProfileDumpTest/phoneVerticalBar.txt
+++ b/tests/assets/dumpTests/DeviceProfileDumpTest/phoneVerticalBar.txt
@@ -38,8 +38,8 @@
 	iconSizePx: 147.0px (56.0dp)
 	iconTextSizePx: 0.0px (0.0dp)
 	iconDrawablePaddingPx: 0.0px (0.0dp)
-	inv.numFolderRows: 4
-	inv.numFolderColumns: 4
+	numFolderRows: 4
+	numFolderColumns: 4
 	folderCellWidthPx: 163.0px (62.095238dp)
 	folderCellHeightPx: 192.0px (73.14286dp)
 	folderChildIconSizePx: 123.0px (46.857143dp)
diff --git a/tests/assets/dumpTests/DeviceProfileDumpTest/phoneVerticalBar3Button.txt b/tests/assets/dumpTests/DeviceProfileDumpTest/phoneVerticalBar3Button.txt
index b0b745d..65460ec 100644
--- a/tests/assets/dumpTests/DeviceProfileDumpTest/phoneVerticalBar3Button.txt
+++ b/tests/assets/dumpTests/DeviceProfileDumpTest/phoneVerticalBar3Button.txt
@@ -38,8 +38,8 @@
 	iconSizePx: 147.0px (56.0dp)
 	iconTextSizePx: 0.0px (0.0dp)
 	iconDrawablePaddingPx: 0.0px (0.0dp)
-	inv.numFolderRows: 4
-	inv.numFolderColumns: 4
+	numFolderRows: 4
+	numFolderColumns: 4
 	folderCellWidthPx: 173.0px (65.90476dp)
 	folderCellHeightPx: 205.0px (78.09524dp)
 	folderChildIconSizePx: 131.0px (49.904762dp)
diff --git a/tests/assets/dumpTests/DeviceProfileDumpTest/tabletLandscape.txt b/tests/assets/dumpTests/DeviceProfileDumpTest/tabletLandscape.txt
index d7f3c1a..1781673 100644
--- a/tests/assets/dumpTests/DeviceProfileDumpTest/tabletLandscape.txt
+++ b/tests/assets/dumpTests/DeviceProfileDumpTest/tabletLandscape.txt
@@ -38,8 +38,8 @@
 	iconSizePx: 120.0px (60.0dp)
 	iconTextSizePx: 28.0px (14.0dp)
 	iconDrawablePaddingPx: 9.0px (4.5dp)
-	inv.numFolderRows: 3
-	inv.numFolderColumns: 3
+	numFolderRows: 3
+	numFolderColumns: 3
 	folderCellWidthPx: 240.0px (120.0dp)
 	folderCellHeightPx: 208.0px (104.0dp)
 	folderChildIconSizePx: 120.0px (60.0dp)
diff --git a/tests/assets/dumpTests/DeviceProfileDumpTest/tabletLandscape3Button.txt b/tests/assets/dumpTests/DeviceProfileDumpTest/tabletLandscape3Button.txt
index 20a2a99..bd9e267 100644
--- a/tests/assets/dumpTests/DeviceProfileDumpTest/tabletLandscape3Button.txt
+++ b/tests/assets/dumpTests/DeviceProfileDumpTest/tabletLandscape3Button.txt
@@ -38,8 +38,8 @@
 	iconSizePx: 120.0px (60.0dp)
 	iconTextSizePx: 28.0px (14.0dp)
 	iconDrawablePaddingPx: 9.0px (4.5dp)
-	inv.numFolderRows: 3
-	inv.numFolderColumns: 3
+	numFolderRows: 3
+	numFolderColumns: 3
 	folderCellWidthPx: 240.0px (120.0dp)
 	folderCellHeightPx: 208.0px (104.0dp)
 	folderChildIconSizePx: 120.0px (60.0dp)
diff --git a/tests/assets/dumpTests/DeviceProfileDumpTest/tabletPortrait.txt b/tests/assets/dumpTests/DeviceProfileDumpTest/tabletPortrait.txt
index 94022e4..e983ef7 100644
--- a/tests/assets/dumpTests/DeviceProfileDumpTest/tabletPortrait.txt
+++ b/tests/assets/dumpTests/DeviceProfileDumpTest/tabletPortrait.txt
@@ -38,8 +38,8 @@
 	iconSizePx: 120.0px (60.0dp)
 	iconTextSizePx: 28.0px (14.0dp)
 	iconDrawablePaddingPx: 9.0px (4.5dp)
-	inv.numFolderRows: 3
-	inv.numFolderColumns: 3
+	numFolderRows: 3
+	numFolderColumns: 3
 	folderCellWidthPx: 204.0px (102.0dp)
 	folderCellHeightPx: 240.0px (120.0dp)
 	folderChildIconSizePx: 120.0px (60.0dp)
diff --git a/tests/assets/dumpTests/DeviceProfileDumpTest/tabletPortrait3Button.txt b/tests/assets/dumpTests/DeviceProfileDumpTest/tabletPortrait3Button.txt
index 7977204..aa92838 100644
--- a/tests/assets/dumpTests/DeviceProfileDumpTest/tabletPortrait3Button.txt
+++ b/tests/assets/dumpTests/DeviceProfileDumpTest/tabletPortrait3Button.txt
@@ -38,8 +38,8 @@
 	iconSizePx: 120.0px (60.0dp)
 	iconTextSizePx: 28.0px (14.0dp)
 	iconDrawablePaddingPx: 9.0px (4.5dp)
-	inv.numFolderRows: 3
-	inv.numFolderColumns: 3
+	numFolderRows: 3
+	numFolderColumns: 3
 	folderCellWidthPx: 204.0px (102.0dp)
 	folderCellHeightPx: 240.0px (120.0dp)
 	folderChildIconSizePx: 120.0px (60.0dp)
diff --git a/tests/assets/dumpTests/DeviceProfileDumpTest/twoPanelLandscape.txt b/tests/assets/dumpTests/DeviceProfileDumpTest/twoPanelLandscape.txt
index 0b17996..43e4a60 100644
--- a/tests/assets/dumpTests/DeviceProfileDumpTest/twoPanelLandscape.txt
+++ b/tests/assets/dumpTests/DeviceProfileDumpTest/twoPanelLandscape.txt
@@ -38,8 +38,8 @@
 	iconSizePx: 141.0px (53.714287dp)
 	iconTextSizePx: 34.0px (12.952381dp)
 	iconDrawablePaddingPx: 13.0px (4.952381dp)
-	inv.numFolderRows: 3
-	inv.numFolderColumns: 4
+	numFolderRows: 3
+	numFolderColumns: 4
 	folderCellWidthPx: 189.0px (72.0dp)
 	folderCellHeightPx: 219.0px (83.42857dp)
 	folderChildIconSizePx: 141.0px (53.714287dp)
diff --git a/tests/assets/dumpTests/DeviceProfileDumpTest/twoPanelLandscape3Button.txt b/tests/assets/dumpTests/DeviceProfileDumpTest/twoPanelLandscape3Button.txt
index 71fffe8..e7ea839 100644
--- a/tests/assets/dumpTests/DeviceProfileDumpTest/twoPanelLandscape3Button.txt
+++ b/tests/assets/dumpTests/DeviceProfileDumpTest/twoPanelLandscape3Button.txt
@@ -38,8 +38,8 @@
 	iconSizePx: 141.0px (53.714287dp)
 	iconTextSizePx: 34.0px (12.952381dp)
 	iconDrawablePaddingPx: 13.0px (4.952381dp)
-	inv.numFolderRows: 3
-	inv.numFolderColumns: 4
+	numFolderRows: 3
+	numFolderColumns: 4
 	folderCellWidthPx: 189.0px (72.0dp)
 	folderCellHeightPx: 219.0px (83.42857dp)
 	folderChildIconSizePx: 141.0px (53.714287dp)
diff --git a/tests/assets/dumpTests/DeviceProfileDumpTest/twoPanelPortrait.txt b/tests/assets/dumpTests/DeviceProfileDumpTest/twoPanelPortrait.txt
index 5da4ed0..043380c 100644
--- a/tests/assets/dumpTests/DeviceProfileDumpTest/twoPanelPortrait.txt
+++ b/tests/assets/dumpTests/DeviceProfileDumpTest/twoPanelPortrait.txt
@@ -38,8 +38,8 @@
 	iconSizePx: 141.0px (53.714287dp)
 	iconTextSizePx: 34.0px (12.952381dp)
 	iconDrawablePaddingPx: 13.0px (4.952381dp)
-	inv.numFolderRows: 3
-	inv.numFolderColumns: 4
+	numFolderRows: 3
+	numFolderColumns: 4
 	folderCellWidthPx: 189.0px (72.0dp)
 	folderCellHeightPx: 219.0px (83.42857dp)
 	folderChildIconSizePx: 141.0px (53.714287dp)
diff --git a/tests/assets/dumpTests/DeviceProfileDumpTest/twoPanelPortrait3Button.txt b/tests/assets/dumpTests/DeviceProfileDumpTest/twoPanelPortrait3Button.txt
index 359e530..a1b3e95 100644
--- a/tests/assets/dumpTests/DeviceProfileDumpTest/twoPanelPortrait3Button.txt
+++ b/tests/assets/dumpTests/DeviceProfileDumpTest/twoPanelPortrait3Button.txt
@@ -38,8 +38,8 @@
 	iconSizePx: 141.0px (53.714287dp)
 	iconTextSizePx: 34.0px (12.952381dp)
 	iconDrawablePaddingPx: 13.0px (4.952381dp)
-	inv.numFolderRows: 3
-	inv.numFolderColumns: 4
+	numFolderRows: 3
+	numFolderColumns: 4
 	folderCellWidthPx: 189.0px (72.0dp)
 	folderCellHeightPx: 219.0px (83.42857dp)
 	folderChildIconSizePx: 141.0px (53.714287dp)
diff --git a/tests/src/com/android/launcher3/FakeInvariantDeviceProfileTest.kt b/tests/src/com/android/launcher3/FakeInvariantDeviceProfileTest.kt
index a421006..30b5663 100644
--- a/tests/src/com/android/launcher3/FakeInvariantDeviceProfileTest.kt
+++ b/tests/src/com/android/launcher3/FakeInvariantDeviceProfileTest.kt
@@ -121,8 +121,8 @@
                     listOf(PointF(16f, 16f), PointF(16f, 16f), PointF(16f, 16f), PointF(16f, 16f))
                         .toTypedArray()
 
-                numFolderRows = 3
-                numFolderColumns = 3
+                numFolderRows = intArrayOf(3, 3, 3, 3)
+                numFolderColumns = intArrayOf(3, 3, 3, 3)
                 folderStyle = R.style.FolderStyleDefault
 
                 inlineNavButtonsEndSpacing = R.dimen.taskbar_button_margin_split
@@ -204,8 +204,8 @@
                     listOf(PointF(16f, 64f), PointF(64f, 16f), PointF(16f, 64f), PointF(16f, 64f))
                         .toTypedArray()
 
-                numFolderRows = 3
-                numFolderColumns = 3
+                numFolderRows = intArrayOf(3, 3, 3, 3)
+                numFolderColumns = intArrayOf(3, 3, 3, 3)
                 folderStyle = R.style.FolderStyleDefault
 
                 inlineNavButtonsEndSpacing = R.dimen.taskbar_button_margin_6_5
@@ -288,8 +288,8 @@
                     listOf(PointF(16f, 16f), PointF(16f, 16f), PointF(16f, 20f), PointF(20f, 20f))
                         .toTypedArray()
 
-                numFolderRows = 3
-                numFolderColumns = 3
+                numFolderRows = intArrayOf(3, 3, 3, 3)
+                numFolderColumns = intArrayOf(3, 3, 3, 3)
                 folderStyle = R.style.FolderStyleDefault
 
                 inlineNavButtonsEndSpacing = R.dimen.taskbar_button_margin_split
diff --git a/tests/src/com/android/launcher3/model/FolderIconLoadTest.kt b/tests/src/com/android/launcher3/model/FolderIconLoadTest.kt
index 21bd900..60a4d2d 100644
--- a/tests/src/com/android/launcher3/model/FolderIconLoadTest.kt
+++ b/tests/src/com/android/launcher3/model/FolderIconLoadTest.kt
@@ -24,9 +24,10 @@
 import com.android.launcher3.util.LauncherModelHelper
 import com.android.launcher3.util.LauncherModelHelper.*
 import com.android.launcher3.util.TestUtil
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
 import java.util.concurrent.CountDownLatch
 import org.junit.After
-import org.junit.Assert
 import org.junit.Before
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -71,7 +72,7 @@
     @Throws(Exception::class)
     fun folderLoadedWithHighRes_2x2() {
         val items = setupAndLoadFolder(4)
-        Assert.assertEquals(4, items.size.toLong())
+        assertThat(items.size).isEqualTo(4)
         verifyHighRes(items, 0, 1, 2, 3)
     }
 
@@ -79,7 +80,7 @@
     @Throws(Exception::class)
     fun folderLoadedWithHighRes_3x2() {
         val items = setupAndLoadFolder(6)
-        Assert.assertEquals(6, items.size.toLong())
+        assertThat(items.size).isEqualTo(6)
         verifyHighRes(items, 0, 1, 3, 4)
         verifyLowRes(items, 2, 5)
     }
@@ -88,8 +89,10 @@
     @Throws(Exception::class)
     fun folderLoadedWithHighRes_max_3x3() {
         val idp = LauncherAppState.getIDP(modelHelper.sandboxContext)
-        idp.numFolderColumns = 3
-        idp.numFolderRows = 3
+        idp.numFolderColumns = intArrayOf(3, 3, 3, 3)
+        idp.numFolderRows = intArrayOf(3, 3, 3, 3)
+        recreateSupportedDeviceProfiles()
+
         val items = setupAndLoadFolder(14)
         verifyHighRes(items, 0, 1, 3, 4)
         verifyLowRes(items, 2, 5, 6, 7, 8, 9, 10, 11, 12, 13)
@@ -99,13 +102,28 @@
     @Throws(Exception::class)
     fun folderLoadedWithHighRes_max_4x4() {
         val idp = LauncherAppState.getIDP(modelHelper.sandboxContext)
-        idp.numFolderColumns = 4
-        idp.numFolderRows = 4
+        idp.numFolderColumns = intArrayOf(4, 4, 4, 4)
+        idp.numFolderRows = intArrayOf(4, 4, 4, 4)
+        recreateSupportedDeviceProfiles()
+
         val items = setupAndLoadFolder(14)
         verifyHighRes(items, 0, 1, 4, 5)
         verifyLowRes(items, 2, 3, 6, 7, 8, 9, 10, 11, 12, 13)
     }
 
+    @Test
+    @Throws(Exception::class)
+    fun folderLoadedWithHighRes_differentFolderConfigurations() {
+        val idp = LauncherAppState.getIDP(modelHelper.sandboxContext)
+        idp.numFolderColumns = intArrayOf(4, 3, 4, 4)
+        idp.numFolderRows = intArrayOf(4, 3, 4, 4)
+        recreateSupportedDeviceProfiles()
+
+        val items = setupAndLoadFolder(14)
+        verifyHighRes(items, 0, 1, 3, 4, 5)
+        verifyLowRes(items, 2, 6, 7, 8, 9, 10, 11, 12, 13)
+    }
+
     @Throws(Exception::class)
     private fun setupAndLoadFolder(itemCount: Int): ArrayList<WorkspaceItemInfo> {
         val builder =
@@ -113,9 +131,7 @@
                 .atWorkspace(0, 0, 1)
                 .putFolder("Sample")
                 .apply {
-                    for (i in 0..itemCount - 1) {
-                        this.addApp(TEST_PACKAGE, uniqueActivities[i])
-                    }
+                    for (i in 0..itemCount - 1) this.addApp(TEST_PACKAGE, uniqueActivities[i])
                 }
                 .build()
 
@@ -136,20 +152,33 @@
         app.model.forceReload()
         modelHelper.loadModelSync()
         val folders = modelHelper.getBgDataModel().folders
-        Assert.assertEquals(1, folders.size())
-        Assert.assertEquals(itemCount, folders.valueAt(0).contents.size)
+
+        assertThat(folders.size()).isEqualTo(1)
+        assertThat(folders.valueAt(0).contents.size).isEqualTo(itemCount)
         return folders.valueAt(0).contents
     }
 
     private fun verifyHighRes(items: ArrayList<WorkspaceItemInfo>, vararg indices: Int) {
         for (index in indices) {
-            Assert.assertFalse("Index $index was not highRes", items[index].bitmap.isNullOrLowRes)
+            assertWithMessage("Index $index was not highRes")
+                .that(items[index].bitmap.isNullOrLowRes)
+                .isFalse()
         }
     }
 
     private fun verifyLowRes(items: ArrayList<WorkspaceItemInfo>, vararg indices: Int) {
         for (index in indices) {
-            Assert.assertTrue("Index $index was not lowRes", items[index].bitmap.isNullOrLowRes)
+            assertWithMessage("Index $index was not lowRes")
+                .that(items[index].bitmap.isNullOrLowRes)
+                .isTrue()
         }
     }
+
+    /** Recreate DeviceProfiles after changing InvariantDeviceProfile */
+    private fun recreateSupportedDeviceProfiles() {
+        LauncherAppState.getIDP(modelHelper.sandboxContext).supportedProfiles =
+            LauncherAppState.getIDP(modelHelper.sandboxContext).supportedProfiles.map {
+                it.copy(modelHelper.sandboxContext)
+            }
+    }
 }
diff --git a/tests/src/com/android/launcher3/ui/AbstractLauncherUiTest.java b/tests/src/com/android/launcher3/ui/AbstractLauncherUiTest.java
index 8ad2249..95ed401 100644
--- a/tests/src/com/android/launcher3/ui/AbstractLauncherUiTest.java
+++ b/tests/src/com/android/launcher3/ui/AbstractLauncherUiTest.java
@@ -131,6 +131,8 @@
     /** Detects activity leaks and throws an exception if a leak is found. */
     public static void checkDetectedLeaks(LauncherInstrumentation launcher,
             boolean requireOneActiveActivityUnused) {
+        if (TestStabilityRule.isPresubmit()) return; // b/313501215
+
         final boolean requireOneActiveActivity =
                 false; // workaround for leaks when there is an unexpected Recents activity
 
diff --git a/tests/src/com/android/launcher3/ui/widget/TaplAddConfigWidgetTest.java b/tests/src/com/android/launcher3/ui/widget/TaplAddConfigWidgetTest.java
index ca72c09..d96287f 100644
--- a/tests/src/com/android/launcher3/ui/widget/TaplAddConfigWidgetTest.java
+++ b/tests/src/com/android/launcher3/ui/widget/TaplAddConfigWidgetTest.java
@@ -101,7 +101,7 @@
         // Verify that the widget id is valid and bound
         assertNotNull(mAppWidgetManager.getAppWidgetInfo(mWidgetId));
 
-        setResult(acceptConfig);
+        setResultAndWaitForAnimation(acceptConfig);
         if (acceptConfig) {
             Wait.atMost("", new WidgetSearchCondition(), DEFAULT_ACTIVITY_TIMEOUT, mLauncher);
             assertNotNull(mAppWidgetManager.getAppWidgetInfo(mWidgetId));
@@ -112,12 +112,20 @@
         }
     }
 
-    private void setResult(boolean success) {
-        mLauncher.executeAndWaitForWallpaperAnimation(() ->
-                        getInstrumentation().getTargetContext().sendBroadcast(
-                                WidgetConfigActivity.getCommandIntent(WidgetConfigActivity.class,
-                                        success ? "clickOK" : "clickCancel")),
-                "setting widget coinfig result");
+    private static void setResult(boolean success) {
+        getInstrumentation().getTargetContext().sendBroadcast(
+                WidgetConfigActivity.getCommandIntent(WidgetConfigActivity.class,
+                        success ? "clickOK" : "clickCancel"));
+    }
+
+    private void setResultAndWaitForAnimation(boolean success) {
+        if (mLauncher.isLauncher3()) {
+            setResult(success);
+        } else {
+            mLauncher.executeAndWaitForWallpaperAnimation(
+                    () -> setResult(success),
+                    "setting widget coinfig result");
+        }
     }
 
     /**
diff --git a/tests/src/com/android/launcher3/util/viewcapture_analysis/ViewCaptureAnalyzer.java b/tests/src/com/android/launcher3/util/viewcapture_analysis/ViewCaptureAnalyzer.java
index ba02473..b27ccbf 100644
--- a/tests/src/com/android/launcher3/util/viewcapture_analysis/ViewCaptureAnalyzer.java
+++ b/tests/src/com/android/launcher3/util/viewcapture_analysis/ViewCaptureAnalyzer.java
@@ -35,7 +35,7 @@
 
     // All detectors. They will be invoked in the order listed here.
     private static final AnomalyDetector[] ANOMALY_DETECTORS = {
-            new AlphaJumpDetector(),
+//            new AlphaJumpDetector(), // b/309014345
 //            new FlashDetector(), // b/309014345
             new PositionJumpDetector()
     };