Minimize a Task whenever we reach the Desktop Task limit

- Introduce DesktopTasksLimiter to limit the number of visible Desktop
Tasks
- Keep track of minimized Desktop Tasks in DesktopModeTaskRepository
- Call into DesktopTasksLimiter from DesktopTasksController whenever we
  handle a transition that might cause us to hit the task limit.
  - to minimize a Task we reorder it to the bottom of the Desktop Tasks,
    placing it behind the Home Task. We mark the task as minimized in
    DesktopModeTaskRepository later when the transition is ready.
- Add a SystemProperty 'persist.wm.debug.desktop_max_task_limit' to
  control the maximum Task limit. To update the limit to X tasks, run:
  'adb shell setprop persist.wm.debug.desktop_max_task_limit X'

Bug: 332503075, 316115519
Test: unit tests
Change-Id: Ia34b67b4628d50e99347d9680e5a869ca666eaa3
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellModule.java b/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellModule.java
index 1408ead..26e7acb 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellModule.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellModule.java
@@ -29,6 +29,7 @@
 import com.android.internal.logging.UiEventLogger;
 import com.android.internal.statusbar.IStatusBarService;
 import com.android.launcher3.icons.IconProvider;
+import com.android.window.flags.Flags;
 import com.android.wm.shell.RootTaskDisplayAreaOrganizer;
 import com.android.wm.shell.ShellTaskOrganizer;
 import com.android.wm.shell.WindowManagerShellWrapper;
@@ -59,6 +60,7 @@
 import com.android.wm.shell.desktopmode.DesktopModeStatus;
 import com.android.wm.shell.desktopmode.DesktopModeTaskRepository;
 import com.android.wm.shell.desktopmode.DesktopTasksController;
+import com.android.wm.shell.desktopmode.DesktopTasksLimiter;
 import com.android.wm.shell.desktopmode.DesktopTasksTransitionObserver;
 import com.android.wm.shell.desktopmode.DragToDesktopTransitionHandler;
 import com.android.wm.shell.desktopmode.EnterDesktopTaskTransitionHandler;
@@ -517,23 +519,39 @@
             LaunchAdjacentController launchAdjacentController,
             RecentsTransitionHandler recentsTransitionHandler,
             MultiInstanceHelper multiInstanceHelper,
-            @ShellMainThread ShellExecutor mainExecutor
-    ) {
+            @ShellMainThread ShellExecutor mainExecutor,
+            Optional<DesktopTasksLimiter> desktopTasksLimiter) {
         return new DesktopTasksController(context, shellInit, shellCommandHandler, shellController,
                 displayController, shellTaskOrganizer, syncQueue, rootTaskDisplayAreaOrganizer,
                 dragAndDropController, transitions, enterDesktopTransitionHandler,
                 exitDesktopTransitionHandler, toggleResizeDesktopTaskTransitionHandler,
                 dragToDesktopTransitionHandler, desktopModeTaskRepository,
                 desktopModeLoggerTransitionObserver, launchAdjacentController,
-                recentsTransitionHandler, multiInstanceHelper, mainExecutor);
+                recentsTransitionHandler, multiInstanceHelper, mainExecutor, desktopTasksLimiter);
     }
 
     @WMSingleton
     @Provides
+    static Optional<DesktopTasksLimiter> provideDesktopTasksLimiter(
+            Transitions transitions,
+            @DynamicOverride DesktopModeTaskRepository desktopModeTaskRepository,
+            ShellTaskOrganizer shellTaskOrganizer) {
+        if (!DesktopModeStatus.isEnabled() || !Flags.enableDesktopWindowingTaskLimit()) {
+            return Optional.empty();
+        }
+        return Optional.of(
+                new DesktopTasksLimiter(
+                        transitions, desktopModeTaskRepository, shellTaskOrganizer));
+    }
+
+
+    @WMSingleton
+    @Provides
     static DragToDesktopTransitionHandler provideDragToDesktopTransitionHandler(
             Context context,
             Transitions transitions,
-            RootTaskDisplayAreaOrganizer rootTaskDisplayAreaOrganizer) {
+            RootTaskDisplayAreaOrganizer rootTaskDisplayAreaOrganizer,
+            Optional<DesktopTasksLimiter> desktopTasksLimiter) {
         return new DragToDesktopTransitionHandler(context, transitions,
                 rootTaskDisplayAreaOrganizer);
     }
@@ -541,7 +559,8 @@
     @WMSingleton
     @Provides
     static EnterDesktopTaskTransitionHandler provideEnterDesktopModeTaskTransitionHandler(
-            Transitions transitions) {
+            Transitions transitions,
+            Optional<DesktopTasksLimiter> desktopTasksLimiter) {
         return new EnterDesktopTaskTransitionHandler(transitions);
     }
 
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeStatus.java b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeStatus.java
index 32c22c0..fcddcad 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeStatus.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeStatus.java
@@ -77,6 +77,22 @@
             "persist.wm.debug.desktop_mode_enforce_device_restrictions", true);
 
     /**
+     * Default value for {@code MAX_TASK_LIMIT}.
+     */
+    @VisibleForTesting
+    public static final int DEFAULT_MAX_TASK_LIMIT = 4;
+
+    // TODO(b/335131008): add a config-overlay field for the max number of tasks in Desktop Mode
+    /**
+     * Flag declaring the maximum number of Tasks to show in Desktop Mode at any one time.
+     *
+     * <p> The limit does NOT affect Picture-in-Picture, Bubbles, or System Modals (like a screen
+     * recording window, or Bluetooth pairing window).
+     */
+    private static final int MAX_TASK_LIMIT = SystemProperties.getInt(
+            "persist.wm.debug.desktop_max_task_limit", DEFAULT_MAX_TASK_LIMIT);
+
+    /**
      * Return {@code true} if desktop windowing is enabled
      */
     public static boolean isEnabled() {
@@ -124,6 +140,13 @@
     }
 
     /**
+     * Return the maximum limit on the number of Tasks to show in Desktop Mode at any one time.
+     */
+    static int getMaxTaskLimit() {
+        return MAX_TASK_LIMIT;
+    }
+
+    /**
      * Return {@code true} if the current device supports desktop mode.
      */
     @VisibleForTesting
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeTaskRepository.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeTaskRepository.kt
index 50cea01..2d508b2 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeTaskRepository.kt
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeTaskRepository.kt
@@ -47,6 +47,7 @@
          */
         val activeTasks: ArraySet<Int> = ArraySet(),
         val visibleTasks: ArraySet<Int> = ArraySet(),
+        val minimizedTasks: ArraySet<Int> = ArraySet(),
         var stashed: Boolean = false
     )
 
@@ -202,6 +203,13 @@
         }
     }
 
+    /** Return whether the given Task is minimized. */
+    fun isMinimizedTask(taskId: Int): Boolean {
+        return displayData.valueIterator().asSequence().any { data ->
+            data.minimizedTasks.contains(taskId)
+        }
+    }
+
     /**
      *  Check if a task with the given [taskId] is the only active task on its display
      */
@@ -219,6 +227,25 @@
     }
 
     /**
+     * Returns whether Desktop Mode is currently showing any tasks, i.e. whether any Desktop Tasks
+     * are visible.
+     */
+    fun isDesktopModeShowing(displayId: Int): Boolean = getVisibleTaskCount(displayId) > 0
+
+    /**
+     * Returns a list of Tasks IDs representing all active non-minimized Tasks on the given display,
+     * ordered from front to back.
+     */
+    fun getActiveNonMinimizedTasksOrderedFrontToBack(displayId: Int): List<Int> {
+        val activeTasks = getActiveTasks(displayId)
+        val allTasksInZOrder = getFreeformTasksInZOrder()
+        return activeTasks
+                // Don't show already minimized Tasks
+                .filter { taskId -> !isMinimizedTask(taskId) }
+                .sortedBy { taskId -> allTasksInZOrder.indexOf(taskId) }
+    }
+
+    /**
      * Get a list of freeform tasks, ordered from top-bottom (top at index 0).
      */
      // TODO(b/278084491): pass in display id
@@ -255,6 +282,7 @@
         val prevCount = getVisibleTaskCount(displayId)
         if (visible) {
             displayData.getOrCreate(displayId).visibleTasks.add(taskId)
+            unminimizeTask(displayId, taskId)
         } else {
             displayData[displayId]?.visibleTasks?.remove(taskId)
         }
@@ -312,6 +340,24 @@
         freeformTasksInZOrder.add(0, taskId)
     }
 
+    /** Mark a Task as minimized. */
+    fun minimizeTask(displayId: Int, taskId: Int) {
+        KtProtoLog.v(
+                WM_SHELL_DESKTOP_MODE,
+                "DesktopModeTaskRepository: minimize Task: display=%d, task=%d",
+                displayId, taskId)
+        displayData.getOrCreate(displayId).minimizedTasks.add(taskId)
+    }
+
+    /** Mark a Task as non-minimized. */
+    fun unminimizeTask(displayId: Int, taskId: Int) {
+        KtProtoLog.v(
+                WM_SHELL_DESKTOP_MODE,
+                "DesktopModeTaskRepository: unminimize Task: display=%d, task=%d",
+                displayId, taskId)
+        displayData[displayId]?.minimizedTasks?.remove(taskId)
+    }
+
     /**
      * Remove the task from the ordered list.
      */
@@ -325,7 +371,7 @@
         boundsBeforeMaximizeByTaskId.remove(taskId)
         KtProtoLog.d(
             WM_SHELL_DESKTOP_MODE,
-            "DesktopTaskRepo: remaining freeform tasks: " + freeformTasksInZOrder.toDumpString()
+            "DesktopTaskRepo: remaining freeform tasks: %s", freeformTasksInZOrder.toDumpString(),
         )
     }
 
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksController.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksController.kt
index 068661a..0a9e5d0 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksController.kt
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksController.kt
@@ -88,6 +88,7 @@
 import com.android.wm.shell.windowdecor.extension.isFreeform
 import com.android.wm.shell.windowdecor.extension.isFullscreen
 import java.io.PrintWriter
+import java.util.Optional
 import java.util.concurrent.Executor
 import java.util.function.Consumer
 
@@ -113,7 +114,8 @@
         private val launchAdjacentController: LaunchAdjacentController,
         private val recentsTransitionHandler: RecentsTransitionHandler,
         private val multiInstanceHelper: MultiInstanceHelper,
-        @ShellMainThread private val mainExecutor: ShellExecutor
+        @ShellMainThread private val mainExecutor: ShellExecutor,
+        private val desktopTasksLimiter: Optional<DesktopTasksLimiter>,
 ) : RemoteCallable<DesktopTasksController>, Transitions.TransitionHandler,
     DragAndDropController.DragAndDropListener {
 
@@ -341,11 +343,13 @@
         )
         exitSplitIfApplicable(wct, task)
         // Bring other apps to front first
-        bringDesktopAppsToFront(task.displayId, wct)
+        val taskToMinimize =
+                bringDesktopAppsToFrontBeforeShowingNewTask(task.displayId, wct, task.taskId)
         addMoveToDesktopChanges(wct, task)
 
         if (Transitions.ENABLE_SHELL_TRANSITIONS) {
-            enterDesktopTaskTransitionHandler.moveToDesktop(wct)
+            val transition = enterDesktopTaskTransitionHandler.moveToDesktop(wct)
+            addPendingMinimizeTransition(transition, taskToMinimize)
         } else {
             shellTaskOrganizer.applyTransaction(wct)
         }
@@ -382,10 +386,14 @@
         )
         val wct = WindowContainerTransaction()
         exitSplitIfApplicable(wct, taskInfo)
-        bringDesktopAppsToFront(taskInfo.displayId, wct)
+        moveHomeTaskToFront(wct)
+        val taskToMinimize =
+                bringDesktopAppsToFrontBeforeShowingNewTask(
+                        taskInfo.displayId, wct, taskInfo.taskId)
         addMoveToDesktopChanges(wct, taskInfo)
         wct.setBounds(taskInfo.token, freeformBounds)
-        dragToDesktopTransitionHandler.finishDragToDesktopTransition(wct)
+        val transition = dragToDesktopTransitionHandler.finishDragToDesktopTransition(wct)
+        transition?.let { addPendingMinimizeTransition(it, taskToMinimize) }
     }
 
     /**
@@ -507,8 +515,10 @@
 
         val wct = WindowContainerTransaction()
         wct.reorder(taskInfo.token, true)
+        val taskToMinimize = addAndGetMinimizeChangesIfNeeded(taskInfo.displayId, wct, taskInfo)
         if (Transitions.ENABLE_SHELL_TRANSITIONS) {
-            transitions.startTransition(TRANSIT_TO_FRONT, wct, null /* handler */)
+            val transition = transitions.startTransition(TRANSIT_TO_FRONT, wct, null /* handler */)
+            addPendingMinimizeTransition(transition, taskToMinimize)
         } else {
             shellTaskOrganizer.applyTransaction(wct)
         }
@@ -688,9 +698,20 @@
             ?: WINDOWING_MODE_UNDEFINED
     }
 
-    private fun bringDesktopAppsToFront(displayId: Int, wct: WindowContainerTransaction) {
-        KtProtoLog.v(WM_SHELL_DESKTOP_MODE, "DesktopTasksController: bringDesktopAppsToFront")
-        val activeTasks = desktopModeTaskRepository.getActiveTasks(displayId)
+    private fun bringDesktopAppsToFrontBeforeShowingNewTask(
+            displayId: Int,
+            wct: WindowContainerTransaction,
+            newTaskIdInFront: Int
+    ): RunningTaskInfo? = bringDesktopAppsToFront(displayId, wct, newTaskIdInFront)
+
+    private fun bringDesktopAppsToFront(
+            displayId: Int,
+            wct: WindowContainerTransaction,
+            newTaskIdInFront: Int? = null
+    ): RunningTaskInfo? {
+        KtProtoLog.v(WM_SHELL_DESKTOP_MODE,
+                "DesktopTasksController: bringDesktopAppsToFront, newTaskIdInFront=%s",
+                newTaskIdInFront ?: "null")
 
         if (Flags.enableDesktopWindowingWallpaperActivity()) {
             // Add translucent wallpaper activity to show the wallpaper underneath
@@ -700,13 +721,21 @@
             moveHomeTaskToFront(wct)
         }
 
-        // Then move other tasks on top of it
-        val allTasksInZOrder = desktopModeTaskRepository.getFreeformTasksInZOrder()
-        activeTasks
-            // Sort descending as the top task is at index 0. It should be ordered to top last
-            .sortedByDescending { taskId -> allTasksInZOrder.indexOf(taskId) }
-            .mapNotNull { taskId -> shellTaskOrganizer.getRunningTaskInfo(taskId) }
-            .forEach { task -> wct.reorder(task.token, true /* onTop */) }
+        val nonMinimizedTasksOrderedFrontToBack =
+                desktopModeTaskRepository.getActiveNonMinimizedTasksOrderedFrontToBack(displayId)
+        // If we're adding a new Task we might need to minimize an old one
+        val taskToMinimize: RunningTaskInfo? =
+                if (newTaskIdInFront != null && desktopTasksLimiter.isPresent) {
+                    desktopTasksLimiter.get().getTaskToMinimizeIfNeeded(
+                            nonMinimizedTasksOrderedFrontToBack, newTaskIdInFront)
+                } else { null }
+        nonMinimizedTasksOrderedFrontToBack
+                // If there is a Task to minimize, let it stay behind the Home Task
+                .filter { taskId -> taskId != taskToMinimize?.taskId }
+                .mapNotNull { taskId -> shellTaskOrganizer.getRunningTaskInfo(taskId) }
+                .reversed() // Start from the back so the front task is brought forward last
+                .forEach { task -> wct.reorder(task.token, true /* onTop */) }
+        return taskToMinimize
     }
 
     private fun moveHomeTaskToFront(wct: WindowContainerTransaction) {
@@ -824,13 +853,13 @@
             when {
                 request.type == TRANSIT_TO_BACK -> handleBackNavigation(task)
                 // If display has tasks stashed, handle as stashed launch
-                task.isStashed -> handleStashedTaskLaunch(task)
+                task.isStashed -> handleStashedTaskLaunch(task, transition)
                 // Check if the task has a top transparent activity
                 shouldLaunchAsModal(task) -> handleTransparentTaskLaunch(task)
                 // Check if fullscreen task should be updated
-                task.isFullscreen -> handleFullscreenTaskLaunch(task)
+                task.isFullscreen -> handleFullscreenTaskLaunch(task, transition)
                 // Check if freeform task should be updated
-                task.isFreeform -> handleFreeformTaskLaunch(task)
+                task.isFreeform -> handleFreeformTaskLaunch(task, transition)
                 else -> {
                     null
                 }
@@ -878,10 +907,12 @@
                 } ?: false
     }
 
-    private fun handleFreeformTaskLaunch(task: RunningTaskInfo): WindowContainerTransaction? {
+    private fun handleFreeformTaskLaunch(
+            task: RunningTaskInfo,
+            transition: IBinder
+    ): WindowContainerTransaction? {
         KtProtoLog.v(WM_SHELL_DESKTOP_MODE, "DesktopTasksController: handleFreeformTaskLaunch")
-        val activeTasks = desktopModeTaskRepository.getActiveTasks(task.displayId)
-        if (activeTasks.none { desktopModeTaskRepository.isVisibleTask(it) }) {
+        if (!desktopModeTaskRepository.isDesktopModeShowing(task.displayId)) {
             KtProtoLog.d(
                     WM_SHELL_DESKTOP_MODE,
                     "DesktopTasksController: switch freeform task to fullscreen oon transition" +
@@ -892,13 +923,23 @@
                 addMoveToFullscreenChanges(wct, task)
             }
         }
+        // Desktop Mode is showing and we're launching a new Task - we might need to minimize
+        // a Task.
+        val wct = WindowContainerTransaction()
+        val taskToMinimize = addAndGetMinimizeChangesIfNeeded(task.displayId, wct, task)
+        if (taskToMinimize != null) {
+            addPendingMinimizeTransition(transition, taskToMinimize)
+            return wct
+        }
         return null
     }
 
-    private fun handleFullscreenTaskLaunch(task: RunningTaskInfo): WindowContainerTransaction? {
+    private fun handleFullscreenTaskLaunch(
+            task: RunningTaskInfo,
+            transition: IBinder
+    ): WindowContainerTransaction? {
         KtProtoLog.v(WM_SHELL_DESKTOP_MODE, "DesktopTasksController: handleFullscreenTaskLaunch")
-        val activeTasks = desktopModeTaskRepository.getActiveTasks(task.displayId)
-        if (activeTasks.any { desktopModeTaskRepository.isVisibleTask(it) }) {
+        if (desktopModeTaskRepository.isDesktopModeShowing(task.displayId)) {
             KtProtoLog.d(
                     WM_SHELL_DESKTOP_MODE,
                     "DesktopTasksController: switch fullscreen task to freeform on transition" +
@@ -907,21 +948,30 @@
             )
             return WindowContainerTransaction().also { wct ->
                 addMoveToDesktopChanges(wct, task)
+                // Desktop Mode is already showing and we're launching a new Task - we might need to
+                // minimize another Task.
+                val taskToMinimize = addAndGetMinimizeChangesIfNeeded(task.displayId, wct, task)
+                addPendingMinimizeTransition(transition, taskToMinimize)
             }
         }
         return null
     }
 
-    private fun handleStashedTaskLaunch(task: RunningTaskInfo): WindowContainerTransaction {
+    private fun handleStashedTaskLaunch(
+            task: RunningTaskInfo,
+            transition: IBinder
+    ): WindowContainerTransaction {
         KtProtoLog.d(
                 WM_SHELL_DESKTOP_MODE,
                 "DesktopTasksController: launch apps with stashed on transition taskId=%d",
                 task.taskId
         )
         val wct = WindowContainerTransaction()
-        bringDesktopAppsToFront(task.displayId, wct)
+        val taskToMinimize =
+                bringDesktopAppsToFrontBeforeShowingNewTask(task.displayId, wct, task.taskId)
         addMoveToDesktopChanges(wct, task)
         desktopModeTaskRepository.setStashed(task.displayId, false)
+        addPendingMinimizeTransition(transition, taskToMinimize)
         return wct
     }
 
@@ -1002,6 +1052,28 @@
         wct.setDensityDpi(taskInfo.token, getDefaultDensityDpi())
     }
 
+    /** Returns the ID of the Task that will be minimized, or null if no task will be minimized. */
+    private fun addAndGetMinimizeChangesIfNeeded(
+            displayId: Int,
+            wct: WindowContainerTransaction,
+            newTaskInfo: RunningTaskInfo
+    ): RunningTaskInfo? {
+        if (!desktopTasksLimiter.isPresent) return null
+        return desktopTasksLimiter.get().addAndGetMinimizeTaskChangesIfNeeded(
+                displayId, wct, newTaskInfo)
+    }
+
+    private fun addPendingMinimizeTransition(
+            transition: IBinder,
+            taskToMinimize: RunningTaskInfo?
+    ) {
+        if (taskToMinimize == null) return
+        desktopTasksLimiter.ifPresent {
+            it.addPendingMinimizeChange(
+                    transition, taskToMinimize.displayId, taskToMinimize.taskId)
+        }
+    }
+
     /** Enter split by using the focused desktop task in given `displayId`. */
     fun enterSplit(
         displayId: Int,
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksLimiter.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksLimiter.kt
new file mode 100644
index 0000000..3404d37
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksLimiter.kt
@@ -0,0 +1,217 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.wm.shell.desktopmode
+
+import android.app.ActivityManager.RunningTaskInfo
+import android.os.IBinder
+import android.view.SurfaceControl
+import android.view.WindowManager.TRANSIT_TO_BACK
+import android.window.TransitionInfo
+import android.window.WindowContainerTransaction
+import androidx.annotation.VisibleForTesting
+import com.android.wm.shell.ShellTaskOrganizer
+import com.android.wm.shell.protolog.ShellProtoLogGroup
+import com.android.wm.shell.transition.Transitions
+import com.android.wm.shell.transition.Transitions.TransitionObserver
+import com.android.wm.shell.util.KtProtoLog
+
+/**
+ * Limits the number of tasks shown in Desktop Mode.
+ *
+ * This class should only be used if
+ * [com.android.window.flags.Flags.enableDesktopWindowingTaskLimit()] is true.
+ */
+class DesktopTasksLimiter (
+        transitions: Transitions,
+        private val taskRepository: DesktopModeTaskRepository,
+        private val shellTaskOrganizer: ShellTaskOrganizer,
+) {
+    private val minimizeTransitionObserver = MinimizeTransitionObserver()
+
+    init {
+        transitions.registerObserver(minimizeTransitionObserver)
+    }
+
+    private data class TaskDetails (val displayId: Int, val taskId: Int)
+
+    // TODO(b/333018485): replace this observer when implementing the minimize-animation
+    private inner class MinimizeTransitionObserver : TransitionObserver {
+        private val mPendingTransitionTokensAndTasks = mutableMapOf<IBinder, TaskDetails>()
+
+        fun addPendingTransitionToken(transition: IBinder, taskDetails: TaskDetails) {
+            mPendingTransitionTokensAndTasks[transition] = taskDetails
+        }
+
+        override fun onTransitionReady(
+                transition: IBinder,
+                info: TransitionInfo,
+                startTransaction: SurfaceControl.Transaction,
+                finishTransaction: SurfaceControl.Transaction
+        ) {
+            val taskToMinimize = mPendingTransitionTokensAndTasks.remove(transition) ?: return
+
+            if (!taskRepository.isActiveTask(taskToMinimize.taskId)) return
+
+            if (!isTaskReorderedToBackOrInvisible(info, taskToMinimize)) {
+                KtProtoLog.v(
+                        ShellProtoLogGroup.WM_SHELL_DESKTOP_MODE,
+                        "DesktopTasksLimiter: task %d is not reordered to back nor invis",
+                        taskToMinimize.taskId)
+                return
+            }
+            this@DesktopTasksLimiter.markTaskMinimized(
+                    taskToMinimize.displayId, taskToMinimize.taskId)
+        }
+
+        /**
+         * Returns whether the given Task is being reordered to the back in the given transition, or
+         * is already invisible.
+         *
+         * <p> This check can be used to double-check that a task was indeed minimized before
+         * marking it as such.
+         */
+        private fun isTaskReorderedToBackOrInvisible(
+                info: TransitionInfo,
+                taskDetails: TaskDetails
+        ): Boolean {
+            val taskChange = info.changes.find { change ->
+                change.taskInfo?.taskId == taskDetails.taskId }
+            if (taskChange == null) {
+                return !taskRepository.isVisibleTask(taskDetails.taskId)
+            }
+            return taskChange.mode == TRANSIT_TO_BACK
+        }
+
+        override fun onTransitionStarting(transition: IBinder) {}
+
+        override fun onTransitionMerged(merged: IBinder, playing: IBinder) {
+            mPendingTransitionTokensAndTasks.remove(merged)?.let { taskToTransfer ->
+                mPendingTransitionTokensAndTasks[playing] = taskToTransfer
+            }
+        }
+
+        override fun onTransitionFinished(transition: IBinder, aborted: Boolean) {
+            KtProtoLog.v(
+                    ShellProtoLogGroup.WM_SHELL_DESKTOP_MODE,
+                    "DesktopTasksLimiter: transition %s finished", transition)
+            mPendingTransitionTokensAndTasks.remove(transition)
+        }
+    }
+
+    /**
+     * Mark a task as minimized, this should only be done after the corresponding transition has
+     * finished so we don't minimize the task if the transition fails.
+     */
+    private fun markTaskMinimized(displayId: Int, taskId: Int) {
+        KtProtoLog.v(
+                ShellProtoLogGroup.WM_SHELL_DESKTOP_MODE,
+                "DesktopTasksLimiter: marking %d as minimized", taskId)
+        taskRepository.minimizeTask(displayId, taskId)
+    }
+
+    /**
+     * Add a minimize-transition to [wct] if adding [newFrontTaskInfo] brings us over the task
+     * limit.
+     *
+     * @param transition the transition that the minimize-transition will be appended to, or null if
+     * the transition will be started later.
+     * @return the ID of the minimized task, or null if no task is being minimized.
+     */
+    fun addAndGetMinimizeTaskChangesIfNeeded(
+            displayId: Int,
+            wct: WindowContainerTransaction,
+            newFrontTaskInfo: RunningTaskInfo,
+    ): RunningTaskInfo? {
+        KtProtoLog.v(
+                ShellProtoLogGroup.WM_SHELL_DESKTOP_MODE,
+                "DesktopTasksLimiter: addMinimizeBackTaskChangesIfNeeded, newFrontTask=%d",
+                newFrontTaskInfo.taskId)
+        val newTaskListOrderedFrontToBack = createOrderedTaskListWithGivenTaskInFront(
+                taskRepository.getActiveNonMinimizedTasksOrderedFrontToBack(displayId),
+                newFrontTaskInfo.taskId)
+        val taskToMinimize = getTaskToMinimizeIfNeeded(newTaskListOrderedFrontToBack)
+        if (taskToMinimize != null) {
+            wct.reorder(taskToMinimize.token, false /* onTop */)
+            return taskToMinimize
+        }
+        return null
+    }
+
+    /**
+     * Add a pending minimize transition change, to update the list of minimized apps once the
+     * transition goes through.
+     */
+    fun addPendingMinimizeChange(transition: IBinder, displayId: Int, taskId: Int) {
+        minimizeTransitionObserver.addPendingTransitionToken(
+                transition, TaskDetails(displayId, taskId))
+    }
+
+    /**
+     * Returns the maximum number of tasks that should ever be displayed at the same time in Desktop
+     * Mode.
+     */
+    fun getMaxTaskLimit(): Int = DesktopModeStatus.getMaxTaskLimit()
+
+    /**
+     * Returns the Task to minimize given 1. a list of visible tasks ordered from front to back and
+     * 2. a new task placed in front of all the others.
+     */
+    fun getTaskToMinimizeIfNeeded(
+            visibleFreeformTaskIdsOrderedFrontToBack: List<Int>,
+            newTaskIdInFront: Int
+    ): RunningTaskInfo? {
+        return getTaskToMinimizeIfNeeded(
+                createOrderedTaskListWithGivenTaskInFront(
+                        visibleFreeformTaskIdsOrderedFrontToBack, newTaskIdInFront))
+    }
+
+    /** Returns the Task to minimize given a list of visible tasks ordered from front to back. */
+    fun getTaskToMinimizeIfNeeded(
+            visibleFreeformTaskIdsOrderedFrontToBack: List<Int>
+    ): RunningTaskInfo? {
+        if (visibleFreeformTaskIdsOrderedFrontToBack.size <= getMaxTaskLimit()) {
+            KtProtoLog.v(
+                    ShellProtoLogGroup.WM_SHELL_DESKTOP_MODE,
+                    "DesktopTasksLimiter: no need to minimize; tasks below limit")
+            // No need to minimize anything
+            return null
+        }
+        val taskToMinimize =
+                shellTaskOrganizer.getRunningTaskInfo(
+                        visibleFreeformTaskIdsOrderedFrontToBack.last())
+        if (taskToMinimize == null) {
+            KtProtoLog.e(
+                    ShellProtoLogGroup.WM_SHELL_DESKTOP_MODE,
+                    "DesktopTasksLimiter: taskToMinimize == null")
+            return null
+        }
+        return taskToMinimize
+    }
+
+    private fun createOrderedTaskListWithGivenTaskInFront(
+            existingTaskIdsOrderedFrontToBack: List<Int>,
+            newTaskId: Int
+    ): List<Int> {
+        return listOf(newTaskId) +
+                existingTaskIdsOrderedFrontToBack.filter { taskId -> taskId != newTaskId }
+    }
+
+    @VisibleForTesting
+    fun getTransitionObserver(): TransitionObserver {
+        return minimizeTransitionObserver
+    }
+}
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DragToDesktopTransitionHandler.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DragToDesktopTransitionHandler.kt
index 0061d03..e341f2d 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DragToDesktopTransitionHandler.kt
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DragToDesktopTransitionHandler.kt
@@ -150,20 +150,20 @@
      * windowing mode changes to the dragged task. This is called when the dragged task is released
      * inside the desktop drop zone.
      */
-    fun finishDragToDesktopTransition(wct: WindowContainerTransaction) {
+    fun finishDragToDesktopTransition(wct: WindowContainerTransaction): IBinder? {
         if (!inProgress) {
             // Don't attempt to finish a drag to desktop transition since there is no transition in
             // progress which means that the drag to desktop transition was never successfully
             // started.
-            return
+            return null
         }
         if (requireTransitionState().startAborted) {
             // Don't attempt to complete the drag-to-desktop since the start transition didn't
             // succeed as expected. Just reset the state as if nothing happened.
             clearState()
-            return
+            return null
         }
-        transitions.startTransition(TRANSIT_DESKTOP_MODE_END_DRAG_TO_DESKTOP, wct, this)
+        return transitions.startTransition(TRANSIT_DESKTOP_MODE_END_DRAG_TO_DESKTOP, wct, this)
     }
 
     /**
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/EnterDesktopTaskTransitionHandler.java b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/EnterDesktopTaskTransitionHandler.java
index 79bb540..74b8f83 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/EnterDesktopTaskTransitionHandler.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/EnterDesktopTaskTransitionHandler.java
@@ -78,10 +78,12 @@
     /**
      * Starts Transition of type TRANSIT_MOVE_TO_DESKTOP
      * @param wct WindowContainerTransaction for transition
+     * @return the token representing the started transition
      */
-    public void moveToDesktop(@NonNull WindowContainerTransaction wct) {
+    public IBinder moveToDesktop(@NonNull WindowContainerTransaction wct) {
         final IBinder token = mTransitions.startTransition(TRANSIT_MOVE_TO_DESKTOP, wct, this);
         mPendingTransitionTokens.add(token);
+        return token;
     }
 
     @Override
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/freeform/FreeformTaskListener.java b/libs/WindowManager/Shell/src/com/android/wm/shell/freeform/FreeformTaskListener.java
index f2bdcae..6fea203 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/freeform/FreeformTaskListener.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/freeform/FreeformTaskListener.java
@@ -95,6 +95,7 @@
         if (DesktopModeStatus.isEnabled()) {
             mDesktopModeTaskRepository.ifPresent(repository -> {
                 repository.addOrMoveFreeformTaskToTop(taskInfo.taskId);
+                repository.unminimizeTask(taskInfo.displayId, taskInfo.taskId);
                 if (taskInfo.isVisible) {
                     if (repository.addActiveTask(taskInfo.displayId, taskInfo.taskId)) {
                         ProtoLog.v(ShellProtoLogGroup.WM_SHELL_DESKTOP_MODE,
@@ -116,6 +117,7 @@
         if (DesktopModeStatus.isEnabled()) {
             mDesktopModeTaskRepository.ifPresent(repository -> {
                 repository.removeFreeformTask(taskInfo.taskId);
+                repository.unminimizeTask(taskInfo.displayId, taskInfo.taskId);
                 if (repository.removeActiveTask(taskInfo.taskId)) {
                     ProtoLog.v(ShellProtoLogGroup.WM_SHELL_DESKTOP_MODE,
                             "Removing active freeform task: #%d", taskInfo.taskId);
@@ -162,6 +164,7 @@
         if (DesktopModeStatus.isEnabled() && taskInfo.isFocused) {
             mDesktopModeTaskRepository.ifPresent(repository -> {
                 repository.addOrMoveFreeformTaskToTop(taskInfo.taskId);
+                repository.unminimizeTask(taskInfo.displayId, taskInfo.taskId);
             });
         }
     }
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopModeTaskRepositoryTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopModeTaskRepositoryTest.kt
index b2b54ac..dca7be1 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopModeTaskRepositoryTest.kt
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopModeTaskRepositoryTest.kt
@@ -483,6 +483,102 @@
         assertThat(repo.removeBoundsBeforeMaximize(taskId)).isNull()
     }
 
+    @Test
+    fun minimizeTaskNotCalled_noTasksMinimized() {
+        assertThat(repo.isMinimizedTask(taskId = 0)).isFalse()
+        assertThat(repo.isMinimizedTask(taskId = 1)).isFalse()
+    }
+
+    @Test
+    fun minimizeTask_onlyThatTaskIsMinimized() {
+        repo.minimizeTask(displayId = 0, taskId = 0)
+
+        assertThat(repo.isMinimizedTask(taskId = 0)).isTrue()
+        assertThat(repo.isMinimizedTask(taskId = 1)).isFalse()
+        assertThat(repo.isMinimizedTask(taskId = 2)).isFalse()
+    }
+
+    @Test
+    fun unminimizeTask_taskNoLongerMinimized() {
+        repo.minimizeTask(displayId = 0, taskId = 0)
+        repo.unminimizeTask(displayId = 0, taskId = 0)
+
+        assertThat(repo.isMinimizedTask(taskId = 0)).isFalse()
+        assertThat(repo.isMinimizedTask(taskId = 1)).isFalse()
+        assertThat(repo.isMinimizedTask(taskId = 2)).isFalse()
+    }
+
+    @Test
+    fun unminimizeTask_nonExistentTask_doesntCrash() {
+        repo.unminimizeTask(displayId = 0, taskId = 0)
+
+        assertThat(repo.isMinimizedTask(taskId = 0)).isFalse()
+        assertThat(repo.isMinimizedTask(taskId = 1)).isFalse()
+        assertThat(repo.isMinimizedTask(taskId = 2)).isFalse()
+    }
+
+
+    @Test
+    fun updateVisibleFreeformTasks_toVisible_taskIsUnminimized() {
+        repo.minimizeTask(displayId = 10, taskId = 2)
+
+        repo.updateVisibleFreeformTasks(displayId = 10, taskId = 2, visible = true)
+
+        assertThat(repo.isMinimizedTask(taskId = 2)).isFalse()
+    }
+
+    @Test
+    fun isDesktopModeShowing_noActiveTasks_returnsFalse() {
+        assertThat(repo.isDesktopModeShowing(displayId = 0)).isFalse()
+    }
+
+    @Test
+    fun isDesktopModeShowing_noTasksVisible_returnsFalse() {
+        repo.addActiveTask(displayId = 0, taskId = 1)
+        repo.addActiveTask(displayId = 0, taskId = 2)
+
+        assertThat(repo.isDesktopModeShowing(displayId = 0)).isFalse()
+    }
+
+    @Test
+    fun isDesktopModeShowing_tasksActiveAndVisible_returnsTrue() {
+        repo.addActiveTask(displayId = 0, taskId = 1)
+        repo.addActiveTask(displayId = 0, taskId = 2)
+        repo.updateVisibleFreeformTasks(displayId = 0, taskId = 1, visible = true)
+
+        assertThat(repo.isDesktopModeShowing(displayId = 0)).isTrue()
+    }
+
+    @Test
+    fun getActiveNonMinimizedTasksOrderedFrontToBack_returnsFreeformTasksInCorrectOrder() {
+        repo.addActiveTask(displayId = 0, taskId = 1)
+        repo.addActiveTask(displayId = 0, taskId = 2)
+        repo.addActiveTask(displayId = 0, taskId = 3)
+        // The front-most task will be the one added last through addOrMoveFreeformTaskToTop
+        repo.addOrMoveFreeformTaskToTop(taskId = 3)
+        repo.addOrMoveFreeformTaskToTop(taskId = 2)
+        repo.addOrMoveFreeformTaskToTop(taskId = 1)
+
+        assertThat(repo.getActiveNonMinimizedTasksOrderedFrontToBack(displayId = 0)).isEqualTo(
+                listOf(1, 2, 3))
+    }
+
+    @Test
+    fun getActiveNonMinimizedTasksOrderedFrontToBack_minimizedTaskNotIncluded() {
+        repo.addActiveTask(displayId = 0, taskId = 1)
+        repo.addActiveTask(displayId = 0, taskId = 2)
+        repo.addActiveTask(displayId = 0, taskId = 3)
+        // The front-most task will be the one added last through addOrMoveFreeformTaskToTop
+        repo.addOrMoveFreeformTaskToTop(taskId = 3)
+        repo.addOrMoveFreeformTaskToTop(taskId = 2)
+        repo.addOrMoveFreeformTaskToTop(taskId = 1)
+        repo.minimizeTask(displayId = 0, taskId = 2)
+
+        assertThat(repo.getActiveNonMinimizedTasksOrderedFrontToBack(displayId = 0)).isEqualTo(
+                listOf(1, 3))
+    }
+
+
     class TestListener : DesktopModeTaskRepository.ActiveTasksListener {
         var activeChangesOnDefaultDisplay = 0
         var activeChangesOnSecondaryDisplay = 0
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksControllerTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksControllerTest.kt
index 64f6041..ad4b720 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksControllerTest.kt
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksControllerTest.kt
@@ -105,6 +105,7 @@
 import org.mockito.kotlin.atLeastOnce
 import org.mockito.kotlin.capture
 import org.mockito.quality.Strictness
+import java.util.Optional
 import org.mockito.Mockito.`when` as whenever
 
 /**
@@ -145,6 +146,7 @@
     private lateinit var controller: DesktopTasksController
     private lateinit var shellInit: ShellInit
     private lateinit var desktopModeTaskRepository: DesktopModeTaskRepository
+    private lateinit var desktopTasksLimiter: DesktopTasksLimiter
     private lateinit var recentsTransitionStateListener: RecentsTransitionStateListener
 
     private val shellExecutor = TestShellExecutor()
@@ -160,9 +162,12 @@
 
         shellInit = Mockito.spy(ShellInit(testExecutor))
         desktopModeTaskRepository = DesktopModeTaskRepository()
+        desktopTasksLimiter =
+                DesktopTasksLimiter(transitions, desktopModeTaskRepository, shellTaskOrganizer)
 
         whenever(shellTaskOrganizer.getRunningTasks(anyInt())).thenAnswer { runningTasks }
         whenever(transitions.startTransition(anyInt(), any(), isNull())).thenAnswer { Binder() }
+        whenever(enterDesktopTransitionHandler.moveToDesktop(any())).thenAnswer { Binder() }
         whenever(displayController.getDisplayLayout(anyInt())).thenReturn(displayLayout)
         whenever(displayLayout.getStableBounds(any())).thenAnswer { i ->
                 (i.arguments.first() as Rect).set(STABLE_BOUNDS)
@@ -203,7 +208,8 @@
             launchAdjacentController,
             recentsTransitionHandler,
             multiInstanceHelper,
-            shellExecutor
+            shellExecutor,
+            Optional.of(desktopTasksLimiter),
         )
     }
 
@@ -409,6 +415,25 @@
     }
 
     @Test
+    fun showDesktopApps_dontReorderMinimizedTask() {
+        val homeTask = setUpHomeTask()
+        val freeformTask = setUpFreeformTask()
+        val minimizedTask = setUpFreeformTask()
+        markTaskHidden(freeformTask)
+        markTaskHidden(minimizedTask)
+        desktopModeTaskRepository.minimizeTask(DEFAULT_DISPLAY, minimizedTask.taskId)
+
+        controller.showDesktopApps(DEFAULT_DISPLAY, RemoteTransition(TestRemoteTransition()))
+
+        val wct = getLatestWct(
+                type = TRANSIT_TO_FRONT, handlerClass = OneShotRemoteHandler::class.java)
+        assertThat(wct.hierarchyOps).hasSize(2)
+        // Reorder home and freeform task to top, don't reorder the minimized task
+        wct.assertReorderAt(index = 0, homeTask, toTop = true)
+        wct.assertReorderAt(index = 1, freeformTask, toTop = true)
+    }
+
+    @Test
     fun getVisibleTaskCount_noTasks_returnsZero() {
         assertThat(controller.getVisibleTaskCount(DEFAULT_DISPLAY)).isEqualTo(0)
     }
@@ -606,6 +631,24 @@
     }
 
     @Test
+    fun moveToDesktop_bringsTasksOverLimit_dontShowBackTask() {
+        val taskLimit = desktopTasksLimiter.getMaxTaskLimit()
+        val homeTask = setUpHomeTask()
+        val freeformTasks = (1..taskLimit).map { _ -> setUpFreeformTask() }
+        val newTask = setUpFullscreenTask()
+
+        controller.moveToDesktop(newTask)
+
+        val wct = getLatestMoveToDesktopWct()
+        assertThat(wct.hierarchyOps.size).isEqualTo(taskLimit + 1) // visible tasks + home
+        wct.assertReorderAt(0, homeTask)
+        for (i in 1..<taskLimit) { // Skipping freeformTasks[0]
+            wct.assertReorderAt(index = i, task = freeformTasks[i])
+        }
+        wct.assertReorderAt(taskLimit, newTask)
+    }
+
+    @Test
     fun moveToFullscreen_tdaFullscreen_windowingModeSetToUndefined() {
         val task = setUpFreeformTask()
         val tda = rootTaskDisplayAreaOrganizer.getDisplayAreaInfo(DEFAULT_DISPLAY)!!
@@ -659,6 +702,20 @@
     }
 
     @Test
+    fun moveTaskToFront_bringsTasksOverLimit_minimizesBackTask() {
+        val taskLimit = desktopTasksLimiter.getMaxTaskLimit()
+        setUpHomeTask()
+        val freeformTasks = (1..taskLimit + 1).map { _ -> setUpFreeformTask() }
+
+        controller.moveTaskToFront(freeformTasks[0])
+
+        val wct = getLatestWct(type = TRANSIT_TO_FRONT)
+        assertThat(wct.hierarchyOps.size).isEqualTo(2) // move-to-front + minimize
+        wct.assertReorderAt(0, freeformTasks[0], toTop = true)
+        wct.assertReorderAt(1, freeformTasks[1], toTop = false)
+    }
+
+    @Test
     fun moveToNextDisplay_noOtherDisplays() {
         whenever(rootTaskDisplayAreaOrganizer.displayIds).thenReturn(intArrayOf(DEFAULT_DISPLAY))
         val task = setUpFreeformTask(displayId = DEFAULT_DISPLAY)
@@ -777,6 +834,38 @@
     }
 
     @Test
+    fun handleRequest_fullscreenTaskToFreeform_underTaskLimit_dontMinimize() {
+        assumeTrue(ENABLE_SHELL_TRANSITIONS)
+
+        val freeformTask = setUpFreeformTask()
+        markTaskVisible(freeformTask)
+        val fullscreenTask = createFullscreenTask()
+
+        val wct = controller.handleRequest(Binder(), createTransition(fullscreenTask))
+
+        // Make sure we only reorder the new task to top (we don't reorder the old task to bottom)
+        assertThat(wct?.hierarchyOps?.size).isEqualTo(1)
+        wct!!.assertReorderAt(0, fullscreenTask, toTop = true)
+    }
+
+    @Test
+    fun handleRequest_fullscreenTaskToFreeform_bringsTasksOverLimit_otherTaskIsMinimized() {
+        assumeTrue(ENABLE_SHELL_TRANSITIONS)
+
+        val taskLimit = desktopTasksLimiter.getMaxTaskLimit()
+        val freeformTasks = (1..taskLimit).map { _ -> setUpFreeformTask() }
+        freeformTasks.forEach { markTaskVisible(it) }
+        val fullscreenTask = createFullscreenTask()
+
+        val wct = controller.handleRequest(Binder(), createTransition(fullscreenTask))
+
+        // Make sure we reorder the new task to top, and the back task to the bottom
+        assertThat(wct!!.hierarchyOps.size).isEqualTo(2)
+        wct!!.assertReorderAt(0, fullscreenTask, toTop = true)
+        wct!!.assertReorderAt(1, freeformTasks[0], toTop = false)
+    }
+
+    @Test
     fun handleRequest_fullscreenTask_freeformNotVisible_returnNull() {
         assumeTrue(ENABLE_SHELL_TRANSITIONS)
 
@@ -841,6 +930,22 @@
     }
 
     @Test
+    fun handleRequest_freeformTask_freeformVisible_aboveTaskLimit_minimize() {
+        assumeTrue(ENABLE_SHELL_TRANSITIONS)
+
+        val taskLimit = desktopTasksLimiter.getMaxTaskLimit()
+        val freeformTasks = (1..taskLimit).map { _ -> setUpFreeformTask() }
+        freeformTasks.forEach { markTaskVisible(it) }
+        val newFreeformTask = createFreeformTask()
+
+        val wct =
+                controller.handleRequest(Binder(), createTransition(newFreeformTask, TRANSIT_OPEN))
+
+        assertThat(wct?.hierarchyOps?.size).isEqualTo(1)
+        wct!!.assertReorderAt(0, freeformTasks[0], toTop = false) // Reorder to the bottom
+    }
+
+    @Test
     fun handleRequest_freeformTask_freeformNotVisible_returnSwitchToFullscreenWCT() {
         assumeTrue(ENABLE_SHELL_TRANSITIONS)
 
@@ -1352,11 +1457,16 @@
         .isGreaterThan(index)
 }
 
-private fun WindowContainerTransaction.assertReorderAt(index: Int, task: RunningTaskInfo) {
+private fun WindowContainerTransaction.assertReorderAt(
+        index: Int,
+        task: RunningTaskInfo,
+        toTop: Boolean? = null
+) {
     assertIndexInBounds(index)
     val op = hierarchyOps[index]
     assertThat(op.type).isEqualTo(HIERARCHY_OP_TYPE_REORDER)
     assertThat(op.container).isEqualTo(task.token.asBinder())
+    toTop?.let { assertThat(op.toTop).isEqualTo(it) }
 }
 
 private fun WindowContainerTransaction.assertReorderSequence(vararg tasks: RunningTaskInfo) {
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksLimiterTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksLimiterTest.kt
new file mode 100644
index 0000000..38ea034
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksLimiterTest.kt
@@ -0,0 +1,317 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.wm.shell.desktopmode
+
+import android.app.ActivityManager.RunningTaskInfo
+import android.os.Binder
+import android.platform.test.flag.junit.SetFlagsRule
+import android.testing.AndroidTestingRunner
+import android.view.Display.DEFAULT_DISPLAY
+import android.view.WindowManager.TRANSIT_OPEN
+import android.view.WindowManager.TRANSIT_TO_BACK
+import android.window.WindowContainerTransaction
+import android.window.WindowContainerTransaction.HierarchyOp.HIERARCHY_OP_TYPE_REORDER
+import androidx.test.filters.SmallTest
+import com.android.dx.mockito.inline.extended.ExtendedMockito
+import com.android.dx.mockito.inline.extended.StaticMockitoSession
+import com.android.wm.shell.ShellTaskOrganizer
+import com.android.wm.shell.ShellTestCase
+import com.android.wm.shell.desktopmode.DesktopTestHelpers.Companion.createFreeformTask
+import com.android.wm.shell.transition.TransitionInfoBuilder
+import com.android.wm.shell.transition.Transitions
+import com.android.wm.shell.util.StubTransaction
+import com.google.common.truth.Truth.assertThat
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito.`when`
+import org.mockito.quality.Strictness
+
+
+/**
+ * Test class for {@link DesktopTasksLimiter}
+ *
+ * Usage: atest WMShellUnitTests:DesktopTasksLimiterTest
+ */
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+class DesktopTasksLimiterTest : ShellTestCase() {
+
+    @JvmField
+    @Rule
+    val setFlagsRule = SetFlagsRule()
+
+    @Mock lateinit var shellTaskOrganizer: ShellTaskOrganizer
+    @Mock lateinit var transitions: Transitions
+
+    private lateinit var mockitoSession: StaticMockitoSession
+    private lateinit var desktopTasksLimiter: DesktopTasksLimiter
+    private lateinit var desktopTaskRepo: DesktopModeTaskRepository
+
+    @Before
+    fun setUp() {
+        mockitoSession = ExtendedMockito.mockitoSession().strictness(Strictness.LENIENT)
+                .spyStatic(DesktopModeStatus::class.java).startMocking()
+        `when`(DesktopModeStatus.isEnabled()).thenReturn(true)
+
+        desktopTaskRepo = DesktopModeTaskRepository()
+
+        desktopTasksLimiter = DesktopTasksLimiter(
+                transitions, desktopTaskRepo, shellTaskOrganizer)
+    }
+
+    @After
+    fun tearDown() {
+        mockitoSession.finishMocking()
+    }
+
+    // Currently, the task limit can be overridden through an adb flag. This test ensures the limit
+    // hasn't been overridden.
+    @Test
+    fun getMaxTaskLimit_isSameAsConstant() {
+        assertThat(desktopTasksLimiter.getMaxTaskLimit()).isEqualTo(
+            DesktopModeStatus.DEFAULT_MAX_TASK_LIMIT)
+    }
+
+    @Test
+    fun addPendingMinimizeTransition_taskIsNotMinimized() {
+        val task = setUpFreeformTask()
+        markTaskHidden(task)
+
+        desktopTasksLimiter.addPendingMinimizeChange(Binder(), displayId = 1, taskId = task.taskId)
+
+        assertThat(desktopTaskRepo.isMinimizedTask(taskId = task.taskId)).isFalse()
+    }
+
+    @Test
+    fun onTransitionReady_noPendingTransition_taskIsNotMinimized() {
+        val task = setUpFreeformTask()
+        markTaskHidden(task)
+
+        desktopTasksLimiter.getTransitionObserver().onTransitionReady(
+                Binder() /* transition */,
+                TransitionInfoBuilder(TRANSIT_OPEN).addChange(TRANSIT_TO_BACK, task).build(),
+                StubTransaction() /* startTransaction */,
+                StubTransaction() /* finishTransaction */)
+
+        assertThat(desktopTaskRepo.isMinimizedTask(taskId = task.taskId)).isFalse()
+    }
+
+    @Test
+    fun onTransitionReady_differentPendingTransition_taskIsNotMinimized() {
+        val pendingTransition = Binder()
+        val taskTransition = Binder()
+        val task = setUpFreeformTask()
+        markTaskHidden(task)
+        desktopTasksLimiter.addPendingMinimizeChange(
+            pendingTransition, displayId = DEFAULT_DISPLAY, taskId = task.taskId)
+
+        desktopTasksLimiter.getTransitionObserver().onTransitionReady(
+            taskTransition /* transition */,
+            TransitionInfoBuilder(TRANSIT_OPEN).addChange(TRANSIT_TO_BACK, task).build(),
+            StubTransaction() /* startTransaction */,
+            StubTransaction() /* finishTransaction */)
+
+        assertThat(desktopTaskRepo.isMinimizedTask(taskId = task.taskId)).isFalse()
+    }
+
+    @Test
+    fun onTransitionReady_pendingTransition_noTaskChange_taskVisible_taskIsNotMinimized() {
+        val transition = Binder()
+        val task = setUpFreeformTask()
+        markTaskVisible(task)
+        desktopTasksLimiter.addPendingMinimizeChange(
+                transition, displayId = DEFAULT_DISPLAY, taskId = task.taskId)
+
+        desktopTasksLimiter.getTransitionObserver().onTransitionReady(
+                transition,
+                TransitionInfoBuilder(TRANSIT_OPEN).build(),
+                StubTransaction() /* startTransaction */,
+                StubTransaction() /* finishTransaction */)
+
+        assertThat(desktopTaskRepo.isMinimizedTask(taskId = task.taskId)).isFalse()
+    }
+
+    @Test
+    fun onTransitionReady_pendingTransition_noTaskChange_taskInvisible_taskIsMinimized() {
+        val transition = Binder()
+        val task = setUpFreeformTask()
+        markTaskHidden(task)
+        desktopTasksLimiter.addPendingMinimizeChange(
+                transition, displayId = DEFAULT_DISPLAY, taskId = task.taskId)
+
+        desktopTasksLimiter.getTransitionObserver().onTransitionReady(
+                transition,
+                TransitionInfoBuilder(TRANSIT_OPEN).build(),
+                StubTransaction() /* startTransaction */,
+                StubTransaction() /* finishTransaction */)
+
+        assertThat(desktopTaskRepo.isMinimizedTask(taskId = task.taskId)).isTrue()
+    }
+
+    @Test
+    fun onTransitionReady_pendingTransition_changeTaskToBack_taskIsMinimized() {
+        val transition = Binder()
+        val task = setUpFreeformTask()
+        desktopTasksLimiter.addPendingMinimizeChange(
+                transition, displayId = DEFAULT_DISPLAY, taskId = task.taskId)
+
+        desktopTasksLimiter.getTransitionObserver().onTransitionReady(
+                transition,
+                TransitionInfoBuilder(TRANSIT_OPEN).addChange(TRANSIT_TO_BACK, task).build(),
+                StubTransaction() /* startTransaction */,
+                StubTransaction() /* finishTransaction */)
+
+        assertThat(desktopTaskRepo.isMinimizedTask(taskId = task.taskId)).isTrue()
+    }
+
+    @Test
+    fun onTransitionReady_transitionMergedFromPending_taskIsMinimized() {
+        val mergedTransition = Binder()
+        val newTransition = Binder()
+        val task = setUpFreeformTask()
+        desktopTasksLimiter.addPendingMinimizeChange(
+            mergedTransition, displayId = DEFAULT_DISPLAY, taskId = task.taskId)
+        desktopTasksLimiter.getTransitionObserver().onTransitionMerged(
+            mergedTransition, newTransition)
+
+        desktopTasksLimiter.getTransitionObserver().onTransitionReady(
+            newTransition,
+            TransitionInfoBuilder(TRANSIT_OPEN).addChange(TRANSIT_TO_BACK, task).build(),
+            StubTransaction() /* startTransaction */,
+            StubTransaction() /* finishTransaction */)
+
+        assertThat(desktopTaskRepo.isMinimizedTask(taskId = task.taskId)).isTrue()
+    }
+
+    @Test
+    fun addAndGetMinimizeTaskChangesIfNeeded_tasksWithinLimit_noTaskMinimized() {
+        val taskLimit = desktopTasksLimiter.getMaxTaskLimit()
+        (1..<taskLimit).forEach { _ -> setUpFreeformTask() }
+
+        val wct = WindowContainerTransaction()
+        val minimizedTaskId =
+                desktopTasksLimiter.addAndGetMinimizeTaskChangesIfNeeded(
+                        displayId = DEFAULT_DISPLAY,
+                        wct = wct,
+                        newFrontTaskInfo = setUpFreeformTask())
+
+        assertThat(minimizedTaskId).isNull()
+        assertThat(wct.hierarchyOps).isEmpty() // No reordering operations added
+    }
+
+    @Test
+    fun addAndGetMinimizeTaskChangesIfNeeded_tasksAboveLimit_backTaskMinimized() {
+        val taskLimit = desktopTasksLimiter.getMaxTaskLimit()
+        // The following list will be ordered bottom -> top, as the last task is moved to top last.
+        val tasks = (1..taskLimit).map { setUpFreeformTask() }
+
+        val wct = WindowContainerTransaction()
+        val minimizedTaskId =
+                desktopTasksLimiter.addAndGetMinimizeTaskChangesIfNeeded(
+                        displayId = DEFAULT_DISPLAY,
+                        wct = wct,
+                        newFrontTaskInfo = setUpFreeformTask())
+
+        assertThat(minimizedTaskId).isEqualTo(tasks.first())
+        assertThat(wct.hierarchyOps.size).isEqualTo(1)
+        assertThat(wct.hierarchyOps[0].type).isEqualTo(HIERARCHY_OP_TYPE_REORDER)
+        assertThat(wct.hierarchyOps[0].toTop).isFalse() // Reorder to bottom
+    }
+
+    @Test
+    fun addAndGetMinimizeTaskChangesIfNeeded_nonMinimizedTasksWithinLimit_noTaskMinimized() {
+        val taskLimit = desktopTasksLimiter.getMaxTaskLimit()
+        val tasks = (1..taskLimit).map { setUpFreeformTask() }
+        desktopTaskRepo.minimizeTask(displayId = DEFAULT_DISPLAY, taskId = tasks[0].taskId)
+
+        val wct = WindowContainerTransaction()
+        val minimizedTaskId =
+                desktopTasksLimiter.addAndGetMinimizeTaskChangesIfNeeded(
+                        displayId = 0,
+                        wct = wct,
+                        newFrontTaskInfo = setUpFreeformTask())
+
+        assertThat(minimizedTaskId).isNull()
+        assertThat(wct.hierarchyOps).isEmpty() // No reordering operations added
+    }
+
+    @Test
+    fun getTaskToMinimizeIfNeeded_tasksWithinLimit_returnsNull() {
+        val taskLimit = desktopTasksLimiter.getMaxTaskLimit()
+        val tasks = (1..taskLimit).map { setUpFreeformTask() }
+
+        val minimizedTask = desktopTasksLimiter.getTaskToMinimizeIfNeeded(
+                visibleFreeformTaskIdsOrderedFrontToBack = tasks.map { it.taskId })
+
+        assertThat(minimizedTask).isNull()
+    }
+
+    @Test
+    fun getTaskToMinimizeIfNeeded_tasksAboveLimit_returnsBackTask() {
+        val taskLimit = desktopTasksLimiter.getMaxTaskLimit()
+        val tasks = (1..taskLimit + 1).map { setUpFreeformTask() }
+
+        val minimizedTask = desktopTasksLimiter.getTaskToMinimizeIfNeeded(
+                visibleFreeformTaskIdsOrderedFrontToBack = tasks.map { it.taskId })
+
+        // first == front, last == back
+        assertThat(minimizedTask).isEqualTo(tasks.last())
+    }
+
+    @Test
+    fun getTaskToMinimizeIfNeeded_withNewTask_tasksAboveLimit_returnsBackTask() {
+        val taskLimit = desktopTasksLimiter.getMaxTaskLimit()
+        val tasks = (1..taskLimit).map { setUpFreeformTask() }
+
+        val minimizedTask = desktopTasksLimiter.getTaskToMinimizeIfNeeded(
+                visibleFreeformTaskIdsOrderedFrontToBack = tasks.map { it.taskId },
+                newTaskIdInFront = setUpFreeformTask().taskId)
+
+        // first == front, last == back
+        assertThat(minimizedTask).isEqualTo(tasks.last())
+    }
+
+    private fun setUpFreeformTask(
+            displayId: Int = DEFAULT_DISPLAY,
+    ): RunningTaskInfo {
+        val task = createFreeformTask(displayId)
+        `when`(shellTaskOrganizer.getRunningTaskInfo(task.taskId)).thenReturn(task)
+        desktopTaskRepo.addActiveTask(displayId, task.taskId)
+        desktopTaskRepo.addOrMoveFreeformTaskToTop(task.taskId)
+        return task
+    }
+
+    private fun markTaskVisible(task: RunningTaskInfo) {
+        desktopTaskRepo.updateVisibleFreeformTasks(
+                task.displayId,
+                task.taskId,
+                visible = true
+        )
+    }
+
+    private fun markTaskHidden(task: RunningTaskInfo) {
+        desktopTaskRepo.updateVisibleFreeformTasks(
+                task.displayId,
+                task.taskId,
+                visible = false
+        )
+    }
+}