Merge "[Launcher3] Hides AllApps education view for trackpad" 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/statehandlers/DesktopVisibilityController.java b/quickstep/src/com/android/launcher3/statehandlers/DesktopVisibilityController.java
index 62cc0bb..6c7fe5b 100644
--- a/quickstep/src/com/android/launcher3/statehandlers/DesktopVisibilityController.java
+++ b/quickstep/src/com/android/launcher3/statehandlers/DesktopVisibilityController.java
@@ -19,10 +19,9 @@
 
 import static com.android.launcher3.LauncherState.BACKGROUND_APP;
 import static com.android.launcher3.util.Executors.MAIN_EXECUTOR;
-import static com.android.window.flags.Flags.enableDesktopWindowingWallpaperActivity;
+import static com.android.wm.shell.shared.desktopmode.DesktopModeFlags.WALLPAPER_ACTIVITY;
 
 import android.os.Debug;
-import android.os.SystemProperties;
 import android.util.Log;
 import android.view.View;
 
@@ -145,7 +144,7 @@
                 notifyDesktopVisibilityListeners(areDesktopTasksVisibleNow);
             }
 
-            if (!enableDesktopWindowingWallpaperActivity() && wasVisible != isVisible) {
+            if (!WALLPAPER_ACTIVITY.isEnabled(mLauncher) && wasVisible != isVisible) {
                 // TODO: b/333533253 - Remove after flag rollout
                 if (mVisibleDesktopTasksCount > 0) {
                     setLauncherViewsVisibility(View.INVISIBLE);
@@ -189,7 +188,7 @@
                 notifyDesktopVisibilityListeners(areDesktopTasksVisibleNow);
             }
 
-            if (enableDesktopWindowingWallpaperActivity()) {
+            if (WALLPAPER_ACTIVITY.isEnabled(mLauncher)) {
                 return;
             }
             // TODO: b/333533253 - Clean up after flag rollout
@@ -289,7 +288,7 @@
      * TODO: b/333533253 - Remove after flag rollout
      */
     private void setLauncherViewsVisibility(int visibility) {
-        if (enableDesktopWindowingWallpaperActivity()) {
+        if (WALLPAPER_ACTIVITY.isEnabled(mLauncher)) {
             return;
         }
         if (DEBUG) {
@@ -314,7 +313,7 @@
      * TODO: b/333533253 - Remove after flag rollout
      */
     private void markLauncherPaused() {
-        if (enableDesktopWindowingWallpaperActivity()) {
+        if (WALLPAPER_ACTIVITY.isEnabled(mLauncher)) {
             return;
         }
         if (DEBUG) {
@@ -331,7 +330,7 @@
      * TODO: b/333533253 - Remove after flag rollout
      */
     private void markLauncherResumed() {
-        if (enableDesktopWindowingWallpaperActivity()) {
+        if (WALLPAPER_ACTIVITY.isEnabled(mLauncher)) {
             return;
         }
         if (DEBUG) {
diff --git a/quickstep/src/com/android/launcher3/taskbar/LauncherTaskbarUIController.java b/quickstep/src/com/android/launcher3/taskbar/LauncherTaskbarUIController.java
index 0add1c4..93a023d 100644
--- a/quickstep/src/com/android/launcher3/taskbar/LauncherTaskbarUIController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/LauncherTaskbarUIController.java
@@ -21,7 +21,7 @@
 import static com.android.launcher3.taskbar.TaskbarLauncherStateController.FLAG_VISIBLE;
 import static com.android.launcher3.taskbar.TaskbarStashController.FLAG_IGNORE_IN_APP;
 import static com.android.quickstep.TaskAnimationManager.ENABLE_SHELL_TRANSITIONS;
-import static com.android.window.flags.Flags.enableDesktopWindowingWallpaperActivity;
+import static com.android.wm.shell.shared.desktopmode.DesktopModeFlags.WALLPAPER_ACTIVITY;
 
 import android.animation.Animator;
 import android.animation.AnimatorSet;
@@ -214,7 +214,7 @@
 
         DesktopVisibilityController desktopController =
                 LauncherActivityInterface.INSTANCE.getDesktopVisibilityController();
-        if (!enableDesktopWindowingWallpaperActivity()
+        if (!WALLPAPER_ACTIVITY.isEnabled(mLauncher)
                 && desktopController != null
                 && desktopController.areDesktopTasksVisible()) {
             // TODO: b/333533253 - Remove after flag rollout
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java
index 3048243..800c594 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java
@@ -315,6 +315,7 @@
                 new TaskbarTranslationController(this),
                 new TaskbarSpringOnStashController(this),
                 new TaskbarRecentAppsController(
+                        this,
                         RecentsModel.INSTANCE.get(this),
                         LauncherActivityInterface.INSTANCE::getDesktopVisibilityController),
                 TaskbarEduTooltipController.newInstance(this),
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarRecentAppsController.kt b/quickstep/src/com/android/launcher3/taskbar/TaskbarRecentAppsController.kt
index 5c08116..2cb950c 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarRecentAppsController.kt
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarRecentAppsController.kt
@@ -15,6 +15,7 @@
  */
 package com.android.launcher3.taskbar
 
+import android.content.Context
 import androidx.annotation.VisibleForTesting
 import com.android.launcher3.Flags.enableRecentsInTaskbar
 import com.android.launcher3.model.data.ItemInfo
@@ -26,8 +27,8 @@
 import com.android.quickstep.RecentsModel
 import com.android.quickstep.util.DesktopTask
 import com.android.quickstep.util.GroupTask
-import com.android.window.flags.Flags.enableDesktopWindowingMode
 import com.android.window.flags.Flags.enableDesktopWindowingTaskbarRunningApps
+import com.android.wm.shell.shared.desktopmode.DesktopModeFlags.DESKTOP_WINDOWING_MODE
 import java.io.PrintWriter
 
 /**
@@ -36,6 +37,7 @@
  * - When in Desktop Mode: show the currently running (open) Tasks
  */
 class TaskbarRecentAppsController(
+    context: Context,
     private val recentsModel: RecentsModel,
     // Pass a provider here instead of the actual DesktopVisibilityController instance since that
     // instance might not be available when this constructor is called.
@@ -44,7 +46,7 @@
 
     // TODO(b/335401172): unify DesktopMode checks in Launcher.
     var canShowRunningApps =
-        enableDesktopWindowingMode() && enableDesktopWindowingTaskbarRunningApps()
+        DESKTOP_WINDOWING_MODE.isEnabled(context) && enableDesktopWindowingTaskbarRunningApps()
         @VisibleForTesting
         set(isEnabledFromTest) {
             field = isEnabledFromTest
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 57f6934..5176d74 100644
--- a/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java
+++ b/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java
@@ -64,10 +64,10 @@
 import static com.android.quickstep.util.ActiveGestureErrorDetector.GestureEvent.QUICK_SWITCH_FROM_HOME_FALLBACK;
 import static com.android.quickstep.util.AnimUtils.completeRunnableListCallback;
 import static com.android.quickstep.util.SplitAnimationTimings.TABLET_HOME_TO_SPLIT;
+import static com.android.wm.shell.shared.desktopmode.DesktopModeFlags.WALLPAPER_ACTIVITY;
 import static com.android.systemui.shared.system.ActivityManagerWrapper.CLOSE_SYSTEM_WINDOWS_REASON_HOME_KEY;
-import static com.android.window.flags.Flags.enableDesktopWindowingMode;
-import static com.android.window.flags.Flags.enableDesktopWindowingWallpaperActivity;
 import static com.android.wm.shell.common.split.SplitScreenConstants.SNAP_TO_50_50;
+import static com.android.wm.shell.shared.desktopmode.DesktopModeFlags.DESKTOP_WINDOWING_MODE;
 
 import android.animation.Animator;
 import android.animation.AnimatorListenerAdapter;
@@ -201,6 +201,8 @@
 import com.android.systemui.unfold.progress.RemoteUnfoldTransitionReceiver;
 import com.android.systemui.unfold.updates.RotationChangeProvider;
 
+import kotlin.Unit;
+
 import java.io.FileDescriptor;
 import java.io.PrintWriter;
 import java.util.ArrayList;
@@ -213,8 +215,6 @@
 import java.util.function.Predicate;
 import java.util.stream.Stream;
 
-import kotlin.Unit;
-
 public class QuickstepLauncher extends Launcher implements RecentsViewContainer {
     private static final boolean TRACE_LAYOUTS =
             SystemProperties.getBoolean("persist.debug.trace_layouts", false);
@@ -278,7 +278,7 @@
         // TODO(b/337863494): Explore use of the same OverviewComponentObserver across launcher
         OverviewComponentObserver overviewComponentObserver = new OverviewComponentObserver(
                 asContext(), deviceState);
-        if (enableDesktopWindowingMode()) {
+        if (DESKTOP_WINDOWING_MODE.isEnabled(this)) {
             mDesktopRecentsTransitionController = new DesktopRecentsTransitionController(
                     getStateManager(), systemUiProxy, getIApplicationThread(),
                     getDepthController());
@@ -298,7 +298,7 @@
 
         mTISBindHelper = new TISBindHelper(this, this::onTISConnected);
         mDepthController = new DepthController(this);
-        if (enableDesktopWindowingMode()) {
+        if (DESKTOP_WINDOWING_MODE.isEnabled(this)) {
             mDesktopVisibilityController = new DesktopVisibilityController(this);
             mDesktopVisibilityController.registerSystemUiListener();
             mSplitSelectStateController.initSplitFromDesktopController(this,
@@ -1006,7 +1006,7 @@
 
     @Override
     public void setResumed() {
-        if (!enableDesktopWindowingWallpaperActivity()
+        if (!WALLPAPER_ACTIVITY.isEnabled(this)
                 && mDesktopVisibilityController != null
                 && mDesktopVisibilityController.areDesktopTasksVisible()
                 && !mDesktopVisibilityController.isRecentsGestureInProgress()) {
diff --git a/quickstep/src/com/android/quickstep/AbsSwipeUpHandler.java b/quickstep/src/com/android/quickstep/AbsSwipeUpHandler.java
index 20eaddc..b059186 100644
--- a/quickstep/src/com/android/quickstep/AbsSwipeUpHandler.java
+++ b/quickstep/src/com/android/quickstep/AbsSwipeUpHandler.java
@@ -62,6 +62,8 @@
 import static com.android.quickstep.util.ActiveGestureErrorDetector.GestureEvent.ON_SETTLED_ON_END_TARGET;
 import static com.android.quickstep.views.RecentsView.UPDATE_SYSUI_FLAGS_THRESHOLD;
 import static com.android.systemui.shared.system.ActivityManagerWrapper.CLOSE_SYSTEM_WINDOWS_REASON_RECENTS;
+import static com.android.wm.shell.shared.desktopmode.DesktopModeFlags.DESKTOP_WINDOWING_MODE;
+import static com.android.wm.shell.shared.desktopmode.DesktopModeFlags.WALLPAPER_ACTIVITY;
 
 import android.animation.Animator;
 import android.animation.AnimatorListenerAdapter;
@@ -152,6 +154,8 @@
 import com.android.wm.shell.common.TransactionPool;
 import com.android.wm.shell.startingsurface.SplashScreenExitAnimationUtils;
 
+import kotlin.Unit;
+
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
@@ -161,8 +165,6 @@
 import java.util.OptionalInt;
 import java.util.function.Consumer;
 
-import kotlin.Unit;
-
 /**
  * Handles the navigation gestures when Launcher is the default home activity.
  */
@@ -952,7 +954,7 @@
     public void onRecentsAnimationStart(RecentsAnimationController controller,
             RecentsAnimationTargets targets) {
         super.onRecentsAnimationStart(controller, targets);
-        if (targets.hasDesktopTasks()) {
+        if (targets.hasDesktopTasks(mContext)) {
             mRemoteTargetHandles = mTargetGluer.assignTargetsForDesktop(targets);
         } else {
             int untrimmedAppCount = mRemoteTargetHandles.length;
@@ -1272,8 +1274,8 @@
         TaskView currentPageTaskView = mRecentsView != null
                 ? mRecentsView.getCurrentPageTaskView() : null;
 
-        if (Flags.enableDesktopWindowingMode()
-                && !(Flags.enableDesktopWindowingWallpaperActivity()
+        if (DESKTOP_WINDOWING_MODE.isEnabled(mContext)
+                && !(WALLPAPER_ACTIVITY.isEnabled(mContext)
                 && Flags.enableDesktopWindowingQuickSwitch())) {
             if ((nextPageTaskView instanceof DesktopTaskView
                     || currentPageTaskView instanceof DesktopTaskView)
@@ -1445,8 +1447,8 @@
             setClampScrollOffset(false);
         };
 
-        if (Flags.enableDesktopWindowingMode()
-                && !(Flags.enableDesktopWindowingWallpaperActivity()
+        if (DESKTOP_WINDOWING_MODE.isEnabled(mContext)
+                && !(WALLPAPER_ACTIVITY.isEnabled(mContext)
                 && Flags.enableDesktopWindowingQuickSwitch())) {
             if (mRecentsView != null && (mRecentsView.getCurrentPageTaskView() != null
                     && !(mRecentsView.getCurrentPageTaskView() instanceof DesktopTaskView))) {
@@ -2293,8 +2295,8 @@
                     mRecentsAnimationController, mRecentsAnimationTargets);
         });
 
-        if (Flags.enableDesktopWindowingMode()
-                && !(Flags.enableDesktopWindowingWallpaperActivity()
+        if (DESKTOP_WINDOWING_MODE.isEnabled(mContext)
+                && !(WALLPAPER_ACTIVITY.isEnabled(mContext)
                         && Flags.enableDesktopWindowingQuickSwitch())) {
             if (mRecentsView.getNextPageTaskView() instanceof DesktopTaskView
                     || mRecentsView.getCurrentPageTaskView() instanceof DesktopTaskView) {
diff --git a/quickstep/src/com/android/quickstep/RecentTasksList.java b/quickstep/src/com/android/quickstep/RecentTasksList.java
index 4989831..95c86fa 100644
--- a/quickstep/src/com/android/quickstep/RecentTasksList.java
+++ b/quickstep/src/com/android/quickstep/RecentTasksList.java
@@ -20,13 +20,14 @@
 
 import static com.android.launcher3.util.Executors.UI_HELPER_EXECUTOR;
 import static com.android.quickstep.util.SplitScreenUtils.convertShellSplitBoundsToLauncher;
-import static com.android.window.flags.Flags.enableDesktopWindowingMode;
+import static com.android.wm.shell.shared.desktopmode.DesktopModeFlags.DESKTOP_WINDOWING_MODE;
 import static com.android.wm.shell.util.GroupedRecentTaskInfo.TYPE_FREEFORM;
 
 import android.app.ActivityManager;
 import android.app.KeyguardManager;
 import android.app.TaskInfo;
 import android.content.ComponentName;
+import android.content.Context;
 import android.os.Process;
 import android.os.RemoteException;
 import android.util.SparseBooleanArray;
@@ -58,6 +59,7 @@
 
     private static final TaskLoadResult INVALID_RESULT = new TaskLoadResult(-1, false, 0);
 
+    private final Context mContext;
     private final KeyguardManager mKeyguardManager;
     private final LooperExecutor mMainThreadExecutor;
     private final SystemUiProxy mSysUiProxy;
@@ -76,8 +78,10 @@
     // Tasks are stored in order of least recently launched to most recently launched.
     private ArrayList<ActivityManager.RunningTaskInfo> mRunningTasks;
 
-    public RecentTasksList(LooperExecutor mainThreadExecutor, KeyguardManager keyguardManager,
-            SystemUiProxy sysUiProxy, TopTaskTracker topTaskTracker) {
+    public RecentTasksList(Context context, LooperExecutor mainThreadExecutor,
+            KeyguardManager keyguardManager, SystemUiProxy sysUiProxy,
+            TopTaskTracker topTaskTracker) {
+        mContext = context;
         mMainThreadExecutor = mainThreadExecutor;
         mKeyguardManager = keyguardManager;
         mChangeId = 1;
@@ -325,9 +329,9 @@
         int numVisibleTasks = 0;
         for (GroupedRecentTaskInfo rawTask : rawTasks) {
             if (rawTask.getType() == TYPE_FREEFORM) {
-                // TYPE_FREEFORM tasks is only created when enableDesktopWindowingMode() is true,
+                // TYPE_FREEFORM tasks is only created whenDESKTOP_WINDOWING_MODE.isEnabled is true,
                 // leftover TYPE_FREEFORM tasks created when flag was on should be ignored.
-                if (enableDesktopWindowingMode()) {
+                if (DESKTOP_WINDOWING_MODE.isEnabled(mContext)) {
                     GroupTask desktopTask = createDesktopTask(rawTask);
                     if (desktopTask != null) {
                         allTasks.add(desktopTask);
diff --git a/quickstep/src/com/android/quickstep/RecentsActivity.java b/quickstep/src/com/android/quickstep/RecentsActivity.java
index 18461a6..e84200d 100644
--- a/quickstep/src/com/android/quickstep/RecentsActivity.java
+++ b/quickstep/src/com/android/quickstep/RecentsActivity.java
@@ -27,7 +27,7 @@
 import static com.android.quickstep.OverviewComponentObserver.startHomeIntentSafely;
 import static com.android.quickstep.TaskUtils.taskIsATargetWithMode;
 import static com.android.quickstep.TaskViewUtils.createRecentsWindowAnimator;
-import static com.android.window.flags.Flags.enableDesktopWindowingMode;
+import static com.android.wm.shell.shared.desktopmode.DesktopModeFlags.DESKTOP_WINDOWING_MODE;
 
 import android.animation.Animator;
 import android.animation.AnimatorListenerAdapter;
@@ -147,7 +147,7 @@
         mActionsView = findViewById(R.id.overview_actions_view);
         getRootView().getSysUiScrim().getSysUIProgress().updateValue(0);
         mDragLayer.recreateControllers();
-        if (enableDesktopWindowingMode()) {
+        if (DESKTOP_WINDOWING_MODE.isEnabled(this)) {
             mDesktopRecentsTransitionController = new DesktopRecentsTransitionController(
                     getStateManager(), systemUiProxy, getIApplicationThread(),
                     null /* depthController */
diff --git a/quickstep/src/com/android/quickstep/RecentsAnimationTargets.java b/quickstep/src/com/android/quickstep/RecentsAnimationTargets.java
index 82bb453..d104911 100644
--- a/quickstep/src/com/android/quickstep/RecentsAnimationTargets.java
+++ b/quickstep/src/com/android/quickstep/RecentsAnimationTargets.java
@@ -18,9 +18,10 @@
 import static android.app.WindowConfiguration.WINDOWING_MODE_FREEFORM;
 import static android.view.RemoteAnimationTarget.MODE_CLOSING;
 
-import static com.android.window.flags.Flags.enableDesktopWindowingMode;
+import static com.android.wm.shell.shared.desktopmode.DesktopModeFlags.DESKTOP_WINDOWING_MODE;
 
 import android.app.WindowConfiguration;
+import android.content.Context;
 import android.graphics.Rect;
 import android.os.Bundle;
 import android.view.RemoteAnimationTarget;
@@ -54,8 +55,8 @@
      *
      * @return {@code true} if at least one target app is a desktop task
      */
-    public boolean hasDesktopTasks() {
-        if (!enableDesktopWindowingMode()) {
+    public boolean hasDesktopTasks(Context context) {
+        if (!DESKTOP_WINDOWING_MODE.isEnabled(context)) {
             return false;
         }
         for (RemoteAnimationTarget target : apps) {
diff --git a/quickstep/src/com/android/quickstep/RecentsModel.java b/quickstep/src/com/android/quickstep/RecentsModel.java
index f2b6005..db03dac 100644
--- a/quickstep/src/com/android/quickstep/RecentsModel.java
+++ b/quickstep/src/com/android/quickstep/RecentsModel.java
@@ -90,7 +90,9 @@
 
     private RecentsModel(Context context, IconProvider iconProvider) {
         this(context,
-                new RecentTasksList(MAIN_EXECUTOR,
+                new RecentTasksList(
+                        context,
+                        MAIN_EXECUTOR,
                         context.getSystemService(KeyguardManager.class),
                         SystemUiProxy.INSTANCE.get(context),
                         TopTaskTracker.INSTANCE.get(context)),
diff --git a/quickstep/src/com/android/quickstep/SystemUiProxy.java b/quickstep/src/com/android/quickstep/SystemUiProxy.java
index 3f73959..f2db5af 100644
--- a/quickstep/src/com/android/quickstep/SystemUiProxy.java
+++ b/quickstep/src/com/android/quickstep/SystemUiProxy.java
@@ -23,8 +23,8 @@
 import static com.android.launcher3.util.SplitConfigurationOptions.StagePosition;
 import static com.android.quickstep.util.ActiveGestureErrorDetector.GestureEvent.RECENT_TASKS_MISSING;
 import static com.android.quickstep.util.LogUtils.splitFailureMessage;
-import static com.android.window.flags.Flags.enableDesktopWindowingMode;
 import static com.android.window.flags.Flags.enableDesktopWindowingTaskbarRunningApps;
+import static com.android.wm.shell.shared.desktopmode.DesktopModeFlags.DESKTOP_WINDOWING_MODE;
 
 import android.app.ActivityManager;
 import android.app.ActivityOptions;
@@ -1444,7 +1444,8 @@
 
     private boolean shouldEnableRunningTasksForDesktopMode() {
         // TODO(b/335401172): unify DesktopMode checks in Launcher
-        return enableDesktopWindowingMode() && enableDesktopWindowingTaskbarRunningApps();
+        return DESKTOP_WINDOWING_MODE.isEnabled(mContext)
+                && enableDesktopWindowingTaskbarRunningApps();
     }
 
     private boolean handleMessageAsync(Message msg) {
diff --git a/quickstep/src/com/android/quickstep/util/AnimUtils.java b/quickstep/src/com/android/quickstep/util/AnimUtils.java
index 8e3d44f..31aca03 100644
--- a/quickstep/src/com/android/quickstep/util/AnimUtils.java
+++ b/quickstep/src/com/android/quickstep/util/AnimUtils.java
@@ -17,18 +17,28 @@
 package com.android.quickstep.util;
 
 import static com.android.app.animation.Interpolators.clampToProgress;
+import static com.android.launcher3.LauncherState.NORMAL;
 import static com.android.launcher3.util.Executors.MAIN_EXECUTOR;
 
+import android.animation.AnimatorSet;
+import android.annotation.NonNull;
 import android.os.Bundle;
 import android.os.IRemoteCallback;
 import android.view.animation.Interpolator;
 
+import com.android.launcher3.logging.StatsLogManager;
+import com.android.launcher3.statemanager.BaseState;
+import com.android.launcher3.statemanager.StateManager;
+import com.android.launcher3.states.StateAnimationConfig;
 import com.android.launcher3.util.RunnableList;
+import com.android.quickstep.views.RecentsViewContainer;
 
 /**
  * Utility class containing methods to help manage animations, interpolators, and timings.
  */
 public class AnimUtils {
+    private static final int DURATION_DEFAULT_SPLIT_DISMISS = 350;
+
     /**
      * Fetches device-specific timings for the Overview > Split animation
      * (splitscreen initiated from Overview).
@@ -59,6 +69,33 @@
     }
 
     /**
+     * Synchronizes the timing for the split dismiss animation to the current transition to
+     * NORMAL (launcher home/workspace)
+     */
+    public static void goToNormalStateWithSplitDismissal(@NonNull StateManager stateManager,
+            @NonNull RecentsViewContainer container,
+            @NonNull StatsLogManager.LauncherEvent exitReason,
+            @NonNull SplitAnimationController animationController) {
+        StateAnimationConfig config = new StateAnimationConfig();
+        BaseState startState = stateManager.getState();
+        long duration = startState.getTransitionDuration(container.asContext(),
+                false /*isToState*/);
+        if (duration == 0) {
+            // Case where we're in contextual on workspace (NORMAL), which by default has 0
+            // transition duration
+            duration = DURATION_DEFAULT_SPLIT_DISMISS;
+        }
+        config.duration = duration;
+        AnimatorSet stateAnim = stateManager.createAtomicAnimation(
+                startState, NORMAL, config);
+        AnimatorSet dismissAnim = animationController
+                .createPlaceholderDismissAnim(container, exitReason, duration);
+        stateAnim.play(dismissAnim);
+        stateManager.setCurrentAnimation(stateAnim, NORMAL);
+        stateAnim.start();
+    }
+
+    /**
      * Returns a IRemoteCallback which completes the provided list as a result
      */
     public static IRemoteCallback completeRunnableListCallback(RunnableList list) {
diff --git a/quickstep/src/com/android/quickstep/views/LauncherRecentsView.java b/quickstep/src/com/android/quickstep/views/LauncherRecentsView.java
index e48a7c6..d20d0a5 100644
--- a/quickstep/src/com/android/quickstep/views/LauncherRecentsView.java
+++ b/quickstep/src/com/android/quickstep/views/LauncherRecentsView.java
@@ -26,7 +26,7 @@
 import static com.android.launcher3.LauncherState.OVERVIEW_SPLIT_SELECT;
 import static com.android.launcher3.LauncherState.SPRING_LOADED;
 import static com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_SPLIT_SELECTION_EXIT_HOME;
-import static com.android.window.flags.Flags.enableDesktopWindowingWallpaperActivity;
+import static com.android.wm.shell.shared.desktopmode.DesktopModeFlags.WALLPAPER_ACTIVITY;
 
 import android.annotation.TargetApi;
 import android.content.Context;
@@ -54,6 +54,7 @@
 import com.android.quickstep.LauncherActivityInterface;
 import com.android.quickstep.RotationTouchHelper;
 import com.android.quickstep.SystemUiProxy;
+import com.android.quickstep.util.AnimUtils;
 import com.android.quickstep.util.SplitSelectStateController;
 import com.android.systemui.shared.recents.model.Task;
 
@@ -91,10 +92,12 @@
     protected void handleStartHome(boolean animated) {
         StateManager stateManager = getStateManager();
         animated &= stateManager.shouldAnimateStateChange();
-        stateManager.goToState(NORMAL, animated);
-        if (FeatureFlags.enableSplitContextually()) {
-            mSplitSelectStateController.getSplitAnimationController()
-                    .playPlaceholderDismissAnim(mContainer, LAUNCHER_SPLIT_SELECTION_EXIT_HOME);
+        if (mSplitSelectStateController.isSplitSelectActive()) {
+            AnimUtils.goToNormalStateWithSplitDismissal(stateManager, mContainer,
+                    LAUNCHER_SPLIT_SELECTION_EXIT_HOME,
+                    mSplitSelectStateController.getSplitAnimationController());
+        } else {
+            stateManager.goToState(NORMAL, animated);
         }
         AbstractFloatingView.closeAllOpenViews(mContainer, animated);
     }
@@ -265,7 +268,7 @@
         super.onGestureAnimationStart(runningTasks, rotationTouchHelper);
         DesktopVisibilityController desktopVisibilityController =
                 mContainer.getDesktopVisibilityController();
-        if (!enableDesktopWindowingWallpaperActivity() && desktopVisibilityController != null) {
+        if (!WALLPAPER_ACTIVITY.isEnabled(mContext) && desktopVisibilityController != null) {
             // TODO: b/333533253 - Remove after flag rollout
             desktopVisibilityController.setRecentsGestureStart();
         }
@@ -288,7 +291,7 @@
             }
         }
         super.onGestureAnimationEnd();
-        if (!enableDesktopWindowingWallpaperActivity() && desktopVisibilityController != null) {
+        if (!WALLPAPER_ACTIVITY.isEnabled(mContext) && desktopVisibilityController != null) {
             // TODO: b/333533253 - Remove after flag rollout
             desktopVisibilityController.setRecentsGestureEnd(endTarget);
         }
diff --git a/quickstep/src/com/android/quickstep/views/RecentsView.java b/quickstep/src/com/android/quickstep/views/RecentsView.java
index a3d6359..d63ac56 100644
--- a/quickstep/src/com/android/quickstep/views/RecentsView.java
+++ b/quickstep/src/com/android/quickstep/views/RecentsView.java
@@ -5498,7 +5498,7 @@
         }
 
         RemoteTargetGluer gluer;
-        if (recentsAnimationTargets.hasDesktopTasks()) {
+        if (recentsAnimationTargets.hasDesktopTasks(mContext)) {
             gluer = new RemoteTargetGluer(getContext(), getSizeStrategy(), recentsAnimationTargets,
                     true /* forDesktop */);
             mRemoteTargetHandles = gluer.assignTargetsForDesktop(recentsAnimationTargets);
diff --git a/quickstep/src/com/android/quickstep/views/SplitInstructionsView.java b/quickstep/src/com/android/quickstep/views/SplitInstructionsView.java
index 3d994e8..f6393e4 100644
--- a/quickstep/src/com/android/quickstep/views/SplitInstructionsView.java
+++ b/quickstep/src/com/android/quickstep/views/SplitInstructionsView.java
@@ -16,13 +16,11 @@
 
 package com.android.quickstep.views;
 
-import static com.android.launcher3.LauncherState.NORMAL;
 import static com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_SPLIT_SELECTION_EXIT_CANCEL_BUTTON;
 import static com.android.settingslib.widget.theme.R.dimen.settingslib_preferred_minimum_touch_target;
 
 import android.animation.Animator;
 import android.animation.AnimatorListenerAdapter;
-import android.animation.AnimatorSet;
 import android.content.Context;
 import android.graphics.Rect;
 import android.util.AttributeSet;
@@ -42,9 +40,8 @@
 import com.android.launcher3.Utilities;
 import com.android.launcher3.anim.PendingAnimation;
 import com.android.launcher3.config.FeatureFlags;
-import com.android.launcher3.statemanager.BaseState;
 import com.android.launcher3.statemanager.StateManager;
-import com.android.launcher3.states.StateAnimationConfig;
+import com.android.quickstep.util.AnimUtils;
 import com.android.quickstep.util.SplitSelectStateController;
 
 /**
@@ -57,7 +54,6 @@
 public class SplitInstructionsView extends LinearLayout {
     private static final int BOUNCE_DURATION = 250;
     private static final float BOUNCE_HEIGHT = 20;
-    private static final int DURATION_DEFAULT_SPLIT_DISMISS = 350;
 
     private final RecentsViewContainer mContainer;
     public boolean mIsCurrentlyAnimating = false;
@@ -165,25 +161,11 @@
     private void exitSplitSelection() {
         RecentsView recentsView = mContainer.getOverviewPanel();
         SplitSelectStateController splitSelectController = recentsView.getSplitSelectController();
-
         StateManager stateManager = recentsView.getStateManager();
-        BaseState startState = stateManager.getState();
-        long duration = startState.getTransitionDuration(mContainer.asContext(), false);
-        if (duration == 0) {
-            // Case where we're in contextual on workspace (NORMAL), which by default has 0
-            // transition duration
-            duration = DURATION_DEFAULT_SPLIT_DISMISS;
-        }
-        StateAnimationConfig config = new StateAnimationConfig();
-        config.duration = duration;
-        AnimatorSet stateAnim = stateManager.createAtomicAnimation(
-                startState, NORMAL, config);
-        AnimatorSet dismissAnim = splitSelectController.getSplitAnimationController()
-                .createPlaceholderDismissAnim(mContainer,
-                        LAUNCHER_SPLIT_SELECTION_EXIT_CANCEL_BUTTON, duration);
-        stateAnim.play(dismissAnim);
-        stateManager.setCurrentAnimation(stateAnim, NORMAL);
-        stateAnim.start();
+
+        AnimUtils.goToNormalStateWithSplitDismissal(stateManager, mContainer,
+                LAUNCHER_SPLIT_SELECTION_EXIT_CANCEL_BUTTON,
+                splitSelectController.getSplitAnimationController());
     }
 
     void ensureProperRotation() {
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/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/quickstep/tests/src/com/android/launcher3/taskbar/TaskbarRecentAppsControllerTest.kt b/quickstep/tests/src/com/android/launcher3/taskbar/TaskbarRecentAppsControllerTest.kt
index 27e761a..07c4ffc 100644
--- a/quickstep/tests/src/com/android/launcher3/taskbar/TaskbarRecentAppsControllerTest.kt
+++ b/quickstep/tests/src/com/android/launcher3/taskbar/TaskbarRecentAppsControllerTest.kt
@@ -18,6 +18,7 @@
 
 import android.app.WindowConfiguration.WINDOWING_MODE_FREEFORM
 import android.content.ComponentName
+import android.content.Context
 import android.content.Intent
 import android.os.Process
 import android.os.UserHandle
@@ -56,6 +57,7 @@
 
     @Mock private lateinit var mockIconCache: TaskIconCache
     @Mock private lateinit var mockRecentsModel: RecentsModel
+    @Mock private lateinit var mockContext: Context
     @Mock private lateinit var mockDesktopVisibilityController: DesktopVisibilityController
 
     private var taskListChangeId: Int = 1
@@ -71,7 +73,9 @@
 
         whenever(mockRecentsModel.iconCache).thenReturn(mockIconCache)
         recentAppsController =
-            TaskbarRecentAppsController(mockRecentsModel) { mockDesktopVisibilityController }
+            TaskbarRecentAppsController(mockContext, mockRecentsModel) {
+                mockDesktopVisibilityController
+            }
         recentAppsController.init(taskbarControllers)
         recentAppsController.canShowRunningApps = true
         recentAppsController.canShowRecentApps = true
diff --git a/quickstep/tests/src/com/android/quickstep/RecentTasksListTest.java b/quickstep/tests/src/com/android/quickstep/RecentTasksListTest.java
index d049fbc..c213dbb 100644
--- a/quickstep/tests/src/com/android/quickstep/RecentTasksListTest.java
+++ b/quickstep/tests/src/com/android/quickstep/RecentTasksListTest.java
@@ -29,6 +29,7 @@
 
 import android.app.ActivityManager;
 import android.app.KeyguardManager;
+import android.content.Context;
 
 import androidx.test.filters.SmallTest;
 
@@ -54,7 +55,9 @@
 public class RecentTasksListTest {
 
     @Mock
-    private SystemUiProxy mockSystemUiProxy;
+    private Context mContext;
+    @Mock
+    private SystemUiProxy mSystemUiProxy;
     @Mock
     private TopTaskTracker mTopTaskTracker;
 
@@ -66,14 +69,14 @@
         MockitoAnnotations.initMocks(this);
         LooperExecutor mockMainThreadExecutor = mock(LooperExecutor.class);
         KeyguardManager mockKeyguardManager = mock(KeyguardManager.class);
-        mRecentTasksList = new RecentTasksList(mockMainThreadExecutor, mockKeyguardManager,
-                mockSystemUiProxy, mTopTaskTracker);
+        mRecentTasksList = new RecentTasksList(mContext, mockMainThreadExecutor,
+                mockKeyguardManager, mSystemUiProxy, mTopTaskTracker);
     }
 
     @Test
     public void onRecentTasksChanged_doesNotFetchTasks() throws Exception {
         mRecentTasksList.onRecentTasksChanged();
-        verify(mockSystemUiProxy, times(0))
+        verify(mSystemUiProxy, times(0))
                 .getRecentTasks(anyInt(), anyInt());
     }
 
@@ -81,7 +84,7 @@
     public void loadTasksInBackground_onlyKeys_noValidTaskDescription() throws Exception  {
         GroupedRecentTaskInfo recentTaskInfos = GroupedRecentTaskInfo.forSplitTasks(
                 new ActivityManager.RecentTaskInfo(), new ActivityManager.RecentTaskInfo(), null);
-        when(mockSystemUiProxy.getRecentTasks(anyInt(), anyInt()))
+        when(mSystemUiProxy.getRecentTasks(anyInt(), anyInt()))
                 .thenReturn(new ArrayList<>(Collections.singletonList(recentTaskInfos)));
 
         List<GroupTask> taskList = mRecentTasksList.loadTasksInBackground(Integer.MAX_VALUE, -1,
@@ -94,7 +97,7 @@
 
     @Test
     public void loadTasksInBackground_GetRecentTasksException() throws Exception  {
-        when(mockSystemUiProxy.getRecentTasks(anyInt(), anyInt()))
+        when(mSystemUiProxy.getRecentTasks(anyInt(), anyInt()))
                 .thenThrow(new SystemUiProxy.GetRecentTasksException("task load failed"));
 
         RecentTasksList.TaskLoadResult taskList = mRecentTasksList.loadTasksInBackground(
@@ -113,7 +116,7 @@
         task2.taskDescription = new ActivityManager.TaskDescription();
         GroupedRecentTaskInfo recentTaskInfos = GroupedRecentTaskInfo.forSplitTasks(task1, task2,
                 null);
-        when(mockSystemUiProxy.getRecentTasks(anyInt(), anyInt()))
+        when(mSystemUiProxy.getRecentTasks(anyInt(), anyInt()))
                 .thenReturn(new ArrayList<>(Collections.singletonList(recentTaskInfos)));
 
         List<GroupTask> taskList = mRecentTasksList.loadTasksInBackground(Integer.MAX_VALUE, -1,
@@ -132,7 +135,7 @@
                 createRecentTaskInfo(5 /* taskId */)};
         GroupedRecentTaskInfo recentTaskInfos = GroupedRecentTaskInfo.forFreeformTasks(
                 tasks, Collections.emptySet() /* minimizedTaskIds */);
-        when(mockSystemUiProxy.getRecentTasks(anyInt(), anyInt()))
+        when(mSystemUiProxy.getRecentTasks(anyInt(), anyInt()))
                 .thenReturn(new ArrayList<>(Collections.singletonList(recentTaskInfos)));
 
         List<GroupTask> taskList = mRecentTasksList.loadTasksInBackground(
@@ -158,7 +161,7 @@
                 Arrays.stream(new Integer[]{1, 4, 5}).collect(Collectors.toSet());
         GroupedRecentTaskInfo recentTaskInfos =
                 GroupedRecentTaskInfo.forFreeformTasks(tasks, minimizedTaskIds);
-        when(mockSystemUiProxy.getRecentTasks(anyInt(), anyInt()))
+        when(mSystemUiProxy.getRecentTasks(anyInt(), anyInt()))
                 .thenReturn(new ArrayList<>(Collections.singletonList(recentTaskInfos)));
 
         List<GroupTask> taskList = mRecentTasksList.loadTasksInBackground(
diff --git a/res/drawable/rounded_action_button.xml b/res/drawable/rounded_action_button.xml
index ddd3042..ebfa996 100644
--- a/res/drawable/rounded_action_button.xml
+++ b/res/drawable/rounded_action_button.xml
@@ -22,8 +22,5 @@
     <stroke
         android:width="1dp"
         android:color="?attr/materialColorSurfaceContainerLow" />
-    <padding
-        android:left="@dimen/rounded_button_padding"
-        android:right="@dimen/rounded_button_padding" />
 </shape>
 
diff --git a/res/layout/work_apps_edu.xml b/res/layout/work_apps_edu.xml
index c581ae3..a45d585 100644
--- a/res/layout/work_apps_edu.xml
+++ b/res/layout/work_apps_edu.xml
@@ -44,8 +44,7 @@
         <FrameLayout
             android:layout_width="@dimen/rounded_button_width"
             android:layout_height="@dimen/rounded_button_width"
-            android:background="@drawable/rounded_action_button"
-            android:padding="@dimen/rounded_button_padding">
+            android:background="@drawable/rounded_action_button">
             <ImageButton
                 android:id="@+id/action_btn"
                 android:layout_width="@dimen/x_icon_size"
diff --git a/res/values/dimens.xml b/res/values/dimens.xml
index 05724e2..af91b5a 100644
--- a/res/values/dimens.xml
+++ b/res/values/dimens.xml
@@ -172,8 +172,6 @@
     <dimen name="padded_rounded_button_height">48dp</dimen>
     <dimen name="rounded_button_height">48dp</dimen>
     <dimen name="rounded_button_radius">200dp</dimen>
-    <dimen name="rounded_button_padding">8dp</dimen>
-
 
     <!-- Widget tray -->
     <dimen name="widget_cell_vertical_padding">8dp</dimen>
diff --git a/src/com/android/launcher3/Utilities.java b/src/com/android/launcher3/Utilities.java
index a448228..53fed20 100644
--- a/src/com/android/launcher3/Utilities.java
+++ b/src/com/android/launcher3/Utilities.java
@@ -746,7 +746,8 @@
      * |          +--+  |
      * |                |
      * +----------------+
-     * This would be case delta % 4 == 2:
+     * This would be case delta % 4 == 2: // This is case was reverted to previous behaviour which
+     * doesn't match the illustration due to b/353965234
      * +-------------+
      * |             |
      * |             |
@@ -768,7 +769,6 @@
             int delta) {
         int rdelta = ((delta % 4) + 4) % 4;
         int origLeft = inOutBounds.left;
-        int origTop = inOutBounds.top;
         switch (rdelta) {
             case 0:
                 return;
@@ -780,8 +780,6 @@
                 return;
             case 2:
                 inOutBounds.left = parentWidth - inOutBounds.right;
-                inOutBounds.top = parentHeight - inOutBounds.bottom;
-                inOutBounds.bottom = parentHeight - origTop;
                 inOutBounds.right = parentWidth - origLeft;
                 return;
             case 3:
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/pageindicators/PageIndicatorDots.java b/src/com/android/launcher3/pageindicators/PageIndicatorDots.java
index e44ea1d..a691e45 100644
--- a/src/com/android/launcher3/pageindicators/PageIndicatorDots.java
+++ b/src/com/android/launcher3/pageindicators/PageIndicatorDots.java
@@ -43,6 +43,7 @@
 import android.view.animation.OvershootInterpolator;
 
 import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
 
 import com.android.launcher3.Insettable;
 import com.android.launcher3.R;
@@ -131,7 +132,8 @@
     private float mCurrentPosition;
     private float mFinalPosition;
     private boolean mIsScrollPaused;
-    private boolean mIsTwoPanels;
+    @VisibleForTesting
+    boolean mIsTwoPanels;
     private ObjectAnimator mAnimator;
     private @Nullable ObjectAnimator mAlphaAnimator;
 
@@ -477,6 +479,21 @@
         return sTempRect;
     }
 
+    @VisibleForTesting
+    int getActivePage() {
+        return mActivePage;
+    }
+
+    @VisibleForTesting
+    int getNumPages() {
+        return mNumPages;
+    }
+
+    @VisibleForTesting
+    float getCurrentPosition() {
+        return mCurrentPosition;
+    }
+
     private class MyOutlineProver extends ViewOutlineProvider {
 
         @Override
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/src/com/android/launcher3/pm/UserCache.java b/src/com/android/launcher3/pm/UserCache.java
index 7339111..e861961 100644
--- a/src/com/android/launcher3/pm/UserCache.java
+++ b/src/com/android/launcher3/pm/UserCache.java
@@ -183,6 +183,11 @@
         mUserToSerialMap.put(userHandle, info);
     }
 
+    @VisibleForTesting
+    public void putToPreInstallCache(UserHandle userHandle, List<String> preInstalledApps) {
+        mUserToPreInstallAppMap.put(userHandle, preInstalledApps);
+    }
+
     /**
      * @see UserManager#getUserProfiles()
      */
diff --git a/src/com/android/launcher3/util/LogConfig.java b/src/com/android/launcher3/util/LogConfig.java
index 3d4b409..f183f18 100644
--- a/src/com/android/launcher3/util/LogConfig.java
+++ b/src/com/android/launcher3/util/LogConfig.java
@@ -76,4 +76,5 @@
      * When turned on, we enable zero state web data loader related logging.
      */
     public static final String ZERO_WEB_DATA_LOADER = "ZeroStateWebDataLoaderLog";
+    public static final String SEARCH_TARGET_UTIL_LOG = "SearchTargetUtilLog";
 }
diff --git a/tests/multivalentTests/src/com/android/launcher3/UtilitiesTest.kt b/tests/multivalentTests/src/com/android/launcher3/UtilitiesTest.kt
index 5a26087..d0aa7a8 100644
--- a/tests/multivalentTests/src/com/android/launcher3/UtilitiesTest.kt
+++ b/tests/multivalentTests/src/com/android/launcher3/UtilitiesTest.kt
@@ -376,9 +376,10 @@
         Utilities.rotateBounds(rect, 100, 100, 1)
         assertEquals(Rect(70, 40, 80, 80), rect)
 
-        rect = Rect(20, 70, 60, 80)
-        Utilities.rotateBounds(rect, 100, 100, 2)
-        assertEquals(Rect(40, 20, 80, 30), rect)
+        // case removed for b/28435189
+        //        rect = Rect(20, 70, 60, 80)
+        //        Utilities.rotateBounds(rect, 100, 100, 2)
+        //        assertEquals(Rect(40, 20, 80, 30), rect)
 
         rect = Rect(20, 70, 60, 80)
         Utilities.rotateBounds(rect, 100, 100, 3)
diff --git a/tests/multivalentTests/src/com/android/launcher3/util/SimpleBroadcastReceiverTest.kt b/tests/multivalentTests/src/com/android/launcher3/util/SimpleBroadcastReceiverTest.kt
index 1de99c5..d3e27b6 100644
--- a/tests/multivalentTests/src/com/android/launcher3/util/SimpleBroadcastReceiverTest.kt
+++ b/tests/multivalentTests/src/com/android/launcher3/util/SimpleBroadcastReceiverTest.kt
@@ -23,6 +23,7 @@
 import android.os.Looper
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SmallTest
+import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
 import com.android.launcher3.util.Executors.UI_HELPER_EXECUTOR
 import com.google.common.truth.Truth.assertThat
 import java.util.function.Consumer
@@ -114,6 +115,7 @@
         underTest = SimpleBroadcastReceiver(Handler(Looper.getMainLooper()), intentConsumer)
 
         underTest.register(context, completionRunnable, 1, "test_action_1", "test_action_2")
+        getInstrumentation().waitForIdleSync()
 
         verify(context).registerReceiver(same(underTest), intentFilterCaptor.capture(), eq(1))
         verify(completionRunnable).run()
@@ -136,6 +138,7 @@
         underTest = SimpleBroadcastReceiver(Handler(Looper.getMainLooper()), intentConsumer)
 
         underTest.unregisterReceiverSafely(context)
+        getInstrumentation().waitForIdleSync()
 
         verify(context).unregisterReceiver(same(underTest))
     }
diff --git a/tests/src/com/android/launcher3/folder/FolderNameInfosTest.kt b/tests/src/com/android/launcher3/folder/FolderNameInfosTest.kt
new file mode 100644
index 0000000..b491f17
--- /dev/null
+++ b/tests/src/com/android/launcher3/folder/FolderNameInfosTest.kt
@@ -0,0 +1,201 @@
+/*
+ * 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.folder
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.launcher3.folder.FolderNameInfos.*
+import org.junit.Test
+import org.junit.runner.RunWith
+
+data class Label(val index: Int, val label: String, val score: Float)
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class FolderNameInfosTest {
+
+    companion object {
+        val statusList =
+            listOf(
+                SUCCESS,
+                HAS_PRIMARY,
+                HAS_SUGGESTIONS,
+                ERROR_NO_PROVIDER,
+                ERROR_APP_LOOKUP_FAILED,
+                ERROR_ALL_APP_LOOKUP_FAILED,
+                ERROR_NO_LABELS_GENERATED,
+                ERROR_LABEL_LOOKUP_FAILED,
+                ERROR_ALL_LABEL_LOOKUP_FAILED,
+                ERROR_NO_PACKAGES,
+            )
+    }
+
+    @Test
+    fun status() {
+        assertStatus(statusList)
+        assertStatus(
+            listOf(
+                ERROR_NO_PROVIDER,
+                ERROR_APP_LOOKUP_FAILED,
+                ERROR_ALL_APP_LOOKUP_FAILED,
+                ERROR_NO_LABELS_GENERATED,
+                ERROR_LABEL_LOOKUP_FAILED,
+                ERROR_ALL_LABEL_LOOKUP_FAILED,
+                ERROR_NO_PACKAGES,
+            )
+        )
+        assertStatus(
+            listOf(
+                SUCCESS,
+                HAS_PRIMARY,
+                HAS_SUGGESTIONS,
+            )
+        )
+        assertStatus(
+            listOf(
+                SUCCESS,
+                HAS_PRIMARY,
+                HAS_SUGGESTIONS,
+            )
+        )
+    }
+
+    fun assertStatus(statusList: List<Int>) {
+        var infos = FolderNameInfos()
+        statusList.forEach { infos.setStatus(it) }
+        assert(infos.status() == statusList.sum()) {
+            "There is an overlap on the status constants!"
+        }
+    }
+
+    @Test
+    fun hasPrimary() {
+        assertHasPrimary(
+            createNameInfos(listOf(Label(0, "label", 1f)), statusList),
+            hasPrimary = true
+        )
+        assertHasPrimary(
+            createNameInfos(listOf(Label(1, "label", 1f)), statusList),
+            hasPrimary = false
+        )
+        assertHasPrimary(
+            createNameInfos(
+                listOf(Label(0, "label", 1f)),
+                listOf(
+                    ERROR_NO_PROVIDER,
+                    ERROR_APP_LOOKUP_FAILED,
+                    ERROR_ALL_APP_LOOKUP_FAILED,
+                    ERROR_NO_LABELS_GENERATED,
+                    ERROR_LABEL_LOOKUP_FAILED,
+                    ERROR_ALL_LABEL_LOOKUP_FAILED,
+                    ERROR_NO_PACKAGES,
+                )
+            ),
+            hasPrimary = false
+        )
+    }
+
+    private fun assertHasPrimary(nameInfos: FolderNameInfos, hasPrimary: Boolean) =
+        assert(nameInfos.hasPrimary() == hasPrimary)
+
+    private fun createNameInfos(labels: List<Label>?, statusList: List<Int>?): FolderNameInfos {
+        val infos = FolderNameInfos()
+        labels?.forEach { infos.setLabel(it.index, it.label, it.score) }
+        statusList?.forEach { infos.setStatus(it) }
+        return infos
+    }
+
+    @Test
+    fun hasSuggestions() {
+        assertHasSuggestions(
+            createNameInfos(listOf(Label(0, "label", 1f)), null),
+            hasSuggestions = true
+        )
+        assertHasSuggestions(createNameInfos(null, null), hasSuggestions = false)
+        // There is a max of 4 suggestions
+        assertHasSuggestions(
+            createNameInfos(listOf(Label(5, "label", 1f)), null),
+            hasSuggestions = false
+        )
+        assertHasSuggestions(
+            createNameInfos(
+                listOf(
+                    Label(0, "label", 1f),
+                    Label(1, "label", 1f),
+                    Label(2, "label", 1f),
+                    Label(3, "label", 1f)
+                ),
+                null
+            ),
+            hasSuggestions = true
+        )
+    }
+
+    private fun assertHasSuggestions(nameInfos: FolderNameInfos, hasSuggestions: Boolean) =
+        assert(nameInfos.hasSuggestions() == hasSuggestions)
+
+    @Test
+    fun hasContains() {
+        assertContains(
+            createNameInfos(
+                listOf(
+                    Label(0, "label1", 1f),
+                    Label(1, "label2", 1f),
+                    Label(2, "label3", 1f),
+                    Label(3, "label4", 1f)
+                ),
+                null
+            ),
+            label = Label(-1, "label3", -1f),
+            contains = true
+        )
+        assertContains(
+            createNameInfos(
+                listOf(
+                    Label(0, "label1", 1f),
+                    Label(1, "label2", 1f),
+                    Label(2, "label3", 1f),
+                    Label(3, "label4", 1f)
+                ),
+                null
+            ),
+            label = Label(-1, "label5", -1f),
+            contains = false
+        )
+        assertContains(
+            createNameInfos(null, null),
+            label = Label(-1, "label1", -1f),
+            contains = false
+        )
+        assertContains(
+            createNameInfos(
+                listOf(
+                    Label(0, "label1", 1f),
+                    Label(1, "label2", 1f),
+                    Label(2, "lAbel3", 1f),
+                    Label(3, "lEbel4", 1f)
+                ),
+                null
+            ),
+            label = Label(-1, "LaBEl3", -1f),
+            contains = true
+        )
+    }
+
+    private fun assertContains(nameInfos: FolderNameInfos, label: Label, contains: Boolean) =
+        assert(nameInfos.contains(label.label) == contains)
+}
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/pageindicators/PageIndicatorDotsTest.kt b/tests/src/com/android/launcher3/pageindicators/PageIndicatorDotsTest.kt
new file mode 100644
index 0000000..9a8f957
--- /dev/null
+++ b/tests/src/com/android/launcher3/pageindicators/PageIndicatorDotsTest.kt
@@ -0,0 +1,64 @@
+/*
+ * 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.pageindicators
+
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import com.android.launcher3.util.ActivityContextWrapper
+import junit.framework.TestCase.assertEquals
+import org.junit.Test
+import org.mockito.Mockito
+
+class PageIndicatorDotsTest {
+
+    private val context: Context =
+        ActivityContextWrapper(ApplicationProvider.getApplicationContext())
+    private val pageIndicatorDots: PageIndicatorDots = Mockito.spy(PageIndicatorDots(context))
+
+    @Test
+    fun `setActiveMarker should set the active page to the parameter passed`() {
+        pageIndicatorDots.setActiveMarker(2)
+
+        assertEquals(2, pageIndicatorDots.activePage)
+    }
+
+    @Test
+    fun `setActiveMarker should set the active page to the parameter passed divided by two in two panel layouts`() {
+        pageIndicatorDots.mIsTwoPanels = true
+
+        pageIndicatorDots.setActiveMarker(5)
+
+        assertEquals(2, pageIndicatorDots.activePage)
+    }
+
+    @Test
+    fun `setMarkersCount should set the number of pages to the passed parameter and if the last page gets removed we want to go to the previous page`() {
+        pageIndicatorDots.setMarkersCount(3)
+
+        assertEquals(3, pageIndicatorDots.numPages)
+    }
+
+    @Test
+    fun `for setMarkersCount if the last page gets removed we want to go to the previous page`() {
+        pageIndicatorDots.setActiveMarker(2)
+
+        pageIndicatorDots.setMarkersCount(2)
+
+        assertEquals(1, pageIndicatorDots.activePage)
+        assertEquals(pageIndicatorDots.activePage.toFloat(), pageIndicatorDots.currentPosition)
+    }
+}
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)
+    }
+}
diff --git a/tests/src/com/android/launcher3/pm/UserCacheTest.kt b/tests/src/com/android/launcher3/pm/UserCacheTest.kt
new file mode 100644
index 0000000..b21219e
--- /dev/null
+++ b/tests/src/com/android/launcher3/pm/UserCacheTest.kt
@@ -0,0 +1,140 @@
+/*
+ * 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.os.Process.myUserHandle
+import android.os.UserHandle
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.launcher3.util.Executors.MODEL_EXECUTOR
+import com.android.launcher3.util.LauncherModelHelper
+import com.android.launcher3.util.TestUtil
+import com.android.launcher3.util.UserIconInfo
+import com.google.common.truth.Truth.assertThat
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class UserCacheTest {
+    private val launcherModelHelper = LauncherModelHelper()
+    private val sandboxContext = launcherModelHelper.sandboxContext
+    private lateinit var userCache: UserCache
+
+    @Before
+    fun setup() {
+        userCache = UserCache.getInstance(sandboxContext)
+    }
+
+    @After
+    fun teardown() {
+        launcherModelHelper.destroy()
+    }
+
+    @Test
+    fun `getBadgeDrawable only returns a UserBadgeDrawable given a user in the cache`() {
+        // Given
+        val expectedIconInfo = UserIconInfo(myUserHandle(), UserIconInfo.TYPE_WORK)
+        TestUtil.runOnExecutorSync(MODEL_EXECUTOR) {
+            userCache.putToCache(myUserHandle(), expectedIconInfo)
+        }
+        InstrumentationRegistry.getInstrumentation().waitForIdleSync()
+        // When
+        val actualDrawable = UserCache.getBadgeDrawable(sandboxContext, myUserHandle())
+        val unexpectedDrawable = UserCache.getBadgeDrawable(sandboxContext, UserHandle(66))
+        // Then
+        assertThat(actualDrawable).isNotNull()
+        assertThat(unexpectedDrawable).isNull()
+    }
+
+    @Test
+    fun `getPreInstallApps returns list of pre installed apps given a user`() {
+        // Given
+        val expectedApps = listOf("Google")
+        TestUtil.runOnExecutorSync(MODEL_EXECUTOR) {
+            userCache.putToPreInstallCache(myUserHandle(), expectedApps)
+        }
+        InstrumentationRegistry.getInstrumentation().waitForIdleSync()
+        // When
+        val actualApps = userCache.getPreInstallApps(myUserHandle())
+        // Then
+        assertThat(actualApps).isEqualTo(expectedApps)
+    }
+
+    @Test
+    fun `getUserProfiles returns copy of UserCache profiles`() {
+        // Given
+        val expectedProfiles = listOf(myUserHandle())
+        val expectedIconInfo = UserIconInfo(myUserHandle(), UserIconInfo.TYPE_MAIN)
+        TestUtil.runOnExecutorSync(MODEL_EXECUTOR) {
+            userCache.putToCache(myUserHandle(), expectedIconInfo)
+        }
+        InstrumentationRegistry.getInstrumentation().waitForIdleSync()
+        // When
+        val actualProfiles = userCache.userProfiles
+        // Then
+        assertThat(actualProfiles).isEqualTo(expectedProfiles)
+    }
+
+    @Test
+    fun `getUserForSerialNumber returns user key matching given entry serial number`() {
+        // Given
+        val expectedSerial = 42L
+        val expectedProfile = UserHandle(42)
+        val expectedIconInfo = UserIconInfo(myUserHandle(), UserIconInfo.TYPE_MAIN, expectedSerial)
+        TestUtil.runOnExecutorSync(MODEL_EXECUTOR) {
+            userCache.putToCache(expectedProfile, expectedIconInfo)
+        }
+        InstrumentationRegistry.getInstrumentation().waitForIdleSync()
+        // When
+        val actualProfile = userCache.getUserForSerialNumber(expectedSerial)
+        // Then
+        assertThat(actualProfile).isEqualTo(expectedProfile)
+    }
+
+    @Test
+    fun `getUserInfo returns cached UserIconInfo given user key`() {
+        // Given
+        val expectedProfile = UserHandle(1)
+        val expectedIconInfo = UserIconInfo(myUserHandle(), UserIconInfo.TYPE_WORK)
+        TestUtil.runOnExecutorSync(MODEL_EXECUTOR) {
+            userCache.putToCache(expectedProfile, expectedIconInfo)
+        }
+        InstrumentationRegistry.getInstrumentation().waitForIdleSync()
+        // When
+        val actualIconInfo = userCache.getUserInfo(expectedProfile)
+        // Then
+        assertThat(actualIconInfo).isEqualTo(expectedIconInfo)
+    }
+
+    @Test
+    fun `getSerialNumberForUser returns cached UserIconInfo serial number given user key`() {
+        // Given
+        val expectedSerial = 42L
+        val expectedProfile = UserHandle(1)
+        val expectedIconInfo = UserIconInfo(myUserHandle(), UserIconInfo.TYPE_WORK, expectedSerial)
+        TestUtil.runOnExecutorSync(MODEL_EXECUTOR) {
+            userCache.putToCache(expectedProfile, expectedIconInfo)
+        }
+        InstrumentationRegistry.getInstrumentation().waitForIdleSync()
+        // When
+        val actualSerial = userCache.getSerialNumberForUser(expectedProfile)
+        // Then
+        assertThat(actualSerial).isEqualTo(expectedSerial)
+    }
+}