Merge "Update Overview flag bug IDs" into main
diff --git a/quickstep/res/values/dimens.xml b/quickstep/res/values/dimens.xml
index 6196be4..36e6902 100644
--- a/quickstep/res/values/dimens.xml
+++ b/quickstep/res/values/dimens.xml
@@ -371,7 +371,6 @@
     <dimen name="taskbar_running_app_indicator_height">2dp</dimen>
     <dimen name="taskbar_running_app_indicator_width">12dp</dimen>
     <dimen name="taskbar_running_app_indicator_top_margin">4dp</dimen>
-    <dimen name="taskbar_minimized_app_indicator_width">6dp</dimen>
 
     <!-- Transient taskbar -->
     <dimen name="transient_taskbar_padding">12dp</dimen>
diff --git a/quickstep/src/com/android/launcher3/taskbar/PinToTaskbarShortcut.kt b/quickstep/src/com/android/launcher3/taskbar/PinToTaskbarShortcut.kt
index b9a211d..da6932f 100644
--- a/quickstep/src/com/android/launcher3/taskbar/PinToTaskbarShortcut.kt
+++ b/quickstep/src/com/android/launcher3/taskbar/PinToTaskbarShortcut.kt
@@ -17,9 +17,15 @@
 package com.android.launcher3.taskbar
 
 import android.content.Context
+import android.util.SparseArray
 import android.view.View
+import com.android.launcher3.DeviceProfile
+import com.android.launcher3.LauncherAppState
+import com.android.launcher3.LauncherSettings.Favorites.CONTAINER_HOTSEAT
 import com.android.launcher3.R
+import com.android.launcher3.model.BgDataModel
 import com.android.launcher3.model.data.ItemInfo
+import com.android.launcher3.model.data.WorkspaceItemInfo
 import com.android.launcher3.popup.SystemShortcut
 import com.android.launcher3.views.ActivityContext
 
@@ -27,16 +33,59 @@
  * A single menu item shortcut to allow users to pin an item to the taskbar and unpin an item from
  * the taskbar.
  */
-class PinToTaskbarShortcut<T>(target: T, itemInfo: ItemInfo?, originalView: View, isPin: Boolean) :
+class PinToTaskbarShortcut<T>(
+    target: T,
+    itemInfo: ItemInfo?,
+    originalView: View,
+    private val mIsPin: Boolean,
+    private val mPinnedInfoList: SparseArray<ItemInfo?>,
+) :
     SystemShortcut<T>(
-        if (isPin) R.drawable.ic_pin else R.drawable.ic_unpin,
-        if (isPin) R.string.pin_to_taskbar else R.string.unpin_from_taskbar,
+        if (mIsPin) R.drawable.ic_pin else R.drawable.ic_unpin,
+        if (mIsPin) R.string.pin_to_taskbar else R.string.unpin_from_taskbar,
         target,
         itemInfo,
         originalView,
     ) where T : Context?, T : ActivityContext? {
 
     override fun onClick(v: View?) {
-        // TODO(b/375648361): Pin/Unpin the item here.
+        dismissTaskMenuView()
+        // Create a placeholder callbacks for the writer to notify other launcher model callbacks
+        // after update.
+        val callbacks: BgDataModel.Callbacks = object : BgDataModel.Callbacks {}
+
+        val writer =
+            LauncherAppState.getInstance(mOriginalView.context)
+                .model
+                .getWriter(true, mTarget!!.cellPosMapper, callbacks)
+
+        if (!mIsPin) {
+            writer.deleteItemFromDatabase(mItemInfo, "item unpinned through long-press menu")
+            return
+        }
+
+        val newInfo =
+            if (mItemInfo is com.android.launcher3.model.data.AppInfo) {
+                mItemInfo.makeWorkspaceItem(mOriginalView.context)
+            } else if (mItemInfo is WorkspaceItemInfo) {
+                mItemInfo.clone()
+            } else {
+                return
+            }
+
+        val dp: DeviceProfile = mTarget.deviceProfile
+        var targetIdx = -1
+
+        for (i in 0 until dp.numShownHotseatIcons) {
+            if (mPinnedInfoList[i] == null) {
+                targetIdx = i
+                break
+            }
+        }
+
+        val cellX = if (dp.isVerticalBarLayout()) 0 else targetIdx
+        val cellY = if (dp.isVerticalBarLayout()) (dp.numShownHotseatIcons - (targetIdx + 1)) else 0
+
+        writer.addItemToDatabase(newInfo, CONTAINER_HOTSEAT, mItemInfo.screenId, cellX, cellY)
     }
 }
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarOverflowView.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarOverflowView.java
index 017a12c..d909d19 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarOverflowView.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarOverflowView.java
@@ -165,7 +165,6 @@
     private boolean mIsRtlLayout;
     private final List<Task> mItems = new ArrayList<Task>();
     private int mIconSize;
-    private int mPadding;
     private Paint mItemBackgroundPaint;
     private final MultiTranslateDelegate mTranslateDelegate = new MultiTranslateDelegate(this);
     private float mScaleForReorderBounce = 1f;
@@ -214,25 +213,25 @@
         TaskbarOverflowView icon = (TaskbarOverflowView) inflater.inflate(resId, group, false);
 
         icon.mIconSize = iconSize;
-        icon.mPadding = padding;
 
         final float taskbarIconRadius =
-                iconSize * IconNormalizer.ICON_VISIBLE_AREA_FACTOR / 2f - padding;
+                (iconSize - padding * 2f) * IconNormalizer.ICON_VISIBLE_AREA_FACTOR / 2f;
 
         icon.mLeaveBehindSizeDefault = taskbarIconRadius;  // 1/2 of taskbar app icon size
         icon.mLeaveBehindSizeScaledDown =
                 icon.mLeaveBehindSizeDefault * LEAVE_BEHIND_SIZE_SCALE_DOWN_MULTIPLIER;
         icon.mLeaveBehindSize = icon.mLeaveBehindSizeScaledDown;
 
-        icon.mItemIconStrokeWidthDefault = taskbarIconRadius / 5f;  // 1/10 of taskbar app icon size
+        icon.mItemIconStrokeWidthDefault =
+                taskbarIconRadius / 10f;  // 1/20 of taskbar app icon size
         icon.mItemIconStrokeWidth = icon.mItemIconStrokeWidthDefault;
 
-        icon.mItemIconSizeDefault = 2 * (taskbarIconRadius - icon.mItemIconStrokeWidthDefault)
-                * TWO_ITEM_ICONS_BOX_ASPECT_RATIO;
+        icon.mItemIconSizeDefault = 2f * taskbarIconRadius * TWO_ITEM_ICONS_BOX_ASPECT_RATIO;
         icon.mItemIconSizeScaledDown = icon.mLeaveBehindSizeScaledDown;
         icon.mItemIconSize = icon.mItemIconSizeDefault;
 
-        icon.mItemIconCenterOffsetDefault = taskbarIconRadius - icon.mItemIconSizeDefault / 2f
+        icon.mItemIconCenterOffsetDefault = taskbarIconRadius
+                - icon.mItemIconSizeDefault * IconNormalizer.ICON_VISIBLE_AREA_FACTOR / 2f
                 - icon.mItemIconStrokeWidthDefault;
         icon.mItemIconCenterOffset = icon.mItemIconCenterOffsetDefault;
 
@@ -242,9 +241,8 @@
     private void init() {
         mIsRtlLayout = Utilities.isRtl(getResources());
         mItemBackgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
-        mItemBackgroundColor = getContext().getColor(Utilities.isDarkTheme(getContext())
-                ? com.android.internal.R.color.materialColorSurface
-                : com.android.internal.R.color.materialColorInverseOnSurface);
+        mItemBackgroundColor = getContext().getColor(
+                com.android.internal.R.color.materialColorInverseOnSurface);
         mLeaveBehindColor = Themes.getAttrColor(getContext(), android.R.attr.textColorTertiary);
 
         setWillNotDraw(false);
@@ -260,8 +258,9 @@
 
     private void drawAppIcons(@NonNull Canvas canvas) {
         mItemBackgroundPaint.setColor(mItemBackgroundColor);
-        float radius = mIconSize / 2f - mPadding;
+        float canvasCenterXY = mIconSize / 2f;
         int adjustedItemIconSize = Math.round(mItemIconSize);
+        float itemIconRadius = adjustedItemIconSize / 2f;
 
         int itemsToShow = Math.min(mItems.size(), MAX_ITEMS_IN_PREVIEW);
         for (int i = itemsToShow - 1; i >= 0; --i) {
@@ -280,12 +279,12 @@
                     BlendMode.SRC_ATOP));
 
             canvas.save();
-            float itemIconRadius = adjustedItemIconSize / 2f;
             canvas.translate(
-                    mPadding + itemCenterX + radius - itemIconRadius,
-                    mPadding + itemCenterY + radius - itemIconRadius);
+                    canvasCenterXY + itemCenterX - itemIconRadius,
+                    canvasCenterXY + itemCenterY - itemIconRadius);
             canvas.drawCircle(itemIconRadius, itemIconRadius,
-                    itemIconRadius + mItemIconStrokeWidth, mItemBackgroundPaint);
+                    itemIconRadius * IconNormalizer.ICON_VISIBLE_AREA_FACTOR + mItemIconStrokeWidth,
+                    mItemBackgroundPaint);
             iconCopy.draw(canvas);
             canvas.restore();
         }
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarPopupController.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarPopupController.java
index 6ab71e9..1a6cd60 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarPopupController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarPopupController.java
@@ -216,11 +216,13 @@
             return null;
         }
         if (itemInfo.container == CONTAINER_HOTSEAT) {
-            return new PinToTaskbarShortcut<>(target, itemInfo, originalView, false);
+            return new PinToTaskbarShortcut<>(target, itemInfo, originalView, false,
+                    mHotseatInfosList);
         }
         if (mHotseatInfosList.size()
                 < mContext.getTaskbarSpecsEvaluator().getNumShownHotseatIcons()) {
-            return new PinToTaskbarShortcut<>(target, itemInfo, originalView, true);
+            return new PinToTaskbarShortcut<>(target, itemInfo, originalView, true,
+                    mHotseatInfosList);
         }
 
         return null;
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarViewController.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarViewController.java
index 0fe0224..a80e2c4 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarViewController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarViewController.java
@@ -24,6 +24,7 @@
 import static com.android.app.animation.Interpolators.EMPHASIZED;
 import static com.android.app.animation.Interpolators.FINAL_FRAME;
 import static com.android.app.animation.Interpolators.LINEAR;
+import static com.android.launcher3.BubbleTextView.LINE_INDICATOR_ANIM_DURATION;
 import static com.android.launcher3.Flags.enableScalingRevealHomeAnimation;
 import static com.android.launcher3.Flags.taskbarOverflow;
 import static com.android.launcher3.LauncherAnimUtils.SCALE_PROPERTY;
@@ -138,6 +139,8 @@
     private static final int TRANSITION_DEFAULT_DURATION = 500;
     private static final int TRANSITION_FADE_IN_DURATION = 167;
     private static final int TRANSITION_FADE_OUT_DURATION = 83;
+    private static final int APPEARING_LINE_INDICATOR_ANIM_DELAY =
+            TRANSITION_DEFAULT_DURATION - LINE_INDICATOR_ANIM_DURATION;
 
     private final TaskbarActivityContext mActivity;
     private @Nullable TaskbarDragLayerController mDragLayerController;
@@ -736,7 +739,7 @@
     public void updateIconViewsRunningStates() {
         for (View iconView : getIconViews()) {
             if (iconView instanceof BubbleTextView btv) {
-                btv.updateRunningState(getRunningAppState(btv));
+                updateRunningState(btv);
                 if (shouldUpdateIconContentDescription(btv)) {
                     btv.setContentDescription(
                             btv.getContentDescription() + " " + btv.getIconStateDescription());
@@ -770,6 +773,10 @@
         return pinnedAppsWithTasks;
     }
 
+    private void updateRunningState(BubbleTextView btv) {
+        btv.updateRunningState(getRunningAppState(btv), mTaskbarView.getLayoutTransition() != null);
+    }
+
     private BubbleTextView.RunningAppState getRunningAppState(BubbleTextView btv) {
         Object tag = btv.getTag();
         if (tag instanceof TaskItemInfo itemInfo) {
@@ -1225,13 +1232,22 @@
                     view.setAlpha(0f);
                     view.setScaleX(0f);
                     view.setScaleY(0f);
+                    if (view instanceof BubbleTextView btv) {
+                        // Defer so that app is mostly scaled in before showing indicator.
+                        btv.setLineIndicatorAnimStartDelay(APPEARING_LINE_INDICATOR_ANIM_DELAY);
+                    }
+                } else if (type == DISAPPEARING && view instanceof BubbleTextView btv) {
+                    // Running state updates happen after removing this view, so update it here.
+                    updateRunningState(btv);
                 }
             }
 
             @Override
             public void endTransition(
                     LayoutTransition transition, ViewGroup container, View view, int type) {
-                // Do nothing.
+                if (type == APPEARING && view instanceof BubbleTextView btv) {
+                    btv.setLineIndicatorAnimStartDelay(0);
+                }
             }
         });
 
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 30cfafe..0da8c1f 100644
--- a/quickstep/src/com/android/launcher3/taskbar/bubbles/animation/BubbleBarViewAnimator.kt
+++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/animation/BubbleBarViewAnimator.kt
@@ -215,6 +215,7 @@
         animator.spring(DynamicAnimation.TRANSLATION_Y, totalTranslationY, initialVelocity ?: 0f)
         animator.addUpdateListener { handle, values ->
             val ty = values[DynamicAnimation.TRANSLATION_Y]?.value ?: return@addUpdateListener
+            if (animatingBubble == null) return@addUpdateListener
             when {
                 ty >= stashedHandleTranslationYForAnimation -> {
                     // we're in the first leg of the animation. only animate the handle. the bubble
diff --git a/quickstep/src/com/android/quickstep/TaskOverlayFactory.java b/quickstep/src/com/android/quickstep/TaskOverlayFactory.java
index deb06c9..df66a5e 100644
--- a/quickstep/src/com/android/quickstep/TaskOverlayFactory.java
+++ b/quickstep/src/com/android/quickstep/TaskOverlayFactory.java
@@ -42,8 +42,7 @@
 import com.android.launcher3.util.ResourceBasedOverride;
 import com.android.launcher3.views.ActivityContext;
 import com.android.launcher3.views.Snackbar;
-import com.android.quickstep.task.thumbnail.TaskOverlayUiState;
-import com.android.quickstep.task.util.TaskOverlayHelper;
+import com.android.quickstep.recents.domain.usecase.ThumbnailPosition;
 import com.android.quickstep.util.RecentsOrientedState;
 import com.android.quickstep.views.DesktopTaskView;
 import com.android.quickstep.views.GroupedTaskView;
@@ -53,6 +52,7 @@
 import com.android.quickstep.views.TaskContainer;
 import com.android.quickstep.views.TaskView;
 import com.android.systemui.shared.recents.model.Task;
+import com.android.systemui.shared.recents.model.ThumbnailData;
 
 import java.util.ArrayList;
 import java.util.List;
@@ -135,54 +135,37 @@
 
         private T mActionsView;
         protected ImageActionsApi mImageApi;
-        protected TaskOverlayHelper mHelper;
+        private ThumbnailData mThumbnailData = null;
 
         protected TaskOverlay(TaskContainer taskContainer) {
             mApplicationContext = taskContainer.getTaskView().getContext().getApplicationContext();
             mTaskContainer = taskContainer;
-            if (enableRefactorTaskThumbnail()) {
-                mHelper = new TaskOverlayHelper(mTaskContainer.getTask(), this);
-            }
             mImageApi = new ImageActionsApi(mApplicationContext, this::getThumbnail);
         }
 
-        /**
-         * Initialize the overlay when a Task is bound to the TaskView.
-         */
-        public void init() {
-            if (enableRefactorTaskThumbnail()) {
-                mHelper.init();
-            }
-        }
-
-        /**
-         * Destroy the overlay when the TaskView is recycled.
-         */
-        public void destroy() {
-            if (enableRefactorTaskThumbnail()) {
-                mHelper.destroy();
-            }
+        public void setThumbnailState(@Nullable ThumbnailData thumbnailData) {
+            mThumbnailData = thumbnailData;
         }
 
         protected @Nullable Bitmap getThumbnail() {
-            return enableRefactorTaskThumbnail() ? mHelper.getEnabledState().getThumbnail()
-                    : mTaskContainer.getThumbnailViewDeprecated().getThumbnail();
+            if (enableRefactorTaskThumbnail()) {
+                return mThumbnailData == null ? null : mThumbnailData.getThumbnail();
+            } else {
+                return mTaskContainer.getThumbnailViewDeprecated().getThumbnail();
+            }
         }
-
         /**
          * Returns whether the snapshot is real. If the device is locked for the user of the task,
          * the snapshot used will be an app-theme generated snapshot instead of a real snapshot.
          */
-        public boolean isRealSnapshot() {
+        protected boolean isRealSnapshot() {
             if (enableRefactorTaskThumbnail()) {
-                if (mHelper.getUiState() instanceof TaskOverlayUiState.Enabled) {
-                    return mHelper.getEnabledState().isRealSnapshot();
-                } else {
-                    return false;
-                }
-            }
+                if (mThumbnailData == null) return false;
 
-            return mTaskContainer.getThumbnailViewDeprecated().isRealSnapshot();
+                return mThumbnailData.isRealSnapshot && !mTaskContainer.getTask().isLocked;
+            } else {
+                return mTaskContainer.getThumbnailViewDeprecated().isRealSnapshot();
+            }
         }
 
         /**
@@ -190,7 +173,8 @@
          */
         public boolean isThumbnailRotationDifferentFromTask() {
             if (enableRefactorTaskThumbnail()) {
-                return mHelper.getThumbnailPositionState().isRotated();
+                ThumbnailPosition thumbnailPosition = mTaskContainer.getThumbnailPosition();
+                return thumbnailPosition != null && thumbnailPosition.isRotated();
             }
 
             return mTaskContainer.getThumbnailViewDeprecated()
@@ -340,9 +324,16 @@
             // inverse tells us where the view would be in the bitmaps coordinates. The insets are
             // the difference between the bitmap bounds and the projected view bounds.
             Matrix boundsToBitmapSpace = new Matrix();
-            Matrix thumbnailMatrix = enableRefactorTaskThumbnail()
-                    ? mHelper.getThumbnailMatrix()
-                    : mTaskContainer.getThumbnailViewDeprecated().getThumbnailMatrix();
+            Matrix thumbnailMatrix;
+            if (enableRefactorTaskThumbnail()) {
+                if (mTaskContainer.getThumbnailPosition() != null) {
+                    thumbnailMatrix = mTaskContainer.getThumbnailPosition().getMatrix();
+                } else {
+                    thumbnailMatrix = Matrix.IDENTITY_MATRIX;
+                }
+            } else {
+                thumbnailMatrix = mTaskContainer.getThumbnailViewDeprecated().getThumbnailMatrix();
+            }
             thumbnailMatrix.invert(boundsToBitmapSpace);
             RectF boundsInBitmapSpace = new RectF();
             boundsToBitmapSpace.mapRect(boundsInBitmapSpace, viewRect);
diff --git a/quickstep/src/com/android/quickstep/recents/ui/viewmodel/TaskTileUiState.kt b/quickstep/src/com/android/quickstep/recents/ui/viewmodel/TaskTileUiState.kt
index 118a931..8a6a805 100644
--- a/quickstep/src/com/android/quickstep/recents/ui/viewmodel/TaskTileUiState.kt
+++ b/quickstep/src/com/android/quickstep/recents/ui/viewmodel/TaskTileUiState.kt
@@ -36,6 +36,7 @@
     val isLiveTile: Boolean,
     val hasHeader: Boolean,
     val sysUiStatusNavFlags: Int,
+    val taskOverlayEnabled: Boolean,
 )
 
 sealed class TaskData {
diff --git a/quickstep/src/com/android/quickstep/recents/ui/viewmodel/TaskViewModel.kt b/quickstep/src/com/android/quickstep/recents/ui/viewmodel/TaskViewModel.kt
index 3c4a384..09e2071 100644
--- a/quickstep/src/com/android/quickstep/recents/ui/viewmodel/TaskViewModel.kt
+++ b/quickstep/src/com/android/quickstep/recents/ui/viewmodel/TaskViewModel.kt
@@ -29,6 +29,7 @@
 import com.android.quickstep.recents.domain.usecase.ThumbnailPosition
 import com.android.quickstep.recents.viewmodel.RecentsViewData
 import com.android.quickstep.views.TaskViewType
+import com.android.quickstep.views.TaskViewType.SINGLE
 import com.android.systemui.shared.recents.model.ThumbnailData
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.flow.Flow
@@ -74,8 +75,18 @@
             )
         }
 
+    private val overlayEnabled =
+        combine(recentsViewData.overlayEnabled, recentsViewData.settledFullyVisibleTaskIds) {
+                isOverlayEnabled,
+                settledFullyVisibleTaskIds ->
+                taskViewType == SINGLE &&
+                    isOverlayEnabled &&
+                    settledFullyVisibleTaskIds.any { it in taskIds.value }
+            }
+            .distinctUntilChanged()
+
     val state: Flow<TaskTileUiState> =
-        combine(taskData, isLiveTile) { tasks, isLiveTile -> mapToTaskTile(tasks, isLiveTile) }
+        combine(taskData, isLiveTile, overlayEnabled, ::mapToTaskTile)
             .distinctUntilChanged()
             .flowOn(dispatcherProvider.background)
 
@@ -99,13 +110,18 @@
             isRtl = isRtl,
         )
 
-    private fun mapToTaskTile(tasks: List<TaskData>, isLiveTile: Boolean): TaskTileUiState {
+    private fun mapToTaskTile(
+        tasks: List<TaskData>,
+        isLiveTile: Boolean,
+        overlayEnabled: Boolean,
+    ): TaskTileUiState {
         val firstThumbnailData = (tasks.firstOrNull() as? TaskData.Data)?.thumbnailData
         return TaskTileUiState(
             tasks = tasks,
             isLiveTile = isLiveTile,
             hasHeader = taskViewType == TaskViewType.DESKTOP,
             sysUiStatusNavFlags = getSysUiStatusNavFlagsUseCase(firstThumbnailData),
+            taskOverlayEnabled = overlayEnabled,
         )
     }
 
diff --git a/quickstep/src/com/android/quickstep/task/thumbnail/TaskOverlayUiState.kt b/quickstep/src/com/android/quickstep/task/thumbnail/TaskOverlayUiState.kt
deleted file mode 100644
index 5fb5b90..0000000
--- a/quickstep/src/com/android/quickstep/task/thumbnail/TaskOverlayUiState.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-/*
- * 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.thumbnail
-
-import android.graphics.Bitmap
-
-/** Ui state for [com.android.quickstep.TaskOverlayFactory.TaskOverlay] */
-sealed class TaskOverlayUiState {
-    data object Disabled : TaskOverlayUiState()
-
-    data class Enabled(val isRealSnapshot: Boolean, val thumbnail: Bitmap?) : TaskOverlayUiState()
-}
diff --git a/quickstep/src/com/android/quickstep/task/util/TaskOverlayHelper.kt b/quickstep/src/com/android/quickstep/task/util/TaskOverlayHelper.kt
deleted file mode 100644
index d8aea9d..0000000
--- a/quickstep/src/com/android/quickstep/task/util/TaskOverlayHelper.kt
+++ /dev/null
@@ -1,128 +0,0 @@
-/*
- * 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.util
-
-import android.util.Log
-import android.view.View.OnLayoutChangeListener
-import com.android.launcher3.util.coroutines.DispatcherProvider
-import com.android.quickstep.TaskOverlayFactory
-import com.android.quickstep.recents.di.RecentsDependencies
-import com.android.quickstep.recents.di.get
-import com.android.quickstep.task.thumbnail.TaskOverlayUiState
-import com.android.quickstep.task.thumbnail.TaskOverlayUiState.Disabled
-import com.android.quickstep.task.thumbnail.TaskOverlayUiState.Enabled
-import com.android.quickstep.task.viewmodel.TaskOverlayViewModel
-import com.android.systemui.shared.recents.model.Task
-import kotlinx.coroutines.CoroutineName
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.cancel
-import kotlinx.coroutines.flow.dropWhile
-import kotlinx.coroutines.flow.flowOn
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.launch
-
-/**
- * Helper for [TaskOverlayFactory.TaskOverlay] to interact with [TaskOverlayViewModel], this helper
- * should merge with [TaskOverlayFactory.TaskOverlay] when it's migrated to MVVM.
- */
-class TaskOverlayHelper(val task: Task, val overlay: TaskOverlayFactory.TaskOverlay<*>) {
-    private val scope = overlay.taskView.context
-    private val recentsCoroutineScope: CoroutineScope = RecentsDependencies.get(scope)
-    private val dispatcherProvider: DispatcherProvider = RecentsDependencies.get(scope)
-    private lateinit var overlayInitializedScope: CoroutineScope
-    var uiState: TaskOverlayUiState = Disabled
-
-    private lateinit var viewModel: TaskOverlayViewModel
-
-    // TODO(b/331753115): TaskOverlay should listen for state changes and react.
-    val enabledState: Enabled
-        get() = uiState as Enabled
-
-    private val snapshotLayoutChangeListener = OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
-        (uiState as? Enabled)?.let { initOverlay(it) }
-    }
-
-    fun getThumbnailMatrix() = getThumbnailPositionState().matrix
-
-    fun getThumbnailPositionState() =
-        viewModel.getThumbnailPositionState(
-            overlay.snapshotView.width,
-            overlay.snapshotView.height,
-            overlay.snapshotView.isLayoutRtl,
-        )
-
-    fun init() {
-        overlayInitializedScope =
-            CoroutineScope(
-                SupervisorJob() + Dispatchers.Main.immediate + CoroutineName("TaskOverlayHelper")
-            )
-        viewModel =
-            TaskOverlayViewModel(
-                task = task,
-                recentsViewData = RecentsDependencies.get(scope),
-                getThumbnailPositionUseCase = RecentsDependencies.get(scope),
-                recentTasksRepository = RecentsDependencies.get(scope),
-                dispatcherProvider = RecentsDependencies.get(scope),
-            )
-        viewModel.overlayState
-            .dropWhile { it == Disabled }
-            .flowOn(dispatcherProvider.background)
-            .onEach {
-                uiState = it
-                if (it is Enabled) {
-                    initOverlay(it)
-                } else {
-                    reset()
-                }
-            }
-            .launchIn(overlayInitializedScope)
-        overlay.snapshotView.addOnLayoutChangeListener(snapshotLayoutChangeListener)
-    }
-
-    private fun initOverlay(enabledState: Enabled) {
-        if (DEBUG) {
-            Log.d(TAG, "initOverlay - taskId: ${task.key.id}, thumbnail: ${enabledState.thumbnail}")
-        }
-        with(getThumbnailPositionState()) {
-            overlay.initOverlay(task, enabledState.thumbnail, matrix, isRotated)
-        }
-    }
-
-    private fun reset() {
-        if (DEBUG) {
-            Log.d(TAG, "reset - taskId: ${task.key.id}")
-        }
-        overlay.reset()
-    }
-
-    fun destroy() {
-        recentsCoroutineScope.launch(dispatcherProvider.background) {
-            overlayInitializedScope.cancel("TaskOverlay being destroyed")
-        }
-        uiState = Disabled
-        overlay.snapshotView.removeOnLayoutChangeListener(snapshotLayoutChangeListener)
-        reset()
-    }
-
-    companion object {
-        private const val TAG = "TaskOverlayHelper"
-        private const val DEBUG = false
-    }
-}
diff --git a/quickstep/src/com/android/quickstep/task/viewmodel/TaskOverlayViewModel.kt b/quickstep/src/com/android/quickstep/task/viewmodel/TaskOverlayViewModel.kt
deleted file mode 100644
index 9bff3ac..0000000
--- a/quickstep/src/com/android/quickstep/task/viewmodel/TaskOverlayViewModel.kt
+++ /dev/null
@@ -1,75 +0,0 @@
-/*
- * 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 android.graphics.Matrix
-import com.android.launcher3.util.coroutines.DispatcherProvider
-import com.android.quickstep.recents.data.RecentTasksRepository
-import com.android.quickstep.recents.domain.usecase.GetThumbnailPositionUseCase
-import com.android.quickstep.recents.viewmodel.RecentsViewData
-import com.android.quickstep.task.thumbnail.TaskOverlayUiState.Disabled
-import com.android.quickstep.task.thumbnail.TaskOverlayUiState.Enabled
-import com.android.systemui.shared.recents.model.Task
-import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.flowOn
-import kotlinx.coroutines.flow.map
-
-/** View model for TaskOverlay */
-class TaskOverlayViewModel(
-    private val task: Task,
-    recentsViewData: RecentsViewData,
-    private val getThumbnailPositionUseCase: GetThumbnailPositionUseCase,
-    private val recentTasksRepository: RecentTasksRepository,
-    dispatcherProvider: DispatcherProvider,
-) {
-    val overlayState =
-        combine(
-                recentsViewData.overlayEnabled,
-                recentsViewData.settledFullyVisibleTaskIds
-                    .map { it.contains(task.key.id) }
-                    .distinctUntilChanged(),
-                recentTasksRepository.getThumbnailById(task.key.id),
-            ) { isOverlayEnabled, isFullyVisible, thumbnailData ->
-                if (isOverlayEnabled && isFullyVisible) {
-                    Enabled(
-                        isRealSnapshot = (thumbnailData?.isRealSnapshot ?: false) && !task.isLocked,
-                        thumbnailData?.thumbnail,
-                    )
-                } else {
-                    Disabled
-                }
-            }
-            .distinctUntilChanged()
-            .flowOn(dispatcherProvider.background)
-
-    fun getThumbnailPositionState(width: Int, height: Int, isRtl: Boolean): ThumbnailPositionState {
-        val thumbnailPositionState =
-            getThumbnailPositionUseCase(
-                thumbnailData = recentTasksRepository.getCurrentThumbnailById(task.key.id),
-                width = width,
-                height = height,
-                isRtl = isRtl,
-            )
-        return ThumbnailPositionState(
-            thumbnailPositionState.matrix,
-            thumbnailPositionState.isRotated,
-        )
-    }
-
-    data class ThumbnailPositionState(val matrix: Matrix, val isRotated: Boolean)
-}
diff --git a/quickstep/src/com/android/quickstep/views/TaskContainer.kt b/quickstep/src/com/android/quickstep/views/TaskContainer.kt
index 6d7ae70..ec6d1c4 100644
--- a/quickstep/src/com/android/quickstep/views/TaskContainer.kt
+++ b/quickstep/src/com/android/quickstep/views/TaskContainer.kt
@@ -18,6 +18,7 @@
 
 import android.graphics.Bitmap
 import android.graphics.Matrix
+import android.util.Log
 import android.view.View
 import android.view.View.OnClickListener
 import com.android.launcher3.Flags.enableRefactorTaskThumbnail
@@ -26,6 +27,7 @@
 import com.android.launcher3.util.TransformingTouchDelegate
 import com.android.quickstep.TaskOverlayFactory
 import com.android.quickstep.ViewUtils.addAccessibleChildToList
+import com.android.quickstep.recents.domain.usecase.ThumbnailPosition
 import com.android.quickstep.recents.ui.mapper.TaskUiStateMapper
 import com.android.quickstep.recents.ui.viewmodel.TaskData
 import com.android.quickstep.task.thumbnail.TaskContentView
@@ -54,6 +56,8 @@
     taskOverlayFactory: TaskOverlayFactory,
 ) {
     val overlay: TaskOverlayFactory.TaskOverlay<*> = taskOverlayFactory.createOverlay(this)
+    var thumbnailPosition: ThumbnailPosition? = null
+    private var overlayEnabledStatus = false
 
     init {
         if (enableRefactorTaskThumbnail()) {
@@ -101,14 +105,13 @@
         if (!enableRefactorTaskThumbnail()) {
             thumbnailViewDeprecated.bind(task, overlay, taskView)
         }
-        overlay.init()
     }
 
     fun destroy() {
         digitalWellBeingToast?.destroy()
         taskContentView.scaleX = 1f
         taskContentView.scaleY = 1f
-        overlay.destroy()
+        overlay.reset()
         if (enableRefactorTaskThumbnail()) {
             isThumbnailValid = false
             thumbnailData = null
@@ -124,6 +127,34 @@
         }
     }
 
+    fun setOverlayEnabled(enabled: Boolean, thumbnailPosition: ThumbnailPosition?) {
+        if (enableRefactorTaskThumbnail()) {
+            if (overlayEnabledStatus != enabled || this.thumbnailPosition != thumbnailPosition) {
+                overlayEnabledStatus = enabled
+
+                refreshOverlay(thumbnailPosition)
+            }
+        }
+    }
+
+    fun refreshOverlay(thumbnailPosition: ThumbnailPosition?) {
+        this.thumbnailPosition = thumbnailPosition
+        when {
+            !overlayEnabledStatus -> overlay.reset()
+            thumbnailPosition == null -> {
+                Log.e(TAG, "Thumbnail position was null during overlay refresh", Exception())
+                overlay.reset()
+            }
+            else ->
+                overlay.initOverlay(
+                    task,
+                    thumbnailData?.thumbnail,
+                    thumbnailPosition.matrix,
+                    thumbnailPosition.isRotated,
+                )
+        }
+    }
+
     fun addChildForAccessibility(outChildren: ArrayList<View>) {
         addAccessibleChildToList(iconView.asView(), outChildren)
         addAccessibleChildToList(snapshotView, outChildren)
@@ -144,6 +175,7 @@
             state?.taskId,
         )
         thumbnailData = if (state is TaskData.Data) state.thumbnailData else null
+        overlay.setThumbnailState(thumbnailData)
     }
 
     fun updateTintAmount(tintAmount: Float) {
@@ -184,4 +216,8 @@
     fun updateThumbnailMatrix(matrix: Matrix) {
         thumbnailView.setImageMatrix(matrix)
     }
+
+    companion object {
+        const val TAG = "TaskContainer"
+    }
 }
diff --git a/quickstep/src/com/android/quickstep/views/TaskMenuView.kt b/quickstep/src/com/android/quickstep/views/TaskMenuView.kt
index 200fd52..7c762f4 100644
--- a/quickstep/src/com/android/quickstep/views/TaskMenuView.kt
+++ b/quickstep/src/com/android/quickstep/views/TaskMenuView.kt
@@ -115,13 +115,9 @@
 
     override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
         var heightMeasure = heightMeasureSpec
-        if (!(enableOverviewIconMenu() && taskView.isOnGridBottomRow())) {
-            // TODO(b/326952853): Cap menu height for grid bottom row in a way that doesn't break
-            // additionalTranslationY.
-            val maxMenuHeight = calculateMaxHeight()
-            if (MeasureSpec.getSize(heightMeasure) > maxMenuHeight) {
-                heightMeasure = MeasureSpec.makeMeasureSpec(maxMenuHeight, MeasureSpec.AT_MOST)
-            }
+        val maxMenuHeight = calculateMaxHeight()
+        if (MeasureSpec.getSize(heightMeasure) > maxMenuHeight) {
+            heightMeasure = MeasureSpec.makeMeasureSpec(maxMenuHeight, MeasureSpec.AT_MOST)
         }
         super.onMeasure(widthMeasureSpec, heightMeasure)
     }
@@ -364,15 +360,20 @@
      * view will scroll. The maximum menu size will sit inside the task with a margin on the top and
      * bottom.
      */
-    private fun calculateMaxHeight(): Int {
-        val taskInsetMargin = resources.getDimension(R.dimen.task_card_margin)
-        return taskView.pagedOrientationHandler.getTaskMenuHeight(
-            taskInsetMargin,
-            recentsViewContainer.deviceProfile,
-            translationX,
-            translationY,
+    private fun calculateMaxHeight(): Int =
+        taskView.pagedOrientationHandler.getTaskMenuHeight(
+            taskInsetMargin = resources.getDimension(R.dimen.task_card_margin), // taskInsetMargin
+            deviceProfile = recentsViewContainer.deviceProfile,
+            taskMenuX = translationX,
+            taskMenuY =
+                when {
+                    !enableOverviewIconMenu() -> translationY
+                    // Bottom menu can translate up to show more options. So we use the min
+                    // translation allowed to calculate its max height.
+                    taskView.isOnGridBottomRow() -> minMenuTop
+                    else -> menuTranslationYBeforeOpen
+                },
         )
-    }
 
     private fun setOnClosingStartCallback(onClosingStartCallback: Runnable?) {
         this.onClosingStartCallback = onClosingStartCallback
diff --git a/quickstep/src/com/android/quickstep/views/TaskView.kt b/quickstep/src/com/android/quickstep/views/TaskView.kt
index c878d56..b7f1d1d 100644
--- a/quickstep/src/com/android/quickstep/views/TaskView.kt
+++ b/quickstep/src/com/android/quickstep/views/TaskView.kt
@@ -82,6 +82,7 @@
 import com.android.quickstep.recents.di.RecentsDependencies
 import com.android.quickstep.recents.di.get
 import com.android.quickstep.recents.di.inject
+import com.android.quickstep.recents.domain.usecase.ThumbnailPosition
 import com.android.quickstep.recents.ui.viewmodel.TaskData
 import com.android.quickstep.recents.ui.viewmodel.TaskTileUiState
 import com.android.quickstep.recents.ui.viewmodel.TaskViewModel
@@ -775,11 +776,13 @@
                     },
             )
             updateThumbnailValidity(container)
-            updateThumbnailMatrix(
-                container = container,
-                width = container.thumbnailView.width,
-                height = container.thumbnailView.height,
-            )
+            val thumbnailPosition =
+                updateThumbnailMatrix(
+                    container = container,
+                    width = container.thumbnailView.width,
+                    height = container.thumbnailView.height,
+                )
+            container.setOverlayEnabled(state.taskOverlayEnabled, thumbnailPosition)
 
             if (enableOverviewIconMenu()) {
                 setIconState(container, containerState)
@@ -808,11 +811,16 @@
      * @param width The desired width of the thumbnail's container.
      * @param height The desired height of the thumbnail's container.
      */
-    private fun updateThumbnailMatrix(container: TaskContainer, width: Int, height: Int) {
+    private fun updateThumbnailMatrix(
+        container: TaskContainer,
+        width: Int,
+        height: Int,
+    ): ThumbnailPosition? {
         val thumbnailPosition =
             viewModel?.getThumbnailPosition(container.thumbnailData, width, height, isLayoutRtl)
-                ?: return
+                ?: return null
         container.updateThumbnailMatrix(thumbnailPosition.matrix)
+        return thumbnailPosition
     }
 
     override fun onDetachedFromWindow() {
@@ -877,7 +885,8 @@
                     thumbnailFullscreenParams.currentCornerRadius
                 container.taskContentView.doOnSizeChange { width, height ->
                     updateThumbnailValidity(container)
-                    updateThumbnailMatrix(container, width, height)
+                    val thumbnailPosition = updateThumbnailMatrix(container, width, height)
+                    container.refreshOverlay(thumbnailPosition)
                 }
             }
         }
diff --git a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarPopupControllerTest.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarPopupControllerTest.kt
new file mode 100644
index 0000000..6bb3205
--- /dev/null
+++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarPopupControllerTest.kt
@@ -0,0 +1,101 @@
+/*
+ * 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
+
+import android.platform.test.annotations.DisableFlags
+import android.platform.test.flag.junit.SetFlagsRule
+import com.android.launcher3.AbstractFloatingView
+import com.android.launcher3.BubbleTextView
+import com.android.launcher3.Flags.FLAG_ENABLE_MULTI_INSTANCE_MENU_TASKBAR
+import com.android.launcher3.Flags.FLAG_ENABLE_PINNING_APP_WITH_CONTEXT_MENU
+import com.android.launcher3.R
+import com.android.launcher3.model.data.WorkspaceItemInfo
+import com.android.launcher3.taskbar.TaskbarControllerTestUtil.runOnMainSync
+import com.android.launcher3.taskbar.TaskbarViewTestUtil.createHotseatWorkspaceItem
+import com.android.launcher3.taskbar.TaskbarViewTestUtil.createRecents
+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.LauncherMultivalentJUnit
+import com.android.launcher3.util.LauncherMultivalentJUnit.EmulatedDevices
+import com.android.quickstep.util.GroupTask
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(LauncherMultivalentJUnit::class)
+@EmulatedDevices(["pixelFoldable2023", "pixelTablet2023"])
+@DisableFlags(FLAG_ENABLE_MULTI_INSTANCE_MENU_TASKBAR, FLAG_ENABLE_PINNING_APP_WITH_CONTEXT_MENU)
+class TaskbarPopupControllerTest {
+    @get:Rule(order = 0) val setFlagsRule = SetFlagsRule()
+
+    @get:Rule(order = 1) val context = TaskbarWindowSandboxContext.create()
+
+    @get:Rule(order = 2) val taskbarUnitTestRule = TaskbarUnitTestRule(this, context)
+
+    @InjectController lateinit var popupController: TaskbarPopupController
+
+    private val taskbarContext: TaskbarActivityContext
+        get() = taskbarUnitTestRule.activityContext
+
+    private lateinit var taskbarView: TaskbarView
+    private lateinit var hotseatIcon: BubbleTextView
+    private lateinit var recentTaskIcon: BubbleTextView
+
+    @Before
+    fun setup() {
+        taskbarContext.controllers.uiController.init(taskbarContext.controllers)
+        runOnMainSync { taskbarView = taskbarContext.dragLayer.findViewById(R.id.taskbar_view) }
+
+        val hotseatItems = arrayOf(createHotseatWorkspaceItem())
+        val recentItems = createRecents(2)
+        runOnMainSync {
+            taskbarView.updateItems(hotseatItems, recentItems)
+            hotseatIcon =
+                taskbarView.iconViews.filterIsInstance<BubbleTextView>().first {
+                    it.tag is WorkspaceItemInfo
+                }
+            recentTaskIcon =
+                taskbarView.iconViews.filterIsInstance<BubbleTextView>().first {
+                    it.tag is GroupTask
+                }
+        }
+    }
+
+    @Test
+    fun showForIcon_hotseatItem() {
+        assertThat(hasPopupMenu()).isFalse()
+        runOnMainSync { popupController.showForIcon(hotseatIcon) }
+        assertThat(hasPopupMenu()).isTrue()
+    }
+
+    @Test
+    fun showForIcon_recentTask() {
+        assertThat(hasPopupMenu()).isFalse()
+        runOnMainSync { popupController.showForIcon(recentTaskIcon) }
+        assertThat(hasPopupMenu()).isTrue()
+    }
+
+    private fun hasPopupMenu(): Boolean {
+        return AbstractFloatingView.hasOpenView(
+            taskbarContext,
+            AbstractFloatingView.TYPE_ACTION_POPUP,
+        )
+    }
+}
diff --git a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarViewTestUtil.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarViewTestUtil.kt
index df70b10..e52aacf 100644
--- a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarViewTestUtil.kt
+++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarViewTestUtil.kt
@@ -46,12 +46,14 @@
 
     /** Creates an array of fake hotseat items. */
     fun createHotseatItems(size: Int): Array<ItemInfo> {
-        return Array(size) {
-            WorkspaceItemInfo(
-                    AppInfo(TEST_COMPONENT, "Test App $it", Process.myUserHandle(), Intent())
-                )
-                .apply { id = it }
-        }
+        return Array(size) { createHotseatWorkspaceItem(it) }
+    }
+
+    fun createHotseatWorkspaceItem(id: Int = 0): WorkspaceItemInfo {
+        return WorkspaceItemInfo(
+                AppInfo(TEST_COMPONENT, "Test App $id", Process.myUserHandle(), Intent())
+            )
+            .apply { this.id = id }
     }
 
     /** Creates a list of fake recent tasks. */
@@ -75,13 +77,13 @@
 }
 
 /** A `Truth` [Subject] with extensions for verifying [TaskbarView]. */
-class TaskbarViewSubject(failureMetadata: FailureMetadata, private val view: TaskbarView) :
+class TaskbarViewSubject(failureMetadata: FailureMetadata, private val view: TaskbarView?) :
     Subject(failureMetadata, view) {
 
     /** Verifies that the types of icons match [expectedTypes] in order. */
     fun hasIconTypes(vararg expectedTypes: TaskbarIconType) {
         val actualTypes =
-            view.iconViews.map {
+            view?.iconViews?.map {
                 when (it) {
                     view.allAppsButtonContainer -> ALL_APPS
                     view.taskbarDividerViewContainer -> DIVIDER
@@ -100,7 +102,7 @@
     /** Verifies that recents from [startIndex] have IDs that match [expectedIds] in order. */
     fun hasRecentsOrder(startIndex: Int, expectedIds: List<Int>) {
         val actualIds =
-            view.iconViews.slice(startIndex..<startIndex + expectedIds.size).flatMap {
+            view?.iconViews?.slice(startIndex..<startIndex + expectedIds.size)?.flatMap {
                 assertThat(it.tag).isInstanceOf(GroupTask::class.java)
                 (it.tag as GroupTask).tasks.map { task -> task.key.id }
             }
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 a456fb9..61a6975 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
@@ -20,8 +20,10 @@
 import android.graphics.Color
 import android.graphics.Path
 import android.graphics.PointF
+import android.graphics.Rect
 import android.graphics.drawable.ColorDrawable
 import android.view.LayoutInflater
+import android.view.MotionEvent
 import android.view.View
 import android.view.View.INVISIBLE
 import android.view.View.VISIBLE
@@ -35,10 +37,13 @@
 import androidx.test.filters.SmallTest
 import androidx.test.platform.app.InstrumentationRegistry
 import com.android.launcher3.R
+import com.android.launcher3.taskbar.TaskbarInsetsController
 import com.android.launcher3.taskbar.bubbles.BubbleBarBubble
 import com.android.launcher3.taskbar.bubbles.BubbleBarOverflow
 import com.android.launcher3.taskbar.bubbles.BubbleBarParentViewHeightUpdateNotifier
 import com.android.launcher3.taskbar.bubbles.BubbleBarView
+import com.android.launcher3.taskbar.bubbles.BubbleBarViewController
+import com.android.launcher3.taskbar.bubbles.BubbleStashedHandleViewController
 import com.android.launcher3.taskbar.bubbles.BubbleView
 import com.android.launcher3.taskbar.bubbles.flyout.BubbleBarFlyoutController
 import com.android.launcher3.taskbar.bubbles.flyout.BubbleBarFlyoutMessage
@@ -46,8 +51,10 @@
 import com.android.launcher3.taskbar.bubbles.flyout.FlyoutCallbacks
 import com.android.launcher3.taskbar.bubbles.flyout.FlyoutScheduler
 import com.android.launcher3.taskbar.bubbles.stashing.BubbleStashController
+import com.android.launcher3.taskbar.bubbles.stashing.BubbleStashController.BubbleLauncherState
 import com.android.wm.shell.shared.animation.PhysicsAnimator
 import com.android.wm.shell.shared.animation.PhysicsAnimatorTestUtils
+import com.android.wm.shell.shared.bubbles.BubbleBarLocation
 import com.android.wm.shell.shared.bubbles.BubbleInfo
 import com.google.common.truth.Truth.assertThat
 import java.util.concurrent.Semaphore
@@ -56,13 +63,6 @@
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
-import org.mockito.kotlin.any
-import org.mockito.kotlin.atLeastOnce
-import org.mockito.kotlin.eq
-import org.mockito.kotlin.mock
-import org.mockito.kotlin.never
-import org.mockito.kotlin.verify
-import org.mockito.kotlin.whenever
 
 @SmallTest
 @RunWith(AndroidJUnit4::class)
@@ -78,7 +78,7 @@
     private lateinit var bubble: BubbleBarBubble
     private lateinit var bubbleBarView: BubbleBarView
     private lateinit var flyoutContainer: FrameLayout
-    private lateinit var bubbleStashController: BubbleStashController
+    private lateinit var bubbleStashController: FakeBubbleStashController
     private lateinit var flyoutController: BubbleBarFlyoutController
     private val emptyRunnable = Runnable {}
 
@@ -89,6 +89,7 @@
     fun setUp() {
         animatorScheduler = TestBubbleBarViewAnimatorScheduler()
         bubbleBarParentViewController = TestBubbleBarParentViewHeightUpdateNotifier()
+        bubbleStashController = FakeBubbleStashController()
         PhysicsAnimatorTestUtils.prepareForTest()
         setupFlyoutController()
     }
@@ -96,11 +97,10 @@
     @Test
     fun animateBubbleInForStashed() {
         setUpBubbleBar()
-        setUpBubbleStashController()
 
         val handle = View(context)
         val handleAnimator = PhysicsAnimator.getInstance(handle)
-        whenever(bubbleStashController.getStashedHandlePhysicsAnimator()).thenReturn(handleAnimator)
+        bubbleStashController.handleAnimator = handleAnimator
 
         val animator =
             BubbleBarViewAnimator(
@@ -147,17 +147,16 @@
         assertThat(handle.translationY).isEqualTo(0)
         assertThat(bubbleBarView.alpha).isEqualTo(0)
         assertThat(animator.isAnimating).isFalse()
-        verify(bubbleStashController).stashBubbleBarImmediate()
+        assertThat(bubbleStashController.isStashed).isTrue()
     }
 
     @Test
     fun animateBubbleInForStashed_tapAnimatingBubble() {
         setUpBubbleBar()
-        setUpBubbleStashController()
 
         val handle = View(context)
         val handleAnimator = PhysicsAnimator.getInstance(handle)
-        whenever(bubbleStashController.getStashedHandlePhysicsAnimator()).thenReturn(handleAnimator)
+        bubbleStashController.handleAnimator = handleAnimator
 
         val animator =
             BubbleBarViewAnimator(
@@ -187,7 +186,7 @@
         assertThat(bubbleBarView.translationY).isEqualTo(BAR_TRANSLATION_Y_FOR_TASKBAR)
         assertThat(animator.isAnimating).isTrue()
 
-        verify(bubbleStashController, atLeastOnce()).updateTaskbarTouchRegion()
+        assertThat(bubbleStashController.taskbarTouchRegionUpdated).isTrue()
         assertThat(bubbleBarParentViewController.timesInvoked).isEqualTo(1)
         waitForFlyoutToShow()
 
@@ -211,11 +210,10 @@
     @Test
     fun animateBubbleInForStashed_touchTaskbarArea_whileShowing() {
         setUpBubbleBar()
-        setUpBubbleStashController()
 
         val handle = View(context)
         val handleAnimator = PhysicsAnimator.getInstance(handle)
-        whenever(bubbleStashController.getStashedHandlePhysicsAnimator()).thenReturn(handleAnimator)
+        bubbleStashController.handleAnimator = handleAnimator
 
         val animator =
             BubbleBarViewAnimator(
@@ -241,7 +239,6 @@
         // verify the hide bubble animation is pending
         assertThat(animatorScheduler.delayedBlock).isNotNull()
 
-        whenever(bubbleStashController.isStashed).thenReturn(true)
         InstrumentationRegistry.getInstrumentation().runOnMainSync {
             animator.onStashStateChangingWhileAnimating()
         }
@@ -255,9 +252,10 @@
         // verify that the hide animation was canceled
         assertThat(animatorScheduler.delayedBlock).isNull()
         assertThat(animator.isAnimating).isFalse()
+        assertThat(bubbleStashController.animationInterrupted).isTrue()
         assertThat(bubbleBarView.scaleX).isEqualTo(1)
         assertThat(bubbleBarView.scaleY).isEqualTo(1)
-        verify(bubbleStashController).onNewBubbleAnimationInterrupted(eq(true), any())
+        assertThat(bubbleStashController.isStashed).isTrue()
 
         // PhysicsAnimatorTestUtils posts the cancellation to the main thread so we need to wait
         // again
@@ -268,11 +266,10 @@
     @Test
     fun animateBubbleInForStashed_touchTaskbarArea_whileHiding() {
         setUpBubbleBar()
-        setUpBubbleStashController()
 
         val handle = View(context)
         val handleAnimator = PhysicsAnimator.getInstance(handle)
-        whenever(bubbleStashController.getStashedHandlePhysicsAnimator()).thenReturn(handleAnimator)
+        bubbleStashController.handleAnimator = handleAnimator
 
         val animator =
             BubbleBarViewAnimator(
@@ -310,7 +307,7 @@
         }
         assertThat(bubbleBarParentViewController.timesInvoked).isEqualTo(2)
         assertThat(animator.isAnimating).isFalse()
-        verify(bubbleStashController).onNewBubbleAnimationInterrupted(any(), any())
+        assertThat(bubbleStashController.animationInterrupted).isTrue()
 
         // PhysicsAnimatorTestUtils posts the cancellation to the main thread so we need to wait
         // again
@@ -321,11 +318,10 @@
     @Test
     fun animateBubbleInForStashed_showAnimationCanceled() {
         setUpBubbleBar()
-        setUpBubbleStashController()
 
         val handle = View(context)
         val handleAnimator = PhysicsAnimator.getInstance(handle)
-        whenever(bubbleStashController.getStashedHandlePhysicsAnimator()).thenReturn(handleAnimator)
+        bubbleStashController.handleAnimator = handleAnimator
 
         val animator =
             BubbleBarViewAnimator(
@@ -359,11 +355,10 @@
     @Test
     fun animateBubbleInForStashed_autoExpanding() {
         setUpBubbleBar()
-        setUpBubbleStashController()
 
         val handle = View(context)
         val handleAnimator = PhysicsAnimator.getInstance(handle)
-        whenever(bubbleStashController.getStashedHandlePhysicsAnimator()).thenReturn(handleAnimator)
+        bubbleStashController.handleAnimator = handleAnimator
 
         var notifiedExpanded = false
         val onExpanded = Runnable { notifiedExpanded = true }
@@ -399,18 +394,17 @@
         // verify there is no hide animation
         assertThat(animatorScheduler.delayedBlock).isNull()
 
-        verify(bubbleStashController).showBubbleBarImmediate()
+        assertThat(bubbleStashController.isStashed).isFalse()
         assertThat(notifiedExpanded).isTrue()
     }
 
     @Test
     fun animateBubbleInForStashed_expandedWhileAnimatingIn() {
         setUpBubbleBar()
-        setUpBubbleStashController()
 
         val handle = View(context)
         val handleAnimator = PhysicsAnimator.getInstance(handle)
-        whenever(bubbleStashController.getStashedHandlePhysicsAnimator()).thenReturn(handleAnimator)
+        bubbleStashController.handleAnimator = handleAnimator
 
         var notifiedExpanded = false
         val onExpanded = Runnable { notifiedExpanded = true }
@@ -459,11 +453,10 @@
     @Test
     fun animateBubbleInForStashed_expandedWhileFullyIn() {
         setUpBubbleBar()
-        setUpBubbleStashController()
 
         val handle = View(context)
         val handleAnimator = PhysicsAnimator.getInstance(handle)
-        whenever(bubbleStashController.getStashedHandlePhysicsAnimator()).thenReturn(handleAnimator)
+        bubbleStashController.handleAnimator = handleAnimator
 
         var notifiedExpanded = false
         val onExpanded = Runnable { notifiedExpanded = true }
@@ -515,13 +508,11 @@
     @Test
     fun animateToInitialState_inApp() {
         setUpBubbleBar()
-        setUpBubbleStashController()
-        whenever(bubbleStashController.bubbleBarTranslationY)
-            .thenReturn(BAR_TRANSLATION_Y_FOR_TASKBAR)
+        bubbleStashController.launcherState = BubbleLauncherState.IN_APP
 
         val handle = View(context)
         val handleAnimator = PhysicsAnimator.getInstance(handle)
-        whenever(bubbleStashController.getStashedHandlePhysicsAnimator()).thenReturn(handleAnimator)
+        bubbleStashController.handleAnimator = handleAnimator
 
         val barAnimator = PhysicsAnimator.getInstance(bubbleBarView)
 
@@ -570,19 +561,17 @@
         assertThat(bubbleBarView.visibility).isEqualTo(VISIBLE)
         assertThat(notifiedBubbleBarVisible).isTrue()
 
-        verify(bubbleStashController).stashBubbleBarImmediate()
+        assertThat(bubbleStashController.isStashed).isTrue()
     }
 
     @Test
     fun animateToInitialState_whileDragging_inApp() {
         setUpBubbleBar()
-        setUpBubbleStashController()
-        whenever(bubbleStashController.bubbleBarTranslationY)
-            .thenReturn(BAR_TRANSLATION_Y_FOR_TASKBAR)
+        bubbleStashController.launcherState = BubbleLauncherState.IN_APP
 
         val handle = View(context)
         val handleAnimator = PhysicsAnimator.getInstance(handle)
-        whenever(bubbleStashController.getStashedHandlePhysicsAnimator()).thenReturn(handleAnimator)
+        bubbleStashController.handleAnimator = handleAnimator
 
         val barAnimator = PhysicsAnimator.getInstance(bubbleBarView)
 
@@ -634,20 +623,16 @@
         assertThat(handle.translationY).isEqualTo(0)
         assertThat(bubbleBarView.visibility).isEqualTo(VISIBLE)
         assertThat(notifiedBubbleBarVisible).isTrue()
-
-        verify(bubbleStashController, never()).stashBubbleBarImmediate()
     }
 
     @Test
     fun animateToInitialState_inApp_autoExpanding() {
         setUpBubbleBar()
-        setUpBubbleStashController()
-        whenever(bubbleStashController.bubbleBarTranslationY)
-            .thenReturn(BAR_TRANSLATION_Y_FOR_TASKBAR)
+        bubbleStashController.launcherState = BubbleLauncherState.IN_APP
 
         val handle = View(context)
         val handleAnimator = PhysicsAnimator.getInstance(handle)
-        whenever(bubbleStashController.getStashedHandlePhysicsAnimator()).thenReturn(handleAnimator)
+        bubbleStashController.handleAnimator = handleAnimator
 
         val barAnimator = PhysicsAnimator.getInstance(bubbleBarView)
 
@@ -677,16 +662,14 @@
         assertThat(bubbleBarView.translationY).isEqualTo(BAR_TRANSLATION_Y_FOR_TASKBAR)
 
         assertThat(animatorScheduler.delayedBlock).isNull()
-        verify(bubbleStashController).showBubbleBarImmediate()
+        assertThat(bubbleStashController.isStashed).isFalse()
         assertThat(notifiedExpanded).isTrue()
     }
 
     @Test
     fun animateToInitialState_inHome() {
         setUpBubbleBar()
-        setUpBubbleStashController()
-        whenever(bubbleStashController.bubbleBarTranslationY)
-            .thenReturn(BAR_TRANSLATION_Y_FOR_HOTSEAT)
+        bubbleStashController.launcherState = BubbleLauncherState.HOME
 
         val barAnimator = PhysicsAnimator.getInstance(bubbleBarView)
 
@@ -725,15 +708,13 @@
         assertThat(bubbleBarView.alpha).isEqualTo(1)
         assertThat(bubbleBarView.translationY).isEqualTo(BAR_TRANSLATION_Y_FOR_HOTSEAT)
 
-        verify(bubbleStashController).showBubbleBarImmediate()
+        assertThat(bubbleStashController.isStashed).isFalse()
     }
 
     @Test
     fun animateToInitialState_expandedWhileAnimatingIn() {
         setUpBubbleBar()
-        setUpBubbleStashController()
-        whenever(bubbleStashController.bubbleBarTranslationY)
-            .thenReturn(BAR_TRANSLATION_Y_FOR_HOTSEAT)
+        bubbleStashController.launcherState = BubbleLauncherState.HOME
 
         var notifiedExpanded = false
         val onExpanded = Runnable { notifiedExpanded = true }
@@ -775,16 +756,14 @@
 
         verifyBubbleBarIsExpandedWithTranslation(BAR_TRANSLATION_Y_FOR_HOTSEAT)
         assertThat(animator.isAnimating).isFalse()
-        verify(bubbleStashController).showBubbleBarImmediate()
+        assertThat(bubbleStashController.isStashed).isFalse()
         assertThat(notifiedExpanded).isTrue()
     }
 
     @Test
     fun animateToInitialState_expandedWhileFullyIn() {
         setUpBubbleBar()
-        setUpBubbleStashController()
-        whenever(bubbleStashController.bubbleBarTranslationY)
-            .thenReturn(BAR_TRANSLATION_Y_FOR_HOTSEAT)
+        bubbleStashController.launcherState = BubbleLauncherState.HOME
 
         var notifiedExpanded = false
         val onExpanded = Runnable { notifiedExpanded = true }
@@ -831,9 +810,7 @@
     @Test
     fun animateBubbleBarForCollapsed() {
         setUpBubbleBar()
-        setUpBubbleStashController()
-        whenever(bubbleStashController.bubbleBarTranslationY)
-            .thenReturn(BAR_TRANSLATION_Y_FOR_HOTSEAT)
+        bubbleStashController.launcherState = BubbleLauncherState.HOME
 
         val barAnimator = PhysicsAnimator.getInstance(bubbleBarView)
 
@@ -877,16 +854,13 @@
         assertThat(bubbleBarParentViewController.timesInvoked).isEqualTo(2)
         // the bubble bar translation y should be back to its initial value
         assertThat(bubbleBarView.translationY).isEqualTo(BAR_TRANSLATION_Y_FOR_HOTSEAT)
-
-        verify(bubbleStashController).showBubbleBarImmediate()
+        assertThat(bubbleStashController.isStashed).isFalse()
     }
 
     @Test
     fun animateBubbleBarForCollapsed_autoExpanding() {
         setUpBubbleBar()
-        setUpBubbleStashController()
-        whenever(bubbleStashController.bubbleBarTranslationY)
-            .thenReturn(BAR_TRANSLATION_Y_FOR_HOTSEAT)
+        bubbleStashController.launcherState = BubbleLauncherState.HOME
 
         val semaphore = Semaphore(0)
         var notifiedExpanded = false
@@ -933,16 +907,14 @@
 
         assertThat(animator.isAnimating).isFalse()
         assertThat(bubbleBarView.translationY).isEqualTo(BAR_TRANSLATION_Y_FOR_HOTSEAT)
-        verify(bubbleStashController).showBubbleBarImmediate()
+        assertThat(bubbleStashController.isStashed).isFalse()
         assertThat(notifiedExpanded).isTrue()
     }
 
     @Test
     fun animateBubbleBarForCollapsed_expandingWhileAnimatingIn() {
         setUpBubbleBar()
-        setUpBubbleStashController()
-        whenever(bubbleStashController.bubbleBarTranslationY)
-            .thenReturn(BAR_TRANSLATION_Y_FOR_HOTSEAT)
+        bubbleStashController.launcherState = BubbleLauncherState.HOME
 
         val semaphore = Semaphore(0)
         var notifiedExpanded = false
@@ -1001,16 +973,14 @@
         assertThat(animator.isAnimating).isFalse()
         assertThat(bubbleBarView.translationY).isEqualTo(BAR_TRANSLATION_Y_FOR_HOTSEAT)
         assertThat(bubbleBarView.isExpanded).isTrue()
-        verify(bubbleStashController).showBubbleBarImmediate()
+        assertThat(bubbleStashController.isStashed).isFalse()
         assertThat(notifiedExpanded).isTrue()
     }
 
     @Test
     fun animateBubbleBarForCollapsed_expandingWhileFullyIn() {
         setUpBubbleBar()
-        setUpBubbleStashController()
-        whenever(bubbleStashController.bubbleBarTranslationY)
-            .thenReturn(BAR_TRANSLATION_Y_FOR_HOTSEAT)
+        bubbleStashController.launcherState = BubbleLauncherState.HOME
 
         val barAnimator = PhysicsAnimator.getInstance(bubbleBarView)
 
@@ -1064,18 +1034,17 @@
         assertThat(animator.isAnimating).isFalse()
         assertThat(bubbleBarView.translationY).isEqualTo(BAR_TRANSLATION_Y_FOR_HOTSEAT)
         assertThat(bubbleBarView.isExpanded).isTrue()
-        verify(bubbleStashController).showBubbleBarImmediate()
+        assertThat(bubbleStashController.isStashed).isFalse()
         assertThat(notifiedExpanded).isTrue()
     }
 
     @Test
     fun interruptAnimation_whileAnimatingIn() {
         setUpBubbleBar()
-        setUpBubbleStashController()
 
         val handle = View(context)
         val handleAnimator = PhysicsAnimator.getInstance(handle)
-        whenever(bubbleStashController.getStashedHandlePhysicsAnimator()).thenReturn(handleAnimator)
+        bubbleStashController.handleAnimator = handleAnimator
 
         val animator =
             BubbleBarViewAnimator(
@@ -1136,17 +1105,16 @@
         assertThat(handle.translationY).isEqualTo(0)
         assertThat(bubbleBarView.alpha).isEqualTo(0)
         assertThat(animator.isAnimating).isFalse()
-        verify(bubbleStashController).stashBubbleBarImmediate()
+        assertThat(bubbleStashController.isStashed).isTrue()
     }
 
     @Test
     fun interruptAnimation_whileIn() {
         setUpBubbleBar()
-        setUpBubbleStashController()
 
         val handle = View(context)
         val handleAnimator = PhysicsAnimator.getInstance(handle)
-        whenever(bubbleStashController.getStashedHandlePhysicsAnimator()).thenReturn(handleAnimator)
+        bubbleStashController.handleAnimator = handleAnimator
 
         val animator =
             BubbleBarViewAnimator(
@@ -1213,17 +1181,16 @@
         assertThat(handle.translationY).isEqualTo(0)
         assertThat(bubbleBarView.alpha).isEqualTo(0)
         assertThat(animator.isAnimating).isFalse()
-        verify(bubbleStashController).stashBubbleBarImmediate()
+        assertThat(bubbleStashController.isStashed).isTrue()
     }
 
     @Test
     fun interruptAnimation_whileAnimatingOut_whileCollapsingFlyout() {
         setUpBubbleBar()
-        setUpBubbleStashController()
 
         val handle = View(context)
         val handleAnimator = PhysicsAnimator.getInstance(handle)
-        whenever(bubbleStashController.getStashedHandlePhysicsAnimator()).thenReturn(handleAnimator)
+        bubbleStashController.handleAnimator = handleAnimator
 
         val animator =
             BubbleBarViewAnimator(
@@ -1301,17 +1268,16 @@
         assertThat(handle.translationY).isEqualTo(0)
         assertThat(bubbleBarView.alpha).isEqualTo(0)
         assertThat(animator.isAnimating).isFalse()
-        verify(bubbleStashController).stashBubbleBarImmediate()
+        assertThat(bubbleStashController.isStashed).isTrue()
     }
 
     @Test
     fun interruptAnimation_whileAnimatingOut_barToHandle() {
         setUpBubbleBar()
-        setUpBubbleStashController()
 
         val handle = View(context)
         val handleAnimator = PhysicsAnimator.getInstance(handle)
-        whenever(bubbleStashController.getStashedHandlePhysicsAnimator()).thenReturn(handleAnimator)
+        bubbleStashController.handleAnimator = handleAnimator
 
         val animator =
             BubbleBarViewAnimator(
@@ -1416,17 +1382,16 @@
         assertThat(handle.translationY).isEqualTo(0)
         assertThat(bubbleBarView.alpha).isEqualTo(0)
         assertThat(animator.isAnimating).isFalse()
-        verify(bubbleStashController).stashBubbleBarImmediate()
+        assertThat(bubbleStashController.isStashed).isTrue()
     }
 
     @Test
     fun interruptForIme() {
         setUpBubbleBar()
-        setUpBubbleStashController()
 
         val handle = View(context)
         val handleAnimator = PhysicsAnimator.getInstance(handle)
-        whenever(bubbleStashController.getStashedHandlePhysicsAnimator()).thenReturn(handleAnimator)
+        bubbleStashController.handleAnimator = handleAnimator
 
         val animator =
             BubbleBarViewAnimator(
@@ -1457,7 +1422,8 @@
         // verify that the hide animation was canceled
         assertThat(animatorScheduler.delayedBlock).isNull()
         assertThat(animator.isAnimating).isFalse()
-        verify(bubbleStashController).onNewBubbleAnimationInterrupted(eq(true), any())
+        assertThat(bubbleStashController.animationInterrupted).isTrue()
+        assertThat(bubbleStashController.isStashed).isTrue()
 
         // PhysicsAnimatorTestUtils posts the cancellation to the main thread so we need to wait
         // again
@@ -1510,17 +1476,6 @@
         InstrumentationRegistry.getInstrumentation().waitForIdleSync()
     }
 
-    private fun setUpBubbleStashController() {
-        bubbleStashController = mock<BubbleStashController>()
-        whenever(bubbleStashController.isStashed).thenReturn(true)
-        whenever(bubbleStashController.getDiffBetweenHandleAndBarCenters())
-            .thenReturn(DIFF_BETWEEN_HANDLE_AND_BAR_CENTERS)
-        whenever(bubbleStashController.getStashedHandleTranslationForNewBubbleAnimation())
-            .thenReturn(HANDLE_TRANSLATION)
-        whenever(bubbleStashController.bubbleBarTranslationYForTaskbar)
-            .thenReturn(BAR_TRANSLATION_Y_FOR_TASKBAR)
-    }
-
     private fun setupFlyoutController() {
         flyoutContainer = FrameLayout(context)
         val flyoutPositioner =
@@ -1627,6 +1582,91 @@
             timesInvoked++
         }
     }
+
+    private class FakeBubbleStashController : BubbleStashController {
+
+        var handleAnimator: PhysicsAnimator<View>? = null
+        var taskbarTouchRegionUpdated = false
+            private set
+
+        var animationInterrupted = false
+            private set
+
+        private var _isStashed = true
+
+        override var launcherState = BubbleLauncherState.HOME
+        override val isStashed: Boolean
+            get() = _isStashed
+
+        override var bubbleBarVerticalCenterForHome = 0
+        override var isSysuiLocked = false
+        override val isTransientTaskBar = true
+        override val hasHandleView = true
+        override val bubbleBarTranslationYForTaskbar = BAR_TRANSLATION_Y_FOR_TASKBAR
+        override val bubbleBarTranslationYForHotseat = BAR_TRANSLATION_Y_FOR_HOTSEAT
+        override var inAppDisplayOverrideProgress = 0f
+
+        override fun init(
+            taskbarInsetsController: TaskbarInsetsController,
+            bubbleBarViewController: BubbleBarViewController,
+            bubbleStashedHandleViewController: BubbleStashedHandleViewController?,
+            controllersAfterInitAction: BubbleStashController.ControllersAfterInitAction,
+        ) {}
+
+        override fun showBubbleBarImmediate() {
+            _isStashed = false
+        }
+
+        override fun showBubbleBarImmediate(bubbleBarTranslationY: Float) {
+            _isStashed = false
+        }
+
+        override fun stashBubbleBarImmediate() {
+            _isStashed = true
+        }
+
+        override fun getTouchableHeight() = 100
+
+        override fun isBubbleBarVisible() = true
+
+        override fun onNewBubbleAnimationInterrupted(
+            isStashed: Boolean,
+            bubbleBarTranslationY: Float,
+        ) {
+            _isStashed = isStashed
+            animationInterrupted = true
+        }
+
+        override fun isEventOverBubbleBarViews(ev: MotionEvent) = false
+
+        override fun setBubbleBarLocation(bubbleBarLocation: BubbleBarLocation) {}
+
+        override fun stashBubbleBar() {
+            _isStashed = true
+        }
+
+        override fun showBubbleBar(expandBubbles: Boolean, bubbleBarGesture: Boolean) {
+            _isStashed = false
+        }
+
+        override fun getDiffBetweenHandleAndBarCenters() = DIFF_BETWEEN_HANDLE_AND_BAR_CENTERS
+
+        override fun getStashedHandleTranslationForNewBubbleAnimation() = HANDLE_TRANSLATION
+
+        override fun getStashedHandlePhysicsAnimator(): PhysicsAnimator<View>? {
+            return handleAnimator
+        }
+
+        override fun updateTaskbarTouchRegion() {
+            taskbarTouchRegionUpdated = true
+        }
+
+        override fun setHandleTranslationY(translationY: Float) {}
+
+        override fun getHandleTranslationY() = 0f
+
+        override fun getHandleBounds(bounds: Rect) {}
+    }
 }
 
 private const val DIFF_BETWEEN_HANDLE_AND_BAR_CENTERS = -20f
diff --git a/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/ui/viewmodel/TaskViewModelTest.kt b/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/ui/viewmodel/TaskViewModelTest.kt
index a97ef0c..18b9fe9 100644
--- a/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/ui/viewmodel/TaskViewModelTest.kt
+++ b/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/ui/viewmodel/TaskViewModelTest.kt
@@ -66,16 +66,7 @@
 
     @Before
     fun setUp() {
-        sut =
-            TaskViewModel(
-                taskViewType = TaskViewType.SINGLE,
-                recentsViewData = recentsViewData,
-                getTaskUseCase = getTaskUseCase,
-                getSysUiStatusNavFlagsUseCase = GetSysUiStatusNavFlagsUseCase(),
-                isThumbnailValidUseCase = isThumbnailValidUseCase,
-                getThumbnailPositionUseCase = getThumbnailPositionUseCase,
-                dispatcherProvider = TestDispatcherProvider(unconfinedTestDispatcher),
-            )
+        sut = createTaskViewModel(TaskViewType.SINGLE)
         whenever(getTaskUseCase.invoke(TASK_MODEL_1.id)).thenReturn(flow { emit(TASK_MODEL_1) })
         whenever(getTaskUseCase.invoke(TASK_MODEL_2.id)).thenReturn(flow { emit(TASK_MODEL_2) })
         whenever(getTaskUseCase.invoke(TASK_MODEL_3.id)).thenReturn(flow { emit(TASK_MODEL_3) })
@@ -93,6 +84,7 @@
                     isLiveTile = false,
                     hasHeader = false,
                     sysUiStatusNavFlags = FLAGS_APPEARANCE_LIGHT_THEME,
+                    taskOverlayEnabled = false,
                 )
             assertThat(sut.state.first()).isEqualTo(expectedResult)
         }
@@ -139,6 +131,7 @@
                     isLiveTile = false,
                     hasHeader = false,
                     sysUiStatusNavFlags = FLAGS_APPEARANCE_LIGHT_THEME,
+                    taskOverlayEnabled = false,
                 )
             assertThat(sut.state.first()).isEqualTo(expectedResult)
         }
@@ -161,6 +154,7 @@
                     isLiveTile = true,
                     hasHeader = false,
                     sysUiStatusNavFlags = FLAGS_APPEARANCE_LIGHT_THEME,
+                    taskOverlayEnabled = false,
                 )
             assertThat(sut.state.first()).isEqualTo(expectedResult)
         }
@@ -183,6 +177,7 @@
                     isLiveTile = false,
                     hasHeader = false,
                     sysUiStatusNavFlags = FLAGS_APPEARANCE_LIGHT_THEME,
+                    taskOverlayEnabled = false,
                 )
             assertThat(sut.state.first()).isEqualTo(expectedResult)
         }
@@ -204,6 +199,7 @@
                     isLiveTile = false,
                     hasHeader = false,
                     sysUiStatusNavFlags = FLAGS_APPEARANCE_LIGHT_THEME,
+                    taskOverlayEnabled = false,
                 )
             assertThat(sut.state.first()).isEqualTo(expectedResult)
         }
@@ -221,6 +217,7 @@
                     isLiveTile = false,
                     hasHeader = false,
                     sysUiStatusNavFlags = FLAGS_APPEARANCE_LIGHT_THEME,
+                    taskOverlayEnabled = false,
                 )
             assertThat(sut.state.first()).isEqualTo(expectedResult)
         }
@@ -235,11 +232,64 @@
                     isLiveTile = false,
                     hasHeader = false,
                     sysUiStatusNavFlags = FLAGS_APPEARANCE_DEFAULT,
+                    taskOverlayEnabled = false,
                 )
             assertThat(sut.state.first()).isEqualTo(expectedResult)
         }
 
     @Test
+    fun taskOverlayEnabled_when_OverlayIsEnabledForVisibleSingleTask() =
+        testScope.runTest {
+            sut.bind(TASK_MODEL_1.id)
+            recentsViewData.overlayEnabled.value = true
+            recentsViewData.settledFullyVisibleTaskIds.value = setOf(1)
+
+            assertThat(sut.state.first().taskOverlayEnabled).isTrue()
+        }
+
+    @Test
+    fun taskOverlayDisabled_when_usingGroupedTask() =
+        testScope.runTest {
+            sut = createTaskViewModel(TaskViewType.GROUPED)
+            sut.bind(TASK_MODEL_1.id)
+            recentsViewData.overlayEnabled.value = true
+            recentsViewData.settledFullyVisibleTaskIds.value = setOf(1)
+
+            assertThat(sut.state.first().taskOverlayEnabled).isFalse()
+        }
+
+    @Test
+    fun taskOverlayDisabled_when_usingDesktopTask() =
+        testScope.runTest {
+            sut = createTaskViewModel(TaskViewType.DESKTOP)
+            sut.bind(TASK_MODEL_1.id)
+            recentsViewData.overlayEnabled.value = true
+            recentsViewData.settledFullyVisibleTaskIds.value = setOf(1)
+
+            assertThat(sut.state.first().taskOverlayEnabled).isFalse()
+        }
+
+    @Test
+    fun taskOverlayDisabled_when_OverlayIsEnabledForInvisibleTask() =
+        testScope.runTest {
+            sut.bind(TASK_MODEL_1.id)
+            recentsViewData.overlayEnabled.value = true
+            recentsViewData.settledFullyVisibleTaskIds.value = setOf(2)
+
+            assertThat(sut.state.first().taskOverlayEnabled).isFalse()
+        }
+
+    @Test
+    fun taskOverlayDisabled_when_OverlayIsDisabledForVisibleTask() =
+        testScope.runTest {
+            sut.bind(TASK_MODEL_1.id)
+            recentsViewData.overlayEnabled.value = false
+            recentsViewData.settledFullyVisibleTaskIds.value = setOf(1)
+
+            assertThat(sut.state.first().taskOverlayEnabled).isFalse()
+        }
+
+    @Test
     fun shouldShowSplash_calls_useCase() {
         sut.isThumbnailValid(null, 0, 0)
         verify(isThumbnailValidUseCase).invoke(anyOrNull(), anyInt(), anyInt())
@@ -256,6 +306,17 @@
             isLocked = isLocked,
         )
 
+    private fun createTaskViewModel(taskViewType: TaskViewType) =
+        TaskViewModel(
+            taskViewType = taskViewType,
+            recentsViewData = recentsViewData,
+            getTaskUseCase = getTaskUseCase,
+            getSysUiStatusNavFlagsUseCase = GetSysUiStatusNavFlagsUseCase(),
+            isThumbnailValidUseCase = isThumbnailValidUseCase,
+            getThumbnailPositionUseCase = getThumbnailPositionUseCase,
+            dispatcherProvider = TestDispatcherProvider(unconfinedTestDispatcher),
+        )
+
     private companion object {
         const val INVALID_TASK_ID = -1
         const val FLAGS_APPEARANCE_LIGHT_THEME = FLAG_LIGHT_STATUS or FLAG_LIGHT_NAV
diff --git a/res/values/dimens.xml b/res/values/dimens.xml
index 64f67cd..a15c130 100644
--- a/res/values/dimens.xml
+++ b/res/values/dimens.xml
@@ -445,7 +445,6 @@
     <dimen name="taskbar_running_app_indicator_height">0dp</dimen>
     <dimen name="taskbar_running_app_indicator_width">0dp</dimen>
     <dimen name="taskbar_running_app_indicator_top_margin">0dp</dimen>
-    <dimen name="taskbar_minimized_app_indicator_width">0dp</dimen>
 
     <!-- Transient taskbar (placeholders to compile in Launcher3 without Quickstep) -->
     <dimen name="transient_taskbar_padding">0dp</dimen>
diff --git a/src/com/android/launcher3/BubbleTextView.java b/src/com/android/launcher3/BubbleTextView.java
index 7b656d7..783e82c 100644
--- a/src/com/android/launcher3/BubbleTextView.java
+++ b/src/com/android/launcher3/BubbleTextView.java
@@ -20,6 +20,7 @@
 import static android.graphics.fonts.FontStyle.FONT_WEIGHT_NORMAL;
 import static android.text.Layout.Alignment.ALIGN_NORMAL;
 
+import static com.android.app.animation.Interpolators.EMPHASIZED;
 import static com.android.launcher3.BubbleTextView.RunningAppState.RUNNING;
 import static com.android.launcher3.BubbleTextView.RunningAppState.NOT_RUNNING;
 import static com.android.launcher3.BubbleTextView.RunningAppState.MINIMIZED;
@@ -37,6 +38,7 @@
 
 import android.animation.Animator;
 import android.animation.AnimatorListenerAdapter;
+import android.animation.AnimatorSet;
 import android.animation.ObjectAnimator;
 import android.content.Context;
 import android.content.res.ColorStateList;
@@ -130,6 +132,9 @@
             StringMatcherUtility.StringMatcher.getInstance();
     private static final int BOLD_TEXT_ADJUSTMENT = FONT_WEIGHT_BOLD - FONT_WEIGHT_NORMAL;
 
+    public static final int LINE_INDICATOR_ANIM_DURATION = 150;
+    private static final float MINIMIZED_APP_INDICATOR_SCALE = 0.5f;
+
     private static final int[] STATE_PRESSED = new int[]{android.R.attr.state_pressed};
 
     private float mScaleForReorderBounce = 1f;
@@ -165,6 +170,36 @@
         }
     };
 
+    private static final Property<BubbleTextView, Integer> LINE_INDICATOR_COLOR_PROPERTY =
+            new Property<>(Integer.class, "lineIndicatorColor") {
+
+                @Override
+                public Integer get(BubbleTextView bubbleTextView) {
+                    return bubbleTextView.mLineIndicatorColor;
+                }
+
+                @Override
+                public void set(BubbleTextView bubbleTextView, Integer color) {
+                    bubbleTextView.mLineIndicatorColor = color;
+                    bubbleTextView.invalidate();
+                }
+            };
+
+    private static final Property<BubbleTextView, Float> LINE_INDICATOR_SCALE_PROPERTY =
+            new Property<>(Float.TYPE, "lineIndicatorScale") {
+
+                @Override
+                public Float get(BubbleTextView bubbleTextView) {
+                    return bubbleTextView.mLineIndicatorScale;
+                }
+
+                @Override
+                public void set(BubbleTextView bubbleTextView, Float scale) {
+                    bubbleTextView.mLineIndicatorScale = scale;
+                    bubbleTextView.invalidate();
+                }
+            };
+
     private final MultiTranslateDelegate mTranslateDelegate = new MultiTranslateDelegate(this);
     protected final ActivityContext mActivity;
     private FastBitmapDrawable mIcon;
@@ -202,7 +237,6 @@
 
     // These fields, related to showing running apps, are only used for Taskbar.
     private final int mRunningAppIndicatorWidth;
-    private final int mMinimizedAppIndicatorWidth;
     private final int mRunningAppIndicatorHeight;
     private final int mRunningAppIndicatorTopMargin;
     private final Paint mRunningAppIndicatorPaint;
@@ -210,6 +244,12 @@
     private RunningAppState mRunningAppState;
     private final int mRunningAppIndicatorColor;
     private final int mMinimizedAppIndicatorColor;
+    @ViewDebug.ExportedProperty(category = "launcher")
+    private int mLineIndicatorColor;
+    @ViewDebug.ExportedProperty(category = "launcher")
+    private float mLineIndicatorScale;
+    private int mLineIndicatorAnimStartDelay;
+    private Animator mLineIndicatorAnim;
 
     private final String mMinimizedStateDescription;
     private final String mRunningStateDescription;
@@ -294,8 +334,6 @@
 
         mRunningAppIndicatorWidth =
                 getResources().getDimensionPixelSize(R.dimen.taskbar_running_app_indicator_width);
-        mMinimizedAppIndicatorWidth =
-                getResources().getDimensionPixelSize(R.dimen.taskbar_minimized_app_indicator_width);
         mRunningAppIndicatorHeight =
                 getResources().getDimensionPixelSize(R.dimen.taskbar_running_app_indicator_height);
         mRunningAppIndicatorTopMargin =
@@ -344,6 +382,11 @@
         mForceHideDot = false;
         setBackground(null);
 
+        mLineIndicatorColor = Color.TRANSPARENT;
+        mLineIndicatorScale = 0;
+        mLineIndicatorAnimStartDelay = 0;
+        cancelLineIndicatorAnim();
+
         setTag(null);
         if (mIconLoadRequest != null) {
             mIconLoadRequest.cancel();
@@ -436,9 +479,50 @@
 
     /** Updates whether the app this view represents is currently running. */
     @UiThread
-    public void updateRunningState(RunningAppState runningAppState) {
+    public void updateRunningState(RunningAppState runningAppState, boolean animate) {
+        if (runningAppState.equals(mRunningAppState)) {
+            return;
+        }
         mRunningAppState = runningAppState;
-        invalidate();
+        cancelLineIndicatorAnim();
+
+        int color = switch (mRunningAppState) {
+            case NOT_RUNNING -> Color.TRANSPARENT;
+            case RUNNING -> mRunningAppIndicatorColor;
+            case MINIMIZED -> mMinimizedAppIndicatorColor;
+        };
+        float scale = switch (mRunningAppState) {
+            case NOT_RUNNING -> 0;
+            case RUNNING -> 1;
+            case MINIMIZED -> MINIMIZED_APP_INDICATOR_SCALE;
+        };
+
+        if (!animate) {
+            mLineIndicatorColor = color;
+            mLineIndicatorScale = scale;
+            invalidate();
+            return;
+        }
+
+        AnimatorSet lineIndicatorAnim  = new AnimatorSet();
+        mLineIndicatorAnim = lineIndicatorAnim;
+        Animator colorAnimator = ObjectAnimator.ofArgb(this, LINE_INDICATOR_COLOR_PROPERTY, color);
+        Animator scaleAnimator = ObjectAnimator.ofFloat(this, LINE_INDICATOR_SCALE_PROPERTY, scale);
+        lineIndicatorAnim.playTogether(colorAnimator, scaleAnimator);
+
+        lineIndicatorAnim.setInterpolator(EMPHASIZED);
+        lineIndicatorAnim.setStartDelay(mLineIndicatorAnimStartDelay);
+        lineIndicatorAnim.setDuration(LINE_INDICATOR_ANIM_DURATION).start();
+    }
+
+    public void setLineIndicatorAnimStartDelay(int lineIndicatorAnimStartDelay) {
+        mLineIndicatorAnimStartDelay = lineIndicatorAnimStartDelay;
+    }
+
+    private void cancelLineIndicatorAnim() {
+        if (mLineIndicatorAnim != null) {
+            mLineIndicatorAnim.cancel();
+        }
     }
 
     /**
@@ -790,19 +874,18 @@
 
     /** Draws a line under the app icon if this is representing a running app in Desktop Mode. */
     protected void drawRunningAppIndicatorIfNecessary(Canvas canvas) {
-        if (mRunningAppState == NOT_RUNNING || mDisplay != DISPLAY_TASKBAR) {
+        if (mDisplay != DISPLAY_TASKBAR
+                || mLineIndicatorScale == 0
+                || mLineIndicatorColor == Color.TRANSPARENT) {
             return;
         }
         getIconBounds(mRunningAppIconBounds);
         Utilities.scaleRectAboutCenter(mRunningAppIconBounds, ICON_VISIBLE_AREA_FACTOR);
 
-        final boolean isMinimized = mRunningAppState == MINIMIZED;
         final int indicatorTop = mRunningAppIconBounds.bottom + mRunningAppIndicatorTopMargin;
-        final int indicatorWidth =
-                isMinimized ? mMinimizedAppIndicatorWidth : mRunningAppIndicatorWidth;
+        final float indicatorWidth = mRunningAppIndicatorWidth * mLineIndicatorScale;
         final float cornerRadius = mRunningAppIndicatorHeight / 2f;
-        mRunningAppIndicatorPaint.setColor(
-                isMinimized ? mMinimizedAppIndicatorColor : mRunningAppIndicatorColor);
+        mRunningAppIndicatorPaint.setColor(mLineIndicatorColor);
 
         canvas.drawRoundRect(
                 mRunningAppIconBounds.centerX() - indicatorWidth / 2f,
diff --git a/tests/src/com/android/launcher3/util/rule/ZipFilesRule.kt b/tests/multivalentTests/src/com/android/launcher3/util/rule/ZipFilesRule.kt
similarity index 100%
rename from tests/src/com/android/launcher3/util/rule/ZipFilesRule.kt
rename to tests/multivalentTests/src/com/android/launcher3/util/rule/ZipFilesRule.kt