Merge "Adjust bounds of a task moving to another display" into main
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 609ac0a..d966aaa 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
@@ -863,7 +863,11 @@
         }
 
         val wct = WindowContainerTransaction()
-        if (!task.isFreeform) addMoveToDesktopChanges(wct, task, displayId)
+        if (!task.isFreeform) {
+            addMoveToDesktopChanges(wct, task, displayId)
+        } else if (Flags.enableMoveToNextDisplayShortcut()) {
+            applyFreeformDisplayChange(wct, task, displayId)
+        }
         wct.reparent(task.token, displayAreaInfo.token, true /* onTop */)
 
         if (Flags.enablePerDisplayDesktopWallpaperActivity()) {
@@ -1909,6 +1913,50 @@
         }
     }
 
+    /**
+     * Apply changes to move a freeform task from one display to another, which includes handling
+     * density changes between displays.
+     */
+    private fun applyFreeformDisplayChange(
+        wct: WindowContainerTransaction,
+        taskInfo: RunningTaskInfo,
+        destDisplayId: Int,
+    ) {
+        val sourceLayout = displayController.getDisplayLayout(taskInfo.displayId) ?: return
+        val destLayout = displayController.getDisplayLayout(destDisplayId) ?: return
+        val bounds = taskInfo.configuration.windowConfiguration.bounds
+        val scaledWidth = bounds.width() * destLayout.densityDpi() / sourceLayout.densityDpi()
+        val scaledHeight = bounds.height() * destLayout.densityDpi() / sourceLayout.densityDpi()
+        val sourceWidthMargin = sourceLayout.width() - bounds.width()
+        val sourceHeightMargin = sourceLayout.height() - bounds.height()
+        val destWidthMargin = destLayout.width() - scaledWidth
+        val destHeightMargin = destLayout.height() - scaledHeight
+        val scaledLeft =
+            if (sourceWidthMargin != 0) {
+                bounds.left * destWidthMargin / sourceWidthMargin
+            } else {
+                destWidthMargin / 2
+            }
+        val scaledTop =
+            if (sourceHeightMargin != 0) {
+                bounds.top * destHeightMargin / sourceHeightMargin
+            } else {
+                destHeightMargin / 2
+            }
+        val boundsWithinDisplay =
+            if (destWidthMargin >= 0 && destHeightMargin >= 0) {
+                Rect(0, 0, scaledWidth, scaledHeight).apply {
+                    offsetTo(
+                        scaledLeft.coerceIn(0, destWidthMargin),
+                        scaledTop.coerceIn(0, destHeightMargin),
+                    )
+                }
+            } else {
+                getInitialBounds(destLayout, taskInfo, destDisplayId)
+            }
+        wct.setBounds(taskInfo.token, boundsWithinDisplay)
+    }
+
     private fun getInitialBounds(
         displayLayout: DisplayLayout,
         taskInfo: RunningTaskInfo,
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 0eb88e3..0283c02 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
@@ -83,6 +83,7 @@
 import com.android.window.flags.Flags
 import com.android.window.flags.Flags.FLAG_ENABLE_DESKTOP_WINDOWING_MODE
 import com.android.window.flags.Flags.FLAG_ENABLE_FULLY_IMMERSIVE_IN_DESKTOP
+import com.android.window.flags.Flags.FLAG_ENABLE_MOVE_TO_NEXT_DISPLAY_SHORTCUT
 import com.android.window.flags.Flags.FLAG_ENABLE_PER_DISPLAY_DESKTOP_WALLPAPER_ACTIVITY
 import com.android.wm.shell.MockToken
 import com.android.wm.shell.R
@@ -297,6 +298,7 @@
         whenever(displayLayout.getStableBounds(any())).thenAnswer { i ->
             (i.arguments.first() as Rect).set(STABLE_BOUNDS)
         }
+        whenever(displayLayout.densityDpi()).thenReturn(160)
         whenever(runBlocking { persistentRepository.readDesktop(any(), any()) })
             .thenReturn(Desktop.getDefaultInstance())
         doReturn(mockToast).`when` { Toast.makeText(any(), anyInt(), anyInt()) }
@@ -1746,6 +1748,154 @@
     }
 
     @Test
+    @EnableFlags(FLAG_ENABLE_MOVE_TO_NEXT_DISPLAY_SHORTCUT)
+    fun moveToNextDisplay_sizeInDpPreserved() {
+        // Set up two display ids
+        whenever(rootTaskDisplayAreaOrganizer.displayIds)
+            .thenReturn(intArrayOf(DEFAULT_DISPLAY, SECOND_DISPLAY))
+        // Create a mock for the target display area: second display
+        val secondDisplayArea = DisplayAreaInfo(MockToken().token(), SECOND_DISPLAY, 0)
+        whenever(rootTaskDisplayAreaOrganizer.getDisplayAreaInfo(SECOND_DISPLAY))
+            .thenReturn(secondDisplayArea)
+        // Two displays have different density
+        whenever(displayLayout.densityDpi()).thenReturn(320)
+        whenever(displayLayout.width()).thenReturn(2400)
+        whenever(displayLayout.height()).thenReturn(1600)
+        val secondaryLayout = mock(DisplayLayout::class.java)
+        whenever(displayController.getDisplayLayout(SECOND_DISPLAY)).thenReturn(secondaryLayout)
+        whenever(secondaryLayout.densityDpi()).thenReturn(160)
+        whenever(secondaryLayout.width()).thenReturn(1280)
+        whenever(secondaryLayout.height()).thenReturn(720)
+
+        // Place a task with a size of 640x480 at a position where the ratio of the left margin to
+        // the right margin is 1:3 and the ratio of top margin to the bottom margin is 1:2.
+        val task =
+            setUpFreeformTask(displayId = DEFAULT_DISPLAY, bounds = Rect(440, 374, 1080, 854))
+
+        controller.moveToNextDisplay(task.taskId)
+
+        with(getLatestWct(type = TRANSIT_CHANGE)) {
+            val taskChange = changes[task.token.asBinder()]
+            assertThat(taskChange).isNotNull()
+            // To preserve DP size, pixel size is changed to 320x240. The ratio of the left margin
+            // to the right margin and the ratio of the top margin to bottom margin are also
+            // preserved.
+            assertThat(taskChange!!.configuration.windowConfiguration.bounds)
+                .isEqualTo(Rect(240, 160, 560, 400))
+        }
+    }
+
+    @Test
+    @EnableFlags(FLAG_ENABLE_MOVE_TO_NEXT_DISPLAY_SHORTCUT)
+    fun moveToNextDisplay_shiftWithinDestinationDisplayBounds() {
+        // Set up two display ids
+        whenever(rootTaskDisplayAreaOrganizer.displayIds)
+            .thenReturn(intArrayOf(DEFAULT_DISPLAY, SECOND_DISPLAY))
+        // Create a mock for the target display area: second display
+        val secondDisplayArea = DisplayAreaInfo(MockToken().token(), SECOND_DISPLAY, 0)
+        whenever(rootTaskDisplayAreaOrganizer.getDisplayAreaInfo(SECOND_DISPLAY))
+            .thenReturn(secondDisplayArea)
+        // Two displays have different density
+        whenever(displayLayout.densityDpi()).thenReturn(320)
+        whenever(displayLayout.width()).thenReturn(2400)
+        whenever(displayLayout.height()).thenReturn(1600)
+        val secondaryLayout = mock(DisplayLayout::class.java)
+        whenever(displayController.getDisplayLayout(SECOND_DISPLAY)).thenReturn(secondaryLayout)
+        whenever(secondaryLayout.densityDpi()).thenReturn(160)
+        whenever(secondaryLayout.width()).thenReturn(1280)
+        whenever(secondaryLayout.height()).thenReturn(720)
+
+        // Place a task with a size of 640x480 at a position where the bottom-right corner of the
+        // window is outside the source display bounds. The destination display still has enough
+        // space to place the window within its bounds.
+        val task =
+            setUpFreeformTask(displayId = DEFAULT_DISPLAY, bounds = Rect(2000, 1200, 2640, 1680))
+
+        controller.moveToNextDisplay(task.taskId)
+
+        with(getLatestWct(type = TRANSIT_CHANGE)) {
+            val taskChange = changes[task.token.asBinder()]
+            assertThat(taskChange).isNotNull()
+            assertThat(taskChange!!.configuration.windowConfiguration.bounds)
+                .isEqualTo(Rect(960, 480, 1280, 720))
+        }
+    }
+
+    @Test
+    @EnableFlags(FLAG_ENABLE_MOVE_TO_NEXT_DISPLAY_SHORTCUT)
+    fun moveToNextDisplay_maximizedTask() {
+        // Set up two display ids
+        whenever(rootTaskDisplayAreaOrganizer.displayIds)
+            .thenReturn(intArrayOf(DEFAULT_DISPLAY, SECOND_DISPLAY))
+        // Create a mock for the target display area: second display
+        val secondDisplayArea = DisplayAreaInfo(MockToken().token(), SECOND_DISPLAY, 0)
+        whenever(rootTaskDisplayAreaOrganizer.getDisplayAreaInfo(SECOND_DISPLAY))
+            .thenReturn(secondDisplayArea)
+        // Two displays have different density
+        whenever(displayLayout.densityDpi()).thenReturn(320)
+        whenever(displayLayout.width()).thenReturn(1280)
+        whenever(displayLayout.height()).thenReturn(960)
+        val secondaryLayout = mock(DisplayLayout::class.java)
+        whenever(displayController.getDisplayLayout(SECOND_DISPLAY)).thenReturn(secondaryLayout)
+        whenever(secondaryLayout.densityDpi()).thenReturn(160)
+        whenever(secondaryLayout.width()).thenReturn(1280)
+        whenever(secondaryLayout.height()).thenReturn(720)
+
+        // Place a task with a size equals to display size.
+        val task = setUpFreeformTask(displayId = DEFAULT_DISPLAY, bounds = Rect(0, 0, 1280, 960))
+
+        controller.moveToNextDisplay(task.taskId)
+
+        with(getLatestWct(type = TRANSIT_CHANGE)) {
+            val taskChange = changes[task.token.asBinder()]
+            assertThat(taskChange).isNotNull()
+            // DP size is preserved. The window is centered in the destination display.
+            assertThat(taskChange!!.configuration.windowConfiguration.bounds)
+                .isEqualTo(Rect(320, 120, 960, 600))
+        }
+    }
+
+    @Test
+    @EnableFlags(FLAG_ENABLE_MOVE_TO_NEXT_DISPLAY_SHORTCUT)
+    fun moveToNextDisplay_defaultBoundsWhenDestinationTooSmall() {
+        // Set up two display ids
+        whenever(rootTaskDisplayAreaOrganizer.displayIds)
+            .thenReturn(intArrayOf(DEFAULT_DISPLAY, SECOND_DISPLAY))
+        // Create a mock for the target display area: second display
+        val secondDisplayArea = DisplayAreaInfo(MockToken().token(), SECOND_DISPLAY, 0)
+        whenever(rootTaskDisplayAreaOrganizer.getDisplayAreaInfo(SECOND_DISPLAY))
+            .thenReturn(secondDisplayArea)
+        // Two displays have different density
+        whenever(displayLayout.densityDpi()).thenReturn(320)
+        whenever(displayLayout.width()).thenReturn(2400)
+        whenever(displayLayout.height()).thenReturn(1600)
+        val secondaryLayout = mock(DisplayLayout::class.java)
+        whenever(displayController.getDisplayLayout(SECOND_DISPLAY)).thenReturn(secondaryLayout)
+        whenever(secondaryLayout.densityDpi()).thenReturn(160)
+        whenever(secondaryLayout.width()).thenReturn(640)
+        whenever(secondaryLayout.height()).thenReturn(480)
+        whenever(secondaryLayout.getStableBoundsForDesktopMode(any())).thenAnswer { i ->
+            (i.arguments.first() as Rect).set(0, 0, 640, 480)
+        }
+
+        // A task with a size of 1800x1200 is being placed. To preserve DP size,
+        // 900x600 pixels are needed, which does not fit in the destination display.
+        val task =
+            setUpFreeformTask(displayId = DEFAULT_DISPLAY, bounds = Rect(300, 200, 2100, 1400))
+
+        controller.moveToNextDisplay(task.taskId)
+
+        with(getLatestWct(type = TRANSIT_CHANGE)) {
+            val taskChange = changes[task.token.asBinder()]
+            assertThat(taskChange).isNotNull()
+            assertThat(taskChange!!.configuration.windowConfiguration.bounds.left).isAtLeast(0)
+            assertThat(taskChange.configuration.windowConfiguration.bounds.top).isAtLeast(0)
+            assertThat(taskChange.configuration.windowConfiguration.bounds.right).isAtMost(640)
+            assertThat(taskChange.configuration.windowConfiguration.bounds.bottom).isAtMost(480)
+        }
+    }
+
+    @Test
     fun getTaskWindowingMode() {
         val fullscreenTask = setUpFullscreenTask()
         val freeformTask = setUpFreeformTask()