Merge "Fix crash when restoring data from phone to tablet with responsive grid" into main
diff --git a/quickstep/src/com/android/launcher3/taskbar/DesktopNavbarButtonsViewController.java b/quickstep/src/com/android/launcher3/taskbar/DesktopNavbarButtonsViewController.java
index 29c5204..0a9dfff 100644
--- a/quickstep/src/com/android/launcher3/taskbar/DesktopNavbarButtonsViewController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/DesktopNavbarButtonsViewController.java
@@ -18,12 +18,15 @@
 import static com.android.launcher3.taskbar.TaskbarNavButtonController.BUTTON_NOTIFICATIONS;
 import static com.android.launcher3.taskbar.TaskbarNavButtonController.BUTTON_QUICK_SETTINGS;
 
+import android.content.Context;
 import android.content.pm.ActivityInfo.Config;
 import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
 import android.widget.FrameLayout;
 
+import androidx.annotation.Nullable;
+
 import com.android.launcher3.R;
 
 /**
@@ -40,8 +43,8 @@
     private TaskbarControllers mControllers;
 
     public DesktopNavbarButtonsViewController(TaskbarActivityContext context,
-            FrameLayout navButtonsView) {
-        super(context, navButtonsView);
+            @Nullable Context navigationBarPanelContext, FrameLayout navButtonsView) {
+        super(context, navigationBarPanelContext, navButtonsView);
         mContext = context;
         mNavButtonsView = navButtonsView;
         mNavButtonContainer = mNavButtonsView.findViewById(R.id.end_nav_buttons);
diff --git a/quickstep/src/com/android/launcher3/taskbar/NavbarButtonsViewController.java b/quickstep/src/com/android/launcher3/taskbar/NavbarButtonsViewController.java
index 3514447..bed4c37 100644
--- a/quickstep/src/com/android/launcher3/taskbar/NavbarButtonsViewController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/NavbarButtonsViewController.java
@@ -53,6 +53,7 @@
 import android.annotation.DrawableRes;
 import android.annotation.IdRes;
 import android.annotation.LayoutRes;
+import android.content.Context;
 import android.content.pm.ActivityInfo.Config;
 import android.content.res.ColorStateList;
 import android.content.res.Resources;
@@ -80,6 +81,8 @@
 import android.widget.ImageView;
 import android.widget.LinearLayout;
 
+import androidx.annotation.Nullable;
+
 import com.android.launcher3.DeviceProfile;
 import com.android.launcher3.LauncherAnimUtils;
 import com.android.launcher3.R;
@@ -146,6 +149,7 @@
     private int mState;
 
     private final TaskbarActivityContext mContext;
+    private final @Nullable Context mNavigationBarPanelContext;
     private final WindowManagerProxy mWindowManagerProxy;
     private final FrameLayout mNavButtonsView;
     private final LinearLayout mNavButtonContainer;
@@ -203,8 +207,10 @@
     private final RecentsHitboxExtender mHitboxExtender = new RecentsHitboxExtender();
     private ImageView mRecentsButton;
 
-    public NavbarButtonsViewController(TaskbarActivityContext context, FrameLayout navButtonsView) {
+    public NavbarButtonsViewController(TaskbarActivityContext context,
+            @Nullable Context navigationBarPanelContext, FrameLayout navButtonsView) {
         mContext = context;
+        mNavigationBarPanelContext = navigationBarPanelContext;
         mWindowManagerProxy = WindowManagerProxy.INSTANCE.get(mContext);
         mNavButtonsView = navButtonsView;
         mNavButtonContainer = mNavButtonsView.findViewById(R.id.end_nav_buttons);
@@ -312,7 +318,8 @@
             rotationButton.hide();
             mControllers.rotationButtonController.setRotationButton(rotationButton, null);
         } else {
-            mFloatingRotationButton = new FloatingRotationButton(mContext,
+            mFloatingRotationButton = new FloatingRotationButton(
+                    ENABLE_TASKBAR_NAVBAR_UNIFICATION ? mNavigationBarPanelContext : mContext,
                     R.string.accessibility_rotate_button,
                     R.layout.rotate_suggestion,
                     R.id.rotate_suggestion,
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java
index e106506..38ee4ac 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java
@@ -74,9 +74,11 @@
 import com.android.launcher3.AbstractFloatingView;
 import com.android.launcher3.BubbleTextView;
 import com.android.launcher3.DeviceProfile;
+import com.android.launcher3.LauncherPrefs;
 import com.android.launcher3.LauncherSettings.Favorites;
 import com.android.launcher3.R;
 import com.android.launcher3.anim.AnimatorPlaybackController;
+import com.android.launcher3.apppairs.AppPairIcon;
 import com.android.launcher3.config.FeatureFlags;
 import com.android.launcher3.dot.DotInfo;
 import com.android.launcher3.folder.Folder;
@@ -145,6 +147,8 @@
 
     private static final String WINDOW_TITLE = "Taskbar";
 
+    private final @Nullable Context mNavigationBarPanelContext;
+
     private final TaskbarDragLayer mDragLayer;
     private final TaskbarControllers mControllers;
 
@@ -180,11 +184,15 @@
 
     private DeviceProfile mPersistentTaskbarDeviceProfile;
 
-    public TaskbarActivityContext(Context windowContext, DeviceProfile launcherDp,
+    private final LauncherPrefs mLauncherPrefs;
+
+    public TaskbarActivityContext(Context windowContext,
+            @Nullable Context navigationBarPanelContext, DeviceProfile launcherDp,
             TaskbarNavButtonController buttonController, ScopedUnfoldTransitionProgressProvider
             unfoldTransitionProgressProvider) {
         super(windowContext);
 
+        mNavigationBarPanelContext = navigationBarPanelContext;
         applyDeviceProfile(launcherDp);
         final Resources resources = getResources();
 
@@ -258,8 +266,10 @@
                 new TaskbarDragController(this),
                 buttonController,
                 isDesktopMode
-                        ? new DesktopNavbarButtonsViewController(this, navButtonsView)
-                        : new NavbarButtonsViewController(this, navButtonsView),
+                        ? new DesktopNavbarButtonsViewController(this, mNavigationBarPanelContext,
+                                navButtonsView)
+                        : new NavbarButtonsViewController(this, mNavigationBarPanelContext,
+                                navButtonsView),
                 rotationButtonController,
                 new TaskbarDragLayerController(this, mDragLayer),
                 new TaskbarViewController(this, taskbarView),
@@ -287,6 +297,8 @@
                 new KeyboardQuickSwitchController(),
                 new TaskbarPinningController(this),
                 bubbleControllersOptional);
+
+        mLauncherPrefs = LauncherPrefs.get(this);
     }
 
     /** Updates {@link DeviceProfile} instances for any Taskbar windows. */
@@ -404,6 +416,11 @@
                 getDeviceProfile().toSmallString());
     }
 
+    @NonNull
+    public LauncherPrefs getLauncherPrefs() {
+        return mLauncherPrefs;
+    }
+
     /**
      * Returns the View bounds of transient taskbar.
      */
@@ -998,7 +1015,7 @@
                         Toast.LENGTH_SHORT).show();
             } else {
                 // Else launch the selected app pair
-                launchFromTaskbarPreservingSplitIfVisible(recents, fi.contents);
+                launchFromTaskbarPreservingSplitIfVisible(recents, view, fi.contents);
                 mControllers.uiController.onTaskbarIconLaunched(fi);
                 mControllers.taskbarStashController.updateAndAnimateTransientTaskbar(true);
             }
@@ -1034,7 +1051,7 @@
                                     .startShortcut(packageName, id, null, null, info.user);
                         } else {
                             launchFromTaskbarPreservingSplitIfVisible(
-                                    recents, Collections.singletonList(info));
+                                    recents, view, Collections.singletonList(info));
                         }
 
                     } catch (NullPointerException
@@ -1072,7 +1089,8 @@
                 // If we are selecting a second app for split, launch the split tasks
                 taskbarUIController.triggerSecondAppForSplit(info, info.intent, view);
             } else {
-                launchFromTaskbarPreservingSplitIfVisible(recents, Collections.singletonList(info));
+                launchFromTaskbarPreservingSplitIfVisible(
+                        recents, view, Collections.singletonList(info));
             }
             mControllers.uiController.onTaskbarIconLaunched(info);
             mControllers.taskbarStashController.updateAndAnimateTransientTaskbar(true);
@@ -1094,7 +1112,7 @@
      * (potentially breaking a split pair).
      */
     private void launchFromTaskbarPreservingSplitIfVisible(@Nullable RecentsView recents,
-            List<? extends ItemInfo> itemInfos) {
+            @Nullable View launchingIconView, List<? extends ItemInfo> itemInfos) {
         if (recents == null) {
             return;
         }
@@ -1122,8 +1140,7 @@
                     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));
+                                (AppPairIcon) launchingIconView);
                     } else {
                         startItemInfoActivity(itemInfos.get(0));
                     }
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarForceVisibleImmersiveController.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarForceVisibleImmersiveController.java
index 294925f..333c07b 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarForceVisibleImmersiveController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarForceVisibleImmersiveController.java
@@ -22,7 +22,7 @@
 import static android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK;
 
 import static com.android.launcher3.taskbar.NavbarButtonsViewController.ALPHA_INDEX_IMMERSIVE_MODE;
-import static com.android.systemui.shared.system.QuickStepContract.SYSUI_STATE_IMMERSIVE_MODE;
+import static com.android.systemui.shared.system.QuickStepContract.SYSUI_STATE_ALLOW_GESTURE_IGNORING_BAR_VISIBILITY;
 
 import android.os.Bundle;
 import android.os.Handler;
@@ -84,7 +84,7 @@
 
     /** Update values tracked via sysui flags. */
     public void updateSysuiFlags(int sysuiFlags) {
-        mIsImmersiveMode = (sysuiFlags & SYSUI_STATE_IMMERSIVE_MODE) != 0;
+        mIsImmersiveMode = (sysuiFlags & SYSUI_STATE_ALLOW_GESTURE_IGNORING_BAR_VISIBILITY) == 0;
         if (mContext.isNavBarForceVisible()) {
             if (mIsImmersiveMode) {
                 startIconDimming();
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarManager.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarManager.java
index c0b07e7..bbac116 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarManager.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarManager.java
@@ -106,6 +106,7 @@
             Settings.Secure.NAV_BAR_KIDS_MODE);
 
     private final Context mContext;
+    private final @Nullable Context mNavigationBarPanelContext;
     private WindowManager mWindowManager;
     private FrameLayout mTaskbarRootLayout;
     private boolean mAddedWindow;
@@ -198,6 +199,9 @@
         mContext = service.createWindowContext(display,
                 ENABLE_TASKBAR_NAVBAR_UNIFICATION ? TYPE_NAVIGATION_BAR : TYPE_NAVIGATION_BAR_PANEL,
                 null);
+        mNavigationBarPanelContext = ENABLE_TASKBAR_NAVBAR_UNIFICATION
+                ? service.createWindowContext(display, TYPE_NAVIGATION_BAR_PANEL, null)
+                : null;
         if (enableTaskbarNoRecreate()) {
             mWindowManager = mContext.getSystemService(WindowManager.class);
             mTaskbarRootLayout = new FrameLayout(mContext) {
@@ -435,8 +439,9 @@
             }
 
             if (enableTaskbarNoRecreate() || mTaskbarActivityContext == null) {
-                mTaskbarActivityContext = new TaskbarActivityContext(mContext, dp,
-                        mNavButtonController, mUnfoldProgressProvider);
+                mTaskbarActivityContext = new TaskbarActivityContext(mContext,
+                        mNavigationBarPanelContext, dp, mNavButtonController,
+                        mUnfoldProgressProvider);
             } else {
                 mTaskbarActivityContext.updateDeviceProfile(dp);
             }
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarPinningController.kt b/quickstep/src/com/android/launcher3/taskbar/TaskbarPinningController.kt
index cbfa024..6cb28ee 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarPinningController.kt
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarPinningController.kt
@@ -16,7 +16,9 @@
 package com.android.launcher3.taskbar
 
 import android.animation.AnimatorSet
+import android.annotation.SuppressLint
 import android.view.View
+import androidx.annotation.VisibleForTesting
 import androidx.core.animation.doOnEnd
 import com.android.launcher3.LauncherPrefs
 import com.android.launcher3.LauncherPrefs.Companion.TASKBAR_PINNING
@@ -31,46 +33,68 @@
 
     private lateinit var controllers: TaskbarControllers
     private lateinit var taskbarSharedState: TaskbarSharedState
-    private val launcherPrefs = LauncherPrefs.get(context)
+    private lateinit var launcherPrefs: LauncherPrefs
     private val statsLogManager = context.statsLogManager
-    private var isAnimatingTaskbarPinning = false
+    @VisibleForTesting var isAnimatingTaskbarPinning = false
+    @VisibleForTesting lateinit var onCloseCallback: (preferenceChanged: Boolean) -> Unit
 
+    @SuppressLint("VisibleForTests")
     fun init(taskbarControllers: TaskbarControllers, sharedState: TaskbarSharedState) {
         controllers = taskbarControllers
         taskbarSharedState = sharedState
+        launcherPrefs = context.launcherPrefs
+        onCloseCallback =
+            fun(didPreferenceChange: Boolean) {
+                statsLogManager.logger().log(LAUNCHER_TASKBAR_DIVIDER_MENU_CLOSE)
+                context.dragLayer.post { context.onPopupVisibilityChanged(false) }
+
+                if (!didPreferenceChange) {
+                    return
+                }
+                val animateToValue =
+                    if (!launcherPrefs.get(TASKBAR_PINNING)) {
+                        PINNING_PERSISTENT
+                    } else {
+                        PINNING_TRANSIENT
+                    }
+                taskbarSharedState.taskbarWasPinned = animateToValue == PINNING_TRANSIENT
+                animateTaskbarPinning(animateToValue)
+            }
     }
 
     fun showPinningView(view: View) {
         context.isTaskbarWindowFullscreen = true
-
         view.post {
-            val popupView = createAndPopulate(view, context)
+            val popupView = getPopupView(view)
             popupView.requestFocus()
-
-            popupView.onCloseCallback =
-                callback@{ didPreferenceChange ->
-                    statsLogManager.logger().log(LAUNCHER_TASKBAR_DIVIDER_MENU_CLOSE)
-                    context.dragLayer.post { context.onPopupVisibilityChanged(false) }
-
-                    if (!didPreferenceChange) {
-                        return@callback
-                    }
-                    val animateToValue =
-                        if (!launcherPrefs.get(TASKBAR_PINNING)) {
-                            PINNING_PERSISTENT
-                        } else {
-                            PINNING_TRANSIENT
-                        }
-                    taskbarSharedState.taskbarWasPinned = animateToValue == PINNING_TRANSIENT
-                    animateTaskbarPinning(animateToValue)
-                }
+            popupView.onCloseCallback = onCloseCallback
             context.onPopupVisibilityChanged(true)
             popupView.show()
             statsLogManager.logger().log(LAUNCHER_TASKBAR_DIVIDER_MENU_OPEN)
         }
     }
 
-    private fun animateTaskbarPinning(animateToValue: Float) {
+    @VisibleForTesting
+    fun getPopupView(view: View): TaskbarDividerPopupView<*> {
+        return createAndPopulate(view, context)
+    }
+
+    @VisibleForTesting
+    fun animateTaskbarPinning(animateToValue: Float) {
+        val taskbarViewController = controllers.taskbarViewController
+        val animatorSet =
+            getAnimatorSetForTaskbarPinningAnimation(animateToValue).apply {
+                doOnEnd { recreateTaskbarAndUpdatePinningValue() }
+                duration = PINNING_ANIMATION_DURATION
+            }
+        controllers.taskbarOverlayController.hideWindow()
+        updateIsAnimatingTaskbarPinningAndNotifyTaskbarDragLayer(true)
+        taskbarViewController.animateAwayNotificationDotsDuringTaskbarPinningAnimation()
+        animatorSet.start()
+    }
+
+    @VisibleForTesting
+    fun getAnimatorSetForTaskbarPinningAnimation(animateToValue: Float): AnimatorSet {
         val animatorSet = AnimatorSet()
         val taskbarViewController = controllers.taskbarViewController
         val dragLayerController = controllers.taskbarDragLayerController
@@ -82,13 +106,7 @@
             taskbarViewController.taskbarIconTranslationXForPinning.animateToValue(animateToValue)
         )
 
-        controllers.taskbarOverlayController.hideWindow()
-
-        animatorSet.doOnEnd { recreateTaskbarAndUpdatePinningValue() }
-        animatorSet.duration = PINNING_ANIMATION_DURATION
-        updateIsAnimatingTaskbarPinningAndNotifyTaskbarDragLayer(true)
-        taskbarViewController.animateAwayNotificationDotsDuringTaskbarPinningAnimation()
-        animatorSet.start()
+        return animatorSet
     }
 
     private fun updateIsAnimatingTaskbarPinningAndNotifyTaskbarDragLayer(isAnimating: Boolean) {
@@ -96,7 +114,8 @@
         context.dragLayer.setAnimatingTaskbarPinning(isAnimating)
     }
 
-    private fun recreateTaskbarAndUpdatePinningValue() {
+    @VisibleForTesting
+    fun recreateTaskbarAndUpdatePinningValue() {
         updateIsAnimatingTaskbarPinningAndNotifyTaskbarDragLayer(false)
         launcherPrefs.put(TASKBAR_PINNING, !launcherPrefs.get(TASKBAR_PINNING))
     }
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarSharedState.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarSharedState.java
index 176a8c5..1224b3f 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarSharedState.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarSharedState.java
@@ -73,9 +73,17 @@
     };
 
     // Allows us to shift translation logic when doing taskbar pinning animation.
-    public Boolean startTaskbarVariantIsTransient = true;
+    public boolean startTaskbarVariantIsTransient = true;
 
     // To track if taskbar was pinned using taskbar pinning feature at the time of recreate,
     // so we can unstash transient taskbar when we un-pinning taskbar.
-    public Boolean taskbarWasPinned = false;
+    private boolean mTaskbarWasPinned = false;
+
+    public boolean getTaskbarWasPinned() {
+        return mTaskbarWasPinned;
+    }
+
+    public void setTaskbarWasPinned(boolean taskbarWasPinned) {
+        mTaskbarWasPinned = taskbarWasPinned;
+    }
 }
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarStashController.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarStashController.java
index 9c532ec..c74ddcb 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarStashController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarStashController.java
@@ -307,7 +307,7 @@
         boolean isTransientTaskbar = DisplayController.isTransientTaskbar(mActivity);
         boolean isInSetup = !mActivity.isUserSetupComplete() || setupUIVisible;
         updateStateForFlag(FLAG_STASHED_IN_APP_AUTO,
-                isTransientTaskbar && !mTaskbarSharedState.taskbarWasPinned);
+                isTransientTaskbar && !mTaskbarSharedState.getTaskbarWasPinned());
         updateStateForFlag(FLAG_STASHED_IN_APP_SETUP, isInSetup);
         updateStateForFlag(FLAG_IN_SETUP, isInSetup);
         updateStateForFlag(FLAG_STASHED_SMALL_SCREEN, isPhoneMode()
@@ -316,7 +316,7 @@
         // us that we're paused until a bit later. This avoids flickering upon recreating taskbar.
         updateStateForFlag(FLAG_IN_APP, true);
         applyState(/* duration = */ 0);
-        if (mTaskbarSharedState.taskbarWasPinned) {
+        if (mTaskbarSharedState.getTaskbarWasPinned()) {
             tryStartTaskbarTimeout();
         }
         notifyStashChange(/* visible */ false, /* stashed */ isStashedInApp());
diff --git a/quickstep/src/com/android/launcher3/taskbar/allapps/TaskbarAllAppsSlideInView.java b/quickstep/src/com/android/launcher3/taskbar/allapps/TaskbarAllAppsSlideInView.java
index 5ce2a7a..964d329 100644
--- a/quickstep/src/com/android/launcher3/taskbar/allapps/TaskbarAllAppsSlideInView.java
+++ b/quickstep/src/com/android/launcher3/taskbar/allapps/TaskbarAllAppsSlideInView.java
@@ -222,7 +222,7 @@
         if (ev.getAction() == MotionEvent.ACTION_DOWN) {
             mNoIntercept = !mAppsView.shouldContainerScroll(ev)
                     || getTopOpenViewWithType(
-                            mActivityContext, TYPE_ACCESSIBLE & ~TYPE_TASKBAR_OVERLAYS) != null;
+                            mActivityContext, TYPE_TOUCH_CONTROLLER_NO_INTERCEPT) != null;
         }
         return super.onControllerInterceptTouchEvent(ev);
     }
diff --git a/quickstep/src/com/android/launcher3/taskbar/overlay/TaskbarOverlayController.java b/quickstep/src/com/android/launcher3/taskbar/overlay/TaskbarOverlayController.java
index c4eeea7..adbec65 100644
--- a/quickstep/src/com/android/launcher3/taskbar/overlay/TaskbarOverlayController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/overlay/TaskbarOverlayController.java
@@ -16,6 +16,7 @@
 package com.android.launcher3.taskbar.overlay;
 
 import static android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS;
+import static android.view.WindowManager.LayoutParams.PRIVATE_FLAG_CONSUME_IME_INSETS;
 import static android.view.WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
 
 import static com.android.launcher3.AbstractFloatingView.TYPE_ALL;
@@ -187,6 +188,7 @@
         layoutParams.setFitInsetsTypes(0); // Handled by container view.
         layoutParams.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS;
         layoutParams.setSystemApplicationOverlay(true);
+        layoutParams.privateFlags = PRIVATE_FLAG_CONSUME_IME_INSETS;
         return layoutParams;
     }
 
diff --git a/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java b/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java
index b685d3c..14e258b 100644
--- a/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java
+++ b/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java
@@ -106,6 +106,7 @@
 import com.android.launcher3.accessibility.LauncherAccessibilityDelegate;
 import com.android.launcher3.anim.AnimatorPlaybackController;
 import com.android.launcher3.anim.PendingAnimation;
+import com.android.launcher3.apppairs.AppPairIcon;
 import com.android.launcher3.appprediction.PredictionRowView;
 import com.android.launcher3.config.FeatureFlags;
 import com.android.launcher3.desktop.DesktopRecentsTransitionController;
@@ -116,7 +117,6 @@
 import com.android.launcher3.model.BgDataModel.FixedContainerItems;
 import com.android.launcher3.model.WellbeingModel;
 import com.android.launcher3.model.data.ItemInfo;
-import com.android.launcher3.model.data.WorkspaceItemInfo;
 import com.android.launcher3.popup.SystemShortcut;
 import com.android.launcher3.proxy.ProxyActivityStarter;
 import com.android.launcher3.statehandlers.DepthController;
@@ -1284,8 +1284,8 @@
     /**
      * Launches two apps as an app pair.
      */
-    public void launchAppPair(WorkspaceItemInfo app1, WorkspaceItemInfo app2) {
-        mSplitSelectStateController.getAppPairsController().launchAppPair(app1, app2);
+    public void launchAppPair(AppPairIcon appPairIcon) {
+        mSplitSelectStateController.getAppPairsController().launchAppPair(appPairIcon);
     }
 
     public boolean canStartHomeSafely() {
diff --git a/quickstep/src/com/android/launcher3/uioverrides/flags/DeveloperOptionsUI.java b/quickstep/src/com/android/launcher3/uioverrides/flags/DeveloperOptionsUI.java
index 301fbe4..c1a85fa 100644
--- a/quickstep/src/com/android/launcher3/uioverrides/flags/DeveloperOptionsUI.java
+++ b/quickstep/src/com/android/launcher3/uioverrides/flags/DeveloperOptionsUI.java
@@ -28,6 +28,7 @@
 import static com.android.launcher3.LauncherPrefs.LONG_PRESS_NAV_HANDLE_HAPTIC_HINT_START_SCALE_PERCENT;
 import static com.android.launcher3.LauncherPrefs.LONG_PRESS_NAV_HANDLE_SLOP_PERCENTAGE;
 import static com.android.launcher3.LauncherPrefs.LONG_PRESS_NAV_HANDLE_TIMEOUT_MS;
+import static com.android.launcher3.LauncherPrefs.PRIVATE_SPACE_APPS;
 import static com.android.launcher3.settings.SettingsActivity.EXTRA_FRAGMENT_HIGHLIGHT_KEY;
 import static com.android.launcher3.uioverrides.plugins.PluginManagerWrapper.PLUGIN_CHANGED;
 import static com.android.launcher3.uioverrides.plugins.PluginManagerWrapper.pluginEnabledKey;
@@ -67,6 +68,7 @@
 import androidx.preference.SwitchPreference;
 
 import com.android.launcher3.ConstantItem;
+import com.android.launcher3.Flags;
 import com.android.launcher3.LauncherPrefs;
 import com.android.launcher3.R;
 import com.android.launcher3.config.FeatureFlags;
@@ -115,6 +117,9 @@
             addAllAppsFromOverviewCatergory();
         }
         addCustomLpnhCategory();
+        if (Flags.enablePrivateSpace()) {
+            addCustomPrivateAppsCategory();
+        }
     }
 
     private void filterPreferences(String query, PreferenceGroup pg) {
@@ -365,6 +370,12 @@
         }
     }
 
+    private void addCustomPrivateAppsCategory() {
+        PreferenceCategory category = newCategory("Apps in Private Space Config");
+        category.addPreference(createSeekBarPreference(
+                "Number of Apps to put in private region", 0, 100, 1, PRIVATE_SPACE_APPS));
+    }
+
     /**
      * Create a preference with text and a seek bar. Should be added to a PreferenceCategory.
      *
diff --git a/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/PortraitStatesTouchController.java b/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/PortraitStatesTouchController.java
index 8cbf239..2c937b0 100644
--- a/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/PortraitStatesTouchController.java
+++ b/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/PortraitStatesTouchController.java
@@ -15,8 +15,7 @@
  */
 package com.android.launcher3.uioverrides.touchcontrollers;
 
-import static com.android.launcher3.AbstractFloatingView.TYPE_ACCESSIBLE;
-import static com.android.launcher3.AbstractFloatingView.TYPE_ALL_APPS_EDU;
+import static com.android.launcher3.AbstractFloatingView.TYPE_TOUCH_CONTROLLER_NO_INTERCEPT;
 import static com.android.launcher3.AbstractFloatingView.getTopOpenViewWithType;
 import static com.android.launcher3.LauncherState.ALL_APPS;
 import static com.android.launcher3.LauncherState.NORMAL;
@@ -84,7 +83,7 @@
                 return false;
             }
         }
-        if (getTopOpenViewWithType(mLauncher, TYPE_ACCESSIBLE | TYPE_ALL_APPS_EDU) != null) {
+        if (getTopOpenViewWithType(mLauncher, TYPE_TOUCH_CONTROLLER_NO_INTERCEPT) != null) {
             return false;
         }
         return true;
diff --git a/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/TaskViewTouchController.java b/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/TaskViewTouchController.java
index 3d94857..19bfe06 100644
--- a/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/TaskViewTouchController.java
+++ b/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/TaskViewTouchController.java
@@ -15,7 +15,7 @@
  */
 package com.android.launcher3.uioverrides.touchcontrollers;
 
-import static com.android.launcher3.AbstractFloatingView.TYPE_ACCESSIBLE;
+import static com.android.launcher3.AbstractFloatingView.TYPE_TOUCH_CONTROLLER_NO_INTERCEPT;
 import static com.android.launcher3.LauncherAnimUtils.SUCCESS_TRANSITION_PROGRESS;
 import static com.android.launcher3.touch.SingleAxisSwipeDetector.DIRECTION_BOTH;
 
@@ -112,7 +112,8 @@
             // If we are already animating from a previous state, we can intercept.
             return true;
         }
-        if (AbstractFloatingView.getTopOpenViewWithType(mActivity, TYPE_ACCESSIBLE) != null) {
+        if (AbstractFloatingView.getTopOpenViewWithType(
+                mActivity, TYPE_TOUCH_CONTROLLER_NO_INTERCEPT) != null) {
             return false;
         }
         return isRecentsInteractive();
diff --git a/quickstep/src/com/android/quickstep/SystemUiProxy.java b/quickstep/src/com/android/quickstep/SystemUiProxy.java
index 27de20c..94ed5b9 100644
--- a/quickstep/src/com/android/quickstep/SystemUiProxy.java
+++ b/quickstep/src/com/android/quickstep/SystemUiProxy.java
@@ -17,7 +17,6 @@
 
 import static android.app.ActivityManager.RECENT_IGNORE_UNAVAILABLE;
 
-import static com.android.launcher3.testing.shared.TestProtocol.testLogD;
 import static com.android.launcher3.util.Executors.MAIN_EXECUTOR;
 import static com.android.launcher3.util.Executors.UI_HELPER_EXECUTOR;
 import static com.android.launcher3.util.SplitConfigurationOptions.StagePosition;
@@ -102,6 +101,7 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.LinkedHashMap;
+import java.util.List;
 
 /**
  * Holds the reference to SystemUI.
@@ -147,6 +147,9 @@
     private IDesktopTaskListener mDesktopTaskListener;
     private final LinkedHashMap<RemoteTransition, TransitionFilter> mRemoteTransitions =
             new LinkedHashMap<>();
+
+    private final List<Runnable> mStateChangeCallbacks = new ArrayList<>();
+
     private IBinder mOriginalTransactionToken = null;
     private IOnBackInvokedCallback mBackToLauncherCallback;
     private IRemoteAnimationRunner mBackToLauncherRunner;
@@ -268,6 +271,7 @@
         setDesktopTaskListener(mDesktopTaskListener);
         setAssistantOverridesRequested(
                 AssistUtils.newInstance(mContext).getSysUiAssistOverrideInvocationTypes());
+        mStateChangeCallbacks.forEach(Runnable::run);
     }
 
     /**
@@ -278,6 +282,20 @@
         setProxy(null, null, null, null, null, null, null, null, null, null, null, null, null);
     }
 
+    /**
+     * Adds a callback to be notified whenever the active state changes
+     */
+    public void addOnStateChangeListener(Runnable callback) {
+        mStateChangeCallbacks.add(callback);
+    }
+
+    /**
+     * Removes a previously added state change callback
+     */
+    public void removeOnStateChangeListener(Runnable callback) {
+        mStateChangeCallbacks.remove(callback);
+    }
+
     // TODO(141886704): Find a way to remove this
     public void setLastSystemUiStateFlags(int stateFlags) {
         mLastSystemUiStateFlags = stateFlags;
@@ -1082,6 +1100,25 @@
     }
 
     /**
+     * Returns a surface which can be used to attach overlays to home task or null if
+     * the task doesn't exist or sysui is not connected
+     */
+    @Nullable
+    public SurfaceControl getHomeTaskOverlayContainer() {
+        // Use a local reference as this method can be called on a worker thread, which can lead
+        // to NullPointer exceptions if mShellTransitions is modified on the main thread.
+        IShellTransitions shellTransitions = mShellTransitions;
+        if (shellTransitions != null) {
+            try {
+                return mShellTransitions.getHomeTaskOverlayContainer();
+            } catch (RemoteException e) {
+                Log.w(TAG, "Failed call getOverlayContainerForTask", e);
+            }
+        }
+        return null;
+    }
+
+    /**
      * Use SystemUI's transaction-queue instead of Launcher's independent one. This is necessary
      * if Launcher and SystemUI need to coordinate transactions (eg. for shell transitions).
      */
diff --git a/quickstep/src/com/android/quickstep/TaskViewUtils.java b/quickstep/src/com/android/quickstep/TaskViewUtils.java
index ddddc89..11c5ab4 100644
--- a/quickstep/src/com/android/quickstep/TaskViewUtils.java
+++ b/quickstep/src/com/android/quickstep/TaskViewUtils.java
@@ -18,8 +18,6 @@
 import static android.view.RemoteAnimationTarget.MODE_CLOSING;
 import static android.view.RemoteAnimationTarget.MODE_OPENING;
 import static android.view.WindowManager.LayoutParams.TYPE_DOCK_DIVIDER;
-import static android.view.WindowManager.TRANSIT_OPEN;
-import static android.view.WindowManager.TRANSIT_TO_FRONT;
 
 import static com.android.app.animation.Interpolators.LINEAR;
 import static com.android.app.animation.Interpolators.TOUCH_RESPONSE;
@@ -421,106 +419,34 @@
      * Technically this case should be taken care of by
      * {@link #composeRecentsSplitLaunchAnimatorLegacy} below, but the way we launch tasks whether
      * it's a single task or multiple tasks results in different entry-points.
-     *
-     * If it is null, then it will simply fade in the starting apps and fade out launcher (for the
-     * case where launcher handles animating starting split tasks from app icon)
      */
     public static void composeRecentsSplitLaunchAnimator(GroupedTaskView launchingTaskView,
             @NonNull StateManager stateManager, @Nullable DepthController depthController,
-            int initialTaskId, int secondTaskId, @NonNull TransitionInfo transitionInfo,
-            SurfaceControl.Transaction t, @NonNull Runnable finishCallback) {
-        if (launchingTaskView != null) {
-            AnimatorSet animatorSet = new AnimatorSet();
-            animatorSet.addListener(new AnimatorListenerAdapter() {
-                @Override
-                public void onAnimationEnd(Animator animation) {
-                    finishCallback.run();
-                }
-            });
-
-            final RemoteAnimationTarget[] appTargets =
-                    RemoteAnimationTargetCompat.wrapApps(transitionInfo, t, null /* leashMap */);
-            final RemoteAnimationTarget[] wallpaperTargets =
-                    RemoteAnimationTargetCompat.wrapNonApps(
-                            transitionInfo, true /* wallpapers */, t, null /* leashMap */);
-            final RemoteAnimationTarget[] nonAppTargets =
-                    RemoteAnimationTargetCompat.wrapNonApps(
-                            transitionInfo, false /* wallpapers */, t, null /* leashMap */);
-            final RecentsView recentsView = launchingTaskView.getRecentsView();
-            composeRecentsLaunchAnimator(animatorSet, launchingTaskView,
-                    appTargets, wallpaperTargets, nonAppTargets,
-                    true, stateManager,
-                    recentsView, depthController);
-
-            t.apply();
-            animatorSet.start();
-            return;
-        }
-
-        TransitionInfo.Change splitRoot1 = null;
-        TransitionInfo.Change splitRoot2 = null;
-        final ArrayList<SurfaceControl> openingTargets = new ArrayList<>();
-        for (int i = 0; i < transitionInfo.getChanges().size(); ++i) {
-            final TransitionInfo.Change change = transitionInfo.getChanges().get(i);
-            if (change.getTaskInfo() == null) {
-                continue;
-            }
-            final int taskId = change.getTaskInfo().taskId;
-            final int mode = change.getMode();
-
-            // Find the target tasks' root tasks since those are the split stages that need to
-            // be animated (the tasks themselves are children and thus inherit animation).
-            if (taskId == initialTaskId || taskId == secondTaskId) {
-                if (!(mode == TRANSIT_OPEN || mode == TRANSIT_TO_FRONT)) {
-                    throw new IllegalStateException(
-                            "Expected task to be showing, but it is " + mode);
-                }
-            }
-            if (taskId == initialTaskId) {
-                splitRoot1 = change.getParent() == null ? change :
-                        transitionInfo.getChange(change.getParent());
-                openingTargets.add(splitRoot1.getLeash());
-            }
-            if (taskId == secondTaskId) {
-                splitRoot2 = change.getParent() == null ? change :
-                        transitionInfo.getChange(change.getParent());
-                openingTargets.add(splitRoot2.getLeash());
-            }
-        }
-
-        SurfaceControl.Transaction animTransaction = new SurfaceControl.Transaction();
-        ValueAnimator animator = ValueAnimator.ofFloat(0f, 1f);
-        animator.setDuration(SPLIT_LAUNCH_DURATION);
-        animator.addUpdateListener(valueAnimator -> {
-            float progress = valueAnimator.getAnimatedFraction();
-            for (SurfaceControl leash: openingTargets) {
-                animTransaction.setAlpha(leash, progress);
-            }
-            animTransaction.apply();
-        });
-        animator.addListener(new AnimatorListenerAdapter() {
-            @Override
-            public void onAnimationStart(Animator animation) {
-                for (SurfaceControl leash: openingTargets) {
-                    animTransaction.show(leash)
-                            .setAlpha(leash, 0.0f);
-                }
-                animTransaction.apply();
-            }
-
+            @NonNull TransitionInfo transitionInfo, SurfaceControl.Transaction t,
+            @NonNull Runnable finishCallback) {
+        AnimatorSet animatorSet = new AnimatorSet();
+        animatorSet.addListener(new AnimatorListenerAdapter() {
             @Override
             public void onAnimationEnd(Animator animation) {
                 finishCallback.run();
             }
         });
 
-        if (splitRoot1 != null && splitRoot1.getParent() != null) {
-            // Set the highest level split root alpha; we could technically use the parent of either
-            // splitRoot1 or splitRoot2
-            t.setAlpha(transitionInfo.getChange(splitRoot1.getParent()).getLeash(), 1f);
-        }
+        final RemoteAnimationTarget[] appTargets =
+                RemoteAnimationTargetCompat.wrapApps(transitionInfo, t, null /* leashMap */);
+        final RemoteAnimationTarget[] wallpaperTargets =
+                RemoteAnimationTargetCompat.wrapNonApps(
+                        transitionInfo, true /* wallpapers */, t, null /* leashMap */);
+        final RemoteAnimationTarget[] nonAppTargets =
+                RemoteAnimationTargetCompat.wrapNonApps(
+                        transitionInfo, false /* wallpapers */, t, null /* leashMap */);
+        final RecentsView recentsView = launchingTaskView.getRecentsView();
+        composeRecentsLaunchAnimator(animatorSet, launchingTaskView, appTargets, wallpaperTargets,
+                nonAppTargets, /* launcherClosing */ true, stateManager, recentsView,
+                depthController);
+
         t.apply();
-        animator.start();
+        animatorSet.start();
     }
 
     /**
diff --git a/quickstep/src/com/android/quickstep/util/AnimUtils.java b/quickstep/src/com/android/quickstep/util/AnimUtils.java
index b7b7825..7fbbb6e 100644
--- a/quickstep/src/com/android/quickstep/util/AnimUtils.java
+++ b/quickstep/src/com/android/quickstep/util/AnimUtils.java
@@ -39,4 +39,13 @@
                 ? SplitAnimationTimings.TABLET_SPLIT_TO_CONFIRM
                 : SplitAnimationTimings.PHONE_SPLIT_TO_CONFIRM;
     }
+
+    /**
+     * Fetches device-specific timings for the app pair launch animation.
+     */
+    public static SplitAnimationTimings getDeviceAppPairLaunchTimings(boolean isTablet) {
+        return isTablet
+                ? SplitAnimationTimings.TABLET_APP_PAIR_LAUNCH
+                : SplitAnimationTimings.PHONE_APP_PAIR_LAUNCH;
+    }
 }
diff --git a/quickstep/src/com/android/quickstep/util/AppPairLaunchTimings.kt b/quickstep/src/com/android/quickstep/util/AppPairLaunchTimings.kt
new file mode 100644
index 0000000..086c8af
--- /dev/null
+++ b/quickstep/src/com/android/quickstep/util/AppPairLaunchTimings.kt
@@ -0,0 +1,72 @@
+/*
+ * 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 com.android.app.animation.Interpolators
+
+/** Timings for the app pair launch animation. */
+abstract class AppPairLaunchTimings : SplitAnimationTimings {
+    protected abstract val STAGED_RECT_SLIDE_DURATION: Int
+
+    // Common timings that apply to app pair launches on any type of device
+    override fun getStagedRectSlideStart() = 0
+    override fun getStagedRectSlideEnd() = stagedRectSlideStart + STAGED_RECT_SLIDE_DURATION
+    override fun getPlaceholderFadeInStart() = 0
+    override fun getPlaceholderFadeInEnd() = 0
+    override fun getPlaceholderIconFadeInStart() = 0
+    override fun getPlaceholderIconFadeInEnd() = 0
+
+    private val iconFadeStart: Int
+        get() = getStagedRectSlideEnd()
+    private val iconFadeEnd: Int
+        get() = iconFadeStart + 83
+    private val appRevealStart: Int
+        get() = getStagedRectSlideEnd() + 67
+    private val appRevealEnd: Int
+        get() = appRevealStart + 217
+    private val cellSplitStart: Int
+        get() = (getStagedRectSlideEnd() * 0.83f).toInt()
+    private val cellSplitEnd: Int
+        get() = cellSplitStart + 500
+
+    override fun getStagedRectXInterpolator() = Interpolators.EMPHASIZED_COMPLEMENT
+    override fun getStagedRectYInterpolator() = Interpolators.EMPHASIZED
+    override fun getStagedRectScaleXInterpolator() = Interpolators.EMPHASIZED
+    override fun getStagedRectScaleYInterpolator() = Interpolators.EMPHASIZED
+    override fun getCellSplitInterpolator() = Interpolators.EMPHASIZED
+    override fun getIconFadeInterpolator() = Interpolators.LINEAR
+
+    override fun getCellSplitStartOffset(): Float {
+        return cellSplitStart.toFloat() / getDuration()
+    }
+    override fun getCellSplitEndOffset(): Float {
+        return cellSplitEnd.toFloat() / getDuration()
+    }
+    override fun getIconFadeStartOffset(): Float {
+        return iconFadeStart.toFloat() / getDuration()
+    }
+    override fun getIconFadeEndOffset(): Float {
+        return iconFadeEnd.toFloat() / getDuration()
+    }
+    override fun getAppRevealStartOffset(): Float {
+        return appRevealStart.toFloat() / getDuration()
+    }
+    override fun getAppRevealEndOffset(): Float {
+        return appRevealEnd.toFloat() / getDuration()
+    }
+    abstract override fun getDuration(): Int
+}
diff --git a/quickstep/src/com/android/quickstep/util/AppPairsController.java b/quickstep/src/com/android/quickstep/util/AppPairsController.java
index b6a8797..3ca2531 100644
--- a/quickstep/src/com/android/quickstep/util/AppPairsController.java
+++ b/quickstep/src/com/android/quickstep/util/AppPairsController.java
@@ -36,6 +36,7 @@
 import com.android.launcher3.LauncherSettings;
 import com.android.launcher3.R;
 import com.android.launcher3.accessibility.LauncherAccessibilityDelegate;
+import com.android.launcher3.apppairs.AppPairIcon;
 import com.android.launcher3.icons.IconCache;
 import com.android.launcher3.logging.StatsLogManager;
 import com.android.launcher3.model.data.FolderInfo;
@@ -120,7 +121,9 @@
      * Launches an app pair by searching the RecentsModel for running instances of each app, and
      * staging either those running instances or launching the apps as new Intents.
      */
-    public void launchAppPair(WorkspaceItemInfo app1, WorkspaceItemInfo app2) {
+    public void launchAppPair(AppPairIcon appPairIcon) {
+        WorkspaceItemInfo app1 = appPairIcon.getInfo().contents.get(0);
+        WorkspaceItemInfo app2 = appPairIcon.getInfo().contents.get(1);
         ComponentKey app1Key = new ComponentKey(app1.getTargetComponent(), app1.user);
         ComponentKey app2Key = new ComponentKey(app2.getTargetComponent(), app2.user);
         mSplitSelectStateController.findLastActiveTasksAndRunCallback(
@@ -152,6 +155,8 @@
                                 app2.intent, app2.user);
                     }
 
+                    mSplitSelectStateController.setLaunchingIconView(appPairIcon);
+
                     mSplitSelectStateController.launchSplitTasks(
                             AppPairsController.convertRankToSnapPosition(app1.rank));
                 }
diff --git a/quickstep/src/com/android/quickstep/util/PhoneAppPairLaunchTimings.kt b/quickstep/src/com/android/quickstep/util/PhoneAppPairLaunchTimings.kt
new file mode 100644
index 0000000..beab90f
--- /dev/null
+++ b/quickstep/src/com/android/quickstep/util/PhoneAppPairLaunchTimings.kt
@@ -0,0 +1,23 @@
+/*
+ * 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
+
+/** Timings for the app pair launch animation on phones. */
+class PhoneAppPairLaunchTimings : AppPairLaunchTimings(), SplitAnimationTimings {
+    override val STAGED_RECT_SLIDE_DURATION = 500
+    override fun getDuration() = SplitAnimationTimings.PHONE_APP_PAIR_LAUNCH_DURATION
+}
diff --git a/quickstep/src/com/android/quickstep/util/SplitAnimationController.kt b/quickstep/src/com/android/quickstep/util/SplitAnimationController.kt
index dfbd32c..ade8074 100644
--- a/quickstep/src/com/android/quickstep/util/SplitAnimationController.kt
+++ b/quickstep/src/com/android/quickstep/util/SplitAnimationController.kt
@@ -21,20 +21,37 @@
 import android.animation.AnimatorListenerAdapter
 import android.animation.AnimatorSet
 import android.animation.ObjectAnimator
+import android.animation.ValueAnimator
+import android.app.ActivityManager.RunningTaskInfo
 import android.graphics.Bitmap
 import android.graphics.Rect
 import android.graphics.RectF
 import android.graphics.drawable.Drawable
+import android.view.RemoteAnimationTarget
+import android.view.SurfaceControl
+import android.view.SurfaceControl.Transaction
 import android.view.View
+import android.view.WindowManager
+import android.window.TransitionInfo
+import android.window.TransitionInfo.Change
+import androidx.annotation.VisibleForTesting
 import com.android.app.animation.Interpolators
 import com.android.launcher3.DeviceProfile
+import com.android.launcher3.Launcher
+import com.android.launcher3.QuickstepTransitionManager
 import com.android.launcher3.Utilities
 import com.android.launcher3.anim.PendingAnimation
+import com.android.launcher3.apppairs.AppPairIcon
 import com.android.launcher3.config.FeatureFlags
+import com.android.launcher3.statehandlers.DepthController
+import com.android.launcher3.statemanager.StateManager
 import com.android.launcher3.statemanager.StatefulActivity
 import com.android.launcher3.util.SplitConfigurationOptions.SplitSelectSource
 import com.android.launcher3.views.BaseDragLayer
+import com.android.quickstep.TaskViewUtils
+import com.android.quickstep.views.FloatingAppPairView
 import com.android.quickstep.views.FloatingTaskView
+import com.android.quickstep.views.GroupedTaskView
 import com.android.quickstep.views.RecentsView
 import com.android.quickstep.views.SplitInstructionsView
 import com.android.quickstep.views.TaskThumbnailView
@@ -308,6 +325,407 @@
         pendingAnimation.buildAnim().start()
     }
 
+    /**
+     * Called when launching a specific pair of apps, e.g. when tapping a pair of apps in Overview,
+     * or launching an app pair from its Home icon. Selects the appropriate launch animation and
+     * plays it.
+     */
+    fun playSplitLaunchAnimation(
+        launchingTaskView: GroupedTaskView?,
+        launchingIconView: AppPairIcon?,
+        initialTaskId: Int,
+        secondTaskId: Int,
+        apps: Array<RemoteAnimationTarget>?,
+        wallpapers: Array<RemoteAnimationTarget>?,
+        nonApps: Array<RemoteAnimationTarget>?,
+        stateManager: StateManager<*>,
+        depthController: DepthController?,
+        info: TransitionInfo?,
+        t: Transaction?,
+        finishCallback: Runnable
+    ) {
+        if (info == null && t == null) {
+            // (Legacy animation) Tapping a split tile in Overview
+            // TODO (b/315490678): Ensure that this works with app pairs flow
+            check(apps != null && wallpapers != null && nonApps != null) {
+                "trying to call composeRecentsSplitLaunchAnimatorLegacy, but encountered an " +
+                    "unexpected null"
+            }
+
+            composeRecentsSplitLaunchAnimatorLegacy(
+                launchingTaskView,
+                initialTaskId,
+                secondTaskId,
+                apps,
+                wallpapers,
+                nonApps,
+                stateManager,
+                depthController,
+                finishCallback
+            )
+
+            return
+        }
+
+        if (launchingTaskView != null) {
+            // Tapping a split tile in Overview
+            check(info != null && t != null) {
+                "trying to launch a GroupedTaskView, but encountered an unexpected null"
+            }
+
+            composeRecentsSplitLaunchAnimator(
+                launchingTaskView,
+                stateManager,
+                depthController,
+                info,
+                t,
+                finishCallback
+            )
+        } else if (launchingIconView != null) {
+            // Tapping an app pair icon
+            check(info != null && t != null) {
+                "trying to launch an app pair icon, but encountered an unexpected null"
+            }
+
+            composeIconSplitLaunchAnimator(
+                launchingIconView,
+                initialTaskId,
+                secondTaskId,
+                info,
+                t,
+                finishCallback
+            )
+        } else {
+            // Fallback case: simple fade-in animation
+            check(info != null && t != null) {
+                "trying to call composeFadeInSplitLaunchAnimator, but encountered an " +
+                    "unexpected null"
+            }
+
+            composeFadeInSplitLaunchAnimator(initialTaskId, secondTaskId, info, t, finishCallback)
+        }
+    }
+
+    /**
+     * When the user taps a split tile in Overview, this will play the tasks' launch animation from
+     * the position of the tapped tile.
+     */
+    @VisibleForTesting
+    fun composeRecentsSplitLaunchAnimator(
+        launchingTaskView: GroupedTaskView,
+        stateManager: StateManager<*>,
+        depthController: DepthController?,
+        info: TransitionInfo,
+        t: Transaction,
+        finishCallback: Runnable
+    ) {
+        TaskViewUtils.composeRecentsSplitLaunchAnimator(
+            launchingTaskView,
+            stateManager,
+            depthController,
+            info,
+            t,
+            finishCallback
+        )
+    }
+
+    /**
+     * LEGACY VERSION: When the user taps a split tile in Overview, this will play the tasks' launch
+     * animation from the position of the tapped tile.
+     */
+    @VisibleForTesting
+    fun composeRecentsSplitLaunchAnimatorLegacy(
+        launchingTaskView: GroupedTaskView?,
+        initialTaskId: Int,
+        secondTaskId: Int,
+        apps: Array<RemoteAnimationTarget>,
+        wallpapers: Array<RemoteAnimationTarget>,
+        nonApps: Array<RemoteAnimationTarget>,
+        stateManager: StateManager<*>,
+        depthController: DepthController?,
+        finishCallback: Runnable
+    ) {
+        TaskViewUtils.composeRecentsSplitLaunchAnimatorLegacy(
+            launchingTaskView,
+            initialTaskId,
+            secondTaskId,
+            apps,
+            wallpapers,
+            nonApps,
+            stateManager,
+            depthController,
+            finishCallback
+        )
+    }
+
+    /**
+     * When the user taps an app pair icon to launch split, this will play the tasks' launch
+     * animation from the position of the icon.
+     */
+    @VisibleForTesting
+    fun composeIconSplitLaunchAnimator(
+        launchingIconView: AppPairIcon,
+        initialTaskId: Int,
+        secondTaskId: Int,
+        transitionInfo: TransitionInfo,
+        t: Transaction,
+        finishCallback: Runnable
+    ) {
+        val launcher = Launcher.getLauncher(launchingIconView.context)
+        val dp = launcher.deviceProfile
+
+        // Create an AnimatorSet that will run both shell and launcher transitions together
+        val launchAnimation = AnimatorSet()
+        val progressUpdater = ValueAnimator.ofFloat(0f, 1f)
+        val timings = AnimUtils.getDeviceAppPairLaunchTimings(dp.isTablet)
+        progressUpdater.setDuration(timings.getDuration().toLong())
+        progressUpdater.interpolator = Interpolators.LINEAR
+
+        // Find the root shell leash that we want to fade in (parent of both app windows and
+        // the divider). For simplicity, we search using the initialTaskId.
+        var rootShellLayer: SurfaceControl? = null
+        var dividerPos = 0
+
+        for (change in transitionInfo.changes) {
+            val taskInfo: RunningTaskInfo = change.taskInfo ?: continue
+            val taskId = taskInfo.taskId
+            val mode = change.mode
+
+            if (taskId == initialTaskId || taskId == secondTaskId) {
+                check(
+                    mode == WindowManager.TRANSIT_OPEN || mode == WindowManager.TRANSIT_TO_FRONT
+                ) {
+                    "Expected task to be showing, but it is $mode"
+                }
+            }
+
+            if (taskId == initialTaskId) {
+                var splitRoot1 = change
+                val parentToken = change.parent
+                if (parentToken != null) {
+                    splitRoot1 = transitionInfo.getChange(parentToken) ?: change
+                }
+
+                val topLevelToken = splitRoot1.parent
+                if (topLevelToken != null) {
+                    rootShellLayer = transitionInfo.getChange(topLevelToken)?.leash
+                }
+
+                dividerPos =
+                    if (dp.isLeftRightSplit) change.endAbsBounds.right
+                    else change.endAbsBounds.bottom
+            }
+        }
+
+        check(rootShellLayer != null) {
+            "Could not find a TransitionInfo.Change matching the initialTaskId"
+        }
+
+        // Shell animation: the apps are revealed toward end of the launch animation
+        progressUpdater.addUpdateListener { valueAnimator: ValueAnimator ->
+            val progress =
+                Interpolators.clampToProgress(
+                    Interpolators.LINEAR,
+                    valueAnimator.animatedFraction,
+                    timings.appRevealStartOffset,
+                    timings.appRevealEndOffset
+                )
+
+            // Set the alpha of the shell layer (2 apps + divider)
+            t.setAlpha(rootShellLayer, progress)
+            t.apply()
+        }
+
+        // Create a new floating view in Launcher, positioned above the launching icon
+        val drawableArea = launchingIconView.iconDrawableArea
+        val appIcon1 = launchingIconView.info.contents[0].newIcon(launchingIconView.context)
+        val appIcon2 = launchingIconView.info.contents[1].newIcon(launchingIconView.context)
+        appIcon1.setBounds(0, 0, dp.iconSizePx, dp.iconSizePx)
+        appIcon2.setBounds(0, 0, dp.iconSizePx, dp.iconSizePx)
+        val floatingView =
+            FloatingAppPairView.getFloatingAppPairView(
+                launcher,
+                drawableArea,
+                appIcon1,
+                appIcon2,
+                dividerPos
+            )
+
+        // Launcher animation: animate the floating view, expanding to fill the display surface
+        progressUpdater.addUpdateListener(
+            object : MultiValueUpdateListener() {
+                var mDx =
+                    FloatProp(
+                        floatingView.startingPosition.left,
+                        dp.widthPx / 2f - floatingView.startingPosition.width() / 2f,
+                        0f /* delay */,
+                        timings.getDuration().toFloat(),
+                        Interpolators.clampToProgress(
+                            timings.getStagedRectXInterpolator(),
+                            timings.stagedRectSlideStartOffset,
+                            timings.stagedRectSlideEndOffset
+                        )
+                    )
+                var mDy =
+                    FloatProp(
+                        floatingView.startingPosition.top,
+                        dp.heightPx / 2f - floatingView.startingPosition.height() / 2f,
+                        0f /* delay */,
+                        timings.getDuration().toFloat(),
+                        Interpolators.clampToProgress(
+                            Interpolators.EMPHASIZED,
+                            timings.stagedRectSlideStartOffset,
+                            timings.stagedRectSlideEndOffset
+                        )
+                    )
+                var mScaleX =
+                    FloatProp(
+                        1f /* start */,
+                        dp.widthPx / floatingView.startingPosition.width(),
+                        0f /* delay */,
+                        timings.getDuration().toFloat(),
+                        Interpolators.clampToProgress(
+                            Interpolators.EMPHASIZED,
+                            timings.stagedRectSlideStartOffset,
+                            timings.stagedRectSlideEndOffset
+                        )
+                    )
+                var mScaleY =
+                    FloatProp(
+                        1f /* start */,
+                        dp.heightPx / floatingView.startingPosition.height(),
+                        0f /* delay */,
+                        timings.getDuration().toFloat(),
+                        Interpolators.clampToProgress(
+                            Interpolators.EMPHASIZED,
+                            timings.stagedRectSlideStartOffset,
+                            timings.stagedRectSlideEndOffset
+                        )
+                    )
+
+                override fun onUpdate(percent: Float, initOnly: Boolean) {
+                    floatingView.progress = percent
+                    floatingView.x = mDx.value
+                    floatingView.y = mDy.value
+                    floatingView.scaleX = mScaleX.value
+                    floatingView.scaleY = mScaleY.value
+                    floatingView.invalidate()
+                }
+            }
+        )
+
+        // When animation ends, remove the floating view and run finishCallback
+        progressUpdater.addListener(
+            object : AnimatorListenerAdapter() {
+                override fun onAnimationEnd(animation: Animator) {
+                    safeRemoveViewFromDragLayer(launcher, floatingView)
+                    finishCallback.run()
+                }
+            }
+        )
+
+        launchAnimation.play(progressUpdater)
+        launchAnimation.start()
+    }
+
+    /**
+     * If we are launching split screen without any special animation from a starting View, we
+     * simply fade in the starting apps and fade out launcher.
+     */
+    @VisibleForTesting
+    fun composeFadeInSplitLaunchAnimator(
+        initialTaskId: Int,
+        secondTaskId: Int,
+        transitionInfo: TransitionInfo,
+        t: Transaction,
+        finishCallback: Runnable
+    ) {
+        var splitRoot1: Change? = null
+        var splitRoot2: Change? = null
+        val openingTargets = ArrayList<SurfaceControl>()
+        for (change in transitionInfo.changes) {
+            val taskInfo: RunningTaskInfo = change.taskInfo ?: continue
+            val taskId = taskInfo.taskId
+            val mode = change.mode
+
+            // Find the target tasks' root tasks since those are the split stages that need to
+            // be animated (the tasks themselves are children and thus inherit animation).
+            if (taskId == initialTaskId || taskId == secondTaskId) {
+                check(
+                    mode == WindowManager.TRANSIT_OPEN || mode == WindowManager.TRANSIT_TO_FRONT
+                ) {
+                    "Expected task to be showing, but it is $mode"
+                }
+            }
+
+            if (taskId == initialTaskId) {
+                splitRoot1 = change
+                val parentToken1 = change.parent
+                if (parentToken1 != null) {
+                    splitRoot1 = transitionInfo.getChange(parentToken1) ?: change
+                }
+
+                if (splitRoot1?.leash != null) {
+                    openingTargets.add(splitRoot1.leash)
+                }
+            }
+
+            if (taskId == secondTaskId) {
+                splitRoot2 = change
+                val parentToken2 = change.parent
+                if (parentToken2 != null) {
+                    splitRoot2 = transitionInfo.getChange(parentToken2) ?: change
+                }
+
+                if (splitRoot2?.leash != null) {
+                    openingTargets.add(splitRoot2.leash)
+                }
+            }
+        }
+
+        val animTransaction = Transaction()
+        val animator = ValueAnimator.ofFloat(0f, 1f)
+        animator.setDuration(QuickstepTransitionManager.SPLIT_LAUNCH_DURATION.toLong())
+        animator.addUpdateListener { valueAnimator: ValueAnimator ->
+            val progress = valueAnimator.animatedFraction
+            for (leash in openingTargets) {
+                animTransaction.setAlpha(leash, progress)
+            }
+            animTransaction.apply()
+        }
+
+        animator.addListener(
+            object : AnimatorListenerAdapter() {
+                override fun onAnimationStart(animation: Animator) {
+                    for (leash in openingTargets) {
+                        animTransaction.show(leash).setAlpha(leash, 0.0f)
+                    }
+                    animTransaction.apply()
+                }
+
+                override fun onAnimationEnd(animation: Animator) {
+                    finishCallback.run()
+                }
+            }
+        )
+
+        if (splitRoot1 != null) {
+            // Set the highest level split root alpha; we could technically use the parent of
+            // either splitRoot1 or splitRoot2
+            val parentToken = splitRoot1.parent
+            var rootLayer: Change? = null
+            if (parentToken != null) {
+                rootLayer = transitionInfo.getChange(parentToken)
+            }
+            if (rootLayer != null && rootLayer.leash != null) {
+                t.setAlpha(rootLayer.leash, 1f)
+            }
+        }
+
+        t.apply()
+        animator.start()
+    }
+
     private fun safeRemoveViewFromDragLayer(launcher: StatefulActivity<*>, view: View?) {
         if (view != null) {
             launcher.dragLayer.removeView(view)
diff --git a/quickstep/src/com/android/quickstep/util/SplitAnimationTimings.java b/quickstep/src/com/android/quickstep/util/SplitAnimationTimings.java
index 93f2255..b618546 100644
--- a/quickstep/src/com/android/quickstep/util/SplitAnimationTimings.java
+++ b/quickstep/src/com/android/quickstep/util/SplitAnimationTimings.java
@@ -21,31 +21,44 @@
 import android.view.animation.Interpolator;
 
 /**
- * An interface that supports the centralization of timing information for splitscreen animations.
+ * Organizes timing information for split screen animations.
  */
 public interface SplitAnimationTimings {
+    /** Total duration (ms) for initiating split screen (staging the first app) on tablets. */
     int TABLET_ENTER_DURATION = 866;
+    /** Total duration (ms) for confirming split screen (selecting the second app) on tablets. */
     int TABLET_CONFIRM_DURATION = 500;
-
+    /** Total duration (ms) for initiating split screen (staging the first app) on phones. */
     int PHONE_ENTER_DURATION = 517;
+    /** Total duration (ms) for confirming split screen (selecting the second app) on phones. */
     int PHONE_CONFIRM_DURATION = 333;
-
+    /** Total duration (ms) for aborting split screen (before selecting the second app). */
     int ABORT_DURATION = 500;
+    /** Total duration (ms) for launching an app pair from its icon on tablets. */
+    int TABLET_APP_PAIR_LAUNCH_DURATION = 998;
+    /** Total duration (ms) for launching an app pair from its icon on phones. */
+    int PHONE_APP_PAIR_LAUNCH_DURATION = 915;
 
+    // Initialize timing classes so they can be accessed statically
     SplitAnimationTimings TABLET_OVERVIEW_TO_SPLIT = new TabletOverviewToSplitTimings();
     SplitAnimationTimings TABLET_HOME_TO_SPLIT = new TabletHomeToSplitTimings();
     SplitAnimationTimings TABLET_SPLIT_TO_CONFIRM = new TabletSplitToConfirmTimings();
-
     SplitAnimationTimings PHONE_OVERVIEW_TO_SPLIT = new PhoneOverviewToSplitTimings();
     SplitAnimationTimings PHONE_SPLIT_TO_CONFIRM = new PhoneSplitToConfirmTimings();
+    SplitAnimationTimings TABLET_APP_PAIR_LAUNCH = new TabletAppPairLaunchTimings();
+    SplitAnimationTimings PHONE_APP_PAIR_LAUNCH = new PhoneAppPairLaunchTimings();
 
-    // Shared methods
+    // Shared methods: all split animations have these parameters
     int getDuration();
+    /** Start fading in the floating view tile at this time (in ms). */
     int getPlaceholderFadeInStart();
     int getPlaceholderFadeInEnd();
+    /** Start fading in the app icon at this time (in ms). */
     int getPlaceholderIconFadeInStart();
     int getPlaceholderIconFadeInEnd();
+    /** Start translating the floating view tile at this time (in ms). */
     int getStagedRectSlideStart();
+    /** The floating tile has reached its final position at this time (in ms). */
     int getStagedRectSlideEnd();
     Interpolator getStagedRectXInterpolator();
     Interpolator getStagedRectYInterpolator();
@@ -70,6 +83,11 @@
         return (float) getStagedRectSlideEnd() / getDuration();
     }
 
+    // DEFAULT VALUES: We define default values here so that SplitAnimationTimings can be used
+    // flexibly in animation-running functions, e.g. a single function that handles 2 types of split
+    // animations. The values are not intended to be used, and can safely be removed if refactoring
+    // these classes.
+
     // Defaults for OverviewToSplit
     default float getGridSlideStartOffset() { return 0; }
     default float getGridSlideStaggerOffset() { return 0; }
@@ -94,5 +112,13 @@
     // Defaults for SplitToConfirm
     default float getInstructionsFadeStartOffset() { return 0; }
     default float getInstructionsFadeEndOffset() { return 0; }
+
+    // Defaults for AppPair
+    default float getCellSplitStartOffset() { return 0; }
+    default float getCellSplitEndOffset() { return 0; }
+    default float getAppRevealStartOffset() { return 0; }
+    default float getAppRevealEndOffset() { return 0; }
+    default Interpolator getCellSplitInterpolator() { return LINEAR; }
+    default Interpolator getIconFadeInterpolator() { return LINEAR; }
 }
 
diff --git a/quickstep/src/com/android/quickstep/util/SplitScreenUtils.kt b/quickstep/src/com/android/quickstep/util/SplitScreenUtils.kt
index 596bb47..38bbe60 100644
--- a/quickstep/src/com/android/quickstep/util/SplitScreenUtils.kt
+++ b/quickstep/src/com/android/quickstep/util/SplitScreenUtils.kt
@@ -56,4 +56,4 @@
             }
         }
     }
-}
\ No newline at end of file
+}
diff --git a/quickstep/src/com/android/quickstep/util/SplitSelectStateController.java b/quickstep/src/com/android/quickstep/util/SplitSelectStateController.java
index 24d6d27..d5899e4 100644
--- a/quickstep/src/com/android/quickstep/util/SplitSelectStateController.java
+++ b/quickstep/src/com/android/quickstep/util/SplitSelectStateController.java
@@ -71,6 +71,7 @@
 import com.android.launcher3.Launcher;
 import com.android.launcher3.R;
 import com.android.launcher3.anim.PendingAnimation;
+import com.android.launcher3.apppairs.AppPairIcon;
 import com.android.launcher3.config.FeatureFlags;
 import com.android.launcher3.icons.IconProvider;
 import com.android.launcher3.logging.StatsLogManager;
@@ -92,7 +93,6 @@
 import com.android.quickstep.SplitSelectionListener;
 import com.android.quickstep.SystemUiProxy;
 import com.android.quickstep.TaskAnimationManager;
-import com.android.quickstep.TaskViewUtils;
 import com.android.quickstep.views.FloatingTaskView;
 import com.android.quickstep.views.GroupedTaskView;
 import com.android.quickstep.views.RecentsView;
@@ -141,6 +141,8 @@
     /** If not null, this is the TaskView we want to launch from */
     @Nullable
     private GroupedTaskView mLaunchingTaskView;
+    /** If not null, this is the icon we want to launch from */
+    private AppPairIcon mLaunchingIconView;
 
     /** True when the first selected split app is being launched in fullscreen. */
     private boolean mLaunchingFirstAppFullscreen;
@@ -664,9 +666,17 @@
                 // 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, () -> {
+                mSplitAnimationController.playSplitLaunchAnimation(
+                        shouldLaunchFromTaskView ? mLaunchingTaskView : null,
+                        mLaunchingIconView,
+                        mInitialTaskId,
+                        mSecondTaskId,
+                        null /* apps */,
+                        null /* wallpapers */,
+                        null /* nonApps */,
+                        mStateManager,
+                        mDepthController,
+                        info, t, () -> {
                             finishAdapter.run();
                             cleanup(true /*success*/);
                         });
@@ -722,9 +732,10 @@
                 RemoteAnimationTarget[] wallpapers, RemoteAnimationTarget[] nonApps,
                 Runnable finishedCallback) {
             postAsyncCallback(mHandler,
-                    () -> TaskViewUtils.composeRecentsSplitLaunchAnimatorLegacy(
-                            mLaunchingTaskView, mInitialTaskId, mSecondTaskId, apps, wallpapers,
-                            nonApps, mStateManager, mDepthController, () -> {
+                    () -> mSplitAnimationController.playSplitLaunchAnimation(mLaunchingTaskView,
+                            mLaunchingIconView, mInitialTaskId, mSecondTaskId, apps, wallpapers,
+                            nonApps, mStateManager, mDepthController, null /* info */, null /* t */,
+                            () -> {
                                 finishedCallback.run();
                                 if (mSuccessCallback != null) {
                                     mSuccessCallback.accept(true);
@@ -757,6 +768,7 @@
         dispatchOnSplitSelectionExit();
         mRecentsAnimationRunning = false;
         mLaunchingTaskView = null;
+        mLaunchingIconView = null;
         mAnimateCurrentTaskDismissal = false;
         mDismissingFromSplitPair = false;
         mFirstFloatingTaskView = null;
@@ -817,6 +829,10 @@
         return mAppPairsController;
     }
 
+    public void setLaunchingIconView(AppPairIcon launchingIconView) {
+        mLaunchingIconView = launchingIconView;
+    }
+
     public BackPressHandler getSplitBackHandler() {
         return mSplitBackHandler;
     }
diff --git a/quickstep/src/com/android/quickstep/util/TabletAppPairLaunchTimings.kt b/quickstep/src/com/android/quickstep/util/TabletAppPairLaunchTimings.kt
new file mode 100644
index 0000000..fb2d63f
--- /dev/null
+++ b/quickstep/src/com/android/quickstep/util/TabletAppPairLaunchTimings.kt
@@ -0,0 +1,23 @@
+/*
+ * 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
+
+/** Timings for the app pair launch animation on tablets. */
+class TabletAppPairLaunchTimings : AppPairLaunchTimings(), SplitAnimationTimings {
+    override val STAGED_RECT_SLIDE_DURATION = 600
+    override fun getDuration() = SplitAnimationTimings.TABLET_APP_PAIR_LAUNCH_DURATION
+}
diff --git a/quickstep/src/com/android/quickstep/views/FloatingAppPairBackground.kt b/quickstep/src/com/android/quickstep/views/FloatingAppPairBackground.kt
new file mode 100644
index 0000000..3a5873b
--- /dev/null
+++ b/quickstep/src/com/android/quickstep/views/FloatingAppPairBackground.kt
@@ -0,0 +1,358 @@
+/*
+ * 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.views
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.ColorFilter
+import android.graphics.Paint
+import android.graphics.PixelFormat
+import android.graphics.RectF
+import android.graphics.drawable.Drawable
+import android.os.Build
+import android.view.animation.Interpolator
+import com.android.app.animation.Interpolators
+import com.android.launcher3.Launcher
+import com.android.launcher3.R
+import com.android.launcher3.Utilities
+import com.android.quickstep.util.AnimUtils
+import com.android.systemui.shared.system.QuickStepContract
+
+/**
+ * A Drawable that is drawn onto [FloatingAppPairView] every frame during the app pair launch
+ * animation. Consists of a rectangular background that splits into two, and two app icons that
+ * increase in size during the animation.
+ */
+class FloatingAppPairBackground(
+    context: Context,
+    private val floatingView: FloatingAppPairView, // the view that we will draw this background on
+    private val appIcon1: Drawable,
+    private val appIcon2: Drawable,
+    dividerPos: Int
+) : Drawable() {
+    companion object {
+        // Design specs -- app icons start small and expand during the animation
+        private val STARTING_ICON_SIZE_PX = Utilities.dpToPx(22f)
+        private val ENDING_ICON_SIZE_PX = Utilities.dpToPx(66f)
+
+        // Null values to use with drawDoubleRoundRect(), since there doesn't seem to be any other
+        // API for drawing rectangles with 4 different corner radii.
+        private val EMPTY_RECT = RectF()
+        private val ARRAY_OF_ZEROES = FloatArray(8)
+    }
+
+    private val launcher: Launcher
+    private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG)
+
+    // Animation interpolators
+    private val expandXInterpolator: Interpolator
+    private val expandYInterpolator: Interpolator
+    private val cellSplitInterpolator: Interpolator
+    private val iconFadeInterpolator: Interpolator
+
+    // Device-specific measurements
+    private val deviceCornerRadius: Float
+    private val deviceHalfDividerSize: Float
+    private val desiredSplitRatio: Float
+
+    init {
+        launcher = Launcher.getLauncher(context)
+        val dp = launcher.deviceProfile
+        // Set up background paint color
+        val ta = context.theme.obtainStyledAttributes(R.styleable.FolderIconPreview)
+        backgroundPaint.style = Paint.Style.FILL
+        backgroundPaint.color = ta.getColor(R.styleable.FolderIconPreview_folderPreviewColor, 0)
+        ta.recycle()
+        // Set up timings and interpolators
+        val timings = AnimUtils.getDeviceAppPairLaunchTimings(launcher.deviceProfile.isTablet)
+        expandXInterpolator =
+            Interpolators.clampToProgress(
+                timings.getStagedRectScaleXInterpolator(),
+                timings.stagedRectSlideStartOffset,
+                timings.stagedRectSlideEndOffset
+            )
+        expandYInterpolator =
+            Interpolators.clampToProgress(
+                timings.getStagedRectScaleYInterpolator(),
+                timings.stagedRectSlideStartOffset,
+                timings.stagedRectSlideEndOffset
+            )
+        cellSplitInterpolator =
+            Interpolators.clampToProgress(
+                timings.cellSplitInterpolator,
+                timings.cellSplitStartOffset,
+                timings.cellSplitEndOffset
+            )
+        iconFadeInterpolator =
+            Interpolators.clampToProgress(
+                timings.iconFadeInterpolator,
+                timings.iconFadeStartOffset,
+                timings.iconFadeEndOffset
+            )
+
+        // Find device-specific measurements
+        deviceCornerRadius = QuickStepContract.getWindowCornerRadius(launcher)
+        deviceHalfDividerSize =
+            launcher.resources.getDimensionPixelSize(R.dimen.multi_window_task_divider_size) / 2f
+        val dividerCenterPos = dividerPos + deviceHalfDividerSize
+        desiredSplitRatio =
+            if (dp.isLeftRightSplit) dividerCenterPos / dp.widthPx
+            else dividerCenterPos / dp.heightPx
+    }
+
+    override fun draw(canvas: Canvas) {
+        if (launcher.deviceProfile.isLandscape) {
+            drawLeftRightSplit(canvas)
+        } else {
+            drawTopBottomSplit(canvas)
+        }
+    }
+
+    /** When device is in landscape, we draw the rectangles with a left-right split. */
+    private fun drawLeftRightSplit(canvas: Canvas) {
+        val progress = floatingView.progress
+
+        // Since the entire floating app pair surface is scaling up during this animation, we
+        // scale down most of these drawn elements so that they appear the proper size on-screen.
+        val scaleFactorX = floatingView.scaleX
+        val scaleFactorY = floatingView.scaleY
+
+        // Get the bounds where we will draw the background image
+        val width = bounds.width().toFloat()
+        val height = bounds.height().toFloat()
+
+        // Get device-specific measurements
+        val cornerRadiusX = deviceCornerRadius / scaleFactorX
+        val cornerRadiusY = deviceCornerRadius / scaleFactorY
+        val halfDividerSize = deviceHalfDividerSize / scaleFactorX
+
+        // Calculate changing measurements for background
+        // We add one pixel to some measurements to create a smooth edge with no gaps
+        val onePixel = 1f / scaleFactorX
+        val changingDividerSize =
+            (cellSplitInterpolator.getInterpolation(progress) * halfDividerSize) - onePixel
+        val changingInnerRadiusX = cellSplitInterpolator.getInterpolation(progress) * cornerRadiusX
+        val changingInnerRadiusY = cellSplitInterpolator.getInterpolation(progress) * cornerRadiusY
+        val dividerCenterPos = width * desiredSplitRatio
+
+        // The left half of the background image
+        val leftSide = RectF(
+            0f,
+            0f,
+            dividerCenterPos - changingDividerSize,
+            height
+        )
+        // The right half of the background image
+        val rightSide = RectF(
+            dividerCenterPos + changingDividerSize,
+            0f,
+            width,
+            height
+        )
+
+        // Draw background
+        drawCustomRoundedRect(
+            canvas,
+            leftSide,
+            floatArrayOf(
+                cornerRadiusX, cornerRadiusY,
+                changingInnerRadiusX, changingInnerRadiusY,
+                changingInnerRadiusX, changingInnerRadiusY,
+                cornerRadiusX, cornerRadiusY
+            )
+        )
+        drawCustomRoundedRect(
+            canvas,
+            rightSide,
+            floatArrayOf(
+                changingInnerRadiusX, changingInnerRadiusY,
+                cornerRadiusX, cornerRadiusY,
+                cornerRadiusX, cornerRadiusY,
+                changingInnerRadiusX, changingInnerRadiusY
+            )
+        )
+
+        // Calculate changing measurements for icons.
+        val changingIconSizeX =
+            (STARTING_ICON_SIZE_PX +
+                ((ENDING_ICON_SIZE_PX - STARTING_ICON_SIZE_PX) *
+                    expandXInterpolator.getInterpolation(progress))) / scaleFactorX
+        val changingIconSizeY =
+            (STARTING_ICON_SIZE_PX +
+                ((ENDING_ICON_SIZE_PX - STARTING_ICON_SIZE_PX) *
+                    expandYInterpolator.getInterpolation(progress))) / scaleFactorY
+
+        val changingIcon1Left = ((width / 2f - halfDividerSize) / 2f) - (changingIconSizeX / 2f)
+        val changingIcon2Left =
+            (width - ((width / 2f - halfDividerSize) / 2f)) - (changingIconSizeX / 2f)
+        val changingIconTop = (height / 2f) - (changingIconSizeY / 2f)
+        val changingIconScaleX = changingIconSizeX / appIcon1.bounds.width()
+        val changingIconScaleY = changingIconSizeY / appIcon1.bounds.height()
+        val changingIconAlpha =
+            (255 - (255 * iconFadeInterpolator.getInterpolation(progress))).toInt()
+
+        // Draw first icon
+        canvas.save()
+        canvas.translate(changingIcon1Left, changingIconTop)
+        canvas.scale(changingIconScaleX, changingIconScaleY)
+        appIcon1.alpha = changingIconAlpha
+        appIcon1.draw(canvas)
+        canvas.restore()
+
+        // Draw second icon
+        canvas.save()
+        canvas.translate(changingIcon2Left, changingIconTop)
+        canvas.scale(changingIconScaleX, changingIconScaleY)
+        appIcon2.alpha = changingIconAlpha
+        appIcon2.draw(canvas)
+        canvas.restore()
+    }
+
+    /** When device is in portrait, we draw the rectangles with a top-bottom split. */
+    private fun drawTopBottomSplit(canvas: Canvas) {
+        val progress = floatingView.progress
+
+        // Since the entire floating app pair surface is scaling up during this animation, we
+        // scale down most of these drawn elements so that they appear the proper size on-screen.
+        val scaleFactorX = floatingView.scaleX
+        val scaleFactorY = floatingView.scaleY
+
+        // Get the bounds where we will draw the background image
+        val width = bounds.width().toFloat()
+        val height = bounds.height().toFloat()
+
+        // Get device-specific measurements
+        val cornerRadiusX = deviceCornerRadius / scaleFactorX
+        val cornerRadiusY = deviceCornerRadius / scaleFactorY
+        val halfDividerSize = deviceHalfDividerSize / scaleFactorY
+
+        // Calculate changing measurements for background
+        // We add one pixel to some measurements to create a smooth edge with no gaps
+        val onePixel = 1f / scaleFactorY
+        val changingDividerSize =
+            (cellSplitInterpolator.getInterpolation(progress) * halfDividerSize) - onePixel
+        val changingInnerRadiusX = cellSplitInterpolator.getInterpolation(progress) * cornerRadiusX
+        val changingInnerRadiusY = cellSplitInterpolator.getInterpolation(progress) * cornerRadiusY
+        val dividerCenterPos = height * desiredSplitRatio
+
+        // The top half of the background image
+        val topSide = RectF(
+            0f,
+            0f,
+            width,
+            dividerCenterPos - changingDividerSize
+        )
+        // The bottom half of the background image
+        val bottomSide = RectF(
+            0f,
+            dividerCenterPos + changingDividerSize,
+            width,
+            height
+        )
+
+        // Draw background
+        drawCustomRoundedRect(
+            canvas,
+            topSide,
+            floatArrayOf(
+                cornerRadiusX, cornerRadiusY,
+                cornerRadiusX, cornerRadiusY,
+                changingInnerRadiusX, changingInnerRadiusY,
+                changingInnerRadiusX, changingInnerRadiusY
+            )
+        )
+        drawCustomRoundedRect(
+            canvas,
+            bottomSide,
+            floatArrayOf(
+                changingInnerRadiusX, changingInnerRadiusY,
+                changingInnerRadiusX, changingInnerRadiusY,
+                cornerRadiusX, cornerRadiusY,
+                cornerRadiusX, cornerRadiusY
+            )
+        )
+
+        // Calculate changing measurements for icons.
+        val changingIconSizeX =
+            (STARTING_ICON_SIZE_PX +
+                ((ENDING_ICON_SIZE_PX - STARTING_ICON_SIZE_PX) *
+                    expandXInterpolator.getInterpolation(progress))) / scaleFactorX
+        val changingIconSizeY =
+            (STARTING_ICON_SIZE_PX +
+                ((ENDING_ICON_SIZE_PX - STARTING_ICON_SIZE_PX) *
+                    expandYInterpolator.getInterpolation(progress))) / scaleFactorY
+
+        val changingIconLeft = (width / 2f) - (changingIconSizeX / 2f)
+        val changingIcon1Top = (((height / 2f) - halfDividerSize) / 2f) - (changingIconSizeY / 2f)
+        val changingIcon2Top =
+            (height - (((height / 2f) - halfDividerSize) / 2f)) - (changingIconSizeY / 2f)
+        val changingIconScaleX = changingIconSizeX / appIcon1.bounds.width()
+        val changingIconScaleY = changingIconSizeY / appIcon1.bounds.height()
+        val changingIconAlpha =
+            (255 - 255 * iconFadeInterpolator.getInterpolation(progress)).toInt()
+
+        // Draw first icon
+        canvas.save()
+        canvas.translate(changingIconLeft, changingIcon1Top)
+        canvas.scale(changingIconScaleX, changingIconScaleY)
+        appIcon1.alpha = changingIconAlpha
+        appIcon1.draw(canvas)
+        canvas.restore()
+
+        // Draw second icon
+        canvas.save()
+        canvas.translate(changingIconLeft, changingIcon2Top)
+        canvas.scale(changingIconScaleX, changingIconScaleY)
+        appIcon2.alpha = changingIconAlpha
+        appIcon2.draw(canvas)
+        canvas.restore()
+    }
+
+    /**
+     * Draws a rectangle with custom rounded corners.
+     *
+     * @param c The Canvas to draw on.
+     * @param rect The bounds of the rectangle.
+     * @param radii An array of 8 radii for the corners: top left x, top left y, top right x, top
+     *   right y, bottom right x, and so on.
+     */
+    private fun drawCustomRoundedRect(c: Canvas, rect: RectF, radii: FloatArray) {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+            // Canvas.drawDoubleRoundRect is supported from Q onward
+            c.drawDoubleRoundRect(rect, radii, EMPTY_RECT, ARRAY_OF_ZEROES, backgroundPaint)
+        } else {
+            // Fallback rectangle with uniform rounded corners
+            val scaleFactorX = floatingView.scaleX
+            val scaleFactorY = floatingView.scaleY
+            val cornerRadiusX = QuickStepContract.getWindowCornerRadius(launcher) / scaleFactorX
+            val cornerRadiusY = QuickStepContract.getWindowCornerRadius(launcher) / scaleFactorY
+            c.drawRoundRect(rect, cornerRadiusX, cornerRadiusY, backgroundPaint)
+        }
+    }
+
+    override fun getOpacity(): Int {
+        return PixelFormat.OPAQUE
+    }
+
+    override fun setAlpha(i: Int) {
+        // Required by Drawable but not used.
+    }
+
+    override fun setColorFilter(colorFilter: ColorFilter?) {
+        // Required by Drawable but not used.
+    }
+}
diff --git a/quickstep/src/com/android/quickstep/views/FloatingAppPairView.kt b/quickstep/src/com/android/quickstep/views/FloatingAppPairView.kt
new file mode 100644
index 0000000..e90aa13
--- /dev/null
+++ b/quickstep/src/com/android/quickstep/views/FloatingAppPairView.kt
@@ -0,0 +1,103 @@
+/*
+ * 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.views
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Rect
+import android.graphics.RectF
+import android.graphics.drawable.Drawable
+import android.util.AttributeSet
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import com.android.launcher3.R
+import com.android.launcher3.Utilities
+import com.android.launcher3.statemanager.StatefulActivity
+import com.android.launcher3.views.BaseDragLayer
+
+/**
+ * A temporary View that is created for the app pair launch animation and destroyed at the end.
+ * Matches the size & position of the app pair icon graphic, and expands to full screen.
+ */
+class FloatingAppPairView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
+    FrameLayout(context, attrs) {
+    companion object {
+        fun getFloatingAppPairView(
+            launcher: StatefulActivity<*>,
+            originalView: View,
+            appIcon1: Drawable,
+            appIcon2: Drawable,
+            dividerPos: Int
+        ): FloatingAppPairView {
+            val dragLayer: ViewGroup = launcher.getDragLayer()
+            val floatingView =
+                launcher
+                    .getLayoutInflater()
+                    .inflate(R.layout.floating_app_pair_view, dragLayer, false)
+                    as FloatingAppPairView
+            floatingView.init(launcher, originalView, appIcon1, appIcon2, dividerPos)
+            dragLayer.addView(floatingView, dragLayer.childCount - 1)
+            return floatingView
+        }
+    }
+
+    val startingPosition = RectF()
+    private lateinit var background: FloatingAppPairBackground
+    var progress = 0f
+
+    /** Initializes the view, copying the bounds and location of the original icon view. */
+    fun init(
+        launcher: StatefulActivity<*>,
+        originalView: View,
+        appIcon1: Drawable,
+        appIcon2: Drawable,
+        dividerPos: Int
+    ) {
+        val viewBounds = Rect(0, 0, originalView.width, originalView.height)
+        Utilities.getBoundsForViewInDragLayer(
+            launcher.getDragLayer(),
+            originalView,
+            viewBounds,
+            false /* ignoreTransform */,
+            null /* recycle */,
+            startingPosition
+        )
+        val lp =
+            BaseDragLayer.LayoutParams(
+                Math.round(startingPosition.width()),
+                Math.round(startingPosition.height())
+            )
+        lp.ignoreInsets = true
+
+        // Position the floating view exactly on top of the original
+        lp.topMargin = Math.round(startingPosition.top)
+        lp.leftMargin = Math.round(startingPosition.left)
+
+        layout(lp.leftMargin, lp.topMargin, lp.leftMargin + lp.width, lp.topMargin + lp.height)
+        layoutParams = lp
+
+        // Prepare to draw app pair icon background
+        background = FloatingAppPairBackground(context, this, appIcon1, appIcon2, dividerPos)
+        background.setBounds(0, 0, lp.width, lp.height)
+    }
+
+    override fun dispatchDraw(canvas: Canvas) {
+        super.dispatchDraw(canvas)
+        background.draw(canvas)
+    }
+}
diff --git a/quickstep/src/com/android/quickstep/views/RecentsView.java b/quickstep/src/com/android/quickstep/views/RecentsView.java
index 7e1034b..87cee63 100644
--- a/quickstep/src/com/android/quickstep/views/RecentsView.java
+++ b/quickstep/src/com/android/quickstep/views/RecentsView.java
@@ -2124,8 +2124,7 @@
         for (int i = 0; i < taskCount; i++) {
             TaskView taskView = requireTaskViewAt(i);
             taskView.updateTaskSize();
-            taskView.getPrimaryNonGridTranslationProperty().set(taskView, accumulatedTranslationX);
-            taskView.getSecondaryNonGridTranslationProperty().set(taskView, 0f);
+            taskView.setNonGridTranslationX(accumulatedTranslationX);
             taskView.setNonGridPivotTranslationX(translateXToMiddle);
             // Compensate space caused by TaskView scaling.
             float widthDiff =
@@ -2642,23 +2641,25 @@
         if (endState.displayOverviewTasksAsGrid(mActivity.getDeviceProfile())) {
             TaskView runningTaskView = getRunningTaskView();
             float runningTaskPrimaryGridTranslation = 0;
+            float runningTaskSecondaryGridTranslation = 0;
             if (runningTaskView != null) {
                 // Apply the grid translation to running task unless it's being snapped to
                 // and removes the current translation applied to the running task.
-                runningTaskPrimaryGridTranslation = mOrientationHandler.getPrimaryValue(
-                        runningTaskView.getGridTranslationX(),
-                        runningTaskView.getGridTranslationY())
-                        - runningTaskView.getPrimaryNonGridTranslationProperty().get(
-                        runningTaskView);
+                runningTaskPrimaryGridTranslation = runningTaskView.getGridTranslationX()
+                        - runningTaskView.getNonGridTranslationX();
+                runningTaskSecondaryGridTranslation = runningTaskView.getGridTranslationY();
             }
             for (TaskViewSimulator tvs : taskViewSimulators) {
                 if (animatorSet == null) {
                     setGridProgress(1);
                     tvs.taskPrimaryTranslation.value = runningTaskPrimaryGridTranslation;
+                    tvs.taskSecondaryTranslation.value = runningTaskSecondaryGridTranslation;
                 } else {
                     animatorSet.play(ObjectAnimator.ofFloat(this, RECENTS_GRID_PROGRESS, 1));
                     animatorSet.play(tvs.taskPrimaryTranslation.animateToValue(
                             runningTaskPrimaryGridTranslation));
+                    animatorSet.play(tvs.taskSecondaryTranslation.animateToValue(
+                            runningTaskSecondaryGridTranslation));
                 }
             }
         }
@@ -3123,6 +3124,14 @@
                     + snappedTaskNonGridScrollAdjustment);
         }
 
+        final TaskView runningTask = getRunningTaskView();
+        if (showAsGrid() && enableGridOnlyOverview() && runningTask != null) {
+            runActionOnRemoteHandles(
+                    remoteTargetHandle -> remoteTargetHandle.getTaskViewSimulator()
+                            .taskSecondaryTranslation.value = runningTask.getGridTranslationY()
+            );
+        }
+
         mClearAllButton.setGridTranslationPrimary(
                 clearAllTotalTranslationX - snappedTaskGridTranslationX);
         mClearAllButton.setGridScrollOffset(
diff --git a/quickstep/src/com/android/quickstep/views/TaskView.java b/quickstep/src/com/android/quickstep/views/TaskView.java
index af4f402..b42f055 100644
--- a/quickstep/src/com/android/quickstep/views/TaskView.java
+++ b/quickstep/src/com/android/quickstep/views/TaskView.java
@@ -119,6 +119,8 @@
 import com.android.systemui.shared.system.ActivityManagerWrapper;
 import com.android.systemui.shared.system.QuickStepContract;
 
+import kotlin.Unit;
+
 import java.lang.annotation.Retention;
 import java.util.Arrays;
 import java.util.Collections;
@@ -127,8 +129,6 @@
 import java.util.function.Consumer;
 import java.util.stream.Stream;
 
-import kotlin.Unit;
-
 /**
  * A task in the Recents view.
  */
@@ -304,32 +304,6 @@
                 }
             };
 
-    private static final FloatProperty<TaskView> NON_GRID_TRANSLATION_X =
-            new FloatProperty<TaskView>("nonGridTranslationX") {
-                @Override
-                public void setValue(TaskView taskView, float v) {
-                    taskView.setNonGridTranslationX(v);
-                }
-
-                @Override
-                public Float get(TaskView taskView) {
-                    return taskView.mNonGridTranslationX;
-                }
-            };
-
-    private static final FloatProperty<TaskView> NON_GRID_TRANSLATION_Y =
-            new FloatProperty<TaskView>("nonGridTranslationY") {
-                @Override
-                public void setValue(TaskView taskView, float v) {
-                    taskView.setNonGridTranslationY(v);
-                }
-
-                @Override
-                public Float get(TaskView taskView) {
-                    return taskView.mNonGridTranslationY;
-                }
-            };
-
     public static final FloatProperty<TaskView> GRID_END_TRANSLATION_X =
             new FloatProperty<TaskView>("gridEndTranslationX") {
                 @Override
@@ -386,7 +360,6 @@
     // Applied as a complement to gridTranslation, for adjusting the carousel overview and quick
     // switch.
     private float mNonGridTranslationX;
-    private float mNonGridTranslationY;
     private float mNonGridPivotTranslationX;
     // Used when in SplitScreenSelectState
     private float mSplitSelectTranslationY;
@@ -1323,7 +1296,7 @@
     }
 
     protected void resetPersistentViewTransforms() {
-        mNonGridTranslationX = mNonGridTranslationY = mGridTranslationX =
+        mNonGridTranslationX = mGridTranslationX =
                 mGridTranslationY = mBoxTranslationY = mNonGridPivotTranslationX = 0f;
         resetViewTransforms();
     }
@@ -1494,14 +1467,16 @@
         applyTranslationY();
     }
 
-    private void setNonGridTranslationX(float nonGridTranslationX) {
-        mNonGridTranslationX = nonGridTranslationX;
-        applyTranslationX();
+    public float getNonGridTranslationX() {
+        return mNonGridTranslationX;
     }
 
-    private void setNonGridTranslationY(float nonGridTranslationY) {
-        mNonGridTranslationY = nonGridTranslationY;
-        applyTranslationY();
+    /**
+     * Updates X coordinate of non-grid translation.
+     */
+    public void setNonGridTranslationX(float nonGridTranslationX) {
+        mNonGridTranslationX = nonGridTranslationX;
+        applyTranslationX();
     }
 
     public void setGridTranslationX(float gridTranslationX) {
@@ -1540,7 +1515,7 @@
         if (gridEnabled) {
             scrollAdjustment += mGridTranslationX;
         } else {
-            scrollAdjustment += getPrimaryNonGridTranslationProperty().get(this);
+            scrollAdjustment += getNonGridTranslationX();
         }
         return scrollAdjustment;
     }
@@ -1586,9 +1561,7 @@
      * change according to a temporary state (e.g. task offset).
      */
     public float getPersistentTranslationY() {
-        return mBoxTranslationY
-                + getNonGridTrans(mNonGridTranslationY)
-                + getGridTrans(mGridTranslationY);
+        return mBoxTranslationY + getGridTrans(mGridTranslationY);
     }
 
     public FloatProperty<TaskView> getPrimarySplitTranslationProperty() {
@@ -1626,16 +1599,6 @@
                 TASK_RESISTANCE_TRANSLATION_X, TASK_RESISTANCE_TRANSLATION_Y);
     }
 
-    public FloatProperty<TaskView> getPrimaryNonGridTranslationProperty() {
-        return getPagedOrientationHandler().getPrimaryValue(
-                NON_GRID_TRANSLATION_X, NON_GRID_TRANSLATION_Y);
-    }
-
-    public FloatProperty<TaskView> getSecondaryNonGridTranslationProperty() {
-        return getPagedOrientationHandler().getSecondaryValue(
-                NON_GRID_TRANSLATION_X, NON_GRID_TRANSLATION_Y);
-    }
-
     @Override
     public boolean hasOverlappingRendering() {
         // TODO: Clip-out the icon region from the thumbnail, since they are overlapping.
diff --git a/quickstep/tests/src/com/android/quickstep/FallbackRecentsTest.java b/quickstep/tests/src/com/android/quickstep/FallbackRecentsTest.java
index eced5a9..8d54dce 100644
--- a/quickstep/tests/src/com/android/quickstep/FallbackRecentsTest.java
+++ b/quickstep/tests/src/com/android/quickstep/FallbackRecentsTest.java
@@ -144,7 +144,7 @@
                 .around(new TestStabilityRule())
                 .around(new NavigationModeSwitchRule(mLauncher))
                 .around(new FailureWatcher(mLauncher, viewCaptureRule::getViewCaptureData))
-                .around(viewCaptureRule)
+                // .around(viewCaptureRule) b/315482167
                 .around(new TestIsolationRule(mLauncher, false))
                 .around(setLauncherCommand);
 
diff --git a/quickstep/tests/src/com/android/quickstep/TaplTestsQuickstep.java b/quickstep/tests/src/com/android/quickstep/TaplTestsQuickstep.java
index 0bcdb19..6cbe171 100644
--- a/quickstep/tests/src/com/android/quickstep/TaplTestsQuickstep.java
+++ b/quickstep/tests/src/com/android/quickstep/TaplTestsQuickstep.java
@@ -355,6 +355,8 @@
     public void testPressBack() throws Exception {
         InstrumentationRegistry.getInstrumentation().getUiAutomation().adoptShellPermissionIdentity(
                 READ_DEVICE_CONFIG_PERMISSION);
+        // Debug if we need to goHome to prevent wrong previous state b/315525621
+        mLauncher.goHome();
         assumeFalse(FeatureFlags.ENABLE_BACK_SWIPE_LAUNCHER_ANIMATION.get());
         mLauncher.getWorkspace().switchToAllApps();
         mLauncher.pressBack();
diff --git a/quickstep/tests/src/com/android/quickstep/TaplTestsTrackpad.java b/quickstep/tests/src/com/android/quickstep/TaplTestsTrackpad.java
index 0eec8b7..3465f23 100644
--- a/quickstep/tests/src/com/android/quickstep/TaplTestsTrackpad.java
+++ b/quickstep/tests/src/com/android/quickstep/TaplTestsTrackpad.java
@@ -86,7 +86,7 @@
             mLauncher.setTrackpadGestureType(TrackpadGestureType.THREE_FINGER);
 
             startTestActivity(2);
-            mLauncher.pressBack();
+            mLauncher.getLaunchedAppState().pressBackToWorkspace();
         } finally {
             instrumentation.getUiAutomation().dropShellPermissionIdentity();
         }
diff --git a/quickstep/tests/src/com/android/quickstep/taskbar/controllers/TaskbarPinningControllerTest.kt b/quickstep/tests/src/com/android/quickstep/taskbar/controllers/TaskbarPinningControllerTest.kt
new file mode 100644
index 0000000..dbe4624
--- /dev/null
+++ b/quickstep/tests/src/com/android/quickstep/taskbar/controllers/TaskbarPinningControllerTest.kt
@@ -0,0 +1,202 @@
+/*
+ * 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.taskbar.controllers
+
+import android.animation.AnimatorSet
+import android.animation.ObjectAnimator
+import android.view.View
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.launcher3.LauncherPrefs
+import com.android.launcher3.LauncherPrefs.Companion.TASKBAR_PINNING
+import com.android.launcher3.logging.StatsLogManager
+import com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_TASKBAR_DIVIDER_MENU_CLOSE
+import com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_TASKBAR_DIVIDER_MENU_OPEN
+import com.android.launcher3.taskbar.TaskbarActivityContext
+import com.android.launcher3.taskbar.TaskbarBaseTestCase
+import com.android.launcher3.taskbar.TaskbarDividerPopupView
+import com.android.launcher3.taskbar.TaskbarDragLayer
+import com.android.launcher3.taskbar.TaskbarPinningController
+import com.android.launcher3.taskbar.TaskbarPinningController.Companion.PINNING_PERSISTENT
+import com.android.launcher3.taskbar.TaskbarPinningController.Companion.PINNING_TRANSIENT
+import com.android.launcher3.taskbar.TaskbarSharedState
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.doNothing
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.spy
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class TaskbarPinningControllerTest : TaskbarBaseTestCase() {
+    private val taskbarDragLayer = mock<TaskbarDragLayer>()
+    private val taskbarSharedState = mock<TaskbarSharedState>()
+    private val launcherPrefs = mock<LauncherPrefs> { on { get(TASKBAR_PINNING) } doReturn false }
+    private val statsLogger = mock<StatsLogManager.StatsLogger>()
+    private val statsLogManager = mock<StatsLogManager> { on { logger() } doReturn statsLogger }
+    private lateinit var pinningController: TaskbarPinningController
+
+    @Before
+    override fun setup() {
+        super.setup()
+        whenever(taskbarActivityContext.launcherPrefs).thenReturn(launcherPrefs)
+        whenever(taskbarActivityContext.dragLayer).thenReturn(taskbarDragLayer)
+        whenever(taskbarActivityContext.statsLogManager).thenReturn(statsLogManager)
+        pinningController = spy(TaskbarPinningController(taskbarActivityContext))
+        pinningController.init(taskbarControllers, taskbarSharedState)
+    }
+
+    @Test
+    fun testOnCloseCallback_whenClosingPopupView_shouldLogStatsForClosingPopupMenu() {
+        pinningController.onCloseCallback(false)
+        verify(statsLogger, times(1)).log(LAUNCHER_TASKBAR_DIVIDER_MENU_CLOSE)
+    }
+
+    @Test
+    fun testOnCloseCallback_whenClosingPopupView_shouldPostVisibilityChangedToDragLayer() {
+        val argumentCaptor = argumentCaptor<Runnable>()
+        pinningController.onCloseCallback(false)
+        verify(taskbarDragLayer, times(1)).post(argumentCaptor.capture())
+
+        val runnable = argumentCaptor.lastValue
+        assertThat(runnable).isNotNull()
+
+        runnable.run()
+        verify(taskbarActivityContext, times(1)).onPopupVisibilityChanged(false)
+    }
+
+    @Test
+    fun testOnCloseCallback_whenPreferenceUnchanged_shouldNotAnimateTaskbarPinning() {
+        pinningController.onCloseCallback(false)
+        verify(taskbarSharedState, never()).taskbarWasPinned = true
+        verify(pinningController, never()).animateTaskbarPinning(any())
+    }
+
+    @Test
+    fun testOnCloseCallback_whenPreferenceChanged_shouldAnimateToPinnedTaskbar() {
+        whenever(launcherPrefs.get(TASKBAR_PINNING)).thenReturn(false)
+        doNothing().whenever(pinningController).animateTaskbarPinning(any())
+
+        pinningController.onCloseCallback(true)
+
+        verify(taskbarSharedState, times(1)).taskbarWasPinned = false
+        verify(pinningController, times(1)).animateTaskbarPinning(PINNING_PERSISTENT)
+    }
+
+    @Test
+    fun testOnCloseCallback_whenPreferenceChanged_shouldAnimateToTransientTaskbar() {
+        whenever(launcherPrefs.get(TASKBAR_PINNING)).thenReturn(true)
+        doNothing().whenever(pinningController).animateTaskbarPinning(any())
+
+        pinningController.onCloseCallback(true)
+
+        verify(taskbarSharedState, times(1)).taskbarWasPinned = true
+        verify(pinningController, times(1)).animateTaskbarPinning(PINNING_TRANSIENT)
+    }
+
+    @Test
+    fun testShowPinningView_whenShowingPinningView_shouldSetTaskbarWindowFullscreenAndPostRunnableToView() {
+        val popupView =
+            mock<TaskbarDividerPopupView<TaskbarActivityContext>> {
+                on { requestFocus() } doReturn true
+            }
+        val view = mock<View>()
+        val argumentCaptor = argumentCaptor<Runnable>()
+        doReturn(popupView).whenever(pinningController).getPopupView(view)
+
+        pinningController.showPinningView(view)
+
+        verify(view, times(1)).post(argumentCaptor.capture())
+
+        val runnable = argumentCaptor.lastValue
+        assertThat(runnable).isNotNull()
+        runnable.run()
+
+        verify(pinningController, times(1)).getPopupView(view)
+        verify(popupView, times(1)).requestFocus()
+        verify(popupView, times(1)).onCloseCallback = any()
+        verify(taskbarActivityContext, times(1)).onPopupVisibilityChanged(true)
+        verify(popupView, times(1)).show()
+        verify(statsLogger, times(1)).log(LAUNCHER_TASKBAR_DIVIDER_MENU_OPEN)
+    }
+
+    @Test
+    fun testAnimateTaskbarPinning_whenAnimationEnds_shouldInvokeCallbackDoOnEnd() {
+        val animatorSet = spy(AnimatorSet())
+        doReturn(animatorSet)
+            .whenever(pinningController)
+            .getAnimatorSetForTaskbarPinningAnimation(PINNING_PERSISTENT)
+        doNothing().whenever(animatorSet).start()
+        pinningController.animateTaskbarPinning(PINNING_PERSISTENT)
+        animatorSet.listeners[0].onAnimationEnd(ObjectAnimator())
+        verify(pinningController, times(1)).recreateTaskbarAndUpdatePinningValue()
+    }
+
+    @Test
+    fun testAnimateTaskbarPinning_whenAnimatingToPersistentTaskbar_shouldAnimateToPinnedTaskbar() {
+        val animatorSet = spy(AnimatorSet())
+        doReturn(animatorSet)
+            .whenever(pinningController)
+            .getAnimatorSetForTaskbarPinningAnimation(PINNING_PERSISTENT)
+        doNothing().whenever(animatorSet).start()
+        pinningController.animateTaskbarPinning(PINNING_PERSISTENT)
+
+        verify(taskbarOverlayController, times(1)).hideWindow()
+        verify(pinningController, times(1))
+            .getAnimatorSetForTaskbarPinningAnimation(PINNING_PERSISTENT)
+        verify(taskbarViewController, times(1))
+            .animateAwayNotificationDotsDuringTaskbarPinningAnimation()
+        verify(taskbarDragLayer, times(1)).setAnimatingTaskbarPinning(true)
+        assertThat(pinningController.isAnimatingTaskbarPinning).isTrue()
+        assertThat(animatorSet.listeners).isNotNull()
+    }
+
+    @Test
+    fun testAnimateTaskbarPinning_whenAnimatingToTransientTaskbar_shouldAnimateToTransientTaskbar() {
+        val animatorSet = spy(AnimatorSet())
+        doReturn(animatorSet)
+            .whenever(pinningController)
+            .getAnimatorSetForTaskbarPinningAnimation(PINNING_TRANSIENT)
+        doNothing().whenever(animatorSet).start()
+        pinningController.animateTaskbarPinning(PINNING_TRANSIENT)
+
+        verify(taskbarOverlayController, times(1)).hideWindow()
+        verify(pinningController, times(1))
+            .getAnimatorSetForTaskbarPinningAnimation(PINNING_TRANSIENT)
+        verify(taskbarDragLayer, times(1)).setAnimatingTaskbarPinning(true)
+        assertThat(pinningController.isAnimatingTaskbarPinning).isTrue()
+        verify(taskbarViewController, times(1))
+            .animateAwayNotificationDotsDuringTaskbarPinningAnimation()
+        assertThat(animatorSet.listeners).isNotNull()
+    }
+
+    @Test
+    fun testRecreateTaskbarAndUpdatePinningValue_whenAnimationEnds_shouldUpdateTaskbarPinningLauncherPref() {
+        pinningController.recreateTaskbarAndUpdatePinningValue()
+        verify(taskbarDragLayer, times(1)).setAnimatingTaskbarPinning(false)
+        assertThat(pinningController.isAnimatingTaskbarPinning).isFalse()
+        verify(launcherPrefs, times(1)).put(TASKBAR_PINNING, true)
+    }
+}
diff --git a/quickstep/tests/src/com/android/quickstep/util/SplitAnimationControllerTest.kt b/quickstep/tests/src/com/android/quickstep/util/SplitAnimationControllerTest.kt
index 50803fe..86018b1 100644
--- a/quickstep/tests/src/com/android/quickstep/util/SplitAnimationControllerTest.kt
+++ b/quickstep/tests/src/com/android/quickstep/util/SplitAnimationControllerTest.kt
@@ -19,8 +19,13 @@
 
 import android.graphics.Bitmap
 import android.graphics.drawable.Drawable
+import android.view.SurfaceControl.Transaction
 import android.view.View
+import android.window.TransitionInfo
 import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.android.launcher3.apppairs.AppPairIcon
+import com.android.launcher3.statehandlers.DepthController
+import com.android.launcher3.statemanager.StateManager
 import com.android.launcher3.util.SplitConfigurationOptions
 import com.android.quickstep.views.GroupedTaskView
 import com.android.quickstep.views.IconView
@@ -32,13 +37,18 @@
 import org.junit.Before
 import org.junit.Test
 import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.doNothing
 import org.mockito.kotlin.mock
+import org.mockito.kotlin.spy
+import org.mockito.kotlin.verify
 import org.mockito.kotlin.whenever
 
 @RunWith(AndroidJUnit4::class)
 class SplitAnimationControllerTest {
 
     private val taskId = 9
+    private val taskId2 = 10
 
     private val mockSplitSelectStateController: SplitSelectStateController = mock()
     // TaskView
@@ -52,12 +62,19 @@
     private val mockTask: Task = mock()
     private val mockTaskKey: Task.TaskKey = mock()
     private val mockTaskIdAttributeContainer: TaskIdAttributeContainer = mock()
+    // AppPairIcon
+    private val mockAppPairIcon: AppPairIcon = mock()
 
     // SplitSelectSource
     private val splitSelectSource: SplitConfigurationOptions.SplitSelectSource = mock()
     private val mockSplitSourceDrawable: Drawable = mock()
     private val mockSplitSourceView: View = mock()
 
+    private val stateManager: StateManager<*> = mock()
+    private val depthController: DepthController = mock()
+    private val transitionInfo: TransitionInfo = mock()
+    private val transaction: Transaction = mock()
+
     lateinit var splitAnimationController: SplitAnimationController
 
     @Before
@@ -172,4 +189,110 @@
             splitAnimInitProps.iconDrawable
         )
     }
+
+    @Test
+    fun playsAppropriateSplitLaunchAnimation_playsLegacyLaunchCorrectly() {
+        val spySplitAnimationController = spy(splitAnimationController)
+        doNothing()
+            .whenever(spySplitAnimationController)
+            .composeRecentsSplitLaunchAnimatorLegacy(
+                any(), any(), any(), any(), any(), any(), any(), any(), any())
+
+        spySplitAnimationController.playSplitLaunchAnimation(
+            mockGroupedTaskView,
+            null /* launchingIconView */,
+            taskId,
+            taskId2,
+            arrayOf() /* apps */,
+            arrayOf() /* wallpapers */,
+            arrayOf() /* nonApps */,
+            stateManager,
+            depthController,
+            null /* info */,
+            null /* t */,
+            {} /* finishCallback */
+        )
+
+        verify(spySplitAnimationController)
+            .composeRecentsSplitLaunchAnimatorLegacy(
+                any(), any(), any(), any(), any(), any(), any(), any(), any())
+    }
+
+    @Test
+    fun playsAppropriateSplitLaunchAnimation_playsRecentsLaunchCorrectly() {
+        val spySplitAnimationController = spy(splitAnimationController)
+        doNothing()
+            .whenever(spySplitAnimationController)
+            .composeRecentsSplitLaunchAnimator(any(), any(), any(), any(), any(), any())
+
+        spySplitAnimationController.playSplitLaunchAnimation(
+            mockGroupedTaskView,
+            null /* launchingIconView */,
+            taskId,
+            taskId2,
+            null /* apps */,
+            null /* wallpapers */,
+            null /* nonApps */,
+            stateManager,
+            depthController,
+            transitionInfo,
+            transaction,
+            {} /* finishCallback */
+        )
+
+        verify(spySplitAnimationController)
+            .composeRecentsSplitLaunchAnimator(any(), any(), any(), any(), any(), any())
+    }
+
+    @Test
+    fun playsAppropriateSplitLaunchAnimation_playsIconLaunchCorrectly() {
+        val spySplitAnimationController = spy(splitAnimationController)
+        doNothing()
+            .whenever(spySplitAnimationController)
+            .composeIconSplitLaunchAnimator(any(), any(), any(), any(), any(), any())
+
+        spySplitAnimationController.playSplitLaunchAnimation(
+            null /* launchingTaskView */,
+            mockAppPairIcon,
+            taskId,
+            taskId2,
+            null /* apps */,
+            null /* wallpapers */,
+            null /* nonApps */,
+            stateManager,
+            depthController,
+            transitionInfo,
+            transaction,
+            {} /* finishCallback */
+        )
+
+        verify(spySplitAnimationController)
+            .composeIconSplitLaunchAnimator(any(), any(), any(), any(), any(), any())
+    }
+
+    @Test
+    fun playsAppropriateSplitLaunchAnimation_playsFadeInLaunchCorrectly() {
+        val spySplitAnimationController = spy(splitAnimationController)
+        doNothing()
+            .whenever(spySplitAnimationController)
+            .composeFadeInSplitLaunchAnimator(any(), any(), any(), any(), any())
+
+        spySplitAnimationController.playSplitLaunchAnimation(
+            null /* launchingTaskView */,
+            null /* launchingIconView */,
+            taskId,
+            taskId2,
+            null /* apps */,
+            null /* wallpapers */,
+            null /* nonApps */,
+            stateManager,
+            depthController,
+            transitionInfo,
+            transaction,
+            {} /* finishCallback */
+        )
+
+        verify(spySplitAnimationController)
+            .composeFadeInSplitLaunchAnimator(any(), any(), any(), any(), any())
+    }
 }
diff --git a/res/layout/app_pair_icon.xml b/res/layout/app_pair_icon.xml
index 2b9a98b..4e2dd58 100644
--- a/res/layout/app_pair_icon.xml
+++ b/res/layout/app_pair_icon.xml
@@ -20,6 +20,11 @@
     android:layout_height="match_parent"
     android:orientation="vertical"
     android:focusable="true" >
+    <com.android.launcher3.apppairs.AppPairIconGraphic
+        android:id="@+id/app_pair_icon_graphic"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:focusable="false" />
     <com.android.launcher3.views.DoubleShadowBubbleTextView
         style="@style/BaseIcon.Workspace"
         android:id="@+id/app_pair_icon_name"
diff --git a/res/layout/floating_app_pair_view.xml b/res/layout/floating_app_pair_view.xml
new file mode 100644
index 0000000..88ec655
--- /dev/null
+++ b/res/layout/floating_app_pair_view.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<com.android.quickstep.views.FloatingAppPairView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+</com.android.quickstep.views.FloatingAppPairView>
\ No newline at end of file
diff --git a/res/values/dimens.xml b/res/values/dimens.xml
index 242c439..8cb6c71 100644
--- a/res/values/dimens.xml
+++ b/res/values/dimens.xml
@@ -125,6 +125,9 @@
     <dimen name="all_apps_tip_bottom_margin">8dp</dimen>
     <dimen name="all_apps_height_extra">6dp</dimen>
     <dimen name="all_apps_paged_view_top_padding">40dp</dimen>
+    <dimen name="all_apps_recycler_view_decorator_padding">1dp</dimen>
+    <dimen name="all_apps_recycler_view_decorator_group_radius">28dp</dimen>
+    <dimen name="all_apps_recycler_view_decorator_result_radius">4dp</dimen>
 
     <dimen name="all_apps_icon_drawable_padding">8dp</dimen>
     <dimen name="all_apps_predicted_icon_vertical_padding">8dp</dimen>
diff --git a/src/com/android/launcher3/AbstractFloatingView.java b/src/com/android/launcher3/AbstractFloatingView.java
index d78afd3..f72c556 100644
--- a/src/com/android/launcher3/AbstractFloatingView.java
+++ b/src/com/android/launcher3/AbstractFloatingView.java
@@ -135,6 +135,10 @@
     public static final int TYPE_TASKBAR_OVERLAYS =
             TYPE_TASKBAR_ALL_APPS | TYPE_TASKBAR_EDUCATION_DIALOG;
 
+    // Floating views that a TouchController should not try to intercept touches from.
+    public static final int TYPE_TOUCH_CONTROLLER_NO_INTERCEPT = TYPE_ALL & ~TYPE_DISCOVERY_BOUNCE
+            & ~TYPE_LISTENER & ~TYPE_TASKBAR_OVERLAYS;
+
     public static final int TYPE_ALL_EXCEPT_ON_BOARD_POPUP = TYPE_ALL & ~TYPE_ON_BOARD_POPUP
             & ~TYPE_PIN_IME_POPUP;
 
diff --git a/src/com/android/launcher3/AppWidgetsRestoredReceiver.java b/src/com/android/launcher3/AppWidgetsRestoredReceiver.java
index 641fd83..429978e 100644
--- a/src/com/android/launcher3/AppWidgetsRestoredReceiver.java
+++ b/src/com/android/launcher3/AppWidgetsRestoredReceiver.java
@@ -9,9 +9,13 @@
 import android.content.Intent;
 import android.util.Log;
 
+import com.android.launcher3.logging.FileLog;
+import com.android.launcher3.provider.RestoreDbTask;
 import com.android.launcher3.util.IntArray;
 import com.android.launcher3.widget.LauncherWidgetHolder;
 
+import java.util.Arrays;
+
 public class AppWidgetsRestoredReceiver extends BroadcastReceiver {
 
     private static final String TAG = "AppWidgetsRestoredReceiver";
@@ -20,8 +24,11 @@
     public void onReceive(final Context context, Intent intent) {
         if (AppWidgetManager.ACTION_APPWIDGET_HOST_RESTORED.equals(intent.getAction())) {
             int hostId = intent.getIntExtra(AppWidgetManager.EXTRA_HOST_ID, 0);
-            Log.d(TAG, "Widget ID map received for host:" + hostId);
+            Log.d(TAG, "onReceive: Widget ID map received for host:" + hostId);
             if (hostId != LauncherWidgetHolder.APPWIDGET_HOST_ID) {
+                Log.w(TAG,  "onReceive: hostId does not match Launcher."
+                        + " Expected: " + LauncherWidgetHolder.APPWIDGET_HOST_ID
+                        + ", Actual: " + hostId);
                 return;
             }
 
@@ -31,8 +38,18 @@
                 LauncherPrefs.get(context).putSync(
                         OLD_APP_WIDGET_IDS.to(IntArray.wrap(oldIds).toConcatString()),
                         APP_WIDGET_IDS.to(IntArray.wrap(newIds).toConcatString()));
+                FileLog.d(TAG, "onReceive: Valid Widget IDs received."
+                        + " old IDs=" + Arrays.toString(oldIds)
+                        + ", new IDs=" + Arrays.toString(newIds));
+                if (!RestoreDbTask.isPending(context)) {
+                    FileLog.w(TAG, "onReceive: Restored App Widget Ids received but Launcher"
+                            + " restore is not pending. New widget Ids might not get restored.");
+                }
             } else {
-                Log.e(TAG, "Invalid host restored received");
+                Log.e(TAG, "onReceive: Invalid widget ids received for Launcher"
+                        + ", skipping restore of widget ids."
+                        + " newIds=" + Arrays.toString(newIds)
+                        + ", oldIds=" + Arrays.toString(oldIds));
             }
         }
     }
diff --git a/src/com/android/launcher3/InvariantDeviceProfile.java b/src/com/android/launcher3/InvariantDeviceProfile.java
index dfbbcaa..5721ed3 100644
--- a/src/com/android/launcher3/InvariantDeviceProfile.java
+++ b/src/com/android/launcher3/InvariantDeviceProfile.java
@@ -298,11 +298,15 @@
      * Reinitialize the current grid after a restore, where some grids might now be disabled.
      */
     public void reinitializeAfterRestore(Context context) {
-        FileLog.d(TAG, "Reinitializing grid after restore");
         String currentGridName = getCurrentGridName(context);
         String currentDbFile = dbFile;
         String newGridName = initGrid(context, currentGridName);
         String newDbFile = dbFile;
+        FileLog.d(TAG, "Reinitializing grid after restore."
+                + " currentGridName=" + currentGridName
+                + ", currentDbFile=" + currentDbFile
+                + ", newGridName=" + newGridName
+                + ", newDbFile=" + newDbFile);
         if (!newDbFile.equals(currentDbFile)) {
             FileLog.d(TAG, "Restored grid is disabled : " + currentGridName
                     + ", migrating to: " + newGridName
diff --git a/src/com/android/launcher3/Launcher.java b/src/com/android/launcher3/Launcher.java
index acb6c05..5adfd43 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;
@@ -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);
     }
 
     /**
@@ -3212,7 +3189,7 @@
      * Handles an app pair launch; overridden in
      * {@link com.android.launcher3.uioverrides.QuickstepLauncher}
      */
-    public void launchAppPair(WorkspaceItemInfo app1, WorkspaceItemInfo app2) {
+    public void launchAppPair(AppPairIcon appPairIcon) {
         // Overridden
     }
 
diff --git a/src/com/android/launcher3/LauncherPrefs.kt b/src/com/android/launcher3/LauncherPrefs.kt
index a05b0f5..78056e6 100644
--- a/src/com/android/launcher3/LauncherPrefs.kt
+++ b/src/com/android/launcher3/LauncherPrefs.kt
@@ -364,6 +364,13 @@
                         EncryptionType.MOVE_TO_DEVICE_PROTECTED
                 )
         @JvmField
+        val PRIVATE_SPACE_APPS =
+                nonRestorableItem(
+                        "pref_private_space_apps",
+                        0,
+                        EncryptionType.MOVE_TO_DEVICE_PROTECTED
+                )
+        @JvmField
         val THEMED_ICONS =
             backedUpItem(Themes.KEY_THEMED_ICONS, false, EncryptionType.MOVE_TO_DEVICE_PROTECTED)
         @JvmField val PROMISE_ICON_IDS = backedUpItem(InstallSessionHelper.PROMISE_ICON_IDS, "")
diff --git a/src/com/android/launcher3/ModelCallbacks.kt b/src/com/android/launcher3/ModelCallbacks.kt
index f37a1ec..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,7 +129,7 @@
         // 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(
@@ -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/allapps/ActivityAllAppsContainerView.java b/src/com/android/launcher3/allapps/ActivityAllAppsContainerView.java
index e5a223a..7f1d216 100644
--- a/src/com/android/launcher3/allapps/ActivityAllAppsContainerView.java
+++ b/src/com/android/launcher3/allapps/ActivityAllAppsContainerView.java
@@ -407,7 +407,7 @@
             // If exiting search, revert predictive back scale on all apps
             mAllAppsTransitionController.animateAllAppsToNoScale();
         }
-        mSearchTransitionController.animateToSearchState(goingToSearch, durationMs,
+        mSearchTransitionController.animateToState(goingToSearch, durationMs,
                 /* onEndRunnable = */ () -> {
                     mIsSearching = goingToSearch;
                     updateSearchResultsVisibility();
diff --git a/src/com/android/launcher3/allapps/AllAppsRecyclerView.java b/src/com/android/launcher3/allapps/AllAppsRecyclerView.java
index b0f13ef..36a44cc 100644
--- a/src/com/android/launcher3/allapps/AllAppsRecyclerView.java
+++ b/src/com/android/launcher3/allapps/AllAppsRecyclerView.java
@@ -36,7 +36,11 @@
 import android.graphics.Canvas;
 import android.util.AttributeSet;
 import android.util.Log;
+import android.view.View;
 
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.util.Consumer;
 import androidx.recyclerview.widget.RecyclerView;
 
 import com.android.launcher3.DeviceProfile;
@@ -57,6 +61,7 @@
     protected static final String TAG = "AllAppsRecyclerView";
     private static final boolean DEBUG = false;
     private static final boolean DEBUG_LATENCY = Utilities.isPropertyEnabled(SEARCH_LOGGING);
+    private Consumer<View> mChildAttachedConsumer;
 
     protected final int mNumAppsPerRow;
     private final AllAppsFastScrollHelper mFastScrollHelper;
@@ -282,6 +287,22 @@
         }
     }
 
+    /**
+     * This will be called just before a new child is attached to the window. Passing in null will
+     * remove the consumer.
+     */
+    protected void setChildAttachedConsumer(@Nullable Consumer<View> childAttachedConsumer) {
+        mChildAttachedConsumer = childAttachedConsumer;
+    }
+
+    @Override
+    public void onChildAttachedToWindow(@NonNull View child) {
+        if (mChildAttachedConsumer != null) {
+            mChildAttachedConsumer.accept(child);
+        }
+        super.onChildAttachedToWindow(child);
+    }
+
     @Override
     public int getScrollBarTop() {
         return ActivityContext.lookupContext(getContext()).getAppsView().isSearchSupported()
diff --git a/src/com/android/launcher3/allapps/AlphabeticalAppsList.java b/src/com/android/launcher3/allapps/AlphabeticalAppsList.java
index 328516e..1782791 100644
--- a/src/com/android/launcher3/allapps/AlphabeticalAppsList.java
+++ b/src/com/android/launcher3/allapps/AlphabeticalAppsList.java
@@ -15,6 +15,10 @@
  */
 package com.android.launcher3.allapps;
 
+import static com.android.launcher3.allapps.SectionDecorationInfo.ROUND_BOTTOM_LEFT;
+import static com.android.launcher3.allapps.SectionDecorationInfo.ROUND_BOTTOM_RIGHT;
+import static com.android.launcher3.allapps.SectionDecorationInfo.ROUND_NOTHING;
+
 import android.content.Context;
 
 import androidx.annotation.Nullable;
@@ -318,6 +322,10 @@
                 case PrivateProfileManager.STATE_ENABLED:
                     // Add PS Apps only in Enabled State.
                     addAppsWithSections(mPrivateApps, position);
+                    if (mActivityContext.getAppsView() != null) {
+                        mActivityContext.getAppsView().getActiveRecyclerView()
+                                .scrollToBottomWithMotion();
+                    }
                     break;
             }
         }
@@ -325,8 +333,34 @@
 
     private void addAppsWithSections(List<AppInfo> appList, int startPosition) {
         String lastSectionName = null;
+        boolean hasPrivateApps = false;
+        if (mPrivateProviderManager != null) {
+            hasPrivateApps = appList.stream().
+                    allMatch(mPrivateProviderManager.getItemInfoMatcher());
+        }
+        int privateAppCount = 0;
+        int numberOfColumns = mActivityContext.getDeviceProfile().numShownAllAppsColumns;
+        int numberOfAppRows = (int) Math.ceil((double) appList.size() / numberOfColumns);
         for (AppInfo info : appList) {
-            mAdapterItems.add(AdapterItem.asApp(info));
+            // Apply decorator to private apps.
+            if (hasPrivateApps) {
+                int roundRegion = ROUND_NOTHING;
+                if ((privateAppCount / numberOfColumns) == numberOfAppRows - 1) {
+                    if ((privateAppCount % numberOfColumns) == 0) {
+                        // App is the first column
+                        roundRegion = ROUND_BOTTOM_LEFT;
+                    } else if ((privateAppCount % numberOfColumns) == numberOfColumns-1) {
+                        roundRegion = ROUND_BOTTOM_RIGHT;
+                    }
+                }
+                mAdapterItems.add(AdapterItem.asAppWithDecorationInfo(info,
+                        new SectionDecorationInfo(mActivityContext.getApplicationContext(),
+                                roundRegion,
+                                true /* decorateTogether */)));
+                privateAppCount += 1;
+            } else {
+                mAdapterItems.add(AdapterItem.asApp(info));
+            }
 
             String sectionName = info.sectionName;
             // Create a new section if the section names do not match
diff --git a/src/com/android/launcher3/allapps/BaseAllAppsAdapter.java b/src/com/android/launcher3/allapps/BaseAllAppsAdapter.java
index 5e26ea5..5eeb259 100644
--- a/src/com/android/launcher3/allapps/BaseAllAppsAdapter.java
+++ b/src/com/android/launcher3/allapps/BaseAllAppsAdapter.java
@@ -15,6 +15,12 @@
  */
 package com.android.launcher3.allapps;
 
+import static com.android.launcher3.allapps.SectionDecorationInfo.ROUND_BOTTOM_LEFT;
+import static com.android.launcher3.allapps.SectionDecorationInfo.ROUND_BOTTOM_RIGHT;
+import static com.android.launcher3.allapps.SectionDecorationInfo.ROUND_TOP_LEFT;
+import static com.android.launcher3.allapps.SectionDecorationInfo.ROUND_TOP_RIGHT;
+import static com.android.launcher3.allapps.UserProfileManager.STATE_DISABLED;
+
 import android.content.Context;
 import android.view.LayoutInflater;
 import android.view.View;
@@ -25,6 +31,7 @@
 import android.widget.RelativeLayout;
 import android.widget.TextView;
 
+import androidx.annotation.Nullable;
 import androidx.recyclerview.widget.RecyclerView;
 
 import com.android.launcher3.BubbleTextView;
@@ -92,7 +99,8 @@
         public int rowAppIndex;
         // The associated ItemInfoWithIcon for the item
         public AppInfo itemInfo = null;
-
+        // Private App Decorator
+        public SectionDecorationInfo decorationInfo = null;
         public AdapterItem(int viewType) {
             this.viewType = viewType;
         }
@@ -106,6 +114,13 @@
             return item;
         }
 
+        public static AdapterItem asAppWithDecorationInfo(AppInfo appInfo,
+                SectionDecorationInfo decorationInfo) {
+            AdapterItem item = asApp(appInfo);
+            item.decorationInfo = decorationInfo;
+            return item;
+        }
+
         protected boolean isCountedForAccessibility() {
             return viewType == VIEW_TYPE_ICON;
         }
@@ -125,9 +140,17 @@
             return itemInfo == null && other.itemInfo == null;
         }
 
-        /** Sets the alpha of the decorator for this item. Returns true if successful. */
-        public boolean setDecorationFillAlpha(int alpha) {
-            return false;
+        @Nullable
+        public SectionDecorationInfo getDecorationInfo() {
+            return decorationInfo;
+        }
+
+        /** Sets the alpha of the decorator for this item. */
+        protected void setDecorationFillAlpha(int alpha) {
+            if (decorationInfo == null || decorationInfo.getDecorationHandler() == null) {
+                return;
+            }
+            decorationInfo.getDecorationHandler().setFillAlpha(alpha);
         }
     }
 
@@ -249,6 +272,15 @@
                 assert mPrivateSpaceHeaderViewController != null;
                 assert psHeaderLayout != null;
                 mPrivateSpaceHeaderViewController.addPrivateSpaceHeaderViewElements(psHeaderLayout);
+                AdapterItem adapterItem = mApps.getAdapterItems().get(position);
+                int roundRegions = ROUND_TOP_LEFT | ROUND_TOP_RIGHT;
+                if (mPrivateSpaceHeaderViewController.getPrivateProfileManager().getCurrentState()
+                        == STATE_DISABLED) {
+                    roundRegions |= (ROUND_BOTTOM_LEFT | ROUND_BOTTOM_RIGHT);
+                }
+                adapterItem.decorationInfo =
+                        new SectionDecorationInfo(mActivityContext, roundRegions,
+                                false /* decorateTogether */);
                 break;
             case VIEW_TYPE_ALL_APPS_DIVIDER:
             case VIEW_TYPE_WORK_DISABLED_CARD:
diff --git a/src/com/android/launcher3/allapps/PrivateAppsSectionDecorator.java b/src/com/android/launcher3/allapps/PrivateAppsSectionDecorator.java
index f4ed754..8712b84 100644
--- a/src/com/android/launcher3/allapps/PrivateAppsSectionDecorator.java
+++ b/src/com/android/launcher3/allapps/PrivateAppsSectionDecorator.java
@@ -16,97 +16,55 @@
 
 package com.android.launcher3.allapps;
 
-import static com.android.launcher3.allapps.BaseAllAppsAdapter.VIEW_TYPE_ICON;
-import static com.android.launcher3.allapps.BaseAllAppsAdapter.VIEW_TYPE_PRIVATE_SPACE_HEADER;
-
-import android.content.Context;
 import android.graphics.Canvas;
-import android.graphics.Paint;
-import android.graphics.Path;
-import android.graphics.RectF;
 import android.view.View;
 
-import androidx.core.content.ContextCompat;
 import androidx.recyclerview.widget.RecyclerView;
 
-import com.android.launcher3.R;
-import com.android.launcher3.pm.UserCache;
-import com.android.launcher3.views.ActivityContext;
+import java.util.HashMap;
 
 /**
  * Decorator which changes the background color for Private Space Icon Rows in AllAppsContainer.
  */
 public class PrivateAppsSectionDecorator extends RecyclerView.ItemDecoration {
 
-    private final Path mTmpPath = new Path();
-    private final RectF mTmpRect = new RectF();
-    private final Context mContext;
+    private static final String PRIVATE_APP_SECTION = "private_apps";
     private final AlphabeticalAppsList<?> mAppsList;
-    private final UserCache mUserCache;
-    private final Paint mPaint;
-    private final int mCornerRadius;
 
-    public PrivateAppsSectionDecorator(Context context, AlphabeticalAppsList<?> appsList) {
-        mContext = context;
+    public PrivateAppsSectionDecorator(AlphabeticalAppsList<?> appsList) {
         mAppsList = appsList;
-        mUserCache = UserCache.getInstance(context);
-        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
-        mPaint.setColor(ContextCompat.getColor(context,
-                R.color.material_color_surface_container_high));
-        mCornerRadius = context.getResources().getDimensionPixelSize(
-                R.dimen.ps_container_corner_radius);
     }
 
     /** Decorates Private Space Header and Icon Rows to give the shape of a container. */
     @Override
     public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
-        mTmpPath.reset();
-        mTmpRect.setEmpty();
-        int numCol = ActivityContext.lookupContext(mContext).getDeviceProfile()
-                .numShownAllAppsColumns;
+        HashMap<String, SectionDecorationHandler.UnionDecorationHandler> deferredDecorations =
+                new HashMap<>();
         for (int i = 0; i < parent.getChildCount(); i++) {
             View view = parent.getChildAt(i);
             int position = parent.getChildAdapterPosition(view);
             BaseAllAppsAdapter.AdapterItem adapterItem = mAppsList.getAdapterItems().get(position);
-            // Rectangle that covers the bottom half of the PS Header View when Space is unlocked.
-            if (adapterItem.viewType == VIEW_TYPE_PRIVATE_SPACE_HEADER) {
-                // We flatten the bottom corners of the rectangle, so that it merges with
-                // the private space app row decorator.
-                mTmpRect.set(
-                        view.getLeft(),
-                        view.getTop() + (float) (view.getBottom() - view.getTop()) / 2,
-                        view.getRight(),
-                        view.getBottom());
-                mTmpPath.addRect(mTmpRect, Path.Direction.CW);
-                c.drawPath(mTmpPath, mPaint);
-            } else if (adapterItem.viewType == VIEW_TYPE_ICON
-                    && mUserCache.getUserInfo(adapterItem.itemInfo.user).isPrivate()
-                    // No decoration for any private space app icon other than those at first row.
-                    && adapterItem.rowAppIndex == 0) {
-                c.drawPath(getPrivateAppRowPath(parent, view, position, numCol), mPaint);
+            SectionDecorationInfo info = adapterItem.decorationInfo;
+            if (info == null) {
+                continue;
+            }
+            SectionDecorationHandler decorationHandler = info.getDecorationHandler();
+            if (info.shouldDecorateItemsTogether()) {
+                SectionDecorationHandler.UnionDecorationHandler unionHandler =
+                        deferredDecorations.getOrDefault(
+                                PRIVATE_APP_SECTION,
+                                new SectionDecorationHandler.UnionDecorationHandler(
+                                        decorationHandler, parent.getPaddingLeft(),
+                                        parent.getPaddingRight()));
+                unionHandler.addChild(decorationHandler, view, true /* applyBackground */);
+                deferredDecorations.put(PRIVATE_APP_SECTION, unionHandler);
+            } else {
+                decorationHandler.onFocusDraw(c, view);
             }
         }
-    }
-
-    /** Returns the path to be decorated for Private Space App Row */
-    private Path getPrivateAppRowPath(RecyclerView parent, View iconView, int adapterPosition,
-            int numCol) {
-        // We always decorate the entire app row here.
-        // As the iconView just represents the first icon of the row, we get the right margin of
-        // our decorator using the parent view.
-        mTmpRect.set(iconView.getLeft(),
-                iconView.getTop(),
-                parent.getRight() - parent.getPaddingRight(),
-                iconView.getBottom());
-        // Decorates last app row with rounded bottom corners.
-        if (adapterPosition + numCol >= mAppsList.getAdapterItems().size()) {
-            float[] mCornersBot = new float[]{0, 0, 0, 0, mCornerRadius, mCornerRadius,
-                    mCornerRadius, mCornerRadius};
-            mTmpPath.addRoundRect(mTmpRect, mCornersBot, Path.Direction.CW);
-        } else {
-            // Decorate other rows as a plain rectangle
-            mTmpPath.addRect(mTmpRect, Path.Direction.CW);
+        for (SectionDecorationHandler.UnionDecorationHandler decorationHandler
+                : deferredDecorations.values()) {
+            decorationHandler.onGroupDecorate(c);
         }
-        return mTmpPath;
     }
 }
diff --git a/src/com/android/launcher3/allapps/PrivateProfileManager.java b/src/com/android/launcher3/allapps/PrivateProfileManager.java
index 334d5c1..693681b 100644
--- a/src/com/android/launcher3/allapps/PrivateProfileManager.java
+++ b/src/com/android/launcher3/allapps/PrivateProfileManager.java
@@ -30,6 +30,7 @@
 
 import androidx.annotation.VisibleForTesting;
 
+import com.android.launcher3.Flags;
 import com.android.launcher3.logging.StatsLogManager;
 import com.android.launcher3.pm.UserCache;
 import com.android.launcher3.util.Preconditions;
@@ -47,6 +48,7 @@
     private static final String SAFETY_CENTER_INTENT = Intent.ACTION_SAFETY_CENTER;
     private static final String PS_SETTINGS_FRAGMENT_KEY = ":settings:fragment_args_key";
     private static final String PS_SETTINGS_FRAGMENT_VALUE = "AndroidPrivateSpace_personal";
+    private static final int ANIMATION_DURATION = 2000;
     private final ActivityAllAppsContainerView<?> mAllApps;
     private final Predicate<UserHandle> mPrivateProfileMatcher;
     private PrivateAppsSectionDecorator mPrivateAppsSectionDecorator;
@@ -125,7 +127,6 @@
             // Create a new decorator instance if not already available.
             if (mPrivateAppsSectionDecorator == null) {
                 mPrivateAppsSectionDecorator = new PrivateAppsSectionDecorator(
-                        mAllApps.mActivityContext,
                         mainAdapterHolder.mAppsList);
             }
             for (int i = 0; i < mainAdapterHolder.mRecyclerView.getItemDecorationCount(); i++) {
@@ -137,6 +138,13 @@
             }
             // Add Private Space Decorator to the Recycler view.
             mainAdapterHolder.mRecyclerView.addItemDecoration(mPrivateAppsSectionDecorator);
+            if (Flags.privateSpaceAnimation() && mAllApps.getActiveRecyclerView()
+                    == mainAdapterHolder.mRecyclerView) {
+                RecyclerViewAnimationController recyclerViewAnimationController =
+                        new RecyclerViewAnimationController(mAllApps);
+                recyclerViewAnimationController.animateToState(true /* expand */,
+                        ANIMATION_DURATION, () -> {});
+            }
         } else {
             // Remove Private Space Decorator from the Recycler view.
             if (mPrivateAppsSectionDecorator != null) {
diff --git a/src/com/android/launcher3/allapps/PrivateSpaceHeaderViewController.java b/src/com/android/launcher3/allapps/PrivateSpaceHeaderViewController.java
index e0ca947..568ce32 100644
--- a/src/com/android/launcher3/allapps/PrivateSpaceHeaderViewController.java
+++ b/src/com/android/launcher3/allapps/PrivateSpaceHeaderViewController.java
@@ -105,4 +105,8 @@
             transitionImage.setVisibility(View.GONE);
         }
     }
+
+    PrivateProfileManager getPrivateProfileManager() {
+        return mPrivateProfileManager;
+    }
 }
diff --git a/src/com/android/launcher3/allapps/RecyclerViewAnimationController.java b/src/com/android/launcher3/allapps/RecyclerViewAnimationController.java
new file mode 100644
index 0000000..6209393
--- /dev/null
+++ b/src/com/android/launcher3/allapps/RecyclerViewAnimationController.java
@@ -0,0 +1,309 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.launcher3.allapps;
+
+import static androidx.recyclerview.widget.RecyclerView.NO_POSITION;
+
+import static com.android.app.animation.Interpolators.DECELERATE_1_7;
+import static com.android.app.animation.Interpolators.INSTANT;
+import static com.android.app.animation.Interpolators.clampToProgress;
+import static com.android.launcher3.LauncherSettings.Favorites.ITEM_TYPE_APPLICATION;
+import static com.android.launcher3.anim.AnimatorListeners.forEndCallback;
+import static com.android.launcher3.anim.AnimatorListeners.forSuccessCallback;
+
+import android.animation.ObjectAnimator;
+import android.animation.TimeInterpolator;
+import android.graphics.drawable.Drawable;
+import android.util.FloatProperty;
+import android.util.Log;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.animation.Interpolator;
+
+import com.android.launcher3.BubbleTextView;
+import com.android.launcher3.Utilities;
+import com.android.launcher3.model.data.ItemInfo;
+
+import java.util.List;
+
+public class RecyclerViewAnimationController {
+
+    private static final String LOG_TAG = "AnimationCtrl";
+
+    /**
+     * These values represent points on the [0, 1] animation progress spectrum. They are used to
+     * animate items in the {@link SearchRecyclerView} and private space container in
+     * {@link AllAppsRecyclerView}.
+     */
+    protected static final float TOP_CONTENT_FADE_PROGRESS_START = 0.133f;
+    protected static final float CONTENT_FADE_PROGRESS_DURATION = 0.083f;
+    protected static final float TOP_BACKGROUND_FADE_PROGRESS_START = 0.633f;
+    protected static final float BACKGROUND_FADE_PROGRESS_DURATION = 0.15f;
+    // Progress before next item starts fading.
+    protected static final float CONTENT_STAGGER = 0.01f;
+
+    protected static final FloatProperty<RecyclerViewAnimationController> PROGRESS =
+            new FloatProperty<RecyclerViewAnimationController>("expansionProgress") {
+                @Override
+                public Float get(RecyclerViewAnimationController controller) {
+                    return controller.getAnimationProgress();
+                }
+
+                @Override
+                public void setValue(RecyclerViewAnimationController controller, float progress) {
+                    controller.setAnimationProgress(progress);
+                }
+            };
+
+    protected final ActivityAllAppsContainerView<?> mAllAppsContainerView;
+    protected ObjectAnimator mAnimator = null;
+    private float mAnimatorProgress = 1f;
+
+    public RecyclerViewAnimationController(ActivityAllAppsContainerView<?> allAppsContainerView) {
+        mAllAppsContainerView = allAppsContainerView;
+    }
+
+    /**
+     * Updates the children views of the current recyclerView based on the current animation
+     * progress.
+     *
+     * @return the total height of animating views (may exclude at most one row of app icons
+     * depending on which recyclerView is being acted upon).
+     */
+    protected int onProgressUpdated(float expansionProgress) {
+        int numItemsAnimated = 0;
+        int totalHeight = 0;
+        int appRowHeight = 0;
+        boolean appRowComplete = false;
+        Integer top = null;
+        AllAppsRecyclerView allAppsRecyclerView = getRecyclerView();
+
+        for (int i = 0; i < allAppsRecyclerView.getChildCount(); i++) {
+            View currentView = allAppsRecyclerView.getChildAt(i);
+            if (currentView == null) {
+                continue;
+            }
+            if (top == null) {
+                top = currentView.getTop();
+            }
+            int adapterPosition = allAppsRecyclerView.getChildAdapterPosition(currentView);
+            List<BaseAllAppsAdapter.AdapterItem> allAppsAdapters = allAppsRecyclerView.getApps()
+                    .getAdapterItems();
+            if (adapterPosition < 0 || adapterPosition >= allAppsAdapters.size()) {
+                continue;
+            }
+            BaseAllAppsAdapter.AdapterItem adapterItemAtPosition =
+                    allAppsAdapters.get(adapterPosition);
+            int spanIndex = getSpanIndex(allAppsRecyclerView, adapterPosition);
+            appRowComplete |= appRowHeight > 0 && spanIndex == 0;
+
+            float backgroundAlpha = 1f;
+            boolean hasDecorationInfo = adapterItemAtPosition.getDecorationInfo() != null;
+            boolean shouldAnimate = shouldAnimate(currentView, hasDecorationInfo, appRowComplete);
+
+            if (shouldAnimate) {
+                if (spanIndex > 0) {
+                    // Animate this item with the previous item on the same row.
+                    numItemsAnimated--;
+                }
+                // Adjust background (or decorator) alpha based on start progress and stagger.
+                backgroundAlpha = getAdjustedBackgroundAlpha(numItemsAnimated);
+            }
+
+            Drawable background = currentView.getBackground();
+            if (background != null && currentView instanceof ViewGroup currentViewGroup) {
+                currentView.setAlpha(1f);
+                // Apply content alpha to each child, since the view needs to be fully opaque for
+                // the background to show properly.
+                for (int j = 0; j < currentViewGroup.getChildCount(); j++) {
+                    setViewAdjustedContentAlpha(currentViewGroup.getChildAt(j), numItemsAnimated,
+                            shouldAnimate);
+                }
+
+                // Apply background alpha to the background drawable directly.
+                background.setAlpha((int) (255 * backgroundAlpha));
+            } else {
+                // Adjust content alpha based on start progress and stagger.
+                setViewAdjustedContentAlpha(currentView, numItemsAnimated, shouldAnimate);
+
+                // Apply background alpha to decorator if possible.
+                setAdjustedAdapterItemDecorationBackgroundAlpha(
+                        allAppsRecyclerView.getApps().getAdapterItems().get(adapterPosition),
+                        numItemsAnimated);
+
+                // Apply background alpha to view's background (e.g. for Search Edu card).
+                if (background != null) {
+                    background.setAlpha((int) (255 * backgroundAlpha));
+                }
+            }
+
+            float scaleY = 1;
+            if (shouldAnimate) {
+                scaleY = 1 - getAnimationProgress();
+                // Update number of search results that has been animated.
+                numItemsAnimated++;
+            }
+            int scaledHeight = (int) (currentView.getHeight() * scaleY);
+            currentView.setScaleY(scaleY);
+
+            // For rows with multiple elements, only count the height once and translate elements to
+            // the same y position.
+            int y = top + totalHeight;
+            if (spanIndex > 0) {
+                // Continuation of an existing row; move this item into the row.
+                y -= scaledHeight;
+            } else {
+                // Start of a new row contributes to total height.
+                totalHeight += scaledHeight;
+                if (!shouldAnimate) {
+                    appRowHeight = scaledHeight;
+                }
+            }
+            currentView.setY(y);
+        }
+        return totalHeight - appRowHeight;
+    }
+
+    protected void animateToState(boolean expand, long duration, Runnable onEndRunnable) {
+        float targetProgress = expand ? 0 : 1;
+        if (mAnimator != null) {
+            mAnimator.cancel();
+        }
+        mAnimator = ObjectAnimator.ofFloat(this, PROGRESS, targetProgress);
+
+        TimeInterpolator timeInterpolator = getInterpolator();
+        if (timeInterpolator == INSTANT) {
+            duration = 0;
+        }
+
+        mAnimator.addListener(forEndCallback(() -> mAnimator = null));
+        mAnimator.setDuration(duration).setInterpolator(timeInterpolator);
+        mAnimator.addListener(forSuccessCallback(onEndRunnable));
+        mAnimator.start();
+        getRecyclerView().setChildAttachedConsumer(this::onChildAttached);
+    }
+
+    /** Called just before a child is attached to the RecyclerView. */
+    private void onChildAttached(View child) {
+        // Avoid allocating hardware layers for alpha changes.
+        child.forceHasOverlappingRendering(false);
+        child.setPivotY(0);
+        if (getAnimationProgress() > 0 && getAnimationProgress() < 1) {
+            // Before the child is rendered, apply the animation including it to avoid flicker.
+            onProgressUpdated(getAnimationProgress());
+        } else {
+            // Apply default states without processing the full layout.
+            child.setAlpha(1);
+            child.setScaleY(1);
+            child.setTranslationY(0);
+            int adapterPosition = getRecyclerView().getChildAdapterPosition(child);
+            List<BaseAllAppsAdapter.AdapterItem> allAppsAdapters =
+                    getRecyclerView().getApps().getAdapterItems();
+            if (adapterPosition >= 0 && adapterPosition < allAppsAdapters.size()) {
+                allAppsAdapters.get(adapterPosition).setDecorationFillAlpha(255);
+            }
+            if (child instanceof ViewGroup childGroup) {
+                for (int i = 0; i < childGroup.getChildCount(); i++) {
+                    childGroup.getChildAt(i).setAlpha(1f);
+                }
+            }
+            if (child.getBackground() != null) {
+                child.getBackground().setAlpha(255);
+            }
+        }
+    }
+
+    /** @return the column that the view at this position is found (0 assumed if indeterminate). */
+    protected int getSpanIndex(AllAppsRecyclerView appsRecyclerView, int adapterPosition) {
+        if (adapterPosition == NO_POSITION) {
+            Log.w(LOG_TAG, "Can't determine span index - child not found in adapter");
+            return 0;
+        }
+        if (!(appsRecyclerView.getAdapter() instanceof AllAppsGridAdapter<?>)) {
+            Log.e(LOG_TAG, "Search RV doesn't have an AllAppsGridAdapter?");
+            // This case shouldn't happen, but for debug devices we will continue to create a more
+            // visible crash.
+            if (!Utilities.IS_DEBUG_DEVICE) {
+                return 0;
+            }
+        }
+        AllAppsGridAdapter<?> adapter = (AllAppsGridAdapter<?>) appsRecyclerView.getAdapter();
+        return adapter.getSpanIndex(adapterPosition);
+    }
+
+    protected TimeInterpolator getInterpolator() {
+        return DECELERATE_1_7;
+    }
+
+    protected AllAppsRecyclerView getRecyclerView() {
+        return mAllAppsContainerView.mAH.get(ActivityAllAppsContainerView.AdapterHolder.MAIN)
+                .mRecyclerView;
+    }
+
+    /** Returns true if a transition animation is currently in progress. */
+    protected boolean isRunning() {
+        return mAnimator != null;
+    }
+
+    /** Should only animate if the view is an app icon and if it has a decoration info. */
+    protected boolean shouldAnimate(View view, boolean hasDecorationInfo,
+            boolean firstAppRowComplete) {
+        return isAppIcon(view) && hasDecorationInfo;
+    }
+
+    private float getAdjustedContentAlpha(int itemsAnimated) {
+        float startContentFadeProgress = Math.max(0,
+                TOP_CONTENT_FADE_PROGRESS_START - CONTENT_STAGGER * itemsAnimated);
+        float endContentFadeProgress = Math.min(1,
+                startContentFadeProgress + CONTENT_FADE_PROGRESS_DURATION);
+        return 1 - clampToProgress(mAnimatorProgress,
+                startContentFadeProgress, endContentFadeProgress);
+    }
+
+    private float getAdjustedBackgroundAlpha(int itemsAnimated) {
+        float startBackgroundFadeProgress = Math.max(0,
+                TOP_BACKGROUND_FADE_PROGRESS_START - CONTENT_STAGGER * itemsAnimated);
+        float endBackgroundFadeProgress = Math.min(1,
+                startBackgroundFadeProgress + BACKGROUND_FADE_PROGRESS_DURATION);
+        return 1 - clampToProgress(mAnimatorProgress,
+                startBackgroundFadeProgress, endBackgroundFadeProgress);
+    }
+
+    private void setViewAdjustedContentAlpha(View view, int numberOfItemsAnimated,
+            boolean shouldAnimate) {
+        view.setAlpha(shouldAnimate ? getAdjustedContentAlpha(numberOfItemsAnimated) : 1f);
+    }
+
+    private void setAdjustedAdapterItemDecorationBackgroundAlpha(
+            BaseAllAppsAdapter.AdapterItem adapterItem, int numberOfItemsAnimated) {
+        adapterItem.setDecorationFillAlpha((int)
+                (255 * getAdjustedBackgroundAlpha(numberOfItemsAnimated)));
+    }
+
+    private float getAnimationProgress() {
+        return mAnimatorProgress;
+    }
+
+    private void setAnimationProgress(float expansionProgress) {
+        mAnimatorProgress = expansionProgress;
+        onProgressUpdated(expansionProgress);
+    }
+
+    protected boolean isAppIcon(View item) {
+        return item instanceof BubbleTextView && item.getTag() instanceof ItemInfo
+                && ((ItemInfo) item.getTag()).itemType == ITEM_TYPE_APPLICATION;
+    }
+}
diff --git a/src/com/android/launcher3/allapps/SearchRecyclerView.java b/src/com/android/launcher3/allapps/SearchRecyclerView.java
index 9d1dfc0..68f9f11 100644
--- a/src/com/android/launcher3/allapps/SearchRecyclerView.java
+++ b/src/com/android/launcher3/allapps/SearchRecyclerView.java
@@ -27,8 +27,6 @@
 /** A RecyclerView for AllApps Search results. */
 public class SearchRecyclerView extends AllAppsRecyclerView {
 
-    private Consumer<View> mChildAttachedConsumer;
-
     public SearchRecyclerView(Context context) {
         this(context, null);
     }
@@ -46,11 +44,6 @@
         super(context, attrs, defStyleAttr, defStyleRes);
     }
 
-    /** This will be called just before a new child is attached to the window. */
-    public void setChildAttachedConsumer(Consumer<View> childAttachedConsumer) {
-        mChildAttachedConsumer = childAttachedConsumer;
-    }
-
     @Override
     protected void updatePoolSize() {
         RecycledViewPool pool = getRecycledViewPool();
@@ -67,12 +60,4 @@
     public RecyclerViewFastScroller getScrollbar() {
         return null;
     }
-
-    @Override
-    public void onChildAttachedToWindow(@NonNull View child) {
-        if (mChildAttachedConsumer != null) {
-            mChildAttachedConsumer.accept(child);
-        }
-        super.onChildAttachedToWindow(child);
-    }
 }
diff --git a/src/com/android/launcher3/allapps/SearchTransitionController.java b/src/com/android/launcher3/allapps/SearchTransitionController.java
index eb1bc0a..d5c3b57 100644
--- a/src/com/android/launcher3/allapps/SearchTransitionController.java
+++ b/src/com/android/launcher3/allapps/SearchTransitionController.java
@@ -18,34 +18,21 @@
 
 import static android.view.View.VISIBLE;
 
-import static androidx.recyclerview.widget.RecyclerView.NO_POSITION;
-
 import static com.android.app.animation.Interpolators.DECELERATE_1_7;
 import static com.android.app.animation.Interpolators.INSTANT;
 import static com.android.app.animation.Interpolators.clampToProgress;
-import static com.android.launcher3.LauncherSettings.Favorites.ITEM_TYPE_APPLICATION;
 import static com.android.launcher3.anim.AnimatorListeners.forEndCallback;
 import static com.android.launcher3.anim.AnimatorListeners.forSuccessCallback;
 
 import android.animation.ObjectAnimator;
 import android.animation.TimeInterpolator;
-import android.graphics.drawable.Drawable;
-import android.util.FloatProperty;
-import android.util.Log;
 import android.view.View;
-import android.view.ViewGroup;
 import android.view.animation.Interpolator;
 
-import com.android.launcher3.BubbleTextView;
 import com.android.launcher3.R;
-import com.android.launcher3.Utilities;
-import com.android.launcher3.config.FeatureFlags;
-import com.android.launcher3.model.data.ItemInfo;
 
 /** Coordinates the transition between Search and A-Z in All Apps. */
-public class SearchTransitionController {
-
-    private static final String LOG_TAG = "SearchTransitionCtrl";
+public class SearchTransitionController extends RecyclerViewAnimationController {
 
     // Interpolator when the user taps the QSB while already in All Apps.
     private static final Interpolator INTERPOLATOR_WITHIN_ALL_APPS = DECELERATE_1_7;
@@ -53,42 +40,10 @@
     // happening simultaneously.
     private static final Interpolator INTERPOLATOR_TRANSITIONING_TO_ALL_APPS = INSTANT;
 
-    /**
-     * These values represent points on the [0, 1] animation progress spectrum. They are used to
-     * animate items in the {@link SearchRecyclerView}.
-     */
-    private static final float TOP_CONTENT_FADE_PROGRESS_START = 0.133f;
-    private static final float CONTENT_FADE_PROGRESS_DURATION = 0.083f;
-    private static final float TOP_BACKGROUND_FADE_PROGRESS_START = 0.633f;
-    private static final float BACKGROUND_FADE_PROGRESS_DURATION = 0.15f;
-    private static final float CONTENT_STAGGER = 0.01f;  // Progress before next item starts fading.
-
-    private static final FloatProperty<SearchTransitionController> SEARCH_TO_AZ_PROGRESS =
-            new FloatProperty<SearchTransitionController>("searchToAzProgress") {
-                @Override
-                public Float get(SearchTransitionController controller) {
-                    return controller.getSearchToAzProgress();
-                }
-
-                @Override
-                public void setValue(SearchTransitionController controller, float progress) {
-                    controller.setSearchToAzProgress(progress);
-                }
-            };
-
-    private final ActivityAllAppsContainerView<?> mAllAppsContainerView;
-
-    private ObjectAnimator mSearchToAzAnimator = null;
-    private float mSearchToAzProgress = 1f;
     private boolean mSkipNextAnimationWithinAllApps;
 
     public SearchTransitionController(ActivityAllAppsContainerView<?> allAppsContainerView) {
-        mAllAppsContainerView = allAppsContainerView;
-    }
-
-    /** Returns true if a transition animation is currently in progress. */
-    public boolean isRunning() {
-        return mSearchToAzAnimator != null;
+        super(allAppsContainerView);
     }
 
     /**
@@ -101,51 +56,31 @@
      * @param onEndRunnable will be called when the animation finishes, unless another animation is
      *                      scheduled in the meantime
      */
-    public void animateToSearchState(boolean goingToSearch, long duration, Runnable onEndRunnable) {
-        float targetProgress = goingToSearch ? 0 : 1;
-
-        if (mSearchToAzAnimator != null) {
-            mSearchToAzAnimator.cancel();
-        }
-
-        mSearchToAzAnimator = ObjectAnimator.ofFloat(this, SEARCH_TO_AZ_PROGRESS, targetProgress);
-        boolean inAllApps = mAllAppsContainerView.isInAllApps();
-        TimeInterpolator timeInterpolator =
-                inAllApps ? INTERPOLATOR_WITHIN_ALL_APPS : INTERPOLATOR_TRANSITIONING_TO_ALL_APPS;
-        if (mSkipNextAnimationWithinAllApps) {
-            timeInterpolator = INSTANT;
-            mSkipNextAnimationWithinAllApps = false;
-        }
-        if (timeInterpolator == INSTANT) {
-            duration = 0;  // Don't want to animate when coming from QSB.
-        }
-        mSearchToAzAnimator.setDuration(duration).setInterpolator(timeInterpolator);
-        mSearchToAzAnimator.addListener(forEndCallback(() -> mSearchToAzAnimator = null));
+    @Override
+    protected void animateToState(boolean goingToSearch, long duration, Runnable onEndRunnable) {
+        super.animateToState(goingToSearch, duration, onEndRunnable);
         if (!goingToSearch) {
-            mSearchToAzAnimator.addListener(forSuccessCallback(() -> {
+            mAnimator.addListener(forSuccessCallback(() -> {
                 mAllAppsContainerView.getFloatingHeaderView().setFloatingRowsCollapsed(false);
                 mAllAppsContainerView.getFloatingHeaderView().reset(false /* animate */);
                 mAllAppsContainerView.getAppsRecyclerViewContainer().setTranslationY(0);
             }));
         }
-        mSearchToAzAnimator.addListener(forSuccessCallback(onEndRunnable));
-
         mAllAppsContainerView.getFloatingHeaderView().setFloatingRowsCollapsed(true);
         mAllAppsContainerView.getFloatingHeaderView().setVisibility(VISIBLE);
         mAllAppsContainerView.getFloatingHeaderView().maybeSetTabVisibility(VISIBLE);
         mAllAppsContainerView.getAppsRecyclerViewContainer().setVisibility(VISIBLE);
-        getSearchRecyclerView().setVisibility(VISIBLE);
-        getSearchRecyclerView().setChildAttachedConsumer(this::onSearchChildAttached);
-        mSearchToAzAnimator.start();
+        getRecyclerView().setVisibility(VISIBLE);
     }
 
-    private SearchRecyclerView getSearchRecyclerView() {
+    @Override
+    protected SearchRecyclerView getRecyclerView() {
         return mAllAppsContainerView.getSearchRecyclerView();
     }
 
-    private void setSearchToAzProgress(float searchToAzProgress) {
-        mSearchToAzProgress = searchToAzProgress;
-        int searchHeight = updateSearchRecyclerViewProgress();
+    @Override
+    protected int onProgressUpdated(float searchToAzProgress) {
+        int searchHeight = super.onProgressUpdated(searchToAzProgress);
 
         FloatingHeaderView headerView = mAllAppsContainerView.getFloatingHeaderView();
 
@@ -171,179 +106,27 @@
         appsContainer.setTranslationY(appsTranslationY);
         // Fade apps out with tabs (in 20% of the total animation).
         appsContainer.setAlpha(clampToProgress(searchToAzProgress, 0.8f, 1f));
+        return searchHeight;
     }
 
     /**
-     * Updates the children views of SearchRecyclerView based on the current animation progress.
-     *
-     * @return the total height of animating views (excluding at most one row of app icons).
+     * Should only animate if the view is not an app icon or if the app row is complete.
      */
-    private int updateSearchRecyclerViewProgress() {
-        int numSearchResultsAnimated = 0;
-        int totalHeight = 0;
-        int appRowHeight = 0;
-        boolean appRowComplete = false;
-        Integer top = null;
-        SearchRecyclerView searchRecyclerView = getSearchRecyclerView();
-
-        for (int i = 0; i < searchRecyclerView.getChildCount(); i++) {
-            View searchResultView = searchRecyclerView.getChildAt(i);
-            if (searchResultView == null) {
-                continue;
-            }
-
-            if (top == null) {
-                top = searchResultView.getTop();
-            }
-
-            int adapterPosition = searchRecyclerView.getChildAdapterPosition(searchResultView);
-            int spanIndex = getSpanIndex(searchRecyclerView, adapterPosition);
-            appRowComplete |= appRowHeight > 0 && spanIndex == 0;
-            // We don't animate the first (currently only) app row we see, as that is assumed to be
-            // predicted/prefix-matched apps.
-            boolean shouldAnimate = !isAppIcon(searchResultView) || appRowComplete;
-
-            float contentAlpha = 1f;
-            float backgroundAlpha = 1f;
-            if (shouldAnimate) {
-                if (spanIndex > 0) {
-                    // Animate this item with the previous item on the same row.
-                    numSearchResultsAnimated--;
-                }
-
-                // Adjust content alpha based on start progress and stagger.
-                float startContentFadeProgress = Math.max(0,
-                        TOP_CONTENT_FADE_PROGRESS_START
-                                - CONTENT_STAGGER * numSearchResultsAnimated);
-                float endContentFadeProgress = Math.min(1,
-                        startContentFadeProgress + CONTENT_FADE_PROGRESS_DURATION);
-                contentAlpha = 1 - clampToProgress(mSearchToAzProgress,
-                        startContentFadeProgress, endContentFadeProgress);
-
-                // Adjust background (or decorator) alpha based on start progress and stagger.
-                float startBackgroundFadeProgress = Math.max(0,
-                        TOP_BACKGROUND_FADE_PROGRESS_START
-                                - CONTENT_STAGGER * numSearchResultsAnimated);
-                float endBackgroundFadeProgress = Math.min(1,
-                        startBackgroundFadeProgress + BACKGROUND_FADE_PROGRESS_DURATION);
-                backgroundAlpha = 1 - clampToProgress(mSearchToAzProgress,
-                        startBackgroundFadeProgress, endBackgroundFadeProgress);
-
-                numSearchResultsAnimated++;
-            }
-
-            Drawable background = searchResultView.getBackground();
-            if (background != null
-                    && searchResultView instanceof ViewGroup
-                    && FeatureFlags.ENABLE_SEARCH_RESULT_BACKGROUND_DRAWABLES.get()) {
-                searchResultView.setAlpha(1f);
-
-                // Apply content alpha to each child, since the view needs to be fully opaque for
-                // the background to show properly.
-                ViewGroup searchResultViewGroup = (ViewGroup) searchResultView;
-                for (int j = 0; j < searchResultViewGroup.getChildCount(); j++) {
-                    searchResultViewGroup.getChildAt(j).setAlpha(contentAlpha);
-                }
-
-                // Apply background alpha to the background drawable directly.
-                background.setAlpha((int) (255 * backgroundAlpha));
-            } else {
-                searchResultView.setAlpha(contentAlpha);
-
-                // Apply background alpha to decorator if possible.
-                if (adapterPosition != NO_POSITION) {
-                    searchRecyclerView.getApps().getAdapterItems().get(adapterPosition)
-                            .setDecorationFillAlpha((int) (255 * backgroundAlpha));
-                }
-
-                // Apply background alpha to view's background (e.g. for Search Edu card).
-                if (background != null) {
-                    background.setAlpha((int) (255 * backgroundAlpha));
-                }
-            }
-
-            float scaleY = 1;
-            if (shouldAnimate) {
-                scaleY = 1 - mSearchToAzProgress;
-            }
-            int scaledHeight = (int) (searchResultView.getHeight() * scaleY);
-            searchResultView.setScaleY(scaleY);
-
-            // For rows with multiple elements, only count the height once and translate elements to
-            // the same y position.
-            int y = top + totalHeight;
-            if (spanIndex > 0) {
-                // Continuation of an existing row; move this item into the row.
-                y -= scaledHeight;
-            } else {
-                // Start of a new row contributes to total height.
-                totalHeight += scaledHeight;
-                if (!shouldAnimate) {
-                    appRowHeight = scaledHeight;
-                }
-            }
-            searchResultView.setY(y);
-        }
-
-        return totalHeight - appRowHeight;
+    @Override
+    protected boolean shouldAnimate(View view, boolean hasDecorationInfo, boolean appRowComplete) {
+        return !isAppIcon(view) || appRowComplete;
     }
 
-    /** @return the column that the view at this position is found (0 assumed if indeterminate). */
-    private int getSpanIndex(SearchRecyclerView searchRecyclerView, int adapterPosition) {
-        if (adapterPosition == NO_POSITION) {
-            Log.w(LOG_TAG, "Can't determine span index - child not found in adapter");
-            return 0;
+    @Override
+    protected TimeInterpolator getInterpolator() {
+        TimeInterpolator timeInterpolator =
+                mAllAppsContainerView.isInAllApps()
+                        ? INTERPOLATOR_WITHIN_ALL_APPS : INTERPOLATOR_TRANSITIONING_TO_ALL_APPS;
+        if (mSkipNextAnimationWithinAllApps) {
+            timeInterpolator = INSTANT;
+            mSkipNextAnimationWithinAllApps = false;
         }
-        if (!(searchRecyclerView.getAdapter() instanceof AllAppsGridAdapter<?>)) {
-            Log.e(LOG_TAG, "Search RV doesn't have an AllAppsGridAdapter?");
-            // This case shouldn't happen, but for debug devices we will continue to create a more
-            // visible crash.
-            if (!Utilities.IS_DEBUG_DEVICE) {
-                return 0;
-            }
-        }
-        AllAppsGridAdapter<?> adapter = (AllAppsGridAdapter<?>) searchRecyclerView.getAdapter();
-        return adapter.getSpanIndex(adapterPosition);
-    }
-
-    private boolean isAppIcon(View item) {
-        return item instanceof BubbleTextView && item.getTag() instanceof ItemInfo
-                && ((ItemInfo) item.getTag()).itemType == ITEM_TYPE_APPLICATION;
-    }
-
-    /** Called just before a child is attached to the SearchRecyclerView. */
-    private void onSearchChildAttached(View child) {
-        // Avoid allocating hardware layers for alpha changes.
-        child.forceHasOverlappingRendering(false);
-        child.setPivotY(0);
-        if (mSearchToAzProgress > 0) {
-            // Before the child is rendered, apply the animation including it to avoid flicker.
-            updateSearchRecyclerViewProgress();
-        } else {
-            // Apply default states without processing the full layout.
-            child.setAlpha(1);
-            child.setScaleY(1);
-            child.setTranslationY(0);
-            int adapterPosition = getSearchRecyclerView().getChildAdapterPosition(child);
-            if (adapterPosition != NO_POSITION) {
-                getSearchRecyclerView().getApps().getAdapterItems().get(adapterPosition)
-                        .setDecorationFillAlpha(255);
-            }
-            if (child instanceof ViewGroup
-                    && FeatureFlags.ENABLE_SEARCH_RESULT_BACKGROUND_DRAWABLES.get()) {
-                ViewGroup childGroup = (ViewGroup) child;
-                for (int i = 0; i < childGroup.getChildCount(); i++) {
-                    childGroup.getChildAt(i).setAlpha(1f);
-                }
-            }
-            if (child.getBackground() != null) {
-                child.getBackground().setAlpha(255);
-            }
-        }
-    }
-
-    private float getSearchToAzProgress() {
-        return mSearchToAzProgress;
+        return timeInterpolator;
     }
 
     /**
diff --git a/src/com/android/launcher3/allapps/SectionDecorationHandler.java b/src/com/android/launcher3/allapps/SectionDecorationHandler.java
new file mode 100644
index 0000000..f79b82c
--- /dev/null
+++ b/src/com/android/launcher3/allapps/SectionDecorationHandler.java
@@ -0,0 +1,206 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.launcher3.allapps;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.RectF;
+import android.graphics.drawable.GradientDrawable;
+import android.graphics.drawable.InsetDrawable;
+import android.view.View;
+
+import androidx.annotation.Nullable;
+import androidx.core.content.ContextCompat;
+
+import com.android.launcher3.R;
+import com.android.launcher3.util.Themes;
+
+public class SectionDecorationHandler {
+
+    protected final Path mTmpPath = new Path();
+    protected final RectF mTmpRect = new RectF();
+
+    protected final int mCornerGroupRadius;
+    protected final int mCornerResultRadius;
+    protected final RectF mBounds = new RectF();
+    protected final Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+
+    protected final int mFocusAlpha = 255; // main focused item alpha
+    protected int mFillColor; // grouping color
+    protected int mFocusColor; // main focused item color
+    protected float mFillSpacing;
+    protected int mInlineRadius;
+    protected Context mContext;
+    protected float[] mCorners;
+    protected int mFillAlpha;
+    protected boolean mIsTopLeftRound;
+    protected boolean mIsTopRightRound;
+    protected boolean mIsBottomLeftRound;
+    protected boolean mIsBottomRightRound;
+    protected boolean mIsBottomRound;
+    protected boolean mIsTopRound;
+
+    public SectionDecorationHandler(Context context, int fillAlpha, boolean isTopLeftRound,
+            boolean isTopRightRound, boolean isBottomLeftRound,
+            boolean isBottomRightRound) {
+
+        mContext = context;
+        mFillAlpha = fillAlpha;
+        mFocusColor = ContextCompat.getColor(context,
+                R.color.material_color_surface_bright); // UX recommended
+        mFillColor = ContextCompat.getColor(context,
+                R.color.material_color_surface_container_high); // UX recommended
+
+        mIsTopLeftRound = isTopLeftRound;
+        mIsTopRightRound = isTopRightRound;
+        mIsBottomLeftRound = isBottomLeftRound;
+        mIsBottomRightRound = isBottomRightRound;
+        mIsBottomRound = mIsBottomLeftRound && mIsBottomRightRound;
+        mIsTopRound = mIsTopLeftRound && mIsTopRightRound;
+
+        mCornerGroupRadius = context.getResources().getDimensionPixelSize(
+                R.dimen.all_apps_recycler_view_decorator_group_radius);
+        mCornerResultRadius = context.getResources().getDimensionPixelSize(
+                R.dimen.all_apps_recycler_view_decorator_result_radius);
+
+        mInlineRadius = 0;
+        mFillSpacing = 0;
+        initCorners();
+    }
+
+    protected void initCorners() {
+        mCorners = new float[]{
+                mIsTopLeftRound ? mCornerGroupRadius : 0,
+                mIsTopLeftRound ? mCornerGroupRadius : 0, // Top left radius in px
+                mIsTopRightRound ? mCornerGroupRadius : 0,
+                mIsTopRightRound ? mCornerGroupRadius : 0, // Top right radius in px
+                mIsBottomRightRound ? mCornerGroupRadius : 0,
+                mIsBottomRightRound ? mCornerGroupRadius : 0, // Bottom right
+                mIsBottomLeftRound ? mCornerGroupRadius : 0,
+                mIsBottomLeftRound ? mCornerGroupRadius : 0 // Bottom left
+        };
+    }
+
+    protected void setFillAlpha(int fillAlpha) {
+        mFillAlpha = fillAlpha;
+        mPaint.setAlpha(mFillAlpha);
+    }
+
+    protected void onFocusDraw(Canvas canvas, @Nullable View view) {
+        if (view == null) {
+            return;
+        }
+        mPaint.setColor(mFillColor);
+        mPaint.setAlpha(mFillAlpha);
+        int scaledHeight = (int) (view.getHeight() * view.getScaleY());
+        mBounds.set(view.getLeft(), view.getY(), view.getRight(), view.getY() + scaledHeight);
+        onDraw(canvas);
+    }
+
+    protected void onDraw(Canvas canvas) {
+        mTmpPath.reset();
+        mTmpRect.set(mBounds.left + mFillSpacing,
+                mBounds.top + mFillSpacing,
+                mBounds.right - mFillSpacing,
+                mBounds.bottom - mFillSpacing);
+        mTmpPath.addRoundRect(mTmpRect, mCorners, Path.Direction.CW);
+        canvas.drawPath(mTmpPath, mPaint);
+    }
+
+    /** Sets the right background drawable to the view based on the give decoration info. */
+    public void applyBackground(View view, Context context,
+           @Nullable SectionDecorationInfo decorationInfo, boolean isHighlighted) {
+        int inset = context.getResources().getDimensionPixelSize(
+                R.dimen.all_apps_recycler_view_decorator_padding);
+        float radiusBottom = (decorationInfo == null || decorationInfo.isBottomRound()) ?
+                mCornerGroupRadius : mCornerResultRadius;
+        float radiusTop =
+                (decorationInfo == null || decorationInfo.isTopRound()) ?
+                        mCornerGroupRadius : mCornerResultRadius;
+        int color = isHighlighted ? mFocusColor : mFillColor;
+
+        GradientDrawable shape = new GradientDrawable();
+        shape.setShape(GradientDrawable.RECTANGLE);
+        shape.setCornerRadii(new float[] {
+                radiusTop, radiusTop, // top-left
+                radiusTop, radiusTop, // top-right
+                radiusBottom, radiusBottom, // bottom-right
+                radiusBottom, radiusBottom // bottom-left
+        });
+        shape.setColor(color);
+
+        // Setting the background resets the padding, so we cache it and reset it afterwards.
+        int paddingLeft = view.getPaddingLeft();
+        int paddingTop = view.getPaddingTop();
+        int paddingRight = view.getPaddingRight();
+        int paddingBottom = view.getPaddingBottom();
+
+        view.setBackground(new InsetDrawable(shape, inset));
+
+        view.setPadding(paddingLeft, paddingTop, paddingRight, paddingBottom);
+    }
+
+    /**
+     * Section decorator that combines views and draws a single block decoration
+     */
+    public static class UnionDecorationHandler extends SectionDecorationHandler {
+
+        private final int mPaddingLeft;
+        private final int mPaddingRight;
+
+        public UnionDecorationHandler(
+                SectionDecorationHandler decorationHandler,
+                int paddingLeft, int paddingRight) {
+            super(decorationHandler.mContext, decorationHandler.mFillAlpha,
+                    decorationHandler.mIsTopLeftRound, decorationHandler.mIsTopRightRound,
+                    decorationHandler.mIsBottomLeftRound, decorationHandler.mIsBottomRightRound);
+            mPaddingLeft = paddingLeft;
+            mPaddingRight = paddingRight;
+        }
+
+        /**
+         * Expands decoration bounds to include child {@link PrivateAppsSectionDecorator}
+         */
+        public void addChild(SectionDecorationHandler child, View view, boolean applyBackground) {
+            int scaledHeight = (int) (view.getHeight() * view.getScaleY());
+            mBounds.union(view.getLeft(), view.getY(),
+                    view.getRight(), view.getY() + scaledHeight);
+            if (applyBackground) {
+                applyBackground(view, mContext, null, false);
+            }
+            mIsBottomRound |= child.mIsBottomRound;
+            mIsBottomLeftRound |= child.mIsBottomLeftRound;
+            mIsBottomRightRound |= child.mIsBottomRightRound;
+            mIsTopRound |= child.mIsTopRound;
+            mIsTopLeftRound |= child.mIsTopLeftRound;
+            mIsTopRightRound |= child.mIsTopRightRound;
+        }
+
+        /**
+         * Draws group decoration to canvas
+         */
+        public void onGroupDecorate(Canvas canvas) {
+            initCorners();
+            mBounds.left = mPaddingLeft;
+            mBounds.right = canvas.getWidth() - mPaddingRight;
+            mPaint.setColor(mFillColor);
+            mPaint.setAlpha(mFillAlpha);
+            onDraw(canvas);
+        }
+    }
+}
diff --git a/src/com/android/launcher3/allapps/SectionDecorationInfo.java b/src/com/android/launcher3/allapps/SectionDecorationInfo.java
new file mode 100644
index 0000000..1fed2b6
--- /dev/null
+++ b/src/com/android/launcher3/allapps/SectionDecorationInfo.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.launcher3.allapps;
+
+import android.content.Context;
+import android.os.Bundle;
+
+import androidx.annotation.NonNull;
+
+public class SectionDecorationInfo {
+
+    public static final int ROUND_NOTHING = 1 << 1;
+    public static final int ROUND_TOP_LEFT = 1 << 2;
+    public static final int ROUND_TOP_RIGHT = 1 << 3;
+    public static final int ROUND_BOTTOM_LEFT = 1 << 4;
+    public static final int ROUND_BOTTOM_RIGHT = 1 << 5;
+    public static final int DECORATOR_ALPHA = 255;
+
+    protected boolean mShouldDecorateItemsTogether;
+    private SectionDecorationHandler mDecorationHandler;
+    protected boolean mIsTopRound;
+    protected boolean mIsBottomRound;
+
+    public SectionDecorationInfo(Context context, int roundRegions, boolean decorateTogether) {
+        mDecorationHandler =
+                new SectionDecorationHandler(context, DECORATOR_ALPHA,
+                        isFlagEnabled(roundRegions, ROUND_TOP_LEFT),
+                        isFlagEnabled(roundRegions, ROUND_TOP_RIGHT),
+                        isFlagEnabled(roundRegions, ROUND_BOTTOM_LEFT),
+                        isFlagEnabled(roundRegions, ROUND_BOTTOM_RIGHT));
+        mShouldDecorateItemsTogether = decorateTogether;
+        mIsTopRound = isFlagEnabled(roundRegions, ROUND_TOP_LEFT) &&
+                isFlagEnabled(roundRegions, ROUND_TOP_RIGHT);
+        mIsBottomRound = isFlagEnabled(roundRegions, ROUND_BOTTOM_LEFT) &&
+                isFlagEnabled(roundRegions, ROUND_BOTTOM_RIGHT);
+    }
+
+    public SectionDecorationInfo(Context context, @NonNull Bundle target,
+            String targetLayoutType, @NonNull Bundle prevTarget, @NonNull Bundle nextTarget) {}
+
+    public SectionDecorationHandler getDecorationHandler() {
+        return mDecorationHandler;
+    }
+
+    private boolean isFlagEnabled(int canonicalFlag, int comparison) {
+        return (canonicalFlag & comparison) != 0;
+    }
+
+    /**
+     * Returns whether multiple {@link SectionDecorationInfo}s with the same sectionId should
+     * be grouped together.
+     */
+    public boolean shouldDecorateItemsTogether() {
+        return mShouldDecorateItemsTogether;
+    }
+
+    public boolean isTopRound() {
+        return mIsTopRound;
+    }
+
+    public boolean isBottomRound() {
+        return mIsBottomRound;
+    }
+}
diff --git a/src/com/android/launcher3/apppairs/AppPairIcon.java b/src/com/android/launcher3/apppairs/AppPairIcon.java
index 46932fb..1d73441 100644
--- a/src/com/android/launcher3/apppairs/AppPairIcon.java
+++ b/src/com/android/launcher3/apppairs/AppPairIcon.java
@@ -17,11 +17,10 @@
 package com.android.launcher3.apppairs;
 
 import android.content.Context;
-import android.graphics.Canvas;
 import android.graphics.Rect;
-import android.graphics.drawable.Drawable;
 import android.util.AttributeSet;
 import android.view.LayoutInflater;
+import android.view.View;
 import android.view.ViewGroup;
 import android.widget.FrameLayout;
 
@@ -33,7 +32,6 @@
 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;
 
@@ -47,33 +45,8 @@
  * member apps are set into these rectangles.
  */
 public class AppPairIcon extends FrameLayout implements DraggableView, Reorderable {
-    /**
-     * Design specs -- the below ratios are in relation to the size of a standard app icon.
-     */
-    private static final float OUTER_PADDING_SCALE = 1 / 30f;
-    private static final float INNER_PADDING_SCALE = 1 / 24f;
-    private static final float MEMBER_ICON_SCALE = 11 / 30f;
-    private static final float CENTER_CHANNEL_SCALE = 1 / 30f;
-    private static final float BIG_RADIUS_SCALE = 1 / 5f;
-    private static final float SMALL_RADIUS_SCALE = 1 / 15f;
-
-    // App pair icons are slightly smaller than regular icons, so we pad the icon by this much on
-    // each side.
-    float mOuterPadding;
-    // Inside of the icon, the two member apps are padded by this much.
-    float mInnerPadding;
-    // The two member apps have icons that are this big (in diameter).
-    float mMemberIconSize;
-    // The size of the center channel.
-    float mCenterChannelSize;
-    // The large outer radius of the background rectangles.
-    float mBigRadius;
-    // The small inner radius of the background rectangles.
-    float mSmallRadius;
-    // The app pairs icon appears differently in portrait and landscape.
-    boolean mIsLandscape;
-
-    private ActivityContext mActivity;
+    // A view that holds the app pair icon graphic.
+    private AppPairIconGraphic mIconGraphic;
     // A view that holds the app pair's title.
     private BubbleTextView mAppPairName;
     // The underlying ItemInfo that stores info about the app pair members, etc.
@@ -109,7 +82,10 @@
         icon.setTag(appPairInfo);
         icon.setOnClickListener(activity.getItemOnClickListener());
         icon.mInfo = appPairInfo;
-        icon.mActivity = activity;
+
+        // Set up icon drawable area
+        icon.mIconGraphic = icon.findViewById(R.id.app_pair_icon_graphic);
+        icon.mIconGraphic.init(activity.getDeviceProfile(), icon);
 
         // Set up app pair title
         icon.mAppPairName = icon.findViewById(R.id.app_pair_icon_name);
@@ -127,85 +103,6 @@
         return icon;
     }
 
-    @Override
-    protected void dispatchDraw(Canvas canvas) {
-        super.dispatchDraw(canvas);
-
-        // Calculate device-specific measurements
-        DeviceProfile grid = mActivity.getDeviceProfile();
-        int defaultIconSize = grid.iconSizePx;
-        mOuterPadding = OUTER_PADDING_SCALE * defaultIconSize;
-        mInnerPadding = INNER_PADDING_SCALE * defaultIconSize;
-        mMemberIconSize = MEMBER_ICON_SCALE * defaultIconSize;
-        mCenterChannelSize = CENTER_CHANNEL_SCALE * defaultIconSize;
-        mBigRadius = BIG_RADIUS_SCALE * defaultIconSize;
-        mSmallRadius = SMALL_RADIUS_SCALE * defaultIconSize;
-        mIsLandscape = grid.isLeftRightSplit;
-
-        // Calculate drawable area position
-        float leftBound = (canvas.getWidth() / 2f) - (defaultIconSize / 2f);
-        float topBound = getPaddingTop();
-
-        // Prepare to draw app pair icon background
-        Drawable background = new AppPairIconBackground(getContext(), this);
-        background.setBounds(0, 0, defaultIconSize, defaultIconSize);
-
-        // Draw background
-        canvas.save();
-        canvas.translate(leftBound, topBound);
-        background.draw(canvas);
-        canvas.restore();
-
-        // Prepare to draw icons
-        WorkspaceItemInfo app1 = mInfo.contents.get(0);
-        WorkspaceItemInfo app2 = mInfo.contents.get(1);
-        Drawable app1Icon = app1.newIcon(getContext());
-        Drawable app2Icon = app2.newIcon(getContext());
-        app1Icon.setBounds(0, 0, defaultIconSize, defaultIconSize);
-        app2Icon.setBounds(0, 0, defaultIconSize, defaultIconSize);
-
-        // Draw first icon
-        canvas.save();
-        canvas.translate(leftBound, topBound);
-        // The app icons are placed differently depending on device orientation.
-        if (mIsLandscape) {
-            canvas.translate(
-                    (defaultIconSize / 2f) - (mCenterChannelSize / 2f) - mInnerPadding
-                            - mMemberIconSize,
-                    (defaultIconSize / 2f) - (mMemberIconSize / 2f)
-            );
-        } else {
-            canvas.translate(
-                    (defaultIconSize / 2f) - (mMemberIconSize / 2f),
-                    (defaultIconSize / 2f) - (mCenterChannelSize / 2f) - mInnerPadding
-                            - mMemberIconSize
-            );
-
-        }
-        canvas.scale(MEMBER_ICON_SCALE, MEMBER_ICON_SCALE);
-        app1Icon.draw(canvas);
-        canvas.restore();
-
-        // Draw second icon
-        canvas.save();
-        canvas.translate(leftBound, topBound);
-        // The app icons are placed differently depending on device orientation.
-        if (mIsLandscape) {
-            canvas.translate(
-                    (defaultIconSize / 2f) + (mCenterChannelSize / 2f) + mInnerPadding,
-                    (defaultIconSize / 2f) - (mMemberIconSize / 2f)
-            );
-        } else {
-            canvas.translate(
-                    (defaultIconSize / 2f) - (mMemberIconSize / 2f),
-                    (defaultIconSize / 2f) + (mCenterChannelSize / 2f) + mInnerPadding
-            );
-        }
-        canvas.scale(MEMBER_ICON_SCALE, MEMBER_ICON_SCALE);
-        app2Icon.draw(canvas);
-        canvas.restore();
-    }
-
     /**
      * Returns a formatted accessibility title for app pairs.
      */
@@ -221,8 +118,8 @@
 
     // Required for DraggableView
     @Override
-    public void getWorkspaceVisualDragBounds(Rect bounds) {
-        mAppPairName.getIconBounds(bounds);
+    public void getWorkspaceVisualDragBounds(Rect outBounds) {
+        mIconGraphic.getIconBounds(outBounds);
     }
 
     /** Sets the visibility of the icon's title text */
@@ -257,4 +154,8 @@
     public FolderInfo getInfo() {
         return mInfo;
     }
+
+    public View getIconDrawableArea() {
+        return mIconGraphic;
+    }
 }
diff --git a/src/com/android/launcher3/apppairs/AppPairIconBackground.java b/src/com/android/launcher3/apppairs/AppPairIconBackground.java
index 735c82f..4e60ece 100644
--- a/src/com/android/launcher3/apppairs/AppPairIconBackground.java
+++ b/src/com/android/launcher3/apppairs/AppPairIconBackground.java
@@ -32,8 +32,8 @@
  * A Drawable for the background behind the twin app icons (looks like two rectangles).
  */
 class AppPairIconBackground extends Drawable {
-    // The icon that we will draw this background on.
-    private final AppPairIcon icon;
+    // The underlying view that we are drawing this background on.
+    private final AppPairIconGraphic icon;
     private final Paint mBackgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
 
     /**
@@ -44,8 +44,8 @@
     private static final RectF EMPTY_RECT = new RectF();
     private static final float[] ARRAY_OF_ZEROES = new float[8];
 
-    AppPairIconBackground(Context context, AppPairIcon appPairIcon) {
-        icon = appPairIcon;
+    AppPairIconBackground(Context context, AppPairIconGraphic iconGraphic) {
+        icon = iconGraphic;
         // Set up background paint color
         TypedArray ta = context.getTheme().obtainStyledAttributes(R.styleable.FolderIconPreview);
         mBackgroundPaint.setStyle(Paint.Style.FILL);
@@ -56,7 +56,7 @@
 
     @Override
     public void draw(Canvas canvas) {
-        if (icon.mIsLandscape) {
+        if (icon.isLeftRightSplit()) {
             drawLeftRightSplit(canvas);
         } else {
             drawTopBottomSplit(canvas);
@@ -73,29 +73,29 @@
 
         // The left half of the background image, excluding center channel
         RectF leftSide = new RectF(
-                icon.mOuterPadding,
-                icon.mOuterPadding,
-                (width / 2f) - (icon.mCenterChannelSize / 2f),
-                height - icon.mOuterPadding
+                0,
+                0,
+                (width / 2f) - (icon.getCenterChannelSize() / 2f),
+                height
         );
         // The right half of the background image, excluding center channel
         RectF rightSide = new RectF(
-                (width / 2f) + (icon.mCenterChannelSize / 2f),
-                icon.mOuterPadding,
-                width - icon.mOuterPadding,
-                height - icon.mOuterPadding
+                (width / 2f) + (icon.getCenterChannelSize() / 2f),
+                0,
+                width,
+                height
         );
 
         drawCustomRoundedRect(canvas, leftSide, new float[]{
-                icon.mBigRadius, icon.mBigRadius,
-                icon.mSmallRadius, icon.mSmallRadius,
-                icon.mSmallRadius, icon.mSmallRadius,
-                icon.mBigRadius, icon.mBigRadius});
+                icon.getBigRadius(), icon.getBigRadius(),
+                icon.getSmallRadius(), icon.getSmallRadius(),
+                icon.getSmallRadius(), icon.getSmallRadius(),
+                icon.getBigRadius(), icon.getBigRadius()});
         drawCustomRoundedRect(canvas, rightSide, new float[]{
-                icon.mSmallRadius, icon.mSmallRadius,
-                icon.mBigRadius, icon.mBigRadius,
-                icon.mBigRadius, icon.mBigRadius,
-                icon.mSmallRadius, icon.mSmallRadius});
+                icon.getSmallRadius(), icon.getSmallRadius(),
+                icon.getBigRadius(), icon.getBigRadius(),
+                icon.getBigRadius(), icon.getBigRadius(),
+                icon.getSmallRadius(), icon.getSmallRadius()});
     }
 
     /**
@@ -108,29 +108,29 @@
 
         // The top half of the background image, excluding center channel
         RectF topSide = new RectF(
-                icon.mOuterPadding,
-                icon.mOuterPadding,
-                width - icon.mOuterPadding,
-                (height / 2f) - (icon.mCenterChannelSize / 2f)
+                0,
+                0,
+                width,
+                (height / 2f) - (icon.getCenterChannelSize() / 2f)
         );
         // The bottom half of the background image, excluding center channel
         RectF bottomSide = new RectF(
-                icon.mOuterPadding,
-                (height / 2f) + (icon.mCenterChannelSize / 2f),
-                width - icon.mOuterPadding,
-                height - icon.mOuterPadding
+                0,
+                (height / 2f) + (icon.getCenterChannelSize() / 2f),
+                width,
+                height
         );
 
         drawCustomRoundedRect(canvas, topSide, new float[]{
-                icon.mBigRadius, icon.mBigRadius,
-                icon.mBigRadius, icon.mBigRadius,
-                icon.mSmallRadius, icon.mSmallRadius,
-                icon.mSmallRadius, icon.mSmallRadius});
+                icon.getBigRadius(), icon.getBigRadius(),
+                icon.getBigRadius(), icon.getBigRadius(),
+                icon.getSmallRadius(), icon.getSmallRadius(),
+                icon.getSmallRadius(), icon.getSmallRadius()});
         drawCustomRoundedRect(canvas, bottomSide, new float[]{
-                icon.mSmallRadius, icon.mSmallRadius,
-                icon.mSmallRadius, icon.mSmallRadius,
-                icon.mBigRadius, icon.mBigRadius,
-                icon.mBigRadius, icon.mBigRadius});
+                icon.getSmallRadius(), icon.getSmallRadius(),
+                icon.getSmallRadius(), icon.getSmallRadius(),
+                icon.getBigRadius(), icon.getBigRadius(),
+                icon.getBigRadius(), icon.getBigRadius()});
     }
 
     /**
@@ -146,7 +146,7 @@
             c.drawDoubleRoundRect(rect, radii, EMPTY_RECT, ARRAY_OF_ZEROES, mBackgroundPaint);
         } else {
             // Fallback rectangle with uniform rounded corners
-            c.drawRoundRect(rect, icon.mBigRadius, icon.mBigRadius, mBackgroundPaint);
+            c.drawRoundRect(rect, icon.getBigRadius(), icon.getBigRadius(), mBackgroundPaint);
         }
     }
 
diff --git a/src/com/android/launcher3/apppairs/AppPairIconGraphic.kt b/src/com/android/launcher3/apppairs/AppPairIconGraphic.kt
new file mode 100644
index 0000000..2945979
--- /dev/null
+++ b/src/com/android/launcher3/apppairs/AppPairIconGraphic.kt
@@ -0,0 +1,141 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.launcher3.apppairs
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Rect
+import android.graphics.drawable.Drawable
+import android.util.AttributeSet
+import android.view.Gravity
+import android.widget.FrameLayout
+import com.android.launcher3.DeviceProfile
+
+/**
+ * A FrameLayout marking the area on an [AppPairIcon] where the visual icon will be drawn. One of
+ * two child UI elements on an [AppPairIcon], along with a BubbleTextView holding the text title.
+ */
+class AppPairIconGraphic @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
+    FrameLayout(context, attrs) {
+    companion object {
+        // Design specs -- the below ratios are in relation to the size of a standard app icon.
+        private const val OUTER_PADDING_SCALE = 1 / 30f
+        private const val INNER_PADDING_SCALE = 1 / 24f
+        private const val MEMBER_ICON_SCALE = 11 / 30f
+        private const val CENTER_CHANNEL_SCALE = 1 / 30f
+        private const val BIG_RADIUS_SCALE = 1 / 5f
+        private const val SMALL_RADIUS_SCALE = 1 / 15f
+    }
+
+    // App pair icons are slightly smaller than regular icons, so we pad the icon by this much on
+    // each side.
+    private var outerPadding = 0f
+    // Inside of the icon, the two member apps are padded by this much.
+    private var innerPadding = 0f
+    // The colored background (two rectangles in a square area) is this big.
+    private var backgroundSize = 0f
+    // The two member apps have icons that are this big (in diameter).
+    private var memberIconSize = 0f
+    // The size of the center channel.
+    var centerChannelSize = 0f
+    // The large outer radius of the background rectangles.
+    var bigRadius = 0f
+    // The small inner radius of the background rectangles.
+    var smallRadius = 0f
+    // The app pairs icon appears differently in portrait and landscape.
+    var isLeftRightSplit = false
+
+    private lateinit var parentIcon: AppPairIcon
+    private lateinit var appPairBackground: Drawable
+    private lateinit var appIcon1: Drawable
+    private lateinit var appIcon2: Drawable
+
+    fun init(grid: DeviceProfile, icon: AppPairIcon) {
+        // Calculate device-specific measurements
+        val defaultIconSize = grid.iconSizePx
+        outerPadding = OUTER_PADDING_SCALE * defaultIconSize
+        innerPadding = INNER_PADDING_SCALE * defaultIconSize
+        backgroundSize = defaultIconSize - outerPadding * 2
+        memberIconSize = MEMBER_ICON_SCALE * defaultIconSize
+        centerChannelSize = CENTER_CHANNEL_SCALE * defaultIconSize
+        bigRadius = BIG_RADIUS_SCALE * defaultIconSize
+        smallRadius = SMALL_RADIUS_SCALE * defaultIconSize
+        isLeftRightSplit = grid.isLeftRightSplit
+        parentIcon = icon
+
+        appPairBackground = AppPairIconBackground(context, this)
+        appPairBackground.setBounds(0, 0, backgroundSize.toInt(), backgroundSize.toInt())
+        appIcon1 = parentIcon.info.contents[0].newIcon(context)
+        appIcon2 = parentIcon.info.contents[1].newIcon(context)
+        appIcon1.setBounds(0, 0, memberIconSize.toInt(), memberIconSize.toInt())
+        appIcon2.setBounds(0, 0, memberIconSize.toInt(), memberIconSize.toInt())
+    }
+
+    /** Gets this icon graphic's bounds, with respect to the parent icon's coordinate system. */
+    fun getIconBounds(outBounds: Rect) {
+        outBounds.set(0, 0, backgroundSize.toInt(), backgroundSize.toInt())
+        outBounds.offset(
+            // x-coordinate in parent's coordinate system
+            ((parentIcon.width - backgroundSize) / 2).toInt(),
+            // y-coordinate in parent's coordinate system
+            parentIcon.paddingTop + outerPadding.toInt()
+        )
+    }
+
+    override fun dispatchDraw(canvas: Canvas) {
+        super.dispatchDraw(canvas)
+
+        // Center the drawable area in the larger icon canvas
+        val lp: LayoutParams = layoutParams as LayoutParams
+        lp.gravity = Gravity.CENTER_HORIZONTAL
+        lp.topMargin = outerPadding.toInt()
+        lp.height = backgroundSize.toInt()
+        lp.width = backgroundSize.toInt()
+        layoutParams = lp
+
+        // Draw background
+        appPairBackground.draw(canvas)
+
+        // Draw first icon
+        canvas.save()
+        // The app icons are placed differently depending on device orientation.
+        if (isLeftRightSplit) {
+            canvas.translate(innerPadding, height / 2f - memberIconSize / 2f)
+        } else {
+            canvas.translate(width / 2f - memberIconSize / 2f, innerPadding)
+        }
+        appIcon1.draw(canvas)
+        canvas.restore()
+
+        // Draw second icon
+        canvas.save()
+        // The app icons are placed differently depending on device orientation.
+        if (isLeftRightSplit) {
+            canvas.translate(
+                width - (innerPadding + memberIconSize),
+                height / 2f - memberIconSize / 2f
+            )
+        } else {
+            canvas.translate(
+                width / 2f - memberIconSize / 2f,
+                height - (innerPadding + memberIconSize)
+            )
+        }
+        appIcon2.draw(canvas)
+        canvas.restore()
+    }
+}
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/model/LoaderTask.java b/src/com/android/launcher3/model/LoaderTask.java
index 6ea3e8a..a98ec64 100644
--- a/src/com/android/launcher3/model/LoaderTask.java
+++ b/src/com/android/launcher3/model/LoaderTask.java
@@ -411,7 +411,7 @@
                     mSessionHelper.getActiveSessions();
             installingPkgs.forEach(mApp.getIconCache()::updateSessionCache);
             FileLog.d(TAG, "loadWorkspace: Packages with active install sessions: "
-                    + installingPkgs.values());
+                    + installingPkgs.keySet().stream().map(info -> info.mPackageName).toList());
 
             final PackageUserKey tempPackageKey = new PackageUserKey(null, null);
             mFirstScreenBroadcast = new FirstScreenBroadcast(installingPkgs);
@@ -440,10 +440,15 @@
                                 mShortcutKeyToPinnedShortcuts.put(ShortcutKey.fromInfo(shortcut),
                                         shortcut);
                             }
+                            if (pinnedShortcuts.isEmpty()) {
+                                FileLog.d(TAG, "No pinned shortcuts found for user " + user);
+                            }
                         } else {
                             // Shortcut manager can fail due to some race condition when the
                             // lock state changes too frequently. For the purpose of the loading
                             // shortcuts, consider the user is still locked.
+                            FileLog.d(TAG, "Shortcut request failed for user "
+                                    + user + ", user may still be locked.");
                             userUnlocked = false;
                         }
                     }
@@ -590,17 +595,17 @@
                             // Package is not yet available but might be
                             // installed later.
                             FileLog.d(TAG, "package not yet restored: " + targetPkg);
-
                             tempPackageKey.update(targetPkg, c.user);
                             if (c.hasRestoreFlag(WorkspaceItemInfo.FLAG_RESTORE_STARTED)) {
                                 // Restore has started once.
                             } else if (installingPkgs.containsKey(tempPackageKey)) {
                                 // App restore has started. Update the flag
                                 c.restoreFlag |= WorkspaceItemInfo.FLAG_RESTORE_STARTED;
-                                c.updater().put(Favorites.RESTORED,
-                                        c.restoreFlag).commit();
+                                FileLog.d(TAG, "restore started for installing app: " + targetPkg);
+                                c.updater().put(Favorites.RESTORED, c.restoreFlag).commit();
                             } else {
-                                c.markDeleted("Unrestored app removed: " + targetPkg);
+                                c.markDeleted("removing app that is not restored and not "
+                                        + "installing. package: " + targetPkg);
                                 return;
                             }
                         } else if (pmHelper.isAppOnSdcard(targetPkg, c.user)) {
@@ -611,7 +616,7 @@
                         } else if (!isSdCardReady) {
                             // SdCard is not ready yet. Package might get available,
                             // once it is ready.
-                            Log.d(TAG, "Missing pkg, will check later: " + targetPkg);
+                            Log.d(TAG, "Missing package, will check later: " + targetPkg);
                             mPendingPackages.add(new PackageUserKey(targetPkg, c.user));
                             // Add the icon on the workspace anyway.
                             allowMissingTarget = true;
@@ -647,7 +652,8 @@
                             ShortcutInfo pinnedShortcut = mShortcutKeyToPinnedShortcuts.get(key);
                             if (pinnedShortcut == null) {
                                 // The shortcut is no longer valid.
-                                c.markDeleted("Pinned shortcut not found");
+                                c.markDeleted("Pinned shortcut not found for package: "
+                                        + key.getPackageName());
                                 return;
                             }
                             info = new WorkspaceItemInfo(pinnedShortcut, mApp.getContext());
@@ -817,7 +823,7 @@
                         } else {
                             Log.v(TAG, "Widget restore pending id=" + c.id
                                     + " appWidgetId=" + appWidgetId
-                                    + " status =" + c.restoreFlag);
+                                    + " status=" + c.restoreFlag);
                             appWidgetInfo = new LauncherAppWidgetInfo(appWidgetId, component);
                             appWidgetInfo.restoreStatus = c.restoreFlag;
 
diff --git a/src/com/android/launcher3/model/ModelDbController.java b/src/com/android/launcher3/model/ModelDbController.java
index 139efc3..d2b7161 100644
--- a/src/com/android/launcher3/model/ModelDbController.java
+++ b/src/com/android/launcher3/model/ModelDbController.java
@@ -62,6 +62,7 @@
 import com.android.launcher3.LauncherSettings;
 import com.android.launcher3.LauncherSettings.Favorites;
 import com.android.launcher3.Utilities;
+import com.android.launcher3.logging.FileLog;
 import com.android.launcher3.pm.UserCache;
 import com.android.launcher3.provider.LauncherDbUtils;
 import com.android.launcher3.provider.LauncherDbUtils.SQLiteTransaction;
@@ -262,7 +263,7 @@
      */
     public void tryMigrateDB() {
         if (!migrateGridIfNeeded()) {
-            Log.d(TAG, "Migration failed: resetting launcher database");
+            FileLog.d(TAG, "Migration failed: resetting launcher database");
             createEmptyDB();
             LauncherPrefs.get(mContext).putSync(
                     getEmptyDbCreatedKey(mOpenHelper.getDatabaseName()).to(true));
@@ -282,15 +283,17 @@
         createDbIfNotExists();
         if (LauncherPrefs.get(mContext).get(getEmptyDbCreatedKey())) {
             // If we have already create a new DB, ignore migration
+            Log.d(TAG, "migrateGridIfNeeded: new DB already created, skipping migration");
             return false;
         }
         InvariantDeviceProfile idp = LauncherAppState.getIDP(mContext);
         if (!GridSizeMigrationUtil.needsToMigrate(mContext, idp)) {
+            Log.d(TAG, "migrateGridIfNeeded: no grid migration needed");
             return true;
         }
         String targetDbName = new DeviceGridState(idp).getDbFile();
         if (TextUtils.equals(targetDbName, mOpenHelper.getDatabaseName())) {
-            Log.e(TAG, "migrateGridIfNeeded - target db is same as current: " + targetDbName);
+            Log.e(TAG, "migrateGridIfNeeded: target db is same as current: " + targetDbName);
             return false;
         }
         DatabaseHelper oldHelper = mOpenHelper;
@@ -299,6 +302,9 @@
         try {
             return GridSizeMigrationUtil.migrateGridIfNeeded(mContext, idp, mOpenHelper,
                    oldHelper.getWritableDatabase());
+        } catch (Exception e) {
+            FileLog.e(TAG, "Failed to migrate grid", e);
+            return false;
         } finally {
             if (mOpenHelper != oldHelper) {
                 oldHelper.close();
diff --git a/src/com/android/launcher3/provider/RestoreDbTask.java b/src/com/android/launcher3/provider/RestoreDbTask.java
index dbd13b3..dc8cd3a 100644
--- a/src/com/android/launcher3/provider/RestoreDbTask.java
+++ b/src/com/android/launcher3/provider/RestoreDbTask.java
@@ -89,9 +89,9 @@
 
     public static final String APPWIDGET_OLD_IDS = "appwidget_old_ids";
     public static final String APPWIDGET_IDS = "appwidget_ids";
-
     private static final String[] DB_COLUMNS_TO_LOG = {"profileId", "title", "itemType", "screen",
-            "container", "cellX", "cellY", "spanX", "spanY", "intent"};
+            "container", "cellX", "cellY", "spanX", "spanY", "intent", "appWidgetProvider",
+            "appWidgetId", "restored"};
 
     /**
      * Tries to restore the backup DB if needed
@@ -141,16 +141,17 @@
      *   4. If restored from a single display backup, remove gaps between screenIds
      *   5. Override shortcuts that need to be replaced.
      *
-     * @return number of items deleted.
+     * @return number of items deleted
      */
     @VisibleForTesting
     protected int sanitizeDB(Context context, ModelDbController controller, SQLiteDatabase db,
             BackupManager backupManager) throws Exception {
-        FileLog.d(TAG, "Old Launcher Database before sanitizing:");
+        logFavoritesTable(db, "Old Launcher Database before sanitizing:", null, null);
         // Primary user ids
         long myProfileId = controller.getSerialNumberForUser(myUserHandle());
         long oldProfileId = getDefaultProfileId(db);
-        FileLog.d(TAG, "sanitizeDB: myProfileId=" + myProfileId + " oldProfileId=" + oldProfileId);
+        FileLog.d(TAG, "sanitizeDB: myProfileId= " + myProfileId
+                + ", oldProfileId= " + oldProfileId);
         LongSparseArray<Long> oldManagedProfileIds = getManagedProfileIds(db, oldProfileId);
         LongSparseArray<Long> profileMapping = new LongSparseArray<>(oldManagedProfileIds.size()
                 + 1);
@@ -182,7 +183,7 @@
         final String[] args = new String[profileIds.length];
         Arrays.fill(args, "?");
         final String where = "profileId NOT IN (" + TextUtils.join(", ", Arrays.asList(args)) + ")";
-        logUnrestoredItems(db, where, profileIds);
+        logFavoritesTable(db, "items to delete from unrestored profiles:", where, profileIds);
         int itemsDeletedCount = db.delete(Favorites.TABLE_NAME, where, profileIds);
         FileLog.d(TAG, itemsDeletedCount + " total items from unrestored user(s) were deleted");
 
@@ -242,47 +243,6 @@
     }
 
     /**
-     * Queries and logs the items we will delete from unrestored profiles in the launcher db.
-     * This is to understand why items might be missing during the restore process for Launcher.
-     * @param database the Launcher db to query from.
-     * @param where the SELECT statement to query items that will be deleted.
-     * @param profileIds the profile ID's the user will be migrating to.
-     */
-    private void logUnrestoredItems(SQLiteDatabase database, String where, String[] profileIds) {
-        try (Cursor itemsToDelete = database.query(
-                /* table */ Favorites.TABLE_NAME,
-                /* columns */ DB_COLUMNS_TO_LOG,
-                /* selection */ where,
-                /* selection args */ profileIds,
-                /* groupBy */ null,
-                /* having */ null,
-                /* orderBy */ null
-        )) {
-            if (itemsToDelete.moveToFirst()) {
-                String[] columnNames = itemsToDelete.getColumnNames();
-                StringBuilder stringBuilder = new StringBuilder(
-                        "items to be deleted from the Favorites Table during restore:\n"
-                );
-                do {
-                    for (String columnName : columnNames) {
-                        stringBuilder.append(columnName)
-                            .append("=")
-                            .append(itemsToDelete.getString(
-                                        itemsToDelete.getColumnIndex(columnName)))
-                            .append(" ");
-                    }
-                    stringBuilder.append("\n");
-                } while (itemsToDelete.moveToNext());
-                FileLog.d(TAG, stringBuilder.toString());
-            } else {
-                FileLog.d(TAG, "logDeletedItems: No items found to delete");
-            }
-        } catch (Exception e) {
-            FileLog.e(TAG, "logDeletedItems: Error reading from database", e);
-        }
-    }
-
-    /**
      * Remove gaps between screenIds to make sure no empty pages are left in between.
      *
      * e.g. [0, 3, 4, 6, 7] -> [0, 1, 2, 3, 4]
@@ -396,7 +356,7 @@
                     IntArray.fromConcatString(lp.get(APP_WIDGET_IDS)).toArray(),
                     host);
         } else {
-            FileLog.d(TAG, "No app widget ids to restore.");
+            FileLog.d(TAG, "No app widget ids were received from backup to restore.");
         }
 
         lp.remove(APP_WIDGET_IDS, OLD_APP_WIDGET_IDS);
@@ -409,16 +369,16 @@
     private void restoreAppWidgetIds(Context context, ModelDbController controller,
             int[] oldWidgetIds, int[] newWidgetIds, @NonNull AppWidgetHost host) {
         if (WidgetsModel.GO_DISABLE_WIDGETS) {
-            Log.e(TAG, "Skipping widget ID remap as widgets not supported");
+            FileLog.e(TAG, "Skipping widget ID remap as widgets not supported");
             host.deleteHost();
             return;
         }
         if (!RestoreDbTask.isPending(context)) {
             // Someone has already gone through our DB once, probably LoaderTask. Skip any further
             // modifications of the DB.
-            Log.e(TAG, "Skipping widget ID remap as DB already in use");
+            FileLog.e(TAG, "Skipping widget ID remap as DB already in use");
             for (int widgetId : newWidgetIds) {
-                Log.d(TAG, "Deleting widgetId: " + widgetId);
+                FileLog.d(TAG, "Deleting widgetId: " + widgetId);
                 host.deleteAppWidgetId(widgetId);
             }
             return;
@@ -426,7 +386,7 @@
 
         final AppWidgetManager widgets = AppWidgetManager.getInstance(context);
 
-        Log.d(TAG, "restoreAppWidgetIds: "
+        FileLog.d(TAG, "restoreAppWidgetIds: "
                 + "oldWidgetIds=" + IntArray.wrap(oldWidgetIds).toConcatString()
                 + ", newWidgetIds=" + IntArray.wrap(newWidgetIds).toConcatString());
 
@@ -434,7 +394,7 @@
         logDatabaseWidgetInfo(controller);
 
         for (int i = 0; i < oldWidgetIds.length; i++) {
-            Log.i(TAG, "Widget state restore id " + oldWidgetIds[i] + " => " + newWidgetIds[i]);
+            FileLog.i(TAG, "Widget state restore id " + oldWidgetIds[i] + " => " + newWidgetIds[i]);
 
             final AppWidgetProviderInfo provider = widgets.getAppWidgetInfo(newWidgetIds[i]);
             final int state;
@@ -454,7 +414,7 @@
             final String where = "appWidgetId=? and (restored & 1) = 1 and profileId=?";
             String profileId = Long.toString(mainProfileId);
             final String[] args = new String[] { oldWidgetId, profileId };
-            Log.d(TAG, "restoreAppWidgetIds: querying profile id=" + profileId
+            FileLog.d(TAG, "restoreAppWidgetIds: querying profile id=" + profileId
                     + " with controller profile ID=" + controllerProfileId);
             int result = new ContentWriter(context,
                     new ContentWriter.CommitParams(controller, where, args))
@@ -463,7 +423,7 @@
                     .commit();
             if (result == 0) {
                 // TODO(b/234700507): Remove the logs after the bug is fixed
-                Log.e(TAG, "restoreAppWidgetIds: remapping failed since the widget is not in"
+                FileLog.e(TAG, "restoreAppWidgetIds: remapping failed since the widget is not in"
                         + " the database anymore");
                 try (Cursor cursor = controller.getDb().query(
                         Favorites.TABLE_NAME,
@@ -471,7 +431,7 @@
                         "appWidgetId=?", new String[]{oldWidgetId}, null, null, null)) {
                     if (!cursor.moveToFirst()) {
                         // The widget no long exists.
-                        Log.d(TAG, "Deleting widgetId: " + newWidgetIds[i] + " with old id: "
+                        FileLog.d(TAG, "Deleting widgetId: " + newWidgetIds[i] + " with old id: "
                                 + oldWidgetId);
                         host.deleteAppWidgetId(newWidgetIds[i]);
                     }
@@ -523,7 +483,7 @@
             }
             builder.append("]");
             Log.d(TAG, "restoreAppWidgetIds: all widget ids in database: "
-                    + builder.toString());
+                    + builder);
         } catch (Exception ex) {
             Log.e(TAG, "Getting widget ids from the database failed", ex);
         }
@@ -572,4 +532,45 @@
                 Collectors.joining(" OR "));
     }
 
+    /**
+     * Queries and logs the items from the Favorites table in the launcher db.
+     * This is to understand why items might be missing during the restore process for Launcher.
+     * @param database The Launcher db to query from.
+     * @param logHeader First line in log statement, used to explain what is being logged.
+     * @param where The SELECT statement to query items.
+     * @param profileIds The profile ID's for each user profile.
+     */
+    public static void logFavoritesTable(SQLiteDatabase database, @NonNull String logHeader,
+            String where, String[] profileIds) {
+        try (Cursor itemsToDelete = database.query(
+                /* table */ Favorites.TABLE_NAME,
+                /* columns */ DB_COLUMNS_TO_LOG,
+                /* selection */ where,
+                /* selection args */ profileIds,
+                /* groupBy */ null,
+                /* having */ null,
+                /* orderBy */ null
+        )) {
+            if (itemsToDelete.moveToFirst()) {
+                String[] columnNames = itemsToDelete.getColumnNames();
+                StringBuilder stringBuilder = new StringBuilder(logHeader + "\n");
+                do {
+                    for (String columnName : columnNames) {
+                        stringBuilder.append(columnName)
+                                .append("=")
+                                .append(itemsToDelete.getString(
+                                        itemsToDelete.getColumnIndex(columnName)))
+                                .append(" ");
+                    }
+                    stringBuilder.append("\n");
+                } while (itemsToDelete.moveToNext());
+                FileLog.d(TAG, stringBuilder.toString());
+            } else {
+                FileLog.d(TAG, "logFavoritesTable: No items found from query for"
+                        + "\"" + logHeader + "\"");
+            }
+        } catch (Exception e) {
+            FileLog.e(TAG, "logFavoritesTable: Error reading from database", e);
+        }
+    }
 }
diff --git a/src/com/android/launcher3/shortcuts/ShortcutRequest.java b/src/com/android/launcher3/shortcuts/ShortcutRequest.java
index 5291ce4..21efceb 100644
--- a/src/com/android/launcher3/shortcuts/ShortcutRequest.java
+++ b/src/com/android/launcher3/shortcuts/ShortcutRequest.java
@@ -24,10 +24,11 @@
 import android.content.pm.LauncherApps.ShortcutQuery;
 import android.content.pm.ShortcutInfo;
 import android.os.UserHandle;
-import android.util.Log;
 
 import androidx.annotation.Nullable;
 
+import com.android.launcher3.logging.FileLog;
+
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -101,7 +102,7 @@
             return new QueryResult(mContext.getSystemService(LauncherApps.class)
                     .getShortcuts(mQuery, mUserHandle));
         } catch (SecurityException | IllegalStateException e) {
-            Log.e(TAG, "Failed to query for shortcuts", e);
+            FileLog.e(TAG, "Failed to query for shortcuts", e);
             return QueryResult.DEFAULT;
         }
     }
diff --git a/src/com/android/launcher3/touch/ItemClickHandler.java b/src/com/android/launcher3/touch/ItemClickHandler.java
index 3bce377..a9c2a2e 100644
--- a/src/com/android/launcher3/touch/ItemClickHandler.java
+++ b/src/com/android/launcher3/touch/ItemClickHandler.java
@@ -145,8 +145,8 @@
      */
     private static void onClickAppPairIcon(View v) {
         Launcher launcher = Launcher.getLauncher(v.getContext());
-        FolderInfo folderInfo = ((AppPairIcon) v).getInfo();
-        launcher.launchAppPair(folderInfo.contents.get(0), folderInfo.contents.get(1));
+        AppPairIcon appPairIcon = (AppPairIcon) v;
+        launcher.launchAppPair(appPairIcon);
     }
 
     /**
diff --git a/tests/src/com/android/launcher3/allapps/TaplOpenCloseAllApps.java b/tests/src/com/android/launcher3/allapps/TaplOpenCloseAllApps.java
index 4a42887..b4a5169 100644
--- a/tests/src/com/android/launcher3/allapps/TaplOpenCloseAllApps.java
+++ b/tests/src/com/android/launcher3/allapps/TaplOpenCloseAllApps.java
@@ -208,9 +208,10 @@
         InstrumentationRegistry.getInstrumentation().getUiAutomation().adoptShellPermissionIdentity(
                 READ_DEVICE_CONFIG_PERMISSION);
         assumeFalse(FeatureFlags.ENABLE_BACK_SWIPE_LAUNCHER_ANIMATION.get());
-        mLauncher.getWorkspace().switchToAllApps();
-        mLauncher.pressBack();
-        mLauncher.getWorkspace();
+        mLauncher
+                .getWorkspace()
+                .switchToAllApps()
+                .pressBackToWorkspace();
         waitForState("Launcher internal state didn't switch to Home", () -> LauncherState.NORMAL);
         startAppFast(resolveSystemApp(Intent.CATEGORY_APP_CALCULATOR));
         mLauncher.pressBack();
diff --git a/tests/src/com/android/launcher3/ui/AbstractLauncherUiTest.java b/tests/src/com/android/launcher3/ui/AbstractLauncherUiTest.java
index 8ad2249..79d8c60 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
 
@@ -255,7 +257,7 @@
         final RuleChain inner = RuleChain
                 .outerRule(new PortraitLandscapeRunner(this))
                 .around(new FailureWatcher(mLauncher, viewCaptureRule::getViewCaptureData))
-                .around(viewCaptureRule)
+                // .around(viewCaptureRule) // b/315482167
                 .around(new TestIsolationRule(mLauncher, true));
 
         return TestHelpers.isInLauncherProcess()
diff --git a/tests/src/com/android/launcher3/ui/widget/TaplAddConfigWidgetTest.java b/tests/src/com/android/launcher3/ui/widget/TaplAddConfigWidgetTest.java
index 3263d60..d96287f 100644
--- a/tests/src/com/android/launcher3/ui/widget/TaplAddConfigWidgetTest.java
+++ b/tests/src/com/android/launcher3/ui/widget/TaplAddConfigWidgetTest.java
@@ -40,7 +40,6 @@
 import com.android.launcher3.ui.TestViewHelpers;
 import com.android.launcher3.util.Wait;
 import com.android.launcher3.util.rule.ShellCommandRule;
-import com.android.launcher3.util.rule.TestStabilityRule;
 import com.android.launcher3.widget.LauncherAppWidgetProviderInfo;
 
 import org.junit.Before;
@@ -120,8 +119,7 @@
     }
 
     private void setResultAndWaitForAnimation(boolean success) {
-        if (mLauncher.isLauncher3()
-                || TestStabilityRule.isPresubmit() /* b/313926097 */) {
+        if (mLauncher.isLauncher3()) {
             setResult(success);
         } else {
             mLauncher.executeAndWaitForWallpaperAnimation(
diff --git a/tests/src/com/android/launcher3/ui/widget/TaplWidgetPickerTest.java b/tests/src/com/android/launcher3/ui/widget/TaplWidgetPickerTest.java
index 465e9b4..bc73683 100644
--- a/tests/src/com/android/launcher3/ui/widget/TaplWidgetPickerTest.java
+++ b/tests/src/com/android/launcher3/ui/widget/TaplWidgetPickerTest.java
@@ -15,10 +15,6 @@
  */
 package com.android.launcher3.ui.widget;
 
-import static com.android.launcher3.ui.AbstractLauncherUiTest.initialize;
-import static com.android.launcher3.util.rule.TestStabilityRule.LOCAL;
-import static com.android.launcher3.util.rule.TestStabilityRule.PLATFORM_POSTSUBMIT;
-
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertTrue;
@@ -31,7 +27,6 @@
 import com.android.launcher3.ui.AbstractLauncherUiTest;
 import com.android.launcher3.ui.PortraitLandscapeRunner.PortraitLandscape;
 import com.android.launcher3.util.rule.ScreenRecordRule.ScreenRecord;
-import com.android.launcher3.util.rule.TestStabilityRule.Stability;
 import com.android.launcher3.widget.picker.WidgetsFullSheet;
 import com.android.launcher3.widget.picker.WidgetsRecyclerView;
 
@@ -65,11 +60,9 @@
      * Open Widget picker, make sure the widget picker can scroll and then go to home screen.
      */
     @Test
-    @Stability(flavors = LOCAL | PLATFORM_POSTSUBMIT) // b/303263644
     @ScreenRecord
     @PortraitLandscape
     public void testWidgets() {
-        // Testing if this will fix b/303263644
         mLauncher.goHome();
         // Test opening widgets.
         executeOnLauncher(launcher ->
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()
     };
diff --git a/tests/tapl/com/android/launcher3/tapl/HomeAllApps.java b/tests/tapl/com/android/launcher3/tapl/HomeAllApps.java
index d9b179c..9ca2dc8 100644
--- a/tests/tapl/com/android/launcher3/tapl/HomeAllApps.java
+++ b/tests/tapl/com/android/launcher3/tapl/HomeAllApps.java
@@ -118,4 +118,17 @@
     public boolean isHomeState() {
         return true;
     }
+
+    /** Send the "back" gesture to go to workspace. */
+    public Workspace pressBackToWorkspace() {
+        try (LauncherInstrumentation.Closable e = mLauncher.eventsCheck();
+             LauncherInstrumentation.Closable c = mLauncher.addContextLayer(
+                     "want to press back from all apps to workspace")) {
+            mLauncher.runToState(
+                    () -> mLauncher.pressBackImpl(),
+                    NORMAL_STATE_ORDINAL,
+                    "pressing back");
+            return new Workspace(mLauncher);
+        }
+    }
 }
diff --git a/tests/tapl/com/android/launcher3/tapl/LaunchedAppState.java b/tests/tapl/com/android/launcher3/tapl/LaunchedAppState.java
index 6d58a35..184ece7 100644
--- a/tests/tapl/com/android/launcher3/tapl/LaunchedAppState.java
+++ b/tests/tapl/com/android/launcher3/tapl/LaunchedAppState.java
@@ -340,4 +340,17 @@
             }
         }
     }
+
+    /** Send the "back" gesture to go to workspace. */
+    public Workspace pressBackToWorkspace() {
+        try (LauncherInstrumentation.Closable e = mLauncher.eventsCheck();
+             LauncherInstrumentation.Closable c = mLauncher.addContextLayer(
+                     "want to press back from launched app to workspace")) {
+            mLauncher.executeAndWaitForWallpaperAnimation(
+                    () -> mLauncher.pressBackImpl(),
+                    "pressing back"
+            );
+            return new Workspace(mLauncher);
+        }
+    }
 }
diff --git a/tests/tapl/com/android/launcher3/tapl/LauncherInstrumentation.java b/tests/tapl/com/android/launcher3/tapl/LauncherInstrumentation.java
index 0db6eb7..91ef472 100644
--- a/tests/tapl/com/android/launcher3/tapl/LauncherInstrumentation.java
+++ b/tests/tapl/com/android/launcher3/tapl/LauncherInstrumentation.java
@@ -1121,30 +1121,34 @@
      */
     public void pressBack() {
         try (Closable e = eventsCheck(); Closable c = addContextLayer("want to press back")) {
-            waitForLauncherInitialized();
-            final boolean launcherVisible =
-                    isTablet() ? isLauncherContainerVisible() : isLauncherVisible();
-            boolean isThreeFingerTrackpadGesture =
-                    mTrackpadGestureType == TrackpadGestureType.THREE_FINGER;
-            if (getNavigationModel() == NavigationModel.ZERO_BUTTON
-                    || isThreeFingerTrackpadGesture) {
-                final Point displaySize = getRealDisplaySize();
-                // TODO(b/225505986): change startY and endY back to displaySize.y / 2 once the
-                //  issue is solved.
-                int startX = isThreeFingerTrackpadGesture ? displaySize.x / 4 : 0;
-                int endX = isThreeFingerTrackpadGesture ? displaySize.x * 3 / 4 : displaySize.x / 2;
-                linearGesture(startX, displaySize.y / 4, endX, displaySize.y / 4,
-                        10, false, GestureScope.DONT_EXPECT_PILFER);
+            pressBackImpl();
+        }
+    }
+
+    void pressBackImpl() {
+        waitForLauncherInitialized();
+        final boolean launcherVisible =
+                isTablet() ? isLauncherContainerVisible() : isLauncherVisible();
+        boolean isThreeFingerTrackpadGesture =
+                mTrackpadGestureType == TrackpadGestureType.THREE_FINGER;
+        if (getNavigationModel() == NavigationModel.ZERO_BUTTON
+                || isThreeFingerTrackpadGesture) {
+            final Point displaySize = getRealDisplaySize();
+            // TODO(b/225505986): change startY and endY back to displaySize.y / 2 once the
+            //  issue is solved.
+            int startX = isThreeFingerTrackpadGesture ? displaySize.x / 4 : 0;
+            int endX = isThreeFingerTrackpadGesture ? displaySize.x * 3 / 4 : displaySize.x / 2;
+            linearGesture(startX, displaySize.y / 4, endX, displaySize.y / 4,
+                    10, false, GestureScope.DONT_EXPECT_PILFER);
+        } else {
+            waitForNavigationUiObject("back").click();
+        }
+        if (launcherVisible) {
+            if (getContext().getApplicationInfo().isOnBackInvokedCallbackEnabled()) {
+                expectEvent(TestProtocol.SEQUENCE_MAIN, EVENT_ON_BACK_INVOKED);
             } else {
-                waitForNavigationUiObject("back").click();
-            }
-            if (launcherVisible) {
-                if (getContext().getApplicationInfo().isOnBackInvokedCallbackEnabled()) {
-                    expectEvent(TestProtocol.SEQUENCE_MAIN, EVENT_ON_BACK_INVOKED);
-                } else {
-                    expectEvent(TestProtocol.SEQUENCE_MAIN, EVENT_KEY_BACK_DOWN);
-                    expectEvent(TestProtocol.SEQUENCE_MAIN, EVENT_KEY_BACK_UP);
-                }
+                expectEvent(TestProtocol.SEQUENCE_MAIN, EVENT_KEY_BACK_DOWN);
+                expectEvent(TestProtocol.SEQUENCE_MAIN, EVENT_KEY_BACK_UP);
             }
         }
     }