Merge "Fix ConcurrentModificationException in OmniInvokerTest" into main
diff --git a/quickstep/res/values/strings.xml b/quickstep/res/values/strings.xml
index 98a2783..63412e9 100644
--- a/quickstep/res/values/strings.xml
+++ b/quickstep/res/values/strings.xml
@@ -348,4 +348,8 @@
     <string name="bubble_bar_action_move_right">Move right</string>
     <!-- Action in accessibility menu to dismiss all bubbles. [CHAR_LIMIT=30] -->
     <string name="bubble_bar_action_dismiss_all">Dismiss all</string>
+    <!-- Accessibility announcement when the bubble bar expands. [CHAR LIMIT=NONE]-->
+    <string name="bubble_bar_accessibility_announce_expand">expand <xliff:g id="bubble_description" example="some title from Messages">%1$s</xliff:g></string>
+    <!-- Accessibility announcement when the bubble bar collapses. [CHAR LIMIT=NONE]-->
+    <string name="bubble_bar_accessibility_announce_collapse">collapse <xliff:g id="bubble_description" example="some title from Messages">%1$s</xliff:g></string>
 </resources>
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java
index 800c594..18becbb 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java
@@ -814,11 +814,6 @@
      */
     public void setUIController(@NonNull TaskbarUIController uiController) {
         mControllers.setUiController(uiController);
-        if (mControllers.bubbleControllers.isEmpty()) {
-            // if the bubble bar was visible in a previous configuration of taskbar and is being
-            // recreated now without bubbles, clean up any bubble bar adjustments from hotseat
-            bubbleBarVisibilityChanged(/* isVisible= */ false);
-        }
     }
 
     /**
diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarView.java b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarView.java
index fab7975..5d550ae 100644
--- a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarView.java
+++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarView.java
@@ -1228,6 +1228,7 @@
                 mWidthAnimator.reverse();
             }
             updateBubbleAccessibilityStates();
+            announceExpandedStateChange();
         }
     }
 
@@ -1344,6 +1345,26 @@
         setContentDescription(contentDesc);
     }
 
+    private void announceExpandedStateChange() {
+        final CharSequence selectedBubbleContentDesc;
+        if (mSelectedBubbleView != null) {
+            selectedBubbleContentDesc = mSelectedBubbleView.getContentDescription();
+        } else {
+            selectedBubbleContentDesc = getResources().getString(
+                    R.string.bubble_bar_bubble_fallback_description);
+        }
+
+        final String msg;
+        if (mIsBarExpanded) {
+            msg = getResources().getString(R.string.bubble_bar_accessibility_announce_expand,
+                    selectedBubbleContentDesc);
+        } else {
+            msg = getResources().getString(R.string.bubble_bar_accessibility_announce_collapse,
+                    selectedBubbleContentDesc);
+        }
+        announceForAccessibility(msg);
+    }
+
     private boolean isIconSizeOrPaddingUpdated(float newIconSize, float newBubbleBarPadding) {
         return isIconSizeUpdated(newIconSize) || isPaddingUpdated(newBubbleBarPadding);
     }
diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarViewController.java b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarViewController.java
index 74a673b..9270f68 100644
--- a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarViewController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarViewController.java
@@ -73,6 +73,7 @@
     private TaskbarInsetsController mTaskbarInsetsController;
     private View.OnClickListener mBubbleClickListener;
     private View.OnClickListener mBubbleBarClickListener;
+    private BubbleView.Controller mBubbleViewController;
 
     // These are exposed to {@link BubbleStashController} to animate for stashing/un-stashing
     private final MultiValueAlpha mBubbleBarAlpha;
@@ -153,6 +154,31 @@
                 mBubbleBarController.updateBubbleBarLocation(location);
             }
         });
+
+        mBubbleViewController = new BubbleView.Controller() {
+            @Override
+            public BubbleBarLocation getBubbleBarLocation() {
+                return BubbleBarViewController.this.getBubbleBarLocation();
+            }
+
+            @Override
+            public void dismiss(BubbleView bubble) {
+                if (bubble.getBubble() != null) {
+                    notifySysUiBubbleDismissed(bubble.getBubble());
+                }
+                onBubbleDismissed(bubble);
+            }
+
+            @Override
+            public void collapse() {
+                collapseBubbleBar();
+            }
+
+            @Override
+            public void updateBubbleBarLocation(BubbleBarLocation location) {
+                mBubbleBarController.updateBubbleBarLocation(location);
+            }
+        };
     }
 
     private void onBubbleClicked(BubbleView bubbleView) {
@@ -165,8 +191,7 @@
         final String currentlySelected = mBubbleBarController.getSelectedBubbleKey();
         if (mBarView.isExpanded() && Objects.equals(bubble.getKey(), currentlySelected)) {
             // Tapping the currently selected bubble while expanded collapses the view.
-            setExpanded(false);
-            mBubbleStashController.stashBubbleBar();
+            collapseBubbleBar();
         } else {
             mBubbleBarController.showAndSelectBubble(bubble);
         }
@@ -196,6 +221,11 @@
         }
     }
 
+    private void collapseBubbleBar() {
+        setExpanded(false);
+        mBubbleStashController.stashBubbleBar();
+    }
+
     /** Notifies that the stash state is changing. */
     public void onStashStateChanging() {
         if (isAnimatingNewBubble()) {
@@ -220,7 +250,7 @@
         return mBubbleBarTranslationY;
     }
 
-    float getBubbleBarCollapsedHeight() {
+    public float getBubbleBarCollapsedHeight() {
         return mBarView.getBubbleBarCollapsedHeight();
     }
 
@@ -440,6 +470,7 @@
     public void removeBubble(BubbleBarBubble b) {
         if (b != null) {
             mBarView.removeBubble(b.getView());
+            b.getView().setController(null);
         } else {
             Log.w(TAG, "removeBubble, bubble was null!");
         }
@@ -450,6 +481,8 @@
             BubbleBarBubble removedBubble, boolean isExpanding, boolean suppressAnimation) {
         mBarView.addBubbleAndRemoveBubble(addedBubble.getView(), removedBubble.getView());
         addedBubble.getView().setOnClickListener(mBubbleClickListener);
+        addedBubble.getView().setController(mBubbleViewController);
+        removedBubble.getView().setController(null);
         mBubbleDragController.setupBubbleView(addedBubble.getView());
         if (!suppressAnimation) {
             animateBubbleNotification(addedBubble, isExpanding, /* isUpdate= */ false);
@@ -464,6 +497,7 @@
             mBarView.addBubble(b.getView());
             b.getView().setOnClickListener(mBubbleClickListener);
             mBubbleDragController.setupBubbleView(b.getView());
+            b.getView().setController(mBubbleViewController);
 
             if (b instanceof BubbleBarOverflow) {
                 return;
@@ -580,8 +614,8 @@
         mSystemUiProxy.stopBubbleDrag(location, mBarView.getRestingTopPositionOnScreen());
     }
 
-    /** Notifies {@link BubbleBarView} that the dragged bubble was dismissed. */
-    public void onBubbleDragDismissed(BubbleView bubble) {
+    /** Handle given bubble being dismissed */
+    public void onBubbleDismissed(BubbleView bubble) {
         mBubbleBarController.onBubbleDismissed(bubble);
         mBarView.removeBubble(bubble);
     }
@@ -624,10 +658,9 @@
     }
 
     /**
-     * Called when given bubble was dismissed. Notifies SystemUI
-     * @param bubble dismissed bubble item
+     * Notify SystemUI that the given bubble has been dismissed.
      */
-    public void onDismissBubble(@NonNull BubbleBarItem bubble) {
+    public void notifySysUiBubbleDismissed(@NonNull BubbleBarItem bubble) {
         mSystemUiProxy.dragBubbleToDismiss(bubble.getKey(), mTimeSource.currentTimeMillis());
     }
 
diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleDismissController.java b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleDismissController.java
index 6a63da8..5eebbd8 100644
--- a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleDismissController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleDismissController.java
@@ -143,7 +143,7 @@
         if (mMagnetizedObject.getUnderlyingObject() instanceof BubbleView) {
             BubbleView bubbleView = (BubbleView) mMagnetizedObject.getUnderlyingObject();
             if (bubbleView.getBubble() != null) {
-                mBubbleBarViewController.onDismissBubble(bubbleView.getBubble());
+                mBubbleBarViewController.notifySysUiBubbleDismissed(bubbleView.getBubble());
             }
         } else if (mMagnetizedObject.getUnderlyingObject() instanceof BubbleBarView) {
             mBubbleBarViewController.onDismissAllBubbles();
diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleDragController.java b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleDragController.java
index 8316b5b..656a266 100644
--- a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleDragController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleDragController.java
@@ -153,7 +153,7 @@
             @Override
             protected void onDragDismiss() {
                 mBubblePinController.onDragEnd();
-                mBubbleBarViewController.onBubbleDragDismissed(bubbleView);
+                mBubbleBarViewController.onBubbleDismissed(bubbleView);
                 mBubbleBarViewController.onBubbleDragEnd();
             }
 
diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleView.java b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleView.java
index acb6b4e..3bcaa16 100644
--- a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleView.java
+++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleView.java
@@ -23,11 +23,13 @@
 import android.graphics.Outline;
 import android.graphics.Path;
 import android.graphics.Rect;
+import android.os.Bundle;
 import android.text.TextUtils;
 import android.util.AttributeSet;
 import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewOutlineProvider;
+import android.view.accessibility.AccessibilityNodeInfo;
 import android.widget.ImageView;
 
 import androidx.constraintlayout.widget.ConstraintLayout;
@@ -36,6 +38,7 @@
 import com.android.launcher3.icons.DotRenderer;
 import com.android.launcher3.icons.IconNormalizer;
 import com.android.wm.shell.animation.Interpolators;
+import com.android.wm.shell.common.bubbles.BubbleBarLocation;
 import com.android.wm.shell.common.bubbles.BubbleInfo;
 
 // TODO: (b/276978250) This is will be similar to WMShell's BadgedImageView, it'd be nice to share.
@@ -73,6 +76,9 @@
 
     private BubbleBarItem mBubble;
 
+    @Nullable
+    private Controller mController;
+
     public BubbleView(Context context) {
         this(context, null);
     }
@@ -180,6 +186,58 @@
         mDotRenderer.draw(canvas, mDrawParams);
     }
 
+    @Override
+    public void onInitializeAccessibilityNodeInfoInternal(AccessibilityNodeInfo info) {
+        super.onInitializeAccessibilityNodeInfoInternal(info);
+        info.addAction(AccessibilityNodeInfo.ACTION_COLLAPSE);
+        if (mBubble instanceof BubbleBarBubble) {
+            info.addAction(AccessibilityNodeInfo.ACTION_DISMISS);
+        }
+        if (mController != null) {
+            if (mController.getBubbleBarLocation().isOnLeft(isLayoutRtl())) {
+                info.addAction(new AccessibilityNodeInfo.AccessibilityAction(R.id.action_move_right,
+                        getResources().getString(R.string.bubble_bar_action_move_right)));
+            } else {
+                info.addAction(new AccessibilityNodeInfo.AccessibilityAction(R.id.action_move_left,
+                        getResources().getString(R.string.bubble_bar_action_move_left)));
+            }
+        }
+    }
+
+    @Override
+    public boolean performAccessibilityActionInternal(int action, Bundle arguments) {
+        if (super.performAccessibilityActionInternal(action, arguments)) {
+            return true;
+        }
+        if (action == AccessibilityNodeInfo.ACTION_COLLAPSE) {
+            if (mController != null) {
+                mController.collapse();
+            }
+            return true;
+        }
+        if (action == AccessibilityNodeInfo.ACTION_DISMISS) {
+            if (mController != null) {
+                mController.dismiss(this);
+            }
+            return true;
+        }
+        if (action == R.id.action_move_left) {
+            if (mController != null) {
+                mController.updateBubbleBarLocation(BubbleBarLocation.LEFT);
+            }
+        }
+        if (action == R.id.action_move_right) {
+            if (mController != null) {
+                mController.updateBubbleBarLocation(BubbleBarLocation.RIGHT);
+            }
+        }
+        return false;
+    }
+
+    void setController(@Nullable Controller controller) {
+        mController = controller;
+    }
+
     /** Sets the bubble being rendered in this view. */
     public void setBubble(BubbleBarBubble bubble) {
         mBubble = bubble;
@@ -337,4 +395,19 @@
         String toString = mBubble != null ? mBubble.getKey() : "null";
         return "BubbleView{" + toString + "}";
     }
+
+    /** Interface for BubbleView to communicate with its controller */
+    public interface Controller {
+        /** Get current bubble bar {@link BubbleBarLocation} */
+        BubbleBarLocation getBubbleBarLocation();
+
+        /** This bubble should be dismissed */
+        void dismiss(BubbleView bubble);
+
+        /** Collapse the bubble bar */
+        void collapse();
+
+        /** Request bubble bar location to be updated to the given location */
+        void updateBubbleBarLocation(BubbleBarLocation location);
+    }
 }
diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/stashing/BubbleStashController.kt b/quickstep/src/com/android/launcher3/taskbar/bubbles/stashing/BubbleStashController.kt
new file mode 100644
index 0000000..e42b6d6
--- /dev/null
+++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/stashing/BubbleStashController.kt
@@ -0,0 +1,202 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.launcher3.taskbar.bubbles.stashing
+
+import android.view.InsetsController
+import android.view.MotionEvent
+import android.view.View
+import com.android.launcher3.taskbar.TaskbarInsetsController
+import com.android.launcher3.taskbar.bubbles.BubbleBarView
+import com.android.launcher3.taskbar.bubbles.BubbleBarViewController
+import com.android.launcher3.taskbar.bubbles.BubbleStashedHandleViewController
+import com.android.wm.shell.common.bubbles.BubbleBarLocation
+import com.android.wm.shell.shared.animation.PhysicsAnimator
+import java.io.PrintWriter
+
+/** StashController that defines stashing behaviour for the taskbar modes. */
+interface BubbleStashController {
+
+    /**
+     * Abstraction on the task bar activity context to only provide the dimensions required for
+     * [BubbleBarView] translation Y computation.
+     */
+    interface TaskbarHotseatDimensionsProvider {
+
+        /** Provides taskbar bottom space in pixels. */
+        fun getTaskbarBottomSpace(): Int
+
+        /** Provides taskbar height in pixels. */
+        fun getTaskbarHeight(): Int
+
+        /** Provides hotseat bottom space in pixels. */
+        fun getHotseatBottomSpace(): Int
+
+        /** Provides hotseat height in pixels. */
+        fun getHotseatHeight(): Int
+    }
+
+    /** Execute passed action only after controllers are initiated. */
+    interface ControllersAfterInitAction {
+        /** Execute action after controllers are initiated. */
+        fun runAfterInit(action: () -> Unit)
+    }
+
+    /** Whether bubble bar is currently stashed */
+    val isStashed: Boolean
+
+    /** Whether launcher enters or exits the home page. */
+    var isBubblesShowingOnHome: Boolean
+
+    /** Whether launcher enters or exits the overview page. */
+    var isBubblesShowingOnOverview: Boolean
+
+    /** Updated when sysui locked state changes, when locked, bubble bar is not shown. */
+    var isSysuiLocked: Boolean
+
+    /** Whether there is a transient taskbar mode */
+    val isTransientTaskBar: Boolean
+
+    /** Whether stash control has a handle view */
+    val hasHandleView: Boolean
+
+    /** Initialize controller */
+    fun init(
+        taskbarInsetsController: TaskbarInsetsController,
+        bubbleBarViewController: BubbleBarViewController,
+        bubbleStashedHandleViewController: BubbleStashedHandleViewController?,
+        controllersAfterInitAction: ControllersAfterInitAction
+    )
+
+    /** Sets stashed and expanded state of the bubble bar */
+    fun updateStashedAndExpandedState(stash: Boolean = false, expand: Boolean = false)
+
+    /**
+     * Shows the bubble bar at [getBubbleBarTranslationY] position immediately without animation.
+     */
+    fun showBubbleBarImmediate()
+
+    /** Shows the bubble bar at [bubbleBarTranslationY] position immediately without animation. */
+    fun showBubbleBarImmediate(bubbleBarTranslationY: Float)
+
+    /** Stashes the bubble bar immediately without animation. */
+    fun stashBubbleBarImmediate()
+
+    /** Returns the touchable height of the bubble bar based on it's stashed state. */
+    fun getTouchableHeight(): Int
+
+    /** Whether bubble bar is currently visible */
+    fun isBubbleBarVisible(): Boolean
+
+    /**
+     * Updates the values of the internal animators after the new bubble animation was interrupted
+     *
+     * @param isStashed whether the current state should be stashed
+     * @param bubbleBarTranslationY the current bubble bar translation. this is only used if the
+     *   bubble bar is showing to ensure that the stash animator runs smoothly.
+     */
+    fun onNewBubbleAnimationInterrupted(isStashed: Boolean, bubbleBarTranslationY: Float)
+
+    /** Checks whether the motion event is over the stash handle or bubble bar. */
+    fun isEventOverBubbleBarViews(ev: MotionEvent): Boolean
+
+    /** Set a bubble bar location */
+    fun setBubbleBarLocation(bubbleBarLocation: BubbleBarLocation)
+
+    /**
+     * Stashes the bubble bar (transform to the handle view), or just shrink width of the expanded
+     * bubble bar based on the controller implementation.
+     */
+    fun stashBubbleBar() {
+        updateStashedAndExpandedState(stash = true, expand = false)
+    }
+
+    /** Shows the bubble bar, and expands bubbles depending on [expandBubbles]. */
+    fun showBubbleBar(expandBubbles: Boolean) {
+        updateStashedAndExpandedState(stash = false, expandBubbles)
+    }
+
+    // TODO(b/354218264): Move to BubbleBarViewAnimator
+    /**
+     * The difference on the Y axis between the center of the handle and the center of the bubble
+     * bar.
+     */
+    fun getSlideInAnimationDistanceY(): Float
+
+    // TODO(b/354218264): Move to BubbleBarViewAnimator
+    /** The distance the handle moves as part of the new bubble animation. */
+    fun getStashedHandleTranslationForNewBubbleAnimation(): Float
+
+    // TODO(b/354218264): Move to BubbleBarViewAnimator
+    /** Returns the [PhysicsAnimator] for the stashed handle view. */
+    fun getStashedHandlePhysicsAnimator(): PhysicsAnimator<View>?
+
+    // TODO(b/354218264): Move to BubbleBarViewAnimator
+    /** Notifies taskbar that it should update its touchable region. */
+    fun updateTaskbarTouchRegion()
+
+    // TODO(b/354218264): Move to BubbleBarViewAnimator
+    /** Set the translation Y for the stashed handle. */
+    fun setHandleTranslationY(translationY: Float)
+
+    /**
+     * Returns bubble bar Y position according to [isBubblesShowingOnHome] and
+     * [isBubblesShowingOnOverview] values. Default implementation only analyse
+     * [isBubblesShowingOnHome] and return translationY to align with the hotseat vertical center.
+     * For Other cases align bubbles with the taskbar.
+     */
+    val bubbleBarTranslationY: Float
+        get() =
+            if (isBubblesShowingOnHome) {
+                bubbleBarTranslationYForHotseat
+            } else {
+                bubbleBarTranslationYForTaskbar
+            }
+
+    /** Translation Y to align the bubble bar with the hotseat. */
+    val bubbleBarTranslationYForTaskbar: Float
+
+    /** Return translation Y to align the bubble bar with the taskbar. */
+    val bubbleBarTranslationYForHotseat: Float
+
+    /** Dumps the state of BubbleStashController. */
+    fun dump(pw: PrintWriter) {
+        pw.println("Bubble stash controller state:")
+        pw.println("  isStashed: $isStashed")
+        pw.println("  isBubblesShowingOnOverview: $isBubblesShowingOnOverview")
+        pw.println("  isBubblesShowingOnHome: $isBubblesShowingOnHome")
+        pw.println("  isSysuiLocked: $isSysuiLocked")
+    }
+
+    companion object {
+        /** How long to stash/unstash. */
+        const val BAR_STASH_DURATION = InsetsController.ANIMATION_DURATION_RESIZE.toLong()
+
+        /** How long to translate Y coordinate of the BubbleBar. */
+        const val BAR_TRANSLATION_DURATION = 300L
+
+        /** The scale bubble bar animates to when being stashed. */
+        const val STASHED_BAR_SCALE = 0.5f
+
+        /** Creates new instance of [BubbleStashController] */
+        @JvmStatic
+        fun newInstance(
+            taskbarHotseatDimensionsProvider: TaskbarHotseatDimensionsProvider
+        ): BubbleStashController {
+            return PersistentTaskbarStashController(taskbarHotseatDimensionsProvider)
+        }
+    }
+}
diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/stashing/PersistentTaskbarStashController.kt b/quickstep/src/com/android/launcher3/taskbar/bubbles/stashing/PersistentTaskbarStashController.kt
new file mode 100644
index 0000000..4a05a5e
--- /dev/null
+++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/stashing/PersistentTaskbarStashController.kt
@@ -0,0 +1,223 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.launcher3.taskbar.bubbles.stashing
+
+import android.animation.Animator
+import android.animation.AnimatorListenerAdapter
+import android.animation.AnimatorSet
+import android.view.MotionEvent
+import android.view.View
+import com.android.launcher3.anim.AnimatedFloat
+import com.android.launcher3.taskbar.TaskbarInsetsController
+import com.android.launcher3.taskbar.bubbles.BubbleBarViewController
+import com.android.launcher3.taskbar.bubbles.BubbleStashedHandleViewController
+import com.android.launcher3.taskbar.bubbles.stashing.BubbleStashController.Companion.BAR_STASH_DURATION
+import com.android.launcher3.taskbar.bubbles.stashing.BubbleStashController.Companion.BAR_TRANSLATION_DURATION
+import com.android.launcher3.taskbar.bubbles.stashing.BubbleStashController.ControllersAfterInitAction
+import com.android.launcher3.taskbar.bubbles.stashing.BubbleStashController.TaskbarHotseatDimensionsProvider
+import com.android.launcher3.util.MultiPropertyFactory
+import com.android.wm.shell.common.bubbles.BubbleBarLocation
+import com.android.wm.shell.shared.animation.PhysicsAnimator
+
+class PersistentTaskbarStashController(
+    private val taskbarHotseatDimensionsProvider: TaskbarHotseatDimensionsProvider,
+) : BubbleStashController {
+
+    private lateinit var taskbarInsetsController: TaskbarInsetsController
+    private lateinit var bubbleBarViewController: BubbleBarViewController
+    private lateinit var bubbleBarTranslationYAnimator: AnimatedFloat
+    private lateinit var bubbleBarAlphaAnimator: MultiPropertyFactory<View>.MultiProperty
+    private lateinit var bubbleBarScaleAnimator: AnimatedFloat
+    private lateinit var controllersAfterInitAction: ControllersAfterInitAction
+
+    override var isBubblesShowingOnHome: Boolean = false
+        set(onHome) {
+            if (field == onHome) return
+            field = onHome
+            if (!bubbleBarViewController.hasBubbles()) {
+                // if there are no bubbles, there's nothing to show, so just return.
+                return
+            }
+            if (onHome) {
+                // When transition to home we should show collapse the bubble bar
+                updateStashedAndExpandedState(stash = false, expand = false)
+            }
+            animateBubbleBarY()
+            bubbleBarViewController.onBubbleBarConfigurationChanged(/* animate= */ true)
+        }
+
+    override var isBubblesShowingOnOverview: Boolean = false
+        set(onOverview) {
+            if (field == onOverview) return
+            field = onOverview
+            if (!onOverview) {
+                // When transition from overview we should show collapse the bubble bar
+                updateStashedAndExpandedState(stash = false, expand = false)
+            }
+            bubbleBarViewController.onBubbleBarConfigurationChanged(/* animate= */ true)
+        }
+
+    override var isSysuiLocked: Boolean = false
+        set(isLocked) {
+            if (field == isLocked) return
+            field = isLocked
+            if (!isLocked && bubbleBarViewController.hasBubbles()) {
+                animateAfterUnlock()
+            }
+        }
+
+    override var isTransientTaskBar: Boolean = false
+
+    /** When the bubble bar is shown for the persistent task bar, there is no handle view. */
+    override val hasHandleView: Boolean = false
+
+    /** For persistent task bar we never stash the bubble bar */
+    override val isStashed: Boolean = false
+
+    override val bubbleBarTranslationYForTaskbar: Float
+        get() {
+            val taskbarBottomMargin = taskbarHotseatDimensionsProvider.getTaskbarBottomSpace()
+            val bubbleBarHeight: Float = bubbleBarViewController.bubbleBarCollapsedHeight
+            val taskbarHeight = taskbarHotseatDimensionsProvider.getTaskbarHeight()
+            return -taskbarBottomMargin - (taskbarHeight - bubbleBarHeight) / 2f
+        }
+
+    override val bubbleBarTranslationYForHotseat: Float
+        get() {
+            val hotseatBottomSpace = taskbarHotseatDimensionsProvider.getHotseatBottomSpace()
+            val hotseatCellHeight = taskbarHotseatDimensionsProvider.getHotseatHeight()
+            val bubbleBarHeight: Float = bubbleBarViewController.bubbleBarCollapsedHeight
+            return -hotseatBottomSpace - (hotseatCellHeight - bubbleBarHeight) / 2
+        }
+
+    override fun init(
+        taskbarInsetsController: TaskbarInsetsController,
+        bubbleBarViewController: BubbleBarViewController,
+        bubbleStashedHandleViewController: BubbleStashedHandleViewController?,
+        controllersAfterInitAction: ControllersAfterInitAction
+    ) {
+        this.taskbarInsetsController = taskbarInsetsController
+        this.bubbleBarViewController = bubbleBarViewController
+        this.controllersAfterInitAction = controllersAfterInitAction
+        bubbleBarTranslationYAnimator = bubbleBarViewController.bubbleBarTranslationY
+        bubbleBarAlphaAnimator = bubbleBarViewController.bubbleBarAlpha.get(0)
+        bubbleBarScaleAnimator = bubbleBarViewController.bubbleBarScale
+    }
+
+    private fun animateAfterUnlock() {
+        val animatorSet = AnimatorSet()
+        if (isBubblesShowingOnHome || isBubblesShowingOnOverview) {
+            animatorSet.playTogether(
+                bubbleBarScaleAnimator.animateToValue(1f),
+                bubbleBarTranslationYAnimator.animateToValue(bubbleBarTranslationY),
+                bubbleBarAlphaAnimator.animateToValue(1f)
+            )
+        }
+        updateTouchRegionOnAnimationEnd(animatorSet)
+        animatorSet.setDuration(BAR_STASH_DURATION).start()
+    }
+
+    override fun updateStashedAndExpandedState(stash: Boolean, expand: Boolean) {
+        if (bubbleBarViewController.isHiddenForNoBubbles) {
+            // If there are no bubbles the bar is invisible, nothing to do here.
+            return
+        }
+        if (bubbleBarViewController.isExpanded != expand) {
+            bubbleBarViewController.isExpanded = expand
+        }
+    }
+
+    override fun showBubbleBarImmediate() = showBubbleBarImmediate(bubbleBarTranslationY)
+
+    override fun showBubbleBarImmediate(bubbleBarTranslationY: Float) {
+        bubbleBarTranslationYAnimator.updateValue(bubbleBarTranslationY)
+        bubbleBarAlphaAnimator.setValue(1f)
+        bubbleBarScaleAnimator.updateValue(1f)
+    }
+
+    override fun setBubbleBarLocation(bubbleBarLocation: BubbleBarLocation) {
+        // When the bubble bar is shown for the persistent task bar, there is no handle view, so no
+        // operation is performed.
+    }
+
+    override fun stashBubbleBarImmediate() {
+        // When the bubble bar is shown for the persistent task bar, there is no handle view, so no
+        // operation is performed.
+    }
+
+    /** If bubble bar is visible return bubble bar height, 0 otherwise */
+    override fun getTouchableHeight() =
+        if (isBubbleBarVisible()) {
+            bubbleBarViewController.bubbleBarCollapsedHeight.toInt()
+        } else {
+            0
+        }
+
+    override fun isBubbleBarVisible(): Boolean = bubbleBarViewController.hasBubbles()
+
+    override fun onNewBubbleAnimationInterrupted(isStashed: Boolean, bubbleBarTranslationY: Float) {
+        showBubbleBarImmediate(bubbleBarTranslationY)
+    }
+
+    override fun isEventOverBubbleBarViews(ev: MotionEvent): Boolean =
+        bubbleBarViewController.isEventOverAnyItem(ev)
+
+    override fun getSlideInAnimationDistanceY(): Float {
+        // distance from the bottom of the screen and the bubble bar center.
+        return -bubbleBarViewController.bubbleBarCollapsedHeight / 2f
+    }
+
+    /** When the bubble bar is shown for the persistent task bar, there is no handle view. */
+    override fun getStashedHandleTranslationForNewBubbleAnimation(): Float = 0f
+
+    /** When the bubble bar is shown for the persistent task bar, there is no handle view. */
+    override fun getStashedHandlePhysicsAnimator(): PhysicsAnimator<View>? = null
+
+    override fun updateTaskbarTouchRegion() {
+        taskbarInsetsController.onTaskbarOrBubblebarWindowHeightOrInsetsChanged()
+    }
+
+    /**
+     * When the bubble bar is shown for the persistent task bar the bar does not stash, so no
+     * operation is performed
+     */
+    override fun setHandleTranslationY(translationY: Float) {
+        // no op since does not have a handle view
+    }
+
+    /** Animates bubble bar Y accordingly to the showing mode */
+    private fun animateBubbleBarY() {
+        val animator =
+            bubbleBarViewController.bubbleBarTranslationY.animateToValue(bubbleBarTranslationY)
+        updateTouchRegionOnAnimationEnd(animator)
+        animator.setDuration(BAR_TRANSLATION_DURATION)
+        animator.start()
+    }
+
+    private fun updateTouchRegionOnAnimationEnd(animator: Animator) {
+        animator.addListener(
+            object : AnimatorListenerAdapter() {
+
+                override fun onAnimationEnd(animation: Animator) {
+                    controllersAfterInitAction.runAfterInit {
+                        taskbarInsetsController.onTaskbarOrBubblebarWindowHeightOrInsetsChanged()
+                    }
+                }
+            }
+        )
+    }
+}
diff --git a/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java b/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java
index df8c05c..5176d74 100644
--- a/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java
+++ b/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java
@@ -256,6 +256,8 @@
 
     private boolean mIsPredictiveBackToHomeInProgress;
 
+    private boolean mCanShowAllAppsEducationView;
+
     public static QuickstepLauncher getLauncher(Context context) {
         return fromContext(context);
     }
@@ -1494,4 +1496,12 @@
     public boolean isRecentsViewVisible() {
         return getStateManager().getState().isRecentsViewVisible;
     }
+
+    public boolean isCanShowAllAppsEducationView() {
+        return mCanShowAllAppsEducationView;
+    }
+
+    public void setCanShowAllAppsEducationView(boolean canShowAllAppsEducationView) {
+        mCanShowAllAppsEducationView = canShowAllAppsEducationView;
+    }
 }
diff --git a/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/NoButtonNavbarToOverviewTouchController.java b/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/NoButtonNavbarToOverviewTouchController.java
index 3325009..5377983 100644
--- a/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/NoButtonNavbarToOverviewTouchController.java
+++ b/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/NoButtonNavbarToOverviewTouchController.java
@@ -39,7 +39,6 @@
 import android.view.ViewConfiguration;
 
 import com.android.internal.jank.Cuj;
-import com.android.launcher3.Launcher;
 import com.android.launcher3.LauncherState;
 import com.android.launcher3.Utilities;
 import com.android.launcher3.anim.AnimatorPlaybackController;
@@ -88,13 +87,16 @@
     // Normal to Hint animation has flag SKIP_OVERVIEW, so we update this scrim with this animator.
     private ObjectAnimator mNormalToHintOverviewScrimAnimator;
 
+    private final QuickstepLauncher mLauncher;
+
     /**
      * @param cancelSplitRunnable Called when split placeholder view needs to be cancelled.
      *                            Animation should be added to the provided AnimatorSet
      */
-    public NoButtonNavbarToOverviewTouchController(Launcher l,
+    public NoButtonNavbarToOverviewTouchController(QuickstepLauncher l,
             BiConsumer<AnimatorSet, Long> cancelSplitRunnable) {
         super(l);
+        mLauncher = l;
         mRecentsView = l.getOverviewPanel();
         mMotionPauseDetector = new MotionPauseDetector(l);
         mMotionPauseMinDisplacement = ViewConfiguration.get(l).getScaledTouchSlop();
@@ -104,7 +106,9 @@
 
     @Override
     protected boolean canInterceptTouch(MotionEvent ev) {
-        if (!isTrackpadMotionEvent(ev) && DisplayController.getNavigationMode(mLauncher)
+        boolean isTrackpadEvent = isTrackpadMotionEvent(ev);
+        mLauncher.setCanShowAllAppsEducationView(!isTrackpadEvent);
+        if (!isTrackpadEvent && DisplayController.getNavigationMode(mLauncher)
                 == THREE_BUTTONS) {
             return false;
         }
diff --git a/quickstep/src/com/android/quickstep/inputconsumers/NavHandleLongPressInputConsumer.java b/quickstep/src/com/android/quickstep/inputconsumers/NavHandleLongPressInputConsumer.java
index 186c453..f4d3695 100644
--- a/quickstep/src/com/android/quickstep/inputconsumers/NavHandleLongPressInputConsumer.java
+++ b/quickstep/src/com/android/quickstep/inputconsumers/NavHandleLongPressInputConsumer.java
@@ -27,6 +27,8 @@
 import android.view.MotionEvent;
 import android.view.ViewConfiguration;
 
+import androidx.annotation.VisibleForTesting;
+
 import com.android.launcher3.Utilities;
 import com.android.launcher3.logging.StatsLogManager;
 import com.android.launcher3.util.DisplayController;
@@ -48,7 +50,7 @@
     private static final boolean DEBUG_NAV_HANDLE = Utilities.isPropertyEnabled(
             NAV_HANDLE_LONG_PRESS);
 
-    private final NavHandleLongPressHandler mNavHandleLongPressHandler;
+    private NavHandleLongPressHandler mNavHandleLongPressHandler;
     private final float mNavHandleWidth;
     private final float mScreenWidth;
 
@@ -265,4 +267,9 @@
     protected String getDelegatorName() {
         return "NavHandleLongPressInputConsumer";
     }
+
+    @VisibleForTesting
+    void setNavHandleLongPressHandler(NavHandleLongPressHandler navHandleLongPressHandler) {
+        mNavHandleLongPressHandler = navHandleLongPressHandler;
+    }
 }
diff --git a/quickstep/src/com/android/quickstep/util/QuickstepOnboardingPrefs.java b/quickstep/src/com/android/quickstep/util/QuickstepOnboardingPrefs.java
index cfe5b2c..70ef47c 100644
--- a/quickstep/src/com/android/quickstep/util/QuickstepOnboardingPrefs.java
+++ b/quickstep/src/com/android/quickstep/util/QuickstepOnboardingPrefs.java
@@ -106,7 +106,8 @@
                         return;
                     }
                     mShouldIncreaseCount = toState == HINT_STATE
-                            && launcher.getWorkspace().getNextPage() == Workspace.DEFAULT_PAGE;
+                            && launcher.getWorkspace().getNextPage() == Workspace.DEFAULT_PAGE
+                            && launcher.isCanShowAllAppsEducationView();
                 }
 
                 @Override
diff --git a/quickstep/tests/src/com/android/launcher3/model/QuickstepModelDelegateTest.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/model/QuickstepModelDelegateTest.kt
similarity index 79%
rename from quickstep/tests/src/com/android/launcher3/model/QuickstepModelDelegateTest.kt
rename to quickstep/tests/multivalentTests/src/com/android/launcher3/model/QuickstepModelDelegateTest.kt
index a532762..0005df6 100644
--- a/quickstep/tests/src/com/android/launcher3/model/QuickstepModelDelegateTest.kt
+++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/model/QuickstepModelDelegateTest.kt
@@ -1,3 +1,18 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
 package com.android.launcher3.model
 
 import android.app.prediction.AppPredictor
@@ -19,7 +34,7 @@
 import org.mockito.Mock
 import org.mockito.Mockito.never
 import org.mockito.Mockito.verify
-import org.mockito.Mockito.verifyZeroInteractions
+import org.mockito.Mockito.verifyNoMoreInteractions
 import org.mockito.MockitoAnnotations
 
 /** Unit tests for [QuickstepModelDelegate]. */
@@ -57,25 +72,25 @@
         underTest.onAppTargetEvent(mockedAppTargetEvent, CONTAINER_PREDICTION)
 
         verify(allAppsPredictor).notifyAppTargetEvent(mockedAppTargetEvent)
-        verifyZeroInteractions(hotseatPredictor)
-        verifyZeroInteractions(widgetRecommendationPredictor)
+        verifyNoMoreInteractions(hotseatPredictor)
+        verifyNoMoreInteractions(widgetRecommendationPredictor)
     }
 
     @Test
     fun onWidgetPrediction_notifyWidgetRecommendationPredictor() {
         underTest.onAppTargetEvent(mockedAppTargetEvent, CONTAINER_WIDGETS_PREDICTION)
 
-        verifyZeroInteractions(allAppsPredictor)
+        verifyNoMoreInteractions(allAppsPredictor)
         verify(widgetRecommendationPredictor).notifyAppTargetEvent(mockedAppTargetEvent)
-        verifyZeroInteractions(hotseatPredictor)
+        verifyNoMoreInteractions(hotseatPredictor)
     }
 
     @Test
     fun onHotseatPrediction_notifyHotseatPredictor() {
         underTest.onAppTargetEvent(mockedAppTargetEvent, CONTAINER_HOTSEAT_PREDICTION)
 
-        verifyZeroInteractions(allAppsPredictor)
-        verifyZeroInteractions(widgetRecommendationPredictor)
+        verifyNoMoreInteractions(allAppsPredictor)
+        verifyNoMoreInteractions(widgetRecommendationPredictor)
         verify(hotseatPredictor).notifyAppTargetEvent(mockedAppTargetEvent)
     }
 
@@ -83,8 +98,8 @@
     fun onOtherClient_notifyHotseatPredictor() {
         underTest.onAppTargetEvent(mockedAppTargetEvent, CONTAINER_WALLPAPERS)
 
-        verifyZeroInteractions(allAppsPredictor)
-        verifyZeroInteractions(widgetRecommendationPredictor)
+        verifyNoMoreInteractions(allAppsPredictor)
+        verifyNoMoreInteractions(widgetRecommendationPredictor)
         verify(hotseatPredictor).notifyAppTargetEvent(mockedAppTargetEvent)
     }
 
diff --git a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/stashing/PersistentTaskbarStashControllerTest.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/stashing/PersistentTaskbarStashControllerTest.kt
new file mode 100644
index 0000000..c46c08d
--- /dev/null
+++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/stashing/PersistentTaskbarStashControllerTest.kt
@@ -0,0 +1,256 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.launcher3.taskbar.bubbles.stashing
+
+import android.animation.AnimatorTestRule
+import android.content.Context
+import android.widget.FrameLayout
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
+import com.android.launcher3.anim.AnimatedFloat
+import com.android.launcher3.taskbar.TaskbarInsetsController
+import com.android.launcher3.taskbar.bubbles.BubbleBarView
+import com.android.launcher3.taskbar.bubbles.BubbleBarViewController
+import com.android.launcher3.util.MultiValueAlpha
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.junit.MockitoJUnit
+import org.mockito.junit.MockitoRule
+import org.mockito.kotlin.clearInvocations
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+
+/** Unit tests for [PersistentTaskbarStashController]. */
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class PersistentTaskbarStashControllerTest {
+
+    companion object {
+        const val BUBBLE_BAR_HEIGHT = 100f
+        const val HOTSEAT_TRANSLATION_Y = -45f
+        const val TASK_BAR_TRANSLATION_Y = -5f
+    }
+
+    @get:Rule val animatorTestRule: AnimatorTestRule = AnimatorTestRule(this)
+    @get:Rule val rule: MockitoRule = MockitoJUnit.rule()
+
+    private val context = ApplicationProvider.getApplicationContext<Context>()
+    private lateinit var bubbleBarView: BubbleBarView
+
+    @Mock lateinit var bubbleBarViewController: BubbleBarViewController
+    @Mock lateinit var taskbarInsetsController: TaskbarInsetsController
+
+    private lateinit var persistentTaskBarStashController: PersistentTaskbarStashController
+    private lateinit var translationY: AnimatedFloat
+    private lateinit var scale: AnimatedFloat
+    private lateinit var alpha: MultiValueAlpha
+
+    @Before
+    fun setUp() {
+        persistentTaskBarStashController =
+            PersistentTaskbarStashController(DefaultDimensionsProvider())
+        setUpBubbleBarView()
+        setUpBubbleBarController()
+        persistentTaskBarStashController.init(
+            taskbarInsetsController,
+            bubbleBarViewController,
+            null,
+            ImmediateAction()
+        )
+    }
+
+    @Test
+    fun setBubblesShowingOnHomeUpdatedToFalse_barPositionYUpdated_controllersNotified() {
+        // Given bubble bar is on home and has bubbles
+        whenever(bubbleBarViewController.hasBubbles()).thenReturn(false)
+        persistentTaskBarStashController.isBubblesShowingOnHome = true
+        whenever(bubbleBarViewController.hasBubbles()).thenReturn(true)
+
+        // When switch out of the home screen
+        getInstrumentation().runOnMainSync {
+            persistentTaskBarStashController.isBubblesShowingOnHome = false
+        }
+
+        // Then translation Y is animating and the bubble bar controller is notified
+        assertThat(translationY.isAnimating).isTrue()
+        verify(bubbleBarViewController).onBubbleBarConfigurationChanged(/* animate= */ true)
+        // Wait until animation ends
+        advanceTimeBy(BubbleStashController.BAR_TRANSLATION_DURATION)
+        // Check translation Y is correct and the insets controller is notified
+        assertThat(bubbleBarView.translationY).isEqualTo(TASK_BAR_TRANSLATION_Y)
+        verify(taskbarInsetsController).onTaskbarOrBubblebarWindowHeightOrInsetsChanged()
+    }
+
+    @Test
+    fun setBubblesShowingOnHomeUpdatedToTrue_barPositionYUpdated_controllersNotified() {
+        // Given bubble bar has bubbles
+        whenever(bubbleBarViewController.hasBubbles()).thenReturn(true)
+
+        // When switch to home screen
+        getInstrumentation().runOnMainSync {
+            persistentTaskBarStashController.isBubblesShowingOnHome = true
+        }
+
+        // Then translation Y is animating and the bubble bar controller is notified
+        assertThat(translationY.isAnimating).isTrue()
+        verify(bubbleBarViewController).onBubbleBarConfigurationChanged(/* animate= */ true)
+        // Wait until animation ends
+        advanceTimeBy(BubbleStashController.BAR_TRANSLATION_DURATION)
+
+        // Check translation Y is correct and the insets controller is notified
+        assertThat(bubbleBarView.translationY).isEqualTo(HOTSEAT_TRANSLATION_Y)
+        verify(taskbarInsetsController).onTaskbarOrBubblebarWindowHeightOrInsetsChanged()
+    }
+
+    @Test
+    fun setBubblesShowingOnOverviewUpdatedToFalse_controllersNotified() {
+        // Given bubble bar is on overview
+        persistentTaskBarStashController.isBubblesShowingOnOverview = true
+        clearInvocations(bubbleBarViewController)
+
+        // When switch out of the overview screen
+        persistentTaskBarStashController.isBubblesShowingOnOverview = false
+
+        // Then bubble bar controller is notified
+        verify(bubbleBarViewController).onBubbleBarConfigurationChanged(/* animate= */ true)
+    }
+
+    @Test
+    fun setBubblesShowingOnOverviewUpdatedToTrue_controllersNotified() {
+        // When switch to the overview screen
+        persistentTaskBarStashController.isBubblesShowingOnOverview = true
+
+        // Then bubble bar controller is notified
+        verify(bubbleBarViewController).onBubbleBarConfigurationChanged(/* animate= */ true)
+    }
+
+    @Test
+    fun isSysuiLockedSwitchedToFalseForOverview_unlockAnimationIsShown() {
+        // Given screen is locked and bubble bar has bubbles
+        persistentTaskBarStashController.isSysuiLocked = true
+        persistentTaskBarStashController.isBubblesShowingOnOverview = true
+        whenever(bubbleBarViewController.hasBubbles()).thenReturn(true)
+
+        // When switch to the overview screen
+        getInstrumentation().runOnMainSync {
+            persistentTaskBarStashController.isSysuiLocked = false
+        }
+
+        // Then
+        assertThat(translationY.isAnimating).isTrue()
+        assertThat(scale.isAnimating).isTrue()
+        // Wait until animation ends
+        advanceTimeBy(BubbleStashController.BAR_STASH_DURATION)
+
+        // Then bubble bar is fully visible at the correct location
+        assertThat(bubbleBarView.scaleX).isEqualTo(1f)
+        assertThat(bubbleBarView.scaleY).isEqualTo(1f)
+        assertThat(bubbleBarView.translationY).isEqualTo(TASK_BAR_TRANSLATION_Y)
+        assertThat(bubbleBarView.alpha).isEqualTo(1f)
+        // Insets controller is notified
+        verify(taskbarInsetsController).onTaskbarOrBubblebarWindowHeightOrInsetsChanged()
+    }
+
+    @Test
+    fun showBubbleBarImmediateToY() {
+        // Given bubble bar is fully transparent and scaled to 0 at 0 y position
+        val targetY = 341f
+        bubbleBarView.alpha = 0f
+        bubbleBarView.scaleX = 0f
+        bubbleBarView.scaleY = 0f
+        bubbleBarView.translationY = 0f
+
+        // When
+        persistentTaskBarStashController.showBubbleBarImmediate(targetY)
+
+        // Then all property values are updated
+        assertThat(bubbleBarView.translationY).isEqualTo(targetY)
+        assertThat(bubbleBarView.alpha).isEqualTo(1f)
+        assertThat(bubbleBarView.scaleX).isEqualTo(1f)
+        assertThat(bubbleBarView.scaleY).isEqualTo(1f)
+    }
+
+    @Test
+    fun isTransientTaskbar_false() {
+        assertThat(persistentTaskBarStashController.isTransientTaskBar).isFalse()
+    }
+
+    @Test
+    fun hasHandleView_false() {
+        assertThat(persistentTaskBarStashController.hasHandleView).isFalse()
+    }
+
+    @Test
+    fun isStashed_false() {
+        assertThat(persistentTaskBarStashController.isStashed).isFalse()
+    }
+
+    @Test
+    fun bubbleBarTranslationYForTaskbar() {
+        // Give bubble bar is on home
+        whenever(bubbleBarViewController.hasBubbles()).thenReturn(false)
+        persistentTaskBarStashController.isBubblesShowingOnHome = true
+
+        // Then bubbleBarTranslationY would be HOTSEAT_TRANSLATION_Y
+        assertThat(persistentTaskBarStashController.bubbleBarTranslationY)
+            .isEqualTo(HOTSEAT_TRANSLATION_Y)
+
+        // Give bubble bar is not on home
+        persistentTaskBarStashController.isBubblesShowingOnHome = false
+
+        // Then bubbleBarTranslationY would be TASK_BAR_TRANSLATION_Y
+        assertThat(persistentTaskBarStashController.bubbleBarTranslationY)
+            .isEqualTo(TASK_BAR_TRANSLATION_Y)
+    }
+
+    private fun advanceTimeBy(advanceMs: Long) {
+        // Advance animator for on-device tests
+        getInstrumentation().runOnMainSync { animatorTestRule.advanceTimeBy(advanceMs) }
+    }
+
+    private fun setUpBubbleBarView() {
+        getInstrumentation().runOnMainSync {
+            bubbleBarView = BubbleBarView(context)
+            bubbleBarView.layoutParams = FrameLayout.LayoutParams(0, 0)
+        }
+    }
+
+    private fun setUpBubbleBarController() {
+        translationY = AnimatedFloat(Runnable { bubbleBarView.translationY = translationY.value })
+        scale =
+            AnimatedFloat(
+                Runnable {
+                    val scale: Float = scale.value
+                    bubbleBarView.scaleX = scale
+                    bubbleBarView.scaleY = scale
+                }
+            )
+        alpha = MultiValueAlpha(bubbleBarView, 1 /* num alpha channels */)
+
+        whenever(bubbleBarViewController.hasBubbles()).thenReturn(true)
+        whenever(bubbleBarViewController.bubbleBarTranslationY).thenReturn(translationY)
+        whenever(bubbleBarViewController.bubbleBarScale).thenReturn(scale)
+        whenever(bubbleBarViewController.bubbleBarAlpha).thenReturn(alpha)
+        whenever(bubbleBarViewController.bubbleBarCollapsedHeight).thenReturn(BUBBLE_BAR_HEIGHT)
+    }
+}
diff --git a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/stashing/StashingTestUtils.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/stashing/StashingTestUtils.kt
new file mode 100644
index 0000000..5dc9440
--- /dev/null
+++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/stashing/StashingTestUtils.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.launcher3.taskbar.bubbles.stashing
+
+class ImmediateAction : BubbleStashController.ControllersAfterInitAction {
+    override fun runAfterInit(action: () -> Unit) = action.invoke()
+}
+
+class DefaultDimensionsProvider : BubbleStashController.TaskbarHotseatDimensionsProvider {
+    override fun getTaskbarBottomSpace(): Int = TASKBAR_BOTTOM_SPACE
+
+    override fun getTaskbarHeight(): Int = TASKBAR_HEIGHT
+
+    override fun getHotseatBottomSpace(): Int = HOTSEAT_BOTTOM_SPACE
+
+    override fun getHotseatHeight(): Int = HOTSEAT_HEIGHT
+
+    companion object {
+        const val TASKBAR_BOTTOM_SPACE = 0
+        const val TASKBAR_HEIGHT = 110
+        const val HOTSEAT_BOTTOM_SPACE = 20
+        const val HOTSEAT_HEIGHT = 150
+    }
+}
diff --git a/quickstep/tests/multivalentTests/src/com/android/quickstep/inputconsumers/NavHandleLongPressInputConsumerTest.java b/quickstep/tests/multivalentTests/src/com/android/quickstep/inputconsumers/NavHandleLongPressInputConsumerTest.java
new file mode 100644
index 0000000..679a208
--- /dev/null
+++ b/quickstep/tests/multivalentTests/src/com/android/quickstep/inputconsumers/NavHandleLongPressInputConsumerTest.java
@@ -0,0 +1,298 @@
+/*
+ * 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.inputconsumers;
+
+import static android.view.MotionEvent.ACTION_CANCEL;
+import static android.view.MotionEvent.ACTION_DOWN;
+import static android.view.MotionEvent.ACTION_HOVER_ENTER;
+import static android.view.MotionEvent.ACTION_MOVE;
+import static android.view.MotionEvent.ACTION_UP;
+
+import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.os.SystemClock;
+import android.view.MotionEvent;
+import android.view.ViewConfiguration;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import com.android.launcher3.util.DisplayController;
+import com.android.launcher3.util.MainThreadInitializedObject.SandboxContext;
+import com.android.quickstep.GestureState;
+import com.android.quickstep.InputConsumer;
+import com.android.quickstep.NavHandle;
+import com.android.quickstep.RecentsAnimationDeviceState;
+import com.android.quickstep.TopTaskTracker;
+import com.android.systemui.shared.system.InputMonitorCompat;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class NavHandleLongPressInputConsumerTest {
+
+    private static final float TOUCH_SLOP = 10;
+    private static final float SQUARED_TOUCH_SLOP = 100;
+
+    private final AtomicBoolean mLongPressTriggered = new AtomicBoolean();
+    private NavHandleLongPressInputConsumer mUnderTest;
+    private float mScreenWidth;
+    @Mock InputConsumer mDelegate;
+    @Mock InputMonitorCompat mInputMonitor;
+    @Mock RecentsAnimationDeviceState mDeviceState;
+    @Mock NavHandle mNavHandle;
+    @Mock GestureState mGestureState;
+    @Mock NavHandleLongPressHandler mNavHandleLongPressHandler;
+    @Mock TopTaskTracker mTopTaskTracker;
+    @Mock TopTaskTracker.CachedTaskInfo mTaskInfo;
+
+    @Before
+    public void setup() {
+        MockitoAnnotations.initMocks(this);
+        when(mTopTaskTracker.getCachedTopTask(anyBoolean())).thenReturn(mTaskInfo);
+        when(mDeviceState.getSquaredTouchSlop()).thenReturn(SQUARED_TOUCH_SLOP);
+        when(mDelegate.allowInterceptByParent()).thenReturn(true);
+        mLongPressTriggered.set(false);
+        when(mNavHandleLongPressHandler.getLongPressRunnable(any())).thenReturn(
+                () -> mLongPressTriggered.set(true));
+        SandboxContext context = new SandboxContext(getApplicationContext());
+        context.putObject(TopTaskTracker.INSTANCE, mTopTaskTracker);
+        mScreenWidth = DisplayController.INSTANCE.get(context).getInfo().currentSize.x;
+        mUnderTest = new NavHandleLongPressInputConsumer(context, mDelegate, mInputMonitor,
+                mDeviceState, mNavHandle, mGestureState);
+        mUnderTest.setNavHandleLongPressHandler(mNavHandleLongPressHandler);
+    }
+
+    @Test
+    public void testGetType() {
+        assertThat(mUnderTest.getType() & InputConsumer.TYPE_NAV_HANDLE_LONG_PRESS).isNotEqualTo(0);
+    }
+
+    @Test
+    public void testDelegateDisallowsTouchIntercept() {
+        when(mDelegate.allowInterceptByParent()).thenReturn(false);
+        mUnderTest.onMotionEvent(generateCenteredMotionEvent(ACTION_DOWN));
+
+        verify(mDelegate).onMotionEvent(any());
+        assertThat(mUnderTest.mState).isEqualTo(DelegateInputConsumer.STATE_INACTIVE);
+        verify(mNavHandleLongPressHandler, never()).onTouchStarted(any());
+        verify(mNavHandleLongPressHandler, never()).onTouchFinished(any(), any());
+    }
+
+    @Test
+    public void testDelegateDisallowsTouchInterceptAfterTouchDown() {
+        mUnderTest.onMotionEvent(generateCenteredMotionEvent(ACTION_DOWN));
+
+        // Delegate should still get touches unless long press is triggered.
+        verify(mDelegate).onMotionEvent(any());
+        verify(mNavHandleLongPressHandler, times(1)).onTouchStarted(any());
+        verify(mNavHandleLongPressHandler, never()).onTouchFinished(any(), any());
+
+        when(mDelegate.allowInterceptByParent()).thenReturn(false);
+        mUnderTest.onMotionEvent(generateCenteredMotionEvent(ACTION_MOVE));
+
+        // Delegate should still get motion events unless long press is triggered.
+        verify(mDelegate, times(2)).onMotionEvent(any());
+        // But our handler should be cancelled.
+        assertThat(mUnderTest.mState).isEqualTo(DelegateInputConsumer.STATE_INACTIVE);
+        verify(mNavHandleLongPressHandler, times(1)).onTouchStarted(any());
+        verify(mNavHandleLongPressHandler, times(1)).onTouchFinished(any(), any());
+    }
+
+    @Test
+    public void testLongPressTriggered() {
+        mUnderTest.onMotionEvent(generateCenteredMotionEvent(ACTION_DOWN));
+        SystemClock.sleep(ViewConfiguration.getLongPressTimeout());
+        InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+        assertThat(mUnderTest.mState).isEqualTo(DelegateInputConsumer.STATE_ACTIVE);
+        assertTrue(mLongPressTriggered.get());
+        verify(mNavHandleLongPressHandler, times(1)).onTouchStarted(any());
+        verify(mNavHandleLongPressHandler, never()).onTouchFinished(any(), any());
+    }
+
+    @Test
+    public void testLongPressTriggeredWithSlightVerticalMovement() {
+        mUnderTest.onMotionEvent(generateCenteredMotionEvent(ACTION_DOWN));
+        mUnderTest.onMotionEvent(generateCenteredMotionEventWithYOffset(ACTION_MOVE,
+                -(TOUCH_SLOP - 1)));
+        SystemClock.sleep(ViewConfiguration.getLongPressTimeout());
+        InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+        assertThat(mUnderTest.mState).isEqualTo(DelegateInputConsumer.STATE_ACTIVE);
+        assertTrue(mLongPressTriggered.get());
+        verify(mNavHandleLongPressHandler, times(1)).onTouchStarted(any());
+        verify(mNavHandleLongPressHandler, never()).onTouchFinished(any(), any());
+    }
+
+    @Test
+    public void testLongPressTriggeredWithSlightHorizontalMovement() {
+        mUnderTest.onMotionEvent(generateCenteredMotionEvent(ACTION_DOWN));
+        mUnderTest.onMotionEvent(generateMotionEvent(ACTION_MOVE,
+                mScreenWidth / 2f - (TOUCH_SLOP - 1), 0));
+        SystemClock.sleep(ViewConfiguration.getLongPressTimeout());
+        InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+        assertThat(mUnderTest.mState).isEqualTo(DelegateInputConsumer.STATE_ACTIVE);
+        assertTrue(mLongPressTriggered.get());
+        verify(mNavHandleLongPressHandler, times(1)).onTouchStarted(any());
+        verify(mNavHandleLongPressHandler, never()).onTouchFinished(any(), any());
+    }
+
+    @Test
+    public void testLongPressAbortedByTouchUp() {
+        mUnderTest.onMotionEvent(generateCenteredMotionEvent(ACTION_DOWN));
+        SystemClock.sleep(ViewConfiguration.getLongPressTimeout() - 10);
+        InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+        assertThat(mUnderTest.mState).isEqualTo(DelegateInputConsumer.STATE_INACTIVE);
+        assertFalse(mLongPressTriggered.get());
+
+        mUnderTest.onMotionEvent(generateCenteredMotionEvent(ACTION_UP));
+        // Wait past the long press timeout, to be extra sure it wouldn't have triggered.
+        SystemClock.sleep(20);
+        InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+        assertThat(mUnderTest.mState).isEqualTo(DelegateInputConsumer.STATE_INACTIVE);
+        assertFalse(mLongPressTriggered.get());
+        verify(mNavHandleLongPressHandler, times(1)).onTouchStarted(any());
+        verify(mNavHandleLongPressHandler, times(1)).onTouchFinished(any(), any());
+    }
+
+    @Test
+    public void testLongPressAbortedByTouchCancel() {
+        mUnderTest.onMotionEvent(generateCenteredMotionEvent(ACTION_DOWN));
+        SystemClock.sleep(ViewConfiguration.getLongPressTimeout() - 10);
+        InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+        assertThat(mUnderTest.mState).isEqualTo(DelegateInputConsumer.STATE_INACTIVE);
+        assertFalse(mLongPressTriggered.get());
+
+        mUnderTest.onMotionEvent(generateCenteredMotionEvent(ACTION_CANCEL));
+        // Wait past the long press timeout, to be extra sure it wouldn't have triggered.
+        SystemClock.sleep(20);
+        InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+        assertThat(mUnderTest.mState).isEqualTo(DelegateInputConsumer.STATE_INACTIVE);
+        assertFalse(mLongPressTriggered.get());
+        verify(mNavHandleLongPressHandler, times(1)).onTouchStarted(any());
+        verify(mNavHandleLongPressHandler, times(1)).onTouchFinished(any(), any());
+    }
+
+    @Test
+    public void testLongPressAbortedByTouchSlopPassedVertically() {
+        mUnderTest.onMotionEvent(generateCenteredMotionEvent(ACTION_DOWN));
+        SystemClock.sleep(ViewConfiguration.getLongPressTimeout() - 10);
+        InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+        assertThat(mUnderTest.mState).isEqualTo(DelegateInputConsumer.STATE_INACTIVE);
+        assertFalse(mLongPressTriggered.get());
+
+        mUnderTest.onMotionEvent(generateCenteredMotionEventWithYOffset(ACTION_MOVE,
+                -(TOUCH_SLOP + 1)));
+        // Wait past the long press timeout, to be extra sure it wouldn't have triggered.
+        SystemClock.sleep(20);
+        InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+        assertThat(mUnderTest.mState).isEqualTo(DelegateInputConsumer.STATE_INACTIVE);
+        assertFalse(mLongPressTriggered.get());
+        verify(mNavHandleLongPressHandler, times(1)).onTouchStarted(any());
+        verify(mNavHandleLongPressHandler, times(1)).onTouchFinished(any(), any());
+    }
+
+    @Test
+    public void testLongPressAbortedByTouchSlopPassedHorizontally() {
+        mUnderTest.onMotionEvent(generateCenteredMotionEvent(ACTION_DOWN));
+        SystemClock.sleep(ViewConfiguration.getLongPressTimeout() - 10);
+        InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+        assertThat(mUnderTest.mState).isEqualTo(DelegateInputConsumer.STATE_INACTIVE);
+        assertFalse(mLongPressTriggered.get());
+
+        mUnderTest.onMotionEvent(generateMotionEvent(ACTION_MOVE,
+                mScreenWidth / 2f - (TOUCH_SLOP + 1), 0));
+        // Wait past the long press timeout, to be extra sure it wouldn't have triggered.
+        SystemClock.sleep(20);
+        InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+        assertThat(mUnderTest.mState).isEqualTo(DelegateInputConsumer.STATE_INACTIVE);
+        assertFalse(mLongPressTriggered.get());
+        verify(mNavHandleLongPressHandler, times(1)).onTouchStarted(any());
+        verify(mNavHandleLongPressHandler, times(1)).onTouchFinished(any(), any());
+    }
+
+    @Test
+    public void testTouchOutsideNavHandleIgnored() {
+        // Touch the far left side of the screen. (y=0 is top of navbar region, picked arbitrarily)
+        mUnderTest.onMotionEvent(generateMotionEvent(ACTION_DOWN, 0, 0));
+        SystemClock.sleep(ViewConfiguration.getLongPressTimeout());
+        InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+        // Should be ignored because the x position was not centered in the navbar region.
+        assertThat(mUnderTest.mState).isEqualTo(DelegateInputConsumer.STATE_INACTIVE);
+        assertFalse(mLongPressTriggered.get());
+        verify(mNavHandleLongPressHandler, never()).onTouchStarted(any());
+        verify(mNavHandleLongPressHandler, never()).onTouchFinished(any(), any());
+    }
+
+    @Test
+    public void testHoverPassedToDelegate() {
+        // Regardless of whether the delegate wants us to intercept, we tell it about hover events.
+        when(mDelegate.allowInterceptByParent()).thenReturn(false);
+        mUnderTest.onHoverEvent(generateCenteredMotionEvent(ACTION_HOVER_ENTER));
+
+        verify(mDelegate).onHoverEvent(any());
+
+        when(mDelegate.allowInterceptByParent()).thenReturn(true);
+        mUnderTest.onHoverEvent(generateCenteredMotionEvent(ACTION_HOVER_ENTER));
+
+        verify(mDelegate, times(2)).onHoverEvent(any());
+    }
+
+    /** Generate a motion event centered horizontally in the screen. */
+    private MotionEvent generateCenteredMotionEvent(int motionAction) {
+        return generateCenteredMotionEventWithYOffset(motionAction, 0);
+    }
+
+    /** Generate a motion event centered horizontally in the screen, with y offset. */
+    private MotionEvent generateCenteredMotionEventWithYOffset(int motionAction, float y) {
+        return generateMotionEvent(motionAction, mScreenWidth / 2f, y);
+    }
+
+    private static MotionEvent generateMotionEvent(int motionAction, float x, float y) {
+        return MotionEvent.obtain(0, 0, motionAction, x, y, 0);
+    }
+}
diff --git a/quickstep/tests/multivalentTests/src/com/android/quickstep/logging/SettingsChangeLoggerTest.kt b/quickstep/tests/multivalentTests/src/com/android/quickstep/logging/SettingsChangeLoggerTest.kt
index 070eeaf..ea2e484 100644
--- a/quickstep/tests/multivalentTests/src/com/android/quickstep/logging/SettingsChangeLoggerTest.kt
+++ b/quickstep/tests/multivalentTests/src/com/android/quickstep/logging/SettingsChangeLoggerTest.kt
@@ -20,9 +20,18 @@
 import androidx.test.core.app.ApplicationProvider
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import com.android.launcher3.LauncherPrefs
+import com.android.launcher3.LauncherPrefs.Companion.THEMED_ICONS
 import com.android.launcher3.LauncherPrefs.Companion.backedUpItem
 import com.android.launcher3.logging.InstanceId
 import com.android.launcher3.logging.StatsLogManager
+import com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_ADD_NEW_APPS_TO_HOME_SCREEN_ENABLED
+import com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_ALL_APPS_SUGGESTIONS_ENABLED
+import com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_HOME_SCREEN_ROTATION_DISABLED
+import com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_HOME_SCREEN_ROTATION_ENABLED
+import com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_HOME_SCREEN_SUGGESTIONS_ENABLED
+import com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_NAVIGATION_MODE_GESTURE_BUTTON
+import com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_NOTIFICATION_DOT_ENABLED
+import com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_THEMED_ICON_DISABLED
 import com.google.common.truth.Truth.assertThat
 import org.junit.After
 import org.junit.Before
@@ -52,18 +61,24 @@
 
     @Captor private lateinit var mEventCaptor: ArgumentCaptor<StatsLogManager.EventEnum>
 
+    private var mDefaultThemedIcons = false
+
     @Before
     fun setUp() {
         MockitoAnnotations.initMocks(this)
 
         whenever(mStatsLogManager.logger()).doReturn(mMockLogger)
         whenever(mStatsLogManager.logger().withInstanceId(any())).doReturn(mMockLogger)
+        mDefaultThemedIcons = LauncherPrefs.get(mContext).get(THEMED_ICONS)
+        // To match the default value of THEMED_ICONS
+        LauncherPrefs.get(mContext).put(THEMED_ICONS, false)
 
         mSystemUnderTest = SettingsChangeLogger(mContext, mStatsLogManager)
     }
 
     @After
     fun tearDown() {
+        LauncherPrefs.get(mContext).put(THEMED_ICONS, mDefaultThemedIcons)
         mSystemUnderTest.close()
     }
 
@@ -87,7 +102,8 @@
         assertThat(capturedEvents.isNotEmpty()).isTrue()
         verifyDefaultEvent(capturedEvents)
         // pref_allowRotation false
-        assertThat(capturedEvents.any { it.id == 616 }).isTrue()
+        assertThat(capturedEvents.any { it.id == LAUNCHER_HOME_SCREEN_ROTATION_DISABLED.id })
+            .isTrue()
     }
 
     @Test
@@ -108,24 +124,22 @@
         val capturedEvents = mEventCaptor.allValues
         assertThat(capturedEvents.isNotEmpty()).isTrue()
         verifyDefaultEvent(capturedEvents)
-        // pref_allowRotation true
-        assertThat(capturedEvents.any { it.id == 615 }).isTrue()
+        assertThat(capturedEvents.any { it.id == LAUNCHER_HOME_SCREEN_ROTATION_ENABLED.id })
+            .isTrue()
     }
 
     private fun verifyDefaultEvent(capturedEvents: MutableList<StatsLogManager.EventEnum>) {
-        // LAUNCHER_NOTIFICATION_DOT_ENABLED
-        assertThat(capturedEvents.any { it.id == 611 }).isTrue()
-        // LAUNCHER_NAVIGATION_MODE_GESTURE_BUTTON
-        assertThat(capturedEvents.any { it.id == 625 }).isTrue()
-        // LAUNCHER_THEMED_ICON_DISABLED
-        assertThat(capturedEvents.any { it.id == 837 }).isTrue()
-        // pref_add_icon_to_home true
-        assertThat(capturedEvents.any { it.id == 613 }).isTrue()
-        // pref_overview_action_suggestions true
-        assertThat(capturedEvents.any { it.id == 619 }).isTrue()
-        // pref_smartspace_home_screen true
-        assertThat(capturedEvents.any { it.id == 621 }).isTrue()
-        // pref_enable_minus_one true
+        assertThat(capturedEvents.any { it.id == LAUNCHER_NOTIFICATION_DOT_ENABLED.id }).isTrue()
+        assertThat(capturedEvents.any { it.id == LAUNCHER_NAVIGATION_MODE_GESTURE_BUTTON.id })
+            .isTrue()
+        assertThat(capturedEvents.any { it.id == LAUNCHER_THEMED_ICON_DISABLED.id }).isTrue()
+        assertThat(capturedEvents.any { it.id == LAUNCHER_ADD_NEW_APPS_TO_HOME_SCREEN_ENABLED.id })
+            .isTrue()
+        assertThat(capturedEvents.any { it.id == LAUNCHER_ALL_APPS_SUGGESTIONS_ENABLED.id })
+            .isTrue()
+        assertThat(capturedEvents.any { it.id == LAUNCHER_HOME_SCREEN_SUGGESTIONS_ENABLED.id })
+            .isTrue()
+        // LAUNCHER_GOOGLE_APP_SWIPE_LEFT_ENABLED
         assertThat(capturedEvents.any { it.id == 617 }).isTrue()
     }
 }
diff --git a/src/com/android/launcher3/allapps/AllAppsStore.java b/src/com/android/launcher3/allapps/AllAppsStore.java
index 89e6adc..a4f130a 100644
--- a/src/com/android/launcher3/allapps/AllAppsStore.java
+++ b/src/com/android/launcher3/allapps/AllAppsStore.java
@@ -262,11 +262,12 @@
         writer.println(prefix + "\tAllAppsStore Apps[] size: " + mApps.length);
         for (int i = 0; i < mApps.length; i++) {
             writer.println(String.format(Locale.getDefault(),
-                    "%s\tPackage index, name, and class: " + "%d/%s:%s",
+                    "%s\tPackage index, name, class, and description: %d/%s:%s, %s",
                     prefix,
                     i,
                     mApps[i].componentName.getPackageName(),
-                    mApps[i].componentName.getClassName()));
+                    mApps[i].componentName.getClassName(),
+                    mApps[i].contentDescription));
         }
     }
 }
diff --git a/src/com/android/launcher3/pm/InstallSessionTracker.java b/src/com/android/launcher3/pm/InstallSessionTracker.java
index 24d58f3..c117be4 100644
--- a/src/com/android/launcher3/pm/InstallSessionTracker.java
+++ b/src/com/android/launcher3/pm/InstallSessionTracker.java
@@ -32,7 +32,6 @@
 import androidx.annotation.WorkerThread;
 
 import com.android.launcher3.Flags;
-import com.android.launcher3.Utilities;
 import com.android.launcher3.util.PackageUserKey;
 
 import java.lang.ref.WeakReference;
diff --git a/tests/src/com/android/launcher3/touch/SingleAxisSwipeDetectorTest.java b/tests/multivalentTests/src/com/android/launcher3/touch/SingleAxisSwipeDetectorTest.java
similarity index 95%
rename from tests/src/com/android/launcher3/touch/SingleAxisSwipeDetectorTest.java
rename to tests/multivalentTests/src/com/android/launcher3/touch/SingleAxisSwipeDetectorTest.java
index 260f556..6cfa6ee 100644
--- a/tests/src/com/android/launcher3/touch/SingleAxisSwipeDetectorTest.java
+++ b/tests/multivalentTests/src/com/android/launcher3/touch/SingleAxisSwipeDetectorTest.java
@@ -22,9 +22,9 @@
 import static com.android.launcher3.touch.SingleAxisSwipeDetector.VERTICAL;
 
 import static org.junit.Assert.assertTrue;
-import static org.mockito.Matchers.any;
-import static org.mockito.Matchers.anyBoolean;
-import static org.mockito.Matchers.anyFloat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyFloat;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.never;
@@ -41,6 +41,8 @@
 
 import com.android.launcher3.testcomponent.TouchEventGenerator;
 
+import com.google.errorprone.annotations.FormatMethod;
+
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -52,7 +54,8 @@
 public class SingleAxisSwipeDetectorTest {
 
     private static final String TAG = SingleAxisSwipeDetectorTest.class.getSimpleName();
-    public static void L(String s, Object... parts) {
+    @FormatMethod
+    public static void logD(String s, Object... parts) {
         Log.d(TAG, (parts.length == 0) ? s : String.format(s, parts));
     }
 
@@ -82,7 +85,7 @@
         mTouchSlop = orgConfig.getScaledTouchSlop();
         doReturn(mTouchSlop).when(mMockConfig).getScaledTouchSlop();
 
-        L("mTouchSlop=", mTouchSlop);
+        logD("mTouchSlop= %s", mTouchSlop);
     }
 
     @Test
diff --git a/tests/src/com/android/launcher3/folder/PreviewItemManagerTest.kt b/tests/src/com/android/launcher3/folder/PreviewItemManagerTest.kt
index da14425..5516f45 100644
--- a/tests/src/com/android/launcher3/folder/PreviewItemManagerTest.kt
+++ b/tests/src/com/android/launcher3/folder/PreviewItemManagerTest.kt
@@ -23,7 +23,7 @@
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SmallTest
 import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
-import com.android.launcher3.LauncherPrefs
+import com.android.launcher3.LauncherPrefs.Companion.THEMED_ICONS
 import com.android.launcher3.LauncherPrefs.Companion.get
 import com.android.launcher3.icons.BaseIconFactory
 import com.android.launcher3.icons.FastBitmapDrawable
@@ -51,6 +51,8 @@
     private lateinit var modelHelper: LauncherModelHelper
     private lateinit var folderIcon: FolderIcon
 
+    private var defaultThemedIcons = false
+
     @Before
     fun setup() {
         getInstrumentation().runOnMainSync {
@@ -127,16 +129,20 @@
                     previewItemManager.mIconSize
                 )
             )
+
+        defaultThemedIcons = get(context).get(THEMED_ICONS)
     }
+
     @After
     @Throws(Exception::class)
     fun tearDown() {
+        get(context).put(THEMED_ICONS, defaultThemedIcons)
         modelHelper.destroy()
     }
 
     @Test
     fun checkThemedIconWithThemingOn_iconShouldBeThemed() {
-        get(context).put(LauncherPrefs.THEMED_ICONS, true)
+        get(context).put(THEMED_ICONS, true)
         val drawingParams = PreviewItemDrawingParams(0f, 0f, 0f)
 
         previewItemManager.setDrawable(drawingParams, folderItems[0])
@@ -146,7 +152,7 @@
 
     @Test
     fun checkThemedIconWithThemingOff_iconShouldNotBeThemed() {
-        get(context).put(LauncherPrefs.THEMED_ICONS, false)
+        get(context).put(THEMED_ICONS, false)
         val drawingParams = PreviewItemDrawingParams(0f, 0f, 0f)
 
         previewItemManager.setDrawable(drawingParams, folderItems[0])
@@ -156,7 +162,7 @@
 
     @Test
     fun checkUnthemedIconWithThemingOn_iconShouldNotBeThemed() {
-        get(context).put(LauncherPrefs.THEMED_ICONS, true)
+        get(context).put(THEMED_ICONS, true)
         val drawingParams = PreviewItemDrawingParams(0f, 0f, 0f)
 
         previewItemManager.setDrawable(drawingParams, folderItems[1])
@@ -166,7 +172,7 @@
 
     @Test
     fun checkUnthemedIconWithThemingOff_iconShouldNotBeThemed() {
-        get(context).put(LauncherPrefs.THEMED_ICONS, false)
+        get(context).put(THEMED_ICONS, false)
         val drawingParams = PreviewItemDrawingParams(0f, 0f, 0f)
 
         previewItemManager.setDrawable(drawingParams, folderItems[1])
@@ -176,7 +182,7 @@
 
     @Test
     fun checkThemedIconWithBadgeWithThemingOn_iconAndBadgeShouldBeThemed() {
-        get(context).put(LauncherPrefs.THEMED_ICONS, true)
+        get(context).put(THEMED_ICONS, true)
         val drawingParams = PreviewItemDrawingParams(0f, 0f, 0f)
 
         previewItemManager.setDrawable(drawingParams, folderItems[2])
@@ -189,7 +195,7 @@
 
     @Test
     fun checkUnthemedIconWithBadgeWithThemingOn_badgeShouldBeThemed() {
-        get(context).put(LauncherPrefs.THEMED_ICONS, true)
+        get(context).put(THEMED_ICONS, true)
         val drawingParams = PreviewItemDrawingParams(0f, 0f, 0f)
 
         previewItemManager.setDrawable(drawingParams, folderItems[3])
@@ -202,7 +208,7 @@
 
     @Test
     fun checkUnthemedIconWithBadgeWithThemingOff_iconAndBadgeShouldNotBeThemed() {
-        get(context).put(LauncherPrefs.THEMED_ICONS, false)
+        get(context).put(THEMED_ICONS, false)
         val drawingParams = PreviewItemDrawingParams(0f, 0f, 0f)
 
         previewItemManager.setDrawable(drawingParams, folderItems[3])
diff --git a/tests/src/com/android/launcher3/pm/InstallSessionTrackerTest.kt b/tests/src/com/android/launcher3/pm/InstallSessionTrackerTest.kt
new file mode 100644
index 0000000..b531adb
--- /dev/null
+++ b/tests/src/com/android/launcher3/pm/InstallSessionTrackerTest.kt
@@ -0,0 +1,228 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.launcher3.pm
+
+import android.content.pm.LauncherApps
+import android.content.pm.PackageInstaller
+import android.os.Build
+import android.os.UserHandle
+import android.platform.test.annotations.EnableFlags
+import android.platform.test.flag.junit.SetFlagsRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import androidx.test.filters.SmallTest
+import com.android.launcher3.Flags.FLAG_ENABLE_SUPPORT_FOR_ARCHIVING
+import com.android.launcher3.util.Executors.MODEL_EXECUTOR
+import com.android.launcher3.util.LauncherModelHelper
+import com.android.launcher3.util.PackageUserKey
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.spy
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class InstallSessionTrackerTest {
+    @get:Rule val setFlagsRule = SetFlagsRule()
+
+    private val mockInstallSessionHelper: InstallSessionHelper = mock()
+    private val mockCallback: InstallSessionTracker.Callback = mock()
+    private val mockPackageInstaller: PackageInstaller = mock()
+
+    private val launcherModelHelper = LauncherModelHelper()
+    private val sandboxContext = launcherModelHelper.sandboxContext
+
+    lateinit var launcherApps: LauncherApps
+    lateinit var installSessionTracker: InstallSessionTracker
+
+    @Before
+    fun setup() {
+        launcherApps = sandboxContext.spyService(LauncherApps::class.java)
+        installSessionTracker =
+            InstallSessionTracker(
+                mockInstallSessionHelper,
+                mockCallback,
+                mockPackageInstaller,
+                launcherApps
+            )
+    }
+
+    @After
+    fun teardown() {
+        launcherModelHelper.destroy()
+    }
+
+    @Test
+    fun `onCreated triggers callbacks for setting up new install session`() {
+        // Given
+        val expectedSessionId = 1
+        val expectedSession =
+            PackageInstaller.SessionInfo().apply {
+                sessionId = expectedSessionId
+                appPackageName = "appPackageName"
+                userId = 0
+            }
+        val expectedPackageKey = PackageUserKey("appPackageName", UserHandle(0))
+        whenever(mockInstallSessionHelper.getVerifiedSessionInfo(expectedSessionId))
+            .thenReturn(expectedSession)
+        // When
+        installSessionTracker.onCreated(expectedSessionId)
+        // Then
+        verify(mockCallback).onInstallSessionCreated(any())
+        verify(mockCallback).onUpdateSessionDisplay(expectedPackageKey, expectedSession)
+        verify(mockInstallSessionHelper).tryQueuePromiseAppIcon(expectedSession)
+    }
+
+    @Test
+    @EnableFlags(FLAG_ENABLE_SUPPORT_FOR_ARCHIVING)
+    fun `onCreated for unarchival triggers onPackageStateChanged`() {
+        // Given
+        val expectedSessionId = 1
+        val expectedSession =
+            spy(PackageInstaller.SessionInfo()).apply {
+                sessionId = expectedSessionId
+                appPackageName = "appPackageName"
+                userId = 0
+                whenever(isUnarchival).thenReturn(true)
+            }
+        whenever(mockInstallSessionHelper.getVerifiedSessionInfo(expectedSessionId))
+            .thenReturn(expectedSession)
+        // When
+        installSessionTracker.onCreated(expectedSessionId)
+        // Then
+        verify(mockCallback).onPackageStateChanged(any())
+    }
+
+    @Test
+    fun `onFinished triggers onPackageStateChanged if session found in cache`() {
+        // Given
+        val expectedSessionId = 1
+        val expectedSession =
+            PackageInstaller.SessionInfo().apply {
+                sessionId = expectedSessionId
+                appPackageName = "appPackageName"
+                userId = 0
+            }
+        val expectedPackageKey = PackageUserKey("appPackageName", UserHandle(0))
+        whenever(mockInstallSessionHelper.getVerifiedSessionInfo(expectedSessionId))
+            .thenReturn(expectedSession)
+        whenever(mockInstallSessionHelper.activeSessions)
+            .thenReturn(hashMapOf(expectedPackageKey to expectedSession))
+        // When
+        installSessionTracker.onFinished(expectedSessionId, /* success */ true)
+        // Then
+        verify(mockCallback).onPackageStateChanged(any())
+    }
+
+    @Test
+    fun `onFinished failure calls onSessionFailure and promise icon removal for existing icon`() {
+        // Given
+        val expectedSessionId = 1
+        val expectedPackage = "appPackageName"
+        val expectedSession =
+            PackageInstaller.SessionInfo().apply {
+                sessionId = expectedSessionId
+                appPackageName = expectedPackage
+                userId = 0
+            }
+        val expectedPackageKey = PackageUserKey(expectedPackage, UserHandle(0))
+        whenever(mockInstallSessionHelper.getVerifiedSessionInfo(expectedSessionId))
+            .thenReturn(expectedSession)
+        whenever(mockInstallSessionHelper.activeSessions)
+            .thenReturn(hashMapOf(expectedPackageKey to expectedSession))
+        whenever(mockInstallSessionHelper.promiseIconAddedForId(expectedSessionId)).thenReturn(true)
+        // When
+        installSessionTracker.onFinished(expectedSessionId, /* success */ false)
+        // Then
+        verify(mockCallback).onSessionFailure(expectedPackage, expectedPackageKey.mUser)
+        verify(mockInstallSessionHelper).removePromiseIconId(expectedSessionId)
+    }
+
+    @Test
+    fun `onProgressChanged triggers onPackageStateChanged if verified session found`() {
+        // Given
+        val expectedSessionId = 1
+        val expectedSession =
+            PackageInstaller.SessionInfo().apply {
+                sessionId = expectedSessionId
+                appPackageName = "appPackageName"
+                userId = 0
+            }
+        whenever(mockInstallSessionHelper.getVerifiedSessionInfo(expectedSessionId))
+            .thenReturn(expectedSession)
+        // When
+        installSessionTracker.onProgressChanged(expectedSessionId, /* progress */ 50f)
+        // Then
+        verify(mockCallback).onPackageStateChanged(any())
+    }
+
+    @Test
+    fun `onBadgingChanged triggers session display update and queues promise icon if verified`() {
+        // Given
+        val expectedSessionId = 1
+        val expectedSession =
+            PackageInstaller.SessionInfo().apply {
+                sessionId = expectedSessionId
+                appPackageName = "appPackageName"
+                userId = 0
+            }
+        val expectedPackageKey = PackageUserKey("appPackageName", UserHandle(0))
+        whenever(mockInstallSessionHelper.getVerifiedSessionInfo(expectedSessionId))
+            .thenReturn(expectedSession)
+        // When
+        installSessionTracker.onBadgingChanged(expectedSessionId)
+        // Then
+        verify(mockCallback).onUpdateSessionDisplay(expectedPackageKey, expectedSession)
+        verify(mockInstallSessionHelper).tryQueuePromiseAppIcon(expectedSession)
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
+    fun `register triggers registerPackageInstallerSessionCallback for versions from Q`() {
+        // Given
+        whenever(
+                launcherApps.registerPackageInstallerSessionCallback(
+                    MODEL_EXECUTOR,
+                    installSessionTracker
+                )
+            )
+            .then { /* no-op */ }
+        // When
+        installSessionTracker.register()
+        // Then
+        verify(launcherApps)
+            .registerPackageInstallerSessionCallback(MODEL_EXECUTOR, installSessionTracker)
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
+    fun `unregister triggers unregisterPackageInstallerSessionCallback for versions from Q`() {
+        // Given
+        whenever(launcherApps.unregisterPackageInstallerSessionCallback(installSessionTracker))
+            .then { /* no-op */ }
+        // When
+        installSessionTracker.unregister()
+        // Then
+        verify(launcherApps).unregisterPackageInstallerSessionCallback(installSessionTracker)
+    }
+}