Merge "[CD Taskbar] Fix Window Context & Enable External Context adding" into main
diff --git a/quickstep/res/values/dimens.xml b/quickstep/res/values/dimens.xml
index f2f1ebd..05f0695 100644
--- a/quickstep/res/values/dimens.xml
+++ b/quickstep/res/values/dimens.xml
@@ -88,6 +88,9 @@
     <dimen name="task_thumbnail_header_icon_size">18dp</dimen>
     <dimen name="task_thumbnail_header_round_corner_radius">16dp</dimen>
 
+    <!--  How much a task being dragged for dismissal can undershoot the origin when dragged back to its start position.  -->
+    <dimen name="task_dismiss_max_undershoot">25dp</dimen>
+
     <dimen name="task_icon_cache_default_icon_size">72dp</dimen>
     <item name="overview_modal_max_scale" format="float" type="dimen">1.1</item>
 
diff --git a/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/TaskViewDismissTouchController.kt b/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/TaskViewDismissTouchController.kt
index 99b962b..77a05c1 100644
--- a/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/TaskViewDismissTouchController.kt
+++ b/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/TaskViewDismissTouchController.kt
@@ -20,6 +20,7 @@
 import androidx.dynamicanimation.animation.SpringAnimation
 import com.android.app.animation.Interpolators.DECELERATE
 import com.android.launcher3.AbstractFloatingView
+import com.android.launcher3.R
 import com.android.launcher3.Utilities.EDGE_NAV_BAR
 import com.android.launcher3.Utilities.boundToRange
 import com.android.launcher3.Utilities.isRtl
@@ -144,7 +145,7 @@
                     0f,
                     dismissLength.toFloat(),
                     0f,
-                    DISMISS_MAX_UNDERSHOOT,
+                    container.resources.getDimension(R.dimen.task_dismiss_max_undershoot),
                     DECELERATE,
                 )
         taskBeingDragged.secondaryDismissTranslationProperty.setValue(
@@ -207,6 +208,5 @@
 
     companion object {
         private const val DISMISS_THRESHOLD_FRACTION = 0.5f
-        private const val DISMISS_MAX_UNDERSHOOT = 25f
     }
 }
diff --git a/quickstep/src/com/android/quickstep/RecentsModel.java b/quickstep/src/com/android/quickstep/RecentsModel.java
index 87b58e6..1d83d42 100644
--- a/quickstep/src/com/android/quickstep/RecentsModel.java
+++ b/quickstep/src/com/android/quickstep/RecentsModel.java
@@ -38,13 +38,18 @@
 import androidx.annotation.Nullable;
 import androidx.annotation.VisibleForTesting;
 
+import com.android.launcher3.dagger.ApplicationContext;
+import com.android.launcher3.dagger.LauncherAppSingleton;
 import com.android.launcher3.graphics.ThemeManager;
 import com.android.launcher3.graphics.ThemeManager.ThemeChangeListener;
 import com.android.launcher3.icons.IconProvider;
+import com.android.launcher3.util.DaggerSingletonObject;
+import com.android.launcher3.util.DaggerSingletonTracker;
+import com.android.launcher3.util.DisplayController;
 import com.android.launcher3.util.Executors.SimpleThreadFactory;
 import com.android.launcher3.util.LockedUserState;
-import com.android.launcher3.util.MainThreadInitializedObject;
 import com.android.launcher3.util.SafeCloseable;
+import com.android.quickstep.dagger.QuickstepBaseAppComponent;
 import com.android.quickstep.recents.data.RecentTasksDataSource;
 import com.android.quickstep.recents.data.TaskVisualsChangeNotifier;
 import com.android.quickstep.util.DesktopTask;
@@ -65,19 +70,22 @@
 import java.util.function.Consumer;
 import java.util.function.Predicate;
 
-import javax.inject.Provider;
+import javax.inject.Inject;
+
+import dagger.Lazy;
 
 /**
  * Singleton class to load and manage recents model.
  */
 @TargetApi(Build.VERSION_CODES.O)
+@LauncherAppSingleton
 public class RecentsModel implements RecentTasksDataSource, TaskStackChangeListener,
         TaskVisualsChangeListener, TaskVisualsChangeNotifier,
-        ThemeChangeListener, SafeCloseable {
+        ThemeChangeListener {
 
     // We do not need any synchronization for this variable as its only written on UI thread.
-    public static final MainThreadInitializedObject<RecentsModel> INSTANCE =
-            new MainThreadInitializedObject<>(RecentsModel::new);
+    public static final DaggerSingletonObject<RecentsModel> INSTANCE =
+            new DaggerSingletonObject<>(QuickstepBaseAppComponent::getRecentsModel);
 
     private static final Executor RECENTS_MODEL_EXECUTOR = Executors.newSingleThreadExecutor(
             new SimpleThreadFactory("TaskThumbnailIconCache-", THREAD_PRIORITY_BACKGROUND));
@@ -85,53 +93,67 @@
     private final ConcurrentLinkedQueue<TaskVisualsChangeListener> mThumbnailChangeListeners =
             new ConcurrentLinkedQueue<>();
     private final Context mContext;
-
     private final RecentTasksList mTaskList;
     private final TaskIconCache mIconCache;
     private final TaskThumbnailCache mThumbnailCache;
-    private final ComponentCallbacks mCallbacks;
 
-    private final TaskStackChangeListeners mTaskStackChangeListeners;
-    private final SafeCloseable mIconChangeCloseable;
-
-    private final LockedUserState mLockedUserState;
-    private final Provider<ThemeManager> mThemeManagerProvider;
-    private final Runnable mUnlockCallback;
-
-    private RecentsModel(Context context) {
-        this(context, new IconProvider(context));
+    @Inject
+     public RecentsModel(@ApplicationContext Context context,
+            SystemUiProxy systemUiProxy,
+            TopTaskTracker topTaskTracker,
+            DisplayController displayController,
+            LockedUserState lockedUserState,
+            Lazy<ThemeManager> themeManagerLazy,
+            DaggerSingletonTracker tracker
+            ) {
+        // Lazily inject the ThemeManager and access themeManager once the device is
+        // unlocked. See b/393248495 for details.
+        this(context, new IconProvider(context), systemUiProxy, topTaskTracker,
+                displayController, lockedUserState,themeManagerLazy, tracker);
     }
 
     @SuppressLint("VisibleForTests")
-    private RecentsModel(Context context, IconProvider iconProvider) {
+    private RecentsModel(@ApplicationContext Context context,
+            IconProvider iconProvider,
+            SystemUiProxy systemUiProxy,
+            TopTaskTracker topTaskTracker,
+            DisplayController displayController,
+            LockedUserState lockedUserState,
+            Lazy<ThemeManager> themeManagerLazy,
+            DaggerSingletonTracker tracker) {
         this(context,
                 new RecentTasksList(
                         context,
                         MAIN_EXECUTOR,
                         context.getSystemService(KeyguardManager.class),
-                        SystemUiProxy.INSTANCE.get(context),
-                        TopTaskTracker.INSTANCE.get(context)),
-                new TaskIconCache(context, RECENTS_MODEL_EXECUTOR, iconProvider),
+                        systemUiProxy,
+                        topTaskTracker),
+                new TaskIconCache(context, RECENTS_MODEL_EXECUTOR, iconProvider, displayController),
                 new TaskThumbnailCache(context, RECENTS_MODEL_EXECUTOR),
                 iconProvider,
                 TaskStackChangeListeners.getInstance(),
-                LockedUserState.get(context),
-                () -> ThemeManager.INSTANCE.get(context));
+                lockedUserState,
+                themeManagerLazy,
+                tracker);
     }
 
     @VisibleForTesting
-    RecentsModel(Context context, RecentTasksList taskList, TaskIconCache iconCache,
-            TaskThumbnailCache thumbnailCache, IconProvider iconProvider,
+    RecentsModel(@ApplicationContext Context context,
+            RecentTasksList taskList,
+            TaskIconCache iconCache,
+            TaskThumbnailCache thumbnailCache,
+            IconProvider iconProvider,
             TaskStackChangeListeners taskStackChangeListeners,
             LockedUserState lockedUserState,
-            Provider<ThemeManager> themeManagerProvider) {
+            Lazy<ThemeManager> themeManagerLazy,
+            DaggerSingletonTracker tracker) {
         mContext = context;
         mTaskList = taskList;
         mIconCache = iconCache;
         mIconCache.registerTaskVisualsChangeListener(this);
         mThumbnailCache = thumbnailCache;
         if (isCachePreloadingEnabled()) {
-            mCallbacks = new ComponentCallbacks() {
+            ComponentCallbacks componentCallbacks = new ComponentCallbacks() {
                 @Override
                 public void onConfigurationChanged(Configuration configuration) {
                     updateCacheSizeAndPreloadIfNeeded();
@@ -141,20 +163,27 @@
                 public void onLowMemory() {
                 }
             };
-            context.registerComponentCallbacks(mCallbacks);
-        } else {
-            mCallbacks = null;
+            context.registerComponentCallbacks(componentCallbacks);
+            tracker.addCloseable(() -> context.unregisterComponentCallbacks(componentCallbacks));
         }
 
-        mTaskStackChangeListeners = taskStackChangeListeners;
-        mTaskStackChangeListeners.registerTaskStackListener(this);
-        mIconChangeCloseable = iconProvider.registerIconChangeListener(
+        taskStackChangeListeners.registerTaskStackListener(this);
+        SafeCloseable iconChangeCloseable = iconProvider.registerIconChangeListener(
                 this::onAppIconChanged, MAIN_EXECUTOR.getHandler());
 
-        mLockedUserState = lockedUserState;
-        mThemeManagerProvider = themeManagerProvider;
-        mUnlockCallback = () -> mThemeManagerProvider.get().addChangeListener(this);
-        lockedUserState.runOnUserUnlocked(mUnlockCallback);
+        Runnable unlockCallback = () -> themeManagerLazy.get().addChangeListener(this);
+        lockedUserState.runOnUserUnlocked(unlockCallback);
+
+        tracker.addCloseable(() -> {
+            taskStackChangeListeners.unregisterTaskStackListener(this);
+            iconChangeCloseable.close();
+            mIconCache.removeTaskVisualsChangeListener();
+            if (lockedUserState.isUserUnlocked()) {
+                themeManagerLazy.get().removeChangeListener(this);
+            } else {
+                lockedUserState.removeOnUserUnlockedRunnable(unlockCallback);
+            }
+        });
     }
 
     public TaskIconCache getIconCache() {
@@ -407,22 +436,6 @@
         }
     }
 
-    @Override
-    public void close() {
-        if (mCallbacks != null) {
-            mContext.unregisterComponentCallbacks(mCallbacks);
-        }
-        mIconCache.removeTaskVisualsChangeListener();
-        mTaskStackChangeListeners.unregisterTaskStackListener(this);
-        mIconChangeCloseable.close();
-
-        if (mLockedUserState.isUserUnlocked()) {
-            mThemeManagerProvider.get().removeChangeListener(this);
-        } else {
-            mLockedUserState.removeOnUserUnlockedRunnable(mUnlockCallback);
-        }
-    }
-
     private boolean isCachePreloadingEnabled() {
         return enableGridOnlyOverview() || enableRefactorTaskThumbnail();
     }
diff --git a/quickstep/src/com/android/quickstep/TaskIconCache.kt b/quickstep/src/com/android/quickstep/TaskIconCache.kt
index b82c110..6a7f1af 100644
--- a/quickstep/src/com/android/quickstep/TaskIconCache.kt
+++ b/quickstep/src/com/android/quickstep/TaskIconCache.kt
@@ -53,6 +53,7 @@
     private val context: Context,
     private val bgExecutor: Executor,
     private val iconProvider: IconProvider,
+    displayController: DisplayController,
 ) : TaskIconDataSource, DisplayInfoChangeListener {
     private val iconCache =
         TaskKeyLruCache<TaskCacheEntry>(
@@ -71,7 +72,7 @@
     var taskVisualsChangeListener: TaskVisualsChangeListener? = null
 
     init {
-        DisplayController.INSTANCE.get(context).addChangeListener(this)
+        displayController.addChangeListener(this)
     }
 
     override fun onDisplayInfoChanged(context: Context, info: DisplayController.Info, flags: Int) {
diff --git a/quickstep/src/com/android/quickstep/dagger/QuickstepBaseAppComponent.java b/quickstep/src/com/android/quickstep/dagger/QuickstepBaseAppComponent.java
index adc45ae..853511b 100644
--- a/quickstep/src/com/android/quickstep/dagger/QuickstepBaseAppComponent.java
+++ b/quickstep/src/com/android/quickstep/dagger/QuickstepBaseAppComponent.java
@@ -22,6 +22,7 @@
 import com.android.launcher3.statehandlers.DesktopVisibilityController;
 import com.android.quickstep.OverviewComponentObserver;
 import com.android.quickstep.RecentsAnimationDeviceState;
+import com.android.quickstep.RecentsModel;
 import com.android.quickstep.RotationTouchHelper;
 import com.android.quickstep.SimpleOrientationTouchTransformer;
 import com.android.quickstep.SystemUiProxy;
@@ -61,6 +62,8 @@
 
     RecentsAnimationDeviceState getRecentsAnimationDeviceState();
 
+    RecentsModel getRecentsModel();
+
     SettingsChangeLogger getSettingsChangeLogger();
 
     SimpleOrientationTouchTransformer getSimpleOrientationTouchTransformer();
diff --git a/quickstep/src/com/android/quickstep/util/TaskGridNavHelper.java b/quickstep/src/com/android/quickstep/util/TaskGridNavHelper.java
index ceffbe4..da26622 100644
--- a/quickstep/src/com/android/quickstep/util/TaskGridNavHelper.java
+++ b/quickstep/src/com/android/quickstep/util/TaskGridNavHelper.java
@@ -103,23 +103,28 @@
             }
             case DIRECTION_RIGHT: {
                 int boundedIndex =
-                        cycle ? (nextIndex < 0 ? maxSize - 1 : nextIndex) : Math.max(
-                                nextIndex, 0);
+                        cycle ? (nextIndex < 0 ? maxSize - 1 : nextIndex)
+                                : Math.max(nextIndex, 0);
                 boolean inOriginalTop = mOriginalTopRowIds.contains(currentPageTaskViewId);
                 return inOriginalTop ? mTopRowIds.get(boundedIndex)
                         : mBottomRowIds.get(boundedIndex);
             }
             case DIRECTION_TAB: {
                 int boundedIndex =
-                        cycle ? nextIndex < 0 ? maxSize - 1 : nextIndex % maxSize : Math.min(
-                                nextIndex, maxSize - 1);
+                        cycle ? (nextIndex < 0 ? maxSize - 1 : nextIndex % maxSize)
+                                : Math.min(nextIndex, maxSize - 1);
                 if (delta >= 0) {
                     return inTop && mTopRowIds.get(index) != mBottomRowIds.get(index)
                             ? mBottomRowIds.get(index)
                             : mTopRowIds.get(boundedIndex);
                 } else {
                     if (mTopRowIds.contains(currentPageTaskViewId)) {
-                        return mBottomRowIds.get(boundedIndex);
+                        if (boundedIndex < 0) {
+                            // If no cycling, always return the first task.
+                            return mTopRowIds.get(0);
+                        } else {
+                            return mBottomRowIds.get(boundedIndex);
+                        }
                     } else {
                         // Go up to top if there is task above
                         return mTopRowIds.get(index) != mBottomRowIds.get(index)
diff --git a/quickstep/src/com/android/quickstep/views/RecentsView.java b/quickstep/src/com/android/quickstep/views/RecentsView.java
index a76ebdb..51980f0 100644
--- a/quickstep/src/com/android/quickstep/views/RecentsView.java
+++ b/quickstep/src/com/android/quickstep/views/RecentsView.java
@@ -6379,7 +6379,7 @@
     }
 
     /**
-     * @return true if the task in on the top of the grid
+     * @return true if the task in on the bottom of the grid
      */
     public boolean isOnGridBottomRow(TaskView taskView) {
         return showAsGrid()
@@ -6942,7 +6942,8 @@
      * Creates the spring animations which run as a task settles back into its place in overview.
      *
      * <p>When a task dismiss is cancelled, the task will return to its original position via a
-     * spring animation.
+     * spring animation. As it passes the threshold of its settling state, its neighbors will
+     * spring in response to the perceived impact of the settling task.
      */
     public SpringAnimation createTaskDismissSettlingSpringAnimation(TaskView draggedTaskView,
             float velocity, boolean isDismissing, SingleAxisSwipeDetector detector,
diff --git a/quickstep/src/com/android/quickstep/views/RecentsViewUtils.kt b/quickstep/src/com/android/quickstep/views/RecentsViewUtils.kt
index f610335..d37a3f9 100644
--- a/quickstep/src/com/android/quickstep/views/RecentsViewUtils.kt
+++ b/quickstep/src/com/android/quickstep/views/RecentsViewUtils.kt
@@ -20,6 +20,7 @@
 import android.view.View
 import androidx.core.view.children
 import androidx.dynamicanimation.animation.FloatPropertyCompat
+import androidx.dynamicanimation.animation.FloatValueHolder
 import androidx.dynamicanimation.animation.SpringAnimation
 import androidx.dynamicanimation.animation.SpringForce
 import com.android.launcher3.Flags.enableLargeDesktopWindowingTile
@@ -29,6 +30,7 @@
 import com.android.launcher3.util.DynamicResource
 import com.android.launcher3.util.IntArray
 import com.android.quickstep.util.GroupTask
+import com.android.quickstep.util.TaskGridNavHelper
 import com.android.quickstep.util.isExternalDisplay
 import com.android.quickstep.views.RecentsView.RUNNING_TASK_ATTACH_ALPHA
 import com.android.systemui.shared.recents.model.ThumbnailData
@@ -305,7 +307,8 @@
      * Creates the spring animations which run when a dragged task view in overview is released.
      *
      * <p>When a task dismiss is cancelled, the task will return to its original position via a
-     * spring animation.
+     * spring animation. As it passes the threshold of its settling state, its neighbors will spring
+     * in response to the perceived impact of the settling task.
      */
     fun createTaskDismissSettlingSpringAnimation(
         draggedTaskView: TaskView?,
@@ -320,37 +323,181 @@
             FloatPropertyCompat.createFloatPropertyCompat(
                 draggedTaskView.secondaryDismissTranslationProperty
             )
-        val rp = DynamicResource.provider(recentsView.mContainer)
-        return SpringAnimation(draggedTaskView, taskDismissFloatProperty)
-            .setSpring(
-                SpringForce()
-                    .setDampingRatio(rp.getFloat(R.dimen.dismiss_task_trans_y_damping_ratio))
-                    .setStiffness(rp.getFloat(R.dimen.dismiss_task_trans_y_stiffness))
-            )
-            .setStartVelocity(if (detector.isFling(velocity)) velocity else 0f)
-            .addUpdateListener { animation, value, _ ->
-                if (isDismissing && abs(value) >= abs(dismissLength)) {
-                    // TODO(b/393553524): Remove 0 alpha, instead animate task fully off screen.
-                    draggedTaskView.alpha = 0f
-                    animation.cancel()
-                } else if (draggedTaskView.isRunningTask && recentsView.enableDrawingLiveTile) {
-                    recentsView.runActionOnRemoteHandles { remoteTargetHandle ->
-                        remoteTargetHandle.taskViewSimulator.taskSecondaryTranslation.value =
-                            taskDismissFloatProperty.getValue(draggedTaskView)
+        // Animate dragged task towards dismissal or rest state.
+        val draggedTaskViewSpringAnimation =
+            SpringAnimation(draggedTaskView, taskDismissFloatProperty)
+                .setSpring(createExpressiveDismissSpringForce())
+                .setStartVelocity(if (detector.isFling(velocity)) velocity else 0f)
+                .addUpdateListener { animation, value, _ ->
+                    if (isDismissing && abs(value) >= abs(dismissLength)) {
+                        // TODO(b/393553524): Remove 0 alpha, instead animate task fully off screen.
+                        draggedTaskView.alpha = 0f
+                        animation.cancel()
+                    } else if (draggedTaskView.isRunningTask && recentsView.enableDrawingLiveTile) {
+                        recentsView.runActionOnRemoteHandles { remoteTargetHandle ->
+                            remoteTargetHandle.taskViewSimulator.taskSecondaryTranslation.value =
+                                taskDismissFloatProperty.getValue(draggedTaskView)
+                        }
+                        recentsView.redrawLiveTile()
                     }
-                    recentsView.redrawLiveTile()
                 }
+                .addEndListener { _, _, _, _ ->
+                    if (isDismissing) {
+                        recentsView.dismissTask(
+                            draggedTaskView,
+                            /* animateTaskView = */ false,
+                            /* removeTask = */ true,
+                        )
+                    } else {
+                        recentsView.onDismissAnimationEnds()
+                    }
+                    onEndRunnable()
+                }
+        if (!isDismissing) {
+            addNeighboringSpringAnimationsForDismissCancel(
+                draggedTaskView,
+                draggedTaskViewSpringAnimation,
+                recentsView.pageCount,
+            )
+        }
+        return draggedTaskViewSpringAnimation
+    }
+
+    private fun addNeighboringSpringAnimationsForDismissCancel(
+        draggedTaskView: TaskView,
+        draggedTaskViewSpringAnimation: SpringAnimation,
+        taskCount: Int,
+    ) {
+        // Empty spring animation exists for conditional start, and to drive neighboring springs.
+        val neighborsToSettle =
+            SpringAnimation(FloatValueHolder()).setSpring(createExpressiveDismissSpringForce())
+        var lastPosition = 0f
+        var startSettling = false
+        draggedTaskViewSpringAnimation.addUpdateListener { _, value, velocity ->
+            // Start the settling animation the first time the dragged task passes the origin (from
+            // negative displacement to positive displacement). We do not check for an exact value
+            // to compare to, as the update listener does not necessarily hit every value (e.g. a
+            // value of zero). Do not check again once it has started settling, as a spring can
+            // bounce past the origin multiple times depending on the stifness and damping ratio.
+            if (startSettling) return@addUpdateListener
+            if (lastPosition < 0 && value >= 0) {
+                startSettling = true
             }
-            .addEndListener { _, _, _, _ ->
-                if (isDismissing) {
-                    recentsView.dismissTask(
-                        draggedTaskView,
-                        /* animateTaskView = */ false,
-                        /* removeTask = */ true,
+            lastPosition = value
+            if (startSettling) {
+                neighborsToSettle.setStartVelocity(velocity).animateToFinalPosition(0f)
+            }
+        }
+
+        // Add tasks before dragged index, fanning out from the dragged task.
+        // The order they are added matters, as each spring drives the next.
+        var previousNeighbor = neighborsToSettle
+        getTasksAdjacentToDraggedTask(draggedTaskView, towardsStart = true).forEach {
+            previousNeighbor = createNeighboringTaskViewSpringAnimation(it, previousNeighbor)
+        }
+        // Add tasks after dragged index, fanning out from the dragged task.
+        // The order they are added matters, as each spring drives the next.
+        previousNeighbor = neighborsToSettle
+        getTasksAdjacentToDraggedTask(draggedTaskView, towardsStart = false).forEach {
+            previousNeighbor = createNeighboringTaskViewSpringAnimation(it, previousNeighbor)
+        }
+    }
+
+    /** Gets adjacent tasks either before or after the dragged task in visual order. */
+    private fun getTasksAdjacentToDraggedTask(
+        draggedTaskView: TaskView,
+        towardsStart: Boolean,
+    ): Sequence<TaskView> {
+        if (recentsView.showAsGrid()) {
+            return gridTaskViewInTabOrderSequence(draggedTaskView, towardsStart)
+        } else {
+            val taskViewList = taskViews.toList()
+            val draggedTaskViewIndex = taskViewList.indexOf(draggedTaskView)
+
+            return if (towardsStart) {
+                taskViewList.take(draggedTaskViewIndex).reversed().asSequence()
+            } else {
+                taskViewList.takeLast(taskViewList.size - draggedTaskViewIndex - 1).asSequence()
+            }
+        }
+    }
+
+    /**
+     * Returns a sequence of TaskViews in the grid, ordered according to tab navigation, starting
+     * from the dragged TaskView, in the direction of the provided delta.
+     *
+     * <p>A positive delta moves forward in the tab order towards the end of the grid, while a
+     * negative value moves backward towards the beginning.
+     */
+    private fun gridTaskViewInTabOrderSequence(
+        draggedTaskView: TaskView,
+        towardsStart: Boolean,
+    ): Sequence<TaskView> = sequence {
+        val taskGridNavHelper =
+            TaskGridNavHelper(
+                recentsView.topRowIdArray,
+                recentsView.bottomRowIdArray,
+                getLargeTaskViewIds(),
+                /* hasAddDesktopButton= */ false,
+            )
+        var nextTaskView: TaskView? = draggedTaskView
+        var previousTaskView: TaskView? = null
+        while (nextTaskView != previousTaskView && nextTaskView != null) {
+            previousTaskView = nextTaskView
+            nextTaskView =
+                recentsView.getTaskViewFromTaskViewId(
+                    taskGridNavHelper.getNextGridPage(
+                        nextTaskView.taskViewId,
+                        if (towardsStart) -1 else 1,
+                        TaskGridNavHelper.DIRECTION_TAB,
+                        /* cycle = */ false,
                     )
-                }
-                onEndRunnable()
+                )
+            if (nextTaskView != null && nextTaskView != previousTaskView) {
+                yield(nextTaskView)
             }
+        }
+    }
+
+    /** Creates a neighboring task view spring, driven by the spring of its neighbor. */
+    private fun createNeighboringTaskViewSpringAnimation(
+        taskView: TaskView,
+        previousNeighborSpringAnimation: SpringAnimation,
+    ): SpringAnimation {
+        val neighboringTaskViewSpringAnimation =
+            SpringAnimation(
+                    taskView,
+                    FloatPropertyCompat.createFloatPropertyCompat(
+                        taskView.secondaryDismissTranslationProperty
+                    ),
+                )
+                .setSpring(createExpressiveDismissSpringForce())
+        // Update live tile on spring animation.
+        if (taskView.isRunningTask && recentsView.enableDrawingLiveTile) {
+            neighboringTaskViewSpringAnimation.addUpdateListener { _, _, _ ->
+                recentsView.runActionOnRemoteHandles { remoteTargetHandle ->
+                    remoteTargetHandle.taskViewSimulator.taskSecondaryTranslation.value =
+                        taskView.secondaryDismissTranslationProperty.get(taskView)
+                }
+                recentsView.redrawLiveTile()
+            }
+        }
+        // Drive current neighbor's spring with the previous neighbor's.
+        previousNeighborSpringAnimation.addUpdateListener { _, value, _ ->
+            neighboringTaskViewSpringAnimation.animateToFinalPosition(value)
+        }
+        return neighboringTaskViewSpringAnimation
+    }
+
+    private fun createExpressiveDismissSpringForce(): SpringForce {
+        val resourceProvider = DynamicResource.provider(recentsView.mContainer)
+        return SpringForce()
+            .setDampingRatio(
+                resourceProvider.getFloat(R.dimen.expressive_dismiss_task_trans_y_damping_ratio)
+            )
+            .setStiffness(
+                resourceProvider.getFloat(R.dimen.expressive_dismiss_task_trans_y_stiffness)
+            )
     }
 
     companion object {
diff --git a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarAutohideSuspendControllerTest.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarAutohideSuspendControllerTest.kt
index 785e585..50d6aff 100644
--- a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarAutohideSuspendControllerTest.kt
+++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarAutohideSuspendControllerTest.kt
@@ -22,6 +22,7 @@
 import com.android.launcher3.taskbar.TaskbarAutohideSuspendController.FLAG_AUTOHIDE_SUSPEND_DRAGGING
 import com.android.launcher3.taskbar.TaskbarAutohideSuspendController.FLAG_AUTOHIDE_SUSPEND_IN_LAUNCHER
 import com.android.launcher3.taskbar.TaskbarAutohideSuspendController.FLAG_AUTOHIDE_SUSPEND_TOUCHING
+import com.android.launcher3.taskbar.rules.SandboxParams
 import com.android.launcher3.taskbar.rules.TaskbarModeRule
 import com.android.launcher3.taskbar.rules.TaskbarModeRule.Mode.TRANSIENT
 import com.android.launcher3.taskbar.rules.TaskbarModeRule.TaskbarMode
@@ -46,15 +47,15 @@
 
     @get:Rule(order = 0)
     val context =
-        TaskbarWindowSandboxContext.create { builder ->
-            builder.bindSystemUiProxy(
+        TaskbarWindowSandboxContext.create(
+            SandboxParams({
                 spy(SystemUiProxy(ApplicationProvider.getApplicationContext())) { proxy ->
                     doAnswer { latestSuspendNotification = it.getArgument(0) }
                         .whenever(proxy)
                         .notifyTaskbarAutohideSuspend(anyOrNull())
                 }
-            )
-        }
+            })
+        )
     @get:Rule(order = 1) val animatorTestRule = AnimatorTestRule(this)
     @get:Rule(order = 2) val taskbarModeRule = TaskbarModeRule(context)
     @get:Rule(order = 3) val taskbarUnitTestRule = TaskbarUnitTestRule(this, context)
diff --git a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarOverflowTest.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarOverflowTest.kt
index 9ca8a1b..bfd53ef 100644
--- a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarOverflowTest.kt
+++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarOverflowTest.kt
@@ -26,21 +26,29 @@
 import com.android.launcher3.Flags.FLAG_ENABLE_MULTI_INSTANCE_MENU_TASKBAR
 import com.android.launcher3.Flags.FLAG_TASKBAR_OVERFLOW
 import com.android.launcher3.R
+import com.android.launcher3.dagger.LauncherAppSingleton
 import com.android.launcher3.taskbar.TaskbarControllerTestUtil.runOnMainSync
 import com.android.launcher3.taskbar.TaskbarViewTestUtil.createHotseatItems
 import com.android.launcher3.taskbar.bubbles.BubbleBarViewController
 import com.android.launcher3.taskbar.bubbles.stashing.BubbleStashController
+import com.android.launcher3.taskbar.rules.DisplayControllerModule
+import com.android.launcher3.taskbar.rules.MockedRecentsModelHelper
 import com.android.launcher3.taskbar.rules.MockedRecentsModelTestRule
+import com.android.launcher3.taskbar.rules.SandboxParams
 import com.android.launcher3.taskbar.rules.TaskbarModeRule
 import com.android.launcher3.taskbar.rules.TaskbarModeRule.Mode.PINNED
 import com.android.launcher3.taskbar.rules.TaskbarModeRule.Mode.TRANSIENT
 import com.android.launcher3.taskbar.rules.TaskbarModeRule.TaskbarMode
+import com.android.launcher3.taskbar.rules.TaskbarSandboxComponent
 import com.android.launcher3.taskbar.rules.TaskbarUnitTestRule
 import com.android.launcher3.taskbar.rules.TaskbarUnitTestRule.InjectController
 import com.android.launcher3.taskbar.rules.TaskbarWindowSandboxContext
+import com.android.launcher3.util.AllModulesForTest
+import com.android.launcher3.util.FakePrefsModule
 import com.android.launcher3.util.LauncherMultivalentJUnit
 import com.android.launcher3.util.LauncherMultivalentJUnit.EmulatedDevices
 import com.android.launcher3.util.TestUtil.getOnUiThread
+import com.android.quickstep.RecentsModel
 import com.android.quickstep.SystemUiProxy
 import com.android.quickstep.util.DesktopTask
 import com.android.systemui.shared.recents.model.Task
@@ -49,6 +57,8 @@
 import com.android.wm.shell.Flags.FLAG_ENABLE_BUBBLE_BAR
 import com.android.wm.shell.desktopmode.IDesktopTaskListener
 import com.google.common.truth.Truth.assertThat
+import dagger.BindsInstance
+import dagger.Component
 import org.junit.Before
 import org.junit.Rule
 import org.junit.Test
@@ -70,19 +80,25 @@
 class TaskbarOverflowTest {
     @get:Rule(order = 0) val setFlagsRule = SetFlagsRule()
 
+    val mockRecentsModelHelper: MockedRecentsModelHelper = MockedRecentsModelHelper()
+
     @get:Rule(order = 1)
     val context =
-        TaskbarWindowSandboxContext.create { builder ->
-            builder.bindSystemUiProxy(
-                spy(SystemUiProxy(ApplicationProvider.getApplicationContext())) { proxy ->
-                    doAnswer { desktopTaskListener = it.getArgument(0) }
-                        .whenever(proxy)
-                        .setDesktopTaskListener(anyOrNull())
-                }
+        TaskbarWindowSandboxContext.create(
+            SandboxParams(
+                {
+                    spy(SystemUiProxy(ApplicationProvider.getApplicationContext())) { proxy ->
+                        doAnswer { desktopTaskListener = it.getArgument(0) }
+                            .whenever(proxy)
+                            .setDesktopTaskListener(anyOrNull())
+                    }
+                },
+                DaggerTaskbarOverflowComponent.builder()
+                    .bindRecentsModel(mockRecentsModelHelper.mockRecentsModel),
             )
-        }
+        )
 
-    @get:Rule(order = 2) val recentsModel = MockedRecentsModelTestRule(context)
+    @get:Rule(order = 2) val recentsModel = MockedRecentsModelTestRule(mockRecentsModelHelper)
 
     @get:Rule(order = 3) val taskbarModeRule = TaskbarModeRule(context)
 
@@ -404,3 +420,18 @@
         return maxNumIconViews
     }
 }
+
+/** TaskbarOverflowComponent used to bind the RecentsModel. */
+@LauncherAppSingleton
+@Component(
+    modules = [AllModulesForTest::class, FakePrefsModule::class, DisplayControllerModule::class]
+)
+interface TaskbarOverflowComponent : TaskbarSandboxComponent {
+
+    @Component.Builder
+    interface Builder : TaskbarSandboxComponent.Builder {
+        @BindsInstance fun bindRecentsModel(model: RecentsModel): Builder
+
+        override fun build(): TaskbarOverflowComponent
+    }
+}
diff --git a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarScrimViewControllerTest.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarScrimViewControllerTest.kt
index 360f019..ba53dcd 100644
--- a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarScrimViewControllerTest.kt
+++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarScrimViewControllerTest.kt
@@ -25,6 +25,7 @@
 import androidx.test.core.app.ApplicationProvider
 import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
 import com.android.launcher3.taskbar.bubbles.stashing.BubbleStashController
+import com.android.launcher3.taskbar.rules.SandboxParams
 import com.android.launcher3.taskbar.rules.TaskbarModeRule
 import com.android.launcher3.taskbar.rules.TaskbarModeRule.Mode.PINNED
 import com.android.launcher3.taskbar.rules.TaskbarModeRule.Mode.TRANSIENT
@@ -55,13 +56,14 @@
     @get:Rule(order = 0) val setFlagsRule = SetFlagsRule()
     @get:Rule(order = 1)
     val context =
-        TaskbarWindowSandboxContext.create { builder ->
-            builder.bindSystemUiProxy(
+        TaskbarWindowSandboxContext.create(
+            SandboxParams({
                 spy(SystemUiProxy(ApplicationProvider.getApplicationContext())) {
                     doAnswer { backPressed = true }.whenever(it).onBackEvent(anyOrNull())
                 }
-            )
-        }
+            })
+        )
+
     @get:Rule(order = 2) val taskbarModeRule = TaskbarModeRule(context)
     @get:Rule(order = 3) val animatorTestRule = AnimatorTestRule(this)
     @get:Rule(order = 4) val taskbarUnitTestRule = TaskbarUnitTestRule(this, context)
diff --git a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/rules/MockedRecentsModelHelper.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/rules/MockedRecentsModelHelper.kt
new file mode 100644
index 0000000..a7bfa9a
--- /dev/null
+++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/rules/MockedRecentsModelHelper.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2025 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.rules
+
+import com.android.quickstep.RecentsModel
+import com.android.quickstep.RecentsModel.RecentTasksChangedListener
+import com.android.quickstep.TaskIconCache
+import com.android.quickstep.util.GroupTask
+import java.util.function.Consumer
+import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.doAnswer
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+
+/** Helper class to mock the {@link RecentsModel} object in test */
+class MockedRecentsModelHelper {
+    private val mockIconCache: TaskIconCache = mock()
+    var taskListId = 0
+    var recentTasksChangedListener: RecentTasksChangedListener? = null
+    var taskRequests: MutableList<(List<GroupTask>) -> Unit> = mutableListOf()
+
+    val mockRecentsModel: RecentsModel = mock {
+        on { iconCache } doReturn mockIconCache
+
+        on { unregisterRecentTasksChangedListener() } doAnswer { recentTasksChangedListener = null }
+
+        on { registerRecentTasksChangedListener(any<RecentTasksChangedListener>()) } doAnswer
+            {
+                recentTasksChangedListener = it.getArgument<RecentTasksChangedListener>(0)
+            }
+
+        on { getTasks(anyOrNull(), anyOrNull()) } doAnswer
+            {
+                val request = it.getArgument<Consumer<List<GroupTask>>?>(0)
+                if (request != null) {
+                    taskRequests.add { response -> request.accept(response) }
+                }
+                taskListId
+            }
+
+        on { getTasks(anyOrNull()) } doAnswer
+            {
+                val request = it.getArgument<Consumer<List<GroupTask>>?>(0)
+                if (request != null) {
+                    taskRequests.add { response -> request.accept(response) }
+                }
+                taskListId
+            }
+
+        on { isTaskListValid(any()) } doAnswer { taskListId == it.getArgument(0) }
+    }
+}
diff --git a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/rules/MockedRecentsModelTestRule.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/rules/MockedRecentsModelTestRule.kt
index ed1443d..359b876 100644
--- a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/rules/MockedRecentsModelTestRule.kt
+++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/rules/MockedRecentsModelTestRule.kt
@@ -16,64 +16,17 @@
 
 package com.android.launcher3.taskbar.rules
 
-import com.android.quickstep.RecentsModel
-import com.android.quickstep.RecentsModel.RecentTasksChangedListener
-import com.android.quickstep.TaskIconCache
 import com.android.quickstep.util.GroupTask
-import java.util.function.Consumer
 import org.junit.rules.TestRule
 import org.junit.runner.Description
 import org.junit.runners.model.Statement
-import org.mockito.kotlin.any
-import org.mockito.kotlin.anyOrNull
-import org.mockito.kotlin.doAnswer
-import org.mockito.kotlin.doReturn
-import org.mockito.kotlin.mock
 
-class MockedRecentsModelTestRule(private val context: TaskbarWindowSandboxContext) : TestRule {
-
-    private val mockIconCache: TaskIconCache = mock()
-
-    private val mockRecentsModel: RecentsModel = mock {
-        on { iconCache } doReturn mockIconCache
-
-        on { unregisterRecentTasksChangedListener() } doAnswer { recentTasksChangedListener = null }
-
-        on { registerRecentTasksChangedListener(any<RecentTasksChangedListener>()) } doAnswer
-            {
-                recentTasksChangedListener = it.getArgument<RecentTasksChangedListener>(0)
-            }
-
-        on { getTasks(anyOrNull(), anyOrNull()) } doAnswer
-            {
-                val request = it.getArgument<Consumer<List<GroupTask>>?>(0)
-                if (request != null) {
-                    taskRequests.add { response -> request.accept(response) }
-                }
-                taskListId
-            }
-
-        on { getTasks(anyOrNull()) } doAnswer
-            {
-                val request = it.getArgument<Consumer<List<GroupTask>>?>(0)
-                if (request != null) {
-                    taskRequests.add { response -> request.accept(response) }
-                }
-                taskListId
-            }
-
-        on { isTaskListValid(any()) } doAnswer { taskListId == it.getArgument(0) }
-    }
-
+class MockedRecentsModelTestRule(private val modelHelper: MockedRecentsModelHelper) : TestRule {
     private var recentTasks: List<GroupTask> = emptyList()
-    private var taskListId = 0
-    private var recentTasksChangedListener: RecentTasksChangedListener? = null
-    private var taskRequests: MutableList<(List<GroupTask>) -> Unit> = mutableListOf()
 
     override fun apply(base: Statement?, description: Description?): Statement {
         return object : Statement() {
             override fun evaluate() {
-                context.putObject(RecentsModel.INSTANCE, mockRecentsModel)
                 base?.evaluate()
             }
         }
@@ -82,15 +35,15 @@
     // NOTE: For the update to take effect, `resolvePendingTaskRequests()` needs to be called, so
     // calbacks to any pending `RecentsModel.getTasks()` get called with the updated task list.
     fun updateRecentTasks(tasks: List<GroupTask>) {
-        ++taskListId
+        ++modelHelper.taskListId
         recentTasks = tasks
-        recentTasksChangedListener?.onRecentTasksChanged()
+        modelHelper.recentTasksChangedListener?.onRecentTasksChanged()
     }
 
     fun resolvePendingTaskRequests() {
         val requests = mutableListOf<(List<GroupTask>) -> Unit>()
-        requests.addAll(taskRequests)
-        taskRequests.clear()
+        requests.addAll(modelHelper.taskRequests)
+        modelHelper.taskRequests.clear()
 
         requests.forEach { it(recentTasks) }
     }
diff --git a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/rules/TaskbarWindowSandboxContext.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/rules/TaskbarWindowSandboxContext.kt
index e6dc2a2..95e8980 100644
--- a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/rules/TaskbarWindowSandboxContext.kt
+++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/rules/TaskbarWindowSandboxContext.kt
@@ -47,10 +47,6 @@
 import org.junit.runner.Description
 import org.junit.runners.model.Statement
 
-/** Include additional bindings when building a [TaskbarSandboxComponent]. */
-typealias TaskbarComponentBinder =
-    TaskbarWindowSandboxContext.(TaskbarSandboxComponent.Builder) -> Unit
-
 /**
  * [SandboxApplication] for running Taskbar tests.
  *
@@ -61,7 +57,7 @@
 private constructor(
     private val base: SandboxApplication,
     val virtualDisplay: VirtualDisplay,
-    private val componentBinder: TaskbarComponentBinder?,
+    private val params: SandboxParams,
 ) : ContextWrapper(base), ObjectSandbox by base, TestRule {
 
     val settingsCacheSandbox = SettingsCacheSandbox()
@@ -76,10 +72,9 @@
             override fun before() {
                 val context = this@TaskbarWindowSandboxContext
                 val builder =
-                    DaggerTaskbarSandboxComponent.builder()
-                        .bindSystemUiProxy(SystemUiProxy(context))
+                    params.builderBase
+                        .bindSystemUiProxy(params.systemUiProxyProvider.invoke(context))
                         .bindSettingsCache(settingsCacheSandbox.cache)
-                componentBinder?.invoke(context, builder)
                 base.initDaggerComponent(builder)
             }
         }
@@ -95,10 +90,9 @@
         private const val VIRTUAL_DISPLAY_NAME = "TaskbarSandboxDisplay"
 
         /** Creates a [SandboxApplication] for Taskbar tests. */
-        fun create(componentBinder: TaskbarComponentBinder? = null): TaskbarWindowSandboxContext {
+        fun create(params: SandboxParams = SandboxParams()): TaskbarWindowSandboxContext {
             val base = ApplicationProvider.getApplicationContext<Context>()
             val displayManager = checkNotNull(base.getSystemService(DisplayManager::class.java))
-
             // Create virtual display to avoid clashing with Taskbar on default display.
             val virtualDisplay =
                 base.resources.displayMetrics.let {
@@ -115,7 +109,7 @@
             return TaskbarWindowSandboxContext(
                 SandboxApplication(base.createDisplayContext(virtualDisplay.display)),
                 virtualDisplay,
-                componentBinder,
+                params,
             )
         }
     }
@@ -157,3 +151,9 @@
         override fun build(): TaskbarSandboxComponent
     }
 }
+
+/** Include additional bindings when building a [TaskbarSandboxComponent]. */
+data class SandboxParams(
+    val systemUiProxyProvider: (Context) -> SystemUiProxy = { SystemUiProxy(it) },
+    val builderBase: TaskbarSandboxComponent.Builder = DaggerTaskbarSandboxComponent.builder(),
+)
diff --git a/quickstep/tests/multivalentTests/src/com/android/quickstep/RecentsModelTest.java b/quickstep/tests/multivalentTests/src/com/android/quickstep/RecentsModelTest.java
index 722e1da..2eb2e4c 100644
--- a/quickstep/tests/multivalentTests/src/com/android/quickstep/RecentsModelTest.java
+++ b/quickstep/tests/multivalentTests/src/com/android/quickstep/RecentsModelTest.java
@@ -43,6 +43,7 @@
 import com.android.launcher3.R;
 import com.android.launcher3.graphics.ThemeManager;
 import com.android.launcher3.icons.IconProvider;
+import com.android.launcher3.util.DaggerSingletonTracker;
 import com.android.launcher3.util.LockedUserState;
 import com.android.launcher3.util.SplitConfigurationOptions;
 import com.android.quickstep.util.GroupTask;
@@ -109,7 +110,7 @@
 
         mRecentsModel = new RecentsModel(mContext, mTasksList, mock(TaskIconCache.class),
                 mThumbnailCache, mock(IconProvider.class), mock(TaskStackChangeListeners.class),
-                mLockedUserState, () -> mThemeManager);
+                mLockedUserState, () -> mThemeManager, mock(DaggerSingletonTracker.class));
 
         mResource = mock(Resources.class);
         when(mResource.getInteger((R.integer.recentsThumbnailCacheSize))).thenReturn(3);
diff --git a/quickstep/tests/multivalentTests/src/com/android/quickstep/util/TaskGridNavHelperTest.kt b/quickstep/tests/multivalentTests/src/com/android/quickstep/util/TaskGridNavHelperTest.kt
index 7066d21..f2fa0c5 100644
--- a/quickstep/tests/multivalentTests/src/com/android/quickstep/util/TaskGridNavHelperTest.kt
+++ b/quickstep/tests/multivalentTests/src/com/android/quickstep/util/TaskGridNavHelperTest.kt
@@ -447,6 +447,37 @@
     }
 
     /*
+                   5   3  [1]
+        CLEAR_ALL
+                   6   4   2
+    */
+    @Test
+    fun equalLengthRows_noFocused_onTop_pressTabWithShift_noCycle_staysOnTop() {
+        assertThat(
+                getNextGridPage(currentPageTaskViewId = 1, DIRECTION_TAB, delta = -1, cycle = false)
+            )
+            .isEqualTo(1)
+    }
+
+    /*
+                   5   3   1
+       [CLEAR_ALL]
+                   6   4   2
+    */
+    @Test
+    fun equalLengthRows_noFocused_onClearAll_pressTab_noCycle_staysOnClearAll() {
+        assertThat(
+                getNextGridPage(
+                    currentPageTaskViewId = CLEAR_ALL_PLACEHOLDER_ID,
+                    DIRECTION_TAB,
+                    delta = 1,
+                    cycle = false,
+                )
+            )
+            .isEqualTo(CLEAR_ALL_PLACEHOLDER_ID)
+    }
+
+    /*
                         5   3   1
            CLEAR_ALL                FOCUSED_TASK←--DESKTOP
                         6   4   2
@@ -783,10 +814,11 @@
         bottomIds: IntArray = IntArray.wrap(2, 4, 6),
         largeTileIds: List<Int> = emptyList(),
         hasAddDesktopButton: Boolean = false,
+        cycle: Boolean = true,
     ): Int {
         val taskGridNavHelper =
             TaskGridNavHelper(topIds, bottomIds, largeTileIds, hasAddDesktopButton)
-        return taskGridNavHelper.getNextGridPage(currentPageTaskViewId, delta, direction, true)
+        return taskGridNavHelper.getNextGridPage(currentPageTaskViewId, delta, direction, cycle)
     }
 
     private companion object {
diff --git a/quickstep/tests/src/com/android/quickstep/TaplTestsQuickstep.java b/quickstep/tests/src/com/android/quickstep/TaplTestsQuickstep.java
index e0560e2..79d3c19 100644
--- a/quickstep/tests/src/com/android/quickstep/TaplTestsQuickstep.java
+++ b/quickstep/tests/src/com/android/quickstep/TaplTestsQuickstep.java
@@ -539,6 +539,24 @@
         }
     }
 
+    @Test
+    @PortraitLandscape
+    public void testDismissCancel() throws Exception {
+        startTestAppsWithCheck();
+        Overview overview = mLauncher.goHome().switchToOverview();
+        assertIsInState("Launcher internal state didn't switch to Overview",
+                ExpectedState.OVERVIEW);
+        final Integer numTasks = getFromRecentsView(RecentsView::getTaskViewCount);
+        OverviewTask task = overview.getCurrentTask();
+        assertNotNull("overview.getCurrentTask() returned null (2)", task);
+
+        task.dismissCancel();
+
+        runOnRecentsView(recentsView -> assertEquals(
+                "Canceling dismissing a task removed a task from Overview",
+                numTasks == null ? 0 : numTasks, recentsView.getTaskViewCount()));
+    }
+
     private void startTestAppsWithCheck() throws Exception {
         startTestApps();
         expectLaunchedAppState();
diff --git a/res/values/config.xml b/res/values/config.xml
index a545f0c..07f97bc 100644
--- a/res/values/config.xml
+++ b/res/values/config.xml
@@ -117,6 +117,10 @@
     <item name="swipe_up_rect_y_damping_ratio" type="dimen" format="float">0.95</item>
     <item name="swipe_up_rect_y_stiffness" type="dimen" format="float">400</item>
 
+    <!-- Expressive Dismiss -->
+    <item name="expressive_dismiss_task_trans_y_damping_ratio" type="dimen" format="float">0.6</item>
+    <item name="expressive_dismiss_task_trans_y_stiffness" type="dimen" format="float">900</item>
+
     <!-- Taskbar -->
     <!-- This is a float because it is converted to dp later in DeviceProfile -->
     <item name="taskbar_icon_size" type="dimen" format="float">0</item>
diff --git a/res/values/dimens.xml b/res/values/dimens.xml
index c48f140..c3cb31d 100644
--- a/res/values/dimens.xml
+++ b/res/values/dimens.xml
@@ -480,6 +480,7 @@
     <dimen name="task_thumbnail_icon_drawable_size_grid">0dp</dimen>
     <dimen name="task_thumbnail_icon_menu_drawable_touch_size">0dp</dimen>
     <dimen name="task_menu_edge_padding">0dp</dimen>
+    <dimen name="task_dismiss_max_undershoot">0dp</dimen>
     <dimen name="overview_task_margin">0dp</dimen>
     <dimen name="overview_actions_height">0dp</dimen>
     <dimen name="overview_actions_button_spacing">0dp</dimen>
diff --git a/src/com/android/launcher3/BubbleTextView.java b/src/com/android/launcher3/BubbleTextView.java
index d3684b2..fb847f9 100644
--- a/src/com/android/launcher3/BubbleTextView.java
+++ b/src/com/android/launcher3/BubbleTextView.java
@@ -77,7 +77,6 @@
 import com.android.launcher3.folder.FolderIcon;
 import com.android.launcher3.graphics.IconShape;
 import com.android.launcher3.graphics.PreloadIconDrawable;
-import com.android.launcher3.graphics.ThemeManager;
 import com.android.launcher3.icons.DotRenderer;
 import com.android.launcher3.icons.FastBitmapDrawable;
 import com.android.launcher3.icons.IconCache.ItemInfoUpdateReceiver;
@@ -484,9 +483,7 @@
     }
 
     private void setNonPendingIcon(ItemInfoWithIcon info) {
-        ThemeManager themeManager = ThemeManager.INSTANCE.get(getContext());
-        int flags = (shouldUseTheme()
-                && themeManager.isMonoThemeEnabled()) ? FLAG_THEMED : 0;
+        int flags = shouldUseTheme() ? FLAG_THEMED : 0;
         // Remove badge on icons smaller than 48dp.
         if (mHideBadge || mDisplay == DISPLAY_SEARCH_RESULT_SMALL) {
             flags |= FLAG_NO_BADGE;
diff --git a/src/com/android/launcher3/Utilities.java b/src/com/android/launcher3/Utilities.java
index d93c07f..cb3a0bc 100644
--- a/src/com/android/launcher3/Utilities.java
+++ b/src/com/android/launcher3/Utilities.java
@@ -658,9 +658,9 @@
                         appState.getInvariantDeviceProfile().fillResIconDpi);
                 // Only fetch badge if the icon is on workspace
                 if (info.id != ItemInfo.NO_ID && badge == null) {
-                    badge = appState.getIconCache().getShortcutInfoBadge(si)
-                            .newIcon(context, ThemeManager.INSTANCE.get(context)
-                                    .isMonoThemeEnabled() ? FLAG_THEMED : 0);
+                    badge = appState.getIconCache().getShortcutInfoBadge(si).newIcon(
+                            context, ThemeManager.INSTANCE.get(context).isIconThemeEnabled()
+                                    ? FLAG_THEMED : 0);
                 }
             }
         } else if (info.itemType == LauncherSettings.Favorites.ITEM_TYPE_FOLDER) {
diff --git a/src/com/android/launcher3/graphics/ThemeManager.kt b/src/com/android/launcher3/graphics/ThemeManager.kt
index 9f35e4a..242220a 100644
--- a/src/com/android/launcher3/graphics/ThemeManager.kt
+++ b/src/com/android/launcher3/graphics/ThemeManager.kt
@@ -40,8 +40,8 @@
 open class ThemeManager
 @Inject
 constructor(
-    @ApplicationContext private val context: Context,
-    private val prefs: LauncherPrefs,
+    @ApplicationContext protected val context: Context,
+    protected val prefs: LauncherPrefs,
     lifecycle: DaggerSingletonTracker,
 ) {
 
@@ -53,9 +53,11 @@
         set(value) = prefs.put(THEMED_ICONS, value)
         get() = prefs.get(THEMED_ICONS)
 
-    var themeController: IconThemeController? =
-        if (isMonoThemeEnabled) MonoIconThemeController() else null
-        private set
+    val themeController: IconThemeController?
+        get() = iconState.themeController
+
+    val isIconThemeEnabled: Boolean
+        get() = themeController != null
 
     private val listeners = CopyOnWriteArrayList<ThemeChangeListener>()
 
@@ -77,12 +79,10 @@
         }
     }
 
-    private fun verifyIconState() {
+    protected fun verifyIconState() {
         val newState = parseIconState()
         if (newState == iconState) return
-
         iconState = newState
-        themeController = if (isMonoThemeEnabled) MonoIconThemeController() else null
 
         listeners.forEach { it.onThemeChanged() }
     }
@@ -105,15 +105,19 @@
         return IconState(
             iconMask = iconMask,
             folderShapeMask = shapeModel?.folderPathString ?: iconMask,
-            isMonoTheme = isMonoThemeEnabled,
+            themeController = createThemeController(),
         )
     }
 
+    protected open fun createThemeController(): IconThemeController? {
+        return if (isMonoThemeEnabled) MONO_THEME_CONTROLLER else null
+    }
+
     data class IconState(
         val iconMask: String,
         val folderShapeMask: String,
-        val isMonoTheme: Boolean,
-        val themeCode: String = if (isMonoTheme) "with-theme" else "no-theme",
+        val themeController: IconThemeController?,
+        val themeCode: String = themeController?.themeID ?: "no-theme",
     ) {
         fun toUniqueId() = "${iconMask.hashCode()},$themeCode"
     }
@@ -135,5 +139,8 @@
         private const val ACTION_OVERLAY_CHANGED = "android.intent.action.OVERLAY_CHANGED"
         private val CONFIG_ICON_MASK_RES_ID: Int =
             Resources.getSystem().getIdentifier("config_icon_mask", "string", "android")
+
+        // Use a constant to allow equality check in verifyIconState
+        private val MONO_THEME_CONTROLLER = MonoIconThemeController()
     }
 }
diff --git a/src/com/android/launcher3/model/data/ItemInfoWithIcon.java b/src/com/android/launcher3/model/data/ItemInfoWithIcon.java
index 7fb0152..ff40f30 100644
--- a/src/com/android/launcher3/model/data/ItemInfoWithIcon.java
+++ b/src/com/android/launcher3/model/data/ItemInfoWithIcon.java
@@ -323,7 +323,7 @@
      * Returns a FastBitmapDrawable with the icon and context theme applied
      */
     public FastBitmapDrawable newIcon(Context context, @DrawableCreationFlags int creationFlags) {
-        if (!ThemeManager.INSTANCE.get(context).isMonoThemeEnabled()) {
+        if (!ThemeManager.INSTANCE.get(context).isIconThemeEnabled()) {
             creationFlags &= ~FLAG_THEMED;
         }
         FastBitmapDrawable drawable = bitmap.newIcon(context, creationFlags);
diff --git a/tests/multivalentTests/src/com/android/launcher3/graphics/ThemeManagerTest.kt b/tests/multivalentTests/src/com/android/launcher3/graphics/ThemeManagerTest.kt
index 43b7b68..85c1156 100644
--- a/tests/multivalentTests/src/com/android/launcher3/graphics/ThemeManagerTest.kt
+++ b/tests/multivalentTests/src/com/android/launcher3/graphics/ThemeManagerTest.kt
@@ -21,11 +21,13 @@
 import com.android.launcher3.FakeLauncherPrefs
 import com.android.launcher3.dagger.LauncherAppComponent
 import com.android.launcher3.dagger.LauncherAppSingleton
+import com.android.launcher3.icons.mono.MonoIconThemeController
 import com.android.launcher3.util.AllModulesForTest
 import com.android.launcher3.util.Executors.MAIN_EXECUTOR
 import com.android.launcher3.util.FakePrefsModule
 import com.android.launcher3.util.SandboxApplication
 import com.android.launcher3.util.TestUtil
+import com.google.common.truth.Truth.assertThat
 import dagger.Component
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertFalse
@@ -55,12 +57,13 @@
         themeManager.isMonoThemeEnabled = true
         TestUtil.runOnExecutorSync(MAIN_EXECUTOR) {}
         assertTrue(themeManager.isMonoThemeEnabled)
-        assertTrue(themeManager.iconState.isMonoTheme)
+        assertThat(themeManager.iconState.themeController)
+            .isInstanceOf(MonoIconThemeController::class.java)
 
         themeManager.isMonoThemeEnabled = false
         TestUtil.runOnExecutorSync(MAIN_EXECUTOR) {}
         assertFalse(themeManager.isMonoThemeEnabled)
-        assertFalse(themeManager.iconState.isMonoTheme)
+        assertThat(themeManager.iconState.themeController).isNull()
     }
 
     @Test
diff --git a/tests/tapl/com/android/launcher3/tapl/OverviewTask.java b/tests/tapl/com/android/launcher3/tapl/OverviewTask.java
index 2431ef5..1158521 100644
--- a/tests/tapl/com/android/launcher3/tapl/OverviewTask.java
+++ b/tests/tapl/com/android/launcher3/tapl/OverviewTask.java
@@ -211,6 +211,36 @@
     }
 
     /**
+     * Starts dismissing the task by swiping up, then cancels, and task springs back to start.
+     */
+    public void dismissCancel() {
+        try (LauncherInstrumentation.Closable e = mLauncher.eventsCheck();
+             LauncherInstrumentation.Closable c = mLauncher.addContextLayer(
+                     "want to start dismissing an overview task then cancel")) {
+            verifyActiveContainer();
+            int taskCountBeforeDismiss = mOverview.getTaskCount();
+            mLauncher.assertNotEquals("Unable to find a task", 0, taskCountBeforeDismiss);
+
+            final Rect taskBounds = mLauncher.getVisibleBounds(mTask);
+            final int centerX = taskBounds.centerX();
+            final int centerY = taskBounds.bottom - 1;
+            final int endCenterY = centerY - (taskBounds.height() / 4);
+            mLauncher.executeAndWaitForLauncherEvent(
+                    // Set slowDown to true so we do not fling the task at the end of the drag, as
+                    // we want it to cancel and return back to the origin. We use 30 steps to
+                    // perform the gesture slowly as well, to avoid flinging.
+                    () -> mLauncher.linearGesture(centerX, centerY, centerX, endCenterY,
+                            /* steps= */ 30, /* slowDown= */ true,
+                            LauncherInstrumentation.GestureScope.DONT_EXPECT_PILFER),
+                    event -> TestProtocol.DISMISS_ANIMATION_ENDS_MESSAGE.equals(
+                            event.getClassName()),
+                    () -> "Canceling swipe to dismiss did not end with task at origin.",
+                    "cancel swiping to dismiss");
+
+        }
+    }
+
+    /**
      * Clicks the task.
      */
     public LaunchedAppState open() {