Merge "Make AddDesktopButton can be navigated to through keys" into main
diff --git a/aconfig/launcher.aconfig b/aconfig/launcher.aconfig
index 9fa2f50..ed370ec 100644
--- a/aconfig/launcher.aconfig
+++ b/aconfig/launcher.aconfig
@@ -601,3 +601,10 @@
     purpose: PURPOSE_BUGFIX
   }
 }
+
+flag {
+  name: "enable_strict_mode"
+  namespace: "launcher"
+  description: "Enable Strict Mode for the Launcher app"
+  bug: "394651876"
+}
diff --git a/aconfig/launcher_accessibility.aconfig b/aconfig/launcher_accessibility.aconfig
index afee8fe..13e1127 100644
--- a/aconfig/launcher_accessibility.aconfig
+++ b/aconfig/launcher_accessibility.aconfig
@@ -1,5 +1,5 @@
 package: "com.android.launcher3"
-container: "system"
+container: "system_ext"
 
 flag {
     name: "remove_exclude_from_screen_magnification_flag_usage"
@@ -9,4 +9,4 @@
     metadata {
         purpose: PURPOSE_BUGFIX
     }
-}
\ No newline at end of file
+}
diff --git a/quickstep/src/com/android/launcher3/statehandlers/DesktopVisibilityController.kt b/quickstep/src/com/android/launcher3/statehandlers/DesktopVisibilityController.kt
index 0703a61..03f5d96 100644
--- a/quickstep/src/com/android/launcher3/statehandlers/DesktopVisibilityController.kt
+++ b/quickstep/src/com/android/launcher3/statehandlers/DesktopVisibilityController.kt
@@ -18,6 +18,7 @@
 import android.content.Context
 import android.os.Debug
 import android.util.Log
+import android.util.SparseArray
 import android.window.DesktopModeFlags.ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY
 import com.android.launcher3.LauncherState
 import com.android.launcher3.dagger.ApplicationContext
@@ -33,6 +34,7 @@
 import com.android.quickstep.GestureState.GestureEndTarget
 import com.android.quickstep.SystemUiProxy
 import com.android.quickstep.fallback.RecentsState
+import com.android.wm.shell.desktopmode.DisplayDeskState
 import com.android.wm.shell.desktopmode.IDesktopTaskListener.Stub
 import com.android.wm.shell.shared.desktopmode.DesktopModeStatus
 import java.io.PrintWriter
@@ -51,6 +53,29 @@
     systemUiProxy: SystemUiProxy,
     lifecycleTracker: DaggerSingletonTracker,
 ) {
+    /**
+     * Tracks the desks configurations on each display.
+     *
+     * (Used only when multiple desks are enabled).
+     *
+     * @property displayId The ID of the display this object represents.
+     * @property canCreateDesks true if it's possible to create new desks on the display represented
+     *   by this object.
+     * @property activeDeskId The ID of the active desk on the associated display (if any). It has a
+     *   value of `-1` if there are no active desks. Note that there can only be at most one active
+     *   desk on each display.
+     * @property deskIds a set containing the IDs of the desks on the associated display.
+     */
+    private data class DisplayDeskConfig(
+        val displayId: Int,
+        var canCreateDesks: Boolean,
+        var activeDeskId: Int = -1,
+        val deskIds: MutableSet<Int>,
+    )
+
+    /** Maps each display by its ID to its desks configuration. */
+    private val displaysDesksConfigsMap = SparseArray<DisplayDeskConfig>()
+
     private val desktopVisibilityListeners: MutableSet<DesktopVisibilityListener> = HashSet()
     private val taskbarDesktopModeListeners: MutableSet<TaskbarDesktopModeListener> = HashSet()
 
@@ -312,6 +337,81 @@
         }
     }
 
+    private fun onListenerConnected(displayDeskStates: Array<DisplayDeskState>) {
+        if (!DesktopModeStatus.enableMultipleDesktops(context)) {
+            return
+        }
+
+        displaysDesksConfigsMap.clear()
+
+        displayDeskStates.forEach { displayDeskState ->
+            displaysDesksConfigsMap[displayDeskState.displayId] =
+                DisplayDeskConfig(
+                    displayId = displayDeskState.displayId,
+                    canCreateDesks = displayDeskState.canCreateDesk,
+                    activeDeskId = displayDeskState.activeDeskId,
+                    deskIds = displayDeskState.deskIds.toMutableSet(),
+                )
+        }
+    }
+
+    private fun getDisplayDeskConfig(displayId: Int): DisplayDeskConfig {
+        return checkNotNull(displaysDesksConfigsMap[displayId]) {
+            "Expected non-null desk config for display: $displayId"
+        }
+    }
+
+    private fun onCanCreateDesksChanged(displayId: Int, canCreateDesks: Boolean) {
+        if (!DesktopModeStatus.enableMultipleDesktops(context)) {
+            return
+        }
+
+        getDisplayDeskConfig(displayId).canCreateDesks = canCreateDesks
+    }
+
+    private fun onDeskAdded(displayId: Int, deskId: Int) {
+        if (!DesktopModeStatus.enableMultipleDesktops(context)) {
+            return
+        }
+
+        getDisplayDeskConfig(displayId).also {
+            check(it.deskIds.add(deskId)) {
+                "Found a duplicate desk Id: $deskId on display: $displayId"
+            }
+        }
+    }
+
+    private fun onDeskRemoved(displayId: Int, deskId: Int) {
+        if (!DesktopModeStatus.enableMultipleDesktops(context)) {
+            return
+        }
+
+        getDisplayDeskConfig(displayId).also {
+            check(it.deskIds.remove(deskId)) {
+                "Removing non-existing desk Id: $deskId on display: $displayId"
+            }
+            if (it.activeDeskId == deskId) {
+                it.activeDeskId = -1
+            }
+        }
+    }
+
+    private fun onActiveDeskChanged(displayId: Int, newActiveDesk: Int, oldActiveDesk: Int) {
+        if (!DesktopModeStatus.enableMultipleDesktops(context)) {
+            return
+        }
+
+        getDisplayDeskConfig(displayId).also {
+            check(oldActiveDesk == it.activeDeskId) {
+                "Mismatch between the Shell's oldActiveDesk: $oldActiveDesk, and Launcher's: ${it.activeDeskId}"
+            }
+            check(it.deskIds.contains(newActiveDesk)) {
+                "newActiveDesk: $newActiveDesk was never added to display: $displayId"
+            }
+            it.activeDeskId = newActiveDesk
+        }
+    }
+
     /** TODO: b/333533253 - Remove after flag rollout */
     private fun markLauncherPaused() {
         if (ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY.isTrue) {
@@ -365,6 +465,12 @@
     ) : Stub() {
         private val controller = WeakReference(controller)
 
+        override fun onListenerConnected(displayDeskStates: Array<DisplayDeskState>) {
+            Executors.MAIN_EXECUTOR.execute {
+                controller.get()?.onListenerConnected(displayDeskStates)
+            }
+        }
+
         override fun onTasksVisibilityChanged(displayId: Int, visibleTasksCount: Int) {
             if (displayId != this.displayId) return
             Executors.MAIN_EXECUTOR.execute {
@@ -398,6 +504,26 @@
         override fun onEnterDesktopModeTransitionStarted(transitionDuration: Int) {}
 
         override fun onExitDesktopModeTransitionStarted(transitionDuration: Int) {}
+
+        override fun onCanCreateDesksChanged(displayId: Int, canCreateDesks: Boolean) {
+            Executors.MAIN_EXECUTOR.execute {
+                controller.get()?.onCanCreateDesksChanged(displayId, canCreateDesks)
+            }
+        }
+
+        override fun onDeskAdded(displayId: Int, deskId: Int) {
+            Executors.MAIN_EXECUTOR.execute { controller.get()?.onDeskAdded(displayId, deskId) }
+        }
+
+        override fun onDeskRemoved(displayId: Int, deskId: Int) {
+            Executors.MAIN_EXECUTOR.execute { controller.get()?.onDeskRemoved(displayId, deskId) }
+        }
+
+        override fun onActiveDeskChanged(displayId: Int, newActiveDesk: Int, oldActiveDesk: Int) {
+            Executors.MAIN_EXECUTOR.execute {
+                controller.get()?.onActiveDeskChanged(displayId, newActiveDesk, oldActiveDesk)
+            }
+        }
     }
 
     /** A listener for Taskbar in Desktop Mode. */
diff --git a/quickstep/src/com/android/launcher3/taskbar/NavbarButtonsViewController.java b/quickstep/src/com/android/launcher3/taskbar/NavbarButtonsViewController.java
index 2e48910..ee5b8d1 100644
--- a/quickstep/src/com/android/launcher3/taskbar/NavbarButtonsViewController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/NavbarButtonsViewController.java
@@ -74,6 +74,7 @@
 import android.graphics.drawable.RotateDrawable;
 import android.inputmethodservice.InputMethodService;
 import android.os.Handler;
+import android.os.SystemProperties;
 import android.util.Property;
 import android.view.Gravity;
 import android.view.HapticFeedbackConstants;
@@ -191,6 +192,7 @@
     private final int mDarkIconColorOnWorkspace;
     /** Color to use for navbar buttons, if they are on on a Taskbar surface background. */
     private final int mOnBackgroundIconColor;
+    private final boolean mIsExpressiveThemeEnabled;
 
     private @Nullable Animator mNavBarLocationAnimator;
     private @Nullable BubbleBarLocation mBubbleBarTargetLocation;
@@ -272,6 +274,9 @@
         if (mContext.isPhoneMode()) {
             mTaskbarTransitions = new TaskbarTransitions(mContext, mNavButtonsView);
         }
+        String SUWTheme = SystemProperties.get("setupwizard.theme", "");
+        mIsExpressiveThemeEnabled = SUWTheme.equals("glif_expressive")
+                || SUWTheme.equals("glif_expressive_light");
     }
 
     /**
@@ -336,10 +341,10 @@
         // Start at 1 because relevant flags are unset at init.
         mOnBackgroundNavButtonColorOverrideMultiplier.value = 1;
 
-        // Force nav buttons (specifically back button) to be visible during setup wizard.
-        boolean isInSetup = !mContext.isUserSetupComplete();
+        // Potentially force the back button to be visible during setup wizard.
+        boolean shouldShowInSetup = !mContext.isUserSetupComplete() && !mIsExpressiveThemeEnabled;
         boolean isInKidsMode = mContext.isNavBarKidsModeActive();
-        boolean alwaysShowButtons = isThreeButtonNav || isInSetup;
+        boolean alwaysShowButtons = isThreeButtonNav || shouldShowInSetup;
 
         // Make sure to remove nav bar buttons translation when any of the following occur:
         // - Notification shade is expanded
@@ -930,6 +935,10 @@
     }
 
     private void handleSetupUi() {
+        // Setup wizard handles the UI when the expressive theme is enabled.
+        if (mIsExpressiveThemeEnabled) {
+            return;
+        }
         // Since setup wizard only has back button enabled, it looks strange to be
         // end-aligned, so start-align instead.
         FrameLayout.LayoutParams navButtonsLayoutParams = (FrameLayout.LayoutParams)
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarDesktopModeController.kt b/quickstep/src/com/android/launcher3/taskbar/TaskbarDesktopModeController.kt
index 8806bc6..cb399e8 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarDesktopModeController.kt
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarDesktopModeController.kt
@@ -50,8 +50,9 @@
 
     fun shouldShowDesktopTasksInTaskbar(): Boolean {
         return desktopVisibilityController.areDesktopTasksVisible() ||
-            DisplayController.showLockedTaskbarOnHome(context) &&
-                taskbarControllers.taskbarStashController.isOnHome
+            DisplayController.showDesktopTaskbarForFreeformDisplay(context) ||
+            (DisplayController.showLockedTaskbarOnHome(context) &&
+                taskbarControllers.taskbarStashController.isOnHome)
     }
 
     fun getTaskbarCornerRoundness(doesAnyTaskRequireTaskbarRounding: Boolean): Float {
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarDragController.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarDragController.java
index 3a83db2..f36c481 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarDragController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarDragController.java
@@ -34,6 +34,7 @@
 import android.content.ClipDescription;
 import android.content.Intent;
 import android.content.pm.LauncherApps;
+import android.content.pm.ShortcutInfo;
 import android.content.res.Resources;
 import android.graphics.Canvas;
 import android.graphics.Point;
@@ -84,6 +85,7 @@
 import com.android.quickstep.util.MultiValueUpdateListener;
 import com.android.quickstep.util.SingleTask;
 import com.android.systemui.shared.recents.model.Task;
+import com.android.wm.shell.shared.bubbles.BubbleAnythingFlagHelper;
 import com.android.wm.shell.shared.draganddrop.DragAndDropConstants;
 
 import java.io.PrintWriter;
@@ -416,6 +418,10 @@
                                 item.user));
                 intent.putExtra(Intent.EXTRA_PACKAGE_NAME, item.getIntent().getPackage());
                 intent.putExtra(Intent.EXTRA_SHORTCUT_ID, deepShortcutId);
+                ShortcutInfo shortcutInfo = ((WorkspaceItemInfo) item).getDeepShortcutInfo();
+                if (BubbleAnythingFlagHelper.enableCreateAnyBubble() && shortcutInfo != null) {
+                    intent.putExtra(DragAndDropConstants.EXTRA_SHORTCUT_INFO, shortcutInfo);
+                }
             } else if (item.itemType == ITEM_TYPE_SEARCH_ACTION) {
                 // TODO(b/289261756): Buggy behavior when split opposite to an existing search pane.
                 intent.putExtra(
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarLauncherStateController.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarLauncherStateController.java
index 9a9575d..cada5a3 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarLauncherStateController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarLauncherStateController.java
@@ -58,7 +58,6 @@
 import com.android.launcher3.Utilities;
 import com.android.launcher3.anim.AnimatedFloat;
 import com.android.launcher3.anim.AnimatorListeners;
-import com.android.launcher3.config.FeatureFlags;
 import com.android.launcher3.statemanager.StateManager;
 import com.android.launcher3.taskbar.bubbles.stashing.BubbleStashController.BubbleLauncherState;
 import com.android.launcher3.uioverrides.QuickstepLauncher;
@@ -714,6 +713,10 @@
 
     private static boolean shouldShowTaskbar(Context context, boolean isInLauncher,
             boolean isInOverview) {
+        if (DisplayController.showDesktopTaskbarForFreeformDisplay(context)) {
+            return true;
+        }
+
         if (DisplayController.showLockedTaskbarOnHome(context) && isInLauncher) {
             return true;
         }
@@ -769,9 +772,14 @@
      * This refers to the intended state - a transition to this state might be in progress.
      */
     public boolean isTaskbarAlignedWithHotseat() {
+        if (DisplayController.showDesktopTaskbarForFreeformDisplay(mLauncher)) {
+            return false;
+        }
+
         if (DisplayController.showLockedTaskbarOnHome(mLauncher) && isInLauncher()) {
             return false;
         }
+
         return mLauncherState.isTaskbarAlignedWithHotseat(mLauncher);
     }
 
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarStashController.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarStashController.java
index 1ca3dfb..2ded1bf 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarStashController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarStashController.java
@@ -301,7 +301,8 @@
         // TODO(b/390665752): Feature to "lock" pinned taskbar to home screen will be superseded by
         //     pinning, in other launcher states, at which point this variable can be removed.
         mInAppStateAffectsDesktopTasksVisibilityInTaskbar =
-                DisplayController.showLockedTaskbarOnHome(mActivity);
+                !DisplayController.showDesktopTaskbarForFreeformDisplay(mActivity)
+                        && DisplayController.showLockedTaskbarOnHome(mActivity);
 
         mTaskbarBackgroundDuration = activity.getResources().getInteger(
                 R.integer.taskbar_background_duration);
diff --git a/quickstep/src/com/android/launcher3/taskbar/navbutton/SetupNavLayoutter.kt b/quickstep/src/com/android/launcher3/taskbar/navbutton/SetupNavLayoutter.kt
index 22a3630..e032430 100644
--- a/quickstep/src/com/android/launcher3/taskbar/navbutton/SetupNavLayoutter.kt
+++ b/quickstep/src/com/android/launcher3/taskbar/navbutton/SetupNavLayoutter.kt
@@ -17,6 +17,7 @@
 package com.android.launcher3.taskbar.navbutton
 
 import android.content.res.Resources
+import android.os.SystemProperties
 import android.view.Gravity
 import android.view.ViewGroup
 import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
@@ -39,7 +40,7 @@
     startContextualContainer: ViewGroup,
     imeSwitcher: ImageView?,
     a11yButton: ImageView?,
-    space: Space?
+    space: Space?,
 ) :
     AbstractNavButtonLayoutter(
         resources,
@@ -48,11 +49,15 @@
         startContextualContainer,
         imeSwitcher,
         a11yButton,
-        space
+        space,
     ) {
     private val mNavButtonsView = navButtonsView
 
     override fun layoutButtons(context: TaskbarActivityContext, isA11yButtonPersistent: Boolean) {
+        val SUWTheme = SystemProperties.get("setupwizard.theme", "")
+        if (SUWTheme == "glif_expressive" || SUWTheme == "glif_expressive_light") {
+            return
+        }
         // Since setup wizard only has back button enabled, it looks strange to be
         // end-aligned, so start-align instead.
         val navButtonsLayoutParams = navButtonContainer.layoutParams as FrameLayout.LayoutParams
@@ -80,7 +85,7 @@
             adjustForSetupInPhoneMode(
                 navButtonsLayoutParams,
                 navButtonsViewLayoutParams,
-                deviceProfile
+                deviceProfile,
             )
         }
         mNavButtonsView.layoutParams = navButtonsViewLayoutParams
@@ -97,7 +102,7 @@
             WRAP_CONTENT,
             contextualMargin,
             contextualMargin,
-            Gravity.START
+            Gravity.START,
         )
 
         if (imeSwitcher != null) {
diff --git a/quickstep/src/com/android/launcher3/uioverrides/PredictedAppIcon.java b/quickstep/src/com/android/launcher3/uioverrides/PredictedAppIcon.java
index 36a4865..b25f999 100644
--- a/quickstep/src/com/android/launcher3/uioverrides/PredictedAppIcon.java
+++ b/quickstep/src/com/android/launcher3/uioverrides/PredictedAppIcon.java
@@ -44,6 +44,7 @@
 import androidx.core.graphics.ColorUtils;
 
 import com.android.launcher3.DeviceProfile;
+import com.android.launcher3.Flags;
 import com.android.launcher3.Launcher;
 import com.android.launcher3.R;
 import com.android.launcher3.anim.AnimatedFloat;
@@ -68,8 +69,7 @@
 
     private static final float RING_SCALE_START_VALUE = 0.75f;
     private static final int RING_SHADOW_COLOR = 0x99000000;
-    private static final float RING_EFFECT_RATIO = 0.095f;
-
+    private static final float RING_EFFECT_RATIO = Flags.enableLauncherIconShapes() ? 0.1f : 0.095f;
     private static final long ICON_CHANGE_ANIM_DURATION = 360;
     private static final long ICON_CHANGE_ANIM_STAGGER = 50;
 
@@ -150,12 +150,12 @@
         int count = canvas.save();
         boolean isSlotMachineAnimRunning = mSlotMachineIcon != null;
         if (!mIsPinned) {
-            drawEffect(canvas);
+            drawRingEffect(canvas);
             if (isSlotMachineAnimRunning) {
                 // Clip to to outside of the ring during the slot machine animation.
                 canvas.clipPath(mRingPath);
             }
-            canvas.scale(1 - 2 * RING_EFFECT_RATIO, 1 - 2 * RING_EFFECT_RATIO,
+            canvas.scale(1 - 2f * RING_EFFECT_RATIO, 1 - 2f * RING_EFFECT_RATIO,
                     getWidth() * .5f, getHeight() * .5f);
             if (isSlotMachineAnimRunning) {
                 canvas.translate(0, mSlotMachineIconTranslationY);
@@ -388,7 +388,7 @@
         mRingScaleAnim.start();
     }
 
-    private void drawEffect(Canvas canvas) {
+    private void drawRingEffect(Canvas canvas) {
         // Don't draw ring effect if item is about to be dragged or if the icon is not visible.
         if (mDrawForDrag || !mIsIconVisible || mForceHideRing) {
             return;
@@ -396,12 +396,28 @@
         mIconRingPaint.setColor(RING_SHADOW_COLOR);
         mIconRingPaint.setMaskFilter(mShadowFilter);
         int count = canvas.save();
-        if (Float.compare(1, mRingScale) != 0) {
+        if (Flags.enableLauncherIconShapes()) {
+            // Scale canvas properly to for ring to be inner stroke and not exceed bounds.
+            // Since STROKE draws half on either side of Path, scale canvas down by 1x stroke ratio.
+            canvas.scale(
+                    mRingScale * (1f - RING_EFFECT_RATIO),
+                    mRingScale * (1f - RING_EFFECT_RATIO),
+                    canvas.getWidth() / 2f,
+                    canvas.getHeight() / 2f);
+        } else if (Float.compare(1, mRingScale) != 0) {
             canvas.scale(mRingScale, mRingScale, canvas.getWidth() / 2f, canvas.getHeight() / 2f);
         }
+        // Draw ring shadow around canvas.
         canvas.drawPath(mRingPath, mIconRingPaint);
         mIconRingPaint.setColor(mPlateColor.currentColor);
+        if (Flags.enableLauncherIconShapes()) {
+            mIconRingPaint.setStrokeWidth(canvas.getWidth() * RING_EFFECT_RATIO);
+            // Using FILL_AND_STROKE as there is still some gap to fill,
+            // between inner curve of ring / outer curve of icon.
+            mIconRingPaint.setStyle(Paint.Style.FILL_AND_STROKE);
+        }
         mIconRingPaint.setMaskFilter(null);
+        // Draw ring around canvas.
         canvas.drawPath(mRingPath, mIconRingPaint);
         canvas.restoreToCount(count);
     }
diff --git a/quickstep/src/com/android/launcher3/uioverrides/SystemApiWrapper.kt b/quickstep/src/com/android/launcher3/uioverrides/SystemApiWrapper.kt
index d097dba..6e901ee 100644
--- a/quickstep/src/com/android/launcher3/uioverrides/SystemApiWrapper.kt
+++ b/quickstep/src/com/android/launcher3/uioverrides/SystemApiWrapper.kt
@@ -144,6 +144,14 @@
     override fun isNonResizeableActivity(lai: LauncherActivityInfo) =
         lai.activityInfo.resizeMode == ActivityInfo.RESIZE_MODE_UNRESIZEABLE
 
+    override fun supportsMultiInstance(lai: LauncherActivityInfo) : Boolean {
+        return try {
+            super.supportsMultiInstance(lai) || lai.supportsMultiInstance()
+        } catch (e: Exception) {
+            false
+        }
+    }
+
     /**
      * Starts an Activity which can be used to set this Launcher as the HOME app, via a consent
      * screen. In case the consent screen cannot be shown, or the user does not set current Launcher
diff --git a/quickstep/src/com/android/quickstep/AbsSwipeUpHandler.java b/quickstep/src/com/android/quickstep/AbsSwipeUpHandler.java
index f4400fa..f46f9ae 100644
--- a/quickstep/src/com/android/quickstep/AbsSwipeUpHandler.java
+++ b/quickstep/src/com/android/quickstep/AbsSwipeUpHandler.java
@@ -1709,7 +1709,7 @@
 
             if (mRecentsView != null) {
                 mRecentsView.onPrepareGestureEndAnimation(null, mGestureState.getEndTarget(),
-                        getRemoteTaskViewSimulators());
+                        mRemoteTargetHandles);
             }
         } else {
             AnimatorSet animatorSet = new AnimatorSet();
@@ -1753,7 +1753,7 @@
                 mRecentsView.onPrepareGestureEndAnimation(
                         mGestureState.isHandlingAtomicEvent() ? null : animatorSet,
                         mGestureState.getEndTarget(),
-                        getRemoteTaskViewSimulators());
+                        mRemoteTargetHandles);
             }
             animatorSet.setDuration(duration).setInterpolator(interpolator);
             animatorSet.start();
diff --git a/quickstep/src/com/android/quickstep/RecentsAnimationDeviceState.java b/quickstep/src/com/android/quickstep/RecentsAnimationDeviceState.java
index e1e962a..090ccdc 100644
--- a/quickstep/src/com/android/quickstep/RecentsAnimationDeviceState.java
+++ b/quickstep/src/com/android/quickstep/RecentsAnimationDeviceState.java
@@ -114,7 +114,6 @@
 
     private final RotationTouchHelper mRotationTouchHelper;
     private final TaskStackChangeListener mPipListener;
-    private final DaggerSingletonTracker mLifeCycle;
     // Cache for better performance since it doesn't change at runtime.
     private final boolean mCanImeRenderGesturalNavButtons =
             InputMethodService.canImeRenderGesturalNavButtons();
@@ -152,16 +151,15 @@
         mExclusionManager = exclusionManager;
         mContextualSearchStateManager = contextualSearchStateManager;
         mRotationTouchHelper = rotationTouchHelper;
-        mLifeCycle = lifeCycle;
         mIsOneHandedModeSupported = SystemProperties.getBoolean(SUPPORT_ONE_HANDED_MODE, false);
 
         // Register for exclusion updates
-        mLifeCycle.addCloseable(this::unregisterExclusionListener);
+        lifeCycle.addCloseable(this::unregisterExclusionListener);
 
         // Register for display changes changes
         mDisplayController.addChangeListener(this);
         onDisplayInfoChanged(context, mDisplayController.getInfo(), CHANGE_ALL);
-        mLifeCycle.addCloseable(() -> mDisplayController.removeChangeListener(this));
+        lifeCycle.addCloseable(() -> mDisplayController.removeChangeListener(this));
 
         if (mIsOneHandedModeSupported) {
             Uri oneHandedUri = Settings.Secure.getUriFor(ONE_HANDED_ENABLED);
@@ -169,7 +167,7 @@
                     enabled -> mIsOneHandedModeEnabled = enabled;
             settingsCache.register(oneHandedUri, onChangeListener);
             mIsOneHandedModeEnabled = settingsCache.getValue(oneHandedUri);
-            mLifeCycle.addCloseable(() -> settingsCache.unregister(oneHandedUri, onChangeListener));
+            lifeCycle.addCloseable(() -> settingsCache.unregister(oneHandedUri, onChangeListener));
         } else {
             mIsOneHandedModeEnabled = false;
         }
@@ -180,7 +178,7 @@
                 enabled -> mIsSwipeToNotificationEnabled = enabled;
         settingsCache.register(swipeBottomNotificationUri, onChangeListener);
         mIsSwipeToNotificationEnabled = settingsCache.getValue(swipeBottomNotificationUri);
-        mLifeCycle.addCloseable(
+        lifeCycle.addCloseable(
                 () -> settingsCache.unregister(swipeBottomNotificationUri, onChangeListener));
 
         Uri setupCompleteUri = Settings.Secure.getUriFor(Settings.Secure.USER_SETUP_COMPLETE);
@@ -188,7 +186,7 @@
         if (!mIsUserSetupComplete) {
             SettingsCache.OnChangeListener userSetupChangeListener = e -> mIsUserSetupComplete = e;
             settingsCache.register(setupCompleteUri, userSetupChangeListener);
-            mLifeCycle.addCloseable(
+            lifeCycle.addCloseable(
                     () -> settingsCache.unregister(setupCompleteUri, userSetupChangeListener));
         }
 
@@ -210,15 +208,19 @@
             }
         };
         TaskStackChangeListeners.getInstance().registerTaskStackListener(mPipListener);
-        mLifeCycle.addCloseable(() ->
+        lifeCycle.addCloseable(() ->
                 TaskStackChangeListeners.getInstance().unregisterTaskStackListener(mPipListener));
     }
 
     /**
      * Adds a listener for the nav mode change, guaranteed to be called after the device state's
      * mode has changed.
+     *
+     * @return Added {@link DisplayInfoChangeListener} so that caller is
+     * responsible for removing the listener from {@link DisplayController} to avoid memory leak.
      */
-    public void addNavigationModeChangedCallback(Runnable callback) {
+    public DisplayController.DisplayInfoChangeListener addNavigationModeChangedCallback(
+            Runnable callback) {
         DisplayController.DisplayInfoChangeListener listener = (context, info, flags) -> {
             if ((flags & CHANGE_NAVIGATION_MODE) != 0) {
                 callback.run();
@@ -226,7 +228,16 @@
         };
         mDisplayController.addChangeListener(listener);
         callback.run();
-        mLifeCycle.addCloseable(() -> mDisplayController.removeChangeListener(listener));
+        return listener;
+    }
+
+    /**
+     * Remove the {DisplayController.DisplayInfoChangeListener} added from
+     * {@link #addNavigationModeChangedCallback} when {@link TouchInteractionService} is destroyed.
+     */
+    public void removeDisplayInfoChangeListener(
+            DisplayController.DisplayInfoChangeListener listener) {
+        mDisplayController.removeChangeListener(listener);
     }
 
     @Override
diff --git a/quickstep/src/com/android/quickstep/TaskIconCache.kt b/quickstep/src/com/android/quickstep/TaskIconCache.kt
index bf94d41..b82c110 100644
--- a/quickstep/src/com/android/quickstep/TaskIconCache.kt
+++ b/quickstep/src/com/android/quickstep/TaskIconCache.kt
@@ -40,6 +40,7 @@
 import com.android.launcher3.util.FlagOp
 import com.android.launcher3.util.Preconditions
 import com.android.quickstep.task.thumbnail.data.TaskIconDataSource
+import com.android.quickstep.util.IconLabelUtil.getBadgedContentDescription
 import com.android.quickstep.util.TaskKeyLruCache
 import com.android.quickstep.util.TaskVisualsChangeListener
 import com.android.systemui.shared.recents.model.Task
@@ -206,6 +207,7 @@
                 TaskCacheEntry(
                     entryIcon,
                     getBadgedContentDescription(
+                        context,
                         activityInfo,
                         task.key.userId,
                         task.taskDescription,
@@ -215,7 +217,12 @@
             else ->
                 TaskCacheEntry(
                     entryIcon,
-                    getBadgedContentDescription(activityInfo, task.key.userId, task.taskDescription),
+                    getBadgedContentDescription(
+                        context,
+                        activityInfo,
+                        task.key.userId,
+                        task.taskDescription,
+                    ),
                 )
         }.also { iconCache.put(task.key, it) }
     }
@@ -224,28 +231,6 @@
         desc.inMemoryIcon
             ?: ActivityManager.TaskDescription.loadTaskDescriptionIcon(desc.iconFilename, userId)
 
-    private fun getBadgedContentDescription(
-        info: ActivityInfo,
-        userId: Int,
-        taskDescription: ActivityManager.TaskDescription?,
-    ): String {
-        val packageManager = context.packageManager
-        var taskLabel = taskDescription?.let { Utilities.trim(it.label) }
-        if (taskLabel.isNullOrEmpty()) {
-            taskLabel = Utilities.trim(info.loadLabel(packageManager))
-        }
-
-        val applicationLabel = Utilities.trim(info.applicationInfo.loadLabel(packageManager))
-        val badgedApplicationLabel =
-            if (userId != UserHandle.myUserId())
-                packageManager
-                    .getUserBadgedLabel(applicationLabel, UserHandle.of(userId))
-                    .toString()
-            else applicationLabel
-        return if (applicationLabel == taskLabel) badgedApplicationLabel
-        else "$badgedApplicationLabel $taskLabel"
-    }
-
     @WorkerThread
     private fun getDefaultIcon(userId: Int): Drawable {
         synchronized(defaultIcons) {
diff --git a/quickstep/src/com/android/quickstep/TouchInteractionService.java b/quickstep/src/com/android/quickstep/TouchInteractionService.java
index 8d0834c..2df4a45 100644
--- a/quickstep/src/com/android/quickstep/TouchInteractionService.java
+++ b/quickstep/src/com/android/quickstep/TouchInteractionService.java
@@ -561,6 +561,8 @@
 
     private DesktopAppLaunchTransitionManager mDesktopAppLaunchTransitionManager;
 
+    private DisplayController.DisplayInfoChangeListener mDisplayInfoChangeListener;
+
     @Override
     public void onCreate() {
         super.onCreate();
@@ -590,7 +592,8 @@
 
         // Call runOnUserUnlocked() before any other callbacks to ensure everything is initialized.
         LockedUserState.get(this).runOnUserUnlocked(mUserUnlockedRunnable);
-        mDeviceState.addNavigationModeChangedCallback(this::onNavigationModeChanged);
+        mDisplayInfoChangeListener =
+                mDeviceState.addNavigationModeChangedCallback(this::onNavigationModeChanged);
         ScreenOnTracker.INSTANCE.get(this).addListener(mScreenOnListener);
     }
 
@@ -742,7 +745,7 @@
             mDesktopAppLaunchTransitionManager.unregisterTransitions();
         }
         mDesktopAppLaunchTransitionManager = null;
-
+        mDeviceState.removeDisplayInfoChangeListener(mDisplayInfoChangeListener);
         LockedUserState.get(this).removeOnUserUnlockedRunnable(mUserUnlockedRunnable);
         ScreenOnTracker.INSTANCE.get(this).removeListener(mScreenOnListener);
         super.onDestroy();
diff --git a/quickstep/src/com/android/quickstep/fallback/FallbackRecentsView.java b/quickstep/src/com/android/quickstep/fallback/FallbackRecentsView.java
index 8d010e2..f426bf5 100644
--- a/quickstep/src/com/android/quickstep/fallback/FallbackRecentsView.java
+++ b/quickstep/src/com/android/quickstep/fallback/FallbackRecentsView.java
@@ -44,11 +44,11 @@
 import com.android.quickstep.BaseContainerInterface;
 import com.android.quickstep.FallbackActivityInterface;
 import com.android.quickstep.GestureState;
+import com.android.quickstep.RemoteTargetGluer.RemoteTargetHandle;
 import com.android.quickstep.fallback.window.RecentsDisplayModel;
 import com.android.quickstep.util.GroupTask;
 import com.android.quickstep.util.SingleTask;
 import com.android.quickstep.util.SplitSelectStateController;
-import com.android.quickstep.util.TaskViewSimulator;
 import com.android.quickstep.views.OverviewActionsView;
 import com.android.quickstep.views.RecentsView;
 import com.android.quickstep.views.RecentsViewContainer;
@@ -129,8 +129,8 @@
     @Override
     public void onPrepareGestureEndAnimation(
             @Nullable AnimatorSet animatorSet, GestureState.GestureEndTarget endTarget,
-            TaskViewSimulator[] taskViewSimulators) {
-        super.onPrepareGestureEndAnimation(animatorSet, endTarget, taskViewSimulators);
+            RemoteTargetHandle[] remoteTargetHandles) {
+        super.onPrepareGestureEndAnimation(animatorSet, endTarget, remoteTargetHandles);
         if (mHomeTask != null && endTarget == RECENTS && animatorSet != null) {
             TaskView tv = getTaskViewByTaskId(mHomeTask.key.id);
             if (tv != null) {
diff --git a/quickstep/src/com/android/quickstep/recents/di/RecentsDependencies.kt b/quickstep/src/com/android/quickstep/recents/di/RecentsDependencies.kt
index 02f48e6..1f428f3 100644
--- a/quickstep/src/com/android/quickstep/recents/di/RecentsDependencies.kt
+++ b/quickstep/src/com/android/quickstep/recents/di/RecentsDependencies.kt
@@ -28,6 +28,7 @@
 import com.android.quickstep.recents.data.TasksRepository
 import com.android.quickstep.recents.domain.usecase.GetSysUiStatusNavFlagsUseCase
 import com.android.quickstep.recents.domain.usecase.GetTaskUseCase
+import com.android.quickstep.recents.domain.usecase.OrganizeDesktopTasksUseCase
 import com.android.quickstep.recents.usecase.GetThumbnailPositionUseCase
 import com.android.quickstep.recents.usecase.GetThumbnailUseCase
 import com.android.quickstep.recents.viewmodel.RecentsViewData
@@ -201,6 +202,7 @@
                         rotationStateRepository = inject(),
                         tasksRepository = inject(),
                     )
+                OrganizeDesktopTasksUseCase::class.java -> OrganizeDesktopTasksUseCase()
                 SplashAlphaUseCase::class.java ->
                     SplashAlphaUseCase(
                         recentsViewData = inject(),
diff --git a/quickstep/src/com/android/quickstep/recents/domain/model/DesktopTaskBoundsData.kt b/quickstep/src/com/android/quickstep/recents/domain/model/DesktopTaskBoundsData.kt
new file mode 100644
index 0000000..a7f102c
--- /dev/null
+++ b/quickstep/src/com/android/quickstep/recents/domain/model/DesktopTaskBoundsData.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.quickstep.recents.domain.model
+
+import android.graphics.Rect
+
+data class DesktopTaskBoundsData(val taskId: Int, val bounds: Rect)
diff --git a/quickstep/src/com/android/quickstep/recents/domain/usecase/OrganizeDesktopTasksUseCase.kt b/quickstep/src/com/android/quickstep/recents/domain/usecase/OrganizeDesktopTasksUseCase.kt
new file mode 100644
index 0000000..4ea39d8
--- /dev/null
+++ b/quickstep/src/com/android/quickstep/recents/domain/usecase/OrganizeDesktopTasksUseCase.kt
@@ -0,0 +1,311 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.quickstep.recents.domain.usecase
+
+import android.graphics.Rect
+import android.graphics.RectF
+import androidx.core.graphics.toRect
+import com.android.quickstep.recents.domain.model.DesktopTaskBoundsData
+
+/** This usecase is responsible for organizing desktop windows in a non-overlapping way. */
+class OrganizeDesktopTasksUseCase {
+    /**
+     * Run to layout [taskBounds] within the screen [desktopBounds]. Layout is done in 2 stages:
+     * 1. Optimal height is determined. In this stage height is bisected to find maximum height
+     *    which still allows all the windows to fit.
+     * 2. Row widths are balanced. In this stage the available width is reduced until some windows
+     *    are no longer fitting or until the difference between the narrowest and the widest rows
+     *    starts growing. Overall this achieves the goals of maximum size for previews (or maximum
+     *    row height which is equivalent assuming fixed height), balanced rows and minimal wasted
+     *    space.
+     */
+    fun run(
+        desktopBounds: Rect,
+        taskBounds: List<DesktopTaskBoundsData>,
+    ): List<DesktopTaskBoundsData> {
+        if (desktopBounds.isEmpty || taskBounds.isEmpty()) {
+            return emptyList()
+        }
+
+        // Filter out [taskBounds] with empty rects before calculating layout.
+        val validTaskBounds = taskBounds.filterNot { it.bounds.isEmpty }
+
+        if (validTaskBounds.isEmpty()) {
+            return emptyList()
+        }
+
+        val availableLayoutBounds = desktopBounds.getLayoutEffectiveBounds()
+        val resultRects = findOptimalHeightAndBalancedWidth(availableLayoutBounds, validTaskBounds)
+
+        centerTaskWindows(
+            availableLayoutBounds,
+            resultRects.maxOf { it.bottom }.toInt(),
+            resultRects,
+        )
+
+        val result = mutableListOf<DesktopTaskBoundsData>()
+        for (i in validTaskBounds.indices) {
+            result.add(DesktopTaskBoundsData(validTaskBounds[i].taskId, resultRects[i].toRect()))
+        }
+        return result
+    }
+
+    /** Calculates the effective bounds for layout by applying insets to the raw desktop bounds. */
+    private fun Rect.getLayoutEffectiveBounds() =
+        Rect(this).apply { inset(OVERVIEW_INSET_TOP_BOTTOM, OVERVIEW_INSET_LEFT_RIGHT) }
+
+    /**
+     * Determines the optimal height for task windows and balances the row widths to minimize wasted
+     * space. Returns the bounds for each task window after layout.
+     */
+    private fun findOptimalHeightAndBalancedWidth(
+        availableLayoutBounds: Rect,
+        validTaskBounds: List<DesktopTaskBoundsData>,
+    ): List<RectF> {
+        // Right bound of the narrowest row.
+        var minRight: Int
+        // Right bound of the widest row.
+        var maxRight: Int
+
+        // Keep track of the difference between the narrowest and the widest row.
+        // Initially this is set to the worst it can ever be assuming the windows fit.
+        var widthDiff = availableLayoutBounds.width()
+
+        // Initially allow the windows to occupy all available width. Shrink this available space
+        // horizontally to find the breakdown into rows that achieves the minimal [widthDiff].
+        var rightBound = availableLayoutBounds.right
+
+        // Determine the optimal height bisecting between [lowHeight] and [highHeight]. Once this
+        // optimal height is known, [heightFixed] is set to `true` and the rows are balanced by
+        // repeatedly squeezing the widest row to cause windows to overflow to the subsequent rows.
+        var lowHeight = VERTICAL_SPACE_BETWEEN_TASKS
+        var highHeight = maxOf(lowHeight, availableLayoutBounds.height() + 1)
+        var optimalHeight = 0.5f * (lowHeight + highHeight)
+        var heightFixed = false
+
+        // Repeatedly try to fit the windows [resultRects] within [rightBound]. If a maximum
+        // [optimalHeight] is found such that all window [resultRects] fit, this fitting continues
+        // while shrinking the [rightBound] in order to balance the rows. If the windows fit the
+        // [rightBound] would have been decremented at least once so it needs to be incremented once
+        // before getting out of this loop and one additional pass made to actually fit the
+        // [resultRects]. If the [resultRects] cannot fit (e.g. there are too many windows) the
+        // bisection will still finish and we might increment the [rightBound] one pixel extra
+        // which is acceptable since there is an unused margin on the right.
+        var makeLastAdjustment = false
+        var resultRects: List<RectF>
+
+        while (true) {
+            val fitWindowResult =
+                fitWindowRectsInBounds(
+                    Rect(availableLayoutBounds).apply { right = rightBound },
+                    validTaskBounds,
+                    minOf(MAXIMUM_TASK_HEIGHT, optimalHeight.toInt()),
+                )
+            val allWindowsFit = fitWindowResult.allWindowsFit
+            resultRects = fitWindowResult.calculatedBounds
+            minRight = fitWindowResult.minRight
+            maxRight = fitWindowResult.maxRight
+
+            if (heightFixed) {
+                if (!allWindowsFit) {
+                    // Revert the previous change to [rightBound] and do one last pass.
+                    rightBound++
+                    makeLastAdjustment = true
+                    break
+                }
+                // Break if all the windows are zero-width at the current scale.
+                if (maxRight <= availableLayoutBounds.left) {
+                    break
+                }
+            } else {
+                // Find the optimal row height bisecting between [lowHeight] and [highHeight].
+                if (allWindowsFit) {
+                    lowHeight = optimalHeight.toInt()
+                } else {
+                    highHeight = optimalHeight.toInt()
+                }
+                optimalHeight = 0.5f * (lowHeight + highHeight)
+                // When height can no longer be improved, start balancing the rows.
+                if (optimalHeight.toInt() == lowHeight) {
+                    heightFixed = true
+                }
+            }
+
+            if (allWindowsFit && heightFixed) {
+                if (maxRight - minRight <= widthDiff) {
+                    // Row alignment is getting better. Try to shrink the [rightBound] in order to
+                    // squeeze the widest row.
+                    rightBound = maxRight - 1
+                    widthDiff = maxRight - minRight
+                } else {
+                    // Row alignment is getting worse.
+                    // Revert the previous change to [rightBound] and do one last pass.
+                    rightBound++
+                    makeLastAdjustment = true
+                    break
+                }
+            }
+        }
+
+        // Once the windows no longer fit, the change to [rightBound] was reverted. Perform one last
+        // pass to position the [resultRects].
+        if (makeLastAdjustment) {
+            val fitWindowResult =
+                fitWindowRectsInBounds(
+                    Rect(availableLayoutBounds).apply { right = rightBound },
+                    validTaskBounds,
+                    minOf(MAXIMUM_TASK_HEIGHT, optimalHeight.toInt()),
+                )
+            resultRects = fitWindowResult.calculatedBounds
+        }
+
+        return resultRects
+    }
+
+    /**
+     * Data structure to hold the returned result of [fitWindowRectsInBounds] function.
+     * [allWindowsFit] specifies whether all windows can be fit into the provided layout bounds.
+     * [calculatedBounds] specifies the output bounds for all provided task windows. [minRight]
+     * specifies the right bound of the narrowest row. [maxRight] specifies the right bound of the
+     * widest rows.
+     */
+    data class FitWindowResult(
+        val allWindowsFit: Boolean,
+        val calculatedBounds: List<RectF>,
+        val minRight: Int,
+        val maxRight: Int,
+    )
+
+    /**
+     * Attempts to fit all [taskBounds] inside [layoutBounds]. The method ensures that the returned
+     * output bounds list has appropriate size and populates it with the values placing task windows
+     * next to each other left-to-right in rows of equal [optimalWindowHeight].
+     */
+    private fun fitWindowRectsInBounds(
+        layoutBounds: Rect,
+        taskBounds: List<DesktopTaskBoundsData>,
+        optimalWindowHeight: Int,
+    ): FitWindowResult {
+        val numTasks = taskBounds.size
+        val outRects = mutableListOf<RectF>()
+
+        // Start in the top-left corner of [layoutBounds].
+        var left = layoutBounds.left
+        var top = layoutBounds.top
+
+        // Right bound of the narrowest row.
+        var minRight = layoutBounds.right
+        // Right bound of the widest row.
+        var maxRight = layoutBounds.left
+
+        var allWindowsFit = true
+        for (i in 0 until numTasks) {
+            val taskBounds = taskBounds[i].bounds
+
+            // Use the height to calculate the width
+            val scale = optimalWindowHeight / taskBounds.height().toFloat()
+            val width = (taskBounds.width() * scale).toInt()
+            val optimalRowHeight = optimalWindowHeight + VERTICAL_SPACE_BETWEEN_TASKS
+
+            if ((left + width + HORIZONTAL_SPACE_BETWEEN_TASKS) > layoutBounds.right) {
+                // Move to the next row if possible.
+                minRight = minOf(minRight, left)
+                maxRight = maxOf(maxRight, left)
+                top += optimalRowHeight
+
+                // Check if the new row reaches the bottom or if the first item in the new
+                // row does not fit within the available width.
+                if (
+                    (top + optimalRowHeight) > layoutBounds.bottom ||
+                        layoutBounds.left + width + HORIZONTAL_SPACE_BETWEEN_TASKS >
+                            layoutBounds.right
+                ) {
+                    allWindowsFit = false
+                    break
+                }
+                left = layoutBounds.left
+            }
+
+            // Position the current rect.
+            outRects.add(
+                RectF(
+                    left.toFloat(),
+                    top.toFloat(),
+                    (left + width).toFloat(),
+                    (top + optimalWindowHeight).toFloat(),
+                )
+            )
+
+            // Increment horizontal position.
+            left += (width + HORIZONTAL_SPACE_BETWEEN_TASKS)
+        }
+
+        // Update the narrowest and widest row width for the last row.
+        minRight = minOf(minRight, left)
+        maxRight = maxOf(maxRight, left)
+
+        return FitWindowResult(allWindowsFit, outRects, minRight, maxRight)
+    }
+
+    /** Centers task windows in the center of Overview. */
+    private fun centerTaskWindows(layoutBounds: Rect, maxBottom: Int, outWindowRects: List<RectF>) {
+        if (outWindowRects.isEmpty()) {
+            return
+        }
+
+        val currentRowUnionRange = RectF(outWindowRects[0])
+        var currentRowY = outWindowRects[0].top
+        var currentRowFirstItemIndex = 0
+        val offsetY = (layoutBounds.bottom - maxBottom) / 2f
+
+        // Batch process to center overview desktop task windows within the same row.
+        fun batchCenterDesktopTaskWindows(endIndex: Int) {
+            // Calculate the shift amount required to center the desktop task items.
+            val rangeCenterX = (currentRowUnionRange.left + currentRowUnionRange.right) / 2f
+            val currentDiffX = (layoutBounds.centerX() - rangeCenterX).coerceAtLeast(0f)
+            for (j in currentRowFirstItemIndex until endIndex) {
+                outWindowRects[j].offset(currentDiffX, offsetY)
+            }
+        }
+
+        outWindowRects.forEachIndexed { index, rect ->
+            if (rect.top != currentRowY) {
+                // As a new row begins processing, batch-shift the previous row's rects
+                // and reset its parameters.
+                batchCenterDesktopTaskWindows(index)
+                currentRowUnionRange.set(rect)
+                currentRowY = rect.top
+                currentRowFirstItemIndex = index
+            }
+
+            // Extend the range by adding the [rect]'s width and extra in-between items
+            // spacing.
+            currentRowUnionRange.right = rect.right
+        }
+
+        // Post-processing rects in the last row.
+        batchCenterDesktopTaskWindows(outWindowRects.size)
+    }
+
+    private companion object {
+        const val VERTICAL_SPACE_BETWEEN_TASKS = 24
+        const val HORIZONTAL_SPACE_BETWEEN_TASKS = 24
+        const val OVERVIEW_INSET_TOP_BOTTOM = 16
+        const val OVERVIEW_INSET_LEFT_RIGHT = 16
+        const val MAXIMUM_TASK_HEIGHT = 800
+    }
+}
diff --git a/quickstep/src/com/android/quickstep/recents/ui/viewmodel/DesktopTaskViewModel.kt b/quickstep/src/com/android/quickstep/recents/ui/viewmodel/DesktopTaskViewModel.kt
new file mode 100644
index 0000000..4de0b90
--- /dev/null
+++ b/quickstep/src/com/android/quickstep/recents/ui/viewmodel/DesktopTaskViewModel.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.quickstep.recents.ui.viewmodel
+
+import android.graphics.Rect
+import android.util.Size
+import com.android.quickstep.recents.domain.model.DesktopTaskBoundsData
+import com.android.quickstep.recents.domain.usecase.OrganizeDesktopTasksUseCase
+
+/** ViewModel used for [com.android.quickstep.views.DesktopTaskView]. */
+class DesktopTaskViewModel(private val organizeDesktopTasksUseCase: OrganizeDesktopTasksUseCase) {
+    /** Positions for desktop tasks as calculated by [organizeDesktopTasksUseCase] */
+    var organizedDesktopTaskPositions = emptyList<DesktopTaskBoundsData>()
+        private set
+
+    /**
+     * Computes new task positions using [organizeDesktopTasksUseCase]. The result is stored in
+     * [organizedDesktopTaskPositions]. This is used for the exploded desktop view where the usecase
+     * will scale and translate tasks so that they don't overlap.
+     *
+     * @param desktopSize the size available for organizing the tasks.
+     * @param defaultPositions the tasks and their bounds as they appear on a desktop.
+     */
+    fun organizeDesktopTasks(desktopSize: Size, defaultPositions: List<DesktopTaskBoundsData>) {
+        organizedDesktopTaskPositions =
+            organizeDesktopTasksUseCase.run(
+                desktopBounds = Rect(0, 0, desktopSize.width, desktopSize.height),
+                taskBounds = defaultPositions,
+            )
+    }
+}
diff --git a/quickstep/src/com/android/quickstep/util/IconLabelUtil.kt b/quickstep/src/com/android/quickstep/util/IconLabelUtil.kt
new file mode 100644
index 0000000..a876bca
--- /dev/null
+++ b/quickstep/src/com/android/quickstep/util/IconLabelUtil.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.quickstep.util
+
+import android.app.ActivityManager
+import android.content.Context
+import android.content.pm.ActivityInfo
+import android.os.UserHandle
+import com.android.launcher3.Utilities
+
+object IconLabelUtil {
+    @JvmStatic
+    @JvmOverloads
+    fun getBadgedContentDescription(
+        context: Context,
+        info: ActivityInfo,
+        userId: Int,
+        taskDescription: ActivityManager.TaskDescription? = null,
+    ): String {
+        val packageManager = context.packageManager
+        var taskLabel = taskDescription?.let { Utilities.trim(it.label) }
+        if (taskLabel.isNullOrEmpty()) {
+            taskLabel = Utilities.trim(info.loadLabel(packageManager))
+        }
+
+        val applicationLabel = Utilities.trim(info.applicationInfo.loadLabel(packageManager))
+        val badgedApplicationLabel =
+            if (userId != UserHandle.myUserId())
+                packageManager
+                    .getUserBadgedLabel(applicationLabel, UserHandle.of(userId))
+                    .toString()
+            else applicationLabel
+        return if (applicationLabel == taskLabel) badgedApplicationLabel
+        else "$badgedApplicationLabel $taskLabel"
+    }
+}
diff --git a/quickstep/src/com/android/quickstep/util/SystemWindowManagerProxy.java b/quickstep/src/com/android/quickstep/util/SystemWindowManagerProxy.java
index 4d56c63..10ae7a3 100644
--- a/quickstep/src/com/android/quickstep/util/SystemWindowManagerProxy.java
+++ b/quickstep/src/com/android/quickstep/util/SystemWindowManagerProxy.java
@@ -33,6 +33,7 @@
 import com.android.launcher3.util.window.CachedDisplayInfo;
 import com.android.launcher3.util.window.WindowManagerProxy;
 import com.android.quickstep.SystemUiProxy;
+import com.android.window.flags.Flags;
 import com.android.wm.shell.shared.desktopmode.DesktopModeStatus;
 
 import java.util.List;
@@ -90,6 +91,25 @@
     }
 
     @Override
+    public boolean showDesktopTaskbarForFreeformDisplay(Context displayInfoContext) {
+        if (!DesktopModeStatus.canEnterDesktopMode(displayInfoContext)) {
+            return false;
+        }
+
+        if (!DesktopModeStatus.enterDesktopByDefaultOnFreeformDisplay(displayInfoContext)) {
+            return false;
+        }
+
+        if (!Flags.enableDesktopTaskbarOnFreeformDisplays()) {
+            return false;
+        }
+
+        final boolean isFreeformDisplay = displayInfoContext.getResources().getConfiguration()
+                .windowConfiguration.getWindowingMode() == WINDOWING_MODE_FREEFORM;
+        return isFreeformDisplay;
+    }
+
+    @Override
     public boolean isHomeVisible(Context context) {
         return SystemUiProxy.INSTANCE.get(context).getHomeVisibilityState().isHomeVisible();
     }
diff --git a/quickstep/src/com/android/quickstep/util/TaskViewSimulator.java b/quickstep/src/com/android/quickstep/util/TaskViewSimulator.java
index a1e55fb..09e9c8b 100644
--- a/quickstep/src/com/android/quickstep/util/TaskViewSimulator.java
+++ b/quickstep/src/com/android/quickstep/util/TaskViewSimulator.java
@@ -120,6 +120,9 @@
     private int mTaskRectTranslationY;
     private int mDesktopTaskIndex = 0;
 
+    @Nullable
+    private Matrix mTaskRectTransform = null;
+
     public TaskViewSimulator(Context context, BaseContainerInterface sizeStrategy,
             boolean isDesktop, int desktopTaskIndex) {
         mContext = context;
@@ -364,6 +367,15 @@
     }
 
     /**
+     * Sets a matrix used to transform the position of tasks. If set, this matrix is applied to
+     * the task rect after the task has been scaled and positioned inside the fulltask, but
+     * before scaling and translation of the whole recents view is performed.
+     */
+    public void setTaskRectTransform(@Nullable Matrix taskRectTransform) {
+        mTaskRectTransform = taskRectTransform;
+    }
+
+    /**
      * Applies the rotation on the matrix to so that it maps from launcher coordinate space to
      * window coordinate space.
      */
@@ -424,8 +436,11 @@
 
         mMatrix.set(mPositionHelper.getMatrix());
 
-        // Apply TaskView matrix: taskRect, translate
+        // Apply TaskView matrix: taskRect, optional transform, translate
         mMatrix.postTranslate(mTaskRect.left, mTaskRect.top);
+        if (mTaskRectTransform != null) {
+            mMatrix.postConcat(mTaskRectTransform);
+        }
         mOrientationState.getOrientationHandler().setPrimary(mMatrix, MATRIX_POST_TRANSLATE,
                 taskPrimaryTranslation.value);
         mOrientationState.getOrientationHandler().setSecondary(mMatrix, MATRIX_POST_TRANSLATE,
diff --git a/quickstep/src/com/android/quickstep/util/TransformParams.java b/quickstep/src/com/android/quickstep/util/TransformParams.java
index bb88818..1c1fbd8 100644
--- a/quickstep/src/com/android/quickstep/util/TransformParams.java
+++ b/quickstep/src/com/android/quickstep/util/TransformParams.java
@@ -22,10 +22,14 @@
 import android.view.SurfaceControl;
 import android.window.TransitionInfo;
 
+import androidx.annotation.VisibleForTesting;
+
 import com.android.quickstep.RemoteAnimationTargets;
 import com.android.quickstep.util.SurfaceTransaction.SurfaceProperties;
 import com.android.window.flags.Flags;
 
+import java.util.function.Supplier;
+
 public class TransformParams {
 
     public static FloatProperty<TransformParams> PROGRESS =
@@ -60,15 +64,23 @@
     private float mCornerRadius;
     private RemoteAnimationTargets mTargetSet;
     private TransitionInfo mTransitionInfo;
+    private boolean mCornerRadiusIsOverridden;
     private SurfaceTransactionApplier mSyncTransactionApplier;
+    private Supplier<SurfaceTransaction> mSurfaceTransactionSupplier;
 
     private BuilderProxy mHomeBuilderProxy = BuilderProxy.ALWAYS_VISIBLE;
     private BuilderProxy mBaseBuilderProxy = BuilderProxy.ALWAYS_VISIBLE;
 
     public TransformParams() {
+        this(SurfaceTransaction::new);
+    }
+
+    @VisibleForTesting
+    public TransformParams(Supplier<SurfaceTransaction> surfaceTransactionSupplier) {
         mProgress = 0;
         mTargetAlpha = 1;
         mCornerRadius = -1;
+        mSurfaceTransactionSupplier = surfaceTransactionSupplier;
     }
 
     /**
@@ -115,6 +127,7 @@
      */
     public TransformParams setTransitionInfo(TransitionInfo transitionInfo) {
         mTransitionInfo = transitionInfo;
+        mCornerRadiusIsOverridden = false;
         return this;
     }
 
@@ -148,7 +161,7 @@
     /** Builds the SurfaceTransaction from the given BuilderProxy params. */
     public SurfaceTransaction createSurfaceParams(BuilderProxy proxy) {
         RemoteAnimationTargets targets = mTargetSet;
-        SurfaceTransaction transaction = new SurfaceTransaction();
+        SurfaceTransaction transaction = mSurfaceTransactionSupplier.get();
         if (targets == null) {
             return transaction;
         }
@@ -166,8 +179,13 @@
             targetProxy.onBuildTargetParams(builder, app, this);
             // Override the corner radius for {@code app} with the leash used by Shell, so that it
             // doesn't interfere with the window clip and corner radius applied here.
-            overrideChangeLeashCornerRadiusToZero(app, transaction.getTransaction());
+            // Only override the corner radius once - so that we don't accidentally override at the
+            // end of transition after WM Shell has reset the corner radius of the task.
+            if (!mCornerRadiusIsOverridden) {
+                overrideFreeformChangeLeashCornerRadiusToZero(app, transaction.getTransaction());
+            }
         }
+        mCornerRadiusIsOverridden = true;
 
         // always put wallpaper layer to bottom.
         final int wallpaperLength = targets.wallpapers != null ? targets.wallpapers.length : 0;
@@ -178,11 +196,15 @@
         return transaction;
     }
 
-    private void overrideChangeLeashCornerRadiusToZero(
+    private void overrideFreeformChangeLeashCornerRadiusToZero(
             RemoteAnimationTarget app, SurfaceControl.Transaction transaction) {
         if (!Flags.enableDesktopRecentsTransitionsCornersBugfix()) {
             return;
         }
+        if (app.taskInfo == null || !app.taskInfo.isFreeform()) {
+            return;
+        }
+
         SurfaceControl changeLeash = getChangeLeashForApp(app);
         if (changeLeash != null) {
             transaction.setCornerRadius(changeLeash, 0);
diff --git a/quickstep/src/com/android/quickstep/views/DesktopTaskView.kt b/quickstep/src/com/android/quickstep/views/DesktopTaskView.kt
index 471313a..bb6829a 100644
--- a/quickstep/src/com/android/quickstep/views/DesktopTaskView.kt
+++ b/quickstep/src/com/android/quickstep/views/DesktopTaskView.kt
@@ -15,33 +15,44 @@
  */
 package com.android.quickstep.views
 
+import android.animation.Animator
 import android.annotation.SuppressLint
 import android.content.Context
-import android.graphics.Point
+import android.graphics.Matrix
 import android.graphics.PointF
 import android.graphics.Rect
+import android.graphics.RectF
 import android.util.AttributeSet
 import android.util.Log
+import android.util.Size
 import android.view.Gravity
 import android.view.View
 import android.view.ViewStub
 import androidx.core.content.res.ResourcesCompat
 import androidx.core.view.updateLayoutParams
+import com.android.launcher3.Flags.enableDesktopExplodedView
 import com.android.launcher3.Flags.enableOverviewIconMenu
 import com.android.launcher3.Flags.enableRefactorTaskThumbnail
 import com.android.launcher3.R
+import com.android.launcher3.anim.AnimatedFloat
 import com.android.launcher3.testing.TestLogging
 import com.android.launcher3.testing.shared.TestProtocol
 import com.android.launcher3.util.RunnableList
 import com.android.launcher3.util.SplitConfigurationOptions
 import com.android.launcher3.util.TransformingTouchDelegate
 import com.android.launcher3.util.ViewPool
+import com.android.launcher3.util.rects.lerpRect
 import com.android.launcher3.util.rects.set
 import com.android.quickstep.BaseContainerInterface
 import com.android.quickstep.DesktopFullscreenDrawParams
 import com.android.quickstep.FullscreenDrawParams
+import com.android.quickstep.RemoteTargetGluer.RemoteTargetHandle
 import com.android.quickstep.TaskOverlayFactory
 import com.android.quickstep.ViewUtils
+import com.android.quickstep.recents.di.RecentsDependencies
+import com.android.quickstep.recents.di.get
+import com.android.quickstep.recents.domain.model.DesktopTaskBoundsData
+import com.android.quickstep.recents.ui.viewmodel.DesktopTaskViewModel
 import com.android.quickstep.task.thumbnail.TaskThumbnailView
 import com.android.quickstep.util.RecentsOrientedState
 import com.android.systemui.shared.recents.model.Task
@@ -79,11 +90,40 @@
         } else null
 
     private val tempPointF = PointF()
-    private val tempRect = Rect()
+    private val lastComputedTaskSize = Rect()
     private lateinit var iconView: TaskViewIcon
     private lateinit var contentView: DesktopTaskContentView
     private lateinit var backgroundView: View
 
+    private var viewModel: DesktopTaskViewModel? = null
+
+    /**
+     * Holds the default (user placed) positions of task windows. This can be moved into the
+     * viewModel once RefactorTaskThumbnail has been launched.
+     */
+    private var defaultTaskPositions: List<DesktopTaskBoundsData> = emptyList()
+
+    /**
+     * When enableDesktopExplodedView is enabled, this controls the gradual transition from the
+     * default positions to the organized non-overlapping positions.
+     */
+    var explodeProgress = 0.0f
+        set(value) {
+            field = value
+            positionTaskWindows()
+        }
+
+    var remoteTargetHandles: Array<RemoteTargetHandle>? = null
+        set(value) {
+            field = value
+            positionTaskWindows()
+        }
+
+    private fun getRemoteTargetHandle(taskId: Int): RemoteTargetHandle? =
+        remoteTargetHandles?.firstOrNull {
+            it.transformParams.targetSet.firstAppTargetTaskId == taskId
+        }
+
     override fun onFinishInflate() {
         super.onFinishInflate()
         iconView =
@@ -121,6 +161,113 @@
             ?.inflate()
     }
 
+    fun startWindowExplodeAnimation(): Animator =
+        AnimatedFloat { progress -> explodeProgress = progress }.animateToValue(0.0f, 1.0f)
+
+    private fun positionTaskWindows() {
+        if (taskContainers.isEmpty()) {
+            return
+        }
+
+        val thumbnailTopMarginPx = container.deviceProfile.overviewTaskThumbnailTopMarginPx
+
+        val containerWidth = layoutParams.width
+        val containerHeight = layoutParams.height - thumbnailTopMarginPx
+
+        BaseContainerInterface.getTaskDimension(mContext, container.deviceProfile, tempPointF)
+
+        val windowWidth = tempPointF.x.toInt()
+        val windowHeight = tempPointF.y.toInt()
+        val scaleWidth = containerWidth / windowWidth.toFloat()
+        val scaleHeight = containerHeight / windowHeight.toFloat()
+
+        taskContainers.forEach {
+            val taskId = it.task.key.id
+            val defaultPosition = defaultTaskPositions.firstOrNull { it.taskId == taskId } ?: return
+            val position =
+                if (enableDesktopExplodedView()) {
+                    viewModel!!
+                        .organizedDesktopTaskPositions
+                        .firstOrNull { it.taskId == taskId }
+                        ?.let { organizedPosition ->
+                            TEMP_RECT.apply {
+                                lerpRect(
+                                    defaultPosition.bounds,
+                                    organizedPosition.bounds,
+                                    explodeProgress,
+                                )
+                            }
+                        } ?: defaultPosition.bounds
+                } else {
+                    defaultPosition.bounds
+                }
+
+            if (enableDesktopExplodedView()) {
+                getRemoteTargetHandle(taskId)?.let { remoteTargetHandle ->
+                    val fromRect =
+                        TEMP_RECTF1.apply {
+                            set(defaultPosition.bounds)
+                            scale(scaleWidth)
+                            offset(
+                                lastComputedTaskSize.left.toFloat(),
+                                lastComputedTaskSize.top.toFloat(),
+                            )
+                        }
+                    val toRect =
+                        TEMP_RECTF2.apply {
+                            set(position)
+                            scale(scaleWidth)
+                            offset(
+                                lastComputedTaskSize.left.toFloat(),
+                                lastComputedTaskSize.top.toFloat(),
+                            )
+                        }
+                    val transform = Matrix()
+                    transform.setRectToRect(fromRect, toRect, Matrix.ScaleToFit.FILL)
+                    remoteTargetHandle.taskViewSimulator.setTaskRectTransform(transform)
+                    remoteTargetHandle.taskViewSimulator.apply(remoteTargetHandle.transformParams)
+                }
+            }
+
+            val taskLeft = position.left * scaleWidth
+            val taskTop = position.top * scaleHeight
+            val taskWidth = position.width() * scaleWidth
+            val taskHeight = position.height() * scaleHeight
+            // TODO(b/394660950): Revisit the choice to update the layout when explodeProgress == 1.
+            // To run the explode animation in reverse, it may be simpler to use translation/scale
+            // for all cases where the progress is non-zero.
+            if (explodeProgress == 0.0f || explodeProgress == 1.0f) {
+                // Reset scaling and translation that may have been applied during animation.
+                it.snapshotView.apply {
+                    scaleX = 1.0f
+                    scaleY = 1.0f
+                    translationX = 0.0f
+                    translationY = 0.0f
+                }
+
+                // Position the task to the same position as it would be on the desktop
+                it.snapshotView.updateLayoutParams<LayoutParams> {
+                    gravity = Gravity.LEFT or Gravity.TOP
+                    width = taskWidth.toInt()
+                    height = taskHeight.toInt()
+                    leftMargin = taskLeft.toInt()
+                    topMargin = taskTop.toInt()
+                }
+            } else {
+                // During the animation, apply translation and scale such that the view is
+                // transformed to where we want, without triggering layout.
+                it.snapshotView.apply {
+                    pivotX = 0.0f
+                    pivotY = 0.0f
+                    translationX = taskLeft - left
+                    translationY = taskTop - top
+                    scaleX = taskWidth / width.toFloat()
+                    scaleY = taskHeight / height.toFloat()
+                }
+            }
+        }
+    }
+
     /** Updates this desktop task to the gives task list defined in `tasks` */
     fun bind(
         tasks: List<Task>,
@@ -133,6 +280,7 @@
             tasks.forEach { sb.append(" key=${it.key}\n") }
             Log.d(TAG, sb.toString())
         }
+
         cancelPendingLoadTasks()
         val backgroundViewIndex = contentView.indexOfChild(backgroundView)
         taskContainers =
@@ -160,8 +308,19 @@
         onBind(orientedState)
     }
 
+    override fun onBind(orientedState: RecentsOrientedState) {
+        super.onBind(orientedState)
+
+        if (enableRefactorTaskThumbnail()) {
+            viewModel =
+                DesktopTaskViewModel(organizeDesktopTasksUseCase = RecentsDependencies.get())
+        }
+    }
+
     override fun onRecycle() {
         super.onRecycle()
+        explodeProgress = 0.0f
+        viewModel = null
         visibility = VISIBLE
         taskContainers.forEach {
             contentView.removeView(it.snapshotView)
@@ -176,61 +335,21 @@
     @SuppressLint("RtlHardcoded")
     override fun updateTaskSize(lastComputedTaskSize: Rect, lastComputedGridTaskSize: Rect) {
         super.updateTaskSize(lastComputedTaskSize, lastComputedGridTaskSize)
-        if (taskContainers.isEmpty()) {
-            return
-        }
-
-        val thumbnailTopMarginPx = container.deviceProfile.overviewTaskThumbnailTopMarginPx
-
-        val containerWidth = layoutParams.width
-        val containerHeight = layoutParams.height - thumbnailTopMarginPx
+        this.lastComputedTaskSize.set(lastComputedTaskSize)
 
         BaseContainerInterface.getTaskDimension(mContext, container.deviceProfile, tempPointF)
+        val desktopSize = Size(tempPointF.x.toInt(), tempPointF.y.toInt())
+        DEFAULT_BOUNDS.set(0, 0, desktopSize.width / 4, desktopSize.height / 4)
 
-        val windowWidth = tempPointF.x.toInt()
-        val windowHeight = tempPointF.y.toInt()
-        val scaleWidth = containerWidth / windowWidth.toFloat()
-        val scaleHeight = containerHeight / windowHeight.toFloat()
-
-        if (DEBUG) {
-            Log.d(
-                TAG,
-                "onMeasure: container=[$containerWidth,$containerHeight]" +
-                    "window=[$windowWidth,$windowHeight] scale=[$scaleWidth,$scaleHeight]",
-            )
-        }
-
-        // Desktop tile is a shrunk down version of launcher and freeform task thumbnails.
-        taskContainers.forEach {
-            // Default to quarter of the desktop if we did not get app bounds.
-            val taskSize =
-                it.task.appBounds
-                    ?: tempRect.apply {
-                        left = 0
-                        top = 0
-                        right = windowWidth / 4
-                        bottom = windowHeight / 4
-                    }
-            val positionInParent = it.task.positionInParent ?: ORIGIN
-
-            // Position the task to the same position as it would be on the desktop
-            it.snapshotView.updateLayoutParams<LayoutParams> {
-                gravity = Gravity.LEFT or Gravity.TOP
-                width = (taskSize.width() * scaleWidth).toInt()
-                height = (taskSize.height() * scaleHeight).toInt()
-                leftMargin = (positionInParent.x * scaleWidth).toInt()
-                topMargin = (positionInParent.y * scaleHeight).toInt()
+        defaultTaskPositions =
+            taskContainers.map {
+                DesktopTaskBoundsData(it.task.key.id, it.task.appBounds ?: DEFAULT_BOUNDS)
             }
-            if (DEBUG) {
-                with(it.snapshotView.layoutParams as LayoutParams) {
-                    Log.d(
-                        TAG,
-                        "onMeasure: task=${it.task.key} size=[$width,$height]" +
-                            " margin=[$leftMargin,$topMargin]",
-                    )
-                }
-            }
+
+        if (enableDesktopExplodedView()) {
+            viewModel?.organizeDesktopTasks(desktopSize, defaultTaskPositions)
         }
+        positionTaskWindows()
     }
 
     override fun onTaskListVisibilityChanged(visible: Boolean, changes: Int) {
@@ -319,6 +438,10 @@
 
         // As DesktopTaskView is inflated in background, use initialSize=0 to avoid initPool.
         private const val VIEW_POOL_INITIAL_SIZE = 0
-        private val ORIGIN = Point(0, 0)
+        private val DEFAULT_BOUNDS = Rect()
+        // Temporaries used for various purposes to avoid allocations.
+        private val TEMP_RECT = Rect()
+        private val TEMP_RECTF1 = RectF()
+        private val TEMP_RECTF2 = RectF()
     }
 }
diff --git a/quickstep/src/com/android/quickstep/views/RecentsView.java b/quickstep/src/com/android/quickstep/views/RecentsView.java
index 519d48e..99bfa7e 100644
--- a/quickstep/src/com/android/quickstep/views/RecentsView.java
+++ b/quickstep/src/com/android/quickstep/views/RecentsView.java
@@ -36,6 +36,7 @@
 import static com.android.launcher3.AbstractFloatingView.getTopOpenViewWithType;
 import static com.android.launcher3.BaseActivity.STATE_HANDLER_INVISIBILITY_FLAGS;
 import static com.android.launcher3.Flags.enableAdditionalHomeAnimations;
+import static com.android.launcher3.Flags.enableDesktopExplodedView;
 import static com.android.launcher3.Flags.enableDesktopTaskAlphaAnimation;
 import static com.android.launcher3.Flags.enableGridOnlyOverview;
 import static com.android.launcher3.Flags.enableLargeDesktopWindowingTile;
@@ -2901,7 +2902,7 @@
      */
     public void onPrepareGestureEndAnimation(
             @Nullable AnimatorSet animatorSet, GestureState.GestureEndTarget endTarget,
-            TaskViewSimulator[] taskViewSimulators) {
+            RemoteTargetHandle[] remoteTargetHandles) {
         Log.d(TAG, "onPrepareGestureEndAnimation - endTarget: " + endTarget);
         mCurrentGestureEndTarget = endTarget;
         boolean isOverviewEndTarget = endTarget == GestureState.GestureEndTarget.RECENTS;
@@ -2909,6 +2910,19 @@
             updateGridProperties();
         }
 
+        if (enableDesktopExplodedView()) {
+            for (TaskView taskView : getTaskViews()) {
+                if (taskView instanceof DesktopTaskView desktopTaskView) {
+                    if (animatorSet == null) {
+                        desktopTaskView.setExplodeProgress(1.0f);
+                    } else {
+                        animatorSet.play(desktopTaskView.startWindowExplodeAnimation());
+                    }
+                    desktopTaskView.setRemoteTargetHandles(remoteTargetHandles);
+                }
+            }
+        }
+
         BaseState<?> endState = mSizeStrategy.stateFromGestureEndTarget(endTarget);
         if (endState.displayOverviewTasksAsGrid(mContainer.getDeviceProfile())) {
             TaskView runningTaskView = getRunningTaskView();
@@ -2921,7 +2935,8 @@
                         - runningTaskView.getNonGridTranslationX();
                 runningTaskSecondaryGridTranslation = runningTaskView.getGridTranslationY();
             }
-            for (TaskViewSimulator tvs : taskViewSimulators) {
+            for (RemoteTargetHandle remoteTargetHandle : remoteTargetHandles) {
+                TaskViewSimulator tvs = remoteTargetHandle.getTaskViewSimulator();
                 if (animatorSet == null) {
                     setGridProgress(1);
                     tvs.taskPrimaryTranslation.value = runningTaskPrimaryGridTranslation;
@@ -2969,6 +2984,12 @@
         startIconFadeInOnGestureComplete();
         animateActionsViewIn();
 
+        for (TaskView taskView : getTaskViews()) {
+            if (taskView instanceof DesktopTaskView desktopTaskView) {
+                desktopTaskView.setRemoteTargetHandles(mRemoteTargetHandles);
+            }
+        }
+
         mCurrentGestureEndTarget = null;
     }
 
@@ -3220,6 +3241,7 @@
 
         int topRowWidth = 0;
         int bottomRowWidth = 0;
+        int largeTileRowWidth = 0;
         float topAccumulatedTranslationX = 0;
         float bottomAccumulatedTranslationX = 0;
 
@@ -3230,9 +3252,12 @@
         int focusedTaskViewShift = 0;
         int largeTaskWidthAndSpacing = 0;
         int snappedTaskRowWidth = 0;
+        int expectedCurrentTaskRowWidth = 0;
         int snappedPage = isKeyboardTaskFocusPending() ? mKeyboardTaskFocusIndex : getNextPage();
         TaskView snappedTaskView = getTaskViewAt(snappedPage);
         TaskView homeTaskView = getHomeTaskView();
+        TaskView expectedCurrentTaskView = mUtils.getExpectedCurrentTask(getFocusedTaskView(),
+                getRunningTaskView());
         TaskView nextFocusedTaskView = null;
 
         // Don't clear the top row, if the user has dismissed a task, to maintain the task order.
@@ -3271,6 +3296,7 @@
                 if (!(taskView instanceof DesktopTaskView && isSplitSelectionActive())) {
                     topRowWidth += taskWidthAndSpacing;
                     bottomRowWidth += taskWidthAndSpacing;
+                    largeTileRowWidth += taskWidthAndSpacing;
                 }
                 gridTranslation += focusedTaskViewShift;
                 gridTranslation += mIsRtl ? taskWidthAndSpacing : -taskWidthAndSpacing;
@@ -3282,8 +3308,10 @@
                 largeTaskWidthAndSpacing = taskWidthAndSpacing;
 
                 if (taskView == snappedTaskView) {
-                    // If focused task is snapped, the row width is just task width and spacing.
-                    snappedTaskRowWidth = taskWidthAndSpacing;
+                    snappedTaskRowWidth = largeTileRowWidth;
+                }
+                if (taskView == expectedCurrentTaskView) {
+                    expectedCurrentTaskRowWidth = largeTileRowWidth;
                 }
             } else {
                 if (encounteredLastLargeTaskView) {
@@ -3352,8 +3380,12 @@
                     lastBottomTaskViews.add(taskView);
                     lastTopTaskViews.clear();
                 }
+                int taskViewRowWidth = isTopRow ? topRowWidth : bottomRowWidth;
                 if (taskView == snappedTaskView) {
-                    snappedTaskRowWidth = isTopRow ? topRowWidth : bottomRowWidth;
+                    snappedTaskRowWidth = taskViewRowWidth;
+                }
+                if (taskView == expectedCurrentTaskView) {
+                    expectedCurrentTaskRowWidth = taskViewRowWidth;
                 }
             }
             gridTranslations.put(taskView, gridTranslation);
@@ -3394,17 +3426,16 @@
         float clearAllShortTotalWidthTranslation = 0;
         int longRowWidth = Math.max(topRowWidth, bottomRowWidth);
 
-        // If Recents contains only large task sizes, it should only consider 1 large size
-        // for ClearAllButton translation. The space at the left side of the large task will be
-        // empty and it should be move ClearAllButton further away as well.
-        // TODO(b/359573248): Validate the translation for ClearAllButton for grid only.
-        if (enableLargeDesktopWindowingTile() && largeTasksCount == getTaskViewCount()) {
-            longRowWidth = largeTaskWidthAndSpacing;
-        }
-
         // If first task is not in the expected position (mLastComputedTaskSize) and being too close
         // to ClearAllButton, then apply extra translation to ClearAllButton.
-        int firstTaskStart = mLastComputedGridSize.left + longRowWidth;
+        int rowWidthAfterExpectedCurrentTask = longRowWidth - expectedCurrentTaskRowWidth;
+        int expectedCurrentTaskWidthAndSpacing =
+                (expectedCurrentTaskView != null
+                        ? expectedCurrentTaskView.getLayoutParams().width
+                        : 0
+                ) + mPageSpacing;
+        int firstTaskStart = mLastComputedGridSize.left + rowWidthAfterExpectedCurrentTask
+                + expectedCurrentTaskWidthAndSpacing;
         int expectedFirstTaskStart = mLastComputedTaskSize.right;
         if (firstTaskStart < expectedFirstTaskStart) {
             mClearAllShortTotalWidthTranslation = expectedFirstTaskStart - firstTaskStart;
diff --git a/quickstep/tests/multivalentTests/src/com/android/quickstep/util/TransformParamsTest.kt b/quickstep/tests/multivalentTests/src/com/android/quickstep/util/TransformParamsTest.kt
new file mode 100644
index 0000000..6dbb667
--- /dev/null
+++ b/quickstep/tests/multivalentTests/src/com/android/quickstep/util/TransformParamsTest.kt
@@ -0,0 +1,191 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.quickstep.util
+
+import android.app.ActivityManager.RunningTaskInfo
+import android.app.WindowConfiguration
+import android.app.WindowConfiguration.ACTIVITY_TYPE_STANDARD
+import android.app.WindowConfiguration.WINDOWING_MODE_FREEFORM
+import android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN
+import android.platform.test.annotations.DisableFlags
+import android.platform.test.annotations.EnableFlags
+import android.platform.test.flag.junit.SetFlagsRule
+import android.view.RemoteAnimationTarget
+import android.view.SurfaceControl
+import android.view.WindowManager.TRANSIT_OPEN
+import android.window.TransitionInfo
+import android.window.TransitionInfo.Change
+import android.window.TransitionInfo.FLAG_NONE
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.quickstep.RemoteAnimationTargets
+import com.android.quickstep.util.TransformParams.BuilderProxy.NO_OP
+import com.android.window.flags.Flags.FLAG_ENABLE_DESKTOP_RECENTS_TRANSITIONS_CORNERS_BUGFIX
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class TransformParamsTest {
+    private val surfaceTransaction = mock<SurfaceTransaction>()
+    private val transaction = mock<SurfaceControl.Transaction>()
+    private val transformParams = TransformParams(::surfaceTransaction)
+
+    private val freeformTaskInfo1 =
+        createTaskInfo(taskId = 1, windowingMode = WINDOWING_MODE_FREEFORM)
+    private val freeformTaskInfo2 =
+        createTaskInfo(taskId = 2, windowingMode = WINDOWING_MODE_FREEFORM)
+    private val fullscreenTaskInfo1 =
+        createTaskInfo(taskId = 1, windowingMode = WINDOWING_MODE_FULLSCREEN)
+
+    @get:Rule val setFlagsRule: SetFlagsRule = SetFlagsRule()
+
+    @Before
+    fun setUp() {
+        whenever(surfaceTransaction.transaction).thenReturn(transaction)
+        whenever(surfaceTransaction.forSurface(anyOrNull()))
+            .thenReturn(mock<SurfaceTransaction.SurfaceProperties>())
+        transformParams.setCornerRadius(CORNER_RADIUS)
+    }
+
+    @Test
+    @EnableFlags(FLAG_ENABLE_DESKTOP_RECENTS_TRANSITIONS_CORNERS_BUGFIX)
+    fun createSurfaceParams_freeformTasks_overridesCornerRadius() {
+        val transitionInfo = TransitionInfo(TRANSIT_OPEN, FLAG_NONE)
+        val leash1 = mock<SurfaceControl>()
+        val leash2 = mock<SurfaceControl>()
+        transitionInfo.addChange(createChange(freeformTaskInfo1, leash = leash1))
+        transitionInfo.addChange(createChange(freeformTaskInfo2, leash = leash2))
+        transformParams.setTransitionInfo(transitionInfo)
+        transformParams.setTargetSet(createTargetSet(listOf(freeformTaskInfo1, freeformTaskInfo2)))
+
+        transformParams.createSurfaceParams(NO_OP)
+
+        verify(transaction).setCornerRadius(leash1, 0f)
+        verify(transaction).setCornerRadius(leash2, 0f)
+    }
+
+    @Test
+    @EnableFlags(FLAG_ENABLE_DESKTOP_RECENTS_TRANSITIONS_CORNERS_BUGFIX)
+    fun createSurfaceParams_freeformTasks_overridesCornerRadiusOnlyOnce() {
+        val transitionInfo = TransitionInfo(TRANSIT_OPEN, FLAG_NONE)
+        val leash1 = mock<SurfaceControl>()
+        val leash2 = mock<SurfaceControl>()
+        transitionInfo.addChange(createChange(freeformTaskInfo1, leash = leash1))
+        transitionInfo.addChange(createChange(freeformTaskInfo2, leash = leash2))
+        transformParams.setTransitionInfo(transitionInfo)
+        transformParams.setTargetSet(createTargetSet(listOf(freeformTaskInfo1, freeformTaskInfo2)))
+        transformParams.createSurfaceParams(NO_OP)
+
+        transformParams.createSurfaceParams(NO_OP)
+
+        verify(transaction).setCornerRadius(leash1, 0f)
+        verify(transaction).setCornerRadius(leash2, 0f)
+    }
+
+    @Test
+    @DisableFlags(FLAG_ENABLE_DESKTOP_RECENTS_TRANSITIONS_CORNERS_BUGFIX)
+    fun createSurfaceParams_flagDisabled_doesntOverrideCornerRadius() {
+        val transitionInfo = TransitionInfo(TRANSIT_OPEN, FLAG_NONE)
+        val leash1 = mock<SurfaceControl>()
+        val leash2 = mock<SurfaceControl>()
+        transitionInfo.addChange(createChange(freeformTaskInfo1, leash = leash1))
+        transitionInfo.addChange(createChange(freeformTaskInfo2, leash = leash2))
+        transformParams.setTransitionInfo(transitionInfo)
+        transformParams.setTargetSet(createTargetSet(listOf(freeformTaskInfo1, freeformTaskInfo2)))
+
+        transformParams.createSurfaceParams(NO_OP)
+
+        verify(transaction, never()).setCornerRadius(leash1, 0f)
+        verify(transaction, never()).setCornerRadius(leash2, 0f)
+    }
+
+    @Test
+    @EnableFlags(FLAG_ENABLE_DESKTOP_RECENTS_TRANSITIONS_CORNERS_BUGFIX)
+    fun createSurfaceParams_fullscreenTasks_doesntOverrideCornerRadius() {
+        val transitionInfo = TransitionInfo(TRANSIT_OPEN, FLAG_NONE)
+        val leash = mock<SurfaceControl>()
+        transitionInfo.addChange(createChange(fullscreenTaskInfo1, leash = leash))
+        transformParams.setTransitionInfo(transitionInfo)
+        transformParams.setTargetSet(createTargetSet(listOf(fullscreenTaskInfo1)))
+
+        transformParams.createSurfaceParams(NO_OP)
+
+        verify(transaction, never()).setCornerRadius(leash, 0f)
+    }
+
+    private fun createTargetSet(taskInfos: List<RunningTaskInfo>): RemoteAnimationTargets {
+        val remoteAnimationTargets = mutableListOf<RemoteAnimationTarget>()
+        taskInfos.map { remoteAnimationTargets.add(createRemoteAnimationTarget(it)) }
+        return RemoteAnimationTargets(
+            remoteAnimationTargets.toTypedArray(),
+            /* wallpapers= */ null,
+            /* nonApps= */ null,
+            /* targetMode= */ TRANSIT_OPEN,
+        )
+    }
+
+    private fun createRemoteAnimationTarget(taskInfo: RunningTaskInfo): RemoteAnimationTarget {
+        val windowConfig = mock<WindowConfiguration>()
+        whenever(windowConfig.activityType).thenReturn(ACTIVITY_TYPE_STANDARD)
+        return RemoteAnimationTarget(
+            taskInfo.taskId,
+            /* mode= */ TRANSIT_OPEN,
+            /* leash= */ null,
+            /* isTranslucent= */ false,
+            /* clipRect= */ null,
+            /* contentInsets= */ null,
+            /* prefixOrderIndex= */ 0,
+            /* position= */ null,
+            /* localBounds= */ null,
+            /* screenSpaceBounds= */ null,
+            windowConfig,
+            /* isNotInRecents= */ false,
+            /* startLeash= */ null,
+            /* startBounds= */ null,
+            taskInfo,
+            /* allowEnterPip= */ false,
+        )
+    }
+
+    private fun createTaskInfo(taskId: Int, windowingMode: Int): RunningTaskInfo {
+        val taskInfo = RunningTaskInfo()
+        taskInfo.taskId = taskId
+        taskInfo.configuration.windowConfiguration.windowingMode = windowingMode
+        return taskInfo
+    }
+
+    private fun createChange(taskInfo: RunningTaskInfo, leash: SurfaceControl): Change {
+        val taskInfo = createTaskInfo(taskInfo.taskId, taskInfo.windowingMode)
+        val change = Change(taskInfo.token, mock<SurfaceControl>())
+        change.mode = TRANSIT_OPEN
+        change.taskInfo = taskInfo
+        change.leash = leash
+        return change
+    }
+
+    private companion object {
+        private const val CORNER_RADIUS = 30f
+    }
+}
diff --git a/src/com/android/launcher3/Launcher.java b/src/com/android/launcher3/Launcher.java
index 30ef24b..58fd154 100644
--- a/src/com/android/launcher3/Launcher.java
+++ b/src/com/android/launcher3/Launcher.java
@@ -29,6 +29,7 @@
 import static com.android.launcher3.AbstractFloatingView.getTopOpenViewWithType;
 import static com.android.launcher3.Flags.enableAddAppWidgetViaConfigActivityV2;
 import static com.android.launcher3.Flags.enableSmartspaceRemovalToggle;
+import static com.android.launcher3.Flags.enableStrictMode;
 import static com.android.launcher3.Flags.enableWorkspaceInflation;
 import static com.android.launcher3.LauncherAnimUtils.HOTSEAT_SCALE_PROPERTY_FACTORY;
 import static com.android.launcher3.LauncherAnimUtils.SCALE_INDEX_WIDGET_TRANSITION;
@@ -459,7 +460,8 @@
         Trace.beginAsyncSection(DISPLAY_ALL_APPS_TRACE_METHOD_NAME,
                 DISPLAY_ALL_APPS_TRACE_COOKIE);
         TraceHelper.INSTANCE.beginSection(ON_CREATE_EVT);
-        if (DEBUG_STRICT_MODE) {
+        if (DEBUG_STRICT_MODE
+                || (FeatureFlags.IS_STUDIO_BUILD && enableStrictMode())) {
             StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder()
                     .detectDiskReads()
                     .detectDiskWrites()
diff --git a/src/com/android/launcher3/LauncherPrefs.kt b/src/com/android/launcher3/LauncherPrefs.kt
index 484cef4..1120ec8 100644
--- a/src/com/android/launcher3/LauncherPrefs.kt
+++ b/src/com/android/launcher3/LauncherPrefs.kt
@@ -34,6 +34,7 @@
 import com.android.launcher3.states.RotationHelper
 import com.android.launcher3.util.DaggerSingletonObject
 import com.android.launcher3.util.DisplayController
+import java.util.concurrent.ConcurrentHashMap
 import javax.inject.Inject
 
 /**
@@ -52,17 +53,18 @@
             .getSharedPreferences(BOOT_AWARE_PREFS_KEY, MODE_PRIVATE)
     }
 
-    open val Item.sharedPrefs: SharedPreferences
-        get() =
+    open protected fun getSharedPrefs(item: Item): SharedPreferences =
+        item.run {
             if (encryptionType == EncryptionType.DEVICE_PROTECTED) deviceProtectedSharedPrefs
             else encryptedContext.getSharedPreferences(sharedPrefFile, MODE_PRIVATE)
+        }
 
     /** Returns the value with type [T] for [item]. */
-    open fun <T> get(item: ContextualItem<T>): T =
+    fun <T> get(item: ContextualItem<T>): T =
         getInner(item, item.defaultValueFromContext(encryptedContext))
 
     /** Returns the value with type [T] for [item]. */
-    open fun <T> get(item: ConstantItem<T>): T = getInner(item, item.defaultValue)
+    fun <T> get(item: ConstantItem<T>): T = getInner(item, item.defaultValue)
 
     /**
      * Retrieves the value for an [Item] from [SharedPreferences]. It handles method typing via the
@@ -71,7 +73,7 @@
      */
     @Suppress("IMPLICIT_CAST_TO_ANY", "UNCHECKED_CAST")
     private fun <T> getInner(item: Item, default: T): T {
-        val sp = item.sharedPrefs
+        val sp = getSharedPrefs(item)
 
         return when (item.type) {
             String::class.java -> sp.getString(item.sharedPrefKey, default as? String)
@@ -127,7 +129,7 @@
     private fun prepareToPutValues(
         updates: Array<out Pair<Item, Any>>
     ): List<SharedPreferences.Editor> {
-        val updatesPerPrefFile = updates.groupBy { it.first.sharedPrefs }.toMap()
+        val updatesPerPrefFile = updates.groupBy { getSharedPrefs(it.first) }.toMap()
 
         return updatesPerPrefFile.map { (sharedPref, itemList) ->
             sharedPref.edit().apply { itemList.forEach { (item, value) -> putValue(item, value) } }
@@ -140,7 +142,7 @@
      * types of Item values.
      */
     @Suppress("UNCHECKED_CAST")
-    private fun SharedPreferences.Editor.putValue(
+    internal fun SharedPreferences.Editor.putValue(
         item: Item,
         value: Any?,
     ): SharedPreferences.Editor =
@@ -168,7 +170,7 @@
      */
     fun addListener(listener: LauncherPrefChangeListener, vararg items: Item) {
         items
-            .map { it.sharedPrefs }
+            .map { getSharedPrefs(it) }
             .distinct()
             .forEach { it.registerOnSharedPreferenceChangeListener(listener) }
     }
@@ -180,7 +182,7 @@
     fun removeListener(listener: LauncherPrefChangeListener, vararg items: Item) {
         // If a listener is not registered to a SharedPreference, unregistering it does nothing
         items
-            .map { it.sharedPrefs }
+            .map { getSharedPrefs(it) }
             .distinct()
             .forEach { it.unregisterOnSharedPreferenceChangeListener(listener) }
     }
@@ -191,7 +193,7 @@
      */
     fun has(vararg items: Item): Boolean {
         items
-            .groupBy { it.sharedPrefs }
+            .groupBy { getSharedPrefs(it) }
             .forEach { (prefs, itemsSublist) ->
                 if (!itemsSublist.none { !prefs.contains(it.sharedPrefKey) }) return false
             }
@@ -215,7 +217,7 @@
      *   .apply() or .commit()
      */
     private fun prepareToRemove(items: Array<out Item>): List<SharedPreferences.Editor> {
-        val itemsPerFile = items.groupBy { it.sharedPrefs }.toMap()
+        val itemsPerFile = items.groupBy { getSharedPrefs(it) }.toMap()
 
         return itemsPerFile.map { (prefs, items) ->
             prefs.edit().also { editor ->
@@ -412,14 +414,20 @@
  */
 class ProxyPrefs(context: Context, private val prefs: SharedPreferences) : LauncherPrefs(context) {
 
-    private val realPrefs = LauncherPrefs(context)
+    private val copiedPrefs = ConcurrentHashMap<SharedPreferences, Boolean>()
 
-    override val Item.sharedPrefs: SharedPreferences
-        get() = prefs
-
-    override fun <T> get(item: ConstantItem<T>) =
-        super.get(backedUpItem(item.sharedPrefKey, realPrefs.get(item)))
-
-    override fun <T> get(item: ContextualItem<T>) =
-        super.get(backedUpItem(item.sharedPrefKey, realPrefs.get(item)))
+    override fun getSharedPrefs(item: Item): SharedPreferences {
+        val originalPrefs = super.getSharedPrefs(item)
+        // Copy all existing values, when the pref is accessed for the first time
+        copiedPrefs.computeIfAbsent(originalPrefs) { op ->
+            val editor = prefs.edit()
+            op.all.forEach { (key, value) ->
+                if (value != null) {
+                    editor.putValue(backedUpItem(key, value), value)
+                }
+            }
+            editor.commit()
+        }
+        return prefs
+    }
 }
diff --git a/src/com/android/launcher3/icons/LauncherIcons.java b/src/com/android/launcher3/icons/LauncherIcons.java
index 59fff62..5c6debe 100644
--- a/src/com/android/launcher3/icons/LauncherIcons.java
+++ b/src/com/android/launcher3/icons/LauncherIcons.java
@@ -16,7 +16,12 @@
 
 package com.android.launcher3.icons;
 
+import static android.graphics.Color.BLACK;
+
+import static com.android.launcher3.graphics.ThemeManager.PREF_ICON_SHAPE;
+
 import android.content.Context;
+import android.graphics.Canvas;
 import android.graphics.Path;
 import android.graphics.Rect;
 import android.graphics.drawable.AdaptiveIconDrawable;
@@ -26,6 +31,7 @@
 
 import com.android.launcher3.Flags;
 import com.android.launcher3.InvariantDeviceProfile;
+import com.android.launcher3.LauncherPrefs;
 import com.android.launcher3.graphics.IconShape;
 import com.android.launcher3.graphics.ThemeManager;
 import com.android.launcher3.pm.UserCache;
@@ -41,6 +47,12 @@
  */
 public class LauncherIcons extends BaseIconFactory implements AutoCloseable {
 
+    private static final float SEVEN_SIDED_COOKIE_SCALE = 72f / 80f;
+    private static final float FOUR_SIDED_COOKIE_SCALE = 72f / 83.4f;
+    private static final float VERY_SUNNY_SCALE = 72f / 92f;
+    private static final float DEFAULT_ICON_SCALE = 1f;
+
+
     private static final MainThreadInitializedObject<Pool> POOL =
             new MainThreadInitializedObject<>(Pool::new);
 
@@ -87,6 +99,36 @@
     }
 
     @Override
+    protected void drawAdaptiveIcon(
+            @NonNull Canvas canvas,
+            @NonNull AdaptiveIconDrawable drawable,
+            @NonNull Path overridePath
+    ) {
+        if (!Flags.enableLauncherIconShapes()) {
+            super.drawAdaptiveIcon(canvas, drawable, overridePath);
+            return;
+        }
+        String shapeKey = LauncherPrefs.get(mContext).get(PREF_ICON_SHAPE);
+        float iconScale = switch (shapeKey) {
+            case "seven_sided_cookie" -> SEVEN_SIDED_COOKIE_SCALE;
+            case "four_sided_cookie" -> FOUR_SIDED_COOKIE_SCALE;
+            case "sunny" -> VERY_SUNNY_SCALE;
+            default -> DEFAULT_ICON_SCALE;
+        };
+        canvas.clipPath(overridePath);
+        canvas.drawColor(BLACK);
+        canvas.save();
+        canvas.scale(iconScale, iconScale, canvas.getWidth() / 2f, canvas.getHeight() / 2f);
+        if (drawable.getBackground() != null) {
+            drawable.getBackground().draw(canvas);
+        }
+        if (drawable.getForeground() != null) {
+            drawable.getForeground().draw(canvas);
+        }
+        canvas.restore();
+    }
+
+    @Override
     public void close() {
         recycle();
     }
diff --git a/src/com/android/launcher3/model/data/AppInfo.java b/src/com/android/launcher3/model/data/AppInfo.java
index 97b62b4..fe8fb5f 100644
--- a/src/com/android/launcher3/model/data/AppInfo.java
+++ b/src/com/android/launcher3/model/data/AppInfo.java
@@ -215,8 +215,7 @@
                 PackageManagerHelper.getLoadingProgress(lai),
                 PackageInstallInfo.STATUS_INSTALLED_DOWNLOADING);
         info.setNonResizeable(apiWrapper.isNonResizeableActivity(lai));
-        info.setSupportsMultiInstance(
-                pmHelper.supportsMultiInstance(lai.getComponentName()));
+        info.setSupportsMultiInstance(apiWrapper.supportsMultiInstance(lai));
         return (oldProgressLevel != info.getProgressLevel())
                 || (oldRuntimeStatusFlags != info.runtimeStatusFlags);
     }
diff --git a/src/com/android/launcher3/util/ApiWrapper.java b/src/com/android/launcher3/util/ApiWrapper.java
index 48e033a..56337b0 100644
--- a/src/com/android/launcher3/util/ApiWrapper.java
+++ b/src/com/android/launcher3/util/ApiWrapper.java
@@ -59,10 +59,13 @@
             LauncherAppComponent::getApiWrapper);
 
     protected final Context mContext;
+    private final String[] mLegacyMultiInstanceSupportedApps;
 
     @Inject
     public ApiWrapper(@ApplicationContext Context context) {
         mContext = context;
+        mLegacyMultiInstanceSupportedApps = context.getResources().getStringArray(
+                com.android.launcher3.R.array.config_appsSupportMultiInstancesSplit);
     }
 
     /**
@@ -157,12 +160,31 @@
     /**
      * Checks if an activity is flagged as non-resizeable.
      */
-    public boolean isNonResizeableActivity(LauncherActivityInfo lai) {
-        // Overridden in quickstep
+    public boolean isNonResizeableActivity(@NonNull LauncherActivityInfo lai) {
+        // Overridden in Quickstep
         return false;
     }
 
     /**
+     * Checks if an activity supports multi-instance.
+     */
+    public boolean supportsMultiInstance(@NonNull LauncherActivityInfo lai) {
+        // Check app multi-instance properties after V
+        if (!Utilities.ATLEAST_V) {
+            return false;
+        }
+
+        // Check the legacy hardcoded allowlist first
+        for (String pkg : mLegacyMultiInstanceSupportedApps) {
+            if (pkg.equals(lai.getComponentName().getPackageName())) {
+                return true;
+            }
+        }
+
+        // Overridden in Quickstep
+        return false;
+    }
+    /**
      * Starts an Activity which can be used to set this Launcher as the HOME app, via a consent
      * screen. In case the consent screen cannot be shown, or the user does not set current Launcher
      * as HOME app, a toast asking the user to do the latter is shown.
diff --git a/src/com/android/launcher3/util/DisplayController.java b/src/com/android/launcher3/util/DisplayController.java
index 475dc04..ee1af81 100644
--- a/src/com/android/launcher3/util/DisplayController.java
+++ b/src/com/android/launcher3/util/DisplayController.java
@@ -220,6 +220,14 @@
         return INSTANCE.get(context).getInfo().showLockedTaskbarOnHome();
     }
 
+    /**
+     * Returns whether desktop taskbar (pinned taskbar that shows desktop tasks) is to be used
+     * on the display because the display is a freeform display.
+     */
+    public static boolean showDesktopTaskbarForFreeformDisplay(Context context) {
+        return INSTANCE.get(context).getInfo().showDesktopTaskbarForFreeformDisplay();
+    }
+
     @Override
     public void onDesktopVisibilityChanged(boolean visible) {
         notifyConfigChange();
@@ -259,7 +267,9 @@
                         new PortraitSize(config.screenHeightDp, config.screenWidthDp))
                 || mWindowContext.getDisplay().getRotation() != mInfo.rotation
                 || mWMProxy.showLockedTaskbarOnHome(mWindowContext)
-                        != mInfo.showLockedTaskbarOnHome()) {
+                        != mInfo.showLockedTaskbarOnHome()
+                || mWMProxy.showDesktopTaskbarForFreeformDisplay(mWindowContext)
+                        != mInfo.showDesktopTaskbarForFreeformDisplay()) {
             notifyConfigChange();
         }
     }
@@ -376,6 +386,8 @@
         private final boolean mShowLockedTaskbarOnHome;
         private final boolean mIsHomeVisible;
 
+        private final boolean mShowDesktopTaskbarForFreeformDisplay;
+
         public Info(Context displayInfoContext) {
             /* don't need system overrides for external displays */
             this(displayInfoContext, new WindowManagerProxy(), new ArrayMap<>());
@@ -438,6 +450,8 @@
                     TASKBAR_PINNING_IN_DESKTOP_MODE);
             mIsInDesktopMode = wmProxy.isInDesktopMode();
             mShowLockedTaskbarOnHome = wmProxy.showLockedTaskbarOnHome(displayInfoContext);
+            mShowDesktopTaskbarForFreeformDisplay = wmProxy.showDesktopTaskbarForFreeformDisplay(
+                    displayInfoContext);
             mIsHomeVisible = wmProxy.isHomeVisible(displayInfoContext);
         }
 
@@ -455,6 +469,11 @@
                 return sTransientTaskbarStatusForTests;
             }
             if (enableTaskbarPinning()) {
+                // If "freeform" display taskbar is enabled, ensure the taskbar is pinned.
+                if (mShowDesktopTaskbarForFreeformDisplay) {
+                    return false;
+                }
+
                 // If Launcher is visible on the freeform display, ensure the taskbar is pinned.
                 if (mShowLockedTaskbarOnHome && mIsHomeVisible) {
                     return false;
@@ -533,6 +552,14 @@
         public boolean showLockedTaskbarOnHome() {
             return mShowLockedTaskbarOnHome;
         }
+
+        /**
+         * Returns whether the taskbar should be pinned, and showing desktop tasks, because the
+         * display is a "freeform" display.
+         */
+        public boolean showDesktopTaskbarForFreeformDisplay() {
+            return mShowDesktopTaskbarForFreeformDisplay;
+        }
     }
 
     /**
diff --git a/src/com/android/launcher3/util/PackageManagerHelper.java b/src/com/android/launcher3/util/PackageManagerHelper.java
index 4b60d98..3d01f24 100644
--- a/src/com/android/launcher3/util/PackageManagerHelper.java
+++ b/src/com/android/launcher3/util/PackageManagerHelper.java
@@ -16,8 +16,6 @@
 
 package com.android.launcher3.util;
 
-import static android.view.WindowManager.PROPERTY_SUPPORTS_MULTI_INSTANCE_SYSTEM_UI;
-
 import static com.android.launcher3.model.data.ItemInfoWithIcon.FLAG_INSTALL_SESSION_ACTIVE;
 
 import android.content.ActivityNotFoundException;
@@ -41,7 +39,6 @@
 
 import com.android.launcher3.PendingAddItemInfo;
 import com.android.launcher3.R;
-import com.android.launcher3.Utilities;
 import com.android.launcher3.dagger.ApplicationContext;
 import com.android.launcher3.dagger.LauncherAppSingleton;
 import com.android.launcher3.dagger.LauncherBaseAppComponent;
@@ -77,15 +74,11 @@
     @NonNull
     private final LauncherApps mLauncherApps;
 
-    private final String[] mLegacyMultiInstanceSupportedApps;
-
     @Inject
     public PackageManagerHelper(@ApplicationContext final Context context) {
         mContext = context;
         mPm = context.getPackageManager();
         mLauncherApps = Objects.requireNonNull(context.getSystemService(LauncherApps.class));
-        mLegacyMultiInstanceSupportedApps = mContext.getResources().getStringArray(
-                    R.array.config_appsSupportMultiInstancesSplit);
     }
 
     /**
@@ -193,39 +186,6 @@
     }
 
     /**
-     * Returns whether the given component or its application has the multi-instance property set.
-     */
-    public boolean supportsMultiInstance(@NonNull ComponentName component) {
-        // Check the legacy hardcoded allowlist first
-        for (String pkg : mLegacyMultiInstanceSupportedApps) {
-            if (pkg.equals(component.getPackageName())) {
-                return true;
-            }
-        }
-
-        // Check app multi-instance properties after V
-        if (!Utilities.ATLEAST_V) {
-            return false;
-        }
-
-        try {
-            // Check if the component has the multi-instance property
-            return mPm.getProperty(PROPERTY_SUPPORTS_MULTI_INSTANCE_SYSTEM_UI, component)
-                    .getBoolean();
-        } catch (PackageManager.NameNotFoundException e1) {
-            try {
-                // Check if the application has the multi-instance property
-                return mPm.getProperty(PROPERTY_SUPPORTS_MULTI_INSTANCE_SYSTEM_UI,
-                                component.getPackageName())
-                    .getBoolean();
-            } catch (PackageManager.NameNotFoundException e2) {
-                // Fall through
-            }
-        }
-        return false;
-    }
-
-    /**
      * Returns whether two apps should be considered the same for multi-instance purposes, which
      * requires additional checks to ensure they can be started as multiple instances.
      */
diff --git a/src/com/android/launcher3/util/rects/Rects.kt b/src/com/android/launcher3/util/rects/Rects.kt
index 1e6d717..2f1942a 100644
--- a/src/com/android/launcher3/util/rects/Rects.kt
+++ b/src/com/android/launcher3/util/rects/Rects.kt
@@ -18,6 +18,24 @@
 
 import android.graphics.Rect
 import android.view.View
+import com.android.launcher3.Utilities
+
+/**
+ * Linearly interpolate between two rectangles. The result is stored in the rect the function is
+ * called on.
+ *
+ * @param start the starting rectangle
+ * @param end the ending rectangle
+ * @param t the interpolation factor, where 0 is the start and 1 is the end
+ */
+fun Rect.lerpRect(start: Rect, end: Rect, t: Float) {
+    set(
+        Utilities.mapRange(t, start.left.toFloat(), end.left.toFloat()).toInt(),
+        Utilities.mapRange(t, start.top.toFloat(), end.top.toFloat()).toInt(),
+        Utilities.mapRange(t, start.right.toFloat(), end.right.toFloat()).toInt(),
+        Utilities.mapRange(t, start.bottom.toFloat(), end.bottom.toFloat()).toInt(),
+    )
+}
 
 /** Copy the coordinates of the [view] relative to its parent into this rectangle. */
 fun Rect.set(view: View) {
diff --git a/src/com/android/launcher3/util/window/WindowManagerProxy.java b/src/com/android/launcher3/util/window/WindowManagerProxy.java
index e568eed..f511ef2 100644
--- a/src/com/android/launcher3/util/window/WindowManagerProxy.java
+++ b/src/com/android/launcher3/util/window/WindowManagerProxy.java
@@ -121,6 +121,14 @@
     }
 
     /**
+     * Returns whether the display is a freeform display for which taskbar should be pinned
+     * and showing desktop tasks.
+     */
+    public boolean showDesktopTaskbarForFreeformDisplay(Context displayInfoContext) {
+        return false;
+    }
+
+    /**
      * Returns if the home is visible.
      */
     public boolean isHomeVisible(Context context) {
diff --git a/tests/multivalentTests/src/com/android/launcher3/FakeLauncherPrefs.kt b/tests/multivalentTests/src/com/android/launcher3/FakeLauncherPrefs.kt
index 7573d2f..5e1e548 100644
--- a/tests/multivalentTests/src/com/android/launcher3/FakeLauncherPrefs.kt
+++ b/tests/multivalentTests/src/com/android/launcher3/FakeLauncherPrefs.kt
@@ -35,6 +35,5 @@
             MODE_PRIVATE,
         )
 
-    override val Item.sharedPrefs: SharedPreferences
-        get() = backingPrefs
+    override fun getSharedPrefs(item: Item): SharedPreferences = backingPrefs
 }
diff --git a/tests/multivalentTests/src/com/android/launcher3/ProxyPrefsTest.kt b/tests/multivalentTests/src/com/android/launcher3/ProxyPrefsTest.kt
new file mode 100644
index 0000000..54f6f63
--- /dev/null
+++ b/tests/multivalentTests/src/com/android/launcher3/ProxyPrefsTest.kt
@@ -0,0 +1,99 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.launcher3
+
+import android.content.Context.MODE_PRIVATE
+import android.platform.uiautomatorhelpers.DeviceHelpers.context
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.launcher3.LauncherPrefs.Companion.backedUpItem
+import com.google.common.truth.Truth.assertThat
+import java.util.UUID
+import org.junit.After
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class ProxyPrefsTest {
+
+    private val prefName = "pref-test-" + UUID.randomUUID().toString()
+
+    private val proxyPrefs by lazy {
+        ProxyPrefs(
+            context,
+            context.getSharedPreferences(prefName, MODE_PRIVATE).apply { edit().clear().commit() },
+        )
+    }
+    private val launcherPrefs by lazy { LauncherPrefs(context) }
+
+    @After
+    fun tearDown() {
+        context.deleteSharedPreferences(prefName)
+    }
+
+    @Test
+    fun `returns fallback value if present`() {
+        launcherPrefs.putSync(TEST_ENTRY.to("new_value"))
+        assertThat(proxyPrefs.get(TEST_ENTRY)).isEqualTo("new_value")
+    }
+
+    @Test
+    fun `returns default value if not present`() {
+        launcherPrefs.removeSync(TEST_ENTRY)
+        assertThat(proxyPrefs.get(TEST_ENTRY)).isEqualTo("default_value")
+    }
+
+    @Test
+    fun `returns overridden value if present`() {
+        launcherPrefs.putSync(TEST_ENTRY.to("new_value"))
+        proxyPrefs.putSync(TEST_ENTRY.to("overridden_value"))
+        assertThat(proxyPrefs.get(TEST_ENTRY)).isEqualTo("overridden_value")
+    }
+
+    @Test
+    fun `value not present when removed`() {
+        launcherPrefs.putSync(TEST_ENTRY.to("new_value"))
+        proxyPrefs.removeSync(TEST_ENTRY)
+        assertThat(proxyPrefs.has(TEST_ENTRY)).isFalse()
+    }
+
+    @Test
+    fun `returns default if removed`() {
+        launcherPrefs.putSync(TEST_ENTRY.to("new_value"))
+        proxyPrefs.removeSync(TEST_ENTRY)
+        assertThat(proxyPrefs.get(TEST_ENTRY)).isEqualTo("default_value")
+    }
+
+    @Test
+    fun `value present on init`() {
+        launcherPrefs.putSync(TEST_ENTRY.to("new_value"))
+        assertThat(proxyPrefs.has(TEST_ENTRY)).isTrue()
+    }
+
+    @Test
+    fun `value absent on init`() {
+        launcherPrefs.removeSync(TEST_ENTRY)
+        assertThat(proxyPrefs.has(TEST_ENTRY)).isFalse()
+    }
+
+    companion object {
+
+        val TEST_ENTRY =
+            backedUpItem("test_prefs", "default_value", EncryptionType.DEVICE_PROTECTED)
+    }
+}
diff --git a/tests/multivalentTests/src/com/android/launcher3/util/DisplayControllerTest.kt b/tests/multivalentTests/src/com/android/launcher3/util/DisplayControllerTest.kt
index 7e76e19..588a668 100644
--- a/tests/multivalentTests/src/com/android/launcher3/util/DisplayControllerTest.kt
+++ b/tests/multivalentTests/src/com/android/launcher3/util/DisplayControllerTest.kt
@@ -32,6 +32,7 @@
 import com.android.launcher3.dagger.LauncherAppComponent
 import com.android.launcher3.dagger.LauncherAppSingleton
 import com.android.launcher3.util.DisplayController.CHANGE_DENSITY
+import com.android.launcher3.util.DisplayController.CHANGE_DESKTOP_MODE
 import com.android.launcher3.util.DisplayController.CHANGE_ROTATION
 import com.android.launcher3.util.DisplayController.CHANGE_TASKBAR_PINNING
 import com.android.launcher3.util.DisplayController.DisplayInfoChangeListener
@@ -229,6 +230,63 @@
             .onDisplayInfoChanged(any(), any(), eq(CHANGE_TASKBAR_PINNING))
         assertFalse(displayController.getInfo().isTransientTaskbar())
     }
+
+    @Test
+    @UiThreadTest
+    fun testTaskbarPinnedForDesktopTaskbar_inDesktopMode() {
+        whenever(windowManagerProxy.showDesktopTaskbarForFreeformDisplay(any())).thenReturn(true)
+        whenever(launcherPrefs.get(TASKBAR_PINNING)).thenReturn(false)
+        whenever(launcherPrefs.get(TASKBAR_PINNING_IN_DESKTOP_MODE)).thenReturn(false)
+        whenever(windowManagerProxy.isInDesktopMode()).thenReturn(true)
+        whenever(windowManagerProxy.isHomeVisible(any())).thenReturn(false)
+        DisplayController.enableTaskbarModePreferenceForTests(true)
+
+        assertTrue(displayController.getInfo().isTransientTaskbar())
+
+        displayController.onConfigurationChanged(configuration)
+
+        verify(displayInfoChangeListener)
+            .onDisplayInfoChanged(any(), any(), eq(CHANGE_TASKBAR_PINNING or CHANGE_DESKTOP_MODE))
+        assertFalse(displayController.getInfo().isTransientTaskbar())
+    }
+
+    @Test
+    @UiThreadTest
+    fun testTaskbarPinnedForDesktopTaskbar_notInDesktopMode() {
+        whenever(windowManagerProxy.showDesktopTaskbarForFreeformDisplay(any())).thenReturn(true)
+        whenever(launcherPrefs.get(TASKBAR_PINNING)).thenReturn(false)
+        whenever(launcherPrefs.get(TASKBAR_PINNING_IN_DESKTOP_MODE)).thenReturn(false)
+        whenever(windowManagerProxy.isInDesktopMode()).thenReturn(false)
+        whenever(windowManagerProxy.isHomeVisible(any())).thenReturn(false)
+        DisplayController.enableTaskbarModePreferenceForTests(true)
+
+        assertTrue(displayController.getInfo().isTransientTaskbar())
+
+        displayController.onConfigurationChanged(configuration)
+
+        verify(displayInfoChangeListener)
+            .onDisplayInfoChanged(any(), any(), eq(CHANGE_TASKBAR_PINNING))
+        assertFalse(displayController.getInfo().isTransientTaskbar())
+    }
+
+    @Test
+    @UiThreadTest
+    fun testTaskbarPinnedForDesktopTaskbar_onHome() {
+        whenever(windowManagerProxy.showDesktopTaskbarForFreeformDisplay(any())).thenReturn(true)
+        whenever(launcherPrefs.get(TASKBAR_PINNING)).thenReturn(false)
+        whenever(launcherPrefs.get(TASKBAR_PINNING_IN_DESKTOP_MODE)).thenReturn(false)
+        whenever(windowManagerProxy.isInDesktopMode()).thenReturn(false)
+        whenever(windowManagerProxy.isHomeVisible(any())).thenReturn(true)
+        DisplayController.enableTaskbarModePreferenceForTests(true)
+
+        assertTrue(displayController.getInfo().isTransientTaskbar())
+
+        displayController.onConfigurationChanged(configuration)
+
+        verify(displayInfoChangeListener)
+            .onDisplayInfoChanged(any(), any(), eq(CHANGE_TASKBAR_PINNING))
+        assertFalse(displayController.getInfo().isTransientTaskbar())
+    }
 }
 
 class MyWmProxy : WindowManagerProxy()