Merge "Promote testSplitTaskTapBothIconMenus to presubmit" into main
diff --git a/OWNERS b/OWNERS
index a66bf54..22efa33 100644
--- a/OWNERS
+++ b/OWNERS
@@ -30,6 +30,7 @@
 jeremysim@google.com
 atsjenk@google.com
 brianji@google.com
+hwwang@google.com
 
 # Overview eng team
 alexchau@google.com
@@ -52,4 +53,4 @@
 per-file DeviceConfigWrapper.java = sunnygoyal@google.com, winsonc@google.com, adamcohen@google.com, hyunyoungs@google.com
 
 # Predictive Back
-per-file LauncherBackAnimationController.java = shanh@google.com, gallmann@google.com
\ No newline at end of file
+per-file LauncherBackAnimationController.java = shanh@google.com, gallmann@google.com
diff --git a/aconfig/launcher.aconfig b/aconfig/launcher.aconfig
index f1f9966..15ac9e3 100644
--- a/aconfig/launcher.aconfig
+++ b/aconfig/launcher.aconfig
@@ -311,6 +311,13 @@
 }
 
 flag {
+    name: "enable_container_return_animations"
+    namespace: "launcher"
+    description: "Enables the container return animation mirroring launches."
+    bug: "341017746"
+}
+
+flag {
     name: "floating_search_bar"
     namespace: "launcher"
     description: "Search bar persists at the bottom of the screen across Launcher states"
diff --git a/go/quickstep/res/values-fa/strings.xml b/go/quickstep/res/values-fa/strings.xml
index 47786e9..8453d4e 100644
--- a/go/quickstep/res/values-fa/strings.xml
+++ b/go/quickstep/res/values-fa/strings.xml
@@ -14,7 +14,7 @@
     <string name="assistant_not_selected_text" msgid="3244613673884359276">"برای گوش کردن به نوشتار در صفحه‌نمایش‌تان یا ترجمه کردن آن، یکی از برنامه‌های دستیار دیجیتالی را در «تنظیمات» انتخاب کنید"</string>
     <string name="assistant_not_supported_title" msgid="1675788067597484142">"برای استفاده از این ویژگی، دستیارتان را تغییر دهید"</string>
     <string name="assistant_not_supported_text" msgid="1708031078549268884">"برای گوش کردن به نوشتار در صفحه‌نمایش‌تان یا ترجمه کردن آن، برنامه دستیار دیجیتالی‌تان را در «تنظیمات» تغییر دهید"</string>
-    <string name="tooltip_listen" msgid="7634466447860989102">"برای گوش کردن به نوشتار در این صفحه، اینجا ضربه بزنید"</string>
-    <string name="tooltip_translate" msgid="4184845868901542567">"برای ترجمه نوشتار در این صفحه، اینجا ضربه بزنید"</string>
+    <string name="tooltip_listen" msgid="7634466447860989102">"برای گوش کردن به نوشتار در این صفحه، اینجا تک‌ضرب بزنید"</string>
+    <string name="tooltip_translate" msgid="4184845868901542567">"برای ترجمه نوشتار در این صفحه، اینجا تک‌ضرب بزنید"</string>
     <string name="toast_p2p_app_not_shareable" msgid="7229739094132131536">"نمی‌توان این برنامه را هم‌رسانی کرد"</string>
 </resources>
diff --git a/go/quickstep/res/values-nb/strings.xml b/go/quickstep/res/values-nb/strings.xml
index 662b544..6299cc8 100644
--- a/go/quickstep/res/values-nb/strings.xml
+++ b/go/quickstep/res/values-nb/strings.xml
@@ -9,7 +9,7 @@
     <string name="dialog_cancel" msgid="6464336969134856366">"AVBRYT"</string>
     <string name="dialog_settings" msgid="6564397136021186148">"INNSTILLINGER"</string>
     <string name="niu_actions_confirmation_title" msgid="3863451714863526143">"Oversett eller lytt til tekst på skjermen"</string>
-    <string name="niu_actions_confirmation_text" msgid="2105271481950866089">"Informasjon som tekst på skjermen, nettadresser og skjermdumper kan deles med Google.\n\nFor å endre hvilken informasjon du deler, gå til "<b>"Innstillinger &gt; Apper &gt; Standardapper &gt; Digital assistent-app"</b>"."</string>
+    <string name="niu_actions_confirmation_text" msgid="2105271481950866089">"Informasjon som tekst på skjermen, nettadresser og skjermbilder kan deles med Google.\n\nFor å endre hvilken informasjon du deler, gå til "<b>"Innstillinger &gt; Apper &gt; Standardapper &gt; Digital assistent-app"</b>"."</string>
     <string name="assistant_not_selected_title" msgid="5017072974603345228">"Velg en assistent for å bruke denne funksjonen"</string>
     <string name="assistant_not_selected_text" msgid="3244613673884359276">"For å høre eller oversette tekst på skjermen, velg en digital assistent-app i innstillingene"</string>
     <string name="assistant_not_supported_title" msgid="1675788067597484142">"Endre assistenten for å bruke denne funksjonen"</string>
diff --git a/quickstep/res/values-en-rAU/strings.xml b/quickstep/res/values-en-rAU/strings.xml
index eedb29e..b84f646 100644
--- a/quickstep/res/values-en-rAU/strings.xml
+++ b/quickstep/res/values-en-rAU/strings.xml
@@ -87,7 +87,7 @@
     <string name="gesture_tutorial_try_again" msgid="65962545858556697">"Try again"</string>
     <string name="gesture_tutorial_nice" msgid="2936275692616928280">"Nice!"</string>
     <string name="gesture_tutorial_step" msgid="1279786122817620968">"Tutorial <xliff:g id="CURRENT">%1$d</xliff:g>/<xliff:g id="TOTAL">%2$d</xliff:g>"</string>
-    <string name="allset_title" msgid="5021126669778966707">"Ready!"</string>
+    <string name="allset_title" msgid="5021126669778966707">"All set!"</string>
     <string name="allset_hint" msgid="459504134589971527">"Swipe up to go home"</string>
     <string name="allset_button_hint" msgid="2395219947744706291">"Tap the home button to go to your home screen"</string>
     <string name="allset_description_generic" msgid="5385500062202019855">"You’re ready to start using your <xliff:g id="DEVICE">%1$s</xliff:g>"</string>
diff --git a/quickstep/res/values-en-rGB/strings.xml b/quickstep/res/values-en-rGB/strings.xml
index eedb29e..b84f646 100644
--- a/quickstep/res/values-en-rGB/strings.xml
+++ b/quickstep/res/values-en-rGB/strings.xml
@@ -87,7 +87,7 @@
     <string name="gesture_tutorial_try_again" msgid="65962545858556697">"Try again"</string>
     <string name="gesture_tutorial_nice" msgid="2936275692616928280">"Nice!"</string>
     <string name="gesture_tutorial_step" msgid="1279786122817620968">"Tutorial <xliff:g id="CURRENT">%1$d</xliff:g>/<xliff:g id="TOTAL">%2$d</xliff:g>"</string>
-    <string name="allset_title" msgid="5021126669778966707">"Ready!"</string>
+    <string name="allset_title" msgid="5021126669778966707">"All set!"</string>
     <string name="allset_hint" msgid="459504134589971527">"Swipe up to go home"</string>
     <string name="allset_button_hint" msgid="2395219947744706291">"Tap the home button to go to your home screen"</string>
     <string name="allset_description_generic" msgid="5385500062202019855">"You’re ready to start using your <xliff:g id="DEVICE">%1$s</xliff:g>"</string>
diff --git a/quickstep/res/values-en-rIN/strings.xml b/quickstep/res/values-en-rIN/strings.xml
index eedb29e..b84f646 100644
--- a/quickstep/res/values-en-rIN/strings.xml
+++ b/quickstep/res/values-en-rIN/strings.xml
@@ -87,7 +87,7 @@
     <string name="gesture_tutorial_try_again" msgid="65962545858556697">"Try again"</string>
     <string name="gesture_tutorial_nice" msgid="2936275692616928280">"Nice!"</string>
     <string name="gesture_tutorial_step" msgid="1279786122817620968">"Tutorial <xliff:g id="CURRENT">%1$d</xliff:g>/<xliff:g id="TOTAL">%2$d</xliff:g>"</string>
-    <string name="allset_title" msgid="5021126669778966707">"Ready!"</string>
+    <string name="allset_title" msgid="5021126669778966707">"All set!"</string>
     <string name="allset_hint" msgid="459504134589971527">"Swipe up to go home"</string>
     <string name="allset_button_hint" msgid="2395219947744706291">"Tap the home button to go to your home screen"</string>
     <string name="allset_description_generic" msgid="5385500062202019855">"You’re ready to start using your <xliff:g id="DEVICE">%1$s</xliff:g>"</string>
diff --git a/quickstep/res/values-fa/strings.xml b/quickstep/res/values-fa/strings.xml
index b296080..bafc2d5 100644
--- a/quickstep/res/values-fa/strings.xml
+++ b/quickstep/res/values-fa/strings.xml
@@ -89,7 +89,7 @@
     <string name="gesture_tutorial_step" msgid="1279786122817620968">"آموزش گام‌به‌گام <xliff:g id="CURRENT">%1$d</xliff:g>/<xliff:g id="TOTAL">%2$d</xliff:g>"</string>
     <string name="allset_title" msgid="5021126669778966707">"همه چیز آماده است!"</string>
     <string name="allset_hint" msgid="459504134589971527">"برای رفتن به صفحه اصلی، تند به‌بالا بکشید"</string>
-    <string name="allset_button_hint" msgid="2395219947744706291">"برای رفتن به صفحه اصلی، روی دکمه صفحه اصلی ضربه بزنید"</string>
+    <string name="allset_button_hint" msgid="2395219947744706291">"برای رفتن به صفحه اصلی، روی دکمه صفحه اصلی تک‌ضرب بزنید"</string>
     <string name="allset_description_generic" msgid="5385500062202019855">"آماده‌اید از <xliff:g id="DEVICE">%1$s</xliff:g> خود استفاده کنید"</string>
     <string name="default_device_name" msgid="6660656727127422487">"دستگاه"</string>
     <string name="allset_navigation_settings" msgid="4713404605961476027"><annotation id="link">"تنظیمات پیمایش سیستم"</annotation></string>
diff --git a/quickstep/res/values-ko/strings.xml b/quickstep/res/values-ko/strings.xml
index 1f4275a..c27b7f8 100644
--- a/quickstep/res/values-ko/strings.xml
+++ b/quickstep/res/values-ko/strings.xml
@@ -141,7 +141,7 @@
     <string name="quick_switch_desktop" msgid="4834587349322698616">"{count,plural, =1{데스크톱 앱 #개를 표시합니다.}other{데스크톱 앱 #개를 표시합니다.}}"</string>
     <string name="quick_switch_split_task" msgid="5598194724255333896">"<xliff:g id="APP_NAME_1">%1$s</xliff:g> 및 <xliff:g id="APP_NAME_2">%2$s</xliff:g>"</string>
     <string name="bubble_bar_bubble_fallback_description" msgid="7811684548953452009">"풍선"</string>
-    <string name="bubble_bar_overflow_description" msgid="8617628132733151708">"오버플로"</string>
+    <string name="bubble_bar_overflow_description" msgid="8617628132733151708">"더보기"</string>
     <string name="bubble_bar_bubble_description" msgid="1882466152448446446">"<xliff:g id="APP_NAME">%2$s</xliff:g>의 <xliff:g id="NOTIFICATION_TITLE">%1$s</xliff:g>"</string>
     <string name="bubble_bar_description_multiple_bubbles" msgid="3922207715357143648">"<xliff:g id="BUBBLE_BAR_BUBBLE_DESCRIPTION">%1$s</xliff:g> 외 <xliff:g id="BUBBLE_COUNT">%2$d</xliff:g>개"</string>
 </resources>
diff --git a/quickstep/res/values-mn/strings.xml b/quickstep/res/values-mn/strings.xml
index 7a4c7e9..fe2e4a4 100644
--- a/quickstep/res/values-mn/strings.xml
+++ b/quickstep/res/values-mn/strings.xml
@@ -141,7 +141,7 @@
     <string name="quick_switch_desktop" msgid="4834587349322698616">"{count,plural, =1{Компьютерын # аппыг харуулна уу.}other{Компьютерын # аппыг харуулна уу.}}"</string>
     <string name="quick_switch_split_task" msgid="5598194724255333896">"<xliff:g id="APP_NAME_1">%1$s</xliff:g> болон <xliff:g id="APP_NAME_2">%2$s</xliff:g>"</string>
     <string name="bubble_bar_bubble_fallback_description" msgid="7811684548953452009">"Бөмбөлөг"</string>
-    <string name="bubble_bar_overflow_description" msgid="8617628132733151708">"Урт цэс"</string>
+    <string name="bubble_bar_overflow_description" msgid="8617628132733151708">"Илүү хэсэг"</string>
     <string name="bubble_bar_bubble_description" msgid="1882466152448446446">"<xliff:g id="APP_NAME">%2$s</xliff:g>-с ирсэн <xliff:g id="NOTIFICATION_TITLE">%1$s</xliff:g>"</string>
     <string name="bubble_bar_description_multiple_bubbles" msgid="3922207715357143648">"<xliff:g id="BUBBLE_BAR_BUBBLE_DESCRIPTION">%1$s</xliff:g> болон бусад <xliff:g id="BUBBLE_COUNT">%2$d</xliff:g>"</string>
 </resources>
diff --git a/quickstep/res/values-pt-rPT/strings.xml b/quickstep/res/values-pt-rPT/strings.xml
index 47498cc..e4d07bd 100644
--- a/quickstep/res/values-pt-rPT/strings.xml
+++ b/quickstep/res/values-pt-rPT/strings.xml
@@ -113,7 +113,7 @@
     <string name="taskbar_edu_splitscreen" msgid="5605512479258053350">"Arraste uma app para o lado para usar 2 apps em simultâneo"</string>
     <string name="taskbar_edu_stashing" msgid="5645461372669217294">"Deslize lentamente para cima para ver a Barra de tarefas"</string>
     <string name="taskbar_edu_suggestions" msgid="8215044496435527982">"Receba sugestões de apps baseadas na sua rotina"</string>
-    <string name="taskbar_edu_pinning" msgid="6708550858580071558">"Mantenha o divisor premido para fixar a Barra de tarefas"</string>
+    <string name="taskbar_edu_pinning" msgid="6708550858580071558">"Mantenha o divisor pressionado para fixar a Barra de tarefas"</string>
     <string name="taskbar_edu_features" msgid="3320337287472848162">"Faça mais com a Barra de tarefas"</string>
     <string name="taskbar_edu_pinning_title" msgid="210102174154211712">"Mostre sempre a Barra de tarefas"</string>
     <string name="taskbar_edu_pinning_standalone" msgid="2636919474366410467">"Para mostrar sempre a Barra de tarefas no fundo do ecrã, toque sem soltar no divisor"</string>
diff --git a/quickstep/res/values-zh-rCN/strings.xml b/quickstep/res/values-zh-rCN/strings.xml
index a89227e..79ea299 100644
--- a/quickstep/res/values-zh-rCN/strings.xml
+++ b/quickstep/res/values-zh-rCN/strings.xml
@@ -141,7 +141,7 @@
     <string name="quick_switch_desktop" msgid="4834587349322698616">"{count,plural, =1{显示 # 款桌面应用。}other{显示 # 款桌面应用。}}"</string>
     <string name="quick_switch_split_task" msgid="5598194724255333896">"<xliff:g id="APP_NAME_1">%1$s</xliff:g>和<xliff:g id="APP_NAME_2">%2$s</xliff:g>"</string>
     <string name="bubble_bar_bubble_fallback_description" msgid="7811684548953452009">"气泡框"</string>
-    <string name="bubble_bar_overflow_description" msgid="8617628132733151708">"菜单"</string>
+    <string name="bubble_bar_overflow_description" msgid="8617628132733151708">"溢出式气泡框"</string>
     <string name="bubble_bar_bubble_description" msgid="1882466152448446446">"来自“<xliff:g id="APP_NAME">%2$s</xliff:g>”的<xliff:g id="NOTIFICATION_TITLE">%1$s</xliff:g>"</string>
     <string name="bubble_bar_description_multiple_bubbles" msgid="3922207715357143648">"<xliff:g id="BUBBLE_BAR_BUBBLE_DESCRIPTION">%1$s</xliff:g>以及另外 <xliff:g id="BUBBLE_COUNT">%2$d</xliff:g> 个"</string>
 </resources>
diff --git a/quickstep/src/com/android/launcher3/LauncherAnimationRunner.java b/quickstep/src/com/android/launcher3/LauncherAnimationRunner.java
index 15180ef..d973149 100644
--- a/quickstep/src/com/android/launcher3/LauncherAnimationRunner.java
+++ b/quickstep/src/com/android/launcher3/LauncherAnimationRunner.java
@@ -238,5 +238,12 @@
         @Override
         @UiThread
         default void onAnimationCancelled() {}
+
+        /**
+         * Returns whether this animation factory supports a tightly coupled return animation.
+         */
+        default boolean supportsReturnTransition() {
+            return false;
+        }
     }
 }
diff --git a/quickstep/src/com/android/launcher3/QuickstepTransitionManager.java b/quickstep/src/com/android/launcher3/QuickstepTransitionManager.java
index fae281a..5a74f4a 100644
--- a/quickstep/src/com/android/launcher3/QuickstepTransitionManager.java
+++ b/quickstep/src/com/android/launcher3/QuickstepTransitionManager.java
@@ -43,6 +43,7 @@
 import static com.android.launcher3.BaseActivity.INVISIBLE_BY_APP_TRANSITIONS;
 import static com.android.launcher3.BaseActivity.INVISIBLE_BY_PENDING_FLAGS;
 import static com.android.launcher3.BaseActivity.PENDING_INVISIBLE_BY_WALLPAPER_ANIMATION;
+import static com.android.launcher3.Flags.enableContainerReturnAnimations;
 import static com.android.launcher3.Flags.enableScalingRevealHomeAnimation;
 import static com.android.launcher3.LauncherAnimUtils.SCALE_PROPERTY;
 import static com.android.launcher3.LauncherAnimUtils.VIEW_BACKGROUND_COLOR;
@@ -68,6 +69,7 @@
 import static com.android.quickstep.TaskViewUtils.findTaskViewToLaunch;
 import static com.android.quickstep.util.AnimUtils.clampToDuration;
 import static com.android.quickstep.util.AnimUtils.completeRunnableListCallback;
+import static com.android.systemui.shared.Flags.returnAnimationFrameworkLibrary;
 import static com.android.systemui.shared.system.QuickStepContract.getWindowCornerRadius;
 import static com.android.systemui.shared.system.QuickStepContract.supportsRoundedCornersOnWindows;
 
@@ -181,6 +183,9 @@
  */
 public class QuickstepTransitionManager implements OnDeviceProfileChangeListener {
 
+    private static final String TRANSITION_COOKIE_PREFIX =
+            "com.android.launcher3.QuickstepTransitionManager_activityLaunch";
+
     private static final boolean ENABLE_SHELL_STARTING_SURFACE =
             SystemProperties.getBoolean("persist.debug.shell_starting_surface", true);
 
@@ -333,17 +338,7 @@
         restartedListener.register(onEndCallback::executeAllAndDestroy);
         onEndCallback.add(restartedListener::unregister);
 
-        mAppLaunchRunner = new AppLaunchAnimationRunner(v, onEndCallback);
-        ItemInfo tag = (ItemInfo) v.getTag();
-        if (tag != null && tag.shouldUseBackgroundAnimation()) {
-            ContainerAnimationRunner containerAnimationRunner = ContainerAnimationRunner.from(
-                    v, mLauncher, mStartingWindowListener, onEndCallback);
-            if (containerAnimationRunner != null) {
-                mAppLaunchRunner = containerAnimationRunner;
-            }
-        }
-        RemoteAnimationRunnerCompat runner = new LauncherAnimationRunner(
-                mHandler, mAppLaunchRunner, true /* startAtFrontOfQueue */);
+        RemoteAnimationRunnerCompat runner = createAppLaunchRunner(v, onEndCallback);
 
         // Note that this duration is a guess as we do not know if the animation will be a
         // recents launch or not for sure until we know the opening app targets.
@@ -360,10 +355,95 @@
         IRemoteCallback endCallback = completeRunnableListCallback(onEndCallback);
         options.setOnAnimationAbortListener(endCallback);
         options.setOnAnimationFinishedListener(endCallback);
+
+        IBinder cookie = mAppLaunchRunner.supportsReturnTransition()
+                ? ((ContainerAnimationRunner) mAppLaunchRunner).getCookie() : null;
+        addLaunchCookie(cookie, (ItemInfo) v.getTag(), options);
+
+        // Register the return animation so it can be triggered on back from the app to home.
+        maybeRegisterAppReturnTransition(v);
+
         return new ActivityOptionsWrapper(options, onEndCallback);
     }
 
     /**
+     * Selects the appropriate type of launch runner for the given view, builds it, and returns it.
+     * {@link QuickstepTransitionManager#mAppLaunchRunner} is updated as a by-product of this
+     * method.
+     */
+    private RemoteAnimationRunnerCompat createAppLaunchRunner(View v, RunnableList onEndCallback) {
+        ItemInfo tag = (ItemInfo) v.getTag();
+        ContainerAnimationRunner containerRunner = null;
+        if (tag != null && tag.shouldUseBackgroundAnimation()) {
+            // The cookie should only override the default used by launcher if container return
+            // animations are enabled.
+            ActivityTransitionAnimator.TransitionCookie cookie =
+                    checkReturnAnimationsFlags()
+                            ? new ActivityTransitionAnimator.TransitionCookie(
+                                    TRANSITION_COOKIE_PREFIX + tag.id)
+                            : null;
+            ContainerAnimationRunner launchAnimationRunner =
+                    ContainerAnimationRunner.fromView(
+                            v, cookie, true /* forLaunch */, mLauncher, mStartingWindowListener,
+                            onEndCallback);
+
+            if (launchAnimationRunner != null) {
+                containerRunner = launchAnimationRunner;
+            }
+        }
+
+        mAppLaunchRunner = containerRunner != null
+                ? containerRunner : new AppLaunchAnimationRunner(v, onEndCallback);
+        return new LauncherAnimationRunner(
+                mHandler, mAppLaunchRunner, true /* startAtFrontOfQueue */);
+    }
+
+    /**
+     * If container return animations are enabled and the current launch runner is itself a
+     * {@link ContainerAnimationRunner}, registers a matching return animation that de-registers
+     * itself after it has run once or is made obsolete by the view going away.
+     */
+    private void maybeRegisterAppReturnTransition(View v) {
+        if (!checkReturnAnimationsFlags() || !mAppLaunchRunner.supportsReturnTransition()) {
+            return;
+        }
+
+        ActivityTransitionAnimator.TransitionCookie cookie =
+                ((ContainerAnimationRunner) mAppLaunchRunner).getCookie();
+        RunnableList onEndCallback = new RunnableList();
+        ContainerAnimationRunner runner =
+                ContainerAnimationRunner.fromView(
+                        v, cookie, false /* forLaunch */, mLauncher, mStartingWindowListener,
+                        onEndCallback);
+        RemoteTransition transition =
+                new RemoteTransition(
+                        new LauncherAnimationRunner(
+                                mHandler, runner, true /* startAtFrontOfQueue */
+                        ).toRemoteTransition()
+                );
+
+        SystemUiProxy.INSTANCE.get(mLauncher).registerRemoteTransition(
+                transition, ContainerAnimationRunner.buildBackToHomeFilter(cookie, mLauncher));
+        ContainerAnimationRunner.setUpRemoteAnimationCleanup(
+                v, transition, onEndCallback, mLauncher);
+    }
+
+    /**
+     * Adds a new launch cookie for the activity launch if supported.
+     * Prioritizes the explicitly provided cookie, falling back on extracting one from the given
+     * {@link ItemInfo} if necessary.
+     */
+    private void addLaunchCookie(IBinder cookie, ItemInfo info, ActivityOptions options) {
+        if (cookie == null) {
+            cookie = mLauncher.getLaunchCookie(info);
+        }
+
+        if (cookie != null) {
+            options.setLaunchCookie(cookie);
+        }
+    }
+
+    /**
      * Whether the launch is a recents app transition and we should do a launch animation
      * from the recents view. Note that if the remote animation targets are not provided, this
      * may not always be correct as we may resolve the opening app to a task when the animation
@@ -1728,6 +1808,10 @@
         }
     }
 
+    private static boolean checkReturnAnimationsFlags() {
+        return enableContainerReturnAnimations() && returnAnimationFrameworkLibrary();
+    }
+
     /**
      * Remote animation runner for animation from the app to Launcher, including recents.
      */
@@ -1844,38 +1928,45 @@
         /** The delegate runner that handles the actual animation. */
         private final RemoteAnimationDelegate<IRemoteAnimationFinishedCallback> mDelegate;
 
+        @Nullable
+        private final ActivityTransitionAnimator.TransitionCookie mCookie;
+
         private ContainerAnimationRunner(
-                RemoteAnimationDelegate<IRemoteAnimationFinishedCallback> delegate) {
+                RemoteAnimationDelegate<IRemoteAnimationFinishedCallback> delegate,
+                ActivityTransitionAnimator.TransitionCookie cookie) {
             mDelegate = delegate;
+            mCookie = cookie;
         }
 
         @Nullable
-        private static ContainerAnimationRunner from(View v, Launcher launcher,
-                StartingWindowListener startingWindowListener, RunnableList onEndCallback) {
-            View viewToUse = findLaunchableViewWithBackground(v);
-            if (viewToUse == null) {
-                return null;
+        ActivityTransitionAnimator.TransitionCookie getCookie() {
+            return mCookie;
+        }
+
+        @Nullable
+        static ContainerAnimationRunner fromView(
+                View v,
+                ActivityTransitionAnimator.TransitionCookie cookie,
+                boolean forLaunch,
+                Launcher launcher,
+                StartingWindowListener startingWindowListener,
+                RunnableList onEndCallback) {
+            if (!forLaunch && !checkReturnAnimationsFlags()) {
+                throw new IllegalStateException(
+                        "forLaunch cannot be false when the enableContainerReturnAnimations or "
+                                + "returnAnimationFrameworkLibrary flag is disabled");
             }
 
-            // The CUJ is logged by the click handler, so we don't log it inside the animation
-            // library.
-            ActivityTransitionAnimator.Controller controllerDelegate =
-                    ActivityTransitionAnimator.Controller.fromView(viewToUse, null /* cujType */);
-
-            if (controllerDelegate == null) {
-                return null;
-            }
-
-            // This wrapper allows us to override the default value, telling the controller that the
-            // current window is below the animating window.
+            // First the controller is created. This is used by the runner to animate the
+            // origin/target view.
             ActivityTransitionAnimator.Controller controller =
-                    new DelegateTransitionAnimatorController(controllerDelegate) {
-                        @Override
-                        public boolean isBelowAnimatingWindow() {
-                            return true;
-                        }
-                    };
+                    buildController(v, cookie, forLaunch);
+            if (controller == null) {
+                return null;
+            }
 
+            // The callback is used to make sure that we use the right color to fade between view
+            // and the window.
             ActivityTransitionAnimator.Callback callback = task -> {
                 final int backgroundColor =
                         startingWindowListener.mBackgroundColor == Color.TRANSPARENT
@@ -1894,7 +1985,52 @@
 
             return new ContainerAnimationRunner(
                     new ActivityTransitionAnimator.AnimationDelegate(
-                            MAIN_EXECUTOR, controller, callback, listener));
+                            MAIN_EXECUTOR, controller, callback, listener),
+                    cookie);
+        }
+
+        /**
+         * Constructs a {@link ActivityTransitionAnimator.Controller} that can be used by a
+         * {@link ContainerAnimationRunner} to animate a view into an opening window or from a
+         * closing one.
+         */
+        @Nullable
+        private static ActivityTransitionAnimator.Controller buildController(
+                View v, ActivityTransitionAnimator.TransitionCookie cookie, boolean isLaunching) {
+            View viewToUse = findLaunchableViewWithBackground(v);
+            if (viewToUse == null) {
+                return null;
+            }
+
+            // The CUJ is logged by the click handler, so we don't log it inside the animation
+            // library. TODO: figure out return CUJ.
+            ActivityTransitionAnimator.Controller controllerDelegate =
+                    ActivityTransitionAnimator.Controller.fromView(viewToUse, null /* cujType */);
+
+            if (controllerDelegate == null) {
+                return null;
+            }
+
+            // This wrapper allows us to override the default value, telling the controller that the
+            // current window is below the animating window as well as information about the return
+            // animation.
+            return new DelegateTransitionAnimatorController(controllerDelegate) {
+                @Override
+                public boolean isLaunching() {
+                    return isLaunching;
+                }
+
+                @Override
+                public boolean isBelowAnimatingWindow() {
+                    return true;
+                }
+
+                @Nullable
+                @Override
+                public ActivityTransitionAnimator.TransitionCookie getTransitionCookie() {
+                    return cookie;
+                }
+            };
         }
 
         /**
@@ -1916,6 +2052,67 @@
             return (T) current;
         }
 
+        /**
+         * Builds the filter used by WM Shell to match app closing transitions (only back, no home
+         * button/gesture) to the given launch cookie.
+         */
+        static TransitionFilter buildBackToHomeFilter(
+                ActivityTransitionAnimator.TransitionCookie cookie, Launcher launcher) {
+            // Closing activity must include the cookie in its list of launch cookies.
+            TransitionFilter.Requirement appRequirement = new TransitionFilter.Requirement();
+            appRequirement.mActivityType = ACTIVITY_TYPE_STANDARD;
+            appRequirement.mLaunchCookie = cookie;
+            appRequirement.mModes = new int[]{TRANSIT_CLOSE, TRANSIT_TO_BACK};
+            // Opening activity must be Launcher.
+            TransitionFilter.Requirement launcherRequirement = new TransitionFilter.Requirement();
+            launcherRequirement.mActivityType = ACTIVITY_TYPE_HOME;
+            launcherRequirement.mModes = new int[]{TRANSIT_OPEN, TRANSIT_TO_FRONT};
+            launcherRequirement.mTopActivity = launcher.getComponentName();
+            // Transition types CLOSE and TO_BACK match the back button/gesture but not the  home
+            // button/gesture.
+            TransitionFilter filter = new TransitionFilter();
+            filter.mTypeSet = new int[]{TRANSIT_CLOSE, TRANSIT_TO_BACK};
+            filter.mRequirements =
+                    new TransitionFilter.Requirement[]{appRequirement, launcherRequirement};
+            return filter;
+        }
+
+        /**
+         * Creates various conditions to ensure that the given transition is cleaned up correctly
+         * when necessary:
+         * - if the transition has run, it is the callback that unregisters it;
+         * - if the associated view is detached before the transition has had an opportunity to run,
+         *   a {@link View.OnAttachStateChangeListener} allows us to do the same (and removes
+         *   itself).
+         */
+        static void setUpRemoteAnimationCleanup(
+                View v, RemoteTransition transition, RunnableList callback, Launcher launcher) {
+            View.OnAttachStateChangeListener listener = new View.OnAttachStateChangeListener() {
+                @Override
+                public void onViewAttachedToWindow(@NonNull View v) {}
+
+                @Override
+                public void onViewDetachedFromWindow(@NonNull View v) {
+                    SystemUiProxy.INSTANCE.get(launcher)
+                            .unregisterRemoteTransition(transition);
+                    v.removeOnAttachStateChangeListener(this);
+                }
+            };
+
+            // Remove the animation as soon as it has run once.
+            callback.add(() -> {
+                SystemUiProxy.INSTANCE.get(launcher).unregisterRemoteTransition(transition);
+                if (v != null) {
+                    v.removeOnAttachStateChangeListener(listener);
+                }
+            });
+
+            // Remove the animation when the view is detached from the hierarchy.
+            // This is so that if back is not invoked (e.g. if we go back home through the home
+            // gesture) we don't have obsolete transitions staying registered.
+            v.addOnAttachStateChangeListener(listener);
+        }
+
         @Override
         public void onAnimationStart(int transit, RemoteAnimationTarget[] appTargets,
                 RemoteAnimationTarget[] wallpaperTargets, RemoteAnimationTarget[] nonAppTargets,
@@ -1928,6 +2125,11 @@
         public void onAnimationCancelled() {
             mDelegate.onAnimationCancelled();
         }
+
+        @Override
+        public boolean supportsReturnTransition() {
+            return true;
+        }
     }
 
     /**
diff --git a/quickstep/src/com/android/launcher3/WidgetPickerActivity.java b/quickstep/src/com/android/launcher3/WidgetPickerActivity.java
index 7e52ea1..7cdca74 100644
--- a/quickstep/src/com/android/launcher3/WidgetPickerActivity.java
+++ b/quickstep/src/com/android/launcher3/WidgetPickerActivity.java
@@ -120,10 +120,6 @@
         WindowInsetsController wc = mDragLayer.getWindowInsetsController();
         wc.hide(navigationBars() + statusBars());
 
-        BaseWidgetSheet widgetSheet = WidgetsFullSheet.show(this, true);
-        widgetSheet.disableNavBarScrim(true);
-        widgetSheet.addOnCloseListener(this::finish);
-
         parseIntentExtras();
         refreshAndBindWidgets();
     }
@@ -224,9 +220,10 @@
         };
     }
 
-    /** Updates the model with widgets and provides them after applying the provided filter. */
+    /** Updates the model with widgets, applies filters and launches the widgets sheet once
+     * widgets are available */
     private void refreshAndBindWidgets() {
-        MODEL_EXECUTOR.execute(() -> {
+        MODEL_EXECUTOR.getHandler().postDelayed(() -> {
             LauncherAppState app = LauncherAppState.getInstance(this);
             mModel.update(app, null);
             final List<WidgetsListBaseEntry> allWidgets =
@@ -240,6 +237,9 @@
                             }
                     );
             bindWidgets(allWidgets);
+            // Open sheet once widgets are available, so that it doesn't interrupt the open
+            // animation.
+            openWidgetsSheet();
             if (mUiSurface != null) {
                 Map<ComponentKey, WidgetItem> allWidgetItems = allWidgets.stream()
                         .filter(entry -> entry instanceof WidgetsListContentEntry)
@@ -253,15 +253,26 @@
                         mUiSurface, allWidgetItems);
                 mWidgetPredictionsRequester.request(mAddedWidgets, this::bindRecommendedWidgets);
             }
-        });
+        }, mDeviceProfile.bottomSheetOpenDuration);
     }
 
     private void bindWidgets(List<WidgetsListBaseEntry> widgets) {
         MAIN_EXECUTOR.execute(() -> mPopupDataProvider.setAllWidgets(widgets));
     }
 
+    private void openWidgetsSheet() {
+        MAIN_EXECUTOR.execute(() -> {
+            BaseWidgetSheet widgetSheet = WidgetsFullSheet.show(this, true);
+            widgetSheet.disableNavBarScrim(true);
+            widgetSheet.addOnCloseListener(this::finish);
+        });
+    }
+
     private void bindRecommendedWidgets(List<ItemInfo> recommendedWidgets) {
-        MAIN_EXECUTOR.execute(() -> mPopupDataProvider.setRecommendedWidgets(recommendedWidgets));
+        // Bind recommendations once picker has finished open animation.
+        MAIN_EXECUTOR.getHandler().postDelayed(
+                () -> mPopupDataProvider.setRecommendedWidgets(recommendedWidgets),
+                mDeviceProfile.bottomSheetOpenDuration);
     }
 
     @Override
diff --git a/quickstep/src/com/android/launcher3/model/QuickstepModelDelegate.java b/quickstep/src/com/android/launcher3/model/QuickstepModelDelegate.java
index 8b5ed7c..6af5a30 100644
--- a/quickstep/src/com/android/launcher3/model/QuickstepModelDelegate.java
+++ b/quickstep/src/com/android/launcher3/model/QuickstepModelDelegate.java
@@ -205,6 +205,7 @@
         mActive = true;
     }
 
+    @WorkerThread
     @Override
     public void workspaceLoadComplete() {
         super.workspaceLoadComplete();
@@ -323,6 +324,7 @@
         }
     }
 
+    @WorkerThread
     @Override
     public void destroy() {
         super.destroy();
diff --git a/quickstep/src/com/android/launcher3/model/WellbeingModel.java b/quickstep/src/com/android/launcher3/model/WellbeingModel.java
index a7c9652..28bc01c 100644
--- a/quickstep/src/com/android/launcher3/model/WellbeingModel.java
+++ b/quickstep/src/com/android/launcher3/model/WellbeingModel.java
@@ -111,6 +111,7 @@
         mWorkerHandler.post(this::initializeInBackground);
     }
 
+    @WorkerThread
     private void initializeInBackground() {
         if (!TextUtils.isEmpty(mWellbeingProviderPkg)) {
             mContext.registerReceiver(
@@ -134,8 +135,8 @@
     public void close() {
         if (!TextUtils.isEmpty(mWellbeingProviderPkg)) {
             mWorkerHandler.post(() -> {
-                mWellbeingAppChangeReceiver.unregisterReceiverSafely(mContext);
-                mAppAddRemoveReceiver.unregisterReceiverSafely(mContext);
+                mWellbeingAppChangeReceiver.unregisterReceiverSafelySync(mContext);
+                mAppAddRemoveReceiver.unregisterReceiverSafelySync(mContext);
                 mContext.getContentResolver().unregisterContentObserver(mContentObserver);
             });
         }
diff --git a/quickstep/src/com/android/launcher3/model/WidgetPredictionsRequester.java b/quickstep/src/com/android/launcher3/model/WidgetPredictionsRequester.java
index 5730273..41fcf61 100644
--- a/quickstep/src/com/android/launcher3/model/WidgetPredictionsRequester.java
+++ b/quickstep/src/com/android/launcher3/model/WidgetPredictionsRequester.java
@@ -65,6 +65,7 @@
     private final Context mContext;
     @NonNull
     private final String mUiSurface;
+    private boolean mPredictionsAvailable;
     @NonNull
     private final Map<ComponentKey, WidgetItem> mAllWidgets;
 
@@ -76,8 +77,8 @@
     }
 
     /**
-     * Requests predictions from the app predictions manager and registers the provided callback to
-     * receive updates when predictions are available.
+     * Requests one time predictions from the app predictions manager and invokes provided callback
+     * once predictions are available.
      *
      * @param existingWidgets widgets that are currently added to the surface;
      * @param callback        consumer of prediction results to be called when predictions are
@@ -159,10 +160,14 @@
     @WorkerThread
     private void bindPredictions(List<AppTarget> targets, Predicate<WidgetItem> filter,
             Consumer<List<ItemInfo>> callback) {
-        List<WidgetItem> filteredPredictions = filterPredictions(targets, mAllWidgets, filter);
-        List<ItemInfo> mappedPredictions = mapWidgetItemsToItemInfo(filteredPredictions);
+        if (!mPredictionsAvailable) {
+            mPredictionsAvailable = true;
+            List<WidgetItem> filteredPredictions = filterPredictions(targets, mAllWidgets, filter);
+            List<ItemInfo> mappedPredictions = mapWidgetItemsToItemInfo(filteredPredictions);
 
-        MAIN_EXECUTOR.execute(() -> callback.accept(mappedPredictions));
+            MAIN_EXECUTOR.execute(() -> callback.accept(mappedPredictions));
+            MODEL_EXECUTOR.execute(this::clear);
+        }
     }
 
     /**
@@ -214,5 +219,6 @@
             mAppPredictor.destroy();
             mAppPredictor = null;
         }
+        mPredictionsAvailable = false;
     }
 }
diff --git a/quickstep/src/com/android/launcher3/statehandlers/DepthController.java b/quickstep/src/com/android/launcher3/statehandlers/DepthController.java
index 747612d..4c24d95 100644
--- a/quickstep/src/com/android/launcher3/statehandlers/DepthController.java
+++ b/quickstep/src/com/android/launcher3/statehandlers/DepthController.java
@@ -19,6 +19,7 @@
 import static com.android.app.animation.Interpolators.LINEAR;
 import static com.android.launcher3.states.StateAnimationConfig.ANIM_DEPTH;
 import static com.android.launcher3.states.StateAnimationConfig.SKIP_DEPTH_CONTROLLER;
+import static com.android.launcher3.util.Executors.UI_HELPER_EXECUTOR;
 import static com.android.launcher3.util.MultiPropertyFactory.MULTI_PROPERTY_VALUE;
 
 import android.animation.Animator;
@@ -74,8 +75,9 @@
             mOnAttachListener = new View.OnAttachStateChangeListener() {
                 @Override
                 public void onViewAttachedToWindow(View view) {
-                    CrossWindowBlurListeners.getInstance().addListener(mLauncher.getMainExecutor(),
-                            mCrossWindowBlurListener);
+                    UI_HELPER_EXECUTOR.execute(() ->
+                            CrossWindowBlurListeners.getInstance().addListener(
+                                    mLauncher.getMainExecutor(), mCrossWindowBlurListener));
                     mLauncher.getScrimView().addOpaquenessListener(mOpaquenessListener);
 
                     // To handle the case where window token is invalid during last setDepth call.
@@ -108,7 +110,9 @@
 
     private void removeSecondaryListeners() {
         if (mCrossWindowBlurListener != null) {
-            CrossWindowBlurListeners.getInstance().removeListener(mCrossWindowBlurListener);
+            UI_HELPER_EXECUTOR.execute(() ->
+                    CrossWindowBlurListeners.getInstance()
+                            .removeListener(mCrossWindowBlurListener));
         }
         if (mOpaquenessListener != null) {
             mLauncher.getScrimView().removeOpaquenessListener(mOpaquenessListener);
diff --git a/quickstep/src/com/android/launcher3/taskbar/KeyboardQuickSwitchViewController.java b/quickstep/src/com/android/launcher3/taskbar/KeyboardQuickSwitchViewController.java
index d6ee92f..73819b3 100644
--- a/quickstep/src/com/android/launcher3/taskbar/KeyboardQuickSwitchViewController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/KeyboardQuickSwitchViewController.java
@@ -15,12 +15,7 @@
  */
 package com.android.launcher3.taskbar;
 
-import static android.window.SplashScreen.SPLASH_SCREEN_STYLE_UNDEFINED;
-
-import static com.android.launcher3.util.Executors.UI_HELPER_EXECUTOR;
-
 import android.animation.Animator;
-import android.app.ActivityOptions;
 import android.view.KeyEvent;
 import android.view.animation.AnimationUtils;
 import android.window.RemoteTransition;
@@ -31,13 +26,10 @@
 import com.android.launcher3.Utilities;
 import com.android.launcher3.anim.AnimatorListeners;
 import com.android.launcher3.taskbar.overlay.TaskbarOverlayContext;
-import com.android.quickstep.SystemUiProxy;
-import com.android.quickstep.util.DesktopTask;
 import com.android.quickstep.util.GroupTask;
 import com.android.quickstep.util.SlideInRemoteTransition;
 import com.android.systemui.shared.recents.model.Task;
 import com.android.systemui.shared.recents.model.ThumbnailData;
-import com.android.systemui.shared.system.ActivityManagerWrapper;
 import com.android.systemui.shared.system.QuickStepContract;
 
 import java.io.PrintWriter;
@@ -158,28 +150,8 @@
                 AnimationUtils.loadInterpolator(
                         context, android.R.interpolator.fast_out_extra_slow_in)),
                 "SlideInTransition");
-        if (task instanceof DesktopTask) {
-            UI_HELPER_EXECUTOR.execute(() ->
-                    SystemUiProxy.INSTANCE.get(mKeyboardQuickSwitchView.getContext())
-                            .showDesktopApps(
-                                    mKeyboardQuickSwitchView.getDisplay().getDisplayId(),
-                                    remoteTransition));
-        } else if (mOnDesktop) {
-            UI_HELPER_EXECUTOR.execute(() ->
-                    SystemUiProxy.INSTANCE.get(mKeyboardQuickSwitchView.getContext())
-                            .showDesktopApp(task.task1.key.id));
-        } else if (task.task2 == null) {
-            UI_HELPER_EXECUTOR.execute(() -> {
-                ActivityOptions activityOptions = mControllers.taskbarActivityContext
-                        .makeDefaultActivityOptions(SPLASH_SCREEN_STYLE_UNDEFINED).options;
-                activityOptions.setRemoteTransition(remoteTransition);
-
-                ActivityManagerWrapper.getInstance().startActivityFromRecents(
-                        task.task1.key, activityOptions);
-            });
-        } else {
-            mControllers.uiController.launchSplitTasks(task, remoteTransition);
-        }
+        mControllers.taskbarActivityContext.handleGroupTaskLaunch(
+                task, remoteTransition, mOnDesktop);
         return -1;
     }
 
diff --git a/quickstep/src/com/android/launcher3/taskbar/NavbarButtonsViewController.java b/quickstep/src/com/android/launcher3/taskbar/NavbarButtonsViewController.java
index 81581b8..63e1e01 100644
--- a/quickstep/src/com/android/launcher3/taskbar/NavbarButtonsViewController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/NavbarButtonsViewController.java
@@ -47,6 +47,7 @@
 import static com.android.systemui.shared.system.QuickStepContract.SYSUI_STATE_SCREEN_PINNING;
 import static com.android.systemui.shared.system.QuickStepContract.SYSUI_STATE_SHORTCUT_HELPER_SHOWING;
 import static com.android.systemui.shared.system.QuickStepContract.SYSUI_STATE_VOICE_INTERACTION_WINDOW_SHOWING;
+import static com.android.wm.shell.Flags.enableTaskbarOnPhones;
 
 import android.animation.ArgbEvaluator;
 import android.animation.ObjectAnimator;
@@ -678,14 +679,19 @@
                 mLightIconColorOnHome,
                 mDarkIconColorOnHome);
 
-        // Override the color from framework if nav buttons are over an opaque Taskbar surface.
-        final int iconColor = (int) argbEvaluator.evaluate(
-                mOnBackgroundNavButtonColorOverrideMultiplier.value
-                        * Math.max(
-                                mOnTaskbarBackgroundNavButtonColorOverride.value,
-                                mSlideInViewVisibleNavButtonColorOverride.value),
-                sysUiNavButtonIconColorOnHome,
-                mOnBackgroundIconColor);
+        final int iconColor;
+        if (ENABLE_TASKBAR_NAVBAR_UNIFICATION && enableTaskbarOnPhones()
+                && mContext.isPhoneMode()) {
+            iconColor = sysUiNavButtonIconColorOnHome;
+        } else {
+            // Override the color from framework if nav buttons are over an opaque Taskbar surface.
+            iconColor = (int) argbEvaluator.evaluate(
+                    mOnBackgroundNavButtonColorOverrideMultiplier.value * Math.max(
+                            mOnTaskbarBackgroundNavButtonColorOverride.value,
+                            mSlideInViewVisibleNavButtonColorOverride.value),
+                    sysUiNavButtonIconColorOnHome,
+                    mOnBackgroundIconColor);
+        }
 
         for (ImageView button : mAllButtons) {
             button.setImageTintList(ColorStateList.valueOf(iconColor));
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java
index 6b62c86..5020206 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java
@@ -67,6 +67,7 @@
 import android.view.WindowInsets;
 import android.view.WindowManager;
 import android.widget.Toast;
+import android.window.RemoteTransition;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
@@ -131,6 +132,9 @@
 import com.android.quickstep.LauncherActivityInterface;
 import com.android.quickstep.NavHandle;
 import com.android.quickstep.RecentsModel;
+import com.android.quickstep.SystemUiProxy;
+import com.android.quickstep.util.DesktopTask;
+import com.android.quickstep.util.GroupTask;
 import com.android.quickstep.views.RecentsView;
 import com.android.quickstep.views.TaskView;
 import com.android.systemui.shared.recents.model.Task;
@@ -298,7 +302,7 @@
                 TaskbarEduTooltipController.newInstance(this),
                 new KeyboardQuickSwitchController(),
                 new TaskbarPinningController(this, () ->
-                        DisplayController.INSTANCE.get(this).getInfo().isInDesktopMode()),
+                        DisplayController.isInDesktopMode(this)),
                 bubbleControllersOptional);
 
         mLauncherPrefs = LauncherPrefs.get(this);
@@ -1081,10 +1085,9 @@
         RecentsView recents = taskbarUIController.getRecentsView();
         boolean shouldCloseAllOpenViews = true;
         Object tag = view.getTag();
-        if (tag instanceof Task) {
-            Task task = (Task) tag;
-            ActivityManagerWrapper.getInstance().startActivityFromRecents(task.key,
-                    ActivityOptions.makeBasic());
+        if (tag instanceof GroupTask groupTask) {
+            handleGroupTaskLaunch(groupTask, /* remoteTransition = */ null,
+                    DisplayController.isInDesktopMode(this));
             mControllers.taskbarStashController.updateAndAnimateTransientTaskbar(true);
         } else if (tag instanceof FolderInfo) {
             // Tapping an expandable folder icon on Taskbar
@@ -1185,6 +1188,36 @@
     }
 
     /**
+     * Launches the given GroupTask with the following behavior:
+     * - If the GroupTask is a DesktopTask, launch the tasks in that Desktop.
+     * - If {@code onDesktop}, bring the given GroupTask to the front.
+     * - If the GroupTask is a single task, launch it via startActivityFromRecents.
+     * - Otherwise, we assume the GroupTask is a Split pair and launch them together.
+     */
+    public void handleGroupTaskLaunch(GroupTask task, @Nullable RemoteTransition remoteTransition,
+            boolean onDesktop) {
+        if (task instanceof DesktopTask) {
+            UI_HELPER_EXECUTOR.execute(() ->
+                    SystemUiProxy.INSTANCE.get(this).showDesktopApps(getDisplay().getDisplayId(),
+                            remoteTransition));
+        } else if (onDesktop) {
+            UI_HELPER_EXECUTOR.execute(() ->
+                    SystemUiProxy.INSTANCE.get(this).showDesktopApp(task.task1.key.id));
+        } else if (task.task2 == null) {
+            UI_HELPER_EXECUTOR.execute(() -> {
+                ActivityOptions activityOptions =
+                        makeDefaultActivityOptions(SPLASH_SCREEN_STYLE_UNDEFINED).options;
+                activityOptions.setRemoteTransition(remoteTransition);
+
+                ActivityManagerWrapper.getInstance().startActivityFromRecents(
+                        task.task1.key, activityOptions);
+            });
+        } else {
+            mControllers.uiController.launchSplitTasks(task, remoteTransition);
+        }
+    }
+
+    /**
      * Runs when the user taps a Taskbar icon in TaskbarActivityContext (Overview or inside an app),
      * and calls the appropriate method to animate and launch.
      */
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarDragController.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarDragController.java
index 4f5922c..efe42fb 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarDragController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarDragController.java
@@ -82,6 +82,7 @@
 import com.android.launcher3.util.ItemInfoMatcher;
 import com.android.launcher3.views.BubbleTextHolder;
 import com.android.quickstep.LauncherActivityInterface;
+import com.android.quickstep.util.GroupTask;
 import com.android.quickstep.util.LogUtils;
 import com.android.quickstep.util.MultiValueUpdateListener;
 import com.android.systemui.shared.recents.model.Task;
@@ -181,7 +182,9 @@
 
     private DragView startInternalDrag(
             BubbleTextView btv, @Nullable DragPreviewProvider dragPreviewProvider) {
-        float iconScale = btv.getIcon().getAnimatedScale();
+        // TODO(b/344038728): null check is only necessary because Recents doesn't use
+        //  FastBitmapDrawable
+        float iconScale = btv.getIcon() == null ? 1f : btv.getIcon().getAnimatedScale();
 
         // Clear the pressed state if necessary
         btv.clearFocus();
@@ -248,7 +251,7 @@
                 dragLayerX + dragOffset.x,
                 dragLayerY + dragOffset.y,
                 (View target, DropTarget.DragObject d, boolean success) -> {} /* DragSource */,
-                (ItemInfo) btv.getTag(),
+                btv.getTag() instanceof ItemInfo itemInfo ? itemInfo : null,
                 dragRect,
                 scale * iconScale,
                 scale,
@@ -288,7 +291,9 @@
                 initialDragViewScale,
                 dragViewScaleOnDrop,
                 scalePx);
-        dragView.setItemInfo(dragInfo);
+        if (dragInfo != null) {
+            dragView.setItemInfo(dragInfo);
+        }
         mDragObject.dragComplete = false;
 
         mDragObject.xOffset = mMotionDown.x - (dragLayerX + dragRegionLeft);
@@ -301,7 +306,8 @@
 
         mDragObject.dragSource = source;
         mDragObject.dragInfo = dragInfo;
-        mDragObject.originalDragInfo = mDragObject.dragInfo.makeShallowCopy();
+        mDragObject.originalDragInfo =
+                mDragObject.dragInfo != null ? mDragObject.dragInfo.makeShallowCopy() : null;
 
         if (mOptions.preDragCondition != null) {
             dragView.setHasDragOffset(mOptions.preDragCondition.getDragOffset().x != 0
@@ -431,8 +437,8 @@
                                 null, item.user));
             }
             intent.putExtra(Intent.EXTRA_USER, item.user);
-        } else if (tag instanceof Task) {
-            Task task = (Task) tag;
+        } else if (tag instanceof GroupTask groupTask && !groupTask.hasMultipleTasks()) {
+            Task task = groupTask.task1;
             clipDescription = new ClipDescription(task.titleDescription,
                     new String[] {
                             ClipDescription.MIMETYPE_APPLICATION_TASK
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarDragLayer.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarDragLayer.java
index f703463..a9b34d2 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarDragLayer.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarDragLayer.java
@@ -18,7 +18,6 @@
 import static android.view.KeyEvent.ACTION_UP;
 import static android.view.KeyEvent.KEYCODE_BACK;
 
-import static com.android.launcher3.Flags.enableScalingRevealHomeAnimation;
 import static com.android.launcher3.config.FeatureFlags.ENABLE_TASKBAR_NAVBAR_UNIFICATION;
 
 import android.content.Context;
@@ -42,7 +41,6 @@
 import com.android.launcher3.AbstractFloatingView;
 import com.android.launcher3.testing.TestLogging;
 import com.android.launcher3.testing.shared.TestProtocol;
-import com.android.launcher3.util.DisplayController;
 import com.android.launcher3.util.MultiPropertyFactory;
 import com.android.launcher3.util.MultiPropertyFactory.MultiProperty;
 import com.android.launcher3.views.BaseDragLayer;
@@ -106,10 +104,6 @@
         mTaskbarBackgroundAlpha = new MultiPropertyFactory<>(this, BG_ALPHA, INDEX_COUNT,
                 (a, b) -> a * b, 1f);
         mTaskbarBackgroundAlpha.get(INDEX_ALL_OTHER_STATES).setValue(0);
-        mTaskbarBackgroundAlpha.get(INDEX_STASH_ANIM).setValue(
-                enableScalingRevealHomeAnimation() && DisplayController.isTransientTaskbar(context)
-                        ? 0
-                        : 1);
     }
 
     public void init(TaskbarDragLayerController.TaskbarDragLayerCallbacks callbacks) {
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarManager.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarManager.java
index 2a58db2..051bdc8 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarManager.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarManager.java
@@ -304,7 +304,7 @@
                 .register(NAV_BAR_KIDS_MODE, mOnSettingsChangeListener);
         Log.d(TASKBAR_NOT_DESTROYED_TAG, "registering component callbacks from constructor.");
         mContext.registerComponentCallbacks(mComponentCallbacks);
-        mShutdownReceiver.register(mContext, Intent.ACTION_SHUTDOWN);
+        mShutdownReceiver.registerAsync(mContext, Intent.ACTION_SHUTDOWN);
         UI_HELPER_EXECUTOR.execute(() -> {
             mSharedState.taskbarSystemActionPendingIntent = PendingIntent.getBroadcast(
                     mContext,
@@ -582,8 +582,7 @@
     public void destroy() {
         debugWhyTaskbarNotDestroyed("TaskbarManager#destroy()");
         removeActivityCallbacksAndListeners();
-        UI_HELPER_EXECUTOR.execute(
-                () -> mTaskbarBroadcastReceiver.unregisterReceiverSafely(mContext));
+        mTaskbarBroadcastReceiver.unregisterReceiverSafelyAsync(mContext);
         destroyExistingTaskbar();
         removeTaskbarRootViewFromWindow();
         if (mUserUnlocked) {
@@ -595,7 +594,7 @@
                 .unregister(NAV_BAR_KIDS_MODE, mOnSettingsChangeListener);
         Log.d(TASKBAR_NOT_DESTROYED_TAG, "unregistering component callbacks from destroy().");
         mContext.unregisterComponentCallbacks(mComponentCallbacks);
-        mContext.unregisterReceiver(mShutdownReceiver);
+        mShutdownReceiver.unregisterReceiverSafelyAsync(mContext);
     }
 
     public @Nullable TaskbarActivityContext getCurrentActivityContext() {
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarModelCallbacks.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarModelCallbacks.java
index 2b0e169..0b7ae39 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarModelCallbacks.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarModelCallbacks.java
@@ -15,9 +15,6 @@
  */
 package com.android.launcher3.taskbar;
 
-import static com.android.window.flags.Flags.enableDesktopWindowingMode;
-import static com.android.window.flags.Flags.enableDesktopWindowingTaskbarRunningApps;
-
 import android.util.SparseArray;
 import android.view.View;
 
@@ -29,7 +26,6 @@
 import com.android.launcher3.model.data.AppInfo;
 import com.android.launcher3.model.data.ItemInfo;
 import com.android.launcher3.model.data.WorkspaceItemInfo;
-import com.android.launcher3.statehandlers.DesktopVisibilityController;
 import com.android.launcher3.util.ComponentKey;
 import com.android.launcher3.util.IntArray;
 import com.android.launcher3.util.IntSet;
@@ -37,8 +33,7 @@
 import com.android.launcher3.util.LauncherBindableItemsContainer;
 import com.android.launcher3.util.PackageUserKey;
 import com.android.launcher3.util.Preconditions;
-import com.android.quickstep.LauncherActivityInterface;
-import com.android.quickstep.RecentsModel;
+import com.android.quickstep.util.GroupTask;
 
 import java.io.PrintWriter;
 import java.util.ArrayList;
@@ -54,7 +49,7 @@
  * Launcher model Callbacks for rendering taskbar.
  */
 public class TaskbarModelCallbacks implements
-        BgDataModel.Callbacks, LauncherBindableItemsContainer, RecentsModel.RunningTasksListener {
+        BgDataModel.Callbacks, LauncherBindableItemsContainer {
 
     private final SparseArray<ItemInfo> mHotseatItems = new SparseArray<>();
     private List<ItemInfo> mPredictedItems = Collections.emptyList();
@@ -68,8 +63,6 @@
     // Used to defer any UI updates during the SUW unstash animation.
     private boolean mDeferUpdatesForSUW;
     private Runnable mDeferredUpdates;
-    private final DesktopVisibilityController.DesktopVisibilityListener mDesktopVisibilityListener =
-            visible -> updateRunningApps();
 
     public TaskbarModelCallbacks(
             TaskbarActivityContext context, TaskbarView container) {
@@ -79,39 +72,6 @@
 
     public void init(TaskbarControllers controllers) {
         mControllers = controllers;
-        if (mControllers.taskbarRecentAppsController.getCanShowRunningApps()) {
-            RecentsModel.INSTANCE.get(mContext).registerRunningTasksListener(this);
-
-            if (shouldShowRunningAppsInDesktopMode()) {
-                DesktopVisibilityController desktopVisibilityController =
-                        LauncherActivityInterface.INSTANCE.getDesktopVisibilityController();
-                if (desktopVisibilityController != null) {
-                    desktopVisibilityController.registerDesktopVisibilityListener(
-                            mDesktopVisibilityListener);
-                }
-            }
-        }
-    }
-
-    /**
-     * Unregisters listeners in this class.
-     */
-    public void unregisterListeners() {
-        RecentsModel.INSTANCE.get(mContext).unregisterRunningTasksListener();
-
-        if (shouldShowRunningAppsInDesktopMode()) {
-            DesktopVisibilityController desktopVisibilityController =
-                    LauncherActivityInterface.INSTANCE.getDesktopVisibilityController();
-            if (desktopVisibilityController != null) {
-                desktopVisibilityController.unregisterDesktopVisibilityListener(
-                        mDesktopVisibilityListener);
-            }
-        }
-    }
-
-    private boolean shouldShowRunningAppsInDesktopMode() {
-        // TODO(b/335401172): unify DesktopMode checks in Launcher
-        return enableDesktopWindowingMode() && enableDesktopWindowingTaskbarRunningApps();
     }
 
     @Override
@@ -171,7 +131,7 @@
         final int itemCount = mContainer.getChildCount();
         for (int itemIdx = 0; itemIdx < itemCount; itemIdx++) {
             View item = mContainer.getChildAt(itemIdx);
-            if (op.evaluate((ItemInfo) item.getTag(), item)) {
+            if (item.getTag() instanceof ItemInfo itemInfo && op.evaluate(itemInfo, item)) {
                 return;
             }
         }
@@ -232,26 +192,30 @@
                 predictionNextIndex++;
             }
         }
-        hotseatItemInfos = mControllers.taskbarRecentAppsController
-                .updateHotseatItemInfos(hotseatItemInfos);
-        Set<String> runningPackages = mControllers.taskbarRecentAppsController.getRunningApps();
-        Set<String> minimizedPackages = mControllers.taskbarRecentAppsController.getMinimizedApps();
+
+        final TaskbarRecentAppsController recentAppsController =
+                mControllers.taskbarRecentAppsController;
+        hotseatItemInfos = recentAppsController.updateHotseatItemInfos(hotseatItemInfos);
+        Set<String> runningPackages = recentAppsController.getRunningAppPackages();
+        Set<String> minimizedPackages = recentAppsController.getMinimizedAppPackages();
 
         if (mDeferUpdatesForSUW) {
             ItemInfo[] finalHotseatItemInfos = hotseatItemInfos;
             mDeferredUpdates = () ->
-                    commitHotseatItemUpdates(finalHotseatItemInfos, runningPackages,
+                    commitHotseatItemUpdates(finalHotseatItemInfos,
+                            recentAppsController.getShownTasks(), runningPackages,
                             minimizedPackages);
         } else {
-            commitHotseatItemUpdates(hotseatItemInfos, runningPackages, minimizedPackages);
+            commitHotseatItemUpdates(hotseatItemInfos,
+                    recentAppsController.getShownTasks(), runningPackages, minimizedPackages);
         }
     }
 
-    private void commitHotseatItemUpdates(ItemInfo[] hotseatItemInfos, Set<String> runningPackages,
-            Set<String> minimizedPackages) {
-        mContainer.updateHotseatItems(hotseatItemInfos);
-        mControllers.taskbarViewController.updateIconViewsRunningStates(runningPackages,
-                minimizedPackages);
+    private void commitHotseatItemUpdates(ItemInfo[] hotseatItemInfos, List<GroupTask> recentTasks,
+            Set<String> runningPackages, Set<String> minimizedPackages) {
+        mContainer.updateHotseatItems(hotseatItemInfos, recentTasks);
+        mControllers.taskbarViewController.updateIconViewsRunningStates(
+                runningPackages, minimizedPackages);
     }
 
     /**
@@ -270,21 +234,11 @@
         }
     }
 
-    @Override
-    public void onRunningTasksChanged() {
-        updateRunningApps();
-    }
-
     /** Called when there's a change in running apps to update the UI. */
     public void commitRunningAppsToUI() {
         commitItemsToUI();
     }
 
-    /** Call TaskbarRecentAppsController to update running apps with mHotseatItems. */
-    public void updateRunningApps() {
-        mControllers.taskbarRecentAppsController.updateRunningApps();
-    }
-
     @Override
     public void bindDeepShortcutMap(HashMap<ComponentKey, Integer> deepShortcutMapCopy) {
         mControllers.taskbarPopupController.setDeepShortcutMap(deepShortcutMapCopy);
@@ -296,7 +250,6 @@
             Map<PackageUserKey, Integer> packageUserKeytoUidMap) {
         Preconditions.assertUIThread();
         mControllers.taskbarAllAppsController.setApps(apps, flags, packageUserKeytoUidMap);
-        mControllers.taskbarRecentAppsController.setApps(apps);
     }
 
     protected void dumpLogs(String prefix, PrintWriter pw) {
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarPopupController.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarPopupController.java
index 2730be1..b697590 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarPopupController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarPopupController.java
@@ -148,8 +148,8 @@
             icon.clearFocus();
             return null;
         }
-        ItemInfo item = (ItemInfo) icon.getTag();
-        if (!ShortcutUtil.supportsShortcuts(item)) {
+        // TODO(b/344657629) support GroupTask as well, for Taskbar Recent apps
+        if (!(icon.getTag() instanceof ItemInfo item) || !ShortcutUtil.supportsShortcuts(item)) {
             return null;
         }
 
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarRecentAppsController.kt b/quickstep/src/com/android/launcher3/taskbar/TaskbarRecentAppsController.kt
index b1fc9cc..fc3b4c7 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarRecentAppsController.kt
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarRecentAppsController.kt
@@ -15,19 +15,20 @@
  */
 package com.android.launcher3.taskbar
 
-import android.app.ActivityManager.RunningTaskInfo
-import android.app.WindowConfiguration
 import androidx.annotation.VisibleForTesting
 import com.android.launcher3.Flags.enableRecentsInTaskbar
-import com.android.launcher3.model.data.AppInfo
 import com.android.launcher3.model.data.ItemInfo
-import com.android.launcher3.model.data.WorkspaceItemInfo
 import com.android.launcher3.statehandlers.DesktopVisibilityController
 import com.android.launcher3.taskbar.TaskbarControllers.LoggableTaskbarController
+import com.android.launcher3.util.CancellableTask
 import com.android.quickstep.RecentsModel
+import com.android.quickstep.util.DesktopTask
+import com.android.quickstep.util.GroupTask
+import com.android.systemui.shared.recents.model.Task
 import com.android.window.flags.Flags.enableDesktopWindowingMode
 import com.android.window.flags.Flags.enableDesktopWindowingTaskbarRunningApps
 import java.io.PrintWriter
+import java.util.function.Consumer
 
 /**
  * Provides recent apps functionality, when the Taskbar Recent Apps section is enabled. Behavior:
@@ -42,22 +43,28 @@
 ) : LoggableTaskbarController {
 
     // TODO(b/335401172): unify DesktopMode checks in Launcher.
-    val canShowRunningApps =
+    var canShowRunningApps =
         enableDesktopWindowingMode() && enableDesktopWindowingTaskbarRunningApps()
+        @VisibleForTesting
+        set(isEnabledFromTest) {
+            field = isEnabledFromTest
+        }
 
     // TODO(b/343532825): Add a setting to disable Recents even when the flag is on.
-    var isEnabled: Boolean = enableRecentsInTaskbar() || canShowRunningApps
+    var canShowRecentApps = enableRecentsInTaskbar()
         @VisibleForTesting
-        set(isEnabledFromTest){
+        set(isEnabledFromTest) {
             field = isEnabledFromTest
         }
 
     // Initialized in init.
     private lateinit var controllers: TaskbarControllers
 
-    private var apps: Array<AppInfo>? = null
-    private var allRunningDesktopAppInfos: List<AppInfo>? = null
-    private var allMinimizedDesktopAppInfos: List<AppInfo>? = null
+    private var shownHotseatItems: List<ItemInfo> = emptyList()
+    private var allRecentTasks: List<GroupTask> = emptyList()
+    private var desktopTask: DesktopTask? = null
+    var shownTasks: List<GroupTask> = emptyList()
+        private set
 
     private val desktopVisibilityController: DesktopVisibilityController?
         get() = desktopVisibilityControllerProvider()
@@ -65,122 +72,170 @@
     private val isInDesktopMode: Boolean
         get() = desktopVisibilityController?.areDesktopTasksVisible() ?: false
 
-    val runningApps: Set<String>
+    val runningAppPackages: Set<String>
+        /**
+         * Returns the package names of apps that should be indicated as "running" to the user.
+         * Specifically, we return all the open tasks if we are in Desktop mode, else emptySet().
+         */
         get() {
-            if (!isEnabled || !isInDesktopMode) {
+            if (!canShowRunningApps || !isInDesktopMode) {
                 return emptySet()
             }
-            return allRunningDesktopAppInfos?.mapNotNull { it.targetPackage }?.toSet() ?: emptySet()
+            val tasks = desktopTask?.tasks ?: return emptySet()
+            return tasks.map { task -> task.key.packageName }.toSet()
         }
 
-    val minimizedApps: Set<String>
+    val minimizedAppPackages: Set<String>
+        /**
+         * Returns the package names of apps that should be indicated as "minimized" to the user.
+         * Specifically, we return all the running packages where all the tasks in that package are
+         * minimized (not visible).
+         */
         get() {
-            if (!isInDesktopMode) {
+            if (!canShowRunningApps || !isInDesktopMode) {
                 return emptySet()
             }
-            return allMinimizedDesktopAppInfos?.mapNotNull { it.targetPackage }?.toSet()
-                ?: emptySet()
+            val desktopTasks = desktopTask?.tasks ?: return emptySet()
+            val packageToTasks = desktopTasks.groupBy { it.key.packageName }
+            return packageToTasks.filterValues { tasks -> tasks.all { !it.isVisible } }.keys
         }
 
+    private val recentTasksChangedListener =
+        RecentsModel.RecentTasksChangedListener { reloadRecentTasksIfNeeded() }
+
+    private val iconLoadRequests: MutableSet<CancellableTask<*>> = HashSet()
+
+    // TODO(b/343291428): add TaskVisualsChangListener as well (for calendar/clock?)
+
+    // Used to keep track of the last requested task list ID, so that we do not request to load the
+    // tasks again if we have already requested it and the task list has not changed
+    private var taskListChangeId = -1
+
     fun init(taskbarControllers: TaskbarControllers) {
         controllers = taskbarControllers
+        recentsModel.registerRecentTasksChangedListener(recentTasksChangedListener)
+        reloadRecentTasksIfNeeded()
     }
 
     fun onDestroy() {
-        apps = null
-    }
-
-    /** Stores the current [AppInfo] instances, no-op except in desktop environment. */
-    fun setApps(apps: Array<AppInfo>?) {
-        this.apps = apps
+        recentsModel.unregisterRecentTasksChangedListener()
+        iconLoadRequests.forEach { it.cancel() }
+        iconLoadRequests.clear()
     }
 
     /** Called to update hotseatItems, in order to de-dupe them from Recent/Running tasks later. */
-    // TODO(next CL): add new section of Tasks instead of changing Hotseat items
     fun updateHotseatItemInfos(hotseatItems: Array<ItemInfo?>): Array<ItemInfo?> {
-        if (!isEnabled || !isInDesktopMode) {
+        // Ignore predicted apps - we show running or recent apps instead.
+        val removePredictions =
+            (isInDesktopMode && canShowRunningApps) || (!isInDesktopMode && canShowRecentApps)
+        if (!removePredictions) {
+            shownHotseatItems = hotseatItems.filterNotNull()
+            onRecentsOrHotseatChanged()
             return hotseatItems
         }
-        val newHotseatItemInfos =
+        shownHotseatItems =
             hotseatItems
                 .filterNotNull()
-                // Ignore predicted apps - we show running apps instead
                 .filter { itemInfo -> !itemInfo.isPredictedItem }
                 .toMutableList()
-        val runningDesktopAppInfos =
-            allRunningDesktopAppInfos?.let {
-                getRunningDesktopAppInfosExceptHotseatApps(it, newHotseatItemInfos.toList())
+
+        onRecentsOrHotseatChanged()
+
+        return shownHotseatItems.toTypedArray()
+    }
+
+    private fun reloadRecentTasksIfNeeded() {
+        if (!recentsModel.isTaskListValid(taskListChangeId)) {
+            taskListChangeId =
+                recentsModel.getTasks { tasks ->
+                    allRecentTasks = tasks
+                    desktopTask = allRecentTasks.filterIsInstance<DesktopTask>().firstOrNull()
+                    onRecentsOrHotseatChanged()
+                    controllers.taskbarViewController.commitRunningAppsToUI()
+                }
+        }
+    }
+
+    private fun onRecentsOrHotseatChanged() {
+        shownTasks =
+            if (isInDesktopMode) {
+                computeShownRunningTasks()
+            } else {
+                computeShownRecentTasks()
             }
-        if (runningDesktopAppInfos != null) {
-            newHotseatItemInfos.addAll(runningDesktopAppInfos)
+
+        for (groupTask in shownTasks) {
+            for (task in groupTask.tasks) {
+                val callback =
+                    Consumer<Task> { controllers.taskbarViewController.onTaskUpdated(it) }
+                val cancellableTask = recentsModel.iconCache.updateIconInBackground(task, callback)
+                if (cancellableTask != null) {
+                    iconLoadRequests.add(cancellableTask)
+                }
+            }
         }
-        return newHotseatItemInfos.toTypedArray()
     }
 
-    private fun getRunningDesktopAppInfosExceptHotseatApps(
-        allRunningDesktopAppInfos: List<AppInfo>,
-        hotseatItems: List<ItemInfo>
-    ): List<ItemInfo> {
-        val hotseatPackages = hotseatItems.map { it.targetPackage }
-        return allRunningDesktopAppInfos
-            .filter { appInfo -> !hotseatPackages.contains(appInfo.targetPackage) }
-            .map { WorkspaceItemInfo(it) }
-    }
-
-    private fun getDesktopRunningTasks(): List<RunningTaskInfo> =
-        recentsModel.runningTasks.filter { taskInfo: RunningTaskInfo ->
-            taskInfo.windowingMode == WindowConfiguration.WINDOWING_MODE_FREEFORM
-        }
-
-    // TODO(b/335398876) fetch app icons from Tasks instead of AppInfos
-    private fun getAppInfosFromRunningTasks(tasks: List<RunningTaskInfo>): List<AppInfo> {
-        // Early return if apps is empty, since we then have no AppInfo to compare to
-        if (apps == null) {
+    private fun computeShownRunningTasks(): List<GroupTask> {
+        if (!canShowRunningApps) {
             return emptyList()
         }
-        val packageNames = tasks.map { it.realActivity?.packageName }.distinct().filterNotNull()
-        return packageNames
-            .map { packageName -> apps?.find { app -> packageName == app.targetPackage } }
-            .filterNotNull()
+        val tasks = desktopTask?.tasks ?: emptyList()
+        // Kind of hacky, we wrap each single task in the Desktop as a GroupTask.
+        var desktopTaskAsList = tasks.map { GroupTask(it) }
+        // TODO(b/315344726 Multi-instance support): dedupe Tasks of the same package too.
+        desktopTaskAsList = dedupeHotseatTasks(desktopTaskAsList, shownHotseatItems)
+        val desktopPackages = desktopTaskAsList.map { it.packageNames }
+        // Remove any missing Tasks.
+        val newShownTasks = shownTasks.filter { it.packageNames in desktopPackages }.toMutableList()
+        val newShownPackages = newShownTasks.map { it.packageNames }
+        // Add any new Tasks, maintaining the order from previous shownTasks.
+        newShownTasks.addAll(desktopTaskAsList.filter { it.packageNames !in newShownPackages })
+        return newShownTasks.toList()
     }
 
-    /** Called to update the list of currently running apps, no-op except in desktop environment. */
-    fun updateRunningApps() {
-        if (!isEnabled || !isInDesktopMode) {
-            return controllers.taskbarViewController.commitRunningAppsToUI()
+    private fun computeShownRecentTasks(): List<GroupTask> {
+        if (!canShowRecentApps || allRecentTasks.isEmpty()) {
+            return emptyList()
         }
-        val runningTasks = getDesktopRunningTasks()
-        val runningAppInfo = getAppInfosFromRunningTasks(runningTasks)
-        allRunningDesktopAppInfos = runningAppInfo
-        updateMinimizedApps(runningTasks, runningAppInfo)
-        controllers.taskbarViewController.commitRunningAppsToUI()
+        // Remove the current task.
+        val allRecentTasks = allRecentTasks.subList(0, allRecentTasks.size - 1)
+        // TODO(b/315344726 Multi-instance support): dedupe Tasks of the same package too
+        var shownTasks = dedupeHotseatTasks(allRecentTasks, shownHotseatItems)
+        if (shownTasks.size > MAX_RECENT_TASKS) {
+            // Remove any tasks older than MAX_RECENT_TASKS.
+            shownTasks = shownTasks.subList(shownTasks.size - MAX_RECENT_TASKS, shownTasks.size)
+        }
+        return shownTasks
     }
 
-    private fun updateMinimizedApps(
-        runningTasks: List<RunningTaskInfo>,
-        runningAppInfo: List<AppInfo>,
-    ) {
-        val allRunningAppTasks =
-            runningAppInfo
-                .mapNotNull { appInfo -> appInfo.targetPackage?.let { appInfo to it } }
-                .associate { (appInfo, targetPackage) ->
-                    appInfo to
-                            runningTasks
-                                .filter { it.realActivity?.packageName == targetPackage }
-                                .map { it.taskId }
-                }
-        val minimizedTaskIds = runningTasks.associate { it.taskId to !it.isVisible }
-        allMinimizedDesktopAppInfos =
-            allRunningAppTasks
-                .filterValues { taskIds -> taskIds.all { minimizedTaskIds[it] ?: false } }
-                .keys
-                .toList()
+    private fun dedupeHotseatTasks(
+        groupTasks: List<GroupTask>,
+        shownHotseatItems: List<ItemInfo>
+    ): List<GroupTask> {
+        val hotseatPackages = shownHotseatItems.map { item -> item.targetPackage }
+        return groupTasks.filter { groupTask ->
+            groupTask.hasMultipleTasks() ||
+                !hotseatPackages.contains(groupTask.task1.key.packageName)
+        }
     }
 
     override fun dumpLogs(prefix: String, pw: PrintWriter) {
         pw.println("$prefix TaskbarRecentAppsController:")
-        pw.println("$prefix\tisEnabled=$isEnabled")
         pw.println("$prefix\tcanShowRunningApps=$canShowRunningApps")
-        // TODO(next CL): add more logs
+        pw.println("$prefix\tcanShowRecentApps=$canShowRecentApps")
+        pw.println("$prefix\tshownHotseatItems=${shownHotseatItems.map{item->item.targetPackage}}")
+        pw.println("$prefix\tallRecentTasks=${allRecentTasks.map { it.packageNames }}")
+        pw.println("$prefix\tdesktopTask=${desktopTask?.packageNames}")
+        pw.println("$prefix\tshownTasks=${shownTasks.map { it.packageNames }}")
+        pw.println("$prefix\trunningTasks=$runningAppPackages")
+        pw.println("$prefix\tminimizedTasks=$minimizedAppPackages")
+    }
+
+    private val GroupTask.packageNames: List<String>
+        get() = tasks.map { task -> task.key.packageName }
+
+    private companion object {
+        const val MAX_RECENT_TASKS = 2
     }
 }
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarStashController.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarStashController.java
index 7ff887c..fa2d907 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarStashController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarStashController.java
@@ -245,7 +245,7 @@
 
     private Animator mTaskbarBackgroundAlphaAnimator;
     private long mTaskbarBackgroundDuration;
-    private boolean mIsGoingHome;
+    private boolean mUserIsNotGoingHome = false;
 
     // Evaluate whether the handle should be stashed
     private final LongPredicate mIsStashedPredicate = flags -> {
@@ -338,7 +338,16 @@
         // For now, assume we're in an app, since LauncherTaskbarUIController won't be able to tell
         // us that we're paused until a bit later. This avoids flickering upon recreating taskbar.
         updateStateForFlag(FLAG_IN_APP, true);
+
         applyState(/* duration = */ 0);
+
+        // Hide the background while stashed so it doesn't show on fast swipes home
+        boolean shouldHideTaskbarBackground = enableScalingRevealHomeAnimation()
+                && DisplayController.isTransientTaskbar(mActivity)
+                && isStashed();
+
+        mTaskbarBackgroundAlphaForStash.setValue(shouldHideTaskbarBackground ? 0 : 1);
+
         if (mTaskbarSharedState.getTaskbarWasPinned()
                 || !mTaskbarSharedState.taskbarWasStashedAuto) {
             tryStartTaskbarTimeout();
@@ -828,17 +837,13 @@
             private boolean mTaskbarBgAlphaAnimationStarted = false;
             @Override
             public void onAnimationUpdate(ValueAnimator valueAnimator) {
-                if (mIsGoingHome) {
-                    mTaskbarBgAlphaAnimationStarted = true;
-                }
                 if (mTaskbarBgAlphaAnimationStarted) {
                     return;
                 }
 
                 if (valueAnimator.getAnimatedFraction() >= ANIMATED_FRACTION_THRESHOLD) {
-                    if (!mIsGoingHome) {
+                    if (mUserIsNotGoingHome) {
                         playTaskbarBackgroundAlphaAnimation();
-                        setUserIsGoingHome(false);
                         mTaskbarBgAlphaAnimationStarted = true;
                     }
                 }
@@ -850,8 +855,8 @@
     /**
      * Sets whether the user is going home based on the current gesture.
      */
-    public void setUserIsGoingHome(boolean isGoingHome) {
-        mIsGoingHome = isGoingHome;
+    public void setUserIsNotGoingHome(boolean userIsNotGoingHome) {
+        mUserIsNotGoingHome = userIsNotGoingHome;
     }
 
     /**
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarUIController.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarUIController.java
index 593285f..f24bc21 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarUIController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarUIController.java
@@ -269,8 +269,8 @@
                                     foundTaskView,
                                     foundTask,
                                     taskContainer.getIconView().getDrawable(),
-                                    taskContainer.getThumbnailViewDeprecated(),
-                                    taskContainer.getThumbnailViewDeprecated().getThumbnail(),
+                                    taskContainer.getSnapshotView(),
+                                    taskContainer.getThumbnail(),
                                     null /* intent */,
                                     null /* user */,
                                     info);
@@ -415,7 +415,7 @@
     /**
      * Sets whether the user is going home based on the current gesture.
      */
-    public void setUserIsGoingHome(boolean isGoingHome) {
-        mControllers.taskbarStashController.setUserIsGoingHome(isGoingHome);
+    public void setUserIsNotGoingHome(boolean isNotGoingHome) {
+        mControllers.taskbarStashController.setUserIsNotGoingHome(isNotGoingHome);
     }
 }
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarView.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarView.java
index 570221c..c42d6c6 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarView.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarView.java
@@ -19,6 +19,7 @@
 
 import static com.android.launcher3.BubbleTextView.DISPLAY_TASKBAR;
 import static com.android.launcher3.Flags.enableCursorHoverStates;
+import static com.android.launcher3.Flags.enableRecentsInTaskbar;
 import static com.android.launcher3.LauncherSettings.Favorites.ITEM_TYPE_APP_PAIR;
 import static com.android.launcher3.LauncherSettings.Favorites.ITEM_TYPE_FOLDER;
 import static com.android.launcher3.config.FeatureFlags.ENABLE_ALL_APPS_SEARCH_IN_TASKBAR;
@@ -30,6 +31,7 @@
 import android.content.res.Resources;
 import android.graphics.Canvas;
 import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
 import android.os.Bundle;
 import android.util.AttributeSet;
 import android.view.DisplayCutout;
@@ -67,7 +69,11 @@
 import com.android.launcher3.views.IconButtonView;
 import com.android.quickstep.DeviceConfigWrapper;
 import com.android.quickstep.util.AssistStateManager;
+import com.android.quickstep.util.DesktopTask;
+import com.android.quickstep.util.GroupTask;
+import com.android.systemui.shared.recents.model.Task;
 
+import java.util.List;
 import java.util.function.Predicate;
 
 /**
@@ -168,7 +174,7 @@
         mAllAppsButton.setForegroundTint(
                 mActivityContext.getColor(R.color.all_apps_button_color));
 
-        if (enableTaskbarPinning()) {
+        if (enableTaskbarPinning() || enableRecentsInTaskbar()) {
             mTaskbarDivider = (IconButtonView) LayoutInflater.from(context).inflate(
                     R.layout.taskbar_divider,
                     this, false);
@@ -308,9 +314,10 @@
     /**
      * Inflates/binds the Hotseat views to show in the Taskbar given their ItemInfos.
      */
-    protected void updateHotseatItems(ItemInfo[] hotseatItemInfos) {
+    protected void updateHotseatItems(ItemInfo[] hotseatItemInfos, List<GroupTask> recentTasks) {
         int nextViewIndex = 0;
         int numViewsAnimated = 0;
+        boolean addedDividerForRecents = false;
 
         if (mAllAppsButton != null) {
             removeView(mAllAppsButton);
@@ -321,8 +328,8 @@
         }
         removeView(mQsb);
 
-        for (int i = 0; i < hotseatItemInfos.length; i++) {
-            ItemInfo hotseatItemInfo = hotseatItemInfos[i];
+        // Add Hotseat icons.
+        for (ItemInfo hotseatItemInfo : hotseatItemInfos) {
             if (hotseatItemInfo == null) {
                 continue;
             }
@@ -388,11 +395,8 @@
             }
 
             // Apply the Hotseat ItemInfos, or hide the view if there is none for a given index.
-            if (hotseatView instanceof BubbleTextView
-                    && hotseatItemInfo instanceof WorkspaceItemInfo) {
-                BubbleTextView btv = (BubbleTextView) hotseatView;
-                WorkspaceItemInfo workspaceInfo = (WorkspaceItemInfo) hotseatItemInfo;
-
+            if (hotseatView instanceof BubbleTextView btv
+                    && hotseatItemInfo instanceof WorkspaceItemInfo workspaceInfo) {
                 boolean animate = btv.shouldAnimateIconChange((WorkspaceItemInfo) hotseatItemInfo);
                 btv.applyFromWorkspaceItem(workspaceInfo, animate, numViewsAnimated);
                 if (animate) {
@@ -405,6 +409,67 @@
             }
             nextViewIndex++;
         }
+
+        if (mTaskbarDivider != null && !recentTasks.isEmpty()) {
+            addView(mTaskbarDivider, nextViewIndex++);
+            addedDividerForRecents = true;
+        }
+
+        // Add Recent/Running icons.
+        for (GroupTask task : recentTasks) {
+            // Replace any Recent views with the appropriate type if it's not already that type.
+            final int expectedLayoutResId;
+            boolean isCollection = false;
+            if (task.hasMultipleTasks()) {
+                if (task instanceof DesktopTask) {
+                    // TODO(b/316004172): use Desktop tile layout.
+                    expectedLayoutResId = -1;
+                } else {
+                    // TODO(b/343289567): use R.layout.app_pair_icon
+                    expectedLayoutResId = -1;
+                }
+                isCollection = true;
+            } else {
+                expectedLayoutResId = R.layout.taskbar_app_icon;
+            }
+
+            View recentIcon = null;
+            while (nextViewIndex < getChildCount()) {
+                recentIcon = getChildAt(nextViewIndex);
+
+                // see if the view can be reused
+                if ((recentIcon.getSourceLayoutResId() != expectedLayoutResId)
+                        || (isCollection && (recentIcon.getTag() != task))) {
+                    removeAndRecycle(recentIcon);
+                    recentIcon = null;
+                } else {
+                    // View found
+                    break;
+                }
+            }
+
+            if (recentIcon == null) {
+                if (isCollection) {
+                    // TODO(b/343289567 and b/316004172): support app pairs and desktop mode.
+                    continue;
+                }
+
+                recentIcon = inflate(expectedLayoutResId);
+                LayoutParams lp = new LayoutParams(mIconTouchSize, mIconTouchSize);
+                recentIcon.setPadding(mItemPadding, mItemPadding, mItemPadding, mItemPadding);
+                addView(recentIcon, nextViewIndex, lp);
+            }
+
+            if (recentIcon instanceof BubbleTextView btv) {
+                applyGroupTaskToBubbleTextView(btv, task);
+            }
+            setClickAndLongClickListenersForIcon(recentIcon);
+            if (enableCursorHoverStates()) {
+                setHoverListenerForIcon(recentIcon);
+            }
+            nextViewIndex++;
+        }
+
         // Remove remaining views
         while (nextViewIndex < getChildCount()) {
             removeAndRecycle(getChildAt(nextViewIndex));
@@ -413,8 +478,8 @@
         if (mAllAppsButton != null) {
             addView(mAllAppsButton, mIsRtl ? getChildCount() : 0);
 
-            // if only all apps button present, don't include divider view.
-            if (mTaskbarDivider != null && getChildCount() > 1) {
+            // If there are no recent tasks, add divider after All Apps (unless it's the only view).
+            if (!addedDividerForRecents && mTaskbarDivider != null && getChildCount() > 1) {
                 addView(mTaskbarDivider, mIsRtl ? (getChildCount() - 1) : 1);
             }
         }
@@ -425,6 +490,20 @@
         }
     }
 
+    /** Binds the GroupTask to the BubbleTextView to be ready to present to the user. */
+    public void applyGroupTaskToBubbleTextView(BubbleTextView btv, GroupTask groupTask) {
+        // TODO(b/343289567): support app pairs.
+        Task task1 = groupTask.task1;
+        // TODO(b/344038728): use FastBitmapDrawable instead of Drawable, to get disabled state
+        //  while dragging.
+        Drawable taskIcon = groupTask.task1.icon;
+        if (taskIcon != null) {
+            taskIcon = taskIcon.getConstantState().newDrawable().mutate();
+        }
+        btv.applyIconAndLabel(taskIcon, task1.titleDescription);
+        btv.setTag(groupTask);
+    }
+
     /**
      * Sets OnClickListener and OnLongClickListener for the given view.
      */
@@ -677,7 +756,8 @@
         // map over all the shortcuts on the taskbar
         for (int i = 0; i < getChildCount(); i++) {
             View item = getChildAt(i);
-            if (op.evaluate((ItemInfo) item.getTag(), item)) {
+            // TODO(b/344657629): Support GroupTask as well for notification dots/popup
+            if (item.getTag() instanceof ItemInfo itemInfo && op.evaluate(itemInfo, item)) {
                 return;
             }
         }
@@ -694,6 +774,7 @@
                 View item = getChildAt(i);
                 if (!(item.getTag() instanceof ItemInfo)) {
                     // Should only happen for All Apps button.
+                    // Will also happen for Recent/Running app icons. (Which have GroupTask as tags)
                     continue;
                 }
                 ItemInfo info = (ItemInfo) item.getTag();
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarViewController.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarViewController.java
index 55745b5..e59a016 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarViewController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarViewController.java
@@ -70,6 +70,8 @@
 import com.android.launcher3.util.MultiTranslateDelegate;
 import com.android.launcher3.util.MultiValueAlpha;
 import com.android.launcher3.views.IconButtonView;
+import com.android.quickstep.util.GroupTask;
+import com.android.systemui.shared.recents.model.Task;
 
 import java.io.PrintWriter;
 import java.util.Set;
@@ -224,7 +226,6 @@
         }
         LauncherAppState.getInstance(mActivity).getModel().removeCallbacks(mModelCallbacks);
         mActivity.removeOnDeviceProfileChangeListener(mDeviceProfileChangeListener);
-        mModelCallbacks.unregisterListeners();
     }
 
     public boolean areIconsVisible() {
@@ -520,21 +521,31 @@
         for (View iconView : getIconViews()) {
             if (iconView instanceof BubbleTextView btv) {
                 btv.updateRunningState(
-                        getRunningAppState(btv.getTargetPackageName(), runningPackages,
-                                minimizedPackages));
+                        getRunningAppState(btv, runningPackages, minimizedPackages));
             }
         }
     }
 
     private BubbleTextView.RunningAppState getRunningAppState(
-            String packageName,
+            BubbleTextView btv,
             Set<String> runningPackages,
             Set<String> minimizedPackages) {
-        if (minimizedPackages.contains(packageName)) {
-            return BubbleTextView.RunningAppState.MINIMIZED;
+        Object tag = btv.getTag();
+        if (tag instanceof ItemInfo itemInfo) {
+            if (minimizedPackages.contains(itemInfo.getTargetPackage())) {
+                return BubbleTextView.RunningAppState.MINIMIZED;
+            }
+            if (runningPackages.contains(itemInfo.getTargetPackage())) {
+                return BubbleTextView.RunningAppState.RUNNING;
+            }
         }
-        if (runningPackages.contains(packageName)) {
-            return BubbleTextView.RunningAppState.RUNNING;
+        if (tag instanceof GroupTask groupTask && !groupTask.hasMultipleTasks()) {
+            if (minimizedPackages.contains(groupTask.task1.key.getPackageName())) {
+                return BubbleTextView.RunningAppState.MINIMIZED;
+            }
+            if (runningPackages.contains(groupTask.task1.key.getPackageName())) {
+                return BubbleTextView.RunningAppState.RUNNING;
+            }
         }
         return BubbleTextView.RunningAppState.NOT_RUNNING;
     }
@@ -869,6 +880,27 @@
         return mTaskbarView.isEventOverAnyItem(ev);
     }
 
+    /** Called when there's a change in running apps to update the UI. */
+    public void commitRunningAppsToUI() {
+        mModelCallbacks.commitRunningAppsToUI();
+    }
+
+    /**
+     * To be called when the given Task is updated, so that we can tell TaskbarView to also update.
+     * @param task The Task whose e.g. icon changed.
+     */
+    public void onTaskUpdated(Task task) {
+        // Find the icon view(s) that changed.
+        for (View view : mTaskbarView.getIconViews()) {
+            if (view instanceof BubbleTextView btv
+                    && view.getTag() instanceof GroupTask groupTask) {
+                if (groupTask.containsTask(task.key.id)) {
+                    mTaskbarView.applyGroupTaskToBubbleTextView(btv, groupTask);
+                }
+            }
+        }
+    }
+
     @Override
     public void dumpLogs(String prefix, PrintWriter pw) {
         pw.println(prefix + "TaskbarViewController:");
@@ -888,15 +920,4 @@
 
         mModelCallbacks.dumpLogs(prefix + "\t", pw);
     }
-
-    /** Called when there's a change in running apps to update the UI. */
-    public void commitRunningAppsToUI() {
-        mModelCallbacks.commitRunningAppsToUI();
-    }
-
-    /** Call TaskbarModelCallbacks to update running apps. */
-    public void updateRunningApps() {
-        mModelCallbacks.updateRunningApps();
-    }
-
 }
diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarController.java b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarController.java
index 028df34..15e4578 100644
--- a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarController.java
@@ -600,7 +600,7 @@
         Bitmap bitmap = createOverflowBitmap(context);
         LayoutInflater inflater = LayoutInflater.from(context);
         BubbleView bubbleView = (BubbleView) inflater.inflate(
-                R.layout.bubblebar_item_view, mBarView, false /* attachToRoot */);
+                R.layout.bubble_bar_overflow_button, mBarView, false /* attachToRoot */);
         BubbleBarOverflow overflow = new BubbleBarOverflow(bubbleView);
         bubbleView.setOverflow(overflow, bitmap);
         return overflow;
diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarItem.kt b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarItem.kt
index 43e21f4..39d1ed7 100644
--- a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarItem.kt
+++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarItem.kt
@@ -34,4 +34,8 @@
 ) : BubbleBarItem(info.key, view)
 
 /** Represents the overflow bubble in the bubble bar. */
-data class BubbleBarOverflow(override var view: BubbleView) : BubbleBarItem("Overflow", view)
+data class BubbleBarOverflow(override var view: BubbleView) : BubbleBarItem(KEY, view) {
+    companion object {
+        const val KEY = "Overflow"
+    }
+}
diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarView.java b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarView.java
index c7c63e8..0ea5031 100644
--- a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarView.java
+++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarView.java
@@ -44,6 +44,7 @@
 
 import com.android.launcher3.R;
 import com.android.launcher3.anim.SpringAnimationBuilder;
+import com.android.launcher3.taskbar.bubbles.animation.BubbleAnimator;
 import com.android.launcher3.util.DisplayController;
 import com.android.wm.shell.Flags;
 import com.android.wm.shell.common.bubbles.BubbleBarLocation;
@@ -101,8 +102,6 @@
     // During fade in animation we shift the bubble bar 1/60th of the screen width
     private static final float FADE_IN_ANIM_POSITION_SHIFT = 1 / 60f;
 
-    private static final int SCALE_IN_ANIMATION_DURATION_MS = 250;
-
     /**
      * Custom property to set alpha value for the bar view while a bubble is being dragged.
      * Skips applying alpha to the dragged bubble.
@@ -161,11 +160,12 @@
     // collapsed state and 1 to the fully expanded state.
     private final ValueAnimator mWidthAnimator = ValueAnimator.ofFloat(0, 1);
 
-    /** An animator used for scaling in a new bubble to the bubble bar while expanded. */
+    /** An animator used for animating individual bubbles in the bubble bar while expanded. */
     @Nullable
-    private ValueAnimator mNewBubbleScaleInAnimator = null;
+    private BubbleAnimator mBubbleAnimator = null;
     @Nullable
     private ValueAnimator mScalePaddingAnimator;
+
     @Nullable
     private Animator mBubbleBarLocationAnimator = null;
 
@@ -258,6 +258,7 @@
         }
         if (!Flags.animateBubbleSizeChange()) {
             setIconSizeAndPadding(newIconSize, newBubbleBarPadding);
+            return;
         }
         if (mScalePaddingAnimator != null && mScalePaddingAnimator.isRunning()) {
             mScalePaddingAnimator.cancel();
@@ -670,38 +671,37 @@
             bubble.setScaleX(0f);
             bubble.setScaleY(0f);
             addView(bubble, 0, lp);
-            createNewBubbleScaleInAnimator(bubble);
-            mNewBubbleScaleInAnimator.start();
+
+            mBubbleAnimator = new BubbleAnimator(mIconSize, mExpandedBarIconsSpacing,
+                    getChildCount(), mBubbleBarLocation.isOnLeft(isLayoutRtl()));
+            BubbleAnimator.Listener listener = new BubbleAnimator.Listener() {
+
+                @Override
+                public void onAnimationEnd() {
+                    updateWidth();
+                    mBubbleAnimator = null;
+                }
+
+                @Override
+                public void onAnimationCancel() {
+                    bubble.setScaleX(1);
+                    bubble.setScaleY(1);
+                }
+
+                @Override
+                public void onAnimationUpdate(float animatedFraction) {
+                    bubble.setScaleX(animatedFraction);
+                    bubble.setScaleY(animatedFraction);
+                    updateBubblesLayoutProperties(mBubbleBarLocation);
+                    invalidate();
+                }
+            };
+            mBubbleAnimator.animateNewBubble(indexOfChild(mSelectedBubbleView), listener);
         } else {
             addView(bubble, 0, lp);
         }
     }
 
-    private void createNewBubbleScaleInAnimator(View bubble) {
-        mNewBubbleScaleInAnimator = ValueAnimator.ofFloat(0, 1);
-        mNewBubbleScaleInAnimator.setDuration(SCALE_IN_ANIMATION_DURATION_MS);
-        mNewBubbleScaleInAnimator.addUpdateListener(animation -> {
-            float animatedFraction = animation.getAnimatedFraction();
-            bubble.setScaleX(animatedFraction);
-            bubble.setScaleY(animatedFraction);
-            updateBubblesLayoutProperties(mBubbleBarLocation);
-            invalidate();
-        });
-        mNewBubbleScaleInAnimator.addListener(new AnimatorListenerAdapter() {
-            @Override
-            public void onAnimationCancel(Animator animation) {
-                bubble.setScaleX(1);
-                bubble.setScaleY(1);
-            }
-
-            @Override
-            public void onAnimationEnd(Animator animation) {
-                updateWidth();
-                mNewBubbleScaleInAnimator = null;
-            }
-        });
-    }
-
     // TODO: (b/280605790) animate it
     @Override
     public void addView(View child, int index, ViewGroup.LayoutParams params) {
@@ -716,6 +716,50 @@
         updateContentDescription();
     }
 
+    /** Removes the given bubble from the bubble bar. */
+    public void removeBubble(View bubble) {
+        if (isExpanded()) {
+            // TODO b/347062801 - animate the bubble bar if the last bubble is removed
+            int bubbleCount = getChildCount();
+            mBubbleAnimator = new BubbleAnimator(mIconSize, mExpandedBarIconsSpacing,
+                    bubbleCount, mBubbleBarLocation.isOnLeft(isLayoutRtl()));
+            BubbleAnimator.Listener listener = new BubbleAnimator.Listener() {
+
+                @Override
+                public void onAnimationEnd() {
+                    removeView(bubble);
+                    mBubbleAnimator = null;
+                }
+
+                @Override
+                public void onAnimationCancel() {
+                    bubble.setScaleX(0);
+                    bubble.setScaleY(0);
+                }
+
+                @Override
+                public void onAnimationUpdate(float animatedFraction) {
+                    bubble.setScaleX(1 - animatedFraction);
+                    bubble.setScaleY(1 - animatedFraction);
+                    updateBubblesLayoutProperties(mBubbleBarLocation);
+                    invalidate();
+                }
+            };
+            int bubbleIndex = indexOfChild(bubble);
+            BubbleView lastBubble = (BubbleView) getChildAt(bubbleCount - 1);
+            String lastBubbleKey = lastBubble.getBubble().getKey();
+            boolean removingLastBubble =
+                    BubbleBarOverflow.KEY.equals(lastBubbleKey)
+                            ? bubbleIndex == bubbleCount - 2
+                            : bubbleIndex == bubbleCount - 1;
+            mBubbleAnimator.animateRemovedBubble(
+                    indexOfChild(bubble), indexOfChild(mSelectedBubbleView), removingLastBubble,
+                    listener);
+        } else {
+            removeView(bubble);
+        }
+    }
+
     // TODO: (b/283309949) animate it
     @Override
     public void removeView(View view) {
@@ -781,9 +825,14 @@
             bv.setDragTranslationX(0f);
             bv.setOffsetX(0f);
 
-            bv.setScaleX(mIconScale);
-            bv.setScaleY(mIconScale);
+            if (mBubbleAnimator == null || !mBubbleAnimator.isRunning()) {
+                // if the bubble animator is running don't set scale here, it will be set by the
+                // animator
+                bv.setScaleX(mIconScale);
+                bv.setScaleY(mIconScale);
+            }
             bv.setTranslationY(ty);
+
             // the position of the bubble when the bar is fully expanded
             final float expandedX = getExpandedBubbleTranslationX(i, bubbleCount, onLeft);
             // the position of the bubble when the bar is fully collapsed
@@ -861,9 +910,8 @@
         }
         final float iconAndSpacing = getScaledIconSize() + mExpandedBarIconsSpacing;
         float translationX;
-        if (mNewBubbleScaleInAnimator != null && mNewBubbleScaleInAnimator.isRunning()) {
-            translationX = getExpandedBubbleTranslationXDuringScaleAnimation(
-                    bubbleIndex, bubbleCount, onLeft);
+        if (mBubbleAnimator != null && mBubbleAnimator.isRunning()) {
+            return mBubbleAnimator.getExpandedBubbleTranslationX(bubbleIndex) + mBubbleBarPadding;
         } else if (onLeft) {
             translationX = mBubbleBarPadding + (bubbleCount - bubbleIndex - 1) * iconAndSpacing;
         } else {
@@ -872,51 +920,6 @@
         return translationX - getScaleIconShift();
     }
 
-    /**
-     * Returns the translation X for the bubble at index {@code bubbleIndex} when the bubble bar is
-     * expanded <b>and</b> a new bubble is animating in.
-     *
-     * <p>This method assumes that the animation is running so callers are expected to verify that
-     * before calling it.
-     */
-    private float getExpandedBubbleTranslationXDuringScaleAnimation(
-            int bubbleIndex, int bubbleCount, boolean onLeft) {
-        // when the new bubble scale animation is running, a new bubble is animating in while the
-        // bubble bar is expanded, so we have at least 2 bubbles in the bubble bar - the expanded
-        // one, and the new one animating in.
-
-        if (mNewBubbleScaleInAnimator == null) {
-            // callers of this method are expected to verify that the animation is running, but the
-            // compiler doesn't know that.
-            return 0;
-        }
-        final float iconAndSpacing = getScaledIconSize() + mExpandedBarIconsSpacing;
-        final float newBubbleScale = mNewBubbleScaleInAnimator.getAnimatedFraction();
-        // the new bubble is scaling in from the center, so we need to adjust its translation so
-        // that the distance to the adjacent bubble scales at the same rate.
-        final float pivotAdjustment = -(1 - newBubbleScale) * getScaledIconSize() / 2f;
-
-        if (onLeft) {
-            if (bubbleIndex == 0) {
-                // this is the animating bubble. use scaled spacing between it and the bubble to
-                // its left
-                return (bubbleCount - 1) * getScaledIconSize()
-                        + (bubbleCount - 2) * mExpandedBarIconsSpacing
-                        + newBubbleScale * mExpandedBarIconsSpacing
-                        + pivotAdjustment;
-            }
-            // when the bubble bar is on the left, only the translation of the right-most bubble
-            // is affected by the scale animation.
-            return (bubbleCount - bubbleIndex - 1) * iconAndSpacing;
-        } else if (bubbleIndex == 0) {
-            // the bubble bar is on the right, and this is the animating bubble. it only needs
-            // to be adjusted for the scaling pivot.
-            return pivotAdjustment;
-        } else {
-            return iconAndSpacing * (bubbleIndex - 1 + newBubbleScale);
-        }
-    }
-
     private float getCollapsedBubbleTranslationX(int bubbleIndex, int bubbleCount,
             boolean onLeft) {
         if (bubbleIndex < 0 || bubbleIndex >= bubbleCount) {
@@ -979,9 +982,11 @@
         BubbleView previouslySelectedBubble = mSelectedBubbleView;
         mSelectedBubbleView = view;
         mBubbleBarBackground.showArrow(view != null);
-        // TODO: (b/283309949) remove animation should be implemented first, so than arrow
-        //  animation is adjusted, skip animation for now
-        updateArrowForSelected(previouslySelectedBubble != null);
+
+        // if bubbles are being animated, the arrow position will be set as part of the animation
+        if (mBubbleAnimator == null) {
+            updateArrowForSelected(previouslySelectedBubble != null);
+        }
     }
 
     /**
@@ -1036,6 +1041,9 @@
     }
 
     private float arrowPositionForSelectedWhenExpanded(BubbleBarLocation bubbleBarLocation) {
+        if (mBubbleAnimator != null && mBubbleAnimator.isRunning()) {
+            return mBubbleAnimator.getArrowPosition() + mBubbleBarPadding;
+        }
         final int index = indexOfChild(mSelectedBubbleView);
         final float selectedBubbleTranslationX = getExpandedBubbleTranslationX(
                 index, getChildCount(), bubbleBarLocation.isOnLeft(isLayoutRtl()));
@@ -1101,20 +1109,14 @@
      */
     public float expandedWidth() {
         final int childCount = getChildCount();
-        // spaces amount is less than child count by 1, or 0 if no child views
-        final float totalSpace;
-        final float totalIconSize;
-        if (mNewBubbleScaleInAnimator != null && mNewBubbleScaleInAnimator.isRunning()) {
-            // when this animation is running, a new bubble is animating in while the bubble bar is
-            // expanded, so we have at least 2 bubbles in the bubble bar.
-            final float newBubbleScale = mNewBubbleScaleInAnimator.getAnimatedFraction();
-            totalSpace = (childCount - 2 + newBubbleScale) * mExpandedBarIconsSpacing;
-            totalIconSize = (childCount - 1 + newBubbleScale) * getScaledIconSize();
-        } else {
-            totalSpace = Math.max(childCount - 1, 0) * mExpandedBarIconsSpacing;
-            totalIconSize = childCount * getScaledIconSize();
+        final float horizontalPadding = 2 * mBubbleBarPadding;
+        if (mBubbleAnimator != null && mBubbleAnimator.isRunning()) {
+            return mBubbleAnimator.getExpandedWidth() + horizontalPadding;
         }
-        return totalIconSize + totalSpace + 2 * mBubbleBarPadding;
+        // spaces amount is less than child count by 1, or 0 if no child views
+        final float totalSpace = Math.max(childCount - 1, 0) * mExpandedBarIconsSpacing;
+        final float totalIconSize = childCount * getScaledIconSize();
+        return totalIconSize + totalSpace + horizontalPadding;
     }
 
     private float collapsedWidth() {
@@ -1165,7 +1167,6 @@
         return mIsAnimatingNewBubble;
     }
 
-
     private boolean hasOverview() {
         // Overview is always the last bubble
         View lastChild = getChildAt(getChildCount() - 1);
diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarViewController.java b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarViewController.java
index 951b99d..da0826b 100644
--- a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarViewController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarViewController.java
@@ -388,7 +388,7 @@
      */
     public void removeBubble(BubbleBarItem b) {
         if (b != null) {
-            mBarView.removeView(b.getView());
+            mBarView.removeBubble(b.getView());
         } else {
             Log.w(TAG, "removeBubble, bubble was null!");
         }
diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleStashController.java b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleStashController.java
index 74ddf90..185f85f 100644
--- a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleStashController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleStashController.java
@@ -184,7 +184,8 @@
 
     /** Whether bubbles are showing on the launcher home page. */
     public boolean isBubblesShowingOnHome() {
-        return mBubblesShowingOnHome;
+        boolean hasBubbles = mBarViewController != null && mBarViewController.hasBubbles();
+        return mBubblesShowingOnHome && hasBubbles;
     }
 
     // TODO: when tapping on an app in overview, this is a bit delayed compared to taskbar stashing
diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/animation/BubbleAnimator.kt b/quickstep/src/com/android/launcher3/taskbar/bubbles/animation/BubbleAnimator.kt
new file mode 100644
index 0000000..7672743
--- /dev/null
+++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/animation/BubbleAnimator.kt
@@ -0,0 +1,297 @@
+/*
+ * Copyright (C) 2024 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.bubbles.animation
+
+import androidx.core.animation.Animator
+import androidx.core.animation.ValueAnimator
+
+/**
+ * Animates individual bubbles within the bubble bar while the bubble bar is expanded.
+ *
+ * This class should only be kept for the duration of the animation and a new instance should be
+ * created for each animation.
+ */
+class BubbleAnimator(
+    private val iconSize: Float,
+    private val expandedBarIconSpacing: Float,
+    private val bubbleCount: Int,
+    private val onLeft: Boolean,
+) {
+
+    companion object {
+        const val ANIMATION_DURATION_MS = 250L
+    }
+
+    private var state: State = State.Idle
+    private lateinit var animator: ValueAnimator
+
+    fun animateNewBubble(selectedBubbleIndex: Int, listener: Listener) {
+        animator = createAnimator(listener)
+        state = State.AddingBubble(selectedBubbleIndex)
+        animator.start()
+    }
+
+    fun animateRemovedBubble(
+        bubbleIndex: Int,
+        selectedBubbleIndex: Int,
+        removingLastBubble: Boolean,
+        listener: Listener
+    ) {
+        animator = createAnimator(listener)
+        state = State.RemovingBubble(bubbleIndex, selectedBubbleIndex, removingLastBubble)
+        animator.start()
+    }
+
+    private fun createAnimator(listener: Listener): ValueAnimator {
+        val animator = ValueAnimator.ofFloat(0f, 1f).setDuration(ANIMATION_DURATION_MS)
+        animator.addUpdateListener { animation ->
+            val animatedFraction = (animation as ValueAnimator).animatedFraction
+            listener.onAnimationUpdate(animatedFraction)
+        }
+        animator.addListener(
+            object : Animator.AnimatorListener {
+
+                override fun onAnimationCancel(animation: Animator) {
+                    listener.onAnimationCancel()
+                }
+
+                override fun onAnimationEnd(animation: Animator) {
+                    state = State.Idle
+                    listener.onAnimationEnd()
+                }
+
+                override fun onAnimationRepeat(animation: Animator) {}
+
+                override fun onAnimationStart(animation: Animator) {}
+            }
+        )
+        return animator
+    }
+
+    /**
+     * The translation X of the bubble at index [bubbleIndex] according to the progress of the
+     * animation.
+     *
+     * Callers should verify that the animation is running before calling this.
+     *
+     * @see isRunning
+     */
+    fun getExpandedBubbleTranslationX(bubbleIndex: Int): Float {
+        return when (val state = state) {
+            State.Idle -> 0f
+            is State.AddingBubble ->
+                getExpandedBubbleTranslationXWhileScalingBubble(
+                    bubbleIndex = bubbleIndex,
+                    scalingBubbleIndex = 0,
+                    bubbleScale = animator.animatedFraction
+                )
+            is State.RemovingBubble ->
+                getExpandedBubbleTranslationXWhileScalingBubble(
+                    bubbleIndex = bubbleIndex,
+                    scalingBubbleIndex = state.bubbleIndex,
+                    bubbleScale = 1 - animator.animatedFraction
+                )
+        }
+    }
+
+    /**
+     * The expanded width of the bubble bar according to the progress of the animation.
+     *
+     * Callers should verify that the animation is running before calling this.
+     *
+     * @see isRunning
+     */
+    fun getExpandedWidth(): Float {
+        val bubbleScale =
+            when (state) {
+                State.Idle -> 0f
+                is State.AddingBubble -> animator.animatedFraction
+                is State.RemovingBubble -> 1 - animator.animatedFraction
+            }
+        // When this animator is running the bubble bar is expanded so it's safe to assume that we
+        // have at least 2 bubbles, but should update the logic to support optional overflow.
+        // If we're removing the last bubble, the entire bar should animate and we shouldn't get
+        // here.
+        val totalSpace = (bubbleCount - 2 + bubbleScale) * expandedBarIconSpacing
+        val totalIconSize = (bubbleCount - 1 + bubbleScale) * iconSize
+        return totalIconSize + totalSpace
+    }
+
+    /**
+     * Returns the arrow position according to the progress of the animation and, if the selected
+     * bubble is being removed, accounting to the newly selected bubble.
+     *
+     * Callers should verify that the animation is running before calling this.
+     *
+     * @see isRunning
+     */
+    fun getArrowPosition(): Float {
+        return when (val state = state) {
+            State.Idle -> 0f
+            is State.AddingBubble -> {
+                val tx =
+                    getExpandedBubbleTranslationXWhileScalingBubble(
+                        bubbleIndex = state.selectedBubbleIndex,
+                        scalingBubbleIndex = 0,
+                        bubbleScale = animator.animatedFraction
+                    )
+                tx + iconSize / 2f
+            }
+            is State.RemovingBubble -> getArrowPositionWhenRemovingBubble(state)
+        }
+    }
+
+    private fun getArrowPositionWhenRemovingBubble(state: State.RemovingBubble): Float {
+        return if (state.selectedBubbleIndex != state.bubbleIndex) {
+            // if we're not removing the selected bubble, the selected bubble doesn't change so just
+            // return the translation X of the selected bubble and add half icon
+            val tx =
+                getExpandedBubbleTranslationXWhileScalingBubble(
+                    bubbleIndex = state.selectedBubbleIndex,
+                    scalingBubbleIndex = state.bubbleIndex,
+                    bubbleScale = 1 - animator.animatedFraction
+                )
+            tx + iconSize / 2f
+        } else {
+            // we're removing the selected bubble so the arrow needs to point to a different bubble.
+            // if we're removing the last bubble the newly selected bubble will be the second to
+            // last. otherwise, it'll be the next bubble (closer to the overflow)
+            val iconAndSpacing = iconSize + expandedBarIconSpacing
+            if (state.removingLastBubble) {
+                if (onLeft) {
+                    // the newly selected bubble is the bubble to the right. at the end of the
+                    // animation all the bubbles will have shifted left, so the arrow stays at the
+                    // same distance from the left edge of bar
+                    (bubbleCount - state.bubbleIndex - 1) * iconAndSpacing + iconSize / 2f
+                } else {
+                    // the newly selected bubble is the bubble to the left. at the end of the
+                    // animation all the bubbles will have shifted right, and the arrow would
+                    // eventually be closer to the left edge of the bar by iconAndSpacing
+                    val initialTx = state.bubbleIndex * iconAndSpacing + iconSize / 2f
+                    initialTx - animator.animatedFraction * iconAndSpacing
+                }
+            } else {
+                if (onLeft) {
+                    // the newly selected bubble is to the left, and bubbles are shifting left, so
+                    // move the arrow closer to the left edge of the bar by iconAndSpacing
+                    val initialTx =
+                        (bubbleCount - state.bubbleIndex - 1) * iconAndSpacing + iconSize / 2f
+                    initialTx - animator.animatedFraction * iconAndSpacing
+                } else {
+                    // the newly selected bubble is to the right, and bubbles are shifting right, so
+                    // the arrow stays at the same distance from the left edge of the bar
+                    state.bubbleIndex * iconAndSpacing + iconSize / 2f
+                }
+            }
+        }
+    }
+
+    /**
+     * Returns the translation X for the bubble at index {@code bubbleIndex} when the bubble bar is
+     * expanded and a bubble is animating in or out.
+     *
+     * @param bubbleIndex the index of the bubble for which the translation is requested
+     * @param scalingBubbleIndex the index of the bubble that is animating
+     * @param bubbleScale the current scale of the animating bubble
+     */
+    private fun getExpandedBubbleTranslationXWhileScalingBubble(
+        bubbleIndex: Int,
+        scalingBubbleIndex: Int,
+        bubbleScale: Float
+    ): Float {
+        val iconAndSpacing = iconSize + expandedBarIconSpacing
+        // the bubble is scaling from the center, so we need to adjust its translation so
+        // that the distance to the adjacent bubble scales at the same rate.
+        val pivotAdjustment = -(1 - bubbleScale) * iconSize / 2f
+
+        return if (onLeft) {
+            when {
+                bubbleIndex < scalingBubbleIndex ->
+                    // the bar is on the left and the current bubble is to the right of the scaling
+                    // bubble so account for its scale
+                    (bubbleCount - bubbleIndex - 2 + bubbleScale) * iconAndSpacing
+                bubbleIndex == scalingBubbleIndex -> {
+                    // the bar is on the left and this is the scaling bubble
+                    val totalIconSize = (bubbleCount - bubbleIndex - 1) * iconSize
+                    // don't count the spacing between the scaling bubble and the bubble on the left
+                    // because we need to scale that space
+                    val totalSpacing = (bubbleCount - bubbleIndex - 2) * expandedBarIconSpacing
+                    val scaledSpace = bubbleScale * expandedBarIconSpacing
+                    totalIconSize + totalSpacing + scaledSpace + pivotAdjustment
+                }
+                else ->
+                    // the bar is on the left and the scaling bubble is on the right. the current
+                    // bubble is unaffected by the scaling bubble
+                    (bubbleCount - bubbleIndex - 1) * iconAndSpacing
+            }
+        } else {
+            when {
+                bubbleIndex < scalingBubbleIndex ->
+                    // the bar is on the right and the scaling bubble is on the right. the current
+                    // bubble is unaffected by the scaling bubble
+                    iconAndSpacing * bubbleIndex
+                bubbleIndex == scalingBubbleIndex ->
+                    // the bar is on the right, and this is the animating bubble. it only needs to
+                    // be adjusted for the scaling pivot.
+                    iconAndSpacing * bubbleIndex + pivotAdjustment
+                else ->
+                    // the bar is on the right and the scaling bubble is on the left so account for
+                    // its scale
+                    iconAndSpacing * (bubbleIndex - 1 + bubbleScale)
+            }
+        }
+    }
+
+    val isRunning: Boolean
+        get() = state != State.Idle
+
+    /** The state of the animation. */
+    sealed interface State {
+
+        /** The animation is not running. */
+        data object Idle : State
+
+        /** A new bubble is being added to the bubble bar. */
+        data class AddingBubble(val selectedBubbleIndex: Int) : State
+
+        /** A bubble is being removed from the bubble bar. */
+        data class RemovingBubble(
+            /** The index of the bubble being removed. */
+            val bubbleIndex: Int,
+            /** The index of the selected bubble. */
+            val selectedBubbleIndex: Int,
+            /** Whether the bubble being removed is also the last bubble. */
+            val removingLastBubble: Boolean
+        ) : State
+    }
+
+    /** Callbacks for the animation. */
+    interface Listener {
+
+        /**
+         * Notifies the listener of an animation update event, where `animatedFraction` represents
+         * the progress of the animation starting from 0 and ending at 1.
+         */
+        fun onAnimationUpdate(animatedFraction: Float)
+
+        /** Notifies the listener that the animation was canceled. */
+        fun onAnimationCancel()
+
+        /** Notifies that listener that the animation ended. */
+        fun onAnimationEnd()
+    }
+}
diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/animation/BubbleBarViewAnimator.kt b/quickstep/src/com/android/launcher3/taskbar/bubbles/animation/BubbleBarViewAnimator.kt
index 2dcd932..feff9fd 100644
--- a/quickstep/src/com/android/launcher3/taskbar/bubbles/animation/BubbleBarViewAnimator.kt
+++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/animation/BubbleBarViewAnimator.kt
@@ -41,7 +41,7 @@
 
     private var animatingBubble: AnimatingBubble? = null
     private val bubbleBarBounceDistanceInPx =
-            bubbleBarView.resources.getDimensionPixelSize(R.dimen.bubblebar_bounce_distance)
+        bubbleBarView.resources.getDimensionPixelSize(R.dimen.bubblebar_bounce_distance)
 
     private companion object {
         /** The time to show the flyout. */
@@ -347,7 +347,7 @@
      */
     private fun buildBubbleBarBounceAnimation() = Runnable {
         bubbleBarView.onAnimatingBubbleStarted()
-        val ty = bubbleBarView.translationY
+        val ty = bubbleStashController.bubbleBarTranslationY
 
         val springBackAnimation = PhysicsAnimator.getInstance(bubbleBarView)
         springBackAnimation.setDefaultSpringConfig(springConfig)
diff --git a/quickstep/src/com/android/launcher3/taskbar/customization/TaskbarFeatureEvaluator.kt b/quickstep/src/com/android/launcher3/taskbar/customization/TaskbarFeatureEvaluator.kt
index 668a87d..ac7dd06 100644
--- a/quickstep/src/com/android/launcher3/taskbar/customization/TaskbarFeatureEvaluator.kt
+++ b/quickstep/src/com/android/launcher3/taskbar/customization/TaskbarFeatureEvaluator.kt
@@ -33,7 +33,7 @@
     val hasNavButtons = taskbarActivityContext.isThreeButtonNav
 
     val hasRecents: Boolean
-        get() = taskbarControllers.taskbarRecentAppsController.isEnabled
+        get() = taskbarControllers.taskbarRecentAppsController.shownTasks.isNotEmpty()
 
     val hasDivider: Boolean
         get() = enableTaskbarPinning() || hasRecents
diff --git a/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java b/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java
index 2168f7a..be6f690 100644
--- a/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java
+++ b/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java
@@ -60,6 +60,8 @@
 import static com.android.launcher3.util.DisplayController.CHANGE_ACTIVE_SCREEN;
 import static com.android.launcher3.util.DisplayController.CHANGE_NAVIGATION_MODE;
 import static com.android.launcher3.util.Executors.UI_HELPER_EXECUTOR;
+import static com.android.quickstep.util.ActiveGestureErrorDetector.GestureEvent.QUICK_SWITCH_FROM_HOME_FAILED;
+import static com.android.quickstep.util.ActiveGestureErrorDetector.GestureEvent.QUICK_SWITCH_FROM_HOME_FALLBACK;
 import static com.android.quickstep.util.AnimUtils.completeRunnableListCallback;
 import static com.android.quickstep.util.SplitAnimationTimings.TABLET_HOME_TO_SPLIT;
 import static com.android.systemui.shared.system.ActivityManagerWrapper.CLOSE_SYSTEM_WINDOWS_REASON_HOME_KEY;
@@ -172,6 +174,7 @@
 import com.android.quickstep.SystemUiProxy;
 import com.android.quickstep.TaskUtils;
 import com.android.quickstep.TouchInteractionService.TISBinder;
+import com.android.quickstep.util.ActiveGestureLog;
 import com.android.quickstep.util.AsyncClockEventDelegate;
 import com.android.quickstep.util.GroupTask;
 import com.android.quickstep.util.LauncherUnfoldAnimationController;
@@ -198,8 +201,6 @@
 import com.android.systemui.unfold.progress.RemoteUnfoldTransitionReceiver;
 import com.android.systemui.unfold.updates.RotationChangeProvider;
 
-import kotlin.Unit;
-
 import java.io.FileDescriptor;
 import java.io.PrintWriter;
 import java.util.ArrayList;
@@ -212,6 +213,8 @@
 import java.util.function.Predicate;
 import java.util.stream.Stream;
 
+import kotlin.Unit;
+
 public class QuickstepLauncher extends Launcher implements RecentsViewContainer {
     private static final boolean TRACE_LAYOUTS =
             SystemProperties.getBoolean("persist.debug.trace_layouts", false);
@@ -581,9 +584,19 @@
             }
             case QUICK_SWITCH_STATE_ORDINAL: {
                 RecentsView rv = getOverviewPanel();
-                TaskView tasktolaunch = rv.getCurrentPageTaskView();
-                if (tasktolaunch != null) {
-                    tasktolaunch.launchTask(success -> {
+                TaskView currentPageTask = rv.getCurrentPageTaskView();
+                TaskView fallbackTask = rv.getTaskViewAt(0);
+                if (currentPageTask != null || fallbackTask != null) {
+                    TaskView taskToLaunch = currentPageTask;
+                    if (currentPageTask == null) {
+                        taskToLaunch = fallbackTask;
+                        ActiveGestureLog.INSTANCE.addLog(new ActiveGestureLog.CompoundString(
+                                "Quick switch from home fallback case: The TaskView at index ")
+                                        .append(rv.getCurrentPage())
+                                        .append(" is missing."),
+                                QUICK_SWITCH_FROM_HOME_FALLBACK);
+                    }
+                    taskToLaunch.launchTask(success -> {
                         if (!success) {
                             getStateManager().goToState(OVERVIEW);
                         } else {
@@ -592,6 +605,11 @@
                         return Unit.INSTANCE;
                     });
                 } else {
+                    ActiveGestureLog.INSTANCE.addLog(new ActiveGestureLog.CompoundString(
+                            "Quick switch from home failed: TaskViews at indices ")
+                                    .append(rv.getCurrentPage())
+                                    .append(" and 0 are missing."),
+                            QUICK_SWITCH_FROM_HOME_FAILED);
                     getStateManager().goToState(NORMAL);
                 }
                 break;
@@ -1182,7 +1200,6 @@
                         : Display.DEFAULT_DISPLAY);
         activityOptions.options.setPendingIntentBackgroundActivityStartMode(
                 ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED);
-        addLaunchCookie(item, activityOptions.options);
         return activityOptions;
     }
 
@@ -1207,19 +1224,6 @@
     }
 
     /**
-     * Adds a new launch cookie for the activity launch if supported.
-     *
-     * @param info the item info for the launch
-     * @param opts the options to set the launchCookie on.
-     */
-    public void addLaunchCookie(ItemInfo info, ActivityOptions opts) {
-        IBinder launchCookie = getLaunchCookie(info);
-        if (launchCookie != null) {
-            opts.setLaunchCookie(launchCookie);
-        }
-    }
-
-    /**
      * Return a new launch cookie for the activity launch if supported.
      *
      * @param info the item info for the launch
diff --git a/quickstep/src/com/android/launcher3/uioverrides/QuickstepWidgetHolder.java b/quickstep/src/com/android/launcher3/uioverrides/QuickstepWidgetHolder.java
index 01d5ff0..56fc4d1 100644
--- a/quickstep/src/com/android/launcher3/uioverrides/QuickstepWidgetHolder.java
+++ b/quickstep/src/com/android/launcher3/uioverrides/QuickstepWidgetHolder.java
@@ -17,7 +17,6 @@
 
 import static com.android.launcher3.BuildConfig.WIDGETS_ENABLED;
 import static com.android.launcher3.util.Executors.MAIN_EXECUTOR;
-import static com.android.launcher3.util.Executors.UI_HELPER_EXECUTOR;
 
 import android.appwidget.AppWidgetHost;
 import android.appwidget.AppWidgetHostView;
@@ -100,7 +99,7 @@
                                     // for concurrent modification.
                                     new ArrayList<>(h.mProviderChangedListeners).forEach(
                                     ProviderChangedListener::notifyWidgetProvidersChanged))),
-                    UI_HELPER_EXECUTOR.getLooper());
+                    getWidgetHolderExecutor().getLooper());
             if (WIDGETS_ENABLED) {
                 sWidgetHost.startListening();
             }
@@ -199,8 +198,10 @@
             return;
         }
 
-        sWidgetHost.setAppWidgetHidden();
-        setListeningFlag(false);
+        getWidgetHolderExecutor().execute(() -> {
+            sWidgetHost.setAppWidgetHidden();
+            setListeningFlag(false);
+        });
     }
 
     @Override
diff --git a/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/TaskViewTouchController.java b/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/TaskViewTouchController.java
index 4bc3c16..3c7f335 100644
--- a/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/TaskViewTouchController.java
+++ b/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/TaskViewTouchController.java
@@ -252,7 +252,7 @@
                     mTaskBeingDragged, maxDuration, currentInterpolator);
 
             // Since the thumbnail is what is filling the screen, based the end displacement on it.
-            View thumbnailView = mTaskBeingDragged.getFirstThumbnailViewDeprecated();
+            View thumbnailView = mTaskBeingDragged.getFirstSnapshotView();
             mTempCords[1] = orientationHandler.getSecondaryDimension(thumbnailView);
             dl.getDescendantCoordRelativeToSelf(thumbnailView, mTempCords);
             mEndDisplacement = secondaryLayerDimension - mTempCords[1];
diff --git a/quickstep/src/com/android/quickstep/AbsSwipeUpHandler.java b/quickstep/src/com/android/quickstep/AbsSwipeUpHandler.java
index 1acafab..fb2a982 100644
--- a/quickstep/src/com/android/quickstep/AbsSwipeUpHandler.java
+++ b/quickstep/src/com/android/quickstep/AbsSwipeUpHandler.java
@@ -1196,7 +1196,7 @@
         }
         if (mContainerInterface.getTaskbarController() != null) {
             // Resets this value as the gesture is now complete.
-            mContainerInterface.getTaskbarController().setUserIsGoingHome(false);
+            mContainerInterface.getTaskbarController().setUserIsNotGoingHome(false);
         }
         ActiveGestureLog.INSTANCE.addLog(
                 new ActiveGestureLog.CompoundString("onSettledOnEndTarget ")
@@ -1205,17 +1205,28 @@
     }
 
     /** @return Whether this was the task we were waiting to appear, and thus handled it. */
-    protected boolean handleTaskAppeared(RemoteAnimationTarget[] appearedTaskTarget) {
+    protected boolean handleTaskAppeared(@NonNull RemoteAnimationTarget[] appearedTaskTargets,
+            @NonNull ActiveGestureLog.CompoundString failureReason) {
         if (mStateCallback.hasStates(STATE_HANDLER_INVALIDATED)) {
+            failureReason.append("State handler was invalidated");
             return false;
         }
-        boolean hasStartedTaskBefore = Arrays.stream(appearedTaskTarget).anyMatch(
-                mGestureState.mLastStartedTaskIdPredicate);
-        if (mStateCallback.hasStates(STATE_START_NEW_TASK) && hasStartedTaskBefore) {
-            reset();
-            return true;
+        boolean stateStartNewTaskSet = mStateCallback.hasStates(STATE_START_NEW_TASK);
+        if (!stateStartNewTaskSet || !hasStartedTaskBefore(appearedTaskTargets)) {
+            if (!stateStartNewTaskSet) {
+                failureReason.append("STATE_START_NEW_TASK was never set");
+            } else {
+                TaskInfo taskInfo = appearedTaskTargets[0].taskInfo;
+                failureReason.append("Unexpected task appeared")
+                                .append(" id=")
+                                .append(taskInfo.taskId)
+                                .append(" pkg=")
+                                .append(taskInfo.baseIntent.getComponent().getPackageName());
+            }
+            return false;
         }
-        return false;
+        reset();
+        return true;
     }
 
     private float dpiFromPx(float pixels) {
@@ -1350,7 +1361,7 @@
                 && mIsTransientTaskbar
                 && mContainerInterface.getTaskbarController() != null) {
             mContainerInterface.getTaskbarController()
-                    .setUserIsGoingHome(endTarget == GestureState.GestureEndTarget.HOME);
+                    .setUserIsNotGoingHome(endTarget != GestureState.GestureEndTarget.HOME);
         }
 
         float endShift = endTarget == ALL_APPS ? mDragLengthFactor
@@ -1796,6 +1807,8 @@
                 && (windowRotation == ROTATION_90 || windowRotation == ROTATION_270)) {
             builder.setFromRotation(mRemoteTargetHandles[0].getTaskViewSimulator(), windowRotation,
                     taskInfo.displayCutoutInsets);
+        } else if (taskInfo.displayCutoutInsets != null) {
+            builder.setDisplayCutoutInsets(taskInfo.displayCutoutInsets);
         }
         final SwipePipToHomeAnimator swipePipToHomeAnimator = builder.build();
         AnimatorPlaybackController activityAnimationToHome =
@@ -2400,14 +2413,18 @@
         }
     }
 
+    private boolean hasStartedTaskBefore(@NonNull RemoteAnimationTarget[] appearedTaskTargets) {
+        return Arrays.stream(appearedTaskTargets)
+                .anyMatch(mGestureState.mLastStartedTaskIdPredicate);
+    }
+
     @Override
     public void onTasksAppeared(@NonNull RemoteAnimationTarget[] appearedTaskTargets) {
         if (mRecentsAnimationController == null) {
             return;
         }
-        boolean hasStartedTaskBefore = Arrays.stream(appearedTaskTargets).anyMatch(
-                mGestureState.mLastStartedTaskIdPredicate);
-        if (!mStateCallback.hasStates(STATE_GESTURE_COMPLETED) && !hasStartedTaskBefore) {
+        if (!mStateCallback.hasStates(STATE_GESTURE_COMPLETED)
+                && !hasStartedTaskBefore(appearedTaskTargets)) {
             // This is a special case, if a task is started mid-gesture that wasn't a part of a
             // previous quickswitch task launch, then cancel the animation back to the app
             RemoteAnimationTarget appearedTaskTarget = appearedTaskTargets[0];
@@ -2421,7 +2438,11 @@
             finishRecentsAnimationOnTasksAppeared(null /* onFinishComplete */);
             return;
         }
-        if (!handleTaskAppeared(appearedTaskTargets)) {
+        ActiveGestureLog.CompoundString handleTaskFailureReason =
+                new ActiveGestureLog.CompoundString("handleTaskAppeared check failed: ");
+        if (!handleTaskAppeared(appearedTaskTargets, handleTaskFailureReason)) {
+            ActiveGestureLog.INSTANCE.addLog(handleTaskFailureReason);
+            finishRecentsAnimationOnTasksAppeared(null /* onFinishComplete */);
             return;
         }
         Optional<RemoteAnimationTarget> taskTargetOptional =
diff --git a/quickstep/src/com/android/quickstep/FallbackSwipeHandler.java b/quickstep/src/com/android/quickstep/FallbackSwipeHandler.java
index 625b6c6..9b66154 100644
--- a/quickstep/src/com/android/quickstep/FallbackSwipeHandler.java
+++ b/quickstep/src/com/android/quickstep/FallbackSwipeHandler.java
@@ -64,6 +64,7 @@
 import com.android.launcher3.util.DisplayController;
 import com.android.quickstep.fallback.FallbackRecentsView;
 import com.android.quickstep.fallback.RecentsState;
+import com.android.quickstep.util.ActiveGestureLog;
 import com.android.quickstep.util.RectFSpringAnim;
 import com.android.quickstep.util.SurfaceTransaction.SurfaceProperties;
 import com.android.quickstep.util.TransformParams;
@@ -170,14 +171,16 @@
     }
 
     @Override
-    protected boolean handleTaskAppeared(RemoteAnimationTarget[] appearedTaskTarget) {
+    protected boolean handleTaskAppeared(@NonNull RemoteAnimationTarget[] appearedTaskTarget,
+            @NonNull ActiveGestureLog.CompoundString failureReason) {
         if (mActiveAnimationFactory != null
                 && mActiveAnimationFactory.handleHomeTaskAppeared(appearedTaskTarget)) {
             mActiveAnimationFactory = null;
+            failureReason.append("(FallbackSwipeHandler) should be handled as home task appeared");
             return false;
         }
 
-        return super.handleTaskAppeared(appearedTaskTarget);
+        return super.handleTaskAppeared(appearedTaskTarget, failureReason);
     }
 
     @Override
diff --git a/quickstep/src/com/android/quickstep/LauncherActivityInterface.java b/quickstep/src/com/android/quickstep/LauncherActivityInterface.java
index c428827..1048ea1 100644
--- a/quickstep/src/com/android/quickstep/LauncherActivityInterface.java
+++ b/quickstep/src/com/android/quickstep/LauncherActivityInterface.java
@@ -35,7 +35,6 @@
 import androidx.annotation.UiThread;
 
 import com.android.launcher3.DeviceProfile;
-import com.android.launcher3.Flags;
 import com.android.launcher3.Launcher;
 import com.android.launcher3.LauncherAnimUtils;
 import com.android.launcher3.LauncherInitListener;
@@ -213,10 +212,7 @@
         if (launcher.isStarted() && (isInLiveTileMode() || launcher.hasBeenResumed())) {
             return launcher;
         }
-        if (Flags.useActivityOverlay()
-                && SystemUiProxy.INSTANCE.get(launcher).getHomeVisibilityState().isHomeVisible()) {
-            return launcher;
-        }
+
         return null;
     }
 
diff --git a/quickstep/src/com/android/quickstep/LauncherSwipeHandlerV2.java b/quickstep/src/com/android/quickstep/LauncherSwipeHandlerV2.java
index 3c66590..e17cdcd 100644
--- a/quickstep/src/com/android/quickstep/LauncherSwipeHandlerV2.java
+++ b/quickstep/src/com/android/quickstep/LauncherSwipeHandlerV2.java
@@ -144,8 +144,6 @@
         return new FloatingViewHomeAnimationFactory(floatingIconView) {
             @Nullable
             private RectF mTargetRect;
-            @Nullable
-            private RectFSpringAnim mSiblingAnimation;
 
             @Nullable
             @Override
@@ -173,14 +171,6 @@
             }
 
             @Override
-            protected void playScalingRevealAnimation() {
-                if (mContainer != null) {
-                    new ScalingWorkspaceRevealAnim(mContainer, mSiblingAnimation,
-                            getWindowTargetRect()).start();
-                }
-            }
-
-            @Override
             public void setAnimation(RectFSpringAnim anim) {
                 super.setAnimation(anim);
                 mSiblingAnimation = anim;
@@ -245,6 +235,8 @@
                 isTargetTranslucent, fallbackBackgroundColor);
 
         return new FloatingViewHomeAnimationFactory(floatingWidgetView) {
+            @Nullable
+            private RectF mTargetRect;
 
             @Override
             @Nullable
@@ -254,8 +246,14 @@
 
             @Override
             public RectF getWindowTargetRect() {
-                super.getWindowTargetRect();
-                return backgroundLocation;
+                if (enableScalingRevealHomeAnimation()) {
+                    if (mTargetRect == null) {
+                        mTargetRect = new RectF(backgroundLocation);
+                    }
+                    return mTargetRect;
+                } else {
+                    return backgroundLocation;
+                }
             }
 
             @Override
@@ -266,10 +264,11 @@
             @Override
             public void setAnimation(RectFSpringAnim anim) {
                 super.setAnimation(anim);
-
-                anim.addAnimatorListener(floatingWidgetView);
-                floatingWidgetView.setOnTargetChangeListener(anim::onTargetPositionChanged);
-                floatingWidgetView.setFastFinishRunnable(anim::end);
+                mSiblingAnimation = anim;
+                mSiblingAnimation.addAnimatorListener(floatingWidgetView);
+                floatingWidgetView.setOnTargetChangeListener(
+                        mSiblingAnimation::onTargetPositionChanged);
+                floatingWidgetView.setFastFinishRunnable(mSiblingAnimation::end);
             }
 
             @Override
@@ -330,14 +329,23 @@
     }
 
     private class FloatingViewHomeAnimationFactory extends LauncherHomeAnimationFactory {
-
         private final FloatingView mFloatingView;
+        @Nullable
+        protected RectFSpringAnim mSiblingAnimation;
 
         FloatingViewHomeAnimationFactory(FloatingView floatingView) {
             mFloatingView = floatingView;
         }
 
         @Override
+        protected void playScalingRevealAnimation() {
+            if (mContainer != null) {
+                new ScalingWorkspaceRevealAnim(mContainer, mSiblingAnimation,
+                        getWindowTargetRect()).start();
+            }
+        }
+
+        @Override
         public void onCancel() {
             mFloatingView.fastFinish();
         }
diff --git a/quickstep/src/com/android/quickstep/OverviewCommandHelper.java b/quickstep/src/com/android/quickstep/OverviewCommandHelper.java
index 7da92bc..8f533a3 100644
--- a/quickstep/src/com/android/quickstep/OverviewCommandHelper.java
+++ b/quickstep/src/com/android/quickstep/OverviewCommandHelper.java
@@ -265,6 +265,10 @@
                 case TYPE_HOME:
                     ActiveGestureLog.INSTANCE.addLog(
                             "OverviewCommandHelper.executeCommand(TYPE_HOME)");
+                    // Although IActivityTaskManager$Stub$Proxy.startActivity is a slow binder call,
+                    // we should still call it on main thread because launcher is waiting for
+                    // ActivityTaskManager to resume it. Also calling startActivity() on bg thread
+                    // could potentially delay resuming launcher. See b/348668521 for more details.
                     mService.startActivity(mOverviewComponentObserver.getHomeIntent());
                     return true;
                 case TYPE_SHOW:
diff --git a/quickstep/src/com/android/quickstep/OverviewComponentObserver.java b/quickstep/src/com/android/quickstep/OverviewComponentObserver.java
index a71e314..9c64576 100644
--- a/quickstep/src/com/android/quickstep/OverviewComponentObserver.java
+++ b/quickstep/src/com/android/quickstep/OverviewComponentObserver.java
@@ -36,6 +36,7 @@
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
 
 import com.android.launcher3.R;
 import com.android.launcher3.util.SimpleBroadcastReceiver;
@@ -101,7 +102,7 @@
             mConfigChangesMap.append(fallbackComponent.hashCode(), fallbackInfo.configChanges);
         } catch (PackageManager.NameNotFoundException ignored) { /* Impossible */ }
 
-        mUserPreferenceChangeReceiver.register(mContext, ACTION_PREFERRED_ACTIVITY_CHANGED);
+        mUserPreferenceChangeReceiver.registerAsync(mContext, ACTION_PREFERRED_ACTIVITY_CHANGED);
         updateOverviewTargets();
     }
 
@@ -114,6 +115,8 @@
         mOverviewChangeListener = overviewChangeListener;
     }
 
+    /** Called on {@link TouchInteractionService#onSystemUiFlagsChanged} */
+    @UiThread
     public void onSystemUiStateChanged() {
         if (mDeviceState.isHomeDisabled() != mIsHomeDisabled) {
             updateOverviewTargets();
@@ -128,6 +131,7 @@
      * Update overview intent and {@link BaseActivityInterface} based off the current launcher home
      * component.
      */
+    @UiThread
     private void updateOverviewTargets() {
         ComponentName defaultHome = PackageManagerWrapper.getInstance()
                 .getHomeActivities(new ArrayList<>());
@@ -187,8 +191,9 @@
                 unregisterOtherHomeAppUpdateReceiver();
 
                 mUpdateRegisteredPackage = defaultHome.getPackageName();
-                mOtherHomeAppUpdateReceiver.registerPkgActions(mContext, mUpdateRegisteredPackage,
-                        ACTION_PACKAGE_ADDED, ACTION_PACKAGE_CHANGED, ACTION_PACKAGE_REMOVED);
+                mOtherHomeAppUpdateReceiver.registerPkgActionsAsync(
+                        mContext, mUpdateRegisteredPackage, ACTION_PACKAGE_ADDED,
+                        ACTION_PACKAGE_CHANGED, ACTION_PACKAGE_REMOVED);
             }
         }
         mOverviewChangeListener.accept(mIsHomeAndOverviewSame);
@@ -198,13 +203,13 @@
      * Clean up any registered receivers.
      */
     public void onDestroy() {
-        mContext.unregisterReceiver(mUserPreferenceChangeReceiver);
+        mUserPreferenceChangeReceiver.unregisterReceiverSafelyAsync(mContext);
         unregisterOtherHomeAppUpdateReceiver();
     }
 
     private void unregisterOtherHomeAppUpdateReceiver() {
         if (mUpdateRegisteredPackage != null) {
-            mContext.unregisterReceiver(mOtherHomeAppUpdateReceiver);
+            mOtherHomeAppUpdateReceiver.unregisterReceiverSafelyAsync(mContext);
             mUpdateRegisteredPackage = null;
         }
     }
diff --git a/quickstep/src/com/android/quickstep/RecentTasksList.java b/quickstep/src/com/android/quickstep/RecentTasksList.java
index b08a46f..3d4167a 100644
--- a/quickstep/src/com/android/quickstep/RecentTasksList.java
+++ b/quickstep/src/com/android/quickstep/RecentTasksList.java
@@ -44,6 +44,7 @@
 
 import java.io.PrintWriter;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.function.Consumer;
@@ -70,7 +71,8 @@
     private TaskLoadResult mResultsBg = INVALID_RESULT;
     private TaskLoadResult mResultsUi = INVALID_RESULT;
 
-    private RecentsModel.RunningTasksListener mRunningTasksListener;
+    private @Nullable RecentsModel.RunningTasksListener mRunningTasksListener;
+    private @Nullable RecentsModel.RecentTasksChangedListener mRecentTasksChangedListener;
     // Tasks are stored in order of least recently launched to most recently launched.
     private ArrayList<ActivityManager.RunningTaskInfo> mRunningTasks;
 
@@ -199,6 +201,9 @@
 
     public void onRecentTasksChanged() {
         invalidateLoadedTasks();
+        if (mRecentTasksChangedListener != null) {
+            mRecentTasksChangedListener.onRecentTasksChanged();
+        }
     }
 
     private synchronized void invalidateLoadedTasks() {
@@ -221,6 +226,21 @@
         mRunningTasksListener = null;
     }
 
+    /**
+     * Registers a listener for running tasks
+     */
+    public void registerRecentTasksChangedListener(
+            RecentsModel.RecentTasksChangedListener listener) {
+        mRecentTasksChangedListener = listener;
+    }
+
+    /**
+     * Removes the previously registered running tasks listener
+     */
+    public void unregisterRecentTasksChangedListener() {
+        mRecentTasksChangedListener = null;
+    }
+
     private void initRunningTasks(ArrayList<ActivityManager.RunningTaskInfo> runningTasks) {
         // Tasks are retrieved in order of most recently launched/used to least recently launched.
         mRunningTasks = new ArrayList<>(runningTasks);
@@ -305,7 +325,9 @@
                 // leftover TYPE_FREEFORM tasks created when flag was on should be ignored.
                 if (enableDesktopWindowingMode()) {
                     GroupTask desktopTask = createDesktopTask(rawTask);
-                    allTasks.add(desktopTask);
+                    if (desktopTask != null) {
+                        allTasks.add(desktopTask);
+                    }
                 }
                 continue;
             }
@@ -349,14 +371,22 @@
         return allTasks;
     }
 
-    private DesktopTask createDesktopTask(GroupedRecentTaskInfo recentTaskInfo) {
+    private @Nullable DesktopTask createDesktopTask(GroupedRecentTaskInfo recentTaskInfo) {
         ArrayList<Task> tasks = new ArrayList<>(recentTaskInfo.getTaskInfoList().size());
+        int[] minimizedTaskIds = recentTaskInfo.getMinimizedTaskIds();
+        if (minimizedTaskIds.length == recentTaskInfo.getTaskInfoList().size()) {
+            // All Tasks are minimized -> don't create a DesktopTask
+            return null;
+        }
         for (ActivityManager.RecentTaskInfo taskInfo : recentTaskInfo.getTaskInfoList()) {
             Task.TaskKey key = new Task.TaskKey(taskInfo);
             Task task = Task.from(key, taskInfo, false);
             task.setLastSnapshotData(taskInfo);
             task.positionInParent = taskInfo.positionInParent;
             task.appBounds = taskInfo.configuration.windowConfiguration.getAppBounds();
+            task.isVisible = taskInfo.isVisible;
+            task.isMinimized =
+                    Arrays.stream(minimizedTaskIds).anyMatch(taskId -> taskId == taskInfo.taskId);
             tasks.add(task);
         }
         return new DesktopTask(tasks);
diff --git a/quickstep/src/com/android/quickstep/RecentsModel.java b/quickstep/src/com/android/quickstep/RecentsModel.java
index 6eefe4a..b796951 100644
--- a/quickstep/src/com/android/quickstep/RecentsModel.java
+++ b/quickstep/src/com/android/quickstep/RecentsModel.java
@@ -42,6 +42,7 @@
 import com.android.launcher3.util.MainThreadInitializedObject;
 import com.android.launcher3.util.SafeCloseable;
 import com.android.quickstep.recents.data.RecentTasksDataSource;
+import com.android.quickstep.util.DesktopTask;
 import com.android.quickstep.util.GroupTask;
 import com.android.quickstep.util.TaskVisualsChangeListener;
 import com.android.systemui.shared.recents.model.Task;
@@ -301,6 +302,8 @@
 
     /**
      * Registers a listener for running tasks
+     * TODO(b/343292503): Should we remove RunningTasksListener entirely if it's not needed?
+     *  (Note that Desktop mode gets the running tasks by checking {@link DesktopTask#tasks}
      */
     public void registerRunningTasksListener(RunningTasksListener listener) {
         mTaskList.registerRunningTasksListener(listener);
@@ -314,6 +317,20 @@
     }
 
     /**
+     * Registers a listener for recent tasks
+     */
+    public void registerRecentTasksChangedListener(RecentTasksChangedListener listener) {
+        mTaskList.registerRecentTasksChangedListener(listener);
+    }
+
+    /**
+     * Removes the previously registered running tasks listener
+     */
+    public void unregisterRecentTasksChangedListener() {
+        mTaskList.unregisterRecentTasksChangedListener();
+    }
+
+    /**
      * Gets the set of running tasks.
      */
     public ArrayList<ActivityManager.RunningTaskInfo> getRunningTasks() {
@@ -379,4 +396,14 @@
          */
         void onRunningTasksChanged();
     }
+
+    /**
+     * Listener for receiving recent tasks changes
+     */
+    public interface RecentTasksChangedListener {
+        /**
+         * Called when there's a change to recent tasks
+         */
+        void onRecentTasksChanged();
+    }
 }
diff --git a/quickstep/src/com/android/quickstep/TaskAnimationManager.java b/quickstep/src/com/android/quickstep/TaskAnimationManager.java
index 28fa81a..08bb6cd 100644
--- a/quickstep/src/com/android/quickstep/TaskAnimationManager.java
+++ b/quickstep/src/com/android/quickstep/TaskAnimationManager.java
@@ -100,6 +100,11 @@
     TaskAnimationManager(Context ctx) {
         mCtx = ctx;
     }
+
+    SystemUiProxy getSystemUiProxy() {
+        return SystemUiProxy.INSTANCE.get(mCtx);
+    }
+
     /**
      * Preloads the recents animation.
      */
@@ -153,7 +158,7 @@
         final BaseContainerInterface containerInterface = gestureState.getContainerInterface();
         mLastGestureState = gestureState;
         RecentsAnimationCallbacks newCallbacks = new RecentsAnimationCallbacks(
-                SystemUiProxy.INSTANCE.get(mCtx), containerInterface.allowMinimizeSplitScreen());
+                getSystemUiProxy(), containerInterface.allowMinimizeSplitScreen());
         mCallbacks = newCallbacks;
         mCallbacks.addListener(new RecentsAnimationCallbacks.RecentsAnimationListener() {
             @Override
@@ -260,7 +265,7 @@
                 }
 
                 RemoteAnimationTarget[] nonAppTargets = ENABLE_SHELL_TRANSITIONS
-                        ? null : SystemUiProxy.INSTANCE.get(mCtx).onStartingSplitLegacy(
+                        ? null : getSystemUiProxy().onStartingSplitLegacy(
                                 appearedTaskTargets);
                 if (nonAppTargets == null) {
                     nonAppTargets = new RemoteAnimationTarget[0];
@@ -327,12 +332,13 @@
 
         if (ENABLE_SHELL_TRANSITIONS) {
             final ActivityOptions options = ActivityOptions.makeBasic();
+            options.setPendingIntentBackgroundActivityLaunchAllowedByPermission(true);
             // Use regular (non-transient) launch for all apps page to control IME.
             if (!containerInterface.allowAllAppsFromOverview()) {
                 options.setTransientLaunch();
             }
             options.setSourceInfo(ActivityOptions.SourceInfo.TYPE_RECENTS_ANIMATION, eventTime);
-            mRecentsAnimationStartPending = SystemUiProxy.INSTANCE.get(mCtx)
+            mRecentsAnimationStartPending = getSystemUiProxy()
                     .startRecentsActivity(intent, options, mCallbacks);
             if (enableHandleDelayedGestureCallbacks()) {
                 ActiveGestureLog.INSTANCE.addLog(new ActiveGestureLog.CompoundString(
diff --git a/quickstep/src/com/android/quickstep/TaskOverlayFactory.java b/quickstep/src/com/android/quickstep/TaskOverlayFactory.java
index b183ae3..5d0d074 100644
--- a/quickstep/src/com/android/quickstep/TaskOverlayFactory.java
+++ b/quickstep/src/com/android/quickstep/TaskOverlayFactory.java
@@ -44,7 +44,6 @@
 import com.android.quickstep.views.OverviewActionsView;
 import com.android.quickstep.views.RecentsView;
 import com.android.quickstep.views.RecentsViewContainer;
-import com.android.quickstep.views.TaskThumbnailViewDeprecated;
 import com.android.quickstep.views.TaskView;
 import com.android.quickstep.views.TaskView.TaskContainer;
 import com.android.systemui.shared.recents.model.Task;
@@ -134,20 +133,20 @@
             mApplicationContext = taskContainer.getTaskView().getContext().getApplicationContext();
             mTaskContainer = taskContainer;
             mImageApi = new ImageActionsApi(
-                    mApplicationContext, mTaskContainer.getThumbnailViewDeprecated()::getThumbnail);
+                    mApplicationContext, mTaskContainer::getThumbnail);
         }
 
         protected T getActionsView() {
             if (mActionsView == null) {
                 mActionsView = BaseActivity.fromContext(
-                        mTaskContainer.getThumbnailViewDeprecated().getContext()).findViewById(
+                        mTaskContainer.getTaskView().getContext()).findViewById(
                         R.id.overview_actions_view);
             }
             return mActionsView;
         }
 
-        public TaskThumbnailViewDeprecated getThumbnailView() {
-            return mTaskContainer.getThumbnailViewDeprecated();
+        public TaskView getTaskView() {
+            return mTaskContainer.getTaskView();
         }
 
         /**
@@ -159,8 +158,7 @@
 
             if (thumbnail != null) {
                 getActionsView().updateDisabledFlags(DISABLED_ROTATED, rotated);
-                boolean isAllowedByPolicy =
-                        mTaskContainer.getThumbnailViewDeprecated().isRealSnapshot();
+                boolean isAllowedByPolicy = mTaskContainer.isRealSnapshot();
                 getActionsView().setCallbacks(new OverlayUICallbacksImpl(isAllowedByPolicy, task));
             }
         }
@@ -172,7 +170,7 @@
          */
         public void endLiveTileMode(@NonNull Runnable callback) {
             RecentsView recentsView =
-                    mTaskContainer.getThumbnailViewDeprecated().getTaskView().getRecentsView();
+                    mTaskContainer.getTaskView().getRecentsView();
             // Task has already been dismissed
             if (recentsView == null) return;
             recentsView.switchToScreenshot(
@@ -185,8 +183,8 @@
          */
         @SuppressLint("NewApi")
         protected void saveScreenshot(Task task) {
-            if (mTaskContainer.getThumbnailViewDeprecated().isRealSnapshot()) {
-                mImageApi.saveScreenshot(mTaskContainer.getThumbnailViewDeprecated().getThumbnail(),
+            if (mTaskContainer.isRealSnapshot()) {
+                mImageApi.saveScreenshot(mTaskContainer.getThumbnail(),
                         getTaskSnapshotBounds(), getTaskSnapshotInsets(), task.key);
             } else {
                 showBlockedByPolicyMessage();
@@ -194,17 +192,14 @@
         }
 
         protected void enterSplitSelect() {
-            RecentsView overviewPanel =
-                    mTaskContainer.getThumbnailViewDeprecated().getTaskView().getRecentsView();
+            RecentsView overviewPanel = mTaskContainer.getTaskView().getRecentsView();
             // Task has already been dismissed
             if (overviewPanel == null) return;
-            overviewPanel.initiateSplitSelect(
-                    mTaskContainer.getThumbnailViewDeprecated().getTaskView());
+            overviewPanel.initiateSplitSelect(mTaskContainer.getTaskView());
         }
 
         protected void saveAppPair() {
-            GroupedTaskView taskView =
-                    (GroupedTaskView) mTaskContainer.getThumbnailViewDeprecated().getTaskView();
+            GroupedTaskView taskView = (GroupedTaskView) mTaskContainer.getTaskView();
             taskView.getRecentsView().getSplitSelectController().getAppPairsController()
                     .saveAppPair(taskView);
         }
@@ -250,11 +245,11 @@
          */
         public Rect getTaskSnapshotBounds() {
             int[] location = new int[2];
-            mTaskContainer.getThumbnailViewDeprecated().getLocationOnScreen(location);
+            mTaskContainer.getSnapshotView().getLocationOnScreen(location);
 
             return new Rect(location[0], location[1],
-                    mTaskContainer.getThumbnailViewDeprecated().getWidth() + location[0],
-                    mTaskContainer.getThumbnailViewDeprecated().getHeight() + location[1]);
+                    mTaskContainer.getSnapshotView().getWidth() + location[0],
+                    mTaskContainer.getSnapshotView().getHeight() + location[1]);
         }
 
         /**
@@ -264,7 +259,7 @@
          */
         @RequiresApi(api = Build.VERSION_CODES.Q)
         public Insets getTaskSnapshotInsets() {
-            return mTaskContainer.getThumbnailViewDeprecated().getScaledInsets();
+            return mTaskContainer.getScaledInsets();
         }
 
         /**
@@ -275,14 +270,14 @@
 
         protected void showBlockedByPolicyMessage() {
             ActivityContext activityContext = ActivityContext.lookupContext(
-                    mTaskContainer.getThumbnailViewDeprecated().getContext());
+                    mTaskContainer.getTaskView().getContext());
             String message = activityContext.getStringCache() != null
                     ? activityContext.getStringCache().disabledByAdminMessage
-                    : mTaskContainer.getThumbnailViewDeprecated().getContext().getString(
+                    : mTaskContainer.getTaskView().getContext().getString(
                             R.string.blocked_by_policy);
 
             Snackbar.show(BaseActivity.fromContext(
-                    mTaskContainer.getThumbnailViewDeprecated().getContext()), message, null);
+                    mTaskContainer.getTaskView().getContext()), message, null);
         }
 
         /** Called when the snapshot has updated its full screen drawing parameters. */
@@ -304,8 +299,7 @@
 
             @Override
             public void onClick(View view) {
-                saveScreenshot(
-                        mTaskContainer.getThumbnailViewDeprecated().getTaskView().getFirstTask());
+                saveScreenshot(mTaskContainer.getTaskView().getFirstTask());
                 dismissTaskMenuView();
             }
         }
diff --git a/quickstep/src/com/android/quickstep/TaskShortcutFactory.java b/quickstep/src/com/android/quickstep/TaskShortcutFactory.java
index fd141c3..b4862fd 100644
--- a/quickstep/src/com/android/quickstep/TaskShortcutFactory.java
+++ b/quickstep/src/com/android/quickstep/TaskShortcutFactory.java
@@ -20,6 +20,7 @@
 import static android.content.Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS;
 import static android.view.Surface.ROTATION_0;
 
+import static com.android.launcher3.Flags.enableRefactorTaskThumbnail;
 import static com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_SYSTEM_SHORTCUT_FREE_FORM_TAP;
 import static com.android.launcher3.util.SplitConfigurationOptions.STAGE_POSITION_BOTTOM_OR_RIGHT;
 import static com.android.window.flags.Flags.enableDesktopWindowingMode;
@@ -55,7 +56,6 @@
 import com.android.quickstep.views.GroupedTaskView;
 import com.android.quickstep.views.RecentsView;
 import com.android.quickstep.views.RecentsViewContainer;
-import com.android.quickstep.views.TaskThumbnailViewDeprecated;
 import com.android.quickstep.views.TaskView;
 import com.android.quickstep.views.TaskView.TaskContainer;
 import com.android.systemui.shared.recents.model.Task;
@@ -174,7 +174,7 @@
         private Handler mHandler;
 
         private final RecentsView mRecentsView;
-        private final TaskThumbnailViewDeprecated mThumbnailView;
+        private final TaskContainer mTaskContainer;
         private final TaskView mTaskView;
         private final LauncherEvent mLauncherEvent;
 
@@ -186,7 +186,7 @@
             mHandler = new Handler(Looper.getMainLooper());
             mTaskView = taskContainer.getTaskView();
             mRecentsView = container.getOverviewPanel();
-            mThumbnailView = taskContainer.getThumbnailViewDeprecated();
+            mTaskContainer = taskContainer;
         }
 
         @Override
@@ -220,19 +220,25 @@
                 };
 
                 final int[] position = new int[2];
-                mThumbnailView.getLocationOnScreen(position);
-                final int width = (int) (mThumbnailView.getWidth() * mTaskView.getScaleX());
-                final int height = (int) (mThumbnailView.getHeight() * mTaskView.getScaleY());
+                View snapShotView = mTaskContainer.getSnapshotView();
+                snapShotView.getLocationOnScreen(position);
+                final int width = (int) (snapShotView.getWidth() * mTaskView.getScaleX());
+                final int height = (int) (snapShotView.getHeight() * mTaskView.getScaleY());
                 final Rect taskBounds = new Rect(position[0], position[1],
                         position[0] + width, position[1] + height);
 
                 // Take the thumbnail of the task without a scrim and apply it back after
-                float alpha = mThumbnailView.getDimAlpha();
-                mThumbnailView.setDimAlpha(0);
+                // TODO(b/348643341) add ability to get override the scrim for this Bitmap retrieval
+                float alpha = 0f;
+                if (!enableRefactorTaskThumbnail()) {
+                    alpha = mTaskContainer.getThumbnailViewDeprecated().getDimAlpha();
+                    mTaskContainer.getThumbnailViewDeprecated().setDimAlpha(0);
+                }
                 Bitmap thumbnail = RecentsTransition.drawViewIntoHardwareBitmap(
-                        taskBounds.width(), taskBounds.height(), mThumbnailView, 1f,
-                        Color.BLACK);
-                mThumbnailView.setDimAlpha(alpha);
+                        taskBounds.width(), taskBounds.height(), snapShotView, 1f, Color.BLACK);
+                if (!enableRefactorTaskThumbnail()) {
+                    mTaskContainer.getThumbnailViewDeprecated().setDimAlpha(alpha);
+                }
 
                 AppTransitionAnimationSpecsFuture future =
                         new AppTransitionAnimationSpecsFuture(mHandler) {
@@ -492,18 +498,8 @@
                 TaskContainer taskContainer) {
             boolean isTablet = container.getDeviceProfile().isTablet;
             boolean isGridOnlyOverview = isTablet && Flags.enableGridOnlyOverview();
-            // Extra conditions if it's not grid-only overview
             if (!isGridOnlyOverview) {
-                RecentsOrientedState orientedState = taskContainer.getTaskView().getOrientedState();
-                boolean isFakeLandscape = !orientedState.isRecentsActivityRotationAllowed()
-                        && orientedState.getTouchRotation() != ROTATION_0;
-                if (!isFakeLandscape) {
-                    return null;
-                }
-                // Disallow "Select" when swiping up from landscape due to rotated thumbnail.
-                if (orientedState.getDisplayRotation() != ROTATION_0) {
-                    return null;
-                }
+                return null;
             }
 
             SystemShortcut modalStateSystemShortcut =
diff --git a/quickstep/src/com/android/quickstep/TaskViewUtils.java b/quickstep/src/com/android/quickstep/TaskViewUtils.java
index ecd84f8..bd44283 100644
--- a/quickstep/src/com/android/quickstep/TaskViewUtils.java
+++ b/quickstep/src/com/android/quickstep/TaskViewUtils.java
@@ -80,7 +80,6 @@
 import com.android.quickstep.views.DesktopTaskView;
 import com.android.quickstep.views.GroupedTaskView;
 import com.android.quickstep.views.RecentsView;
-import com.android.quickstep.views.TaskThumbnailViewDeprecated;
 import com.android.quickstep.views.TaskView;
 import com.android.systemui.animation.RemoteAnimationTargetCompat;
 import com.android.systemui.shared.recents.model.Task;
@@ -334,7 +333,7 @@
             // During animation we apply transformation on the thumbnailView (and not the rootView)
             // to follow the TaskViewSimulator. So the final matrix applied on the thumbnailView is:
             //    Mt K(0)` K(t) Mt`
-            TaskThumbnailViewDeprecated[] thumbnails = v.getThumbnailViews();
+            View[] thumbnails = v.getSnapshotViews();
 
             // In case simulator copies and thumbnail size do no match, ensure we get the lesser.
             // This ensures we do not create arrays with empty elements or attempt to references
@@ -344,7 +343,7 @@
             Matrix[] mt = new Matrix[matrixSize];
             Matrix[] mti = new Matrix[matrixSize];
             for (int i = 0; i < matrixSize; i++) {
-                TaskThumbnailViewDeprecated ttv = thumbnails[i];
+                View ttv = thumbnails[i];
                 RectF localBounds = new RectF(0, 0,  ttv.getWidth(), ttv.getHeight());
                 float[] tvBoundsMapped = new float[]{0, 0,  ttv.getWidth(), ttv.getHeight()};
                 getDescendantCoordRelativeToAncestor(ttv, ttv.getRootView(), tvBoundsMapped, false);
@@ -391,7 +390,7 @@
             out.addListener(new AnimatorListenerAdapter() {
                 @Override
                 public void onAnimationEnd(Animator animation) {
-                    for (TaskThumbnailViewDeprecated ttv : thumbnails) {
+                    for (View ttv : thumbnails) {
                         ttv.setAnimationMatrix(null);
                     }
                 }
diff --git a/quickstep/src/com/android/quickstep/orientation/PortraitPagedViewHandler.java b/quickstep/src/com/android/quickstep/orientation/PortraitPagedViewHandler.java
index 758a737..eeacee1 100644
--- a/quickstep/src/com/android/quickstep/orientation/PortraitPagedViewHandler.java
+++ b/quickstep/src/com/android/quickstep/orientation/PortraitPagedViewHandler.java
@@ -259,7 +259,8 @@
             return new Pair<>(translationX, translationY);
         }
 
-        bannerParams.gravity = BOTTOM | ((deviceProfile.isLandscape) ? START : CENTER_HORIZONTAL);
+        bannerParams.gravity =
+                BOTTOM | (deviceProfile.isLeftRightSplit ? START : CENTER_HORIZONTAL);
 
         // Set correct width
         if (desiredTaskId == splitBounds.leftTopTaskId) {
diff --git a/quickstep/src/com/android/quickstep/task/thumbnail/TaskThumbnailView.kt b/quickstep/src/com/android/quickstep/task/thumbnail/TaskThumbnailView.kt
index 2836c89..dbe2b19 100644
--- a/quickstep/src/com/android/quickstep/task/thumbnail/TaskThumbnailView.kt
+++ b/quickstep/src/com/android/quickstep/task/thumbnail/TaskThumbnailView.kt
@@ -53,25 +53,31 @@
         TaskThumbnailViewModel(
             recentsView.mRecentsViewData,
             (parent as TaskView).taskViewData,
+            (parent as TaskView).getTaskContainerForTaskThumbnailView(this)!!.taskContainerData,
             recentsView.mTasksRepository,
         )
     }
 
     private var uiState: TaskThumbnailUiState = Uninitialized
     private var inheritedScale: Float = 1f
+    private var dimProgress: Float = 0f
 
     private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG)
+    private val scrimPaint = Paint().apply { color = Color.BLACK }
     private val _measuredBounds = Rect()
     private val measuredBounds: Rect
         get() {
             _measuredBounds.set(0, 0, measuredWidth, measuredHeight)
             return _measuredBounds
         }
+
     private var cornerRadius: Float = TaskCornerRadius.get(context)
     private var fullscreenCornerRadius: Float = QuickStepContract.getWindowCornerRadius(context)
 
     constructor(context: Context?) : super(context)
+
     constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
+
     constructor(
         context: Context?,
         attrs: AttributeSet?,
@@ -87,6 +93,13 @@
                 invalidate()
             }
         }
+        MainScope().launch {
+            viewModel.dimProgress.collect { dimProgress ->
+                // TODO(b/348195366) Add fade in/out for scrim
+                this@TaskThumbnailView.dimProgress = dimProgress
+                invalidate()
+            }
+        }
         MainScope().launch { viewModel.recentsFullscreenProgress.collect { invalidateOutline() } }
         MainScope().launch {
             viewModel.inheritedScale.collect { viewModelInheritedScale ->
@@ -111,6 +124,10 @@
             is Snapshot -> drawSnapshotState(canvas, uiStateVal)
             is BackgroundOnly -> drawBackgroundOnly(canvas, uiStateVal.backgroundColor)
         }
+
+        if (dimProgress > 0) {
+            drawScrim(canvas)
+        }
     }
 
     private fun drawBackgroundOnly(canvas: Canvas, @ColorInt backgroundColor: Int) {
@@ -135,6 +152,11 @@
         canvas.drawBitmap(snapshot.bitmap, snapshot.drawnRect, measuredBounds, null)
     }
 
+    private fun drawScrim(canvas: Canvas) {
+        scrimPaint.alpha = (dimProgress * MAX_SCRIM_ALPHA).toInt()
+        canvas.drawRect(measuredBounds, scrimPaint)
+    }
+
     private fun getCurrentCornerRadius() =
         Utilities.mapRange(
             viewModel.recentsFullscreenProgress.value,
@@ -145,5 +167,6 @@
     companion object {
         private val CLEAR_PAINT =
             Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) }
+        private const val MAX_SCRIM_ALPHA = (0.4f * 255).toInt()
     }
 }
diff --git a/quickstep/src/com/android/quickstep/task/thumbnail/TaskThumbnailViewModel.kt b/quickstep/src/com/android/quickstep/task/thumbnail/TaskThumbnailViewModel.kt
index 4511ea7..fe21174 100644
--- a/quickstep/src/com/android/quickstep/task/thumbnail/TaskThumbnailViewModel.kt
+++ b/quickstep/src/com/android/quickstep/task/thumbnail/TaskThumbnailViewModel.kt
@@ -25,6 +25,7 @@
 import com.android.quickstep.task.thumbnail.TaskThumbnailUiState.LiveTile
 import com.android.quickstep.task.thumbnail.TaskThumbnailUiState.Snapshot
 import com.android.quickstep.task.thumbnail.TaskThumbnailUiState.Uninitialized
+import com.android.quickstep.task.viewmodel.TaskContainerData
 import com.android.quickstep.task.viewmodel.TaskViewData
 import com.android.systemui.shared.recents.model.Task
 import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -40,6 +41,7 @@
 class TaskThumbnailViewModel(
     recentsViewData: RecentsViewData,
     taskViewData: TaskViewData,
+    taskContainerData: TaskContainerData,
     private val tasksRepository: RecentTasksRepository,
 ) {
     private val task = MutableStateFlow<Flow<Task?>>(flowOf(null))
@@ -50,6 +52,7 @@
         combine(recentsViewData.scale, taskViewData.scale) { recentsScale, taskScale ->
             recentsScale * taskScale
         }
+    val dimProgress: Flow<Float> = taskContainerData.taskMenuOpenProgress
     val uiState: Flow<TaskThumbnailUiState> =
         task
             .flatMapLatest { taskFlow ->
diff --git a/quickstep/src/com/android/quickstep/task/viewmodel/TaskContainerData.kt b/quickstep/src/com/android/quickstep/task/viewmodel/TaskContainerData.kt
new file mode 100644
index 0000000..769424c
--- /dev/null
+++ b/quickstep/src/com/android/quickstep/task/viewmodel/TaskContainerData.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.quickstep.task.viewmodel
+
+import kotlinx.coroutines.flow.MutableStateFlow
+
+class TaskContainerData {
+    val taskMenuOpenProgress = MutableStateFlow(0f)
+}
diff --git a/quickstep/src/com/android/quickstep/util/ActiveGestureErrorDetector.java b/quickstep/src/com/android/quickstep/util/ActiveGestureErrorDetector.java
index cfa6b98..3140fff 100644
--- a/quickstep/src/com/android/quickstep/util/ActiveGestureErrorDetector.java
+++ b/quickstep/src/com/android/quickstep/util/ActiveGestureErrorDetector.java
@@ -40,6 +40,7 @@
         SCROLLER_ANIMATION_ABORTED, TASK_APPEARED, EXPECTING_TASK_APPEARED,
         FLAG_USING_OTHER_ACTIVITY_INPUT_CONSUMER, LAUNCHER_DESTROYED, RECENT_TASKS_MISSING,
         INVALID_VELOCITY_ON_SWIPE_UP, RECENTS_ANIMATION_START_PENDING,
+        QUICK_SWITCH_FROM_HOME_FALLBACK, QUICK_SWITCH_FROM_HOME_FAILED,
 
         /**
          * These GestureEvents are specifically associated to state flags that get set in
@@ -282,6 +283,22 @@
                                     + " animation is still pending.",
                             writer);
                     break;
+                case QUICK_SWITCH_FROM_HOME_FALLBACK:
+                    errorDetected |= printErrorIfTrue(
+                            true,
+                            prefix,
+                            /* errorMessage= */ "Quick switch from home fallback case: the "
+                                    + "TaskView at the current page index was missing.",
+                            writer);
+                    break;
+                case QUICK_SWITCH_FROM_HOME_FAILED:
+                    errorDetected |= printErrorIfTrue(
+                            true,
+                            prefix,
+                            /* errorMessage= */ "Quick switch from home failed: the TaskViews at "
+                                    + "the current page index and index 0 were missing.",
+                            writer);
+                    break;
                 case EXPECTING_TASK_APPEARED:
                 case MOTION_DOWN:
                 case SET_END_TARGET:
diff --git a/quickstep/src/com/android/quickstep/util/AsyncClockEventDelegate.java b/quickstep/src/com/android/quickstep/util/AsyncClockEventDelegate.java
index cda87c0..c26fc0c5 100644
--- a/quickstep/src/com/android/quickstep/util/AsyncClockEventDelegate.java
+++ b/quickstep/src/com/android/quickstep/util/AsyncClockEventDelegate.java
@@ -18,8 +18,6 @@
 import static android.content.Intent.ACTION_TIMEZONE_CHANGED;
 import static android.content.Intent.ACTION_TIME_CHANGED;
 
-import static com.android.launcher3.util.Executors.UI_HELPER_EXECUTOR;
-
 import android.content.BroadcastReceiver;
 import android.content.Context;
 import android.content.Intent;
@@ -64,9 +62,7 @@
     private AsyncClockEventDelegate(Context context) {
         super(context);
         mContext = context;
-
-        UI_HELPER_EXECUTOR.execute(() ->
-                mReceiver.register(mContext, ACTION_TIME_CHANGED, ACTION_TIMEZONE_CHANGED));
+        mReceiver.registerAsync(mContext, ACTION_TIME_CHANGED, ACTION_TIMEZONE_CHANGED);
     }
 
     @Override
@@ -127,6 +123,6 @@
     public void close() {
         mDestroyed = true;
         SettingsCache.INSTANCE.get(mContext).unregister(mFormatUri, this);
-        UI_HELPER_EXECUTOR.execute(() -> mReceiver.unregisterReceiverSafely(mContext));
+        mReceiver.unregisterReceiverSafelyAsync(mContext);
     }
 }
diff --git a/quickstep/src/com/android/quickstep/util/SplitAnimationController.kt b/quickstep/src/com/android/quickstep/util/SplitAnimationController.kt
index 7ea04b1..876a4ea 100644
--- a/quickstep/src/com/android/quickstep/util/SplitAnimationController.kt
+++ b/quickstep/src/com/android/quickstep/util/SplitAnimationController.kt
@@ -43,6 +43,7 @@
 import com.android.app.animation.Interpolators
 import com.android.launcher3.DeviceProfile
 import com.android.launcher3.Flags.enableOverviewIconMenu
+import com.android.launcher3.Flags.enableRefactorTaskThumbnail
 import com.android.launcher3.InsettableFrameLayout
 import com.android.launcher3.QuickstepTransitionManager
 import com.android.launcher3.R
@@ -118,8 +119,8 @@
                 if (container.task.getKey().getId() == splitSelectStateController.initialTaskId) {
                     val drawable = getDrawable(container.iconView, splitSelectSource)
                     return SplitAnimInitProps(
-                        container.thumbnailViewDeprecated,
-                        container.thumbnailViewDeprecated.thumbnail,
+                        container.snapshotView,
+                        container.thumbnail,
                         drawable!!,
                         fadeWithThumbnail = true,
                         isStagedTask = true,
@@ -137,8 +138,8 @@
             taskView.taskContainers.first().let {
                 val drawable = getDrawable(it.iconView, splitSelectSource)
                 return SplitAnimInitProps(
-                    it.thumbnailViewDeprecated,
-                    it.thumbnailViewDeprecated.thumbnail,
+                    it.snapshotView,
+                    it.thumbnail,
                     drawable!!,
                     fadeWithThumbnail = true,
                     isStagedTask = true,
@@ -165,27 +166,37 @@
     /**
      * When selecting first app from split pair, second app's thumbnail remains. This animates the
      * second thumbnail by expanding it to take up the full taskViewWidth/Height and overlaying it
-     * with [TaskThumbnailViewDeprecated]'s splashView. Adds animations to the provided builder.
-     * Note: The app that **was not** selected as the first split app should be the container that's
-     * passed through.
+     * with [TaskContainer]'s splashView. Adds animations to the provided builder. Note: The app
+     * that **was not** selected as the first split app should be the container that's passed
+     * through.
      *
      * @param builder Adds animation to this
-     * @param taskIdAttributeContainer container of the app that **was not** selected
+     * @param taskContainer container of the app that **was not** selected
      * @param isPrimaryTaskSplitting if true, task that was split would be top/left in the pair
-     *   (opposite of that representing [taskIdAttributeContainer])
+     *   (opposite of that representing [taskContainer])
      */
     fun addInitialSplitFromPair(
-        taskIdAttributeContainer: TaskContainer,
+        taskContainer: TaskContainer,
         builder: PendingAnimation,
         deviceProfile: DeviceProfile,
         taskViewWidth: Int,
         taskViewHeight: Int,
         isPrimaryTaskSplitting: Boolean
     ) {
-        val thumbnail = taskIdAttributeContainer.thumbnailViewDeprecated
-        val iconView: View = taskIdAttributeContainer.iconView.asView()
-        builder.add(ObjectAnimator.ofFloat(thumbnail, TaskThumbnailViewDeprecated.SPLASH_ALPHA, 1f))
-        thumbnail.setShowSplashForSplitSelection(true)
+        val snapshot = taskContainer.snapshotView
+        val iconView: View = taskContainer.iconView.asView()
+        // TODO(334826842): Switch to splash state in TaskThumbnailView
+        if (!enableRefactorTaskThumbnail()) {
+            val thumbnailViewDeprecated = taskContainer.thumbnailViewDeprecated
+            builder.add(
+                ObjectAnimator.ofFloat(
+                    thumbnailViewDeprecated,
+                    TaskThumbnailViewDeprecated.SPLASH_ALPHA,
+                    1f
+                )
+            )
+            thumbnailViewDeprecated.setShowSplashForSplitSelection(true)
+        }
         // With the new `IconAppChipView`, we always want to keep the chip pinned to the
         // top left of the task / thumbnail.
         if (enableOverviewIconMenu()) {
@@ -202,14 +213,10 @@
         }
         if (deviceProfile.isLeftRightSplit) {
             // Center view first so scaling happens uniformly, alternatively we can move pivotX to 0
-            val centerThumbnailTranslationX: Float = (taskViewWidth - thumbnail.width) / 2f
-            val finalScaleX: Float = taskViewWidth.toFloat() / thumbnail.width
+            val centerThumbnailTranslationX: Float = (taskViewWidth - snapshot.width) / 2f
+            val finalScaleX: Float = taskViewWidth.toFloat() / snapshot.width
             builder.add(
-                ObjectAnimator.ofFloat(
-                    thumbnail,
-                    TaskThumbnailViewDeprecated.SPLIT_SELECT_TRANSLATE_X,
-                    centerThumbnailTranslationX
-                )
+                ObjectAnimator.ofFloat(snapshot, View.TRANSLATION_X, centerThumbnailTranslationX)
             )
             if (!enableOverviewIconMenu()) {
                 // icons are anchored from Gravity.END, so need to use negative translation
@@ -218,21 +225,15 @@
                     ObjectAnimator.ofFloat(iconView, View.TRANSLATION_X, -centerIconTranslationX)
                 )
             }
-            builder.add(ObjectAnimator.ofFloat(thumbnail, View.SCALE_X, finalScaleX))
+            builder.add(ObjectAnimator.ofFloat(snapshot, View.SCALE_X, finalScaleX))
 
             // Reset other dimensions
             // TODO(b/271468547), can't set Y translate to 0, need to account for top space
-            thumbnail.scaleY = 1f
+            snapshot.scaleY = 1f
             val translateYResetVal: Float =
                 if (!isPrimaryTaskSplitting) 0f
                 else deviceProfile.overviewTaskThumbnailTopMarginPx.toFloat()
-            builder.add(
-                ObjectAnimator.ofFloat(
-                    thumbnail,
-                    TaskThumbnailViewDeprecated.SPLIT_SELECT_TRANSLATE_Y,
-                    translateYResetVal
-                )
-            )
+            builder.add(ObjectAnimator.ofFloat(snapshot, View.TRANSLATION_Y, translateYResetVal))
         } else {
             val thumbnailSize = taskViewHeight - deviceProfile.overviewTaskThumbnailTopMarginPx
             // Center view first so scaling happens uniformly, alternatively we can move pivotY to 0
@@ -247,36 +248,26 @@
             //  thumbnail needs to take that into account. We should migrate to only using
             //  translations otherwise this asymmetry causes problems..
             if (isPrimaryTaskSplitting) {
-                centerThumbnailTranslationY = (thumbnailSize - thumbnail.height) / 2f
+                centerThumbnailTranslationY = (thumbnailSize - snapshot.height) / 2f
                 centerThumbnailTranslationY +=
                     deviceProfile.overviewTaskThumbnailTopMarginPx.toFloat()
             } else {
-                centerThumbnailTranslationY = (thumbnailSize - thumbnail.height) / 2f
+                centerThumbnailTranslationY = (thumbnailSize - snapshot.height) / 2f
             }
-            val finalScaleY: Float = thumbnailSize.toFloat() / thumbnail.height
+            val finalScaleY: Float = thumbnailSize.toFloat() / snapshot.height
             builder.add(
-                ObjectAnimator.ofFloat(
-                    thumbnail,
-                    TaskThumbnailViewDeprecated.SPLIT_SELECT_TRANSLATE_Y,
-                    centerThumbnailTranslationY
-                )
+                ObjectAnimator.ofFloat(snapshot, View.TRANSLATION_Y, centerThumbnailTranslationY)
             )
 
             if (!enableOverviewIconMenu()) {
                 // icons are anchored from Gravity.END, so need to use negative translation
                 builder.add(ObjectAnimator.ofFloat(iconView, View.TRANSLATION_X, 0f))
             }
-            builder.add(ObjectAnimator.ofFloat(thumbnail, View.SCALE_Y, finalScaleY))
+            builder.add(ObjectAnimator.ofFloat(snapshot, View.SCALE_Y, finalScaleY))
 
             // Reset other dimensions
-            thumbnail.scaleX = 1f
-            builder.add(
-                ObjectAnimator.ofFloat(
-                    thumbnail,
-                    TaskThumbnailViewDeprecated.SPLIT_SELECT_TRANSLATE_X,
-                    0f
-                )
-            )
+            snapshot.scaleX = 1f
+            builder.add(ObjectAnimator.ofFloat(snapshot, View.TRANSLATION_X, 0f))
         }
     }
 
diff --git a/quickstep/src/com/android/quickstep/util/SplitScreenUtils.kt b/quickstep/src/com/android/quickstep/util/SplitScreenUtils.kt
index 38bbe60..4820c35 100644
--- a/quickstep/src/com/android/quickstep/util/SplitScreenUtils.kt
+++ b/quickstep/src/com/android/quickstep/util/SplitScreenUtils.kt
@@ -31,29 +31,13 @@
                 null
             } else {
                 SplitConfigurationOptions.SplitBounds(
-                    shellSplitBounds.leftTopBounds, shellSplitBounds.rightBottomBounds,
-                    shellSplitBounds.leftTopTaskId, shellSplitBounds.rightBottomTaskId,
+                    shellSplitBounds.leftTopBounds,
+                    shellSplitBounds.rightBottomBounds,
+                    shellSplitBounds.leftTopTaskId,
+                    shellSplitBounds.rightBottomTaskId,
                     shellSplitBounds.snapPosition
                 )
             }
         }
-
-        /** Converts the launcher version of SplitBounds to the shell version */
-        @JvmStatic
-        fun convertLauncherSplitBoundsToShell(
-            launcherSplitBounds: SplitConfigurationOptions.SplitBounds?
-        ): SplitBounds? {
-            return if (launcherSplitBounds == null) {
-                null
-            } else {
-                SplitBounds(
-                    launcherSplitBounds.leftTopBounds,
-                    launcherSplitBounds.rightBottomBounds,
-                    launcherSplitBounds.leftTopTaskId,
-                    launcherSplitBounds.rightBottomTaskId,
-                    launcherSplitBounds.snapPosition
-                )
-            }
-        }
     }
 }
diff --git a/quickstep/src/com/android/quickstep/util/SwipePipToHomeAnimator.java b/quickstep/src/com/android/quickstep/util/SwipePipToHomeAnimator.java
index e44f148..88c3a08 100644
--- a/quickstep/src/com/android/quickstep/util/SwipePipToHomeAnimator.java
+++ b/quickstep/src/com/android/quickstep/util/SwipePipToHomeAnimator.java
@@ -164,22 +164,7 @@
         }
 
         if (sourceRectHint.isEmpty()) {
-            // Crop a Rect matches the aspect ratio and pivots at the center point.
-            // To make the animation path simplified.
-            if ((appBounds.width() / (float) appBounds.height()) > aspectRatio) {
-                // use the full height.
-                mSourceRectHint.set(0, 0,
-                        (int) (appBounds.height() * aspectRatio), appBounds.height());
-                mSourceRectHint.offset(
-                        (appBounds.width() - mSourceRectHint.width()) / 2, 0);
-            } else {
-                // use the full width.
-                mSourceRectHint.set(0, 0,
-                        appBounds.width(), (int) (appBounds.width() / aspectRatio));
-                mSourceRectHint.offset(
-                        0, (appBounds.height() - mSourceRectHint.height()) / 2);
-            }
-
+            mSourceRectHint.set(getEnterPipWithOverlaySrcRectHint(appBounds, aspectRatio));
             // Create a new overlay layer. We do not call detach on this instance, it's propagated
             // to other classes like PipTaskOrganizer / RecentsAnimationController to complete
             // the cleanup.
@@ -225,6 +210,26 @@
         addOnUpdateListener(this::onAnimationUpdate);
     }
 
+    /**
+     * Crop a Rect matches the aspect ratio and pivots at the center point.
+     */
+    private Rect getEnterPipWithOverlaySrcRectHint(Rect appBounds, float aspectRatio) {
+        final float appBoundsAspectRatio = appBounds.width() / (float) appBounds.height();
+        final int width, height;
+        int left = appBounds.left;
+        int top = appBounds.top;
+        if (appBoundsAspectRatio < aspectRatio) {
+            width = appBounds.width();
+            height = (int) (width / aspectRatio);
+            top = appBounds.top + (appBounds.height() - height) / 2;
+        } else {
+            height = appBounds.height();
+            width = (int) (height * aspectRatio);
+            left = appBounds.left + (appBounds.width() - width) / 2;
+        }
+        return new Rect(left, top, left + width, top + height);
+    }
+
     private void onAnimationUpdate(RectF currentRect, float progress) {
         if (mHasAnimationEnded) return;
         final SurfaceControl.Transaction tx =
@@ -437,13 +442,21 @@
             return this;
         }
 
+        public Builder setDisplayCutoutInsets(@NonNull Rect displayCutoutInsets) {
+            mDisplayCutoutInsets = new Rect(displayCutoutInsets);
+            return this;
+        }
+
         public SwipePipToHomeAnimator build() {
             if (mDestinationBoundsTransformed.isEmpty()) {
                 mDestinationBoundsTransformed.set(mDestinationBounds);
             }
             // adjust the mSourceRectHint / mAppBounds by display cutout if applicable.
             if (mSourceRectHint != null && mDisplayCutoutInsets != null) {
-                if (mFromRotation == Surface.ROTATION_90) {
+                if (mFromRotation == Surface.ROTATION_0 && mDisplayCutoutInsets.top >= 0) {
+                    // TODO: this is to special case the issues on Pixel Foldable device(s).
+                    mSourceRectHint.offset(mDisplayCutoutInsets.left, mDisplayCutoutInsets.top);
+                } else if (mFromRotation == Surface.ROTATION_90) {
                     mSourceRectHint.offset(mDisplayCutoutInsets.left, mDisplayCutoutInsets.top);
                 } else if (mFromRotation == Surface.ROTATION_270) {
                     mAppBounds.inset(mDisplayCutoutInsets);
@@ -457,15 +470,6 @@
         }
     }
 
-    private static class RotatedPosition {
-        private final float degree;
-        private final float positionX;
-        private final float positionY;
-
-        private RotatedPosition(float degree, float positionX, float positionY) {
-            this.degree = degree;
-            this.positionX = positionX;
-            this.positionY = positionY;
-        }
+    private record RotatedPosition(float degree, float positionX, float positionY) {
     }
 }
diff --git a/quickstep/src/com/android/quickstep/util/TaskViewSimulator.java b/quickstep/src/com/android/quickstep/util/TaskViewSimulator.java
index 49f4e5f..d9b7d20 100644
--- a/quickstep/src/com/android/quickstep/util/TaskViewSimulator.java
+++ b/quickstep/src/com/android/quickstep/util/TaskViewSimulator.java
@@ -27,7 +27,6 @@
 import static com.android.quickstep.TaskAnimationManager.ENABLE_SHELL_TRANSITIONS;
 import static com.android.quickstep.util.RecentsOrientedState.postDisplayRotation;
 import static com.android.quickstep.util.RecentsOrientedState.preDisplayRotation;
-import static com.android.quickstep.util.SplitScreenUtils.convertLauncherSplitBoundsToShell;
 
 import android.animation.TimeInterpolator;
 import android.content.Context;
@@ -247,8 +246,6 @@
         } else {
             mStagePosition = runningTarget.taskId == splitInfo.leftTopTaskId
                     ? STAGE_POSITION_TOP_OR_LEFT : STAGE_POSITION_BOTTOM_OR_RIGHT;
-            mPositionHelper.setSplitBounds(convertLauncherSplitBoundsToShell(mSplitBounds),
-                    mStagePosition);
         }
         calculateTaskSize();
     }
diff --git a/quickstep/src/com/android/quickstep/views/DesktopTaskView.kt b/quickstep/src/com/android/quickstep/views/DesktopTaskView.kt
index 936f6a1..46ed2ee 100644
--- a/quickstep/src/com/android/quickstep/views/DesktopTaskView.kt
+++ b/quickstep/src/com/android/quickstep/views/DesktopTaskView.kt
@@ -128,7 +128,7 @@
                     }
             val thumbWidth = (taskSize.width() * scaleWidth).toInt()
             val thumbHeight = (taskSize.height() * scaleHeight).toInt()
-            it.thumbnailViewDeprecated.measure(
+            it.snapshotView.measure(
                 MeasureSpec.makeMeasureSpec(thumbWidth, MeasureSpec.EXACTLY),
                 MeasureSpec.makeMeasureSpec(thumbHeight, MeasureSpec.EXACTLY)
             )
@@ -139,8 +139,8 @@
             var taskY = (positionInParent.y * scaleHeight).toInt()
             // move task down by margin size
             taskY += thumbnailTopMarginPx
-            it.thumbnailViewDeprecated.x = taskX.toFloat()
-            it.thumbnailViewDeprecated.y = taskY.toFloat()
+            it.snapshotView.x = taskX.toFloat()
+            it.snapshotView.y = taskY.toFloat()
             if (DEBUG) {
                 Log.d(
                     TAG,
@@ -193,23 +193,23 @@
             }
             val taskContainer =
                 TaskContainer(
-                        task,
-                        // TODO(b/338360089): Support new TTV for DesktopTaskView
-                        thumbnailView = null,
-                        thumbnailViewDeprecated,
-                        iconView,
-                        TransformingTouchDelegate(iconView.asView()),
-                        SplitConfigurationOptions.STAGE_POSITION_UNDEFINED,
-                        digitalWellBeingToast = null,
-                        showWindowsView = null,
-                        taskOverlayFactory
-                    )
-                    .apply { thumbnailViewDeprecated.bind(task, overlay) }
+                    task,
+                    // TODO(b/338360089): Support new TTV for DesktopTaskView
+                    thumbnailView = null,
+                    thumbnailViewDeprecated,
+                    iconView,
+                    TransformingTouchDelegate(iconView.asView()),
+                    SplitConfigurationOptions.STAGE_POSITION_UNDEFINED,
+                    digitalWellBeingToast = null,
+                    showWindowsView = null,
+                    taskOverlayFactory
+                )
             if (index >= taskContainers.size) {
                 taskContainers.add(taskContainer)
             } else {
                 taskContainers[index] = taskContainer
             }
+            taskContainer.bind()
         }
         repeat(taskContainers.size - tasks.size) {
             with(taskContainers.removeLast()) {
diff --git a/quickstep/src/com/android/quickstep/views/DigitalWellBeingToast.java b/quickstep/src/com/android/quickstep/views/DigitalWellBeingToast.java
index 4a5b9e4..9f268a0 100644
--- a/quickstep/src/com/android/quickstep/views/DigitalWellBeingToast.java
+++ b/quickstep/src/com/android/quickstep/views/DigitalWellBeingToast.java
@@ -332,12 +332,12 @@
                 (FrameLayout.LayoutParams) mBanner.getLayoutParams();
         DeviceProfile deviceProfile = mContainer.getDeviceProfile();
         layoutParams.bottomMargin = ((ViewGroup.MarginLayoutParams)
-                mTaskView.getFirstThumbnailViewDeprecated().getLayoutParams()).bottomMargin;
+                mTaskView.getFirstSnapshotView().getLayoutParams()).bottomMargin;
         RecentsPagedOrientationHandler orientationHandler = mTaskView.getPagedOrientationHandler();
         Pair<Float, Float> translations = orientationHandler
                 .getDwbLayoutTranslations(mTaskView.getMeasuredWidth(),
                         mTaskView.getMeasuredHeight(), mSplitBounds, deviceProfile,
-                        mTaskView.getThumbnailViews(), mTask.key.id, mBanner);
+                        mTaskView.getSnapshotViews(), mTask.key.id, mBanner);
         mSplitOffsetTranslationX = translations.first;
         mSplitOffsetTranslationY = translations.second;
         updateTranslationY();
diff --git a/quickstep/src/com/android/quickstep/views/GroupedTaskView.kt b/quickstep/src/com/android/quickstep/views/GroupedTaskView.kt
index d6a3376..b070244 100644
--- a/quickstep/src/com/android/quickstep/views/GroupedTaskView.kt
+++ b/quickstep/src/com/android/quickstep/views/GroupedTaskView.kt
@@ -33,10 +33,8 @@
 import com.android.launcher3.util.SplitConfigurationOptions.STAGE_POSITION_UNDEFINED
 import com.android.quickstep.TaskOverlayFactory
 import com.android.quickstep.util.RecentsOrientedState
-import com.android.quickstep.util.SplitScreenUtils.Companion.convertLauncherSplitBoundsToShell
 import com.android.quickstep.util.SplitSelectStateController
 import com.android.systemui.shared.recents.model.Task
-import com.android.systemui.shared.recents.utilities.PreviewPositionHelper
 import com.android.systemui.shared.system.InteractionJankMonitorWrapper
 import com.android.wm.shell.common.split.SplitScreenConstants.PersistentSnapPosition
 
@@ -70,36 +68,20 @@
         val initSplitTaskId = getThisTaskCurrentlyInSplitSelection()
         if (initSplitTaskId == INVALID_TASK_ID) {
             pagedOrientationHandler.measureGroupedTaskViewThumbnailBounds(
-                taskContainers[0].thumbnailViewDeprecated,
-                taskContainers[1].thumbnailViewDeprecated,
+                taskContainers[0].snapshotView,
+                taskContainers[1].snapshotView,
                 widthSize,
                 heightSize,
                 splitBoundsConfig,
                 container.deviceProfile,
                 layoutDirection == LAYOUT_DIRECTION_RTL
             )
-            // Should we be having a separate translation step apart from the measuring above?
-            // The following only applies to large screen for now, but for future reference
-            // we'd want to abstract this out in PagedViewHandlers to get the primary/secondary
-            // translation directions
-            taskContainers[0]
-                .thumbnailViewDeprecated
-                .applySplitSelectTranslateX(taskContainers[0].thumbnailViewDeprecated.translationX)
-            taskContainers[0]
-                .thumbnailViewDeprecated
-                .applySplitSelectTranslateY(taskContainers[0].thumbnailViewDeprecated.translationY)
-            taskContainers[1]
-                .thumbnailViewDeprecated
-                .applySplitSelectTranslateX(taskContainers[1].thumbnailViewDeprecated.translationX)
-            taskContainers[1]
-                .thumbnailViewDeprecated
-                .applySplitSelectTranslateY(taskContainers[1].thumbnailViewDeprecated.translationY)
         } else {
             // Currently being split with this taskView, let the non-split selected thumbnail
             // take up full thumbnail area
             taskContainers
                 .firstOrNull { it.task.key.id != initSplitTaskId }
-                ?.thumbnailViewDeprecated
+                ?.snapshotView
                 ?.measure(
                     widthMeasureSpec,
                     MeasureSpec.makeMeasureSpec(
@@ -145,23 +127,9 @@
                     taskOverlayFactory
                 )
             )
-        this.splitBoundsConfig =
-            splitBoundsConfig?.also {
-                taskContainers[0]
-                    .thumbnailViewDeprecated
-                    .previewPositionHelper
-                    .setSplitBounds(
-                        convertLauncherSplitBoundsToShell(it),
-                        PreviewPositionHelper.STAGE_POSITION_TOP_OR_LEFT
-                    )
-                taskContainers[1]
-                    .thumbnailViewDeprecated
-                    .previewPositionHelper
-                    .setSplitBounds(
-                        convertLauncherSplitBoundsToShell(it),
-                        PreviewPositionHelper.STAGE_POSITION_BOTTOM_OR_RIGHT
-                    )
-            }
+        taskContainers.forEach { it.bind() }
+
+        this.splitBoundsConfig = splitBoundsConfig
         taskContainers.forEach { it.digitalWellBeingToast?.setSplitBounds(splitBoundsConfig) }
         setOrientationState(orientedState)
     }
@@ -228,8 +196,8 @@
                 taskContainers[0].iconView.asView(),
                 taskContainers[1].iconView.asView(),
                 taskIconHeight,
-                taskContainers[0].thumbnailViewDeprecated.measuredWidth,
-                taskContainers[0].thumbnailViewDeprecated.measuredHeight,
+                taskContainers[0].snapshotView.measuredWidth,
+                taskContainers[0].snapshotView.measuredHeight,
                 measuredHeight,
                 measuredWidth,
                 isRtl,
@@ -324,7 +292,7 @@
         // Check which of the two apps was selected
         if (
             taskContainers[1].iconView.asView().containsPoint(lastTouchDownPosition) ||
-                taskContainers[1].thumbnailViewDeprecated.containsPoint(lastTouchDownPosition)
+                taskContainers[1].snapshotView.containsPoint(lastTouchDownPosition)
         ) {
             return 1
         }
diff --git a/quickstep/src/com/android/quickstep/views/RecentsView.java b/quickstep/src/com/android/quickstep/views/RecentsView.java
index b332652..c1e112a 100644
--- a/quickstep/src/com/android/quickstep/views/RecentsView.java
+++ b/quickstep/src/com/android/quickstep/views/RecentsView.java
@@ -31,6 +31,7 @@
 import static com.android.app.animation.Interpolators.LINEAR;
 import static com.android.app.animation.Interpolators.OVERSHOOT_0_75;
 import static com.android.app.animation.Interpolators.clampToProgress;
+import static com.android.launcher3.AbstractFloatingView.TYPE_REBIND_SAFE;
 import static com.android.launcher3.AbstractFloatingView.TYPE_TASK_MENU;
 import static com.android.launcher3.AbstractFloatingView.getTopOpenViewWithType;
 import static com.android.launcher3.BaseActivity.STATE_HANDLER_INVISIBILITY_FLAGS;
@@ -131,6 +132,7 @@
 import androidx.core.graphics.ColorUtils;
 
 import com.android.internal.jank.Cuj;
+import com.android.launcher3.AbstractFloatingView;
 import com.android.launcher3.BaseActivity.MultiWindowModeChangedListener;
 import com.android.launcher3.DeviceProfile;
 import com.android.launcher3.Flags;
@@ -314,7 +316,6 @@
     /**
      * Can be used to tint the color of the RecentsView to simulate a scrim that can views
      * excluded from. Really should be a proper scrim.
-     * TODO(b/187528071): Remove this and replace with a real scrim.
      */
     private static final FloatProperty<RecentsView> COLOR_TINT =
             new FloatProperty<RecentsView>("colorTint") {
@@ -555,7 +556,6 @@
     @Nullable
     protected GestureState.GestureEndTarget mCurrentGestureEndTarget;
 
-    // TODO(b/187528071): Remove these and replace with a real scrim.
     private float mColorTint;
     private final int mTintingColor;
     @Nullable
@@ -1826,8 +1826,13 @@
                 ((GroupedTaskView) taskView).bind(leftTopTask, rightBottomTask, mOrientationState,
                         mTaskOverlayFactory, groupTask.mSplitBounds);
             } else if (taskView instanceof DesktopTaskView) {
-                ((DesktopTaskView) taskView).bind(((DesktopTask) groupTask).tasks,
-                        mOrientationState, mTaskOverlayFactory);
+                // Minimized tasks should not be shown in Overview
+                List<Task> nonMinimizedTasks =
+                        ((DesktopTask) groupTask).tasks.stream()
+                                .filter(task -> !task.isMinimized)
+                                .toList();
+                ((DesktopTaskView) taskView).bind(nonMinimizedTasks, mOrientationState,
+                        mTaskOverlayFactory);
                 mDesktopTaskView = (DesktopTaskView) taskView;
             } else {
                 Task task = groupTask.task1.key.id == stagedTaskIdToBeRemoved ? groupTask.task2
@@ -2689,6 +2694,7 @@
     }
 
     private void animateRotation(int newRotation) {
+        AbstractFloatingView.closeAllOpenViewsExcept(mContainer, false, TYPE_REBIND_SAFE);
         AnimatorSet pa = setRecentsChangedOrientation(true);
         pa.addListener(AnimatorListeners.forSuccessCallback(() -> {
             setLayoutRotation(newRotation, mOrientationState.getDisplayRotation());
@@ -6029,6 +6035,7 @@
      * tasks to be dimmed while other elements in the recents view are left alone.
      */
     public void showForegroundScrim(boolean show) {
+        // TODO(b/335606129) Add scrim response into new TTV - this is called from overlay
         if (!show && mColorTint == 0) {
             if (mTintingAnimator != null) {
                 mTintingAnimator.cancel();
@@ -6044,7 +6051,6 @@
     }
 
     /** Tint the RecentsView and TaskViews in to simulate a scrim. */
-    // TODO(b/187528071): Replace this tinting with a scrim on top of RecentsView
     private void setColorTint(float tintAmount) {
         mColorTint = tintAmount;
 
diff --git a/quickstep/src/com/android/quickstep/views/TaskMenuView.java b/quickstep/src/com/android/quickstep/views/TaskMenuView.java
index eda58c5..8d5ba77 100644
--- a/quickstep/src/com/android/quickstep/views/TaskMenuView.java
+++ b/quickstep/src/com/android/quickstep/views/TaskMenuView.java
@@ -18,6 +18,7 @@
 
 import static com.android.app.animation.Interpolators.EMPHASIZED;
 import static com.android.launcher3.Flags.enableOverviewIconMenu;
+import static com.android.launcher3.Flags.enableRefactorTaskThumbnail;
 import static com.android.launcher3.util.MultiPropertyFactory.MULTI_PROPERTY_VALUE;
 import static com.android.launcher3.util.SplitConfigurationOptions.STAGE_POSITION_BOTTOM_OR_RIGHT;
 import static com.android.quickstep.views.TaskThumbnailViewDeprecated.DIM_ALPHA;
@@ -237,12 +238,12 @@
         mContainer.getDragLayer().getDescendantRectRelativeToSelf(
                 enableOverviewIconMenu()
                         ? getIconView().findViewById(R.id.icon_view_menu_anchor)
-                        : taskContainer.getThumbnailViewDeprecated(),
+                        : taskContainer.getSnapshotView(),
                 sTempRect);
         Rect insets = mContainer.getDragLayer().getInsets();
         BaseDragLayer.LayoutParams params = (BaseDragLayer.LayoutParams) getLayoutParams();
         params.width = orientationHandler.getTaskMenuWidth(
-                taskContainer.getThumbnailViewDeprecated(), deviceProfile,
+                taskContainer.getSnapshotView(), deviceProfile,
                 taskContainer.getStagePosition());
         // Gravity set to Left instead of Start as sTempRect.left measures Left distance not Start
         params.gravity = Gravity.LEFT;
@@ -276,10 +277,10 @@
             // Margin that insets the menuView inside the taskView
             float taskInsetMargin = getResources().getDimension(R.dimen.task_card_margin);
             setTranslationX(orientationHandler.getTaskMenuX(thumbnailAlignedX,
-                    mTaskContainer.getThumbnailViewDeprecated(), deviceProfile, taskInsetMargin,
+                    mTaskContainer.getSnapshotView(), deviceProfile, taskInsetMargin,
                     getIconView()));
             setTranslationY(orientationHandler.getTaskMenuY(
-                    thumbnailAlignedY, mTaskContainer.getThumbnailViewDeprecated(),
+                    thumbnailAlignedY, mTaskContainer.getSnapshotView(),
                     mTaskContainer.getStagePosition(), this, taskInsetMargin,
                     getIconView()));
         }
@@ -315,7 +316,7 @@
                 .createRevealAnimator(this, closing, revealAnimationStartProgress);
         mRevealAnimator.setInterpolator(enableOverviewIconMenu() ? Interpolators.EMPHASIZED
                 : Interpolators.DECELERATE);
-
+        AnimatorSet.Builder openCloseAnimatorBuilder = mOpenCloseAnimator.play(mRevealAnimator);
         if (enableOverviewIconMenu()) {
             IconAppChipView iconAppChip = (IconAppChipView) mTaskContainer.getIconView().asView();
 
@@ -333,11 +334,13 @@
                     closing ? mMenuTranslationYBeforeOpen
                             : mMenuTranslationYBeforeOpen + additionalTranslationY);
             translationYAnim.setInterpolator(EMPHASIZED);
+            openCloseAnimatorBuilder.with(translationYAnim);
 
             ObjectAnimator menuTranslationYAnim = ObjectAnimator.ofFloat(
                     iconAppChip.getMenuTranslationY(),
                     MULTI_PROPERTY_VALUE, closing ? 0 : additionalTranslationY);
             menuTranslationYAnim.setInterpolator(EMPHASIZED);
+            openCloseAnimatorBuilder.with(menuTranslationYAnim);
 
             float additionalTranslationX = 0;
             if (mContainer.getDeviceProfile().isLandscape
@@ -353,20 +356,27 @@
                     closing ? mMenuTranslationXBeforeOpen
                             : mMenuTranslationXBeforeOpen - additionalTranslationX);
             translationXAnim.setInterpolator(EMPHASIZED);
+            openCloseAnimatorBuilder.with(translationXAnim);
 
             ObjectAnimator menuTranslationXAnim = ObjectAnimator.ofFloat(
                     iconAppChip.getMenuTranslationX(),
                     MULTI_PROPERTY_VALUE, closing ? 0 : -additionalTranslationX);
             menuTranslationXAnim.setInterpolator(EMPHASIZED);
-
-            mOpenCloseAnimator.playTogether(translationYAnim, translationXAnim,
-                    menuTranslationXAnim, menuTranslationYAnim);
+            openCloseAnimatorBuilder.with(menuTranslationXAnim);
         }
-        mOpenCloseAnimator.playTogether(mRevealAnimator,
-                ObjectAnimator.ofFloat(
-                        mTaskContainer.getThumbnailViewDeprecated(), DIM_ALPHA,
-                        closing ? 0 : TaskView.MAX_PAGE_SCRIM_ALPHA),
-                ObjectAnimator.ofFloat(this, ALPHA, closing ? 0 : 1));
+        openCloseAnimatorBuilder.with(ObjectAnimator.ofFloat(this, ALPHA, closing ? 0 : 1));
+        if (enableRefactorTaskThumbnail()) {
+            mRevealAnimator.addUpdateListener(animation -> {
+                float animatedFraction = animation.getAnimatedFraction();
+                float openProgress = closing ? (1 - animatedFraction) : animatedFraction;
+                mTaskContainer.getTaskContainerData()
+                        .getTaskMenuOpenProgress().setValue(openProgress);
+            });
+        } else {
+            openCloseAnimatorBuilder.with(ObjectAnimator.ofFloat(
+                    mTaskContainer.getThumbnailViewDeprecated(), DIM_ALPHA,
+                    closing ? 0 : TaskView.MAX_PAGE_SCRIM_ALPHA));
+        }
         mOpenCloseAnimator.addListener(new AnimationSuccessListener() {
             @Override
             public void onAnimationStart(Animator animation) {
diff --git a/quickstep/src/com/android/quickstep/views/TaskThumbnailViewDeprecated.java b/quickstep/src/com/android/quickstep/views/TaskThumbnailViewDeprecated.java
index 4283d0e..2afb6a6 100644
--- a/quickstep/src/com/android/quickstep/views/TaskThumbnailViewDeprecated.java
+++ b/quickstep/src/com/android/quickstep/views/TaskThumbnailViewDeprecated.java
@@ -99,36 +99,6 @@
                 }
             };
 
-    /** Use to animate thumbnail translationX while first app in split selection is initiated */
-    public static final Property<TaskThumbnailViewDeprecated, Float> SPLIT_SELECT_TRANSLATE_X =
-            new FloatProperty<TaskThumbnailViewDeprecated>("splitSelectTranslateX") {
-                @Override
-                public void setValue(TaskThumbnailViewDeprecated thumbnail,
-                        float splitSelectTranslateX) {
-                    thumbnail.applySplitSelectTranslateX(splitSelectTranslateX);
-                }
-
-                @Override
-                public Float get(TaskThumbnailViewDeprecated thumbnailView) {
-                    return thumbnailView.mSplitSelectTranslateX;
-                }
-            };
-
-    /** Use to animate thumbnail translationY while first app in split selection is initiated */
-    public static final Property<TaskThumbnailViewDeprecated, Float> SPLIT_SELECT_TRANSLATE_Y =
-            new FloatProperty<TaskThumbnailViewDeprecated>("splitSelectTranslateY") {
-                @Override
-                public void setValue(TaskThumbnailViewDeprecated thumbnail,
-                        float splitSelectTranslateY) {
-                    thumbnail.applySplitSelectTranslateY(splitSelectTranslateY);
-                }
-
-                @Override
-                public Float get(TaskThumbnailViewDeprecated thumbnailView) {
-                    return thumbnailView.mSplitSelectTranslateY;
-                }
-            };
-
     private final RecentsViewContainer mContainer;
     private TaskOverlay<?> mOverlay;
     private final Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
@@ -160,8 +130,6 @@
     private boolean mOverlayEnabled;
     /** Used as a placeholder when the original thumbnail animates out to. */
     private boolean mShowSplashForSplitSelection;
-    private float mSplitSelectTranslateX;
-    private float mSplitSelectTranslateY;
 
     public TaskThumbnailViewDeprecated(Context context) {
         this(context, null);
@@ -415,31 +383,6 @@
         }
     }
 
-    /** See {@link #SPLIT_SELECT_TRANSLATE_X} */
-    protected void applySplitSelectTranslateX(float splitSelectTranslateX) {
-        mSplitSelectTranslateX = splitSelectTranslateX;
-        applyTranslateX();
-    }
-
-    /** See {@link #SPLIT_SELECT_TRANSLATE_Y} */
-    protected void applySplitSelectTranslateY(float splitSelectTranslateY) {
-        mSplitSelectTranslateY = splitSelectTranslateY;
-        applyTranslateY();
-    }
-
-    private void applyTranslateX() {
-        setTranslationX(mSplitSelectTranslateX);
-    }
-
-    private void applyTranslateY() {
-        setTranslationY(mSplitSelectTranslateY);
-    }
-
-    protected void resetViewTransforms() {
-        mSplitSelectTranslateX = 0;
-        mSplitSelectTranslateY = 0;
-    }
-
     public TaskView getTaskView() {
         return (TaskView) getParent();
     }
diff --git a/quickstep/src/com/android/quickstep/views/TaskView.kt b/quickstep/src/com/android/quickstep/views/TaskView.kt
index 9c1aaa6..d4b0040 100644
--- a/quickstep/src/com/android/quickstep/views/TaskView.kt
+++ b/quickstep/src/com/android/quickstep/views/TaskView.kt
@@ -23,7 +23,9 @@
 import android.app.ActivityOptions
 import android.content.Context
 import android.content.Intent
+import android.graphics.Bitmap
 import android.graphics.Canvas
+import android.graphics.Insets
 import android.graphics.PointF
 import android.graphics.Rect
 import android.graphics.drawable.Drawable
@@ -88,6 +90,7 @@
 import com.android.quickstep.orientation.RecentsPagedOrientationHandler
 import com.android.quickstep.task.thumbnail.TaskThumbnail
 import com.android.quickstep.task.thumbnail.TaskThumbnailView
+import com.android.quickstep.task.viewmodel.TaskContainerData
 import com.android.quickstep.task.viewmodel.TaskViewData
 import com.android.quickstep.util.ActiveGestureErrorDetector
 import com.android.quickstep.util.ActiveGestureLog
@@ -137,8 +140,8 @@
         /** Returns a copy of integer array containing taskIds of all tasks in the TaskView. */
         get() = taskContainers.map { it.task.key.id }.toIntArray()
 
-    val thumbnailViews: Array<TaskThumbnailViewDeprecated>
-        get() = taskContainers.map { it.thumbnailViewDeprecated }.toTypedArray()
+    val snapshotViews: Array<View>
+        get() = taskContainers.map { it.snapshotView }.toTypedArray()
 
     val isGridTask: Boolean
         /** Returns whether the task is part of overview grid and not being focused. */
@@ -170,6 +173,11 @@
         get() = taskContainers[0].thumbnailViewDeprecated
 
     @get:Deprecated("Use [taskContainers] instead.")
+    val firstSnapshotView: View
+        /** Returns the first snapshotView of the TaskView. */
+        get() = taskContainers[0].snapshotView
+
+    @get:Deprecated("Use [taskContainers] instead.")
     val firstItemInfo: ItemInfo
         get() = taskContainers[0].itemInfo
 
@@ -667,6 +675,7 @@
                     taskOverlayFactory
                 )
             )
+        taskContainers.forEach { it.bind() }
         setOrientationState(orientedState)
     }
 
@@ -693,24 +702,16 @@
         }
         val iconView = getOrInflateIconView(iconViewId)
         return TaskContainer(
-                task,
-                thumbnailView,
-                thumbnailViewDeprecated,
-                iconView,
-                TransformingTouchDelegate(iconView.asView()),
-                stagePosition,
-                DigitalWellBeingToast(container, this),
-                findViewById(showWindowViewId)!!,
-                taskOverlayFactory
-            )
-            .apply {
-                if (enableRefactorTaskThumbnail()) {
-                    thumbnailViewDeprecated.setTaskOverlay(overlay)
-                    bindThumbnailView()
-                } else {
-                    thumbnailViewDeprecated.bind(task, overlay)
-                }
-            }
+            task,
+            thumbnailView,
+            thumbnailViewDeprecated,
+            iconView,
+            TransformingTouchDelegate(iconView.asView()),
+            stagePosition,
+            DigitalWellBeingToast(container, this),
+            findViewById(showWindowViewId)!!,
+            taskOverlayFactory
+        )
     }
 
     protected fun getOrInflateIconView(@IdRes iconViewId: Int): TaskViewIcon {
@@ -1203,10 +1204,10 @@
             this,
             container.task,
             container.iconView.drawable,
-            container.thumbnailViewDeprecated,
-            container.thumbnailViewDeprecated.thumbnail, /* intent */
-            null, /* user */
-            null,
+            container.snapshotView,
+            container.thumbnail,
+            /* intent */ null,
+            /* user */ null,
             container.itemInfo
         )
     }
@@ -1379,7 +1380,6 @@
     open fun setColorTint(amount: Float, tintColor: Int) {
         taskContainers.forEach {
             if (!enableRefactorTaskThumbnail()) {
-                // TODO(b/334832108) Add scrim to new TTV
                 it.thumbnailViewDeprecated.dimAlpha = amount
             }
             it.iconView.setIconColorTint(tintColor, amount)
@@ -1519,9 +1519,16 @@
         gridTranslationY = 0f
         boxTranslationY = 0f
         nonGridPivotTranslationX = 0f
+        taskContainers.forEach {
+            it.snapshotView.translationX = 0f
+            it.snapshotView.translationY = 0f
+        }
         resetViewTransforms()
     }
 
+    fun getTaskContainerForTaskThumbnailView(taskThumbnailView: TaskThumbnailView): TaskContainer? =
+        taskContainers.firstOrNull { it.thumbnailView == taskThumbnailView }
+
     open fun resetViewTransforms() {
         // fullscreenTranslation and accumulatedTranslation should not be reset, as
         // resetViewTransforms is called during QuickSwitch scrolling.
@@ -1541,10 +1548,6 @@
         alpha = stableAlpha
         setIconScaleAndDim(1f)
         setColorTint(0f, 0)
-        if (!enableRefactorTaskThumbnail()) {
-            // TODO(b/335399428) add split select functionality to new TTV
-            taskContainers.forEach { it.thumbnailViewDeprecated.resetViewTransforms() }
-        }
     }
 
     private fun getGridTrans(endTranslation: Float) =
@@ -1623,10 +1626,23 @@
         taskOverlayFactory: TaskOverlayFactory
     ) {
         val overlay: TaskOverlay<*> = taskOverlayFactory.createOverlay(this)
+        val taskContainerData = TaskContainerData()
 
         val snapshotView: View
             get() = thumbnailView ?: thumbnailViewDeprecated
 
+        // TODO(b/349120849): Extract ThumbnailData from TaskContainerData/TaskThumbnailViewModel
+        val thumbnail: Bitmap?
+            get() = thumbnailViewDeprecated.thumbnail
+
+        // TODO(b/349120849): Extract ThumbnailData from TaskContainerData/TaskThumbnailViewModel
+        val isRealSnapshot: Boolean
+            get() = thumbnailViewDeprecated.isRealSnapshot()
+
+        // TODO(b/349120849): Extract ThumbnailData from TaskContainerData/TaskThumbnailViewModel
+        val scaledInsets: Insets
+            get() = thumbnailViewDeprecated.scaledInsets
+
         /** Builds proto for logging */
         val itemInfo: WorkspaceItemInfo
             get() =
@@ -1656,6 +1672,15 @@
             thumbnailView?.let { taskView.removeView(it) }
         }
 
+        fun bind() {
+            if (enableRefactorTaskThumbnail() && thumbnailView != null) {
+                thumbnailViewDeprecated.setTaskOverlay(overlay)
+                bindThumbnailView()
+            } else {
+                thumbnailViewDeprecated.bind(task, overlay)
+            }
+        }
+
         // TODO(b/335649589): TaskView's VM will already have access to TaskThumbnailView's VM
         //  so there will be no need to access TaskThumbnailView's VM through the TaskThumbnailView
         fun bindThumbnailView() {
diff --git a/quickstep/tests/src/com/android/launcher3/model/WidgetsPredictionsRequesterTest.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/model/WidgetsPredictionsRequesterTest.kt
similarity index 100%
rename from quickstep/tests/src/com/android/launcher3/model/WidgetsPredictionsRequesterTest.kt
rename to quickstep/tests/multivalentTests/src/com/android/launcher3/model/WidgetsPredictionsRequesterTest.kt
diff --git a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarModeRule.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarModeRule.kt
new file mode 100644
index 0000000..3b53cdc
--- /dev/null
+++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarModeRule.kt
@@ -0,0 +1,85 @@
+/*
+ * Copyright (C) 2024 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 com.android.launcher3.taskbar.TaskbarModeRule.Mode
+import com.android.launcher3.taskbar.TaskbarModeRule.TaskbarMode
+import com.android.launcher3.util.DisplayController
+import com.android.launcher3.util.MainThreadInitializedObject
+import com.android.launcher3.util.MainThreadInitializedObject.SandboxContext
+import com.android.launcher3.util.NavigationMode
+import org.junit.rules.TestRule
+import org.junit.runner.Description
+import org.junit.runners.model.Statement
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.spy
+
+/**
+ * Allows tests to specify which Taskbar [Mode] to run under.
+ *
+ * [context] should match the test's target context, so that [MainThreadInitializedObject] instances
+ * are properly sandboxed.
+ *
+ * Annotate tests with [TaskbarMode] to set a mode. If the annotation is omitted for any tests, this
+ * rule is a no-op.
+ *
+ * Make sure this rule precedes any rules that depend on [DisplayController], or else the instance
+ * might be inconsistent across the test lifecycle.
+ */
+class TaskbarModeRule(private val context: SandboxContext) : TestRule {
+    /** The selected Taskbar mode. */
+    enum class Mode {
+        TRANSIENT,
+        PINNED,
+        THREE_BUTTONS,
+    }
+
+    /** Overrides Taskbar [mode] for a test. */
+    @Retention(AnnotationRetention.RUNTIME)
+    @Target(AnnotationTarget.FUNCTION)
+    annotation class TaskbarMode(val mode: Mode)
+
+    override fun apply(base: Statement, description: Description): Statement {
+        val taskbarMode = description.getAnnotation(TaskbarMode::class.java) ?: return base
+
+        return object : Statement() {
+            override fun evaluate() {
+                val mode = taskbarMode.mode
+
+                context.putObject(
+                    DisplayController.INSTANCE,
+                    object : DisplayController(context) {
+                        override fun getInfo(): Info {
+                            return spy(super.getInfo()) {
+                                on { isTransientTaskbar } doReturn (mode == Mode.TRANSIENT)
+                                on { isPinnedTaskbar } doReturn (mode == Mode.PINNED)
+                                on { navigationMode } doReturn
+                                    when (mode) {
+                                        Mode.TRANSIENT,
+                                        Mode.PINNED -> NavigationMode.NO_BUTTON
+                                        Mode.THREE_BUTTONS -> NavigationMode.THREE_BUTTONS
+                                    }
+                            }
+                        }
+                    },
+                )
+
+                base.evaluate()
+            }
+        }
+    }
+}
diff --git a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarModeRuleTest.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarModeRuleTest.kt
new file mode 100644
index 0000000..7dfbb9a
--- /dev/null
+++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarModeRuleTest.kt
@@ -0,0 +1,89 @@
+/*
+ * Copyright (C) 2024 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 androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
+import com.android.launcher3.InvariantDeviceProfile
+import com.android.launcher3.taskbar.TaskbarModeRule.Mode.PINNED
+import com.android.launcher3.taskbar.TaskbarModeRule.Mode.THREE_BUTTONS
+import com.android.launcher3.taskbar.TaskbarModeRule.Mode.TRANSIENT
+import com.android.launcher3.taskbar.TaskbarModeRule.TaskbarMode
+import com.android.launcher3.util.DisplayController
+import com.android.launcher3.util.LauncherMultivalentJUnit
+import com.android.launcher3.util.MainThreadInitializedObject.SandboxContext
+import com.android.launcher3.util.NavigationMode
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(LauncherMultivalentJUnit::class)
+class TaskbarModeRuleTest {
+
+    private val context = SandboxContext(getInstrumentation().targetContext)
+
+    @get:Rule val taskbarModeRule = TaskbarModeRule(context)
+
+    @Test
+    @TaskbarMode(TRANSIENT)
+    fun testTaskbarMode_transient_overridesDisplayController() {
+        assertThat(DisplayController.isTransientTaskbar(context)).isTrue()
+        assertThat(DisplayController.isPinnedTaskbar(context)).isFalse()
+        assertThat(DisplayController.getNavigationMode(context)).isEqualTo(NavigationMode.NO_BUTTON)
+    }
+
+    @Test
+    @TaskbarMode(TRANSIENT)
+    fun testTaskbarMode_transient_overridesDeviceProfile() {
+        val dp = InvariantDeviceProfile.INSTANCE.get(context).getDeviceProfile(context)
+        assertThat(dp.isTransientTaskbar).isTrue()
+        assertThat(dp.isGestureMode).isTrue()
+    }
+
+    @Test
+    @TaskbarMode(PINNED)
+    fun testTaskbarMode_pinned_overridesDisplayController() {
+        assertThat(DisplayController.isTransientTaskbar(context)).isFalse()
+        assertThat(DisplayController.isPinnedTaskbar(context)).isTrue()
+        assertThat(DisplayController.getNavigationMode(context)).isEqualTo(NavigationMode.NO_BUTTON)
+    }
+
+    @Test
+    @TaskbarMode(PINNED)
+    fun testTaskbarMode_pinned_overridesDeviceProfile() {
+        val dp = InvariantDeviceProfile.INSTANCE.get(context).getDeviceProfile(context)
+        assertThat(dp.isTransientTaskbar).isFalse()
+        assertThat(dp.isGestureMode).isTrue()
+    }
+
+    @Test
+    @TaskbarMode(THREE_BUTTONS)
+    fun testTaskbarMode_threeButtons_overridesDisplayController() {
+        assertThat(DisplayController.isTransientTaskbar(context)).isFalse()
+        assertThat(DisplayController.isPinnedTaskbar(context)).isFalse()
+        assertThat(DisplayController.getNavigationMode(context))
+            .isEqualTo(NavigationMode.THREE_BUTTONS)
+    }
+
+    @Test
+    @TaskbarMode(THREE_BUTTONS)
+    fun testTaskbarMode_threeButtons_overridesDeviceProfile() {
+        val dp = InvariantDeviceProfile.INSTANCE.get(context).getDeviceProfile(context)
+        assertThat(dp.isTransientTaskbar).isFalse()
+        assertThat(dp.isGestureMode).isFalse()
+    }
+}
diff --git a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarUnitTestRule.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarUnitTestRule.kt
index a999e7f..bbf738e 100644
--- a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarUnitTestRule.kt
+++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarUnitTestRule.kt
@@ -18,6 +18,7 @@
 
 import android.app.Instrumentation
 import android.app.PendingIntent
+import android.content.Context
 import android.content.IIntentSender
 import android.content.Intent
 import androidx.test.platform.app.InstrumentationRegistry
@@ -30,13 +31,16 @@
 import com.android.quickstep.TouchInteractionService
 import com.android.quickstep.TouchInteractionService.TISBinder
 import org.junit.Assume.assumeTrue
-import org.junit.rules.MethodRule
-import org.junit.runners.model.FrameworkMethod
+import org.junit.rules.TestRule
+import org.junit.runner.Description
 import org.junit.runners.model.Statement
 
 /**
  * Manages the Taskbar lifecycle for unit tests.
  *
+ * Tests should pass in themselves as [testInstance]. They also need to provide their target
+ * [context] through the constructor.
+ *
  * See [InjectController] for grabbing controller(s) under test with minimal boilerplate.
  *
  * The rule interacts with [TaskbarManager] on the main thread. A good rule of thumb for tests is
@@ -58,12 +62,11 @@
  * }
  * ```
  */
-class TaskbarUnitTestRule : MethodRule {
+class TaskbarUnitTestRule(private val testInstance: Any, private val context: Context) : TestRule {
     private val instrumentation = InstrumentationRegistry.getInstrumentation()
     private val serviceTestRule = ServiceTestRule()
 
     private lateinit var taskbarManager: TaskbarManager
-    private lateinit var target: Any
 
     val activityContext: TaskbarActivityContext
         get() {
@@ -71,12 +74,10 @@
                 ?: throw RuntimeException("Failed to obtain TaskbarActivityContext.")
         }
 
-    override fun apply(base: Statement, method: FrameworkMethod, target: Any): Statement {
+    override fun apply(base: Statement, description: Description): Statement {
         return object : Statement() {
             override fun evaluate() {
-                this@TaskbarUnitTestRule.target = target
 
-                val context = instrumentation.targetContext
                 instrumentation.runOnMainSync {
                     assumeTrue(
                         LauncherAppState.getIDP(context).getDeviceProfile(context).isTaskbarPresent
@@ -139,11 +140,11 @@
     private fun injectControllers() {
         val controllers = activityContext.controllers
         val controllerFieldsByType = controllers.javaClass.fields.associateBy { it.type }
-        target.javaClass.fields
+        testInstance.javaClass.fields
             .filter { it.isAnnotationPresent(InjectController::class.java) }
             .forEach {
                 it.set(
-                    target,
+                    testInstance,
                     controllerFieldsByType[it.type]?.get(controllers)
                         ?: throw NoSuchElementException("Failed to find controller for ${it.type}"),
                 )
diff --git a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/allapps/TaskbarAllAppsControllerTest.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/allapps/TaskbarAllAppsControllerTest.kt
index fe4e2d2..bfad697 100644
--- a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/allapps/TaskbarAllAppsControllerTest.kt
+++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/allapps/TaskbarAllAppsControllerTest.kt
@@ -42,7 +42,8 @@
 @EmulatedDevices(["pixelFoldable2023", "pixelTablet2023"])
 class TaskbarAllAppsControllerTest {
 
-    @get:Rule val taskbarUnitTestRule = TaskbarUnitTestRule()
+    @get:Rule
+    val taskbarUnitTestRule = TaskbarUnitTestRule(this, getInstrumentation().targetContext)
     @get:Rule val animatorTestRule = AnimatorTestRule(this)
 
     @InjectController lateinit var allAppsController: TaskbarAllAppsController
diff --git a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/animation/BubbleAnimatorTest.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/animation/BubbleAnimatorTest.kt
new file mode 100644
index 0000000..20bd617
--- /dev/null
+++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/animation/BubbleAnimatorTest.kt
@@ -0,0 +1,91 @@
+/*
+ * Copyright (C) 2024 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.bubbles.animation
+
+import androidx.core.animation.AnimatorTestRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import androidx.test.platform.app.InstrumentationRegistry
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class BubbleAnimatorTest {
+
+    @get:Rule val animatorTestRule = AnimatorTestRule()
+
+    private lateinit var bubbleAnimator: BubbleAnimator
+
+    @Test
+    fun animateNewBubble_isRunning() {
+        bubbleAnimator =
+            BubbleAnimator(
+                iconSize = 40f,
+                expandedBarIconSpacing = 10f,
+                bubbleCount = 5,
+                onLeft = false
+            )
+        val listener = TestBubbleAnimatorListener()
+        InstrumentationRegistry.getInstrumentation().runOnMainSync {
+            bubbleAnimator.animateNewBubble(selectedBubbleIndex = 2, listener)
+        }
+
+        assertThat(bubbleAnimator.isRunning).isTrue()
+        InstrumentationRegistry.getInstrumentation().runOnMainSync {
+            animatorTestRule.advanceTimeBy(250)
+        }
+        assertThat(bubbleAnimator.isRunning).isFalse()
+    }
+
+    @Test
+    fun animateRemovedBubble_isRunning() {
+        bubbleAnimator =
+            BubbleAnimator(
+                iconSize = 40f,
+                expandedBarIconSpacing = 10f,
+                bubbleCount = 5,
+                onLeft = false
+            )
+        val listener = TestBubbleAnimatorListener()
+        InstrumentationRegistry.getInstrumentation().runOnMainSync {
+            bubbleAnimator.animateRemovedBubble(
+                bubbleIndex = 2,
+                selectedBubbleIndex = 3,
+                removingLastBubble = false,
+                listener
+            )
+        }
+
+        assertThat(bubbleAnimator.isRunning).isTrue()
+        InstrumentationRegistry.getInstrumentation().runOnMainSync {
+            animatorTestRule.advanceTimeBy(250)
+        }
+        assertThat(bubbleAnimator.isRunning).isFalse()
+    }
+
+    private class TestBubbleAnimatorListener : BubbleAnimator.Listener {
+
+        override fun onAnimationUpdate(animatedFraction: Float) {}
+
+        override fun onAnimationCancel() {}
+
+        override fun onAnimationEnd() {}
+    }
+}
diff --git a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/animation/BubbleBarViewAnimatorTest.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/animation/BubbleBarViewAnimatorTest.kt
index 2ae4e6b..e9c0dd6 100644
--- a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/animation/BubbleBarViewAnimatorTest.kt
+++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/animation/BubbleBarViewAnimatorTest.kt
@@ -388,7 +388,8 @@
     fun animateBubbleBarForCollapsed() {
         setUpBubbleBar()
         setUpBubbleStashController()
-        bubbleBarView.translationY = BAR_TRANSLATION_Y_FOR_HOTSEAT
+        whenever(bubbleStashController.bubbleBarTranslationY)
+            .thenReturn(BAR_TRANSLATION_Y_FOR_HOTSEAT)
 
         val barAnimator = PhysicsAnimator.getInstance(bubbleBarView)
 
diff --git a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/overlay/TaskbarOverlayControllerTest.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/overlay/TaskbarOverlayControllerTest.kt
index eebd8f9..72bdc16 100644
--- a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/overlay/TaskbarOverlayControllerTest.kt
+++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/overlay/TaskbarOverlayControllerTest.kt
@@ -41,7 +41,8 @@
 @EmulatedDevices(["pixelFoldable2023"])
 class TaskbarOverlayControllerTest {
 
-    @get:Rule val taskbarUnitTestRule = TaskbarUnitTestRule()
+    @get:Rule
+    val taskbarUnitTestRule = TaskbarUnitTestRule(this, getInstrumentation().targetContext)
     @InjectController lateinit var overlayController: TaskbarOverlayController
 
     private val taskbarContext: TaskbarActivityContext
diff --git a/quickstep/tests/multivalentTests/src/com/android/quickstep/task/thumbnail/TaskThumbnailViewModelTest.kt b/quickstep/tests/multivalentTests/src/com/android/quickstep/task/thumbnail/TaskThumbnailViewModelTest.kt
index 3b8754c..a394b65 100644
--- a/quickstep/tests/multivalentTests/src/com/android/quickstep/task/thumbnail/TaskThumbnailViewModelTest.kt
+++ b/quickstep/tests/multivalentTests/src/com/android/quickstep/task/thumbnail/TaskThumbnailViewModelTest.kt
@@ -28,6 +28,7 @@
 import com.android.quickstep.task.thumbnail.TaskThumbnailUiState.LiveTile
 import com.android.quickstep.task.thumbnail.TaskThumbnailUiState.Snapshot
 import com.android.quickstep.task.thumbnail.TaskThumbnailUiState.Uninitialized
+import com.android.quickstep.task.viewmodel.TaskContainerData
 import com.android.quickstep.task.viewmodel.TaskViewData
 import com.android.systemui.shared.recents.model.Task
 import com.android.systemui.shared.recents.model.ThumbnailData
@@ -43,9 +44,10 @@
 class TaskThumbnailViewModelTest {
     private val recentsViewData = RecentsViewData()
     private val taskViewData = TaskViewData()
+    private val taskContainerData = TaskContainerData()
     private val tasksRepository = FakeTasksRepository()
     private val systemUnderTest =
-        TaskThumbnailViewModel(recentsViewData, taskViewData, tasksRepository)
+        TaskThumbnailViewModel(recentsViewData, taskViewData, taskContainerData, tasksRepository)
 
     private val tasks = (0..5).map(::createTaskWithId)
 
diff --git a/quickstep/tests/src/com/android/quickstep/taskbar/customization/TaskbarSpecsEvaluatorTest.kt b/quickstep/tests/multivalentTests/src/com/android/quickstep/taskbar/customization/TaskbarSpecsEvaluatorTest.kt
similarity index 100%
rename from quickstep/tests/src/com/android/quickstep/taskbar/customization/TaskbarSpecsEvaluatorTest.kt
rename to quickstep/tests/multivalentTests/src/com/android/quickstep/taskbar/customization/TaskbarSpecsEvaluatorTest.kt
diff --git a/quickstep/tests/multivalentTests/src/com/android/quickstep/util/SplitAnimationControllerTest.kt b/quickstep/tests/multivalentTests/src/com/android/quickstep/util/SplitAnimationControllerTest.kt
index d40f8ab..c8893ad 100644
--- a/quickstep/tests/multivalentTests/src/com/android/quickstep/util/SplitAnimationControllerTest.kt
+++ b/quickstep/tests/multivalentTests/src/com/android/quickstep/util/SplitAnimationControllerTest.kt
@@ -33,7 +33,6 @@
 import com.android.launcher3.util.SplitConfigurationOptions
 import com.android.quickstep.views.GroupedTaskView
 import com.android.quickstep.views.IconView
-import com.android.quickstep.views.TaskThumbnailViewDeprecated
 import com.android.quickstep.views.TaskView
 import com.android.quickstep.views.TaskView.TaskContainer
 import com.android.systemui.shared.recents.model.Task
@@ -59,7 +58,7 @@
     private val mockSplitSelectStateController: SplitSelectStateController = mock()
     // TaskView
     private val mockTaskView: TaskView = mock()
-    private val mockThumbnailView: TaskThumbnailViewDeprecated = mock()
+    private val mockSnapshotView: View = mock()
     private val mockBitmap: Bitmap = mock()
     private val mockIconView: IconView = mock()
     private val mockTaskViewDrawable: Drawable = mock()
@@ -87,8 +86,8 @@
 
     @Before
     fun setup() {
-        whenever(mockTaskContainer.thumbnailViewDeprecated).thenReturn(mockThumbnailView)
-        whenever(mockThumbnailView.thumbnail).thenReturn(mockBitmap)
+        whenever(mockTaskContainer.snapshotView).thenReturn(mockSnapshotView)
+        whenever(mockTaskContainer.thumbnail).thenReturn(mockBitmap)
         whenever(mockTaskContainer.iconView).thenReturn(mockIconView)
         whenever(mockIconView.drawable).thenReturn(mockTaskViewDrawable)
         whenever(mockTaskView.taskContainers).thenReturn(List(1) { mockTaskContainer })
@@ -180,7 +179,6 @@
 
         whenever(mockTaskContainer.task).thenReturn(mockTask)
         whenever(mockTaskContainer.iconView).thenReturn(mockIconView)
-        whenever(mockTaskContainer.thumbnailViewDeprecated).thenReturn(mockThumbnailView)
         whenever(mockTask.getKey()).thenReturn(mockTaskKey)
         whenever(mockTaskKey.getId()).thenReturn(taskId)
         whenever(mockSplitSelectStateController.initialTaskId).thenReturn(taskId)
diff --git a/quickstep/tests/src/com/android/launcher3/taskbar/TaskbarRecentAppsControllerTest.kt b/quickstep/tests/src/com/android/launcher3/taskbar/TaskbarRecentAppsControllerTest.kt
index 104263a..486dc68 100644
--- a/quickstep/tests/src/com/android/launcher3/taskbar/TaskbarRecentAppsControllerTest.kt
+++ b/quickstep/tests/src/com/android/launcher3/taskbar/TaskbarRecentAppsControllerTest.kt
@@ -16,24 +16,35 @@
 
 package com.android.launcher3.taskbar
 
-import android.app.ActivityManager.RunningTaskInfo
 import android.app.WindowConfiguration.WINDOWING_MODE_FREEFORM
 import android.content.ComponentName
 import android.content.Intent
 import android.os.Process
 import android.os.UserHandle
 import android.testing.AndroidTestingRunner
+import com.android.launcher3.LauncherSettings.Favorites.CONTAINER_HOTSEAT
+import com.android.launcher3.LauncherSettings.Favorites.CONTAINER_HOTSEAT_PREDICTION
 import com.android.launcher3.model.data.AppInfo
 import com.android.launcher3.model.data.ItemInfo
 import com.android.launcher3.statehandlers.DesktopVisibilityController
 import com.android.quickstep.RecentsModel
+import com.android.quickstep.RecentsModel.RecentTasksChangedListener
+import com.android.quickstep.TaskIconCache
+import com.android.quickstep.util.DesktopTask
+import com.android.quickstep.util.GroupTask
+import com.android.systemui.shared.recents.model.Task
 import com.google.common.truth.Truth.assertThat
+import java.util.function.Consumer
 import org.junit.Before
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
+import org.mockito.ArgumentCaptor
 import org.mockito.Mock
 import org.mockito.junit.MockitoJUnit
+import org.mockito.kotlin.any
+import org.mockito.kotlin.doAnswer
+import org.mockito.kotlin.verify
 import org.mockito.kotlin.whenever
 
 @RunWith(AndroidTestingRunner::class)
@@ -41,177 +52,471 @@
 
     @get:Rule val mockitoRule = MockitoJUnit.rule()
 
+    @Mock private lateinit var mockIconCache: TaskIconCache
     @Mock private lateinit var mockRecentsModel: RecentsModel
     @Mock private lateinit var mockDesktopVisibilityController: DesktopVisibilityController
 
     private var nextTaskId: Int = 500
+    private var taskListChangeId: Int = 1
 
     private lateinit var recentAppsController: TaskbarRecentAppsController
+    private lateinit var recentTasksChangedListener: RecentTasksChangedListener
     private lateinit var userHandle: UserHandle
 
     @Before
     fun setUp() {
         super.setup()
         userHandle = Process.myUserHandle()
+
+        whenever(mockRecentsModel.iconCache).thenReturn(mockIconCache)
         recentAppsController =
             TaskbarRecentAppsController(mockRecentsModel) { mockDesktopVisibilityController }
         recentAppsController.init(taskbarControllers)
-        recentAppsController.isEnabled = true
-        recentAppsController.setApps(
-            ALL_APP_PACKAGES.map { createTestAppInfo(packageName = it) }.toTypedArray()
-        )
+        recentAppsController.canShowRunningApps = true
+        recentAppsController.canShowRecentApps = true
+
+        val listenerCaptor = ArgumentCaptor.forClass(RecentTasksChangedListener::class.java)
+        verify(mockRecentsModel).registerRecentTasksChangedListener(listenerCaptor.capture())
+        recentTasksChangedListener = listenerCaptor.value
     }
 
     @Test
-    fun updateHotseatItemInfos_notInDesktopMode_returnsExistingHotseatItems() {
-        setInDesktopMode(false)
-        val hotseatItems =
-            createHotseatItemsFromPackageNames(listOf(HOTSEAT_PACKAGE_1, HOTSEAT_PACKAGE_2))
-
-        assertThat(recentAppsController.updateHotseatItemInfos(hotseatItems.toTypedArray()))
-            .isEqualTo(hotseatItems.toTypedArray())
-    }
-
-    @Test
-    fun updateHotseatItemInfos_notInDesktopMode_runningApps_returnsExistingHotseatItems() {
-        setInDesktopMode(false)
-        val hotseatPackages = listOf(HOTSEAT_PACKAGE_1, HOTSEAT_PACKAGE_2)
-        val hotseatItems = createHotseatItemsFromPackageNames(hotseatPackages)
-        val runningTasks =
-            createDesktopTasksFromPackageNames(listOf(RUNNING_APP_PACKAGE_1, RUNNING_APP_PACKAGE_2))
-        whenever(mockRecentsModel.runningTasks).thenReturn(runningTasks)
-        recentAppsController.updateRunningApps()
-
+    fun updateHotseatItemInfos_cantShowRunning_inDesktopMode_returnsAllHotseatItems() {
+        recentAppsController.canShowRunningApps = false
+        setInDesktopMode(true)
+        val hotseatPackages = listOf(HOTSEAT_PACKAGE_1, HOTSEAT_PACKAGE_2, PREDICTED_PACKAGE_1)
         val newHotseatItems =
-            recentAppsController.updateHotseatItemInfos(hotseatItems.toTypedArray())
-
+            prepareHotseatAndRunningAndRecentApps(
+                hotseatPackages = hotseatPackages,
+                runningTaskPackages = emptyList(),
+                recentTaskPackages = emptyList()
+            )
         assertThat(newHotseatItems.map { it?.targetPackage })
             .containsExactlyElementsIn(hotseatPackages)
     }
 
     @Test
-    fun updateHotseatItemInfos_noRunningApps_returnsExistingHotseatItems() {
-        setInDesktopMode(true)
-        val hotseatItems =
-            createHotseatItemsFromPackageNames(listOf(HOTSEAT_PACKAGE_1, HOTSEAT_PACKAGE_2))
-
-        assertThat(recentAppsController.updateHotseatItemInfos(hotseatItems.toTypedArray()))
-            .isEqualTo(hotseatItems.toTypedArray())
-    }
-
-    @Test
-    fun updateHotseatItemInfos_returnsExistingHotseatItemsAndRunningApps() {
-        setInDesktopMode(true)
-        val hotseatItems =
-            createHotseatItemsFromPackageNames(listOf(HOTSEAT_PACKAGE_1, HOTSEAT_PACKAGE_2))
-        val runningTasks =
-            createDesktopTasksFromPackageNames(listOf(RUNNING_APP_PACKAGE_1, RUNNING_APP_PACKAGE_2))
-        whenever(mockRecentsModel.runningTasks).thenReturn(runningTasks)
-        recentAppsController.updateRunningApps()
-
-        val newHotseatItems =
-            recentAppsController.updateHotseatItemInfos(hotseatItems.toTypedArray())
-
-        val expectedPackages =
-            listOf(
-                HOTSEAT_PACKAGE_1,
-                HOTSEAT_PACKAGE_2,
-                RUNNING_APP_PACKAGE_1,
-                RUNNING_APP_PACKAGE_2,
-            )
-        assertThat(newHotseatItems.map { it?.targetPackage })
-            .containsExactlyElementsIn(expectedPackages)
-    }
-
-    @Test
-    fun updateHotseatItemInfos_runningAppIsHotseatItem_returnsDistinctItems() {
-        setInDesktopMode(true)
-        val hotseatItems =
-            createHotseatItemsFromPackageNames(listOf(HOTSEAT_PACKAGE_1, HOTSEAT_PACKAGE_2))
-        val runningTasks =
-            createDesktopTasksFromPackageNames(
-                listOf(HOTSEAT_PACKAGE_1, RUNNING_APP_PACKAGE_1, RUNNING_APP_PACKAGE_2)
-            )
-        whenever(mockRecentsModel.runningTasks).thenReturn(runningTasks)
-        recentAppsController.updateRunningApps()
-
-        val newHotseatItems =
-            recentAppsController.updateHotseatItemInfos(hotseatItems.toTypedArray())
-
-        val expectedPackages =
-            listOf(
-                HOTSEAT_PACKAGE_1,
-                HOTSEAT_PACKAGE_2,
-                RUNNING_APP_PACKAGE_1,
-                RUNNING_APP_PACKAGE_2,
-            )
-        assertThat(newHotseatItems.map { it?.targetPackage })
-            .containsExactlyElementsIn(expectedPackages)
-    }
-
-    @Test
-    fun getRunningApps_notInDesktopMode_returnsEmptySet() {
+    fun updateHotseatItemInfos_cantShowRecent_notInDesktopMode_returnsAllHotseatItems() {
+        recentAppsController.canShowRecentApps = false
         setInDesktopMode(false)
-        val runningTasks =
-            createDesktopTasksFromPackageNames(listOf(RUNNING_APP_PACKAGE_1, RUNNING_APP_PACKAGE_2))
-        whenever(mockRecentsModel.runningTasks).thenReturn(runningTasks)
-        recentAppsController.updateRunningApps()
-
-        assertThat(recentAppsController.runningApps).isEmpty()
-        assertThat(recentAppsController.minimizedApps).isEmpty()
+        val hotseatPackages = listOf(HOTSEAT_PACKAGE_1, HOTSEAT_PACKAGE_2, PREDICTED_PACKAGE_1)
+        val newHotseatItems =
+            prepareHotseatAndRunningAndRecentApps(
+                hotseatPackages = hotseatPackages,
+                runningTaskPackages = emptyList(),
+                recentTaskPackages = emptyList()
+            )
+        assertThat(newHotseatItems.map { it?.targetPackage })
+            .containsExactlyElementsIn(hotseatPackages)
     }
 
     @Test
-    fun getRunningApps_inDesktopMode_returnsRunningApps() {
+    fun updateHotseatItemInfos_canShowRunning_inDesktopMode_returnsNonPredictedHotseatItems() {
+        recentAppsController.canShowRunningApps = true
         setInDesktopMode(true)
-        val runningTasks =
-            createDesktopTasksFromPackageNames(listOf(RUNNING_APP_PACKAGE_1, RUNNING_APP_PACKAGE_2))
-        whenever(mockRecentsModel.runningTasks).thenReturn(runningTasks)
-        recentAppsController.updateRunningApps()
+        val newHotseatItems =
+            prepareHotseatAndRunningAndRecentApps(
+                hotseatPackages = listOf(HOTSEAT_PACKAGE_1, HOTSEAT_PACKAGE_2, PREDICTED_PACKAGE_1),
+                runningTaskPackages = emptyList(),
+                recentTaskPackages = emptyList()
+            )
+        val expectedPackages = listOf(HOTSEAT_PACKAGE_1, HOTSEAT_PACKAGE_2)
+        assertThat(newHotseatItems.map { it?.targetPackage })
+            .containsExactlyElementsIn(expectedPackages)
+    }
 
-        assertThat(recentAppsController.runningApps)
-            .containsExactly(RUNNING_APP_PACKAGE_1, RUNNING_APP_PACKAGE_2)
-        assertThat(recentAppsController.minimizedApps).isEmpty()
+    @Test
+    fun updateHotseatItemInfos_canShowRecent_notInDesktopMode_returnsNonPredictedHotseatItems() {
+        recentAppsController.canShowRecentApps = true
+        setInDesktopMode(false)
+        val newHotseatItems =
+            prepareHotseatAndRunningAndRecentApps(
+                hotseatPackages = listOf(HOTSEAT_PACKAGE_1, HOTSEAT_PACKAGE_2, PREDICTED_PACKAGE_1),
+                runningTaskPackages = emptyList(),
+                recentTaskPackages = emptyList()
+            )
+        val expectedPackages = listOf(HOTSEAT_PACKAGE_1, HOTSEAT_PACKAGE_2)
+        assertThat(newHotseatItems.map { it?.targetPackage })
+            .containsExactlyElementsIn(expectedPackages)
+    }
+
+    @Test
+    fun onRecentTasksChanged_cantShowRunning_inDesktopMode_shownTasks_returnsEmptyList() {
+        recentAppsController.canShowRunningApps = false
+        setInDesktopMode(true)
+        prepareHotseatAndRunningAndRecentApps(
+            hotseatPackages = listOf(HOTSEAT_PACKAGE_1, HOTSEAT_PACKAGE_2, PREDICTED_PACKAGE_1),
+            runningTaskPackages = listOf(RECENT_PACKAGE_1, RECENT_PACKAGE_2),
+            recentTaskPackages = emptyList()
+        )
+        assertThat(recentAppsController.shownTasks).isEmpty()
+    }
+
+    @Test
+    fun onRecentTasksChanged_cantShowRecent_notInDesktopMode_shownTasks_returnsEmptyList() {
+        recentAppsController.canShowRecentApps = false
+        setInDesktopMode(false)
+        prepareHotseatAndRunningAndRecentApps(
+            hotseatPackages = listOf(HOTSEAT_PACKAGE_1, HOTSEAT_PACKAGE_2, PREDICTED_PACKAGE_1),
+            runningTaskPackages = emptyList(),
+            recentTaskPackages = listOf(RECENT_PACKAGE_1, RECENT_PACKAGE_2)
+        )
+        assertThat(recentAppsController.shownTasks).isEmpty()
+    }
+
+    @Test
+    fun onRecentTasksChanged_notInDesktopMode_noRecentTasks_shownTasks_returnsEmptyList() {
+        setInDesktopMode(false)
+        prepareHotseatAndRunningAndRecentApps(
+            hotseatPackages = emptyList(),
+            runningTaskPackages = listOf(RUNNING_APP_PACKAGE_1, RUNNING_APP_PACKAGE_2),
+            recentTaskPackages = emptyList()
+        )
+        assertThat(recentAppsController.shownTasks).isEmpty()
+        assertThat(recentAppsController.minimizedAppPackages).isEmpty()
+    }
+
+    @Test
+    fun onRecentTasksChanged_inDesktopMode_noRunningApps_shownTasks_returnsEmptyList() {
+        setInDesktopMode(true)
+        prepareHotseatAndRunningAndRecentApps(
+            hotseatPackages = emptyList(),
+            runningTaskPackages = emptyList(),
+            recentTaskPackages = listOf(RECENT_PACKAGE_1, RECENT_PACKAGE_2)
+        )
+        assertThat(recentAppsController.shownTasks).isEmpty()
+    }
+
+    @Test
+    fun onRecentTasksChanged_inDesktopMode_shownTasks_returnsRunningTasks() {
+        setInDesktopMode(true)
+        val runningTaskPackages = listOf(RUNNING_APP_PACKAGE_1, RUNNING_APP_PACKAGE_2)
+        prepareHotseatAndRunningAndRecentApps(
+            hotseatPackages = emptyList(),
+            runningTaskPackages = runningTaskPackages,
+            recentTaskPackages = emptyList()
+        )
+        val shownPackages = recentAppsController.shownTasks.flatMap { it.packageNames }
+        assertThat(shownPackages).containsExactlyElementsIn(runningTaskPackages)
+    }
+
+    @Test
+    fun onRecentTasksChanged_inDesktopMode_runningAppIsHotseatItem_shownTasks_returnsDistinctItems() {
+        setInDesktopMode(true)
+        prepareHotseatAndRunningAndRecentApps(
+            hotseatPackages = listOf(HOTSEAT_PACKAGE_1, HOTSEAT_PACKAGE_2),
+            runningTaskPackages =
+                listOf(HOTSEAT_PACKAGE_1, RUNNING_APP_PACKAGE_1, RUNNING_APP_PACKAGE_2),
+            recentTaskPackages = emptyList()
+        )
+        val expectedPackages = listOf(RUNNING_APP_PACKAGE_1, RUNNING_APP_PACKAGE_2)
+        val shownPackages = recentAppsController.shownTasks.flatMap { it.packageNames }
+        assertThat(shownPackages).containsExactlyElementsIn(expectedPackages)
+    }
+
+    @Test
+    fun onRecentTasksChanged_notInDesktopMode_getRunningApps_returnsEmptySet() {
+        setInDesktopMode(false)
+        prepareHotseatAndRunningAndRecentApps(
+            hotseatPackages = emptyList(),
+            runningTaskPackages = listOf(RUNNING_APP_PACKAGE_1, RUNNING_APP_PACKAGE_2),
+            recentTaskPackages = emptyList()
+        )
+        assertThat(recentAppsController.runningAppPackages).isEmpty()
+        assertThat(recentAppsController.minimizedAppPackages).isEmpty()
+    }
+
+    @Test
+    fun onRecentTasksChanged_inDesktopMode_getRunningApps_returnsAllDesktopTasks() {
+        setInDesktopMode(true)
+        val runningTaskPackages = listOf(RUNNING_APP_PACKAGE_1, RUNNING_APP_PACKAGE_2)
+        prepareHotseatAndRunningAndRecentApps(
+            hotseatPackages = emptyList(),
+            runningTaskPackages = runningTaskPackages,
+            recentTaskPackages = emptyList()
+        )
+        assertThat(recentAppsController.runningAppPackages)
+            .containsExactlyElementsIn(runningTaskPackages)
+        assertThat(recentAppsController.minimizedAppPackages).isEmpty()
+    }
+
+    @Test
+    fun onRecentTasksChanged_inDesktopMode_getRunningApps_includesHotseat() {
+        setInDesktopMode(true)
+        val runningTaskPackages =
+            listOf(HOTSEAT_PACKAGE_1, RUNNING_APP_PACKAGE_1, RUNNING_APP_PACKAGE_2)
+        prepareHotseatAndRunningAndRecentApps(
+            hotseatPackages = listOf(HOTSEAT_PACKAGE_1, HOTSEAT_PACKAGE_2),
+            runningTaskPackages = runningTaskPackages,
+            recentTaskPackages = listOf(RECENT_PACKAGE_1, RECENT_PACKAGE_2)
+        )
+        assertThat(recentAppsController.runningAppPackages)
+            .containsExactlyElementsIn(runningTaskPackages)
+        assertThat(recentAppsController.minimizedAppPackages).isEmpty()
     }
 
     @Test
     fun getMinimizedApps_inDesktopMode_returnsAllAppsRunningAndInvisibleAppsMinimized() {
         setInDesktopMode(true)
-        val runningTasks =
-            ArrayList(
-                listOf(
-                    createDesktopTaskInfo(RUNNING_APP_PACKAGE_1) { isVisible = true },
-                    createDesktopTaskInfo(RUNNING_APP_PACKAGE_2) { isVisible = true },
-                    createDesktopTaskInfo(RUNNING_APP_PACKAGE_3) { isVisible = false },
-                )
-            )
-        whenever(mockRecentsModel.runningTasks).thenReturn(runningTasks)
-        recentAppsController.updateRunningApps()
+        val runningTaskPackages =
+            listOf(RUNNING_APP_PACKAGE_1, RUNNING_APP_PACKAGE_2, RUNNING_APP_PACKAGE_3)
+        val minimizedTaskIndices = setOf(2) // RUNNING_APP_PACKAGE_3
+        prepareHotseatAndRunningAndRecentApps(
+            hotseatPackages = emptyList(),
+            runningTaskPackages = runningTaskPackages,
+            minimizedTaskIndices = minimizedTaskIndices,
+            recentTaskPackages = emptyList()
+        )
+        assertThat(recentAppsController.runningAppPackages)
+            .containsExactlyElementsIn(runningTaskPackages)
+        assertThat(recentAppsController.minimizedAppPackages).containsExactly(RUNNING_APP_PACKAGE_3)
+    }
 
-        assertThat(recentAppsController.runningApps)
-            .containsExactly(RUNNING_APP_PACKAGE_1, RUNNING_APP_PACKAGE_2, RUNNING_APP_PACKAGE_3)
-        assertThat(recentAppsController.minimizedApps).containsExactly(RUNNING_APP_PACKAGE_3)
+    @Test
+    fun getMinimizedApps_inDesktopMode_twoTasksSamePackageOneMinimizedReturnsNotMinimized() {
+        setInDesktopMode(true)
+        val runningTaskPackages = listOf(RUNNING_APP_PACKAGE_1, RUNNING_APP_PACKAGE_1)
+        val minimizedTaskIndices = setOf(1) // The second RUNNING_APP_PACKAGE_1 task.
+        prepareHotseatAndRunningAndRecentApps(
+            hotseatPackages = emptyList(),
+            runningTaskPackages = runningTaskPackages,
+            minimizedTaskIndices = minimizedTaskIndices,
+            recentTaskPackages = emptyList()
+        )
+        assertThat(recentAppsController.runningAppPackages)
+            .containsExactlyElementsIn(runningTaskPackages.toSet())
+        assertThat(recentAppsController.minimizedAppPackages).isEmpty()
+    }
+
+    @Test
+    fun onRecentTasksChanged_inDesktopMode_shownTasks_maintainsOrder() {
+        setInDesktopMode(true)
+        val originalOrder = listOf(RUNNING_APP_PACKAGE_1, RUNNING_APP_PACKAGE_2)
+        prepareHotseatAndRunningAndRecentApps(
+            hotseatPackages = emptyList(),
+            runningTaskPackages = originalOrder,
+            recentTaskPackages = emptyList()
+        )
+        prepareHotseatAndRunningAndRecentApps(
+            hotseatPackages = emptyList(),
+            runningTaskPackages = listOf(RUNNING_APP_PACKAGE_2, RUNNING_APP_PACKAGE_1),
+            recentTaskPackages = emptyList()
+        )
+        val shownPackages = recentAppsController.shownTasks.flatMap { it.packageNames }
+        assertThat(shownPackages).isEqualTo(originalOrder)
+    }
+
+    @Test
+    fun onRecentTasksChanged_notInDesktopMode_shownTasks_maintainsRecency() {
+        setInDesktopMode(false)
+        prepareHotseatAndRunningAndRecentApps(
+            hotseatPackages = emptyList(),
+            runningTaskPackages = emptyList(),
+            recentTaskPackages = listOf(RECENT_PACKAGE_1, RECENT_PACKAGE_2, RECENT_PACKAGE_3)
+        )
+        prepareHotseatAndRunningAndRecentApps(
+            hotseatPackages = emptyList(),
+            runningTaskPackages = emptyList(),
+            recentTaskPackages = listOf(RECENT_PACKAGE_2, RECENT_PACKAGE_3, RECENT_PACKAGE_1)
+        )
+        val shownPackages = recentAppsController.shownTasks.flatMap { it.packageNames }
+        // Most recent packages, minus the currently running one (RECENT_PACKAGE_1).
+        assertThat(shownPackages).isEqualTo(listOf(RECENT_PACKAGE_2, RECENT_PACKAGE_3))
+    }
+
+    @Test
+    fun onRecentTasksChanged_inDesktopMode_addTask_shownTasks_maintainsOrder() {
+        setInDesktopMode(true)
+        prepareHotseatAndRunningAndRecentApps(
+            hotseatPackages = emptyList(),
+            runningTaskPackages = listOf(RUNNING_APP_PACKAGE_1, RUNNING_APP_PACKAGE_2),
+            recentTaskPackages = emptyList()
+        )
+        prepareHotseatAndRunningAndRecentApps(
+            hotseatPackages = emptyList(),
+            runningTaskPackages =
+                listOf(RUNNING_APP_PACKAGE_2, RUNNING_APP_PACKAGE_1, RUNNING_APP_PACKAGE_3),
+            recentTaskPackages = emptyList()
+        )
+        val shownPackages = recentAppsController.shownTasks.flatMap { it.packageNames }
+        val expectedOrder =
+            listOf(RUNNING_APP_PACKAGE_1, RUNNING_APP_PACKAGE_2, RUNNING_APP_PACKAGE_3)
+        assertThat(shownPackages).isEqualTo(expectedOrder)
+    }
+
+    @Test
+    fun onRecentTasksChanged_notInDesktopMode_addTask_shownTasks_maintainsRecency() {
+        setInDesktopMode(false)
+        prepareHotseatAndRunningAndRecentApps(
+            hotseatPackages = emptyList(),
+            runningTaskPackages = emptyList(),
+            recentTaskPackages = listOf(RECENT_PACKAGE_3, RECENT_PACKAGE_2)
+        )
+        prepareHotseatAndRunningAndRecentApps(
+            hotseatPackages = emptyList(),
+            runningTaskPackages = emptyList(),
+            recentTaskPackages = listOf(RECENT_PACKAGE_2, RECENT_PACKAGE_3, RECENT_PACKAGE_1)
+        )
+        val shownPackages = recentAppsController.shownTasks.flatMap { it.packageNames }
+        // Most recent packages, minus the currently running one (RECENT_PACKAGE_1).
+        assertThat(shownPackages).isEqualTo(listOf(RECENT_PACKAGE_2, RECENT_PACKAGE_3))
+    }
+
+    @Test
+    fun onRecentTasksChanged_inDesktopMode_removeTask_shownTasks_maintainsOrder() {
+        setInDesktopMode(true)
+        prepareHotseatAndRunningAndRecentApps(
+            hotseatPackages = emptyList(),
+            runningTaskPackages =
+                listOf(RUNNING_APP_PACKAGE_1, RUNNING_APP_PACKAGE_2, RUNNING_APP_PACKAGE_3),
+            recentTaskPackages = emptyList()
+        )
+        prepareHotseatAndRunningAndRecentApps(
+            hotseatPackages = emptyList(),
+            runningTaskPackages = listOf(RUNNING_APP_PACKAGE_2, RUNNING_APP_PACKAGE_1),
+            recentTaskPackages = emptyList()
+        )
+        val shownPackages = recentAppsController.shownTasks.flatMap { it.packageNames }
+        assertThat(shownPackages).isEqualTo(listOf(RUNNING_APP_PACKAGE_1, RUNNING_APP_PACKAGE_2))
+    }
+
+    @Test
+    fun onRecentTasksChanged_notInDesktopMode_removeTask_shownTasks_maintainsRecency() {
+        setInDesktopMode(false)
+        prepareHotseatAndRunningAndRecentApps(
+            hotseatPackages = emptyList(),
+            runningTaskPackages = emptyList(),
+            recentTaskPackages = listOf(RECENT_PACKAGE_1, RECENT_PACKAGE_2, RECENT_PACKAGE_3)
+        )
+        prepareHotseatAndRunningAndRecentApps(
+            hotseatPackages = emptyList(),
+            runningTaskPackages = emptyList(),
+            recentTaskPackages = listOf(RECENT_PACKAGE_2, RECENT_PACKAGE_3)
+        )
+        val shownPackages = recentAppsController.shownTasks.flatMap { it.packageNames }
+        // Most recent packages, minus the currently running one (RECENT_PACKAGE_3).
+        assertThat(shownPackages).isEqualTo(listOf(RECENT_PACKAGE_2))
+    }
+
+    @Test
+    fun onRecentTasksChanged_enterDesktopMode_shownTasks_onlyIncludesRunningTasks() {
+        setInDesktopMode(false)
+        val runningTaskPackages = listOf(RUNNING_APP_PACKAGE_1, RUNNING_APP_PACKAGE_2)
+        val recentTaskPackages = listOf(RECENT_PACKAGE_1, RECENT_PACKAGE_2)
+        prepareHotseatAndRunningAndRecentApps(
+            hotseatPackages = emptyList(),
+            runningTaskPackages = runningTaskPackages,
+            recentTaskPackages = recentTaskPackages
+        )
+        setInDesktopMode(true)
+        recentTasksChangedListener.onRecentTasksChanged()
+        val shownPackages = recentAppsController.shownTasks.flatMap { it.packageNames }
+        assertThat(shownPackages).containsExactlyElementsIn(runningTaskPackages)
+    }
+
+    @Test
+    fun onRecentTasksChanged_exitDesktopMode_shownTasks_onlyIncludesRecentTasks() {
+        setInDesktopMode(true)
+        val runningTaskPackages = listOf(RUNNING_APP_PACKAGE_1, RUNNING_APP_PACKAGE_2)
+        val recentTaskPackages = listOf(RECENT_PACKAGE_1, RECENT_PACKAGE_2, RECENT_PACKAGE_3)
+        prepareHotseatAndRunningAndRecentApps(
+            hotseatPackages = emptyList(),
+            runningTaskPackages = runningTaskPackages,
+            recentTaskPackages = recentTaskPackages
+        )
+        setInDesktopMode(false)
+        recentTasksChangedListener.onRecentTasksChanged()
+        val shownPackages = recentAppsController.shownTasks.flatMap { it.packageNames }
+        // Don't expect RECENT_PACKAGE_3 because it is currently running.
+        val expectedPackages = listOf(RECENT_PACKAGE_1, RECENT_PACKAGE_2)
+        assertThat(shownPackages).containsExactlyElementsIn(expectedPackages)
+    }
+
+    @Test
+    fun onRecentTasksChanged_notInDesktopMode_hasRecentTasks_shownTasks_returnsRecentTasks() {
+        setInDesktopMode(false)
+        prepareHotseatAndRunningAndRecentApps(
+            hotseatPackages = emptyList(),
+            runningTaskPackages = emptyList(),
+            recentTaskPackages = listOf(RECENT_PACKAGE_1, RECENT_PACKAGE_2, RECENT_PACKAGE_3)
+        )
+        val shownPackages = recentAppsController.shownTasks.flatMap { it.packageNames }
+        // RECENT_PACKAGE_3 is the top task (visible to user) so should be excluded.
+        val expectedPackages = listOf(RECENT_PACKAGE_1, RECENT_PACKAGE_2)
+        assertThat(shownPackages).containsExactlyElementsIn(expectedPackages)
+    }
+
+    @Test
+    fun onRecentTasksChanged_notInDesktopMode_hasRecentAndRunningTasks_shownTasks_returnsRecentTaskAndDesktopTile() {
+        setInDesktopMode(false)
+        prepareHotseatAndRunningAndRecentApps(
+            hotseatPackages = emptyList(),
+            runningTaskPackages = listOf(RUNNING_APP_PACKAGE_1, RUNNING_APP_PACKAGE_2),
+            recentTaskPackages = listOf(RECENT_PACKAGE_1, RECENT_PACKAGE_2)
+        )
+        val shownPackages = recentAppsController.shownTasks.map { it.packageNames }
+        // Only 2 recent tasks shown: Desktop Tile + 1 Recent Task
+        val desktopTilePackages = listOf(RUNNING_APP_PACKAGE_1, RUNNING_APP_PACKAGE_2)
+        val recentTaskPackages = listOf(RECENT_PACKAGE_1)
+        val expectedPackages = listOf(desktopTilePackages, recentTaskPackages)
+        assertThat(shownPackages).containsExactlyElementsIn(expectedPackages)
+    }
+
+    @Test
+    fun onRecentTasksChanged_notInDesktopMode_hasRecentAndSplitTasks_shownTasks_returnsRecentTaskAndPair() {
+        setInDesktopMode(false)
+        prepareHotseatAndRunningAndRecentApps(
+            hotseatPackages = emptyList(),
+            runningTaskPackages = emptyList(),
+            recentTaskPackages = listOf(RECENT_SPLIT_PACKAGES_1, RECENT_PACKAGE_1, RECENT_PACKAGE_2)
+        )
+        val shownPackages = recentAppsController.shownTasks.map { it.packageNames }
+        // Only 2 recent tasks shown: Pair + 1 Recent Task
+        val pairPackages = RECENT_SPLIT_PACKAGES_1.split("_")
+        val recentTaskPackages = listOf(RECENT_PACKAGE_1)
+        val expectedPackages = listOf(pairPackages, recentTaskPackages)
+        assertThat(shownPackages).containsExactlyElementsIn(expectedPackages)
+    }
+
+    private fun prepareHotseatAndRunningAndRecentApps(
+        hotseatPackages: List<String>,
+        runningTaskPackages: List<String>,
+        minimizedTaskIndices: Set<Int> = emptySet(),
+        recentTaskPackages: List<String>,
+    ): Array<ItemInfo?> {
+        val hotseatItems = createHotseatItemsFromPackageNames(hotseatPackages)
+        val newHotseatItems =
+            recentAppsController.updateHotseatItemInfos(hotseatItems.toTypedArray())
+        val runningTasks = createDesktopTask(runningTaskPackages, minimizedTaskIndices)
+        val recentTasks = createRecentTasksFromPackageNames(recentTaskPackages)
+        val allTasks =
+            ArrayList<GroupTask>().apply {
+                if (runningTasks != null) {
+                    add(runningTasks)
+                }
+                addAll(recentTasks)
+            }
+        doAnswer {
+                val callback: Consumer<ArrayList<GroupTask>> = it.getArgument(0)
+                callback.accept(allTasks)
+                taskListChangeId
+            }
+            .whenever(mockRecentsModel)
+            .getTasks(any<Consumer<List<GroupTask>>>())
+        recentTasksChangedListener.onRecentTasksChanged()
+        return newHotseatItems
     }
 
     private fun createHotseatItemsFromPackageNames(packageNames: List<String>): List<ItemInfo> {
-        return packageNames.map { createTestAppInfo(packageName = it) }
-    }
-
-    private fun createDesktopTasksFromPackageNames(
-        packageNames: List<String>
-    ): ArrayList<RunningTaskInfo> {
-        return ArrayList(packageNames.map { createDesktopTaskInfo(packageName = it) })
-    }
-
-    private fun createDesktopTaskInfo(
-        packageName: String,
-        init: RunningTaskInfo.() -> Unit = { isVisible = true },
-    ): RunningTaskInfo {
-        return RunningTaskInfo().apply {
-            taskId = nextTaskId++
-            configuration.windowConfiguration.windowingMode = WINDOWING_MODE_FREEFORM
-            realActivity = ComponentName(packageName, "TestActivity")
-            init()
+        return packageNames.map {
+            createTestAppInfo(packageName = it).apply {
+                container =
+                    if (it.startsWith("predicted")) {
+                        CONTAINER_HOTSEAT_PREDICTION
+                    } else {
+                        CONTAINER_HOTSEAT
+                    }
+            }
         }
     }
 
@@ -220,23 +525,67 @@
         className: String = "testClassName"
     ) = AppInfo(ComponentName(packageName, className), className /* title */, userHandle, Intent())
 
+    private fun createDesktopTask(
+        packageNames: List<String>,
+        minimizedTaskIndices: Set<Int>
+    ): DesktopTask? {
+        if (packageNames.isEmpty()) return null
+
+        return DesktopTask(
+            ArrayList(
+                packageNames.mapIndexed { index, packageName ->
+                    createTask(packageName, index !in minimizedTaskIndices)
+                }
+            )
+        )
+    }
+
+    private fun createRecentTasksFromPackageNames(packageNames: List<String>): List<GroupTask> {
+        return packageNames.map {
+            if (it.startsWith("split")) {
+                val splitPackages = it.split("_")
+                GroupTask(
+                    createTask(splitPackages[0]),
+                    createTask(splitPackages[1]),
+                    /* splitBounds = */ null
+                )
+            } else {
+                GroupTask(createTask(it))
+            }
+        }
+    }
+
+    private fun createTask(packageName: String, isVisible: Boolean = true): Task {
+        return Task(
+                Task.TaskKey(
+                    nextTaskId++,
+                    WINDOWING_MODE_FREEFORM,
+                    Intent().apply { `package` = packageName },
+                    ComponentName(packageName, "TestActivity"),
+                    userHandle.identifier,
+                    0
+                )
+            )
+            .apply { this.isVisible = isVisible }
+    }
+
     private fun setInDesktopMode(inDesktopMode: Boolean) {
         whenever(mockDesktopVisibilityController.areDesktopTasksVisible()).thenReturn(inDesktopMode)
     }
 
+    private val GroupTask.packageNames: List<String>
+        get() = tasks.map { task -> task.key.packageName }
+
     private companion object {
         const val HOTSEAT_PACKAGE_1 = "hotseat1"
         const val HOTSEAT_PACKAGE_2 = "hotseat2"
+        const val PREDICTED_PACKAGE_1 = "predicted1"
         const val RUNNING_APP_PACKAGE_1 = "running1"
         const val RUNNING_APP_PACKAGE_2 = "running2"
         const val RUNNING_APP_PACKAGE_3 = "running3"
-        val ALL_APP_PACKAGES =
-            listOf(
-                HOTSEAT_PACKAGE_1,
-                HOTSEAT_PACKAGE_2,
-                RUNNING_APP_PACKAGE_1,
-                RUNNING_APP_PACKAGE_2,
-                RUNNING_APP_PACKAGE_3,
-            )
+        const val RECENT_PACKAGE_1 = "recent1"
+        const val RECENT_PACKAGE_2 = "recent2"
+        const val RECENT_PACKAGE_3 = "recent3"
+        const val RECENT_SPLIT_PACKAGES_1 = "split1_split2"
     }
 }
diff --git a/quickstep/tests/src/com/android/quickstep/RecentTasksListTest.java b/quickstep/tests/src/com/android/quickstep/RecentTasksListTest.java
index 03244eb..ce16b70 100644
--- a/quickstep/tests/src/com/android/quickstep/RecentTasksListTest.java
+++ b/quickstep/tests/src/com/android/quickstep/RecentTasksListTest.java
@@ -32,6 +32,8 @@
 
 import com.android.launcher3.util.LooperExecutor;
 import com.android.quickstep.util.GroupTask;
+import com.android.quickstep.views.TaskView;
+import com.android.systemui.shared.recents.model.Task;
 import com.android.wm.shell.util.GroupedRecentTaskInfo;
 
 import org.junit.Before;
@@ -40,8 +42,11 @@
 import org.mockito.MockitoAnnotations;
 
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
 
 @SmallTest
 public class RecentTasksListTest {
@@ -104,4 +109,52 @@
         assertEquals(taskDescription, taskList.get(0).task1.taskDescription.getLabel());
         assertNull(taskList.get(0).task2.taskDescription.getLabel());
     }
+
+    @Test
+    public void loadTasksInBackground_freeformTask_createsDesktopTask() {
+        ActivityManager.RecentTaskInfo[] tasks = {
+                createRecentTaskInfo(1 /* taskId */),
+                createRecentTaskInfo(4 /* taskId */),
+                createRecentTaskInfo(5 /* taskId */)};
+        GroupedRecentTaskInfo recentTaskInfos = GroupedRecentTaskInfo.forFreeformTasks(
+                tasks, Collections.emptySet() /* minimizedTaskIds */);
+        when(mockSystemUiProxy.getRecentTasks(anyInt(), anyInt()))
+                .thenReturn(new ArrayList<>(Collections.singletonList(recentTaskInfos)));
+
+        List<GroupTask> taskList = mRecentTasksList.loadTasksInBackground(
+                Integer.MAX_VALUE /* numTasks */, -1 /* requestId */, false /* loadKeysOnly */);
+
+        assertEquals(1, taskList.size());
+        assertEquals(TaskView.Type.DESKTOP, taskList.get(0).taskViewType);
+        List<Task> actualFreeformTasks = taskList.get(0).getTasks();
+        assertEquals(3, actualFreeformTasks.size());
+        assertEquals(1, actualFreeformTasks.get(0).key.id);
+        assertEquals(4, actualFreeformTasks.get(1).key.id);
+        assertEquals(5, actualFreeformTasks.get(2).key.id);
+    }
+
+    @Test
+    public void loadTasksInBackground_freeformTask_onlyMinimizedTasks_doesNotCreateDesktopTask() {
+        ActivityManager.RecentTaskInfo[] tasks = {
+                createRecentTaskInfo(1 /* taskId */),
+                createRecentTaskInfo(4 /* taskId */),
+                createRecentTaskInfo(5 /* taskId */)};
+        Set<Integer> minimizedTaskIds =
+                Arrays.stream(new Integer[]{1, 4, 5}).collect(Collectors.toSet());
+        GroupedRecentTaskInfo recentTaskInfos =
+                GroupedRecentTaskInfo.forFreeformTasks(tasks, minimizedTaskIds);
+        when(mockSystemUiProxy.getRecentTasks(anyInt(), anyInt()))
+                .thenReturn(new ArrayList<>(Collections.singletonList(recentTaskInfos)));
+
+        List<GroupTask> taskList = mRecentTasksList.loadTasksInBackground(
+                Integer.MAX_VALUE /* numTasks */, -1 /* requestId */, false /* loadKeysOnly */);
+
+        assertEquals(0, taskList.size());
+    }
+
+    private ActivityManager.RecentTaskInfo createRecentTaskInfo(int taskId) {
+        ActivityManager.RecentTaskInfo recentTaskInfo = new ActivityManager.RecentTaskInfo();
+        recentTaskInfo.taskId = taskId;
+        return recentTaskInfo;
+    }
 }
diff --git a/quickstep/tests/src/com/android/quickstep/TaplTestsQuickstep.java b/quickstep/tests/src/com/android/quickstep/TaplTestsQuickstep.java
index 7877e8a..1dfab26 100644
--- a/quickstep/tests/src/com/android/quickstep/TaplTestsQuickstep.java
+++ b/quickstep/tests/src/com/android/quickstep/TaplTestsQuickstep.java
@@ -401,6 +401,7 @@
     @Test
     @NavigationModeSwitch
     @PortraitLandscape
+    @TestStabilityRule.Stability(flavors = LOCAL | PLATFORM_POSTSUBMIT) // b/325659406
     public void testQuickSwitchFromHome() throws Exception {
         startTestActivity(2);
         mLauncher.goHome().quickSwitchToPreviousApp();
diff --git a/quickstep/tests/src/com/android/quickstep/TaskAnimationManagerTest.java b/quickstep/tests/src/com/android/quickstep/TaskAnimationManagerTest.java
new file mode 100644
index 0000000..2d79623
--- /dev/null
+++ b/quickstep/tests/src/com/android/quickstep/TaskAnimationManagerTest.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.quickstep;
+
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assume.assumeTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import android.app.ActivityOptions;
+import android.content.Context;
+import android.content.Intent;
+
+import androidx.test.filters.SmallTest;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+@SmallTest
+public class TaskAnimationManagerTest {
+
+    @Mock
+    private Context mContext;
+
+    @Mock
+    private SystemUiProxy mSystemUiProxy;
+
+    private TaskAnimationManager mTaskAnimationManager;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+        mTaskAnimationManager = new TaskAnimationManager(mContext) {
+            @Override
+            SystemUiProxy getSystemUiProxy() {
+                return mSystemUiProxy;
+            }
+        };
+    }
+
+    @Test
+    public void startRecentsActivity_allowBackgroundLaunch() {
+        assumeTrue(TaskAnimationManager.ENABLE_SHELL_TRANSITIONS);
+
+        final LauncherActivityInterface activityInterface = mock(LauncherActivityInterface.class);
+        final GestureState gestureState = mock(GestureState.class);
+        final RecentsAnimationCallbacks.RecentsAnimationListener listener =
+                mock(RecentsAnimationCallbacks.RecentsAnimationListener.class);
+        doReturn(activityInterface).when(gestureState).getContainerInterface();
+        mTaskAnimationManager.startRecentsAnimation(gestureState, new Intent(), listener);
+
+        final ArgumentCaptor<ActivityOptions> optionsCaptor =
+                ArgumentCaptor.forClass(ActivityOptions.class);
+        verify(mSystemUiProxy).startRecentsActivity(any(), optionsCaptor.capture(), any());
+        assertTrue(optionsCaptor.getValue()
+                .isPendingIntentBackgroundActivityLaunchAllowedByPermission());
+    }
+}
diff --git a/res/layout/bubble_bar_overflow_button.xml b/res/layout/bubble_bar_overflow_button.xml
new file mode 100644
index 0000000..cb54990
--- /dev/null
+++ b/res/layout/bubble_bar_overflow_button.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2024 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.taskbar.bubbles.BubbleView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/bubble_overflow_button"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"/>
\ No newline at end of file
diff --git a/res/values-fa/strings.xml b/res/values-fa/strings.xml
index b39945b..27ce075 100644
--- a/res/values-fa/strings.xml
+++ b/res/values-fa/strings.xml
@@ -67,7 +67,7 @@
     <string name="widget_category_note_taking" msgid="3469689394504266039">"یادداشت‌برداری"</string>
     <string name="widget_add_button_label" msgid="2761267068711937179">"افزودن"</string>
     <string name="widget_add_button_content_description" msgid="1810530016360039643">"افزودن ابزارک <xliff:g id="WIDGET_NAME">%1$s</xliff:g>"</string>
-    <string name="reconfigurable_widget_education_tip" msgid="6336962690888067057">"برای تغییر تنظیمات ابزارک، ضربه بزنید"</string>
+    <string name="reconfigurable_widget_education_tip" msgid="6336962690888067057">"برای تغییر تنظیمات ابزارک، تک‌ضرب بزنید"</string>
     <string name="widget_reconfigure_button_content_description" msgid="8811472721881205250">"تغییر تنظیمات ابزارک"</string>
     <string name="all_apps_search_bar_hint" msgid="1390553134053255246">"جستجوی برنامه‌ها"</string>
     <string name="all_apps_loading_message" msgid="5813968043155271636">"درحال بارگیری برنامه‌‌ها…"</string>
@@ -99,7 +99,7 @@
     <string name="permdesc_write_settings" msgid="726859348127868466">"به برنامه اجازه می‌دهد تنظیمات و میان‌برهای صفحه اصلی را تغییر دهد."</string>
     <string name="gadget_error_text" msgid="740356548025791839">"ابزارک را نمی‌توان بار کرد"</string>
     <string name="gadget_setup_text" msgid="8348374825537681407">"تنظیمات ابزارک"</string>
-    <string name="gadget_complete_setup_text" msgid="309040266978007925">"برای تکمیل راه‌اندازی ضربه بزنید"</string>
+    <string name="gadget_complete_setup_text" msgid="309040266978007925">"برای تکمیل راه‌اندازی تک‌ضرب بزنید"</string>
     <string name="uninstall_system_app_text" msgid="4172046090762920660">"این برنامه سیستمی است و حذف نصب نمی‌شود."</string>
     <string name="folder_hint_text" msgid="5174843001373488816">"ویرایش نام"</string>
     <string name="disabled_app_label" msgid="6673129024321402780">"<xliff:g id="APP_NAME">%1$s</xliff:g> غیرفعال شد"</string>
@@ -108,8 +108,8 @@
     <string name="workspace_scroll_format" msgid="8458889198184077399">"‏صفحه اصلی %1$d از %2$d"</string>
     <string name="workspace_new_page" msgid="257366611030256142">"صفحه اصلی جدید"</string>
     <string name="folder_opened" msgid="94695026776264709">"پوشه باز شده، <xliff:g id="WIDTH">%1$d</xliff:g> در <xliff:g id="HEIGHT">%2$d</xliff:g>"</string>
-    <string name="folder_tap_to_close" msgid="4625795376335528256">"برای بستن پوشه، ضربه بزنید"</string>
-    <string name="folder_tap_to_rename" msgid="4017685068016979677">"برای ذخیره تغییر نام، ضربه بزنید"</string>
+    <string name="folder_tap_to_close" msgid="4625795376335528256">"برای بستن پوشه، تک‌ضرب بزنید"</string>
+    <string name="folder_tap_to_rename" msgid="4017685068016979677">"برای ذخیره تغییر نام، تک‌ضرب بزنید"</string>
     <string name="folder_closed" msgid="4100806530910930934">"پوشه بسته شد"</string>
     <string name="folder_renamed" msgid="1794088362165669656">"نام پوشه به <xliff:g id="NAME">%1$s</xliff:g> تغییر کرد"</string>
     <string name="folder_name_format_exact" msgid="8626242716117004803">"پوشه: <xliff:g id="NAME">%1$s</xliff:g>، <xliff:g id="SIZE">%2$d</xliff:g> مورد"</string>
@@ -139,7 +139,7 @@
     <string name="app_installing_title" msgid="5864044122733792085">"<xliff:g id="NAME">%1$s</xliff:g> درحال نصب است، <xliff:g id="PROGRESS">%2$s</xliff:g> تکمیل شده است"</string>
     <string name="app_downloading_title" msgid="8336702962104482644">"درحال بارگیری <xliff:g id="NAME">%1$s</xliff:g>، <xliff:g id="PROGRESS">%2$s</xliff:g> کامل شد"</string>
     <string name="app_waiting_download_title" msgid="7053938513995617849">"<xliff:g id="NAME">%1$s</xliff:g> درانتظار نصب"</string>
-    <string name="app_archived_title" msgid="7717956158562544081">"<xliff:g id="NAME">%1$s</xliff:g> بایگانی شده است. برای بارگیری و بازیابی ضربه بزنید."</string>
+    <string name="app_archived_title" msgid="7717956158562544081">"<xliff:g id="NAME">%1$s</xliff:g> بایگانی شده است. برای بارگیری و بازیابی تک‌ضرب بزنید."</string>
     <string name="dialog_update_title" msgid="114234265740994042">"برنامه باید به‌روز شود"</string>
     <string name="dialog_update_message" msgid="4176784553982226114">"برنامه برای این نماد به‌روز نشده است. می‌توانید آن را به‌صورت دستی به‌روز کنید تا میان‌بر دوباره فعال شود، یا نماد را بردارید."</string>
     <string name="dialog_update" msgid="2178028071796141234">"به‌روزرسانی"</string>
@@ -187,7 +187,7 @@
     <string name="developer_options_filter_hint" msgid="5896817443635989056">"فیلتر"</string>
     <string name="remote_action_failed" msgid="1383965239183576790">"ناموفق بود: <xliff:g id="WHAT">%1$s</xliff:g>"</string>
     <string name="private_space_label" msgid="2359721649407947001">"فضای خصوصی"</string>
-    <string name="private_space_secondary_label" msgid="9203933341714508907">"برای راه‌اندازی یا باز کردن، ضربه بزنید"</string>
+    <string name="private_space_secondary_label" msgid="9203933341714508907">"برای راه‌اندازی یا باز کردن، تک‌ضرب بزنید"</string>
     <string name="ps_container_title" msgid="4391796149519594205">"خصوصی"</string>
     <string name="ps_container_settings" msgid="6059734123353320479">"تنظیمات «فضای خصوصی»"</string>
     <string name="ps_container_unlock_button_content_description" msgid="9181551784092204234">"خصوصی، باز."</string>
diff --git a/src/com/android/launcher3/BubbleTextView.java b/src/com/android/launcher3/BubbleTextView.java
index 7d09164..83427a0 100644
--- a/src/com/android/launcher3/BubbleTextView.java
+++ b/src/com/android/launcher3/BubbleTextView.java
@@ -430,10 +430,21 @@
         setDownloadStateContentDescription(info, info.getProgressLevel());
     }
 
+    /**
+     * Directly set the icon and label.
+     */
+    @UiThread
+    public void applyIconAndLabel(Drawable icon, CharSequence label) {
+        applyCompoundDrawables(icon);
+        setText(label);
+        setContentDescription(label);
+    }
+
     /** Updates whether the app this view represents is currently running. */
     @UiThread
     public void updateRunningState(RunningAppState runningAppState) {
         mRunningAppState = runningAppState;
+        invalidate();
     }
 
     protected void setItemInfo(ItemInfoWithIcon itemInfo) {
@@ -1291,13 +1302,4 @@
     public boolean canShowLongPressPopup() {
         return getTag() instanceof ItemInfo && ShortcutUtil.supportsShortcuts((ItemInfo) getTag());
     }
-
-    /** Returns the package name of the app this icon represents. */
-    public String getTargetPackageName() {
-        Object tag = getTag();
-        if (tag instanceof ItemInfo itemInfo) {
-            return itemInfo.getTargetPackage();
-        }
-        return null;
-    }
 }
diff --git a/src/com/android/launcher3/Hotseat.java b/src/com/android/launcher3/Hotseat.java
index 8546454..f775673 100644
--- a/src/com/android/launcher3/Hotseat.java
+++ b/src/com/android/launcher3/Hotseat.java
@@ -97,10 +97,9 @@
         if (bubbleBarEnabled) {
             float adjustedBorderSpace = dp.getHotseatAdjustedBorderSpaceForBubbleBar(getContext());
             if (hasBubbles && Float.compare(adjustedBorderSpace, 0f) != 0) {
-                getShortcutsAndWidgets().setTranslationProvider(child -> {
-                    int index = getShortcutsAndWidgets().indexOfChild(child);
+                getShortcutsAndWidgets().setTranslationProvider(cellX -> {
                     float borderSpaceDelta = adjustedBorderSpace - dp.hotseatBorderSpace;
-                    return dp.iconSizePx + index * borderSpaceDelta;
+                    return dp.iconSizePx + cellX * borderSpaceDelta;
                 });
                 if (mQsb instanceof HorizontalInsettableView) {
                     HorizontalInsettableView insettableQsb = (HorizontalInsettableView) mQsb;
@@ -147,10 +146,7 @@
 
         // update the translation provider for future layout passes of hotseat icons.
         if (isBubbleBarVisible) {
-            icons.setTranslationProvider(child -> {
-                int index = icons.indexOfChild(child);
-                return dp.iconSizePx + index * borderSpaceDelta;
-            });
+            icons.setTranslationProvider(cellX -> dp.iconSizePx + cellX * borderSpaceDelta);
         } else {
             icons.setTranslationProvider(null);
         }
diff --git a/src/com/android/launcher3/Launcher.java b/src/com/android/launcher3/Launcher.java
index 4e566ab..d905801 100644
--- a/src/com/android/launcher3/Launcher.java
+++ b/src/com/android/launcher3/Launcher.java
@@ -2797,9 +2797,11 @@
     }
 
     private void updateDisallowBack() {
-        if (BuildCompat.isAtLeastV() && Flags.enableDesktopWindowingMode()
-            && mDeviceProfile.isTablet) {
-            // TODO(b/330183377) disable back in launcher when when we productionize
+        if (BuildCompat.isAtLeastV()
+                && Flags.enableDesktopWindowingMode()
+                && !Flags.enableDesktopWindowingWallpaperActivity()
+                && mDeviceProfile.isTablet) {
+            // TODO(b/333533253): Clean up after desktop wallpaper activity flag is rolled out
             return;
         }
         LauncherRootView rv = getRootView();
diff --git a/src/com/android/launcher3/LauncherAppState.java b/src/com/android/launcher3/LauncherAppState.java
index 3b8ff62..239967d 100644
--- a/src/com/android/launcher3/LauncherAppState.java
+++ b/src/com/android/launcher3/LauncherAppState.java
@@ -116,13 +116,13 @@
 
         SimpleBroadcastReceiver modelChangeReceiver =
                 new SimpleBroadcastReceiver(mModel::onBroadcastIntent);
-        modelChangeReceiver.register(mContext, Intent.ACTION_LOCALE_CHANGED,
+        modelChangeReceiver.registerAsync(mContext, Intent.ACTION_LOCALE_CHANGED,
                 ACTION_DEVICE_POLICY_RESOURCE_UPDATED);
         if (BuildConfig.IS_STUDIO_BUILD) {
             mContext.registerReceiver(modelChangeReceiver, new IntentFilter(ACTION_FORCE_ROLOAD),
                     RECEIVER_EXPORTED);
         }
-        mOnTerminateCallback.add(() -> mContext.unregisterReceiver(modelChangeReceiver));
+        mOnTerminateCallback.add(() -> modelChangeReceiver.unregisterReceiverSafelyAsync(mContext));
 
         SafeCloseable userChangeListener = UserCache.INSTANCE.get(mContext)
                 .addUserEventListener(mModel::onUserEvent);
diff --git a/src/com/android/launcher3/ShortcutAndWidgetContainer.java b/src/com/android/launcher3/ShortcutAndWidgetContainer.java
index 7484b64..d2c3c78 100644
--- a/src/com/android/launcher3/ShortcutAndWidgetContainer.java
+++ b/src/com/android/launcher3/ShortcutAndWidgetContainer.java
@@ -245,7 +245,7 @@
         }
         child.layout(childLeft, childTop, childLeft + lp.width, childTop + lp.height);
         if (mTranslationProvider != null) {
-            final float tx = mTranslationProvider.getTranslationX(child);
+            final float tx = mTranslationProvider.getTranslationX(lp.getCellX());
             if (child instanceof Reorderable) {
                 ((Reorderable) child).getTranslateDelegate()
                         .getTranslationX(INDEX_BUBBLE_ADJUSTMENT_ANIM)
@@ -330,6 +330,6 @@
 
     /** Provides translation values to apply when laying out child views. */
     interface TranslationProvider {
-        float getTranslationX(View child);
+        float getTranslationX(int cellX);
     }
 }
diff --git a/src/com/android/launcher3/allapps/AllAppsRecyclerView.java b/src/com/android/launcher3/allapps/AllAppsRecyclerView.java
index ba34f59..2a47222 100644
--- a/src/com/android/launcher3/allapps/AllAppsRecyclerView.java
+++ b/src/com/android/launcher3/allapps/AllAppsRecyclerView.java
@@ -305,9 +305,7 @@
 
     @Override
     public int getScrollBarTop() {
-        return ActivityContext.lookupContext(getContext()).getAppsView().isSearchSupported()
-                ? getResources().getDimensionPixelOffset(R.dimen.all_apps_header_top_padding)
-                : 0;
+        return getResources().getDimensionPixelOffset(R.dimen.all_apps_header_top_padding);
     }
 
     @Override
diff --git a/src/com/android/launcher3/config/FeatureFlags.java b/src/com/android/launcher3/config/FeatureFlags.java
index 33e6f91..d0596fa 100644
--- a/src/com/android/launcher3/config/FeatureFlags.java
+++ b/src/com/android/launcher3/config/FeatureFlags.java
@@ -19,6 +19,7 @@
 import static com.android.launcher3.config.FeatureFlags.BooleanFlag.DISABLED;
 import static com.android.launcher3.config.FeatureFlags.BooleanFlag.ENABLED;
 import static com.android.wm.shell.Flags.enableTaskbarNavbarUnification;
+import static com.android.wm.shell.Flags.enableTaskbarOnPhones;
 
 import android.content.res.Resources;
 
@@ -143,7 +144,7 @@
             DISABLED, "Sends a notification whenever launcher encounters an uncaught exception.");
 
     public static final boolean ENABLE_TASKBAR_NAVBAR_UNIFICATION =
-            enableTaskbarNavbarUnification() && !isPhone();
+            enableTaskbarNavbarUnification() && (!isPhone() || enableTaskbarOnPhones());
 
     private static boolean isPhone() {
         final boolean isPhone;
diff --git a/src/com/android/launcher3/model/LoaderTask.java b/src/com/android/launcher3/model/LoaderTask.java
index dc6968c..312c6f4 100644
--- a/src/com/android/launcher3/model/LoaderTask.java
+++ b/src/com/android/launcher3/model/LoaderTask.java
@@ -209,7 +209,7 @@
                 mApp.getContext().getContentResolver(),
                 "launcher_broadcast_installed_apps",
                 /* def= */ 0);
-        if (launcherBroadcastInstalledApps == 1) {
+        if (launcherBroadcastInstalledApps == 1 && mIsRestoreFromBackup) {
             List<FirstScreenBroadcastModel> broadcastModels =
                     FirstScreenBroadcastHelper.createModelsForFirstScreenBroadcast(
                             mPmHelper,
diff --git a/src/com/android/launcher3/pm/UserCache.java b/src/com/android/launcher3/pm/UserCache.java
index ed25186..cf03462 100644
--- a/src/com/android/launcher3/pm/UserCache.java
+++ b/src/com/android/launcher3/pm/UserCache.java
@@ -93,12 +93,12 @@
 
     @Override
     public void close() {
-        MODEL_EXECUTOR.execute(() -> mUserChangeReceiver.unregisterReceiverSafely(mContext));
+        MODEL_EXECUTOR.execute(() -> mUserChangeReceiver.unregisterReceiverSafelySync(mContext));
     }
 
     @WorkerThread
     private void initAsync() {
-        mUserChangeReceiver.register(mContext,
+        mUserChangeReceiver.registerSync(mContext,
                 Intent.ACTION_MANAGED_PROFILE_AVAILABLE,
                 Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE,
                 Intent.ACTION_MANAGED_PROFILE_REMOVED,
diff --git a/src/com/android/launcher3/touch/ItemClickHandler.java b/src/com/android/launcher3/touch/ItemClickHandler.java
index f46dcd3..78709b8 100644
--- a/src/com/android/launcher3/touch/ItemClickHandler.java
+++ b/src/com/android/launcher3/touch/ItemClickHandler.java
@@ -46,7 +46,6 @@
 import com.android.launcher3.Launcher;
 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;
@@ -67,10 +66,7 @@
 import com.android.launcher3.util.ApiWrapper;
 import com.android.launcher3.util.ItemInfoMatcher;
 import com.android.launcher3.views.FloatingIconView;
-import com.android.launcher3.views.Snackbar;
 import com.android.launcher3.widget.LauncherAppWidgetProviderInfo;
-import com.android.launcher3.widget.PendingAddShortcutInfo;
-import com.android.launcher3.widget.PendingAddWidgetInfo;
 import com.android.launcher3.widget.PendingAppWidgetHostView;
 import com.android.launcher3.widget.WidgetAddFlowHandler;
 import com.android.launcher3.widget.WidgetManagerHelper;
@@ -127,20 +123,6 @@
             }
         } else if (tag instanceof ItemClickProxy) {
             ((ItemClickProxy) tag).onItemClicked(v);
-        } else if (tag instanceof PendingAddShortcutInfo) {
-            CharSequence msg = Utilities.wrapForTts(
-                    launcher.getText(R.string.long_press_shortcut_to_add),
-                    launcher.getString(R.string.long_accessible_way_to_add_shortcut));
-            Snackbar.show(launcher, msg, null);
-        } else if (tag instanceof PendingAddWidgetInfo) {
-            if (DEBUG) {
-                String targetPackage = ((PendingAddWidgetInfo) tag).getTargetPackage();
-                Log.d(TAG, "onClick: PendingAddWidgetInfo clicked for package=" + targetPackage);
-            }
-            CharSequence msg = Utilities.wrapForTts(
-                    launcher.getText(R.string.long_press_widget_to_add),
-                    launcher.getString(R.string.long_accessible_way_to_add));
-            Snackbar.show(launcher, msg, null);
         }
     }
 
diff --git a/src/com/android/launcher3/util/DisplayController.java b/src/com/android/launcher3/util/DisplayController.java
index 16fabe2..3dcc663 100644
--- a/src/com/android/launcher3/util/DisplayController.java
+++ b/src/com/android/launcher3/util/DisplayController.java
@@ -132,11 +132,11 @@
             mWindowContext.registerComponentCallbacks(this);
         } else {
             mWindowContext = null;
-            mReceiver.register(mContext, ACTION_CONFIGURATION_CHANGED);
+            mReceiver.registerAsync(mContext, ACTION_CONFIGURATION_CHANGED);
         }
 
         // Initialize navigation mode change listener
-        mReceiver.registerPkgActions(mContext, TARGET_OVERLAY_PACKAGE, ACTION_OVERLAY_CHANGED);
+        mReceiver.registerPkgActionsAsync(mContext, TARGET_OVERLAY_PACKAGE, ACTION_OVERLAY_CHANGED);
 
         WindowManagerProxy wmProxy = WindowManagerProxy.INSTANCE.get(context);
         Context displayInfoContext = getDisplayInfoContext(display);
@@ -182,6 +182,11 @@
         return INSTANCE.get(context).getInfo().isTransientTaskbar();
     }
 
+    /** Returns whether we are currently in Desktop mode. */
+    public static boolean isInDesktopMode(Context context) {
+        return INSTANCE.get(context).getInfo().isInDesktopMode();
+    }
+
     /**
      * Handles info change for desktop mode.
      */
@@ -218,6 +223,7 @@
         } else {
             // TODO: unregister broadcast receiver
         }
+        mReceiver.unregisterReceiverSafelyAsync(mContext);
     }
 
     /**
diff --git a/src/com/android/launcher3/util/LockedUserState.kt b/src/com/android/launcher3/util/LockedUserState.kt
index 94f9e4f..2737249 100644
--- a/src/com/android/launcher3/util/LockedUserState.kt
+++ b/src/com/android/launcher3/util/LockedUserState.kt
@@ -25,6 +25,7 @@
     val isUserUnlockedAtLauncherStartup: Boolean
     var isUserUnlocked: Boolean
         private set
+
     private val mUserUnlockedActions: RunnableList = RunnableList()
 
     @VisibleForTesting
@@ -50,22 +51,18 @@
         if (isUserUnlocked) {
             notifyUserUnlocked()
         } else {
-            mUserUnlockedReceiver.register(mContext, Intent.ACTION_USER_UNLOCKED)
+            mUserUnlockedReceiver.registerAsync(mContext, Intent.ACTION_USER_UNLOCKED)
         }
     }
 
     private fun notifyUserUnlocked() {
         mUserUnlockedActions.executeAllAndDestroy()
-        Executors.THREAD_POOL_EXECUTOR.execute {
-            mUserUnlockedReceiver.unregisterReceiverSafely(mContext)
-        }
+        mUserUnlockedReceiver.unregisterReceiverSafelyAsync(mContext)
     }
 
     /** Stops the receiver from listening for ACTION_USER_UNLOCK broadcasts. */
     override fun close() {
-        Executors.THREAD_POOL_EXECUTOR.execute {
-            mUserUnlockedReceiver.unregisterReceiverSafely(mContext)
-        }
+        mUserUnlockedReceiver.unregisterReceiverSafelyAsync(mContext)
     }
 
     /**
diff --git a/src/com/android/launcher3/util/ScreenOnTracker.java b/src/com/android/launcher3/util/ScreenOnTracker.java
index e16e477..c1d192c 100644
--- a/src/com/android/launcher3/util/ScreenOnTracker.java
+++ b/src/com/android/launcher3/util/ScreenOnTracker.java
@@ -42,12 +42,12 @@
         // Assume that the screen is on to begin with
         mContext = context;
         mIsScreenOn = true;
-        mReceiver.register(context, ACTION_SCREEN_ON, ACTION_SCREEN_OFF, ACTION_USER_PRESENT);
+        mReceiver.registerAsync(context, ACTION_SCREEN_ON, ACTION_SCREEN_OFF, ACTION_USER_PRESENT);
     }
 
     @Override
     public void close() {
-        mReceiver.unregisterReceiverSafely(mContext);
+        mReceiver.unregisterReceiverSafelyAsync(mContext);
     }
 
     private void onReceive(Intent intent) {
diff --git a/src/com/android/launcher3/util/SettingsCache.java b/src/com/android/launcher3/util/SettingsCache.java
index ccd154a..cd6701d 100644
--- a/src/com/android/launcher3/util/SettingsCache.java
+++ b/src/com/android/launcher3/util/SettingsCache.java
@@ -18,6 +18,8 @@
 
 import static android.provider.Settings.System.ACCELEROMETER_ROTATION;
 
+import static com.android.launcher3.util.Executors.UI_HELPER_EXECUTOR;
+
 import android.content.ContentResolver;
 import android.content.Context;
 import android.database.ContentObserver;
@@ -87,7 +89,7 @@
 
     @Override
     public void close() {
-        mResolver.unregisterContentObserver(this);
+        UI_HELPER_EXECUTOR.execute(() -> mResolver.unregisterContentObserver(this));
     }
 
     @Override
@@ -135,7 +137,8 @@
             CopyOnWriteArrayList<OnChangeListener> l = new CopyOnWriteArrayList<>();
             l.add(changeListener);
             mListenerMap.put(uri, l);
-            mResolver.registerContentObserver(uri, false, this);
+            UI_HELPER_EXECUTOR.execute(
+                    () -> mResolver.registerContentObserver(uri, false, this));
         }
     }
 
diff --git a/src/com/android/launcher3/util/SimpleBroadcastReceiver.java b/src/com/android/launcher3/util/SimpleBroadcastReceiver.java
index 064bcd0..5f39cce 100644
--- a/src/com/android/launcher3/util/SimpleBroadcastReceiver.java
+++ b/src/com/android/launcher3/util/SimpleBroadcastReceiver.java
@@ -15,14 +15,21 @@
  */
 package com.android.launcher3.util;
 
+import static com.android.launcher3.util.Executors.UI_HELPER_EXECUTOR;
+
 import android.content.BroadcastReceiver;
 import android.content.Context;
 import android.content.Intent;
 import android.content.IntentFilter;
+import android.os.Looper;
 import android.os.PatternMatcher;
 import android.text.TextUtils;
 
 import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import androidx.annotation.WorkerThread;
+
+import com.android.launcher3.BuildConfig;
 
 import java.util.function.Consumer;
 
@@ -39,21 +46,63 @@
         mIntentConsumer.accept(intent);
     }
 
-    /**
-     * Helper method to register multiple actions
-     */
-    public void register(Context context, String... actions) {
+    /** Helper method to register multiple actions. Caller should be on main thread. */
+    @UiThread
+    public void registerAsync(Context context, String... actions) {
+        assertOnMainThread();
+        UI_HELPER_EXECUTOR.execute(() -> registerSync(context, actions));
+    }
+
+    /** Helper method to register multiple actions. Caller should be on main thread. */
+    @WorkerThread
+    public void registerSync(Context context, String... actions) {
+        assertOnBgThread();
         context.registerReceiver(this, getFilter(actions));
     }
 
     /**
-     * Helper method to register multiple actions associated with a paction
+     * Helper method to register multiple actions associated with a action. Caller should be from
+     * main thread.
      */
-    public void registerPkgActions(Context context, @Nullable String pkg, String... actions) {
+    @UiThread
+    public void registerPkgActionsAsync(Context context, @Nullable String pkg, String... actions) {
+        assertOnMainThread();
+        UI_HELPER_EXECUTOR.execute(() -> registerPkgActionsSync(context, pkg, actions));
+    }
+
+    /**
+     * Helper method to register multiple actions associated with a action. Caller should be from
+     * bg thread.
+     */
+    @WorkerThread
+    public void registerPkgActionsSync(Context context, @Nullable String pkg, String... actions) {
+        assertOnBgThread();
         context.registerReceiver(this, getPackageFilter(pkg, actions));
     }
 
     /**
+     * Unregisters the receiver ignoring any errors on bg thread. Caller should be on main thread.
+     */
+    @UiThread
+    public void unregisterReceiverSafelyAsync(Context context) {
+        assertOnMainThread();
+        UI_HELPER_EXECUTOR.execute(() -> unregisterReceiverSafelySync(context));
+    }
+
+    /**
+     * Unregisters the receiver ignoring any errors on bg thread. Caller should be on bg thread.
+     */
+    @WorkerThread
+    public void unregisterReceiverSafelySync(Context context) {
+        assertOnBgThread();
+        try {
+            context.unregisterReceiver(this);
+        } catch (IllegalArgumentException e) {
+            // It was probably never registered or already unregistered. Ignore.
+        }
+    }
+
+    /**
      * Creates an intent filter to listen for actions with a specific package in the data field.
      */
     public static IntentFilter getPackageFilter(String pkg, String... actions) {
@@ -73,14 +122,19 @@
         return filter;
     }
 
-    /**
-     * Unregisters the receiver ignoring any errors
-     */
-    public void unregisterReceiverSafely(Context context) {
-        try {
-            context.unregisterReceiver(this);
-        } catch (IllegalArgumentException e) {
-            // It was probably never registered or already unregistered. Ignore.
+    private static void assertOnBgThread() {
+        if (BuildConfig.IS_STUDIO_BUILD && isMainThread()) {
+            throw new IllegalStateException("Should not be called from main thread!");
         }
     }
+
+    private static void assertOnMainThread() {
+        if (BuildConfig.IS_STUDIO_BUILD && !isMainThread()) {
+            throw new IllegalStateException("Should not be called from bg thread!");
+        }
+    }
+
+    private static boolean isMainThread() {
+        return Thread.currentThread() == Looper.getMainLooper().getThread();
+    }
 }
diff --git a/src/com/android/launcher3/util/WallpaperOffsetInterpolator.java b/src/com/android/launcher3/util/WallpaperOffsetInterpolator.java
index b97b889..a2277a0 100644
--- a/src/com/android/launcher3/util/WallpaperOffsetInterpolator.java
+++ b/src/com/android/launcher3/util/WallpaperOffsetInterpolator.java
@@ -198,10 +198,11 @@
     public void setWindowToken(IBinder token) {
         mWindowToken = token;
         if (mWindowToken == null && mRegistered) {
-            mWallpaperChangeReceiver.unregisterReceiverSafely(mWorkspace.getContext());
+            mWallpaperChangeReceiver.unregisterReceiverSafelyAsync(mWorkspace.getContext());
             mRegistered = false;
         } else if (mWindowToken != null && !mRegistered) {
-            mWallpaperChangeReceiver.register(mWorkspace.getContext(), ACTION_WALLPAPER_CHANGED);
+            mWallpaperChangeReceiver.registerAsync(
+                    mWorkspace.getContext(), ACTION_WALLPAPER_CHANGED);
             onWallpaperChanged();
             mRegistered = true;
         }
diff --git a/src/com/android/launcher3/widget/BaseWidgetSheet.java b/src/com/android/launcher3/widget/BaseWidgetSheet.java
index 1368084..c59e295 100644
--- a/src/com/android/launcher3/widget/BaseWidgetSheet.java
+++ b/src/com/android/launcher3/widget/BaseWidgetSheet.java
@@ -331,8 +331,21 @@
      * status bar, into account.
      */
     protected void doMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+        int widthUsed = getInsetsWidth();
+
         DeviceProfile deviceProfile = mActivityContext.getDeviceProfile();
+        measureChildWithMargins(mContent, widthMeasureSpec,
+                widthUsed, heightMeasureSpec, deviceProfile.bottomSheetTopPadding);
+        setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec),
+                MeasureSpec.getSize(heightMeasureSpec));
+    }
+
+    /**
+     * Returns the width used on left and right by the insets / padding.
+     */
+    protected int getInsetsWidth() {
         int widthUsed;
+        DeviceProfile deviceProfile = mActivityContext.getDeviceProfile();
         if (deviceProfile.isTablet) {
             widthUsed = Math.max(2 * getTabletHorizontalMargin(deviceProfile),
                     2 * (mInsets.left + mInsets.right));
@@ -343,11 +356,7 @@
             widthUsed = Math.max(padding.left + padding.right,
                     2 * (mInsets.left + mInsets.right));
         }
-
-        measureChildWithMargins(mContent, widthMeasureSpec,
-                widthUsed, heightMeasureSpec, deviceProfile.bottomSheetTopPadding);
-        setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec),
-                MeasureSpec.getSize(heightMeasureSpec));
+        return widthUsed;
     }
 
     /** Returns the horizontal margins to be applied to the widget sheet. **/
diff --git a/src/com/android/launcher3/widget/LauncherWidgetHolder.java b/src/com/android/launcher3/widget/LauncherWidgetHolder.java
index a5e22c5..1fb8c83 100644
--- a/src/com/android/launcher3/widget/LauncherWidgetHolder.java
+++ b/src/com/android/launcher3/widget/LauncherWidgetHolder.java
@@ -20,6 +20,7 @@
 import static com.android.launcher3.BuildConfig.WIDGETS_ENABLED;
 import static com.android.launcher3.Flags.enableWorkspaceInflation;
 import static com.android.launcher3.util.Executors.MAIN_EXECUTOR;
+import static com.android.launcher3.util.Executors.UI_HELPER_EXECUTOR;
 import static com.android.launcher3.widget.LauncherAppWidgetProviderInfo.fromProviderInfo;
 
 import android.appwidget.AppWidgetHost;
@@ -36,6 +37,7 @@
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
+import androidx.annotation.WorkerThread;
 
 import com.android.launcher3.BaseActivity;
 import com.android.launcher3.BaseDraggingActivity;
@@ -44,6 +46,7 @@
 import com.android.launcher3.model.data.ItemInfo;
 import com.android.launcher3.testing.TestLogging;
 import com.android.launcher3.testing.shared.TestProtocol;
+import com.android.launcher3.util.LooperExecutor;
 import com.android.launcher3.util.ResourceBasedOverride;
 import com.android.launcher3.util.SafeCloseable;
 import com.android.launcher3.widget.LauncherAppWidgetHost.ListenableHostView;
@@ -51,6 +54,7 @@
 
 import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
 import java.util.function.IntConsumer;
 
 /**
@@ -77,7 +81,7 @@
     protected final SparseArray<LauncherAppWidgetHostView> mViews = new SparseArray<>();
     protected final List<ProviderChangedListener> mProviderChangedListeners = new ArrayList<>();
 
-    protected int mFlags = FLAG_STATE_IS_NORMAL;
+    protected AtomicInteger mFlags = new AtomicInteger(FLAG_STATE_IS_NORMAL);
 
     // TODO(b/191735836): Replace with ActivityOptions.KEY_SPLASH_SCREEN_STYLE when un-hidden
     private static final String KEY_SPLASH_SCREEN_STYLE = "android.activity.splashScreenStyle";
@@ -96,6 +100,10 @@
                 context, appWidgetRemovedCallback, mProviderChangedListeners);
     }
 
+    protected LooperExecutor getWidgetHolderExecutor() {
+        return UI_HELPER_EXECUTOR;
+    }
+
     /**
      * Starts listening to the widget updates from the server side
      */
@@ -104,21 +112,23 @@
             return;
         }
 
-        try {
-            mWidgetHost.startListening();
-        } catch (Exception e) {
-            if (!Utilities.isBinderSizeError(e)) {
-                throw new RuntimeException(e);
+        getWidgetHolderExecutor().execute(() -> {
+            try {
+                mWidgetHost.startListening();
+            } catch (Exception e) {
+                if (!Utilities.isBinderSizeError(e)) {
+                    throw new RuntimeException(e);
+                }
+                // We're willing to let this slide. The exception is being caused by the list of
+                // RemoteViews which is being passed back. The startListening relationship will
+                // have been established by this point, and we will end up populating the
+                // widgets upon bind anyway. See issue 14255011 for more context.
             }
-            // We're willing to let this slide. The exception is being caused by the list of
-            // RemoteViews which is being passed back. The startListening relationship will
-            // have been established by this point, and we will end up populating the
-            // widgets upon bind anyway. See issue 14255011 for more context.
-        }
-        // TODO: Investigate why widgetHost.startListening() always return non-empty updates
-        setListeningFlag(true);
+            // TODO: Investigate why widgetHost.startListening() always return non-empty updates
+            setListeningFlag(true);
 
-        updateDeferredView();
+            MAIN_EXECUTOR.execute(() -> updateDeferredView());
+        });
     }
 
     /**
@@ -282,16 +292,23 @@
         if (!WIDGETS_ENABLED) {
             return;
         }
-        mWidgetHost.stopListening();
-        setListeningFlag(false);
+        getWidgetHolderExecutor().execute(() -> {
+            mWidgetHost.stopListening();
+            setListeningFlag(false);
+        });
     }
 
+    /**
+     * Update {@link FLAG_LISTENING} on {@link mFlags} after making binder calls from
+     * {@link sWidgetHost}.
+     */
+    @WorkerThread
     protected void setListeningFlag(final boolean isListening) {
         if (isListening) {
-            mFlags |= FLAG_LISTENING;
+            mFlags.updateAndGet(old -> old | FLAG_LISTENING);
             return;
         }
-        mFlags &= ~FLAG_LISTENING;
+        mFlags.updateAndGet(old -> old & ~FLAG_LISTENING);
     }
 
     /**
@@ -373,7 +390,7 @@
      *      as a result of using the same flow.
      */
     protected LauncherAppWidgetHostView recycleExistingView(LauncherAppWidgetHostView view) {
-        if ((mFlags & FLAG_LISTENING) == 0) {
+        if ((mFlags.get() & FLAG_LISTENING) == 0) {
             if (view instanceof PendingAppWidgetHostView pv && pv.isDeferredWidget()) {
                 return view;
             } else {
@@ -395,7 +412,7 @@
     @NonNull
     protected LauncherAppWidgetHostView createViewInternal(
             int appWidgetId, @NonNull LauncherAppWidgetProviderInfo appWidget) {
-        if ((mFlags & FLAG_LISTENING) == 0) {
+        if ((mFlags.get() & FLAG_LISTENING) == 0) {
             // Since the launcher hasn't started listening to widget updates, we can't simply call
             // host.createView here because the later will make a binder call to retrieve
             // RemoteViews from system process.
@@ -460,7 +477,7 @@
      * @return True if the host is listening to the updates, false otherwise
      */
     public boolean isListening() {
-        return (mFlags & FLAG_LISTENING) != 0;
+        return (mFlags.get() & FLAG_LISTENING) != 0;
     }
 
     /**
@@ -469,16 +486,17 @@
      */
     private void setShouldListenFlag(int flag, boolean on) {
         if (on) {
-            mFlags |= flag;
+            mFlags.updateAndGet(old -> old | flag);
         } else {
-            mFlags &= ~flag;
+            mFlags.updateAndGet(old -> old & ~flag);
         }
 
         final boolean listening = isListening();
-        if (!listening && shouldListen(mFlags)) {
+        int currentFlag = mFlags.get();
+        if (!listening && shouldListen(currentFlag)) {
             // Postpone starting listening until all flags are on.
             startListening();
-        } else if (listening && (mFlags & FLAG_ACTIVITY_STARTED) == 0) {
+        } else if (listening && (currentFlag & FLAG_ACTIVITY_STARTED) == 0) {
             // Postpone stopping listening until the activity is stopped.
             stopListening();
         }
diff --git a/src/com/android/launcher3/widget/picker/WidgetsFullSheet.java b/src/com/android/launcher3/widget/picker/WidgetsFullSheet.java
index 9929892..fd15677 100644
--- a/src/com/android/launcher3/widget/picker/WidgetsFullSheet.java
+++ b/src/com/android/launcher3/widget/picker/WidgetsFullSheet.java
@@ -55,7 +55,6 @@
 
 import com.android.launcher3.BaseActivity;
 import com.android.launcher3.DeviceProfile;
-import com.android.launcher3.LauncherAppState;
 import com.android.launcher3.R;
 import com.android.launcher3.anim.PendingAnimation;
 import com.android.launcher3.compat.AccessibilityManagerCompat;
@@ -416,19 +415,18 @@
 
     @Override
     protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+        int availableWidth = MeasureSpec.getSize(widthMeasureSpec);
+        updateMaxSpansPerRow(availableWidth);
         doMeasure(widthMeasureSpec, heightMeasureSpec);
-
-        if (updateMaxSpansPerRow()) {
-            doMeasure(widthMeasureSpec, heightMeasureSpec);
-        }
     }
 
-    /** Returns {@code true} if the max spans have been updated. */
-    private boolean updateMaxSpansPerRow() {
-        if (getMeasuredWidth() == 0) return false;
-
-        @Px int maxHorizontalSpan = getContentView().getMeasuredWidth()
-                - (2 * mContentHorizontalMargin);
+    /** Returns {@code true} if the max spans have been updated.
+     *
+     * @param availableWidth Total width available within parent (includes insets).
+     */
+    private void updateMaxSpansPerRow(int availableWidth) {
+        @Px int maxHorizontalSpan = getAvailableWidthForSuggestions(
+                availableWidth - getInsetsWidth());
         if (mMaxSpanPerRow != maxHorizontalSpan) {
             mMaxSpanPerRow = maxHorizontalSpan;
             mAdapters.get(AdapterHolder.PRIMARY).mWidgetsListAdapter.setMaxHorizontalSpansPxPerRow(
@@ -439,16 +437,15 @@
                 mAdapters.get(AdapterHolder.WORK).mWidgetsListAdapter.setMaxHorizontalSpansPxPerRow(
                         maxHorizontalSpan);
             }
-            onRecommendedWidgetsBound();
-            return true;
+            post(this::onRecommendedWidgetsBound);
         }
-        return false;
     }
 
-    protected View getContentView() {
-        return mHasWorkProfile
-                ? mViewPager
-                : mAdapters.get(AdapterHolder.PRIMARY).mWidgetsRecyclerView;
+    /**
+     * Returns the width available to display suggestions.
+     */
+    protected int getAvailableWidthForSuggestions(int pickerAvailableWidth) {
+        return pickerAvailableWidth -  (2 * mContentHorizontalMargin);
     }
 
     @Override
@@ -493,7 +490,7 @@
                         .mWidgetsListAdapter.hasVisibleEntries());
         if (mIsNoWidgetsViewNeeded != isNoWidgetsViewNeeded) {
             mIsNoWidgetsViewNeeded = isNoWidgetsViewNeeded;
-            onRecommendedWidgetsBound();
+            post(this::onRecommendedWidgetsBound);
         }
     }
 
@@ -549,7 +546,7 @@
             mAdapters.get(AdapterHolder.SEARCH).mWidgetsRecyclerView.setVisibility(GONE);
             // Visibility of recommended widgets, recycler views and headers are handled in methods
             // below.
-            onRecommendedWidgetsBound();
+            post(this::onRecommendedWidgetsBound);
             onWidgetsBound();
         }
     }
diff --git a/src/com/android/launcher3/widget/picker/WidgetsListItemAnimator.java b/src/com/android/launcher3/widget/picker/WidgetsListItemAnimator.java
index 854700f..6a1921e 100644
--- a/src/com/android/launcher3/widget/picker/WidgetsListItemAnimator.java
+++ b/src/com/android/launcher3/widget/picker/WidgetsListItemAnimator.java
@@ -16,6 +16,8 @@
 
 package com.android.launcher3.widget.picker;
 
+import static android.animation.ValueAnimator.areAnimatorsEnabled;
+
 import static com.android.launcher3.widget.picker.WidgetsListAdapter.VIEW_TYPE_WIDGETS_LIST;
 
 import androidx.recyclerview.widget.DefaultItemAnimator;
@@ -26,6 +28,14 @@
     public static final int MOVE_DURATION_MS = 90;
     public static final int ADD_DURATION_MS = 120;
 
+    // DefaultItemAnimator runs change and move animations before running add animations (i.e.
+    // before expanded list item's content start animating to become visible on screen).
+    public static final int WIDGET_LIST_ITEM_APPEARANCE_START_DELAY =
+            areAnimatorsEnabled() ? (CHANGE_DURATION_MS + MOVE_DURATION_MS) : 0;
+    // Delay after which all item animations are ran and list item's content is visible.
+    public static final int WIDGET_LIST_ITEM_APPEARANCE_DELAY =
+            WIDGET_LIST_ITEM_APPEARANCE_START_DELAY + ADD_DURATION_MS;
+
     public WidgetsListItemAnimator() {
         super();
 
diff --git a/src/com/android/launcher3/widget/picker/WidgetsListTableViewHolderBinder.java b/src/com/android/launcher3/widget/picker/WidgetsListTableViewHolderBinder.java
index 45d733a..679b0f5 100644
--- a/src/com/android/launcher3/widget/picker/WidgetsListTableViewHolderBinder.java
+++ b/src/com/android/launcher3/widget/picker/WidgetsListTableViewHolderBinder.java
@@ -15,10 +15,7 @@
  */
 package com.android.launcher3.widget.picker;
 
-import static com.android.launcher3.widget.picker.WidgetsListItemAnimator.CHANGE_DURATION_MS;
-import static com.android.launcher3.widget.picker.WidgetsListItemAnimator.MOVE_DURATION_MS;
-
-import static android.animation.ValueAnimator.areAnimatorsEnabled;
+import static com.android.launcher3.widget.picker.WidgetsListItemAnimator.WIDGET_LIST_ITEM_APPEARANCE_START_DELAY;
 
 import android.content.Context;
 import android.graphics.Bitmap;
@@ -157,8 +154,7 @@
             // Pass resize delay to let the "move" and "change" animations run before resizing the
             // row.
             tableRow.setupRow(widgetItems.size(),
-                    /*resizeDelayMs=*/
-                    areAnimatorsEnabled() ? (CHANGE_DURATION_MS + MOVE_DURATION_MS) : 0);
+                    /*resizeDelayMs=*/ WIDGET_LIST_ITEM_APPEARANCE_START_DELAY);
             if (tableRow.getChildCount() > widgetItems.size()) {
                 for (int j = widgetItems.size(); j < tableRow.getChildCount(); j++) {
                     tableRow.getChildAt(j).setVisibility(View.GONE);
diff --git a/src/com/android/launcher3/widget/picker/WidgetsTwoPaneSheet.java b/src/com/android/launcher3/widget/picker/WidgetsTwoPaneSheet.java
index 5d71db6..840d98a 100644
--- a/src/com/android/launcher3/widget/picker/WidgetsTwoPaneSheet.java
+++ b/src/com/android/launcher3/widget/picker/WidgetsTwoPaneSheet.java
@@ -21,6 +21,7 @@
 import static com.android.launcher3.UtilitiesKt.CLIP_TO_PADDING_FALSE_MODIFIER;
 import static com.android.launcher3.UtilitiesKt.modifyAttributesOnViewTree;
 import static com.android.launcher3.UtilitiesKt.restoreAttributesOnViewTree;
+import static com.android.launcher3.widget.picker.WidgetsListItemAnimator.WIDGET_LIST_ITEM_APPEARANCE_DELAY;
 
 import android.content.Context;
 import android.graphics.Rect;
@@ -31,6 +32,7 @@
 import android.view.View;
 import android.view.ViewGroup;
 import android.view.ViewParent;
+import android.view.accessibility.AccessibilityNodeInfo;
 import android.widget.FrameLayout;
 import android.widget.LinearLayout;
 import android.widget.ScrollView;
@@ -281,10 +283,19 @@
             mRightPane.removeAllViews();
             mRightPane.addView(mWidgetRecommendationsContainer);
             mRightPaneScrollView.setScrollY(0);
-            mRightPane.setAccessibilityPaneTitle(suggestionsRightPaneTitle);
             mSuggestedWidgetsPackageUserKey = PackageUserKey.fromPackageItemInfo(packageItemInfo);
             final boolean isChangingHeaders = mSelectedHeader == null
                     || !mSelectedHeader.equals(mSuggestedWidgetsPackageUserKey);
+            // If the initial focus view is still focused, it is likely a programmatic header
+            // click.
+            if (mSelectedHeader != null
+                    && !getAccessibilityInitialFocusView().isAccessibilityFocused()) {
+                post(() -> {
+                    mRightPaneScrollView.setAccessibilityPaneTitle(suggestionsRightPaneTitle);
+                    mRightPaneScrollView.performAccessibilityAction(
+                            AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS, null);
+                });
+            }
             if (isChangingHeaders)  {
                 // If switching from another header, unselect any WidgetCells. This is necessary
                 // because we do not clear/recycle the WidgetCells in the recommendations container
@@ -296,7 +307,6 @@
             mSelectedHeader = mSuggestedWidgetsPackageUserKey;
         });
         mSuggestedWidgetsContainer.addView(mSuggestedWidgetsHeader);
-        mRightPane.setAccessibilityPaneTitle(suggestionsRightPaneTitle);
     }
 
     @Override
@@ -313,6 +323,30 @@
     }
 
     @Override
+    @Px
+    protected int getAvailableWidthForSuggestions(int pickerAvailableWidth) {
+        int rightPaneWidth = (int) Math.ceil(0.67 * pickerAvailableWidth);
+
+        if (mDeviceProfile.isTwoPanels && enableUnfoldedTwoPanePicker()) {
+            // See onLayout
+            int leftPaneWidth = (int) (0.33 * pickerAvailableWidth);
+            @Px int minLeftPaneWidthPx = Utilities.dpToPx(MINIMUM_WIDTH_LEFT_PANE_FOLDABLE_DP);
+            @Px int maxLeftPaneWidthPx = Utilities.dpToPx(MAXIMUM_WIDTH_LEFT_PANE_FOLDABLE_DP);
+            if (leftPaneWidth < minLeftPaneWidthPx) {
+                leftPaneWidth = minLeftPaneWidthPx;
+            } else if (leftPaneWidth > maxLeftPaneWidthPx) {
+                leftPaneWidth = maxLeftPaneWidthPx;
+            }
+            rightPaneWidth = pickerAvailableWidth - leftPaneWidth;
+        }
+
+        // Since suggestions are shown in right pane, the available width is 2/3 of total width of
+        // bottom sheet.
+        return rightPaneWidth - getResources().getDimensionPixelSize(
+                R.dimen.widget_list_horizontal_margin_two_pane); // right pane end margin.
+    }
+
+    @Override
     public void onActivePageChanged(int currentActivePage) {
         super.onActivePageChanged(currentActivePage);
 
@@ -323,12 +357,14 @@
 
         mActivePage = currentActivePage;
 
-        if (mSuggestedWidgetsHeader == null) {
-            mAdapters.get(currentActivePage).mWidgetsListAdapter.selectFirstHeaderEntry();
-            mAdapters.get(currentActivePage).mWidgetsRecyclerView.scrollToTop();
-        } else if (currentActivePage == PERSONAL_TAB || currentActivePage == WORK_TAB) {
-            mSuggestedWidgetsHeader.callOnClick();
-        }
+        // When using talkback, swiping left while on right pane, should navigate to the widgets
+        // list on left.
+        mAdapters.get(mActivePage).mWidgetsRecyclerView.setAccessibilityTraversalBefore(
+                mRightPaneScrollView.getId());
+
+        // On page change, select the first item in the list to show in the right pane.
+        mAdapters.get(currentActivePage).mWidgetsListAdapter.selectFirstHeaderEntry();
+        mAdapters.get(currentActivePage).mWidgetsRecyclerView.scrollToTop();
     }
 
     @Override
@@ -372,17 +408,16 @@
 
     }
 
-    @Override
-    protected View getContentView() {
-        return mRightPane;
-    }
-
     private HeaderChangeListener getHeaderChangeListener() {
         return new HeaderChangeListener() {
             @Override
             public void onHeaderChanged(@NonNull PackageUserKey selectedHeader) {
                 final boolean isSameHeader = mSelectedHeader != null
                         && mSelectedHeader.equals(selectedHeader);
+                // If the initial focus view is still focused, it is likely a programmatic header
+                // click.
+                final boolean isUserClick = mSelectedHeader != null
+                        && !getAccessibilityInitialFocusView().isAccessibilityFocused();
                 mSelectedHeader = selectedHeader;
                 WidgetsListContentEntry contentEntry = mActivityContext.getPopupDataProvider()
                         .getSelectedAppWidgets(selectedHeader);
@@ -427,11 +462,14 @@
                 };
                 mRightPane.removeAllViews();
                 mRightPane.addView(widgetsRowViewHolder.itemView);
+                if (isUserClick) {
+                    mRightPaneScrollView.setAccessibilityPaneTitle(getContext().getString(
+                            R.string.widget_picker_right_pane_accessibility_title,
+                            contentEntry.mPkgItem.title));
+                    postDelayed(() -> focusOnFirstWidgetCell(widgetsRowViewHolder.tableContainer),
+                            WIDGET_LIST_ITEM_APPEARANCE_DELAY);
+                }
                 mRightPaneScrollView.setScrollY(0);
-                mRightPane.setAccessibilityPaneTitle(
-                        getContext().getString(
-                                R.string.widget_picker_right_pane_accessibility_title,
-                                contentEntry.mPkgItem.title));
             }
         };
     }
@@ -445,6 +483,18 @@
         }
     }
 
+    /**
+     * Requests focus on the first widget cell in the given widget section.
+     */
+    private static void focusOnFirstWidgetCell(ViewGroup parent) {
+        if (parent == null) return;
+        WidgetCell cell = Utilities.findViewByPredicate(parent, v -> v instanceof WidgetCell);
+        if (cell != null) {
+            cell.performAccessibilityAction(
+                    AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS, null);
+        }
+    }
+
     private static void unselectWidgetCell(ViewGroup parent, WidgetItem item) {
         if (parent == null || item == null) return;
         WidgetCell cell = Utilities.findViewByPredicate(parent, v -> v instanceof WidgetCell wc
diff --git a/tests/multivalentTests/src/com/android/launcher3/RoboObjectInitializer.kt b/tests/multivalentTests/src/com/android/launcher3/RoboObjectInitializer.kt
new file mode 100644
index 0000000..c5f9f86
--- /dev/null
+++ b/tests/multivalentTests/src/com/android/launcher3/RoboObjectInitializer.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright (C) 2024 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
+
+import com.android.launcher3.util.MainThreadInitializedObject
+import com.android.launcher3.util.MainThreadInitializedObject.SandboxApplication
+import com.android.launcher3.util.SafeCloseable
+
+/**
+ * Initializes [MainThreadInitializedObject] instances for Robolectric tests.
+ *
+ * Unlike instrumentation tests, Robolectric creates a new application instance for each test, which
+ * could cause the various static objects defined in [MainThreadInitializedObject] to leak. Thus, a
+ * [SandboxApplication] for Robolectric tests can implement this interface to limit the lifecycle of
+ * these objects to a single test.
+ */
+interface RoboObjectInitializer {
+
+    /** Overrides an object with [type] to [value]. */
+    fun <T : SafeCloseable> initializeObject(type: MainThreadInitializedObject<T>, value: T)
+}
diff --git a/tests/src/com/android/launcher3/allapps/PrivateSpaceSettingsButtonTest.java b/tests/multivalentTests/src/com/android/launcher3/allapps/PrivateSpaceSettingsButtonTest.java
similarity index 96%
rename from tests/src/com/android/launcher3/allapps/PrivateSpaceSettingsButtonTest.java
rename to tests/multivalentTests/src/com/android/launcher3/allapps/PrivateSpaceSettingsButtonTest.java
index 9537e1c..1eb4173 100644
--- a/tests/src/com/android/launcher3/allapps/PrivateSpaceSettingsButtonTest.java
+++ b/tests/multivalentTests/src/com/android/launcher3/allapps/PrivateSpaceSettingsButtonTest.java
@@ -24,7 +24,7 @@
 
 import android.content.Context;
 
-import androidx.test.runner.AndroidJUnit4;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
 
 import com.android.launcher3.model.data.AppInfo;
 import com.android.launcher3.util.ActivityContextWrapper;
diff --git a/tests/src/com/android/launcher3/folder/FolderNameProviderTest.java b/tests/multivalentTests/src/com/android/launcher3/folder/FolderNameProviderTest.java
similarity index 100%
rename from tests/src/com/android/launcher3/folder/FolderNameProviderTest.java
rename to tests/multivalentTests/src/com/android/launcher3/folder/FolderNameProviderTest.java
diff --git a/tests/src/com/android/launcher3/model/DbDowngradeHelperTest.java b/tests/multivalentTests/src/com/android/launcher3/model/DbDowngradeHelperTest.java
similarity index 100%
rename from tests/src/com/android/launcher3/model/DbDowngradeHelperTest.java
rename to tests/multivalentTests/src/com/android/launcher3/model/DbDowngradeHelperTest.java
diff --git a/tests/src/com/android/launcher3/provider/RestoreDbTaskTest.java b/tests/multivalentTests/src/com/android/launcher3/provider/RestoreDbTaskTest.java
similarity index 100%
rename from tests/src/com/android/launcher3/provider/RestoreDbTaskTest.java
rename to tests/multivalentTests/src/com/android/launcher3/provider/RestoreDbTaskTest.java
diff --git a/tests/multivalentTests/src/com/android/launcher3/widget/GeneratedPreviewTest.kt b/tests/multivalentTests/src/com/android/launcher3/widget/GeneratedPreviewTest.kt
index b239aed..ec83b8b 100644
--- a/tests/multivalentTests/src/com/android/launcher3/widget/GeneratedPreviewTest.kt
+++ b/tests/multivalentTests/src/com/android/launcher3/widget/GeneratedPreviewTest.kt
@@ -8,7 +8,6 @@
 import android.content.Context
 import android.content.pm.ActivityInfo
 import android.content.pm.ApplicationInfo
-import android.platform.test.annotations.RequiresFlagsDisabled
 import android.platform.test.annotations.RequiresFlagsEnabled
 import android.platform.test.flag.junit.CheckFlagsRule
 import android.platform.test.flag.junit.DeviceFlagsValueProvider
@@ -34,6 +33,7 @@
 
 @SmallTest
 @RunWith(AndroidJUnit4::class)
+@RequiresFlagsEnabled(FLAG_ENABLE_GENERATED_PREVIEWS)
 class GeneratedPreviewTest {
     @get:Rule val checkFlagsRule: CheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule()
     private val providerName =
@@ -104,7 +104,6 @@
     }
 
     @Test
-    @RequiresFlagsEnabled(FLAG_ENABLE_GENERATED_PREVIEWS)
     fun widgetItem_hasGeneratedPreview() {
         assertThat(widgetItem.hasGeneratedPreview(WIDGET_CATEGORY_HOME_SCREEN)).isTrue()
         assertThat(widgetItem.hasGeneratedPreview(WIDGET_CATEGORY_KEYGUARD)).isFalse()
@@ -112,7 +111,6 @@
     }
 
     @Test
-    @RequiresFlagsEnabled(FLAG_ENABLE_GENERATED_PREVIEWS)
     fun widgetItem_hasGeneratedPreview_noPreview() {
         appWidgetProviderInfo.generatedPreviewCategories = 0
         createWidgetItem()
@@ -122,7 +120,6 @@
     }
 
     @Test
-    @RequiresFlagsEnabled(FLAG_ENABLE_GENERATED_PREVIEWS)
     fun widgetItem_hasGeneratedPreview_nullPreview() {
         appWidgetProviderInfo.generatedPreviewCategories =
             WIDGET_CATEGORY_HOME_SCREEN or WIDGET_CATEGORY_KEYGUARD
@@ -134,22 +131,12 @@
     }
 
     @Test
-    @RequiresFlagsDisabled(FLAG_ENABLE_GENERATED_PREVIEWS)
-    fun widgetItem_hasGeneratedPreview_flagDisabled() {
-        assertThat(widgetItem.hasGeneratedPreview(WIDGET_CATEGORY_HOME_SCREEN)).isFalse()
-        assertThat(widgetItem.hasGeneratedPreview(WIDGET_CATEGORY_KEYGUARD)).isFalse()
-        assertThat(widgetItem.hasGeneratedPreview(WIDGET_CATEGORY_SEARCHBOX)).isFalse()
-    }
-
-    @Test
-    @RequiresFlagsEnabled(FLAG_ENABLE_GENERATED_PREVIEWS)
     fun widgetItem_getGeneratedPreview() {
         val preview = widgetItem.generatedPreviews.get(WIDGET_CATEGORY_HOME_SCREEN)
         assertThat(preview).isEqualTo(generatedPreview)
     }
 
     @Test
-    @RequiresFlagsEnabled(FLAG_ENABLE_GENERATED_PREVIEWS)
     fun widgetCell_showGeneratedPreview() {
         widgetCell.applyFromCellItem(widgetItem)
         DatabaseWidgetPreviewLoader.getLoaderExecutor().submit {}.get()
@@ -157,12 +144,4 @@
         assertThat(widgetCell.appWidgetHostViewPreview?.appWidgetInfo)
             .isEqualTo(appWidgetProviderInfo)
     }
-
-    @Test
-    @RequiresFlagsDisabled(FLAG_ENABLE_GENERATED_PREVIEWS)
-    fun widgetCell_showGeneratedPreview_flagDisabled() {
-        widgetCell.applyFromCellItem(widgetItem)
-        DatabaseWidgetPreviewLoader.getLoaderExecutor().submit {}.get()
-        assertThat(widgetCell.appWidgetHostViewPreview).isNull()
-    }
 }
diff --git a/tests/src/com/android/launcher3/dragging/TaplUninstallRemoveTest.java b/tests/src/com/android/launcher3/dragging/TaplUninstallRemoveTest.java
index 362596c..405dae7 100644
--- a/tests/src/com/android/launcher3/dragging/TaplUninstallRemoveTest.java
+++ b/tests/src/com/android/launcher3/dragging/TaplUninstallRemoveTest.java
@@ -159,13 +159,13 @@
                     mLauncher.getWorkspace().getWorkspaceIconsPositions();
             assertThat(initialPositions.keySet()).containsAtLeastElementsIn(appNames);
 
-            mLauncher.getWorkspace().getWorkspaceAppIcon(DUMMY_APP_NAME).uninstall();
-            mLauncher.getWorkspace().verifyWorkspaceAppIconIsGone(
+            final Workspace workspace = mLauncher.getWorkspace().getWorkspaceAppIcon(
+                    DUMMY_APP_NAME).uninstall();
+            workspace.verifyWorkspaceAppIconIsGone(
                     DUMMY_APP_NAME + " was expected to disappear after uninstall.", DUMMY_APP_NAME);
 
             Log.d(UIOBJECT_STALE_ELEMENT, "second getWorkspaceIconsPositions()");
-            Map<String, Point> finalPositions =
-                    mLauncher.getWorkspace().getWorkspaceIconsPositions();
+            Map<String, Point> finalPositions = workspace.getWorkspaceIconsPositions();
             assertThat(finalPositions).doesNotContainKey(DUMMY_APP_NAME);
         } finally {
             TestUtil.uninstallDummyApp();
diff --git a/tests/src/com/android/launcher3/model/LoaderTaskTest.kt b/tests/src/com/android/launcher3/model/LoaderTaskTest.kt
index 28a001f..d16674c 100644
--- a/tests/src/com/android/launcher3/model/LoaderTaskTest.kt
+++ b/tests/src/com/android/launcher3/model/LoaderTaskTest.kt
@@ -1,10 +1,13 @@
 package com.android.launcher3.model
 
 import android.appwidget.AppWidgetManager
+import android.content.Intent
 import android.os.UserHandle
 import android.platform.test.flag.junit.SetFlagsRule
+import android.provider.Settings
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SmallTest
+import com.android.dx.mockito.inline.extended.ExtendedMockito
 import com.android.launcher3.Flags
 import com.android.launcher3.InvariantDeviceProfile
 import com.android.launcher3.LauncherAppState
@@ -14,6 +17,7 @@
 import com.android.launcher3.icons.cache.CachingLogic
 import com.android.launcher3.icons.cache.IconCacheUpdateHandler
 import com.android.launcher3.pm.UserCache
+import com.android.launcher3.provider.RestoreDbTask
 import com.android.launcher3.ui.TestViewHelpers
 import com.android.launcher3.util.Executors.MODEL_EXECUTOR
 import com.android.launcher3.util.LauncherModelHelper.SandboxModelContext
@@ -21,21 +25,30 @@
 import com.android.launcher3.util.UserIconInfo
 import com.google.common.truth.Truth
 import java.util.concurrent.CountDownLatch
+import junit.framework.Assert.assertEquals
 import org.junit.After
 import org.junit.Before
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
+import org.mockito.ArgumentCaptor
 import org.mockito.ArgumentMatchers.any
 import org.mockito.ArgumentMatchers.anyInt
+import org.mockito.ArgumentMatchers.anyList
+import org.mockito.ArgumentMatchers.anyMap
 import org.mockito.Mock
 import org.mockito.Mockito
 import org.mockito.Mockito.times
-import org.mockito.Mockito.verify
 import org.mockito.Mockito.`when`
 import org.mockito.MockitoAnnotations
+import org.mockito.MockitoSession
 import org.mockito.Spy
+import org.mockito.kotlin.anyOrNull
 import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.spy
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import org.mockito.quality.Strictness
 
 private const val INSERTION_STATEMENT_FILE = "databases/workspace_items.sql"
 
@@ -43,6 +56,20 @@
 @RunWith(AndroidJUnit4::class)
 class LoaderTaskTest {
     private var context = SandboxModelContext()
+    private val expectedBroadcastModel =
+        FirstScreenBroadcastModel(
+            installerPackage = "installerPackage",
+            pendingCollectionItems = mutableSetOf("pendingCollectionItem"),
+            pendingWidgetItems = mutableSetOf("pendingWidgetItem"),
+            pendingHotseatItems = mutableSetOf("pendingHotseatItem"),
+            pendingWorkspaceItems = mutableSetOf("pendingWorkspaceItem"),
+            installedHotseatItems = mutableSetOf("installedHotseatItem"),
+            installedWorkspaceItems = mutableSetOf("installedWorkspaceItem"),
+            firstScreenInstalledWidgets = mutableSetOf("installedFirstScreenWidget"),
+            secondaryScreenInstalledWidgets = mutableSetOf("installedSecondaryScreenWidget")
+        )
+    private lateinit var mockitoSession: MockitoSession
+
     @Mock private lateinit var app: LauncherAppState
     @Mock private lateinit var bgAllAppsList: AllAppsList
     @Mock private lateinit var modelDelegate: ModelDelegate
@@ -61,7 +88,11 @@
     @Before
     fun setup() {
         MockitoAnnotations.initMocks(this)
-
+        mockitoSession =
+            ExtendedMockito.mockitoSession()
+                .strictness(Strictness.LENIENT)
+                .mockStatic(FirstScreenBroadcastHelper::class.java)
+                .startMocking()
         val idp =
             InvariantDeviceProfile().apply {
                 numRows = 5
@@ -90,6 +121,7 @@
     @After
     fun tearDown() {
         context.onDestroy()
+        mockitoSession.finishMocking()
     }
 
     @Test
@@ -166,6 +198,141 @@
             verify(bgAllAppsList, Mockito.never())
                 .setFlags(BgDataModel.Callbacks.FLAG_QUIET_MODE_ENABLED, true)
         }
+
+    @Test
+    fun `When launcher_broadcast_installed_apps and is restore then send installed item broadcast`() {
+        // Given
+        val spyContext = spy(context)
+        `when`(app.context).thenReturn(spyContext)
+        whenever(
+                FirstScreenBroadcastHelper.createModelsForFirstScreenBroadcast(
+                    anyOrNull(),
+                    anyList(),
+                    anyMap(),
+                    anyList()
+                )
+            )
+            .thenReturn(listOf(expectedBroadcastModel))
+
+        whenever(
+                FirstScreenBroadcastHelper.sendBroadcastsForModels(
+                    spyContext,
+                    listOf(expectedBroadcastModel)
+                )
+            )
+            .thenCallRealMethod()
+
+        Settings.Secure.putInt(spyContext.contentResolver, "launcher_broadcast_installed_apps", 1)
+        RestoreDbTask.setPending(spyContext)
+
+        // When
+        LoaderTask(app, bgAllAppsList, BgDataModel(), modelDelegate, launcherBinder)
+            .runSyncOnBackgroundThread()
+
+        // Then
+        val argumentCaptor = ArgumentCaptor.forClass(Intent::class.java)
+        verify(spyContext).sendBroadcast(argumentCaptor.capture())
+        val actualBroadcastIntent = argumentCaptor.value
+        assertEquals(expectedBroadcastModel.installerPackage, actualBroadcastIntent.`package`)
+        assertEquals(
+            ArrayList(expectedBroadcastModel.installedWorkspaceItems),
+            actualBroadcastIntent.getStringArrayListExtra("workspaceInstalledItems")
+        )
+        assertEquals(
+            ArrayList(expectedBroadcastModel.installedHotseatItems),
+            actualBroadcastIntent.getStringArrayListExtra("hotseatInstalledItems")
+        )
+        assertEquals(
+            ArrayList(
+                expectedBroadcastModel.firstScreenInstalledWidgets +
+                    expectedBroadcastModel.secondaryScreenInstalledWidgets
+            ),
+            actualBroadcastIntent.getStringArrayListExtra("widgetInstalledItems")
+        )
+        assertEquals(
+            ArrayList(expectedBroadcastModel.pendingCollectionItems),
+            actualBroadcastIntent.getStringArrayListExtra("folderItem")
+        )
+        assertEquals(
+            ArrayList(expectedBroadcastModel.pendingWorkspaceItems),
+            actualBroadcastIntent.getStringArrayListExtra("workspaceItem")
+        )
+        assertEquals(
+            ArrayList(expectedBroadcastModel.pendingHotseatItems),
+            actualBroadcastIntent.getStringArrayListExtra("hotseatItem")
+        )
+        assertEquals(
+            ArrayList(expectedBroadcastModel.pendingWidgetItems),
+            actualBroadcastIntent.getStringArrayListExtra("widgetItem")
+        )
+    }
+
+    @Test
+    fun `When not a restore then installed item broadcast not sent`() {
+        // Given
+        val spyContext = spy(context)
+        `when`(app.context).thenReturn(spyContext)
+        whenever(
+                FirstScreenBroadcastHelper.createModelsForFirstScreenBroadcast(
+                    anyOrNull(),
+                    anyList(),
+                    anyMap(),
+                    anyList()
+                )
+            )
+            .thenReturn(listOf(expectedBroadcastModel))
+
+        whenever(
+                FirstScreenBroadcastHelper.sendBroadcastsForModels(
+                    spyContext,
+                    listOf(expectedBroadcastModel)
+                )
+            )
+            .thenCallRealMethod()
+
+        Settings.Secure.putInt(spyContext.contentResolver, "launcher_broadcast_installed_apps", 1)
+
+        // When
+        LoaderTask(app, bgAllAppsList, BgDataModel(), modelDelegate, launcherBinder)
+            .runSyncOnBackgroundThread()
+
+        // Then
+        verify(spyContext, times(0)).sendBroadcast(any(Intent::class.java))
+    }
+
+    @Test
+    fun `When launcher_broadcast_installed_apps false then installed item broadcast not sent`() {
+        // Given
+        val spyContext = spy(context)
+        `when`(app.context).thenReturn(spyContext)
+        whenever(
+                FirstScreenBroadcastHelper.createModelsForFirstScreenBroadcast(
+                    anyOrNull(),
+                    anyList(),
+                    anyMap(),
+                    anyList()
+                )
+            )
+            .thenReturn(listOf(expectedBroadcastModel))
+
+        whenever(
+                FirstScreenBroadcastHelper.sendBroadcastsForModels(
+                    spyContext,
+                    listOf(expectedBroadcastModel)
+                )
+            )
+            .thenCallRealMethod()
+
+        Settings.Secure.putInt(spyContext.contentResolver, "launcher_broadcast_installed_apps", 0)
+        RestoreDbTask.setPending(spyContext)
+
+        // When
+        LoaderTask(app, bgAllAppsList, BgDataModel(), modelDelegate, launcherBinder)
+            .runSyncOnBackgroundThread()
+
+        // Then
+        verify(spyContext, times(0)).sendBroadcast(any(Intent::class.java))
+    }
 }
 
 private fun LoaderTask.runSyncOnBackgroundThread() {
diff --git a/tests/src/com/android/launcher3/ui/AbstractLauncherUiTest.java b/tests/src/com/android/launcher3/ui/AbstractLauncherUiTest.java
index a6f4441..6e01f9e 100644
--- a/tests/src/com/android/launcher3/ui/AbstractLauncherUiTest.java
+++ b/tests/src/com/android/launcher3/ui/AbstractLauncherUiTest.java
@@ -239,7 +239,8 @@
         final CountDownLatch count = new CountDownLatch(2);
         final SimpleBroadcastReceiver broadcastReceiver =
                 new SimpleBroadcastReceiver(i -> count.countDown());
-        broadcastReceiver.registerPkgActions(mTargetContext, pkg,
+        // We OK to make binder calls on main thread in test.
+        broadcastReceiver.registerPkgActionsSync(mTargetContext, pkg,
                 Intent.ACTION_PACKAGE_RESTARTED, Intent.ACTION_PACKAGE_DATA_CLEARED);
 
         mDevice.executeShellCommand("pm clear " + pkg);