Merge "Ensure the reorder test runs on the foldable devices" into udc-qpr-dev
diff --git a/quickstep/res/values-da/strings.xml b/quickstep/res/values-da/strings.xml
index d0fed79..1603fac 100644
--- a/quickstep/res/values-da/strings.xml
+++ b/quickstep/res/values-da/strings.xml
@@ -72,7 +72,7 @@
     <string name="overview_gesture_feedback_home_detected" msgid="663432226180397138">"Prøv at holde fingeren nede på vinduet i længere tid, inden du løfter den"</string>
     <string name="overview_gesture_feedback_wrong_swipe_direction" msgid="1191055451018584958">"Stryg lige opad, og hold derefter fingeren stille"</string>
     <string name="overview_gesture_feedback_complete_with_follow_up" msgid="3544611727467765026">"Du har lært, hvordan du bruger bevægelser. Du kan aktivere bevægelser i Indstillinger."</string>
-    <string name="overview_gesture_feedback_complete_without_follow_up" msgid="2903050864432331629">"Du har fuldført bevægelsen for Skift app"</string>
+    <string name="overview_gesture_feedback_complete_without_follow_up" msgid="2903050864432331629">"Du har fuldført bevægelsen for at skifte mellem apps"</string>
     <string name="overview_gesture_intro_title" msgid="2902054412868489378">"Stryg for at skifte app"</string>
     <string name="overview_gesture_intro_subtitle" msgid="4968091015637850859">"Skift mellem apps ved at stryge opad fra bunden af skærmen, holde fingeren stille og løfte den."</string>
     <string name="overview_gesture_spoken_intro_subtitle" msgid="3853371838260201751">"Skift mellem apps ved at stryge opad fra bunden af skærmen med 2 fingre, holde dem nede og slippe."</string>
diff --git a/quickstep/res/values-en-rAU/strings.xml b/quickstep/res/values-en-rAU/strings.xml
index dde0177..878c062 100644
--- a/quickstep/res/values-en-rAU/strings.xml
+++ b/quickstep/res/values-en-rAU/strings.xml
@@ -70,7 +70,7 @@
     <string name="home_gesture_tutorial_success" msgid="1736295017642244751">"Great work!"</string>
     <string name="overview_gesture_feedback_swipe_too_far_from_edge" msgid="6402349235265407385">"Make sure you swipe up from the bottom edge of the screen"</string>
     <string name="overview_gesture_feedback_home_detected" msgid="663432226180397138">"Try holding the window for longer before releasing"</string>
-    <string name="overview_gesture_feedback_wrong_swipe_direction" msgid="1191055451018584958">"Make sure that you swipe straight up, then pause."</string>
+    <string name="overview_gesture_feedback_wrong_swipe_direction" msgid="1191055451018584958">"Make sure that you swipe straight up, then pause"</string>
     <string name="overview_gesture_feedback_complete_with_follow_up" msgid="3544611727467765026">"You\'ve learned how to use gestures. To turn off gestures, go to Settings."</string>
     <string name="overview_gesture_feedback_complete_without_follow_up" msgid="2903050864432331629">"You completed the switch apps gesture"</string>
     <string name="overview_gesture_intro_title" msgid="2902054412868489378">"Swipe to switch apps"</string>
diff --git a/quickstep/res/values-en-rGB/strings.xml b/quickstep/res/values-en-rGB/strings.xml
index dde0177..878c062 100644
--- a/quickstep/res/values-en-rGB/strings.xml
+++ b/quickstep/res/values-en-rGB/strings.xml
@@ -70,7 +70,7 @@
     <string name="home_gesture_tutorial_success" msgid="1736295017642244751">"Great work!"</string>
     <string name="overview_gesture_feedback_swipe_too_far_from_edge" msgid="6402349235265407385">"Make sure you swipe up from the bottom edge of the screen"</string>
     <string name="overview_gesture_feedback_home_detected" msgid="663432226180397138">"Try holding the window for longer before releasing"</string>
-    <string name="overview_gesture_feedback_wrong_swipe_direction" msgid="1191055451018584958">"Make sure that you swipe straight up, then pause."</string>
+    <string name="overview_gesture_feedback_wrong_swipe_direction" msgid="1191055451018584958">"Make sure that you swipe straight up, then pause"</string>
     <string name="overview_gesture_feedback_complete_with_follow_up" msgid="3544611727467765026">"You\'ve learned how to use gestures. To turn off gestures, go to Settings."</string>
     <string name="overview_gesture_feedback_complete_without_follow_up" msgid="2903050864432331629">"You completed the switch apps gesture"</string>
     <string name="overview_gesture_intro_title" msgid="2902054412868489378">"Swipe to switch apps"</string>
diff --git a/quickstep/res/values-en-rIN/strings.xml b/quickstep/res/values-en-rIN/strings.xml
index dde0177..878c062 100644
--- a/quickstep/res/values-en-rIN/strings.xml
+++ b/quickstep/res/values-en-rIN/strings.xml
@@ -70,7 +70,7 @@
     <string name="home_gesture_tutorial_success" msgid="1736295017642244751">"Great work!"</string>
     <string name="overview_gesture_feedback_swipe_too_far_from_edge" msgid="6402349235265407385">"Make sure you swipe up from the bottom edge of the screen"</string>
     <string name="overview_gesture_feedback_home_detected" msgid="663432226180397138">"Try holding the window for longer before releasing"</string>
-    <string name="overview_gesture_feedback_wrong_swipe_direction" msgid="1191055451018584958">"Make sure that you swipe straight up, then pause."</string>
+    <string name="overview_gesture_feedback_wrong_swipe_direction" msgid="1191055451018584958">"Make sure that you swipe straight up, then pause"</string>
     <string name="overview_gesture_feedback_complete_with_follow_up" msgid="3544611727467765026">"You\'ve learned how to use gestures. To turn off gestures, go to Settings."</string>
     <string name="overview_gesture_feedback_complete_without_follow_up" msgid="2903050864432331629">"You completed the switch apps gesture"</string>
     <string name="overview_gesture_intro_title" msgid="2902054412868489378">"Swipe to switch apps"</string>
diff --git a/quickstep/res/values-sw720dp/dimens.xml b/quickstep/res/values-sw720dp/dimens.xml
index 9e832bc..1caffb8 100644
--- a/quickstep/res/values-sw720dp/dimens.xml
+++ b/quickstep/res/values-sw720dp/dimens.xml
@@ -43,4 +43,7 @@
     <dimen name="taskbar_app_window_threshold">100dp</dimen>
     <dimen name="taskbar_home_overview_threshold">180dp</dimen>
     <dimen name="taskbar_catch_up_threshold">300dp</dimen>
+
+    <!-- Taskbar swipe up threshold multipliers -->
+    <item name="taskbar_nav_threshold_mult" format="float" type="dimen">3</item>
 </resources>
diff --git a/quickstep/res/values/dimens.xml b/quickstep/res/values/dimens.xml
index 902e0c3..6d2fca6 100644
--- a/quickstep/res/values/dimens.xml
+++ b/quickstep/res/values/dimens.xml
@@ -330,6 +330,12 @@
     <!-- Taskbar swipe down threshold -->
     <dimen name="taskbar_to_nav_threshold">24dp</dimen>
 
+    <!-- Taskbar swipe up threshold multipliers -->
+    <item name="taskbar_nav_threshold_mult" format="float" type="dimen">4.5</item>
+    <item name="taskbar_app_window_threshold_mult" format="float" type="dimen">10</item>
+    <item name="taskbar_home_overview_threshold_mult" format="float" type="dimen">18</item>
+    <item name="taskbar_catch_up_threshold_mult" format="float" type="dimen">30</item>
+
     <!--  Taskbar 3 button spacing  -->
     <dimen name="taskbar_button_space_inbetween">24dp</dimen>
     <dimen name="taskbar_button_space_inbetween_phone">40dp</dimen>
diff --git a/quickstep/src/com/android/launcher3/statehandlers/DepthController.java b/quickstep/src/com/android/launcher3/statehandlers/DepthController.java
index d379d6d..882682d 100644
--- a/quickstep/src/com/android/launcher3/statehandlers/DepthController.java
+++ b/quickstep/src/com/android/launcher3/statehandlers/DepthController.java
@@ -193,5 +193,6 @@
         writer.println(prefix + "\tmIgnoreStateChangesDuringMultiWindowAnimation="
                 + mIgnoreStateChangesDuringMultiWindowAnimation);
         writer.println(prefix + "\tmPauseBlurs=" + mPauseBlurs);
+        writer.println(prefix + "\tmWaitingOnSurfaceValidity=" + mWaitingOnSurfaceValidity);
     }
 }
diff --git a/quickstep/src/com/android/launcher3/statehandlers/DesktopVisibilityController.java b/quickstep/src/com/android/launcher3/statehandlers/DesktopVisibilityController.java
index 7283a18..ecf483c 100644
--- a/quickstep/src/com/android/launcher3/statehandlers/DesktopVisibilityController.java
+++ b/quickstep/src/com/android/launcher3/statehandlers/DesktopVisibilityController.java
@@ -27,6 +27,7 @@
 import com.android.launcher3.LauncherState;
 import com.android.launcher3.statemanager.StatefulActivity;
 import com.android.launcher3.uioverrides.QuickstepLauncher;
+import com.android.quickstep.GestureState;
 import com.android.quickstep.SystemUiProxy;
 import com.android.quickstep.views.DesktopAppSelectView;
 import com.android.wm.shell.desktopmode.IDesktopTaskListener;
@@ -39,7 +40,8 @@
 
     private static final String TAG = "DesktopVisController";
     private static final boolean DEBUG = false;
-
+    private static final boolean IS_STASHING_ENABLED = SystemProperties.getBoolean(
+            "persist.wm.debug.desktop_stashing", false);
     private final Launcher mLauncher;
 
     private boolean mFreeformTasksVisible;
@@ -73,6 +75,9 @@
 
             @Override
             public void onStashedChanged(int displayId, boolean stashed) {
+                if (!IS_STASHING_ENABLED) {
+                    return;
+                }
                 MAIN_EXECUTOR.execute(() -> {
                     if (displayId == mLauncher.getDisplayId()) {
                         if (DEBUG) {
@@ -166,20 +171,40 @@
     /**
      * Whether recents gesture is currently in progress.
      */
-    public boolean isGestureInProgress() {
+    public boolean isRecentsGestureInProgress() {
         return mGestureInProgress;
     }
 
     /**
-     * Sets whether recents gesture is in progress.
+     * Notify controller that recents gesture has started.
      */
-    public void setGestureInProgress(boolean gestureInProgress) {
-        if (DEBUG) {
-            Log.d(TAG, "setGestureInProgress: inProgress=" + gestureInProgress);
-        }
+    public void setRecentsGestureStart() {
         if (!isDesktopModeSupported()) {
             return;
         }
+        setRecentsGestureInProgress(true);
+    }
+
+    /**
+     * Notify controller that recents gesture finished with the given
+     * {@link com.android.quickstep.GestureState.GestureEndTarget}
+     */
+    public void setRecentsGestureEnd(@Nullable GestureState.GestureEndTarget endTarget) {
+        if (!isDesktopModeSupported()) {
+            return;
+        }
+        setRecentsGestureInProgress(false);
+
+        if (endTarget == null) {
+            // Gesture did not result in a new end target. Ensure launchers gets paused again.
+            markLauncherPaused();
+        }
+    }
+
+    private void setRecentsGestureInProgress(boolean gestureInProgress) {
+        if (DEBUG) {
+            Log.d(TAG, "setGestureInProgress: inProgress=" + gestureInProgress);
+        }
         if (gestureInProgress != mGestureInProgress) {
             mGestureInProgress = gestureInProgress;
         }
@@ -189,7 +214,7 @@
      * Handle launcher moving to home due to home gesture or home button press.
      */
     public void onHomeActionTriggered() {
-        if (areFreeformTasksVisible()) {
+        if (IS_STASHING_ENABLED && areFreeformTasksVisible()) {
             SystemUiProxy.INSTANCE.get(mLauncher).stashDesktopApps(mLauncher.getDisplayId());
         }
     }
diff --git a/quickstep/src/com/android/launcher3/taskbar/NavbarButtonsViewController.java b/quickstep/src/com/android/launcher3/taskbar/NavbarButtonsViewController.java
index 1705f11..fcd8c80 100644
--- a/quickstep/src/com/android/launcher3/taskbar/NavbarButtonsViewController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/NavbarButtonsViewController.java
@@ -736,6 +736,7 @@
                             dp, mNavButtonsView, res, isInKidsMode, isInSetup, isThreeButtonNav,
                             TaskbarManager.isPhoneMode(dp), mDisplayController.getInfo().rotation);
             navButtonLayoutter.layoutButtons(dp, isContextualButtonShowing());
+            updateNavButtonColor();
             return;
         }
 
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java
index cb9c329..b58ad38 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java
@@ -119,6 +119,7 @@
 import com.android.systemui.unfold.util.ScopedUnfoldTransitionProgressProvider;
 
 import java.io.PrintWriter;
+import java.util.Collections;
 import java.util.Optional;
 
 /**
@@ -146,7 +147,7 @@
     private int mLastRequestedNonFullscreenHeight;
 
     private NavigationMode mNavMode;
-    private final boolean mImeDrawsImeNavBar;
+    private boolean mImeDrawsImeNavBar;
     private final ViewCache mViewCache = new ViewCache();
 
     private final boolean mIsSafeModeEnabled;
@@ -299,6 +300,7 @@
 
 
     public void init(@NonNull TaskbarSharedState sharedState) {
+        mImeDrawsImeNavBar = getBoolByName(IME_DRAWS_IME_NAV_BAR_RES_NAME, getResources(), false);
         mLastRequestedNonFullscreenHeight = getDefaultTaskbarWindowHeight();
         mWindowLayoutParams = createAllWindowParams();
 
@@ -996,9 +998,10 @@
         if (recents == null) {
             return;
         }
-        recents.getSplitSelectController().findLastActiveTaskAndRunCallback(
-                info.getComponentKey(),
-                foundTask -> {
+        recents.getSplitSelectController().findLastActiveTasksAndRunCallback(
+                Collections.singletonList(info.getComponentKey()),
+                foundTasks -> {
+                    @Nullable Task foundTask = foundTasks.get(0);
                     if (foundTask != null) {
                         TaskView foundTaskView =
                                 recents.getTaskViewByTaskId(foundTask.key.id);
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarHoverToolTipController.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarHoverToolTipController.java
index 363f915..c3ec1e5 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarHoverToolTipController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarHoverToolTipController.java
@@ -35,6 +35,8 @@
 import android.view.MotionEvent;
 import android.view.View;
 
+import androidx.annotation.VisibleForTesting;
+
 import com.android.app.animation.Interpolators;
 import com.android.launcher3.AbstractFloatingView;
 import com.android.launcher3.BubbleTextView;
@@ -49,7 +51,7 @@
  */
 public class TaskbarHoverToolTipController implements View.OnHoverListener {
 
-    private static final int HOVER_TOOL_TIP_REVEAL_START_DELAY = 400;
+    @VisibleForTesting protected static final int HOVER_TOOL_TIP_REVEAL_START_DELAY = 400;
     private static final int HOVER_TOOL_TIP_REVEAL_DURATION = 300;
     private static final int HOVER_TOOL_TIP_EXIT_DURATION = 150;
 
@@ -145,7 +147,6 @@
     }
 
     private void startRevealHoverToolTip() {
-        mActivity.setTaskbarWindowFullscreen(true);
         mHoverToolTipHandler.postDelayed(mRevealHoverToolTipRunnable,
                 HOVER_TOOL_TIP_REVEAL_START_DELAY);
     }
@@ -157,6 +158,7 @@
         if (mHoverView instanceof FolderIcon && !((FolderIcon) mHoverView).getIconVisible()) {
             return;
         }
+        mActivity.setTaskbarWindowFullscreen(true);
         Rect iconViewBounds = Utilities.getViewBounds(mHoverView);
         mHoverToolTipView.showAtLocation(mToolTipText, iconViewBounds.centerX(),
                 mTaskbarView.getTop(), /* shouldAutoClose= */ false);
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarInsetsController.kt b/quickstep/src/com/android/launcher3/taskbar/TaskbarInsetsController.kt
index 68ea475..a935bac 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarInsetsController.kt
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarInsetsController.kt
@@ -94,26 +94,12 @@
             } else {
                 0
             }
-        if (context.isGestureNav) {
-            windowLayoutParams.providedInsets =
-                arrayOf(
-                    InsetsFrameProvider(insetsOwner, 0, navigationBars())
-                        .setFlags(
-                            FLAG_SUPPRESS_SCRIM or insetsRoundedCornerFlag,
-                            FLAG_SUPPRESS_SCRIM or FLAG_INSETS_ROUNDED_CORNER
-                        ),
-                    InsetsFrameProvider(insetsOwner, 0, tappableElement()),
-                    InsetsFrameProvider(insetsOwner, 0, mandatorySystemGestures()),
-                    InsetsFrameProvider(insetsOwner, INDEX_LEFT, systemGestures())
-                        .setSource(SOURCE_DISPLAY),
-                    InsetsFrameProvider(insetsOwner, INDEX_RIGHT, systemGestures())
-                        .setSource(SOURCE_DISPLAY)
-                )
-        } else {
-            windowLayoutParams.providedInsets = getButtonNavInsets(insetsRoundedCornerFlag)
+
+        windowLayoutParams.providedInsets = getProvidedInsets(insetsRoundedCornerFlag)
+        if (!context.isGestureNav) {
             if (windowLayoutParams.paramsForRotation != null) {
                 for (layoutParams in windowLayoutParams.paramsForRotation) {
-                    layoutParams.providedInsets = getButtonNavInsets(insetsRoundedCornerFlag)
+                    layoutParams.providedInsets = getProvidedInsets(insetsRoundedCornerFlag)
                 }
             }
         }
@@ -165,15 +151,28 @@
         context.notifyUpdateLayoutParams()
     }
 
-    private fun getButtonNavInsets(insetsRoundedCornerFlag: Int): Array<InsetsFrameProvider> {
+    /**
+     * The inset types and number of insets provided have to match for both gesture nav and button
+     * nav. The values and the order of the elements in array are allowed to differ.
+     * Reason being WM does not allow types and number of insets changing for a given window once it
+     * is added into the hierarchy for performance reasons.
+     */
+    private fun getProvidedInsets(insetsRoundedCornerFlag: Int): Array<InsetsFrameProvider> {
+        val navBarsFlag =
+                (if (context.isGestureNav) FLAG_SUPPRESS_SCRIM else 0) or insetsRoundedCornerFlag
         return arrayOf(
-                    InsetsFrameProvider(insetsOwner, 0, navigationBars())
+                InsetsFrameProvider(insetsOwner, 0, navigationBars())
                         .setFlags(
-                            insetsRoundedCornerFlag,
-                            (FLAG_SUPPRESS_SCRIM or FLAG_INSETS_ROUNDED_CORNER)
+                                navBarsFlag,
+                                FLAG_SUPPRESS_SCRIM or FLAG_INSETS_ROUNDED_CORNER
                         ),
-                    InsetsFrameProvider(insetsOwner, 0, tappableElement()),
-                    InsetsFrameProvider(insetsOwner, 0, mandatorySystemGestures()))
+                InsetsFrameProvider(insetsOwner, 0, tappableElement()),
+                InsetsFrameProvider(insetsOwner, 0, mandatorySystemGestures()),
+                InsetsFrameProvider(insetsOwner, INDEX_LEFT, systemGestures())
+                        .setSource(SOURCE_DISPLAY),
+                InsetsFrameProvider(insetsOwner, INDEX_RIGHT, systemGestures())
+                        .setSource(SOURCE_DISPLAY)
+        )
     }
 
     private fun setProviderInsets(provider: InsetsFrameProvider, gravity: Int) {
@@ -181,47 +180,45 @@
         val tappableHeight = controllers.taskbarStashController.tappableHeightToReportToApps
         val res = context.resources
         if (provider.type == navigationBars() || provider.type == mandatorySystemGestures()) {
-            provider.insetsSize = getInsetsByNavMode(contentHeight, gravity)
+            provider.insetsSize = getInsetsForGravity(contentHeight, gravity)
         } else if (provider.type == tappableElement()) {
-            provider.insetsSize = getInsetsByNavMode(tappableHeight, gravity)
+            provider.insetsSize = getInsetsForGravity(tappableHeight, gravity)
         } else if (provider.type == systemGestures() && provider.index == INDEX_LEFT) {
-            provider.insetsSize =
-                    Insets.of(
-                            gestureNavSettingsObserver.getLeftSensitivityForCallingUser(res),
-                            0,
-                            0,
-                            0
-                    )
+            val leftIndexInset =
+                    if (context.isThreeButtonNav) 0
+                    else gestureNavSettingsObserver.getLeftSensitivityForCallingUser(res)
+            provider.insetsSize = Insets.of(leftIndexInset, 0, 0, 0)
         } else if (provider.type == systemGestures() && provider.index == INDEX_RIGHT) {
-            provider.insetsSize =
-                    Insets.of(
-                            0,
-                            0,
-                            gestureNavSettingsObserver.getRightSensitivityForCallingUser(res),
-                            0
-                    )
+            val rightIndexInset =
+                    if (context.isThreeButtonNav) 0
+                    else gestureNavSettingsObserver.getRightSensitivityForCallingUser(res)
+            provider.insetsSize = Insets.of(0, 0, rightIndexInset, 0)
         }
 
-        val imeInsetsSize = getInsetsByNavMode(taskbarHeightForIme, gravity)
-        val insetsSizeOverride =
+
+        val imeInsetsSize = getInsetsForGravity(taskbarHeightForIme, gravity)
+        val imeInsetsSizeOverride =
                 arrayOf(
                         InsetsFrameProvider.InsetsSizeOverride(TYPE_INPUT_METHOD, imeInsetsSize),
                 )
         // Use 0 tappableElement insets for the VoiceInteractionWindow when gesture nav is enabled.
-        val visInsetsSizeForGestureNavTappableElement = getInsetsByNavMode(0, gravity)
-        val insetsSizeOverrideForGestureNavTappableElement =
+        val visInsetsSizeForTappableElement =
+                if (context.isGestureNav) getInsetsForGravity(0, gravity)
+                else getInsetsForGravity(tappableHeight, gravity)
+        val insetsSizeOverrideForTappableElement =
                 arrayOf(
                         InsetsFrameProvider.InsetsSizeOverride(TYPE_INPUT_METHOD, imeInsetsSize),
                         InsetsFrameProvider.InsetsSizeOverride(
                                 TYPE_VOICE_INTERACTION,
-                                visInsetsSizeForGestureNavTappableElement
+                                visInsetsSizeForTappableElement
                         ),
                 )
-        if (context.isGestureNav && provider.type == tappableElement()) {
-            provider.insetsSizeOverrides = insetsSizeOverrideForGestureNavTappableElement
+        if ((context.isGestureNav || TaskbarManager.FLAG_HIDE_NAVBAR_WINDOW)
+                && provider.type == tappableElement()) {
+            provider.insetsSizeOverrides = insetsSizeOverrideForTappableElement
         } else if (provider.type != systemGestures()) {
             // We only override insets at the bottom of the screen
-            provider.insetsSizeOverrides = insetsSizeOverride
+            provider.insetsSizeOverrides = imeInsetsSizeOverride
         }
     }
 
@@ -229,14 +226,14 @@
      * @return [Insets] where the [inset] is either used as a bottom inset or
      * right/left inset if using 3 button nav
      */
-    private fun getInsetsByNavMode(inset: Int, gravity: Int): Insets {
-        if ((gravity and Gravity.BOTTOM) != 0) {
+    private fun getInsetsForGravity(inset: Int, gravity: Int): Insets {
+        if ((gravity and Gravity.BOTTOM) == Gravity.BOTTOM) {
             // Taskbar or portrait phone mode
             return Insets.of(0, 0, 0, inset)
         }
 
         // TODO(b/230394142): seascape
-        val isSeascape = (gravity and Gravity.START) != 0
+        val isSeascape = (gravity and Gravity.START) == Gravity.START
         val leftInset = if (isSeascape) inset else 0
         val rightInset = if (isSeascape) 0 else inset
         return Insets.of(leftInset , 0, rightInset, 0)
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarManager.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarManager.java
index 1809d40..528cb30 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarManager.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarManager.java
@@ -40,6 +40,7 @@
 import android.net.Uri;
 import android.os.Handler;
 import android.os.SystemProperties;
+import android.os.Trace;
 import android.provider.Settings;
 import android.util.Log;
 import android.view.Display;
@@ -180,6 +181,8 @@
 
             @Override
             public void onConfigurationChanged(Configuration newConfig) {
+                Trace.instantForTrack(Trace.TRACE_TAG_APP, "TaskbarManager",
+                        "onConfigurationChanged: " + newConfig);
                 debugWhyTaskbarNotDestroyed(
                         "TaskbarManager#mComponentCallbacks.onConfigurationChanged: " + newConfig);
                 DeviceProfile dp = mUserUnlocked
@@ -347,38 +350,44 @@
      */
     @VisibleForTesting
     public void recreateTaskbar() {
-        DeviceProfile dp = mUserUnlocked ?
+        Trace.beginSection("recreateTaskbar");
+        try {
+            DeviceProfile dp = mUserUnlocked ?
                 LauncherAppState.getIDP(mContext).getDeviceProfile(mContext) : null;
 
-        destroyExistingTaskbar();
+            destroyExistingTaskbar();
 
-        boolean isTaskbarEnabled = dp != null && isTaskbarPresent(dp);
-        debugWhyTaskbarNotDestroyed("recreateTaskbar: isTaskbarEnabled=" + isTaskbarEnabled
+            boolean isTaskbarEnabled = dp != null && isTaskbarPresent(dp);
+            debugWhyTaskbarNotDestroyed("recreateTaskbar: isTaskbarEnabled=" + isTaskbarEnabled
                 + " [dp != null (i.e. mUserUnlocked)]=" + (dp != null)
                 + " FLAG_HIDE_NAVBAR_WINDOW=" + FLAG_HIDE_NAVBAR_WINDOW
                 + " dp.isTaskbarPresent=" + (dp == null ? "null" : dp.isTaskbarPresent));
-        if (!isTaskbarEnabled) {
-            SystemUiProxy.INSTANCE.get(mContext)
+            if (!isTaskbarEnabled) {
+                SystemUiProxy.INSTANCE.get(mContext)
                     .notifyTaskbarStatus(/* visible */ false, /* stashed */ false);
-            return;
-        }
+                return;
+            }
 
-        if (mTaskbarActivityContext == null) {
-            mTaskbarActivityContext = new TaskbarActivityContext(mContext, dp, mNavButtonController,
+            if (mTaskbarActivityContext == null) {
+                mTaskbarActivityContext = new TaskbarActivityContext(mContext, dp,
+                    mNavButtonController,
                     mUnfoldProgressProvider);
-        } else {
-            mTaskbarActivityContext.updateDeviceProfile(dp);
-        }
-        mTaskbarActivityContext.init(mSharedState);
+            } else {
+                mTaskbarActivityContext.updateDeviceProfile(dp);
+            }
+            mTaskbarActivityContext.init(mSharedState);
 
-        if (mActivity != null) {
-            mTaskbarActivityContext.setUIController(
+            if (mActivity != null) {
+                mTaskbarActivityContext.setUIController(
                     createTaskbarUIControllerForActivity(mActivity));
-        }
+            }
 
-        // We to wait until user unlocks the device to attach listener.
-        LauncherPrefs.get(mContext).addListener(mTaskbarPinningPreferenceChangeListener,
+            // We to wait until user unlocks the device to attach listener.
+            LauncherPrefs.get(mContext).addListener(mTaskbarPinningPreferenceChangeListener,
                 TASKBAR_PINNING);
+        } finally {
+            Trace.endSection();
+        }
     }
 
     public void onSystemUiFlagsChanged(int systemUiStateFlags) {
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarStashController.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarStashController.java
index 5e37cf4..b5b453b 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarStashController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarStashController.java
@@ -338,7 +338,6 @@
         // us that we're paused until a bit later. This avoids flickering upon recreating taskbar.
         updateStateForFlag(FLAG_IN_APP, true);
         applyState(/* duration = */ 0);
-
         notifyStashChange(/* visible */ false, /* stashed */ isStashedInApp());
     }
 
@@ -675,7 +674,10 @@
                     .setDuration(duration));
             mAnimator.play(mTaskbarImeBgAlpha.animateToValue(
                     hasAnyFlag(FLAG_STASHED_IN_APP_IME) ? 0 : 1).setDuration(duration));
-            mAnimator.addListener(AnimatorListeners.forEndCallback(() -> mAnimator = null));
+            mAnimator.addListener(AnimatorListeners.forEndCallback(() -> {
+                mAnimator = null;
+                mIsStashed = isStashed;
+            }));
             return;
         }
 
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarThresholdUtils.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarThresholdUtils.java
new file mode 100644
index 0000000..5b6fbef
--- /dev/null
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarThresholdUtils.java
@@ -0,0 +1,87 @@
+/*
+ * 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.taskbar;
+
+import static com.android.launcher3.Utilities.dpToPx;
+import static com.android.launcher3.Utilities.dpiFromPx;
+
+import android.content.res.Resources;
+import android.util.DisplayMetrics;
+
+import androidx.core.content.res.ResourcesCompat;
+
+import com.android.launcher3.DeviceProfile;
+import com.android.launcher3.R;
+import com.android.launcher3.config.FeatureFlags;
+
+/**
+ * Utility class that contains the different taskbar thresholds logic.
+ */
+public class TaskbarThresholdUtils {
+
+    // We divide the screen into this many parts, and use the result to scale the thresholds to
+    // any size device. Note that this value was calculated arbitrarily by using two tablet devices
+    // as data points.
+    private static final float SCREEN_UNITS = 1 / 80f;
+
+    private static int getThreshold(Resources r, DeviceProfile dp, int thresholdDimen,
+            int multiplierDimen) {
+        if (!FeatureFlags.ENABLE_DYNAMIC_TASKBAR_THRESHOLDS.get()) {
+            return r.getDimensionPixelSize(thresholdDimen);
+        }
+
+        float landscapeScreenHeight = dp.isLandscape ? dp.heightPx : dp.widthPx;
+        float screenPart = (landscapeScreenHeight * SCREEN_UNITS);
+        float defaultDp = dpiFromPx(screenPart, DisplayMetrics.DENSITY_DEVICE_STABLE);
+        float thisDp = dpToPx(defaultDp);
+        float multiplier = ResourcesCompat.getFloat(r, multiplierDimen);
+        float value = (thisDp) * multiplier;
+
+        return Math.round(value);
+    }
+
+    /**
+     * Returns the threshold that determines if we should show taskbar.
+     */
+    public static int getFromNavThreshold(Resources r, DeviceProfile dp) {
+        return getThreshold(r, dp, R.dimen.taskbar_from_nav_threshold,
+                R.dimen.taskbar_nav_threshold_mult);
+    }
+
+    /**
+     * Returns the threshold that we start moving the app window.
+     */
+    public static int getAppWindowThreshold(Resources r, DeviceProfile dp) {
+        return getThreshold(r, dp, R.dimen.taskbar_app_window_threshold,
+                R.dimen.taskbar_app_window_threshold_mult);
+    }
+
+    /**
+     * Returns the threshold for whether we land in home or overview.
+     */
+    public static int getHomeOverviewThreshold(Resources r, DeviceProfile dp) {
+        return getThreshold(r, dp, R.dimen.taskbar_home_overview_threshold,
+                R.dimen.taskbar_home_overview_threshold_mult);
+    }
+
+    /**
+     * Returns the threshold that we use to allow swipe to catch up to finger.
+     */
+    public static int getCatchUpThreshold(Resources r, DeviceProfile dp) {
+        return getThreshold(r, dp, R.dimen.taskbar_catch_up_threshold,
+                R.dimen.taskbar_catch_up_threshold_mult);
+    }
+}
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarUIController.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarUIController.java
index 7154731..6fad655 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarUIController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarUIController.java
@@ -40,8 +40,11 @@
 import com.android.quickstep.views.RecentsView;
 import com.android.quickstep.views.TaskView;
 import com.android.quickstep.views.TaskView.TaskIdAttributeContainer;
+import com.android.systemui.shared.recents.model.Task;
 
 import java.io.PrintWriter;
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.stream.Stream;
 
 /**
@@ -204,9 +207,10 @@
             return;
         }
 
-        recentsView.getSplitSelectController().findLastActiveTaskAndRunCallback(
-                splitSelectSource.itemInfo.getComponentKey(),
-                foundTask -> {
+        recentsView.getSplitSelectController().findLastActiveTasksAndRunCallback(
+                Collections.singletonList(splitSelectSource.itemInfo.getComponentKey()),
+                foundTasks -> {
+                    @Nullable Task foundTask = foundTasks.get(0);
                     splitSelectSource.alreadyRunningTaskId = foundTask == null
                             ? INVALID_TASK_ID
                             : foundTask.key.id;
@@ -221,9 +225,10 @@
      */
     public void triggerSecondAppForSplit(ItemInfoWithIcon info, Intent intent, View startingView) {
         RecentsView recents = getRecentsView();
-        recents.getSplitSelectController().findLastActiveTaskAndRunCallback(
-                info.getComponentKey(),
-                foundTask -> {
+        recents.getSplitSelectController().findLastActiveTasksAndRunCallback(
+                Collections.singletonList(info.getComponentKey()),
+                foundTasks -> {
+                    @Nullable Task foundTask = foundTasks.get(0);
                     if (foundTask != null) {
                         TaskView foundTaskView = recents.getTaskViewByTaskId(foundTask.key.id);
                         // TODO (b/266482558): This additional null check is needed because there
diff --git a/quickstep/src/com/android/launcher3/taskbar/navbutton/NavButtonLayoutFactory.kt b/quickstep/src/com/android/launcher3/taskbar/navbutton/NavButtonLayoutFactory.kt
index 7db2320..5c7f2be 100644
--- a/quickstep/src/com/android/launcher3/taskbar/navbutton/NavButtonLayoutFactory.kt
+++ b/quickstep/src/com/android/launcher3/taskbar/navbutton/NavButtonLayoutFactory.kt
@@ -67,6 +67,7 @@
             val startContextualContainer =
                 navButtonsView.findViewById<ViewGroup>(ID_START_CONTEXTUAL_BUTTONS)
             val isPhoneNavMode = phoneMode && isThreeButtonNav
+            val isPhoneGestureMode = phoneMode && !isThreeButtonNav
             return when {
                 isPhoneNavMode -> {
                     if (!deviceProfile.isLandscape) {
@@ -92,6 +93,14 @@
                         )
                     }
                 }
+                isPhoneGestureMode ->{
+                    PhoneGestureLayoutter(
+                            resources,
+                            navButtonContainer,
+                            endContextualContainer,
+                            startContextualContainer
+                    )
+                }
                 deviceProfile.isTaskbarPresent -> {
                     return when {
                         isInSetup -> {
diff --git a/quickstep/src/com/android/launcher3/taskbar/navbutton/PhoneGestureLayoutter.kt b/quickstep/src/com/android/launcher3/taskbar/navbutton/PhoneGestureLayoutter.kt
new file mode 100644
index 0000000..8525c6c
--- /dev/null
+++ b/quickstep/src/com/android/launcher3/taskbar/navbutton/PhoneGestureLayoutter.kt
@@ -0,0 +1,41 @@
+/*
+ * 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.taskbar.navbutton
+
+import android.content.res.Resources
+import android.view.ViewGroup
+import android.widget.LinearLayout
+import com.android.launcher3.DeviceProfile
+
+/** Layoutter for showing gesture navigation on phone screen. No buttons here, no-op container */
+class PhoneGestureLayoutter(
+        resources: Resources,
+        navBarContainer: LinearLayout,
+        endContextualContainer: ViewGroup,
+        startContextualContainer: ViewGroup
+) :
+        AbstractNavButtonLayoutter(
+                resources,
+                navBarContainer,
+                endContextualContainer,
+                startContextualContainer
+        ) {
+
+    override fun layoutButtons(dp: DeviceProfile, isContextualButtonShowing: Boolean) {
+        // no-op
+    }
+}
diff --git a/quickstep/src/com/android/launcher3/taskbar/navbutton/PhoneLandscapeNavLayoutter.kt b/quickstep/src/com/android/launcher3/taskbar/navbutton/PhoneLandscapeNavLayoutter.kt
index 92715a7..aeec2c6 100644
--- a/quickstep/src/com/android/launcher3/taskbar/navbutton/PhoneLandscapeNavLayoutter.kt
+++ b/quickstep/src/com/android/launcher3/taskbar/navbutton/PhoneLandscapeNavLayoutter.kt
@@ -52,7 +52,6 @@
         navContainerParams.apply {
             width = taskbarDimensions.x
             height = ViewGroup.LayoutParams.MATCH_PARENT
-            gravity = Gravity.CENTER
             topMargin = endStartMargins
             bottomMargin = endStartMargins
             marginEnd = 0
@@ -65,6 +64,7 @@
         navButtonContainer.addView(backButton)
 
         navButtonContainer.layoutParams = navContainerParams
+        navButtonContainer.gravity = Gravity.CENTER_HORIZONTAL
 
         // Add the spaces in between the nav buttons
         val spaceInBetween: Int =
diff --git a/quickstep/src/com/android/launcher3/taskbar/navbutton/PhonePortraitNavLayoutter.kt b/quickstep/src/com/android/launcher3/taskbar/navbutton/PhonePortraitNavLayoutter.kt
index 7f7fda7..e97e378 100644
--- a/quickstep/src/com/android/launcher3/taskbar/navbutton/PhonePortraitNavLayoutter.kt
+++ b/quickstep/src/com/android/launcher3/taskbar/navbutton/PhonePortraitNavLayoutter.kt
@@ -46,23 +46,27 @@
             DimensionUtils.getTaskbarPhoneDimensions(dp, resources,
                     TaskbarManager.isPhoneMode(dp))
         val endStartMargins = resources.getDimensionPixelSize(R.dimen.taskbar_nav_buttons_size)
-        navContainerParams.width = taskbarDimensions.x
-        navContainerParams.height = ViewGroup.LayoutParams.MATCH_PARENT
-        navContainerParams.gravity = Gravity.CENTER_VERTICAL
 
         // Ensure order of buttons is correct
         navButtonContainer.removeAllViews()
         navButtonContainer.orientation = LinearLayout.HORIZONTAL
-        navContainerParams.topMargin = 0
-        navContainerParams.bottomMargin = 0
-        navContainerParams.marginEnd = endStartMargins
-        navContainerParams.marginStart = endStartMargins
+
+        navContainerParams.apply {
+            width = taskbarDimensions.x
+            height = ViewGroup.LayoutParams.MATCH_PARENT
+            topMargin = 0
+            bottomMargin = 0
+            marginEnd = endStartMargins
+            marginStart = endStartMargins
+        }
+
         // Swap recents and back button in case we were landscape prior to this
         navButtonContainer.addView(backButton)
         navButtonContainer.addView(homeButton)
         navButtonContainer.addView(recentsButton)
 
         navButtonContainer.layoutParams = navContainerParams
+        navButtonContainer.gravity = Gravity.CENTER_VERTICAL
 
         // Add the spaces in between the nav buttons
         val spaceInBetween =
diff --git a/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java b/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java
index 1e1bff3..45d2fb0 100644
--- a/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java
+++ b/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java
@@ -117,6 +117,7 @@
 import com.android.launcher3.model.BgDataModel.FixedContainerItems;
 import com.android.launcher3.model.WellbeingModel;
 import com.android.launcher3.model.data.ItemInfo;
+import com.android.launcher3.model.data.WorkspaceItemInfo;
 import com.android.launcher3.popup.SystemShortcut;
 import com.android.launcher3.proxy.ProxyActivityStarter;
 import com.android.launcher3.statehandlers.DepthController;
@@ -173,6 +174,7 @@
 import com.android.quickstep.views.OverviewActionsView;
 import com.android.quickstep.views.RecentsView;
 import com.android.quickstep.views.TaskView;
+import com.android.systemui.shared.recents.model.Task;
 import com.android.systemui.shared.system.ActivityManagerWrapper;
 import com.android.systemui.unfold.RemoteUnfoldSharedComponent;
 import com.android.systemui.unfold.UnfoldSharedComponent;
@@ -618,9 +620,10 @@
         RecentsView recentsView = getOverviewPanel();
         // Check if there is already an instance of this app running, if so, initiate the split
         // using that.
-        mSplitSelectStateController.findLastActiveTaskAndRunCallback(
-                splitSelectSource.itemInfo.getComponentKey(),
-                foundTask -> {
+        mSplitSelectStateController.findLastActiveTasksAndRunCallback(
+                Collections.singletonList(splitSelectSource.itemInfo.getComponentKey()),
+                foundTasks -> {
+                    @Nullable Task foundTask = foundTasks.get(0);
                     boolean taskWasFound = foundTask != null;
                     splitSelectSource.alreadyRunningTaskId = taskWasFound
                             ? foundTask.key.id
@@ -867,7 +870,7 @@
         if (DesktopTaskView.DESKTOP_MODE_SUPPORTED) {
             DesktopVisibilityController controller = mDesktopVisibilityController;
             if (controller != null && controller.areFreeformTasksVisible()
-                    && !controller.isGestureInProgress()) {
+                    && !controller.isRecentsGestureInProgress()) {
                 // Return early to skip setting activity to appear as resumed
                 // TODO(b/255649902): shouldn't be needed when we have a separate launcher state
                 //  for desktop that we can use to control other parts of launcher
@@ -1326,6 +1329,13 @@
                                 : groupTask.mSplitBounds.leftTaskPercent);
     }
 
+    /**
+     * Launches two apps as an app pair.
+     */
+    public void launchAppPair(WorkspaceItemInfo app1, WorkspaceItemInfo app2) {
+        mSplitSelectStateController.getAppPairsController().launchAppPair(app1, app2);
+    }
+
     public boolean canStartHomeSafely() {
         OverviewCommandHelper overviewCommandHelper = mTISBindHelper.getOverviewCommandHelper();
         return overviewCommandHelper == null || overviewCommandHelper.canStartHomeSafely();
diff --git a/quickstep/src/com/android/quickstep/AbsSwipeUpHandler.java b/quickstep/src/com/android/quickstep/AbsSwipeUpHandler.java
index ff757b1..796840d 100644
--- a/quickstep/src/com/android/quickstep/AbsSwipeUpHandler.java
+++ b/quickstep/src/com/android/quickstep/AbsSwipeUpHandler.java
@@ -106,6 +106,7 @@
 import com.android.launcher3.statehandlers.DesktopVisibilityController;
 import com.android.launcher3.statemanager.BaseState;
 import com.android.launcher3.statemanager.StatefulActivity;
+import com.android.launcher3.taskbar.TaskbarThresholdUtils;
 import com.android.launcher3.taskbar.TaskbarUIController;
 import com.android.launcher3.uioverrides.QuickstepLauncher;
 import com.android.launcher3.util.ActivityLifecycleCallbacksAdapter;
@@ -379,12 +380,12 @@
         mTaskbarAlreadyOpen = controller != null && !controller.isTaskbarStashed();
         mIsTaskbarAllAppsOpen = controller != null && controller.isTaskbarAllAppsOpen();
         mTaskbarAppWindowThreshold =
-                res.getDimensionPixelSize(R.dimen.taskbar_app_window_threshold);
+                TaskbarThresholdUtils.getAppWindowThreshold(res, mDp);
         boolean swipeWillNotShowTaskbar = mTaskbarAlreadyOpen || mGestureState.isTrackpadGesture();
         mTaskbarHomeOverviewThreshold = swipeWillNotShowTaskbar
                 ? 0
-                : res.getDimensionPixelSize(R.dimen.taskbar_home_overview_threshold);
-        mTaskbarCatchUpThreshold = res.getDimensionPixelSize(R.dimen.taskbar_catch_up_threshold);
+                : TaskbarThresholdUtils.getHomeOverviewThreshold(res, mDp);
+        mTaskbarCatchUpThreshold = TaskbarThresholdUtils.getCatchUpThreshold(res, mDp);
     }
 
     @Nullable
diff --git a/quickstep/src/com/android/quickstep/TaskShortcutFactory.java b/quickstep/src/com/android/quickstep/TaskShortcutFactory.java
index 56f407c..901690b 100644
--- a/quickstep/src/com/android/quickstep/TaskShortcutFactory.java
+++ b/quickstep/src/com/android/quickstep/TaskShortcutFactory.java
@@ -140,6 +140,7 @@
 
         @Override
         public void onClick(View view) {
+            dismissTaskMenuView(mTarget);
             ((RecentsView) mTarget.getOverviewPanel())
                     .getSplitSelectController().getAppPairsController().saveAppPair(mTaskView);
         }
diff --git a/quickstep/src/com/android/quickstep/inputconsumers/TaskbarUnstashInputConsumer.java b/quickstep/src/com/android/quickstep/inputconsumers/TaskbarUnstashInputConsumer.java
index 172c9e9..4b13cd1 100644
--- a/quickstep/src/com/android/quickstep/inputconsumers/TaskbarUnstashInputConsumer.java
+++ b/quickstep/src/com/android/quickstep/inputconsumers/TaskbarUnstashInputConsumer.java
@@ -37,6 +37,7 @@
 import com.android.launcher3.R;
 import com.android.launcher3.Utilities;
 import com.android.launcher3.taskbar.TaskbarActivityContext;
+import com.android.launcher3.taskbar.TaskbarThresholdUtils;
 import com.android.launcher3.taskbar.TaskbarTranslationController.TransitionCallback;
 import com.android.launcher3.taskbar.bubbles.BubbleControllers;
 import com.android.launcher3.touch.OverScroll;
@@ -94,7 +95,8 @@
 
         Resources res = context.getResources();
         mUnstashArea = res.getDimensionPixelSize(R.dimen.taskbar_unstash_input_area);
-        mTaskbarNavThreshold = res.getDimensionPixelSize(R.dimen.taskbar_from_nav_threshold);
+        mTaskbarNavThreshold = TaskbarThresholdUtils.getFromNavThreshold(res,
+                taskbarActivityContext.getDeviceProfile());
         mTaskbarNavThresholdY = taskbarActivityContext.getDeviceProfile().heightPx
                 - mTaskbarNavThreshold;
         mIsTaskbarAllAppsOpen =
diff --git a/quickstep/src/com/android/quickstep/util/AppPairsController.java b/quickstep/src/com/android/quickstep/util/AppPairsController.java
index cbde257..1a7099d 100644
--- a/quickstep/src/com/android/quickstep/util/AppPairsController.java
+++ b/quickstep/src/com/android/quickstep/util/AppPairsController.java
@@ -17,19 +17,30 @@
 
 package com.android.quickstep.util;
 
+import static com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_APP_PAIR_LAUNCH;
 import static com.android.launcher3.util.Executors.MAIN_EXECUTOR;
 import static com.android.launcher3.util.Executors.MODEL_EXECUTOR;
 
+import android.app.ActivityTaskManager;
 import android.content.Context;
+import android.content.Intent;
+
+import androidx.annotation.Nullable;
 
 import com.android.launcher3.Launcher;
 import com.android.launcher3.LauncherAppState;
 import com.android.launcher3.LauncherSettings;
 import com.android.launcher3.accessibility.LauncherAccessibilityDelegate;
 import com.android.launcher3.icons.IconCache;
+import com.android.launcher3.logging.StatsLogManager;
 import com.android.launcher3.model.data.FolderInfo;
 import com.android.launcher3.model.data.WorkspaceItemInfo;
+import com.android.launcher3.util.ComponentKey;
+import com.android.launcher3.util.SplitConfigurationOptions;
 import com.android.quickstep.views.TaskView;
+import com.android.systemui.shared.recents.model.Task;
+
+import java.util.Arrays;
 
 /**
  * Mini controller class that handles app pair interactions: saving, modifying, deleting, etc.
@@ -52,10 +63,13 @@
 
     private final Context mContext;
     private final SplitSelectStateController mSplitSelectStateController;
+    private final StatsLogManager mStatsLogManager;
     public AppPairsController(Context context,
-            SplitSelectStateController splitSelectStateController) {
+            SplitSelectStateController splitSelectStateController,
+            StatsLogManager statsLogManager) {
         mContext = context;
         mSplitSelectStateController = splitSelectStateController;
+        mStatsLogManager = statsLogManager;
     }
 
     /**
@@ -84,11 +98,51 @@
                 LauncherAccessibilityDelegate delegate =
                         Launcher.getLauncher(mContext).getAccessibilityDelegate();
                 if (delegate != null) {
-                    MAIN_EXECUTOR.execute(() -> delegate.addToWorkspace(newAppPair, true));
+                    delegate.addToWorkspace(newAppPair, true);
+                    mStatsLogManager.logger().withItemInfo(newAppPair)
+                            .log(StatsLogManager.LauncherEvent.LAUNCHER_APP_PAIR_SAVE);
                 }
             });
         });
+    }
 
+    /**
+     * Launches an app pair by searching the RecentsModel for running instances of each app, and
+     * staging either those running instances or launching the apps as new Intents.
+     */
+    public void launchAppPair(WorkspaceItemInfo app1, WorkspaceItemInfo app2) {
+        ComponentKey app1Key = new ComponentKey(app1.getTargetComponent(), app1.user);
+        ComponentKey app2Key = new ComponentKey(app2.getTargetComponent(), app2.user);
+        mSplitSelectStateController.findLastActiveTasksAndRunCallback(
+                Arrays.asList(app1Key, app2Key),
+                foundTasks -> {
+                    @Nullable Task foundTask1 = foundTasks.get(0);
+                    Intent task1Intent;
+                    int task1Id;
+                    if (foundTask1 != null) {
+                        task1Id = foundTask1.key.id;
+                        task1Intent = null;
+                    } else {
+                        task1Id = ActivityTaskManager.INVALID_TASK_ID;
+                        task1Intent = app1.intent;
+                    }
+
+                    mSplitSelectStateController.setInitialTaskSelect(task1Intent,
+                            SplitConfigurationOptions.STAGE_POSITION_TOP_OR_LEFT,
+                            app1,
+                            LAUNCHER_APP_PAIR_LAUNCH,
+                            task1Id);
+
+                    @Nullable Task foundTask2 = foundTasks.get(1);
+                    if (foundTask2 != null) {
+                        mSplitSelectStateController.setSecondTask(foundTask2);
+                    } else {
+                        mSplitSelectStateController.setSecondTask(
+                                app2.intent, app2.user);
+                    }
+
+                    mSplitSelectStateController.launchSplitTasks();
+                });
     }
 
     /**
diff --git a/quickstep/src/com/android/quickstep/util/BaseDepthController.java b/quickstep/src/com/android/quickstep/util/BaseDepthController.java
index 931e468..99f564c 100644
--- a/quickstep/src/com/android/quickstep/util/BaseDepthController.java
+++ b/quickstep/src/com/android/quickstep/util/BaseDepthController.java
@@ -18,6 +18,7 @@
 import android.app.WallpaperManager;
 import android.os.IBinder;
 import android.util.FloatProperty;
+import android.util.Log;
 import android.view.AttachedSurfaceControl;
 import android.view.SurfaceControl;
 
@@ -50,6 +51,9 @@
     private static final int DEPTH_INDEX_WIDGET = 1;
     private static final int DEPTH_INDEX_COUNT = 2;
 
+    // b/291401432
+    private static final String TAG = "BaseDepthController";
+
     protected final Launcher mLauncher;
     /** Property to set the depth for state transition. */
     public final MultiProperty stateDepth;
@@ -88,7 +92,7 @@
      */
     protected boolean mInEarlyWakeUp;
 
-    private boolean mWaitingOnSurfaceValidity;
+    protected boolean mWaitingOnSurfaceValidity;
 
     public BaseDepthController(Launcher activity) {
         mLauncher = activity;
@@ -133,9 +137,11 @@
             return;
         }
         if (mSurface == null) {
+            Log.d(TAG, "mSurface is null and mCurrentBlur is: " + mCurrentBlur);
             return;
         }
         if (!mSurface.isValid()) {
+            Log.d(TAG, "mSurface is not valid");
             mWaitingOnSurfaceValidity = true;
             onInvalidSurface();
             return;
@@ -186,6 +192,8 @@
     protected void setSurface(SurfaceControl surface) {
         if (mSurface != surface || mWaitingOnSurfaceValidity) {
             mSurface = surface;
+            Log.d(TAG, "setSurface:\n\tmWaitingOnSurfaceValidity: " + mWaitingOnSurfaceValidity
+                    + "\n\tmSurface: " + mSurface);
             applyDepthAndBlur();
         }
     }
diff --git a/quickstep/src/com/android/quickstep/util/SplitSelectStateController.java b/quickstep/src/com/android/quickstep/util/SplitSelectStateController.java
index 7ba6d42..c42b834 100644
--- a/quickstep/src/com/android/quickstep/util/SplitSelectStateController.java
+++ b/quickstep/src/com/android/quickstep/util/SplitSelectStateController.java
@@ -78,6 +78,7 @@
 
 import java.io.PrintWriter;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 import java.util.function.Consumer;
 
@@ -126,7 +127,7 @@
         mDepthController = depthController;
         mRecentTasksModel = recentsModel;
         mSplitAnimationController = new SplitAnimationController(this);
-        mAppPairsController = new AppPairsController(context, this);
+        mAppPairsController = new AppPairsController(context, this, statsLogManager);
         mSplitSelectDataHolder = new SplitSelectDataHolder(mContext);
     }
 
@@ -153,37 +154,46 @@
     }
 
     /**
-     * Pulls the list of active Tasks from RecentsModel, and finds the most recently active Task
-     * matching a given ComponentName. Then uses that Task (which could be null) with the given
-     * callback.
+     * Maps a List<ComponentKey> to List<@Nullable Task>, searching through active Tasks in
+     * RecentsModel. If found, the Task will be the most recently-interacted-with instance of that
+     * Task. Then runs the given callback on that List.
      * <p>
      * Used in various task-switching or splitscreen operations when we need to check if there is a
      * currently running Task of a certain type and use the most recent one.
      */
-    public void findLastActiveTaskAndRunCallback(
-            @Nullable ComponentKey componentKey, Consumer<Task> callback) {
+    public void findLastActiveTasksAndRunCallback(
+            @Nullable List<ComponentKey> componentKeys, Consumer<List<Task>> callback) {
         mRecentTasksModel.getTasks(taskGroups -> {
-            if (componentKey == null) {
-                callback.accept(null);
+            if (componentKeys == null || componentKeys.isEmpty()) {
+                callback.accept(Collections.emptyList());
                 return;
             }
-            Task lastActiveTask = null;
-            // Loop through tasks in reverse, since they are ordered with most-recent tasks last.
-            for (int i = taskGroups.size() - 1; i >= 0; i--) {
-                GroupTask groupTask = taskGroups.get(i);
-                Task task1 = groupTask.task1;
-                if (isInstanceOfComponent(task1, componentKey)) {
-                    lastActiveTask = task1;
-                    break;
+
+            List<Task> lastActiveTasks = new ArrayList<>();
+            // For each key we are looking for, add to lastActiveTasks with the corresponding Task
+            // (or null if not found).
+            for (ComponentKey key : componentKeys) {
+                Task lastActiveTask = null;
+                // Loop through tasks in reverse, since they are ordered with most-recent tasks last
+                for (int i = taskGroups.size() - 1; i >= 0; i--) {
+                    GroupTask groupTask = taskGroups.get(i);
+                    Task task1 = groupTask.task1;
+                    // Don't add duplicate Tasks
+                    if (isInstanceOfComponent(task1, key) && !lastActiveTasks.contains(task1)) {
+                        lastActiveTask = task1;
+                        break;
+                    }
+                    Task task2 = groupTask.task2;
+                    if (isInstanceOfComponent(task2, key) && !lastActiveTasks.contains(task2)) {
+                        lastActiveTask = task2;
+                        break;
+                    }
                 }
-                Task task2 = groupTask.task2;
-                if (isInstanceOfComponent(task2, componentKey)) {
-                    lastActiveTask = task2;
-                    break;
-                }
+
+                lastActiveTasks.add(lastActiveTask);
             }
 
-            callback.accept(lastActiveTask);
+            callback.accept(lastActiveTasks);
         });
     }
 
@@ -226,7 +236,7 @@
      * To be called when the both split tasks are ready to be launched. Call after launcher side
      * animations are complete.
      */
-    public void launchSplitTasks(Consumer<Boolean> callback) {
+    public void launchSplitTasks(@Nullable Consumer<Boolean> callback) {
         Pair<InstanceId, com.android.launcher3.logging.InstanceId> instanceIds =
                 LogUtils.getShellShareableInstanceId();
         launchTasks(callback, false /* freezeTaskList */, DEFAULT_SPLIT_RATIO,
@@ -239,6 +249,14 @@
     }
 
     /**
+     * A version of {@link #launchTasks(Consumer, boolean, float, InstanceId)} with no success
+     * callback.
+     */
+    public void launchSplitTasks() {
+        launchSplitTasks(null);
+    }
+
+    /**
      * To be called as soon as user selects the second task (even if animations aren't complete)
      * @param task The second task that will be launched.
      */
@@ -271,8 +289,8 @@
      *                   create a split instance, null for cases that bring existing instaces to the
      *                   foreground (quickswitch, launching previous pairs from overview)
      */
-    public void launchTasks(Consumer<Boolean> callback, boolean freezeTaskList, float splitRatio,
-            @Nullable InstanceId shellInstanceId) {
+    public void launchTasks(@Nullable Consumer<Boolean> callback, boolean freezeTaskList,
+            float splitRatio, @Nullable InstanceId shellInstanceId) {
         TestLogging.recordEvent(
                 TestProtocol.SEQUENCE_MAIN, "launchSplitTasks");
         final ActivityOptions options1 = ActivityOptions.makeBasic();
@@ -457,7 +475,7 @@
     }
 
     private RemoteTransition getShellRemoteTransition(int firstTaskId, int secondTaskId,
-            Consumer<Boolean> callback, String transitionName) {
+            @Nullable Consumer<Boolean> callback, String transitionName) {
         final RemoteSplitLaunchTransitionRunner animationRunner =
                 new RemoteSplitLaunchTransitionRunner(firstTaskId, secondTaskId, callback);
         return new RemoteTransition(animationRunner,
@@ -465,7 +483,7 @@
     }
 
     private RemoteAnimationAdapter getLegacyRemoteAdapter(int firstTaskId, int secondTaskId,
-            Consumer<Boolean> callback) {
+            @Nullable Consumer<Boolean> callback) {
         final RemoteSplitLaunchAnimationRunner animationRunner =
                 new RemoteSplitLaunchAnimationRunner(firstTaskId, secondTaskId, callback);
         return new RemoteAnimationAdapter(animationRunner, 300, 150,
@@ -514,7 +532,7 @@
         private final Consumer<Boolean> mSuccessCallback;
 
         RemoteSplitLaunchTransitionRunner(int initialTaskId, int secondTaskId,
-                Consumer<Boolean> callback) {
+                @Nullable Consumer<Boolean> callback) {
             mInitialTaskId = initialTaskId;
             mSecondTaskId = secondTaskId;
             mSuccessCallback = callback;
@@ -563,7 +581,7 @@
         private final Consumer<Boolean> mSuccessCallback;
 
         RemoteSplitLaunchAnimationRunner(int initialTaskId, int secondTaskId,
-                Consumer<Boolean> successCallback) {
+                @Nullable Consumer<Boolean> successCallback) {
             mInitialTaskId = initialTaskId;
             mSecondTaskId = secondTaskId;
             mSuccessCallback = successCallback;
diff --git a/quickstep/src/com/android/quickstep/views/DesktopTaskView.java b/quickstep/src/com/android/quickstep/views/DesktopTaskView.java
index 1cfaf14..83e9945 100644
--- a/quickstep/src/com/android/quickstep/views/DesktopTaskView.java
+++ b/quickstep/src/com/android/quickstep/views/DesktopTaskView.java
@@ -51,6 +51,7 @@
 import com.android.quickstep.util.RecentsOrientedState;
 import com.android.systemui.shared.recents.model.Task;
 import com.android.systemui.shared.recents.model.ThumbnailData;
+import com.android.systemui.shared.system.QuickStepContract;
 
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -79,7 +80,7 @@
 
     private static final String TAG = DesktopTaskView.class.getSimpleName();
 
-    private static final boolean DEBUG = true;
+    private static final boolean DEBUG = false;
 
     @NonNull
     private List<Task> mTasks = new ArrayList<>();
@@ -91,6 +92,8 @@
 
     private final ArrayList<CancellableTask<?>> mPendingThumbnailRequests = new ArrayList<>();
 
+    private final TaskView.FullscreenDrawParams mSnapshotDrawParams;
+
     private View mBackgroundView;
 
     public DesktopTaskView(Context context) {
@@ -103,6 +106,10 @@
 
     public DesktopTaskView(Context context, AttributeSet attrs, int defStyleAttr) {
         super(context, attrs, defStyleAttr);
+
+        mSnapshotDrawParams = new FullscreenDrawParams(
+                QuickStepContract.getWindowCornerRadius(context),
+                QuickStepContract.getWindowCornerRadius(context));
     }
 
     @Override
@@ -465,14 +472,20 @@
         for (int i = 0; i < mSnapshotViewMap.size(); i++) {
             TaskThumbnailView thumbnailView = mSnapshotViewMap.valueAt(i);
             thumbnailView.getTaskOverlay().setFullscreenProgress(progress);
-            updateSnapshotRadius();
         }
+        updateSnapshotRadius();
     }
 
     @Override
     protected void updateSnapshotRadius() {
+        super.updateSnapshotRadius();
         for (int i = 0; i < mSnapshotViewMap.size(); i++) {
-            mSnapshotViewMap.valueAt(i).setFullscreenParams(mCurrentFullscreenParams);
+            if (i == 0) {
+                // All snapshots share the same params. Only update it with the first snapshot.
+                updateFullscreenParams(mSnapshotDrawParams,
+                        mSnapshotView.getPreviewPositionHelper());
+            }
+            mSnapshotViewMap.valueAt(i).setFullscreenParams(mSnapshotDrawParams);
         }
     }
 
diff --git a/quickstep/src/com/android/quickstep/views/DigitalWellBeingToast.java b/quickstep/src/com/android/quickstep/views/DigitalWellBeingToast.java
index 7bbe36a..e5a0e10 100644
--- a/quickstep/src/com/android/quickstep/views/DigitalWellBeingToast.java
+++ b/quickstep/src/com/android/quickstep/views/DigitalWellBeingToast.java
@@ -145,23 +145,29 @@
         mAppUsageLimitTimeMs = mAppRemainingTimeMs = -1;
         mTask = task;
         THREAD_POOL_EXECUTOR.execute(() -> {
-            final AppUsageLimit usageLimit = mLauncherApps.getAppUsageLimit(
-                    mTask.getTopComponent().getPackageName(),
-                    UserHandle.of(mTask.key.userId));
+                    AppUsageLimit usageLimit = null;
+                    try {
+                        usageLimit = mLauncherApps.getAppUsageLimit(
+                                mTask.getTopComponent().getPackageName(),
+                                UserHandle.of(mTask.key.userId));
+                    } catch (Exception e) {
+                        Log.e(TAG, "Error initializing digital well being toast", e);
+                    }
+                    final long appUsageLimitTimeMs =
+                            usageLimit != null ? usageLimit.getTotalUsageLimit() : -1;
+                    final long appRemainingTimeMs =
+                            usageLimit != null ? usageLimit.getUsageRemaining() : -1;
 
-            final long appUsageLimitTimeMs =
-                    usageLimit != null ? usageLimit.getTotalUsageLimit() : -1;
-            final long appRemainingTimeMs =
-                    usageLimit != null ? usageLimit.getUsageRemaining() : -1;
+                    mTaskView.post(() -> {
+                        if (appUsageLimitTimeMs < 0 || appRemainingTimeMs < 0) {
+                            setNoLimit();
+                        } else {
+                            setLimit(appUsageLimitTimeMs, appRemainingTimeMs);
+                        }
+                    });
 
-            mTaskView.post(() -> {
-                if (appUsageLimitTimeMs < 0 || appRemainingTimeMs < 0) {
-                    setNoLimit();
-                } else {
-                    setLimit(appUsageLimitTimeMs, appRemainingTimeMs);
                 }
-            });
-        });
+        );
     }
 
     public void setSplitConfiguration(SplitBounds splitBounds) {
diff --git a/quickstep/src/com/android/quickstep/views/LauncherRecentsView.java b/quickstep/src/com/android/quickstep/views/LauncherRecentsView.java
index 5e488cc..4f119c0 100644
--- a/quickstep/src/com/android/quickstep/views/LauncherRecentsView.java
+++ b/quickstep/src/com/android/quickstep/views/LauncherRecentsView.java
@@ -249,7 +249,7 @@
         DesktopVisibilityController desktopVisibilityController =
                 mActivity.getDesktopVisibilityController();
         if (desktopVisibilityController != null) {
-            desktopVisibilityController.setGestureInProgress(true);
+            desktopVisibilityController.setRecentsGestureStart();
         }
     }
 
@@ -257,9 +257,11 @@
     public void onGestureAnimationEnd() {
         DesktopVisibilityController desktopVisibilityController = null;
         boolean showDesktopApps = false;
+        GestureState.GestureEndTarget endTarget = null;
         if (DesktopTaskView.DESKTOP_MODE_SUPPORTED) {
             desktopVisibilityController = mActivity.getDesktopVisibilityController();
-            if (mCurrentGestureEndTarget == GestureState.GestureEndTarget.LAST_TASK
+            endTarget = mCurrentGestureEndTarget;
+            if (endTarget == GestureState.GestureEndTarget.LAST_TASK
                     && desktopVisibilityController.areFreeformTasksVisible()) {
                 // Recents gesture was cancelled and we are returning to the previous task.
                 // After super class has handled clean up, show desktop apps on top again
@@ -268,7 +270,7 @@
         }
         super.onGestureAnimationEnd();
         if (desktopVisibilityController != null) {
-            desktopVisibilityController.setGestureInProgress(false);
+            desktopVisibilityController.setRecentsGestureEnd(endTarget);
         }
         if (showDesktopApps) {
             SystemUiProxy.INSTANCE.get(mActivity).showDesktopApps(mActivity.getDisplayId());
diff --git a/quickstep/src/com/android/quickstep/views/OverviewActionsView.java b/quickstep/src/com/android/quickstep/views/OverviewActionsView.java
index 754d1ff..bfb578b 100644
--- a/quickstep/src/com/android/quickstep/views/OverviewActionsView.java
+++ b/quickstep/src/com/android/quickstep/views/OverviewActionsView.java
@@ -263,6 +263,13 @@
     }
 
     /**
+     * Returns the visibility of the overview actions buttons.
+     */
+    public @Visibility int getActionsButtonVisibility() {
+        return findViewById(R.id.action_buttons).getVisibility();
+    }
+
+    /**
      * Offsets OverviewActionsView horizontal position based on 3 button nav container in taskbar.
      */
     private void updatePadding() {
diff --git a/quickstep/src/com/android/quickstep/views/TaskView.java b/quickstep/src/com/android/quickstep/views/TaskView.java
index 6b0843c..854c3c7 100644
--- a/quickstep/src/com/android/quickstep/views/TaskView.java
+++ b/quickstep/src/com/android/quickstep/views/TaskView.java
@@ -1707,10 +1707,15 @@
     }
 
     void updateCurrentFullscreenParams(PreviewPositionHelper previewPositionHelper) {
+        updateFullscreenParams(mCurrentFullscreenParams, previewPositionHelper);
+    }
+
+    protected void updateFullscreenParams(TaskView.FullscreenDrawParams fullscreenParams,
+            PreviewPositionHelper previewPositionHelper) {
         if (getRecentsView() == null) {
             return;
         }
-        mCurrentFullscreenParams.setProgress(mFullscreenProgress, getRecentsView().getScaleX(),
+        fullscreenParams.setProgress(mFullscreenProgress, getRecentsView().getScaleX(),
                 getScaleX(), getWidth(), mActivity.getDeviceProfile(), previewPositionHelper);
     }
 
@@ -1860,9 +1865,12 @@
         public float mCurrentDrawnCornerRadius;
 
         public FullscreenDrawParams(Context context) {
-            mCornerRadius = TaskCornerRadius.get(context);
-            mWindowCornerRadius = QuickStepContract.getWindowCornerRadius(context);
+            this(TaskCornerRadius.get(context), QuickStepContract.getWindowCornerRadius(context));
+        }
 
+        FullscreenDrawParams(float cornerRadius, float windowCornerRadius) {
+            mCornerRadius = cornerRadius;
+            mWindowCornerRadius = windowCornerRadius;
             mCurrentDrawnCornerRadius = mCornerRadius;
         }
 
diff --git a/quickstep/tests/src/com/android/launcher3/taskbar/TaskbarHoverToolTipControllerTest.java b/quickstep/tests/src/com/android/launcher3/taskbar/TaskbarHoverToolTipControllerTest.java
index 82849be..6c0d44d 100644
--- a/quickstep/tests/src/com/android/launcher3/taskbar/TaskbarHoverToolTipControllerTest.java
+++ b/quickstep/tests/src/com/android/launcher3/taskbar/TaskbarHoverToolTipControllerTest.java
@@ -18,6 +18,7 @@
 import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
 
 import static com.android.launcher3.taskbar.TaskbarAutohideSuspendController.FLAG_AUTOHIDE_SUSPEND_HOVERING_ICONS;
+import static com.android.launcher3.taskbar.TaskbarHoverToolTipController.HOVER_TOOL_TIP_REVEAL_START_DELAY;
 
 import static com.google.common.truth.Truth.assertThat;
 
@@ -26,6 +27,7 @@
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.spy;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -56,7 +58,7 @@
  */
 @SmallTest
 @RunWith(AndroidTestingRunner.class)
-@TestableLooper.RunWithLooper
+@TestableLooper.RunWithLooper(setAsMainLooper = true)
 public class TaskbarHoverToolTipControllerTest extends TaskbarBaseTestCase {
 
     private TaskbarHoverToolTipController mTaskbarHoverToolTipController;
@@ -126,8 +128,10 @@
 
         boolean hoverHandled =
                 mTaskbarHoverToolTipController.onHover(mHoverBubbleTextView, mMotionEvent);
-        waitForIdleSync();
 
+        // Verify fullscreen is not set until the delayed runnable to reveal the tooltip has run
+        verify(taskbarActivityContext, never()).setTaskbarWindowFullscreen(true);
+        waitForIdleSync();
         assertThat(hoverHandled).isTrue();
         verify(taskbarActivityContext).setAutohideSuspendFlag(FLAG_AUTOHIDE_SUSPEND_HOVERING_ICONS,
                 true);
@@ -155,8 +159,10 @@
 
         boolean hoverHandled =
                 mTaskbarHoverToolTipController.onHover(mHoverFolderIcon, mMotionEvent);
-        waitForIdleSync();
 
+        // Verify fullscreen is not set until the delayed runnable to reveal the tooltip has run
+        verify(taskbarActivityContext, never()).setTaskbarWindowFullscreen(true);
+        waitForIdleSync();
         assertThat(hoverHandled).isTrue();
         verify(taskbarActivityContext).setAutohideSuspendFlag(FLAG_AUTOHIDE_SUSPEND_HOVERING_ICONS,
                 true);
@@ -216,6 +222,7 @@
     }
 
     private void waitForIdleSync() {
+        mTestableLooper.moveTimeForward(HOVER_TOOL_TIP_REVEAL_START_DELAY + 1);
         mTestableLooper.processAllMessages();
     }
 }
diff --git a/quickstep/tests/src/com/android/quickstep/TaplTestsQuickstep.java b/quickstep/tests/src/com/android/quickstep/TaplTestsQuickstep.java
index 06ce0e5..9d188ed 100644
--- a/quickstep/tests/src/com/android/quickstep/TaplTestsQuickstep.java
+++ b/quickstep/tests/src/com/android/quickstep/TaplTestsQuickstep.java
@@ -44,6 +44,7 @@
 import com.android.launcher3.tapl.Overview;
 import com.android.launcher3.tapl.OverviewActions;
 import com.android.launcher3.tapl.OverviewTask;
+import com.android.launcher3.tapl.OverviewTaskMenu;
 import com.android.launcher3.ui.PortraitLandscapeRunner.PortraitLandscape;
 import com.android.launcher3.ui.TaplTestsLauncher3;
 import com.android.launcher3.util.DisplayController;
@@ -196,6 +197,19 @@
         actionsView.clickAndDismissScreenshot();
     }
 
+
+    @Test
+    public void testOverviewActionsMenu() throws Exception {
+        startTestAppsWithCheck();
+
+        OverviewTaskMenu menu = mLauncher.goHome().switchToOverview().getCurrentTask().tapMenu();
+
+        assertNotNull("Tapping App info menu item returned null", menu.tapAppInfoMenuItem());
+        executeOnLauncher(launcher -> assertTrue(
+                "Launcher activity is the top activity; expecting another activity to be the top",
+                isInLaunchedApp(launcher)));
+    }
+
     private int getCurrentOverviewPage(Launcher launcher) {
         return launcher.<RecentsView>getOverviewPanel().getCurrentPage();
     }
diff --git a/quickstep/tests/src/com/android/quickstep/util/SplitSelectStateControllerTest.kt b/quickstep/tests/src/com/android/quickstep/util/SplitSelectStateControllerTest.kt
index 65542cf..69109c2 100644
--- a/quickstep/tests/src/com/android/quickstep/util/SplitSelectStateControllerTest.kt
+++ b/quickstep/tests/src/com/android/quickstep/util/SplitSelectStateControllerTest.kt
@@ -37,6 +37,7 @@
 import com.android.quickstep.RecentsModel
 import com.android.quickstep.SystemUiProxy
 import com.android.systemui.shared.recents.model.Task
+import java.util.function.Consumer
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertFalse
 import org.junit.Assert.assertNull
@@ -48,7 +49,6 @@
 import org.mockito.Mockito.verify
 import org.mockito.Mockito.`when`
 import org.mockito.MockitoAnnotations
-import java.util.function.Consumer
 
 @RunWith(AndroidJUnit4::class)
 class SplitSelectStateControllerTest {
@@ -67,6 +67,9 @@
     private val primaryUserHandle = UserHandle(ActivityManager.RunningTaskInfo().userId)
     private val nonPrimaryUserHandle = UserHandle(ActivityManager.RunningTaskInfo().userId + 10)
 
+    private var taskIdCounter = 0
+    private fun getUniqueId(): Int { return ++taskIdCounter }
+
     @Before
     fun setup() {
         MockitoAnnotations.initMocks(this)
@@ -100,15 +103,15 @@
         tasks.add(groupTask2)
 
         // Assertions happen in the callback we get from what we pass into
-        // #findLastActiveTaskAndRunCallback
+        // #findLastActiveTasksAndRunCallback
         val taskConsumer =
-            Consumer<Task> { assertNull("No tasks should have matched", it /*task*/) }
+            Consumer<List<Task>> { assertNull("No tasks should have matched", it[0] /*task*/) }
 
         // Capture callback from recentsModel#getTasks()
         val consumer =
             withArgCaptor<Consumer<ArrayList<GroupTask>>> {
-                splitSelectStateController.findLastActiveTaskAndRunCallback(
-                    nonMatchingComponent,
+                splitSelectStateController.findLastActiveTasksAndRunCallback(
+                    listOf(nonMatchingComponent),
                     taskConsumer
                 )
                 verify(recentsModel).getTasks(capture())
@@ -139,27 +142,27 @@
         tasks.add(groupTask2)
 
         // Assertions happen in the callback we get from what we pass into
-        // #findLastActiveTaskAndRunCallback
+        // #findLastActiveTasksAndRunCallback
         val taskConsumer =
-            Consumer<Task> {
+            Consumer<List<Task>> {
                 assertEquals(
                     "ComponentName package mismatched",
-                    it.key.baseIntent.component.packageName,
+                    it[0].key.baseIntent.component?.packageName,
                     matchingPackage
                 )
                 assertEquals(
                     "ComponentName class mismatched",
-                    it.key.baseIntent.component.className,
+                    it[0].key.baseIntent.component?.className,
                     matchingClass
                 )
-                assertEquals(it, groupTask1.task1)
+                assertEquals(it[0], groupTask1.task1)
             }
 
         // Capture callback from recentsModel#getTasks()
         val consumer =
             withArgCaptor<Consumer<ArrayList<GroupTask>>> {
-                splitSelectStateController.findLastActiveTaskAndRunCallback(
-                    matchingComponent,
+                splitSelectStateController.findLastActiveTasksAndRunCallback(
+                    listOf(matchingComponent),
                     taskConsumer
                 )
                 verify(recentsModel).getTasks(capture())
@@ -190,15 +193,15 @@
         tasks.add(groupTask2)
 
         // Assertions happen in the callback we get from what we pass into
-        // #findLastActiveTaskAndRunCallback
+        // #findLastActiveTasksAndRunCallback
         val taskConsumer =
-            Consumer<Task> { assertNull("No tasks should have matched", it /*task*/) }
+            Consumer<List<Task>> { assertNull("No tasks should have matched", it[0] /*task*/) }
 
         // Capture callback from recentsModel#getTasks()
         val consumer =
             withArgCaptor<Consumer<ArrayList<GroupTask>>> {
-                splitSelectStateController.findLastActiveTaskAndRunCallback(
-                    nonPrimaryUserComponent,
+                splitSelectStateController.findLastActiveTasksAndRunCallback(
+                    listOf(nonPrimaryUserComponent),
                     taskConsumer
                 )
                 verify(recentsModel).getTasks(capture())
@@ -231,28 +234,28 @@
         tasks.add(groupTask2)
 
         // Assertions happen in the callback we get from what we pass into
-        // #findLastActiveTaskAndRunCallback
+        // #findLastActiveTasksAndRunCallback
         val taskConsumer =
-            Consumer<Task> {
+            Consumer<List<Task>> {
                 assertEquals(
                     "ComponentName package mismatched",
-                    it.key.baseIntent.component.packageName,
+                    it[0].key.baseIntent.component?.packageName,
                     matchingPackage
                 )
                 assertEquals(
                     "ComponentName class mismatched",
-                    it.key.baseIntent.component.className,
+                    it[0].key.baseIntent.component?.className,
                     matchingClass
                 )
-                assertEquals("userId mismatched", it.key.userId, nonPrimaryUserHandle.identifier)
-                assertEquals(it, groupTask1.task1)
+                assertEquals("userId mismatched", it[0].key.userId, nonPrimaryUserHandle.identifier)
+                assertEquals(it[0], groupTask1.task1)
             }
 
         // Capture callback from recentsModel#getTasks()
         val consumer =
             withArgCaptor<Consumer<ArrayList<GroupTask>>> {
-                splitSelectStateController.findLastActiveTaskAndRunCallback(
-                    nonPrimaryUserComponent,
+                splitSelectStateController.findLastActiveTasksAndRunCallback(
+                    listOf(nonPrimaryUserComponent),
                     taskConsumer
                 )
                 verify(recentsModel).getTasks(capture())
@@ -283,27 +286,200 @@
         tasks.add(groupTask1)
 
         // Assertions happen in the callback we get from what we pass into
-        // #findLastActiveTaskAndRunCallback
+        // #findLastActiveTasksAndRunCallback
         val taskConsumer =
-            Consumer<Task> {
+            Consumer<List<Task>> {
                 assertEquals(
                     "ComponentName package mismatched",
-                    it.key.baseIntent.component.packageName,
+                    it[0].key.baseIntent.component?.packageName,
                     matchingPackage
                 )
                 assertEquals(
                     "ComponentName class mismatched",
-                    it.key.baseIntent.component.className,
+                    it[0].key.baseIntent.component?.className,
                     matchingClass
                 )
-                assertEquals(it, groupTask2.task2)
+                assertEquals(it[0], groupTask1.task1)
             }
 
         // Capture callback from recentsModel#getTasks()
         val consumer =
             withArgCaptor<Consumer<ArrayList<GroupTask>>> {
-                splitSelectStateController.findLastActiveTaskAndRunCallback(
-                    matchingComponent,
+                splitSelectStateController.findLastActiveTasksAndRunCallback(
+                    listOf(matchingComponent),
+                    taskConsumer
+                )
+                verify(recentsModel).getTasks(capture())
+            }
+
+        // Send our mocked tasks
+        consumer.accept(tasks)
+    }
+
+    @Test
+    fun activeTasks_multipleSearchShouldFindTask() {
+        val nonMatchingComponent = ComponentKey(ComponentName("no", "match"), primaryUserHandle)
+        val matchingPackage = "hotdog"
+        val matchingClass = "juice"
+        val matchingComponent =
+            ComponentKey(ComponentName(matchingPackage, matchingClass), primaryUserHandle)
+
+        val groupTask1 =
+            generateGroupTask(
+                ComponentName("hotdog", "pie"),
+                ComponentName("pumpkin", "pie")
+            )
+        val groupTask2 =
+            generateGroupTask(
+                ComponentName("pomegranate", "juice"),
+                ComponentName(matchingPackage, matchingClass)
+            )
+        val tasks: ArrayList<GroupTask> = ArrayList()
+        tasks.add(groupTask2)
+        tasks.add(groupTask1)
+
+        // Assertions happen in the callback we get from what we pass into
+        // #findLastActiveTasksAndRunCallback
+        val taskConsumer =
+            Consumer<List<Task>> {
+                assertEquals("Expected array length 2", 2, it.size)
+                assertNull("No tasks should have matched", it[0] /*task*/)
+                assertEquals(
+                    "ComponentName package mismatched",
+                    it[1].key.baseIntent.component?.packageName,
+                    matchingPackage
+                )
+                assertEquals(
+                    "ComponentName class mismatched",
+                    it[1].key.baseIntent.component?.className,
+                    matchingClass
+                )
+                assertEquals(it[1], groupTask2.task2)
+            }
+
+        // Capture callback from recentsModel#getTasks()
+        val consumer =
+            withArgCaptor<Consumer<ArrayList<GroupTask>>> {
+                splitSelectStateController.findLastActiveTasksAndRunCallback(
+                    listOf(nonMatchingComponent, matchingComponent),
+                    taskConsumer
+                )
+                verify(recentsModel).getTasks(capture())
+            }
+
+        // Send our mocked tasks
+        consumer.accept(tasks)
+    }
+
+    @Test
+    fun activeTasks_multipleSearchShouldNotFindSameTaskTwice() {
+        val matchingPackage = "hotdog"
+        val matchingClass = "juice"
+        val matchingComponent =
+            ComponentKey(ComponentName(matchingPackage, matchingClass), primaryUserHandle)
+
+        val groupTask1 =
+            generateGroupTask(
+                ComponentName("hotdog", "pie"),
+                ComponentName("pumpkin", "pie")
+            )
+        val groupTask2 =
+            generateGroupTask(
+                ComponentName("pomegranate", "juice"),
+                ComponentName(matchingPackage, matchingClass)
+            )
+        val tasks: ArrayList<GroupTask> = ArrayList()
+        tasks.add(groupTask2)
+        tasks.add(groupTask1)
+
+        // Assertions happen in the callback we get from what we pass into
+        // #findLastActiveTasksAndRunCallback
+        val taskConsumer =
+            Consumer<List<Task>> {
+                assertEquals("Expected array length 2", 2, it.size)
+                assertEquals(
+                    "ComponentName package mismatched",
+                    it[0].key.baseIntent.component?.packageName,
+                    matchingPackage
+                )
+                assertEquals(
+                    "ComponentName class mismatched",
+                    it[0].key.baseIntent.component?.className,
+                    matchingClass
+                )
+                assertEquals(it[0], groupTask2.task2)
+                assertNull("No tasks should have matched", it[1] /*task*/)
+            }
+
+        // Capture callback from recentsModel#getTasks()
+        val consumer =
+            withArgCaptor<Consumer<ArrayList<GroupTask>>> {
+                splitSelectStateController.findLastActiveTasksAndRunCallback(
+                    listOf(matchingComponent, matchingComponent),
+                    taskConsumer
+                )
+                verify(recentsModel).getTasks(capture())
+            }
+
+        // Send our mocked tasks
+        consumer.accept(tasks)
+    }
+
+    @Test
+    fun activeTasks_multipleSearchShouldFindDifferentInstancesOfSameTask() {
+        val matchingPackage = "hotdog"
+        val matchingClass = "juice"
+        val matchingComponent =
+            ComponentKey(ComponentName(matchingPackage, matchingClass), primaryUserHandle)
+
+        val groupTask1 =
+            generateGroupTask(
+                ComponentName(matchingPackage, matchingClass),
+                ComponentName("pumpkin", "pie")
+            )
+        val groupTask2 =
+            generateGroupTask(
+                ComponentName("pomegranate", "juice"),
+                ComponentName(matchingPackage, matchingClass)
+            )
+        val tasks: ArrayList<GroupTask> = ArrayList()
+        tasks.add(groupTask2)
+        tasks.add(groupTask1)
+
+        // Assertions happen in the callback we get from what we pass into
+        // #findLastActiveTasksAndRunCallback
+        val taskConsumer =
+            Consumer<List<Task>> {
+                assertEquals("Expected array length 2", 2, it.size)
+                assertEquals(
+                    "ComponentName package mismatched",
+                    it[0].key.baseIntent.component?.packageName,
+                    matchingPackage
+                )
+                assertEquals(
+                    "ComponentName class mismatched",
+                    it[0].key.baseIntent.component?.className,
+                    matchingClass
+                )
+                assertEquals(it[0], groupTask1.task1)
+                assertEquals(
+                    "ComponentName package mismatched",
+                    it[1].key.baseIntent.component?.packageName,
+                    matchingPackage
+                )
+                assertEquals(
+                    "ComponentName class mismatched",
+                    it[1].key.baseIntent.component?.className,
+                    matchingClass
+                )
+                assertEquals(it[1], groupTask2.task2)
+            }
+
+        // Capture callback from recentsModel#getTasks()
+        val consumer =
+            withArgCaptor<Consumer<ArrayList<GroupTask>>> {
+                splitSelectStateController.findLastActiveTasksAndRunCallback(
+                    listOf(matchingComponent, matchingComponent),
                     taskConsumer
                 )
                 verify(recentsModel).getTasks(capture())
@@ -366,6 +542,7 @@
     ): GroupTask {
         val task1 = Task()
         var taskInfo = ActivityManager.RunningTaskInfo()
+        taskInfo.taskId = getUniqueId()
         var intent = Intent()
         intent.component = task1ComponentName
         taskInfo.baseIntent = intent
@@ -373,6 +550,7 @@
 
         val task2 = Task()
         taskInfo = ActivityManager.RunningTaskInfo()
+        taskInfo.taskId = getUniqueId()
         intent = Intent()
         intent.component = task2ComponentName
         taskInfo.baseIntent = intent
@@ -393,6 +571,7 @@
     ): GroupTask {
         val task1 = Task()
         var taskInfo = ActivityManager.RunningTaskInfo()
+        taskInfo.taskId = getUniqueId()
         // Apply custom userHandle1
         taskInfo.userId = userHandle1.identifier
         var intent = Intent()
@@ -401,6 +580,7 @@
         task1.key = Task.TaskKey(taskInfo)
         val task2 = Task()
         taskInfo = ActivityManager.RunningTaskInfo()
+        taskInfo.taskId = getUniqueId()
         // Apply custom userHandle2
         taskInfo.userId = userHandle2.identifier
         intent = Intent()
diff --git a/res/layout/app_pair_icon.xml b/res/layout/app_pair_icon.xml
new file mode 100644
index 0000000..2b9a98b
--- /dev/null
+++ b/res/layout/app_pair_icon.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2023 The Android Open Source Project
+
+     Licensed under the Apache License, Version 2.0 (the "License");
+     you may not use this file except in compliance with the License.
+     You may obtain a copy of the License at
+
+          http://www.apache.org/licenses/LICENSE-2.0
+
+     Unless required by applicable law or agreed to in writing, software
+     distributed under the License is distributed on an "AS IS" BASIS,
+     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+     See the License for the specific language governing permissions and
+     limitations under the License.
+-->
+
+<com.android.launcher3.apppairs.AppPairIcon
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:orientation="vertical"
+    android:focusable="true" >
+    <com.android.launcher3.views.DoubleShadowBubbleTextView
+        style="@style/BaseIcon.Workspace"
+        android:id="@+id/app_pair_icon_name"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:focusable="false"
+        android:layout_gravity="top" />
+</com.android.launcher3.apppairs.AppPairIcon>
diff --git a/src/com/android/launcher3/DeviceProfile.java b/src/com/android/launcher3/DeviceProfile.java
index 28e0cf2..9a3480b 100644
--- a/src/com/android/launcher3/DeviceProfile.java
+++ b/src/com/android/launcher3/DeviceProfile.java
@@ -886,6 +886,10 @@
         updateIconSize(1f, res);
         updateWorkspacePadding();
 
+        if (mIsResponsiveGrid) {
+            return 0;
+        }
+
         // Check to see if the icons fit within the available height.
         float usedHeight = getCellLayoutHeightSpecification();
         final int maxHeight = getCellLayoutHeight();
diff --git a/src/com/android/launcher3/Launcher.java b/src/com/android/launcher3/Launcher.java
index 6df9aab..c737074 100644
--- a/src/com/android/launcher3/Launcher.java
+++ b/src/com/android/launcher3/Launcher.java
@@ -145,6 +145,7 @@
 import com.android.launcher3.allapps.BaseSearchConfig;
 import com.android.launcher3.allapps.DiscoveryBounce;
 import com.android.launcher3.anim.PropertyListBuilder;
+import com.android.launcher3.apppairs.AppPairIcon;
 import com.android.launcher3.celllayout.CellPosMapper;
 import com.android.launcher3.celllayout.CellPosMapper.CellPos;
 import com.android.launcher3.celllayout.CellPosMapper.TwoPanelCellPosMapper;
@@ -725,18 +726,23 @@
 
     @Override
     protected void onHandleConfigurationChanged() {
-        if (!initDeviceProfile(mDeviceProfile.inv)) {
-            return;
+        Trace.beginSection("Launcher#onHandleconfigurationChanged");
+        try {
+            if (!initDeviceProfile(mDeviceProfile.inv)) {
+                return;
+            }
+
+            dispatchDeviceProfileChanged();
+            reapplyUi();
+            mDragLayer.recreateControllers();
+
+            // Calling onSaveInstanceState ensures that static cache used by listWidgets is
+            // initialized properly.
+            onSaveInstanceState(new Bundle());
+            mModel.rebindCallbacks();
+        } finally {
+            Trace.endSection();
         }
-
-        dispatchDeviceProfileChanged();
-        reapplyUi();
-        mDragLayer.recreateControllers();
-
-        // Calling onSaveInstanceState ensures that static cache used by listWidgets is
-        // initialized properly.
-        onSaveInstanceState(new Bundle());
-        mModel.rebindCallbacks();
     }
 
     public void onAssistantVisibilityChanged(float visibility) {
@@ -2446,9 +2452,9 @@
                     break;
                 }
                 case LauncherSettings.Favorites.ITEM_TYPE_APP_PAIR: {
-                    FolderInfo info = (FolderInfo) item;
-                    // TODO (jeremysim b/274189428): Create app pair icon
-                    view = null;
+                    view = AppPairIcon.inflateIcon(R.layout.app_pair_icon, this,
+                            (ViewGroup) workspace.getChildAt(workspace.getCurrentPage()),
+                            (FolderInfo) item);
                     break;
                 }
                 case LauncherSettings.Favorites.ITEM_TYPE_APPWIDGET:
@@ -3390,4 +3396,12 @@
     public View.OnLongClickListener getAllAppsItemLongClickListener() {
         return ItemLongClickListener.INSTANCE_ALL_APPS;
     }
+
+    /**
+     * Handles an app pair launch; overridden in
+     * {@link com.android.launcher3.uioverrides.QuickstepLauncher}
+     */
+    public void launchAppPair(WorkspaceItemInfo app1, WorkspaceItemInfo app2) {
+        // Overridden
+    }
 }
diff --git a/src/com/android/launcher3/LauncherState.java b/src/com/android/launcher3/LauncherState.java
index bfbca65..d2ea7cc 100644
--- a/src/com/android/launcher3/LauncherState.java
+++ b/src/com/android/launcher3/LauncherState.java
@@ -386,8 +386,7 @@
      * Gets the translation provider for workspace pages.
      */
     public PageTranslationProvider getWorkspacePageTranslationProvider(Launcher launcher) {
-        if (this != SPRING_LOADED
-                || this != EDIT_MODE
+        if (!(this == SPRING_LOADED || this == EDIT_MODE)
                 || !launcher.getDeviceProfile().isTwoPanels) {
             return DEFAULT_PAGE_TRANSLATION_PROVIDER;
         }
diff --git a/src/com/android/launcher3/ShortcutAndWidgetContainer.java b/src/com/android/launcher3/ShortcutAndWidgetContainer.java
index f921d1d..07b71b3 100644
--- a/src/com/android/launcher3/ShortcutAndWidgetContainer.java
+++ b/src/com/android/launcher3/ShortcutAndWidgetContainer.java
@@ -28,6 +28,7 @@
 import android.graphics.Point;
 import android.graphics.PointF;
 import android.graphics.Rect;
+import android.os.Trace;
 import android.view.MotionEvent;
 import android.view.View;
 import android.view.ViewGroup;
@@ -182,6 +183,7 @@
 
     @Override
     protected void onLayout(boolean changed, int l, int t, int r, int b) {
+        Trace.beginSection("ShortcutAndWidgetConteiner#onLayout");
         int count = getChildCount();
         for (int i = 0; i < count; i++) {
             final View child = getChildAt(i);
@@ -189,6 +191,7 @@
                 layoutChild(child);
             }
         }
+        Trace.endSection();
     }
 
     /**
diff --git a/src/com/android/launcher3/allapps/ActivityAllAppsContainerView.java b/src/com/android/launcher3/allapps/ActivityAllAppsContainerView.java
index 82ed962..40382b2 100644
--- a/src/com/android/launcher3/allapps/ActivityAllAppsContainerView.java
+++ b/src/com/android/launcher3/allapps/ActivityAllAppsContainerView.java
@@ -934,6 +934,7 @@
     public void onDeviceProfileChanged(DeviceProfile dp) {
         for (AdapterHolder holder : mAH) {
             holder.mAdapter.setAppsPerRow(dp.numShownAllAppsColumns);
+            holder.mAppsList.setNumAppsPerRowAllApps(dp.numShownAllAppsColumns);
             if (holder.mRecyclerView != null) {
                 // Remove all views and clear the pool, while keeping the data same. After this
                 // call, all the viewHolders will be recreated.
diff --git a/src/com/android/launcher3/allapps/AlphabeticalAppsList.java b/src/com/android/launcher3/allapps/AlphabeticalAppsList.java
index 0657178..ee4b5bc 100644
--- a/src/com/android/launcher3/allapps/AlphabeticalAppsList.java
+++ b/src/com/android/launcher3/allapps/AlphabeticalAppsList.java
@@ -82,7 +82,7 @@
     private final ArrayList<AdapterItem> mSearchResults = new ArrayList<>();
     private BaseAllAppsAdapter<T> mAdapter;
     private AppInfoComparator mAppNameComparator;
-    private final int mNumAppsPerRowAllApps;
+    private int mNumAppsPerRowAllApps;
     private int mNumAppRowsInAdapter;
     private Predicate<ItemInfo> mItemFilter;
 
@@ -92,12 +92,17 @@
         mActivityContext = ActivityContext.lookupContext(context);
         mAppNameComparator = new AppInfoComparator(context);
         mWorkProviderManager = workProfileManager;
-        mNumAppsPerRowAllApps = mActivityContext.getDeviceProfile().inv.numAllAppsColumns;
+        mNumAppsPerRowAllApps = mActivityContext.getDeviceProfile().numShownAllAppsColumns;
         if (mAllAppsStore != null) {
             mAllAppsStore.addUpdateListener(this);
         }
     }
 
+    /** Set the number of apps per row when device profile changes. */
+    public void setNumAppsPerRowAllApps(int numAppsPerRow) {
+        mNumAppsPerRowAllApps = numAppsPerRow;
+    }
+
     public void updateItemFilter(Predicate<ItemInfo> itemFilter) {
         this.mItemFilter = itemFilter;
         onAppsUpdated();
diff --git a/src/com/android/launcher3/apppairs/AppPairIcon.java b/src/com/android/launcher3/apppairs/AppPairIcon.java
new file mode 100644
index 0000000..1dc4ad2
--- /dev/null
+++ b/src/com/android/launcher3/apppairs/AppPairIcon.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.launcher3.apppairs;
+
+import android.content.Context;
+import android.graphics.Rect;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+
+import androidx.annotation.Nullable;
+
+import com.android.launcher3.BubbleTextView;
+import com.android.launcher3.R;
+import com.android.launcher3.dragndrop.DraggableView;
+import com.android.launcher3.model.data.FolderInfo;
+import com.android.launcher3.model.data.WorkspaceItemInfo;
+import com.android.launcher3.views.ActivityContext;
+
+import java.util.Collections;
+import java.util.Comparator;
+
+/**
+ * A {@link android.widget.FrameLayout} used to represent an app pair icon on the workspace.
+ */
+public class AppPairIcon extends FrameLayout implements DraggableView {
+
+    private ActivityContext mActivity;
+    private BubbleTextView mAppPairName;
+    private FolderInfo mInfo;
+
+    public AppPairIcon(Context context, AttributeSet attrs) {
+        super(context, attrs);
+    }
+
+    public AppPairIcon(Context context) {
+        super(context);
+    }
+
+    /**
+     * Builds an AppPairIcon to be added to the Launcher
+     */
+    public static AppPairIcon inflateIcon(int resId, ActivityContext activity,
+            @Nullable ViewGroup group, FolderInfo appPairInfo) {
+
+        LayoutInflater inflater = (group != null)
+                ? LayoutInflater.from(group.getContext())
+                : activity.getLayoutInflater();
+        AppPairIcon icon = (AppPairIcon) inflater.inflate(resId, group, false);
+
+        // Sort contents, so that left-hand app comes first
+        Collections.sort(appPairInfo.contents, Comparator.comparingInt(a -> a.rank));
+
+        icon.setClipToPadding(false);
+        icon.mAppPairName = icon.findViewById(R.id.app_pair_icon_name);
+
+        // TODO (jeremysim b/274189428): Replace this placeholder icon
+        WorkspaceItemInfo placeholder = new WorkspaceItemInfo();
+        placeholder.newIcon(icon.getContext());
+        icon.mAppPairName.applyFromWorkspaceItem(placeholder);
+
+        icon.mAppPairName.setText(appPairInfo.title);
+
+        icon.setTag(appPairInfo);
+        icon.setOnClickListener(activity.getItemOnClickListener());
+        icon.mInfo = appPairInfo;
+        icon.mActivity = activity;
+
+        icon.setAccessibilityDelegate(activity.getAccessibilityDelegate());
+
+        return icon;
+    }
+
+    @Override
+    public int getViewType() {
+        return DRAGGABLE_ICON;
+    }
+
+    @Override
+    public void getWorkspaceVisualDragBounds(Rect bounds) {
+        mAppPairName.getIconBounds(bounds);
+    }
+
+    public FolderInfo getInfo() {
+        return mInfo;
+    }
+}
diff --git a/src/com/android/launcher3/config/FeatureFlags.java b/src/com/android/launcher3/config/FeatureFlags.java
index cabcabc..273d505 100644
--- a/src/com/android/launcher3/config/FeatureFlags.java
+++ b/src/com/android/launcher3/config/FeatureFlags.java
@@ -277,6 +277,10 @@
             "ENABLE_BACK_SWIPE_HOME_ANIMATION", ENABLED,
             "Enables home animation to icon when user swipes back.");
 
+    public static final BooleanFlag ENABLE_DYNAMIC_TASKBAR_THRESHOLDS = getDebugFlag(294252473,
+            "ENABLE_DYNAMIC_TASKBAR_THRESHOLDS", TEAMFOOD,
+            "Enables taskbar thresholds that scale based on screen size.");
+
     // TODO(Block 21): Clean up flags
     public static final BooleanFlag ENABLE_APP_ICON_FOR_INLINE_SHORTCUTS = getDebugFlag(270395087,
             "ENABLE_APP_ICON_IN_INLINE_SHORTCUTS", DISABLED, "Show app icon for inline shortcut");
@@ -359,7 +363,7 @@
 
     // TODO(Block 28): Clean up flags
     public static final BooleanFlag ENABLE_SPLIT_FROM_FULLSCREEN_WITH_KEYBOARD_SHORTCUTS =
-            getDebugFlag(270394122, "ENABLE_SPLIT_FROM_FULLSCREEN_SHORTCUT", DISABLED,
+            getDebugFlag(270394122, "ENABLE_SPLIT_FROM_FULLSCREEN_SHORTCUT", TEAMFOOD,
                     "Enable splitting from fullscreen app with keyboard shortcuts");
 
     public static final BooleanFlag ENABLE_SPLIT_FROM_WORKSPACE_TO_WORKSPACE = getDebugFlag(
@@ -395,12 +399,12 @@
 
     // TODO(Block 33): Clean up flags
     public static final BooleanFlag ENABLE_ALL_APPS_RV_PREINFLATION = getDebugFlag(288161355,
-            "ENABLE_ALL_APPS_RV_PREINFLATION", ENABLED,
+            "ENABLE_ALL_APPS_RV_PREINFLATION", DISABLED,
             "Enables preinflating all apps icons to avoid scrolling jank.");
 
     // TODO(Block 34): Clean up flags
     public static final BooleanFlag ALL_APPS_GONE_VISIBILITY = getDebugFlag(291651514,
-            "ALL_APPS_GONE_VISIBILITY", ENABLED,
+            "ALL_APPS_GONE_VISIBILITY", DISABLED,
             "Set all apps container view's hidden visibility to GONE instead of INVISIBLE.");
 
     // TODO(Block 35): Empty block
diff --git a/src/com/android/launcher3/graphics/LauncherPreviewRenderer.java b/src/com/android/launcher3/graphics/LauncherPreviewRenderer.java
index 7241b17..68106c4 100644
--- a/src/com/android/launcher3/graphics/LauncherPreviewRenderer.java
+++ b/src/com/android/launcher3/graphics/LauncherPreviewRenderer.java
@@ -51,6 +51,7 @@
 import android.view.ViewGroup;
 import android.view.WindowInsets;
 import android.view.WindowManager;
+import android.widget.FrameLayout;
 import android.widget.TextClock;
 
 import androidx.annotation.NonNull;
@@ -70,6 +71,7 @@
 import com.android.launcher3.Utilities;
 import com.android.launcher3.Workspace;
 import com.android.launcher3.WorkspaceLayoutManager;
+import com.android.launcher3.apppairs.AppPairIcon;
 import com.android.launcher3.celllayout.CellLayoutLayoutParams;
 import com.android.launcher3.celllayout.CellPosMapper;
 import com.android.launcher3.config.FeatureFlags;
@@ -358,12 +360,13 @@
         addInScreenFromBind(icon, info);
     }
 
-    private void inflateAndAddFolder(FolderInfo info) {
+    private void inflateAndAddCollectionIcon(FolderInfo info) {
         CellLayout screen = info.container == Favorites.CONTAINER_DESKTOP
                 ? mWorkspaceScreens.get(info.screenId)
                 : mHotseat;
-        FolderIcon folderIcon = FolderIcon.inflateIcon(R.layout.folder_icon, this, screen,
-                info);
+        FrameLayout folderIcon = info.itemType == Favorites.ITEM_TYPE_FOLDER
+                ? FolderIcon.inflateIcon(R.layout.folder_icon, this, screen, info)
+                : AppPairIcon.inflateIcon(R.layout.app_pair_icon, this, screen, info);
         addInScreenFromBind(folderIcon, info);
     }
 
@@ -467,7 +470,8 @@
                     inflateAndAddIcon((WorkspaceItemInfo) itemInfo);
                     break;
                 case Favorites.ITEM_TYPE_FOLDER:
-                    inflateAndAddFolder((FolderInfo) itemInfo);
+                case Favorites.ITEM_TYPE_APP_PAIR:
+                    inflateAndAddCollectionIcon((FolderInfo) itemInfo);
                     break;
                 default:
                     break;
diff --git a/src/com/android/launcher3/logging/StatsLogManager.java b/src/com/android/launcher3/logging/StatsLogManager.java
index 2a7cd9a..8bb06c1 100644
--- a/src/com/android/launcher3/logging/StatsLogManager.java
+++ b/src/com/android/launcher3/logging/StatsLogManager.java
@@ -641,11 +641,17 @@
         @UiEvent(doc = "User has swiped upwards from the gesture handle to show transient taskbar.")
         LAUNCHER_TRANSIENT_TASKBAR_SHOW(1331),
 
+        @UiEvent(doc = "User has clicked an app pair and launched directly into split screen.")
+        LAUNCHER_APP_PAIR_LAUNCH(1374),
+
+        @UiEvent(doc = "User saved an app pair.")
+        LAUNCHER_APP_PAIR_SAVE(1456),
+
         @UiEvent(doc = "App launched through pending intent")
-        LAUNCHER_APP_LAUNCH_PENDING_INTENT(1394),
-        ;
+        LAUNCHER_APP_LAUNCH_PENDING_INTENT(1394)
 
         // ADD MORE
+        ;
 
         private final int mId;
 
diff --git a/src/com/android/launcher3/model/BaseLauncherBinder.java b/src/com/android/launcher3/model/BaseLauncherBinder.java
index 556ac26..dbb29b8 100644
--- a/src/com/android/launcher3/model/BaseLauncherBinder.java
+++ b/src/com/android/launcher3/model/BaseLauncherBinder.java
@@ -21,6 +21,7 @@
 import static com.android.launcher3.util.Executors.MODEL_EXECUTOR;
 
 import android.os.Process;
+import android.os.Trace;
 import android.util.Log;
 
 import com.android.launcher3.InvariantDeviceProfile;
@@ -83,13 +84,18 @@
      * Binds all loaded data to actual views on the main thread.
      */
     public void bindWorkspace(boolean incrementBindId, boolean isBindSync) {
-        if (FeatureFlags.ENABLE_WORKSPACE_LOADING_OPTIMIZATION.get()) {
-            DisjointWorkspaceBinder workspaceBinder =
+        Trace.beginSection("BaseLauncherBinder#bindWorkspace");
+        try {
+            if (FeatureFlags.ENABLE_WORKSPACE_LOADING_OPTIMIZATION.get()) {
+                DisjointWorkspaceBinder workspaceBinder =
                     initWorkspaceBinder(incrementBindId, mBgDataModel.collectWorkspaceScreens());
-            workspaceBinder.bindCurrentWorkspacePages(isBindSync);
-            workspaceBinder.bindOtherWorkspacePages();
-        } else {
-            bindWorkspaceAllAtOnce(incrementBindId, isBindSync);
+                workspaceBinder.bindCurrentWorkspacePages(isBindSync);
+                workspaceBinder.bindOtherWorkspacePages();
+            } else {
+                bindWorkspaceAllAtOnce(incrementBindId, isBindSync);
+            }
+        } finally {
+            Trace.endSection();
         }
     }
 
diff --git a/src/com/android/launcher3/model/LoaderTask.java b/src/com/android/launcher3/model/LoaderTask.java
index 787ac38..933468c 100644
--- a/src/com/android/launcher3/model/LoaderTask.java
+++ b/src/com/android/launcher3/model/LoaderTask.java
@@ -690,9 +690,11 @@
                     break;
 
                 case Favorites.ITEM_TYPE_FOLDER:
+                case Favorites.ITEM_TYPE_APP_PAIR:
                     FolderInfo folderInfo = mBgDataModel.findOrMakeFolder(c.id);
                     c.applyCommonProperties(folderInfo);
 
+                    folderInfo.itemType = c.itemType;
                     // Do not trim the folder label, as is was set by the user.
                     folderInfo.title = c.getString(c.mTitleIndex);
                     folderInfo.spanX = 1;
diff --git a/src/com/android/launcher3/model/ModelWriter.java b/src/com/android/launcher3/model/ModelWriter.java
index a6b4d59..2358a9f 100644
--- a/src/com/android/launcher3/model/ModelWriter.java
+++ b/src/com/android/launcher3/model/ModelWriter.java
@@ -489,6 +489,7 @@
                         case Favorites.ITEM_TYPE_APPLICATION:
                         case Favorites.ITEM_TYPE_DEEP_SHORTCUT:
                         case Favorites.ITEM_TYPE_FOLDER:
+                        case Favorites.ITEM_TYPE_APP_PAIR:
                             if (!mBgDataModel.workspaceItems.contains(modelItem)) {
                                 mBgDataModel.workspaceItems.add(modelItem);
                             }
diff --git a/src/com/android/launcher3/model/data/FolderInfo.java b/src/com/android/launcher3/model/data/FolderInfo.java
index e5a0eb1..9bf6d43 100644
--- a/src/com/android/launcher3/model/data/FolderInfo.java
+++ b/src/com/android/launcher3/model/data/FolderInfo.java
@@ -119,8 +119,8 @@
     public static FolderInfo createAppPair(WorkspaceItemInfo app1, WorkspaceItemInfo app2) {
         FolderInfo newAppPair = new FolderInfo();
         newAppPair.itemType = LauncherSettings.Favorites.ITEM_TYPE_APP_PAIR;
-        newAppPair.contents.add(app1);
-        newAppPair.contents.add(app2);
+        newAppPair.add(app1, /* animate */ false);
+        newAppPair.add(app2, /* animate */ false);
         return newAppPair;
     }
 
diff --git a/src/com/android/launcher3/touch/ItemClickHandler.java b/src/com/android/launcher3/touch/ItemClickHandler.java
index 790c226..8c12547 100644
--- a/src/com/android/launcher3/touch/ItemClickHandler.java
+++ b/src/com/android/launcher3/touch/ItemClickHandler.java
@@ -42,6 +42,7 @@
 import com.android.launcher3.LauncherSettings;
 import com.android.launcher3.R;
 import com.android.launcher3.Utilities;
+import com.android.launcher3.apppairs.AppPairIcon;
 import com.android.launcher3.folder.Folder;
 import com.android.launcher3.folder.FolderIcon;
 import com.android.launcher3.logging.InstanceId;
@@ -95,6 +96,8 @@
         } else if (tag instanceof FolderInfo) {
             if (v instanceof FolderIcon) {
                 onClickFolderIcon(v);
+            } else if (v instanceof AppPairIcon) {
+                onClickAppPairIcon(v);
             }
         } else if (tag instanceof AppInfo) {
             startAppShortcutOrInfoActivity(v, (AppInfo) tag, launcher);
@@ -123,6 +126,17 @@
     }
 
     /**
+     * Event handler for an app pair icon click.
+     *
+     * @param v The view that was clicked. Must be an instance of {@link AppPairIcon}.
+     */
+    private static void onClickAppPairIcon(View v) {
+        Launcher launcher = Launcher.getLauncher(v.getContext());
+        FolderInfo folderInfo = ((AppPairIcon) v).getInfo();
+        launcher.launchAppPair(folderInfo.contents.get(0), folderInfo.contents.get(1));
+    }
+
+    /**
      * Event handler for the app widget view which has not fully restored.
      */
     private static void onClickPendingWidget(PendingAppWidgetHostView v, Launcher launcher) {
diff --git a/tests/AndroidManifest-common.xml b/tests/AndroidManifest-common.xml
index 28688fd..bb61fbe 100644
--- a/tests/AndroidManifest-common.xml
+++ b/tests/AndroidManifest-common.xml
@@ -32,6 +32,7 @@
         <receiver
             android:name="com.android.launcher3.testcomponent.AppWidgetNoConfig"
             android:exported="true"
+            android:icon="@drawable/test_widget_no_config_icon"
             android:label="No Config">
             <intent-filter>
                 <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
@@ -65,6 +66,7 @@
         <receiver
             android:name="com.android.launcher3.testcomponent.AppWidgetWithConfig"
             android:exported="true"
+            android:icon="@drawable/test_widget_with_config_icon"
             android:label="With Config">
             <intent-filter>
                 <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
@@ -76,6 +78,7 @@
         <receiver
             android:name="com.android.launcher3.testcomponent.AppWidgetWithDialog"
             android:exported="true"
+            android:icon="@drawable/test_widget_with_dialog_icon"
             android:label="With Dialog">
             <intent-filter>
                 <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
@@ -87,6 +90,7 @@
         <receiver
             android:name="com.android.launcher3.testcomponent.AppWidgetDynamicColors"
             android:exported="true"
+            android:icon="@drawable/test_widget_dynamic_colors_icon"
             android:label="Dynamic Colors">
             <intent-filter>
                 <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
diff --git a/tests/res/drawable/test_widget_dynamic_colors_icon.xml b/tests/res/drawable/test_widget_dynamic_colors_icon.xml
new file mode 100644
index 0000000..69f6675
--- /dev/null
+++ b/tests/res/drawable/test_widget_dynamic_colors_icon.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+Copyright (C) 2023 The Android Open Source Project
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+    you may not use this file except in compliance with the License.
+    You may obtain a copy of the License at
+
+         http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing, software
+    distributed under the License is distributed on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+    See the License for the specific language governing permissions and
+    limitations under the License.
+-->
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@android:color/white"/>
+    <foreground>
+        <color android:color="#964B00"/>
+    </foreground>
+    <monochrome>
+        <vector android:width="48dp" android:height="48dp" android:viewportWidth="48.0" android:viewportHeight="48.0">
+            <path
+                android:fillColor="#FF000000"
+                android:pathData="M0,24L48,24 48,48, 0,48 Z"/>
+        </vector>
+    </monochrome>
+</adaptive-icon>
diff --git a/tests/res/drawable/test_widget_no_config_icon.xml b/tests/res/drawable/test_widget_no_config_icon.xml
new file mode 100644
index 0000000..e3d0125
--- /dev/null
+++ b/tests/res/drawable/test_widget_no_config_icon.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+Copyright (C) 2023 The Android Open Source Project
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+    you may not use this file except in compliance with the License.
+    You may obtain a copy of the License at
+
+         http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing, software
+    distributed under the License is distributed on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+    See the License for the specific language governing permissions and
+    limitations under the License.
+-->
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@android:color/white"/>
+    <foreground>
+        <color android:color="#00FFFF"/>
+    </foreground>
+    <monochrome>
+        <vector android:width="48dp" android:height="48dp" android:viewportWidth="48.0" android:viewportHeight="48.0">
+            <path
+                android:fillColor="#FF000000"
+                android:pathData="M0,24L48,24 48,48, 0,48 Z"/>
+        </vector>
+    </monochrome>
+</adaptive-icon>
diff --git a/tests/res/drawable/test_widget_with_config_icon.xml b/tests/res/drawable/test_widget_with_config_icon.xml
new file mode 100644
index 0000000..98b797b
--- /dev/null
+++ b/tests/res/drawable/test_widget_with_config_icon.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+Copyright (C) 2023 The Android Open Source Project
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+    you may not use this file except in compliance with the License.
+    You may obtain a copy of the License at
+
+         http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing, software
+    distributed under the License is distributed on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+    See the License for the specific language governing permissions and
+    limitations under the License.
+-->
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@android:color/white"/>
+    <foreground>
+        <color android:color="#008000" />
+    </foreground>
+    <monochrome>
+        <vector android:width="48dp" android:height="48dp" android:viewportWidth="48.0" android:viewportHeight="48.0">
+            <path
+                android:fillColor="#FF000000"
+                android:pathData="M0,24L48,24 48,48, 0,48 Z"/>
+        </vector>
+    </monochrome>
+</adaptive-icon>
diff --git a/tests/res/drawable/test_widget_with_dialog_icon.xml b/tests/res/drawable/test_widget_with_dialog_icon.xml
new file mode 100644
index 0000000..d2879d2
--- /dev/null
+++ b/tests/res/drawable/test_widget_with_dialog_icon.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+Copyright (C) 2023 The Android Open Source Project
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+    you may not use this file except in compliance with the License.
+    You may obtain a copy of the License at
+
+         http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing, software
+    distributed under the License is distributed on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+    See the License for the specific language governing permissions and
+    limitations under the License.
+-->
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@android:color/white"/>
+    <foreground>
+        <color android:color="#800080"/>
+    </foreground>
+    <monochrome>
+        <vector android:width="48dp" android:height="48dp" android:viewportWidth="48.0" android:viewportHeight="48.0">
+            <path
+                android:fillColor="#FF000000"
+                android:pathData="M0,24L48,24 48,48, 0,48 Z"/>
+        </vector>
+    </monochrome>
+</adaptive-icon>
diff --git a/tests/src/com/android/launcher3/ui/TaplTestsLauncher3.java b/tests/src/com/android/launcher3/ui/TaplTestsLauncher3.java
index b67478f..753d89d 100644
--- a/tests/src/com/android/launcher3/ui/TaplTestsLauncher3.java
+++ b/tests/src/com/android/launcher3/ui/TaplTestsLauncher3.java
@@ -318,6 +318,7 @@
     }
 
     @Test
+    @Ignore // b/293191790
     @PortraitLandscape
     public void testWidgets() throws Exception {
         // Test opening widgets.
diff --git a/tests/src/com/android/launcher3/util/rule/ViewCaptureRule.kt b/tests/src/com/android/launcher3/util/rule/ViewCaptureRule.kt
index b4ad1f3..f3f9b89 100644
--- a/tests/src/com/android/launcher3/util/rule/ViewCaptureRule.kt
+++ b/tests/src/com/android/launcher3/util/rule/ViewCaptureRule.kt
@@ -26,8 +26,13 @@
 import com.android.launcher3.tapl.TestHelpers
 import com.android.launcher3.util.ActivityLifecycleCallbacksAdapter
 import com.android.launcher3.util.viewcapture_analysis.ViewCaptureAnalyzer
-import org.junit.Assert.assertTrue
+import java.io.BufferedOutputStream
+import java.io.FileOutputStream
+import java.io.IOException
+import java.io.OutputStreamWriter
 import java.util.function.Supplier
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
 import org.junit.rules.TestRule
 import org.junit.runner.Description
 import org.junit.runners.model.Statement
@@ -81,7 +86,7 @@
                     MAIN_EXECUTOR.execute { windowListenerCloseables.onEach(SafeCloseable::close) }
                 }
 
-                analyzeViewCapture()
+                analyzeViewCapture(description)
             }
 
             private fun startCapturingExistingActivity(
@@ -107,16 +112,39 @@
         }
     }
 
-    private fun analyzeViewCapture() {
+    private fun analyzeViewCapture(description: Description) {
         // OOP tests don't produce ViewCapture data
         if (!TestHelpers.isInLauncherProcess()) return
 
-        ViewCaptureAnalyzer.assertNoAnomalies(viewCaptureData)
-
         var frameCount = 0
         for (i in 0 until viewCaptureData!!.windowDataCount) {
             frameCount += viewCaptureData!!.getWindowData(i).frameDataCount
         }
         assertTrue("Empty ViewCapture data", frameCount > 0)
+
+        val anomalies: Map<String, String> = ViewCaptureAnalyzer.getAnomalies(viewCaptureData)
+        if (!anomalies.isEmpty()) {
+            val diagFile = FailureWatcher.diagFile(description, "ViewAnomalies", "txt")
+            try {
+                OutputStreamWriter(BufferedOutputStream(FileOutputStream(diagFile))).use { writer ->
+                    writer.write("View animation anomalies detected.\r\n")
+                    writer.write(
+                        "To suppress an anomaly for a view, add its full path to the PATHS_TO_IGNORE list in the corresponding AnomalyDetector.\r\n"
+                    )
+                    writer.write("List of views with animation anomalies:\r\n")
+
+                    for ((viewPath, message) in anomalies) {
+                        writer.write("View: $viewPath\r\n        $message\r\n")
+                    }
+                }
+            } catch (ex: IOException) {
+                throw RuntimeException(ex)
+            }
+
+            val (viewPath, message) = anomalies.entries.first()
+            fail(
+                "${anomalies.size} view(s) had animation anomalies during the test, including view: $viewPath: $message\r\nSee ${diagFile.name} for details."
+            )
+        }
     }
 }
diff --git a/tests/src/com/android/launcher3/util/viewcapture_analysis/AlphaJumpDetector.java b/tests/src/com/android/launcher3/util/viewcapture_analysis/AlphaJumpDetector.java
index cb404b9..a147350 100644
--- a/tests/src/com/android/launcher3/util/viewcapture_analysis/AlphaJumpDetector.java
+++ b/tests/src/com/android/launcher3/util/viewcapture_analysis/AlphaJumpDetector.java
@@ -113,7 +113,7 @@
             DRAG_LAYER + "WidgetsFullSheet|SpringRelativeLayout:id/container",
             DRAG_LAYER + "WidgetsTwoPaneSheet|SpringRelativeLayout:id/container",
             CONTENT + "LauncherRootView:id/launcher|FloatingIconView",
-            RECENTS_DRAG_LAYER + "ArrowTipView|View:id/arrow",
+            RECENTS_DRAG_LAYER + "ArrowTipView",
             DRAG_LAYER + "FallbackRecentsView:id/overview_panel",
             RECENTS_DRAG_LAYER + "FallbackRecentsView:id/overview_panel",
             DRAG_LAYER
@@ -133,7 +133,8 @@
                     + "|LinearLayout:id/action_buttons|ImageButton:id/action_split",
             RECENTS_DRAG_LAYER
                     + "NexusOverviewActionsView:id/overview_actions_view"
-                    + "|LinearLayout:id/action_buttons|ImageButton:id/action_split"
+                    + "|LinearLayout:id/action_buttons|ImageButton:id/action_split",
+            DRAG_LAYER + "IconView"
     );
 
     /**
@@ -212,24 +213,26 @@
     }
 
     @Override
-    void detectAnomalies(AnalysisNode oldInfo, AnalysisNode newInfo, int frameN) {
+    String detectAnomalies(AnalysisNode oldInfo, AnalysisNode newInfo, int frameN) {
         // If the view was previously seen, proceed with analysis only if it was present in the
         // view hierarchy in the previous frame.
-        if (oldInfo != null && oldInfo.frameN != frameN) return;
+        if (oldInfo != null && oldInfo.frameN != frameN) return null;
 
         final AnalysisNode latestInfo = newInfo != null ? newInfo : oldInfo;
-        if (getNodeData(latestInfo).ignoreAlphaJumps) return;
+        final NodeData nodeData = getNodeData(latestInfo);
+        if (nodeData.ignoreAlphaJumps) return null;
 
         final float oldAlpha = oldInfo != null ? oldInfo.alpha : 0;
         final float newAlpha = newInfo != null ? newInfo.alpha : 0;
         final float alphaDeltaAbs = Math.abs(newAlpha - oldAlpha);
 
         if (alphaDeltaAbs >= ALPHA_JUMP_THRESHOLD) {
-            throw new AssertionError(
-                    String.format(
-                            "Alpha jump detected in ViewCapture data: alpha change: %s (%s -> %s)"
-                                    + ", threshold: %s, view: %s",
-                            alphaDeltaAbs, oldAlpha, newAlpha, ALPHA_JUMP_THRESHOLD, latestInfo));
+            nodeData.ignoreAlphaJumps = true; // No need to report alpha jump in children.
+            return String.format(
+                    "Alpha jump detected in ViewCapture data: alpha change: %s (%s -> %s)"
+                            + ", threshold: %s, %s", // ----------- no need to include view?
+                    alphaDeltaAbs, oldAlpha, newAlpha, ALPHA_JUMP_THRESHOLD, latestInfo);
         }
+        return null;
     }
 }
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 2cf3843..949c536 100644
--- a/tests/src/com/android/launcher3/util/viewcapture_analysis/ViewCaptureAnalyzer.java
+++ b/tests/src/com/android/launcher3/util/viewcapture_analysis/ViewCaptureAnalyzer.java
@@ -61,8 +61,9 @@
          *                the view is not present in the 'currentFrame', but was present in earlier
          *                frames.
          * @param frameN  number of the current frame.
+         * @return Anomaly diagnostic message if an anomaly has been detected; null otherwise.
          */
-        abstract void detectAnomalies(
+        abstract String detectAnomalies(
                 @Nullable AnalysisNode oldInfo, @Nullable AnalysisNode newInfo, int frameN);
     }
 
@@ -101,25 +102,31 @@
 
         @Override
         public String toString() {
-            return String.format("window coordinates: (%s, %s), class path from the root: %s",
-                    left, top, diagPathFromRoot(this));
+            return String.format("view window coordinates: (%s, %s)", left, top);
         }
     }
 
     /**
-     * Scans a view capture record and throws an error if an anomaly is found.
+     * Scans a view capture record and searches for view animation anomalies. Can find anomalies for
+     * multiple views.
+     * Returns a map from the view path to the anomaly message for the view. Non-empty map means
+     * that anomalies were detected.
      */
-    public static void assertNoAnomalies(ExportedData viewCaptureData) {
+    public static Map<String, String> getAnomalies(ExportedData viewCaptureData) {
+        final Map<String, String> anomalies = new HashMap<>();
+
         final int scrimClassIndex = viewCaptureData.getClassnameList().indexOf(SCRIM_VIEW_CLASS);
 
         final int windowDataCount = viewCaptureData.getWindowDataCount();
         for (int i = 0; i < windowDataCount; ++i) {
-            analyzeWindowData(viewCaptureData, viewCaptureData.getWindowData(i), scrimClassIndex);
+            analyzeWindowData(
+                    viewCaptureData, viewCaptureData.getWindowData(i), scrimClassIndex, anomalies);
         }
+        return anomalies;
     }
 
     private static void analyzeWindowData(ExportedData viewCaptureData, WindowData windowData,
-            int scrimClassIndex) {
+            int scrimClassIndex, Map<String, String> anomalies) {
         // View hash code => Last seen node with this hash code.
         // The view is added when we analyze the first frame where it's visible.
         // After that, it gets updated for every frame where it's visible.
@@ -128,12 +135,13 @@
 
         for (int frameN = 0; frameN < windowData.getFrameDataCount(); ++frameN) {
             analyzeFrame(frameN, windowData.getFrameData(frameN), viewCaptureData, lastSeenNodes,
-                    scrimClassIndex);
+                    scrimClassIndex, anomalies);
         }
     }
 
     private static void analyzeFrame(int frameN, FrameData frame, ExportedData viewCaptureData,
-            Map<Integer, AnalysisNode> lastSeenNodes, int scrimClassIndex) {
+            Map<Integer, AnalysisNode> lastSeenNodes, int scrimClassIndex,
+            Map<String, String> anomalies) {
         // Analyze the node tree starting from the root.
         analyzeView(
                 frame.getNode(),
@@ -143,7 +151,8 @@
                 /* topShift = */ 0,
                 viewCaptureData,
                 lastSeenNodes,
-                scrimClassIndex);
+                scrimClassIndex,
+                anomalies);
 
         // Analyze transitions when a view visible in the last frame become invisible in the
         // current one.
@@ -151,10 +160,14 @@
             if (info.frameN == frameN - 1) {
                 if (!info.viewCaptureNode.getWillNotDraw()) {
                     Arrays.stream(ANOMALY_DETECTORS).forEach(
-                            detector -> detector.detectAnomalies(
-                                    /* oldInfo = */ info,
-                                    /* newInfo = */ null,
-                                    frameN));
+                            detector ->
+                                    detectAnomaly(
+                                            detector,
+                                            frameN,
+                                            /* oldInfo = */ info,
+                                            /* newInfo = */ null,
+                                            anomalies)
+                    );
                 }
             }
         }
@@ -162,7 +175,8 @@
 
     private static void analyzeView(ViewNode viewCaptureNode, AnalysisNode parent, int frameN,
             float leftShift, float topShift, ExportedData viewCaptureData,
-            Map<Integer, AnalysisNode> lastSeenNodes, int scrimClassIndex) {
+            Map<Integer, AnalysisNode> lastSeenNodes, int scrimClassIndex,
+            Map<String, String> anomalies) {
         // Skip analysis of invisible views
         final float parentAlpha = parent != null ? parent.alpha : 1;
         final float alpha = getVisibleAlpha(viewCaptureNode, parentAlpha);
@@ -205,7 +219,10 @@
         final AnalysisNode oldAnalysisNode = lastSeenNodes.get(hashcode); // may be null
         if (frameN != 0 && !viewCaptureNode.getWillNotDraw()) {
             Arrays.stream(ANOMALY_DETECTORS).forEach(
-                    detector -> detector.detectAnomalies(oldAnalysisNode, newAnalysisNode, frameN));
+                    detector ->
+                            detectAnomaly(detector, frameN, oldAnalysisNode, newAnalysisNode,
+                                    anomalies)
+            );
         }
         lastSeenNodes.put(hashcode, newAnalysisNode);
 
@@ -220,8 +237,20 @@
             if (child.getClassnameIndex() == scrimClassIndex) break;
 
             analyzeView(child, newAnalysisNode, frameN, leftShiftForChildren, topShiftForChildren,
-                    viewCaptureData, lastSeenNodes,
-                    scrimClassIndex);
+                    viewCaptureData, lastSeenNodes, scrimClassIndex, anomalies);
+        }
+    }
+
+    private static void detectAnomaly(AnomalyDetector detector, int frameN,
+            AnalysisNode oldAnalysisNode, AnalysisNode newAnalysisNode,
+            Map<String, String> anomalies) {
+        final String maybeAnomaly =
+                detector.detectAnomalies(oldAnalysisNode, newAnalysisNode, frameN);
+        if (maybeAnomaly != null) {
+            final String viewDiagPath = diagPathFromRoot(newAnalysisNode);
+            if (!anomalies.containsKey(viewDiagPath)) {
+                anomalies.put(viewDiagPath, maybeAnomaly);
+            }
         }
     }
 
@@ -235,9 +264,11 @@
         return className.substring(className.lastIndexOf(".") + 1);
     }
 
-    private static String diagPathFromRoot(AnalysisNode nodeBox) {
-        final StringBuilder path = new StringBuilder(nodeBox.nodeIdentity);
-        for (AnalysisNode ancestor = nodeBox.parent; ancestor != null; ancestor = ancestor.parent) {
+    private static String diagPathFromRoot(AnalysisNode analysisNode) {
+        final StringBuilder path = new StringBuilder(analysisNode.nodeIdentity);
+        for (AnalysisNode ancestor = analysisNode.parent;
+                ancestor != null;
+                ancestor = ancestor.parent) {
             path.insert(0, ancestor.nodeIdentity + "|");
         }
         return path.toString();
diff --git a/tests/tapl/com/android/launcher3/tapl/OverviewTaskMenu.java b/tests/tapl/com/android/launcher3/tapl/OverviewTaskMenu.java
index e349620..7c29a6c 100644
--- a/tests/tapl/com/android/launcher3/tapl/OverviewTaskMenu.java
+++ b/tests/tapl/com/android/launcher3/tapl/OverviewTaskMenu.java
@@ -50,6 +50,24 @@
         }
     }
 
+    /** Taps the app info item from the overview task menu and returns the LaunchedAppState
+     * representing the App info settings page. */
+    @NonNull
+    public LaunchedAppState tapAppInfoMenuItem() {
+        try (LauncherInstrumentation.Closable e = mLauncher.eventsCheck();
+             LauncherInstrumentation.Closable c = mLauncher.addContextLayer(
+                     "before tapping the app info menu item")) {
+            mLauncher.clickLauncherObject(
+                    mLauncher.findObjectInContainer(mMenu, By.text("App info")));
+
+            try (LauncherInstrumentation.Closable c1 = mLauncher.addContextLayer(
+                    "tapped app info menu item")) {
+                mLauncher.waitUntilSystemLauncherObjectGone("overview_panel");
+                return new LaunchedAppState(mLauncher);
+            }
+        }
+    }
+
     /** Returns true if an item matching the given string is present in the menu. */
     public boolean hasMenuItem(String expectedMenuItemText) {
         UiObject2 menuItem = mLauncher.findObjectInContainer(mMenu, By.text(expectedMenuItemText));