Merge "Take in test instance as field." 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/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/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..ce281c3 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarUIController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarUIController.java
@@ -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..037f2f6 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;
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/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..66091d4 100644
--- a/quickstep/src/com/android/quickstep/RecentTasksList.java
+++ b/quickstep/src/com/android/quickstep/RecentTasksList.java
@@ -70,7 +70,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 +200,9 @@
 
     public void onRecentTasksChanged() {
         invalidateLoadedTasks();
+        if (mRecentTasksChangedListener != null) {
+            mRecentTasksChangedListener.onRecentTasksChanged();
+        }
     }
 
     private synchronized void invalidateLoadedTasks() {
@@ -221,6 +225,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);
@@ -357,6 +376,7 @@
             task.setLastSnapshotData(taskInfo);
             task.positionInParent = taskInfo.positionInParent;
             task.appBounds = taskInfo.configuration.windowConfiguration.getAppBounds();
+            task.isVisible = taskInfo.isVisible;
             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/TaskShortcutFactory.java b/quickstep/src/com/android/quickstep/TaskShortcutFactory.java
index fd141c3..4691ea9 100644
--- a/quickstep/src/com/android/quickstep/TaskShortcutFactory.java
+++ b/quickstep/src/com/android/quickstep/TaskShortcutFactory.java
@@ -228,6 +228,7 @@
 
                 // Take the thumbnail of the task without a scrim and apply it back after
                 float alpha = mThumbnailView.getDimAlpha();
+                // TODO(b/348643341) add ability to get override the scrim for this Bitmap retrieval
                 mThumbnailView.setDimAlpha(0);
                 Bitmap thumbnail = RecentsTransition.drawViewIntoHardwareBitmap(
                         taskBounds.width(), taskBounds.height(), mThumbnailView, 1f,
@@ -492,18 +493,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/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/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/views/DesktopTaskView.kt b/quickstep/src/com/android/quickstep/views/DesktopTaskView.kt
index 936f6a1..4c78e21 100644
--- a/quickstep/src/com/android/quickstep/views/DesktopTaskView.kt
+++ b/quickstep/src/com/android/quickstep/views/DesktopTaskView.kt
@@ -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/GroupedTaskView.kt b/quickstep/src/com/android/quickstep/views/GroupedTaskView.kt
index d6a3376..6296b0e 100644
--- a/quickstep/src/com/android/quickstep/views/GroupedTaskView.kt
+++ b/quickstep/src/com/android/quickstep/views/GroupedTaskView.kt
@@ -145,6 +145,8 @@
                     taskOverlayFactory
                 )
             )
+        taskContainers.forEach { it.bind() }
+
         this.splitBoundsConfig =
             splitBoundsConfig?.also {
                 taskContainers[0]
diff --git a/quickstep/src/com/android/quickstep/views/RecentsView.java b/quickstep/src/com/android/quickstep/views/RecentsView.java
index b332652..d806e3d 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
@@ -2689,6 +2689,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 +6030,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 +6046,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..4f446b2 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;
@@ -367,6 +368,14 @@
                         mTaskContainer.getThumbnailViewDeprecated(), DIM_ALPHA,
                         closing ? 0 : TaskView.MAX_PAGE_SCRIM_ALPHA),
                 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);
+            });
+        }
         mOpenCloseAnimator.addListener(new AnimationSuccessListener() {
             @Override
             public void onAnimationStart(Animator animation) {
diff --git a/quickstep/src/com/android/quickstep/views/TaskView.kt b/quickstep/src/com/android/quickstep/views/TaskView.kt
index 9c1aaa6..7a3b00f 100644
--- a/quickstep/src/com/android/quickstep/views/TaskView.kt
+++ b/quickstep/src/com/android/quickstep/views/TaskView.kt
@@ -88,6 +88,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
@@ -667,6 +668,7 @@
                     taskOverlayFactory
                 )
             )
+        taskContainers.forEach { it.bind() }
         setOrientationState(orientedState)
     }
 
@@ -693,24 +695,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 {
@@ -1379,7 +1373,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)
@@ -1522,6 +1515,9 @@
         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.
@@ -1623,6 +1619,7 @@
         taskOverlayFactory: TaskOverlayFactory
     ) {
         val overlay: TaskOverlay<*> = taskOverlayFactory.createOverlay(this)
+        val taskContainerData = TaskContainerData()
 
         val snapshotView: View
             get() = thumbnailView ?: thumbnailViewDeprecated
@@ -1656,6 +1653,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/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/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/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/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/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/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/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/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/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);