summaryrefslogtreecommitdiff
path: root/libs
diff options
context:
space:
mode:
Diffstat (limited to 'libs')
-rw-r--r--libs/WindowManager/Shell/res/drawable/ic_baseline_expand_more_16.xml28
-rw-r--r--libs/WindowManager/Shell/res/layout/desktop_mode_app_header.xml4
-rw-r--r--libs/WindowManager/Shell/res/values/dimen.xml4
-rw-r--r--libs/WindowManager/Shell/shared/src/com/android/wm/shell/shared/bubbles/BubbleDropTargetBoundsProvider.kt29
-rw-r--r--libs/WindowManager/Shell/shared/src/com/android/wm/shell/shared/bubbles/DragZone.kt59
-rw-r--r--libs/WindowManager/Shell/shared/src/com/android/wm/shell/shared/bubbles/DragZoneFactory.kt450
-rw-r--r--libs/WindowManager/Shell/shared/src/com/android/wm/shell/shared/bubbles/DraggedObject.kt27
-rw-r--r--libs/WindowManager/Shell/shared/src/com/android/wm/shell/shared/pip/PipContentOverlay.java18
-rw-r--r--libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/Bubble.java92
-rw-r--r--libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleController.java13
-rw-r--r--libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleExpandedView.java9
-rw-r--r--libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubblePositioner.java17
-rw-r--r--libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleTaskViewHelper.java8
-rw-r--r--libs/WindowManager/Shell/src/com/android/wm/shell/common/InputChannelSupplier.kt34
-rw-r--r--libs/WindowManager/Shell/src/com/android/wm/shell/common/WindowSessionSupplier.kt35
-rw-r--r--libs/WindowManager/Shell/src/com/android/wm/shell/common/transition/SurfaceBuilderSupplier.kt34
-rw-r--r--libs/WindowManager/Shell/src/com/android/wm/shell/common/transition/TransactionSupplier.kt34
-rw-r--r--libs/WindowManager/Shell/src/com/android/wm/shell/compatui/letterbox/LetterboxGestureListener.kt65
-rw-r--r--libs/WindowManager/Shell/src/com/android/wm/shell/compatui/letterbox/LetterboxInputController.kt113
-rw-r--r--libs/WindowManager/Shell/src/com/android/wm/shell/compatui/letterbox/LetterboxInputDetector.kt230
-rw-r--r--libs/WindowManager/Shell/src/com/android/wm/shell/compatui/letterbox/LetterboxInputSurfaceBuilder.kt58
-rw-r--r--libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeVisualIndicator.java103
-rw-r--r--libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksController.kt126
-rw-r--r--libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipTouchHandler.java5
-rw-r--r--libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/TvPipMenuController.java15
-rw-r--r--libs/WindowManager/Shell/src/com/android/wm/shell/pip2/phone/PipTouchHandler.java1
-rw-r--r--libs/WindowManager/Shell/src/com/android/wm/shell/taskview/TaskViewTransitions.java55
-rw-r--r--libs/WindowManager/Shell/src/com/android/wm/shell/transition/Transitions.java3
-rw-r--r--libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorViewModel.java3
-rw-r--r--libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecoration.java25
-rw-r--r--libs/WindowManager/Shell/tests/e2e/desktopmode/flicker-service/Android.bp1
-rw-r--r--libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/common/InputChannelSupplierTest.kt42
-rw-r--r--libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/common/SuppliersUtilsTest.kt34
-rw-r--r--libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/common/WindowSessionSupplierTest.kt42
-rw-r--r--libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/common/transition/SurfaceBuilderSupplierTest.kt43
-rw-r--r--libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/common/transition/TransactionSupplierTest.kt43
-rw-r--r--libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/compatui/letterbox/LetterboxControllerRobotTest.kt4
-rw-r--r--libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/compatui/letterbox/LetterboxGestureDelegateTest.kt75
-rw-r--r--libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/compatui/letterbox/LetterboxInputControllerTest.kt203
-rw-r--r--libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/compatui/letterbox/LetterboxTestUtils.kt19
-rw-r--r--libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopModeVisualIndicatorTest.kt101
-rw-r--r--libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksControllerTest.kt70
-rw-r--r--libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/shared/bubbles/DragZoneFactoryTest.kt249
43 files changed, 2452 insertions, 171 deletions
diff --git a/libs/WindowManager/Shell/res/drawable/ic_baseline_expand_more_16.xml b/libs/WindowManager/Shell/res/drawable/ic_baseline_expand_more_16.xml
new file mode 100644
index 000000000000..c2a20b977b70
--- /dev/null
+++ b/libs/WindowManager/Shell/res/drawable/ic_baseline_expand_more_16.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2023 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
+ -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="16dp"
+ android:height="16dp"
+ android:viewportWidth="16"
+ android:viewportHeight="16"
+ android:tint="?android:attr/textColorSecondary">
+ <path
+ android:fillColor="#FF000000"
+ android:pathData="M 8 11.375 L 2 5.375 L 3.4 3.975 L 8 8.575 L 12.6 3.975 L 14 5.375 L 8 11.375 Z"
+ />
+</vector>
+
diff --git a/libs/WindowManager/Shell/res/layout/desktop_mode_app_header.xml b/libs/WindowManager/Shell/res/layout/desktop_mode_app_header.xml
index 87c520ca1b51..b898e4b06c14 100644
--- a/libs/WindowManager/Shell/res/layout/desktop_mode_app_header.xml
+++ b/libs/WindowManager/Shell/res/layout/desktop_mode_app_header.xml
@@ -64,7 +64,7 @@
android:id="@+id/expand_menu_button"
android:layout_width="16dp"
android:layout_height="16dp"
- android:src="@drawable/ic_baseline_expand_more_24"
+ android:src="@drawable/ic_baseline_expand_more_16"
android:background="@null"
android:scaleType="fitCenter"
android:clickable="false"
@@ -101,7 +101,7 @@
android:layout_width="44dp"
android:layout_height="40dp"
android:layout_gravity="end"
- android:layout_marginHorizontal="8dp"
+ android:layout_marginEnd="8dp"
android:clickable="true"
android:focusable="true"/>
diff --git a/libs/WindowManager/Shell/res/values/dimen.xml b/libs/WindowManager/Shell/res/values/dimen.xml
index 404bbd1d0a33..e23d5725e9c3 100644
--- a/libs/WindowManager/Shell/res/values/dimen.xml
+++ b/libs/WindowManager/Shell/res/values/dimen.xml
@@ -295,6 +295,10 @@
<dimen name="bubble_bar_dismiss_zone_width">192dp</dimen>
<!-- Height of the box around bottom center of the screen where drag only leads to dismiss -->
<dimen name="bubble_bar_dismiss_zone_height">242dp</dimen>
+ <!-- Height of the box at the corner of the screen where drag leads to app moving to bubble -->
+ <dimen name="bubble_transform_area_width">140dp</dimen>
+ <!-- Width of the box at the corner of the screen where drag leads to app moving to bubble -->
+ <dimen name="bubble_transform_area_height">140dp</dimen>
<!-- Bottom and end margin for compat buttons. -->
<dimen name="compat_button_margin">24dp</dimen>
diff --git a/libs/WindowManager/Shell/shared/src/com/android/wm/shell/shared/bubbles/BubbleDropTargetBoundsProvider.kt b/libs/WindowManager/Shell/shared/src/com/android/wm/shell/shared/bubbles/BubbleDropTargetBoundsProvider.kt
new file mode 100644
index 000000000000..9bee11a92430
--- /dev/null
+++ b/libs/WindowManager/Shell/shared/src/com/android/wm/shell/shared/bubbles/BubbleDropTargetBoundsProvider.kt
@@ -0,0 +1,29 @@
+/*
+ * 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.wm.shell.shared.bubbles
+
+import android.graphics.Rect
+
+/**
+ * Provide bounds for Bubbles drop targets that are shown when dragging over drag zones
+ */
+interface BubbleDropTargetBoundsProvider {
+ /**
+ * Get bubble bar expanded view visual drop target bounds on screen
+ */
+ fun getBubbleBarExpandedViewDropTargetBounds(onLeft: Boolean): Rect
+} \ No newline at end of file
diff --git a/libs/WindowManager/Shell/shared/src/com/android/wm/shell/shared/bubbles/DragZone.kt b/libs/WindowManager/Shell/shared/src/com/android/wm/shell/shared/bubbles/DragZone.kt
new file mode 100644
index 000000000000..5d346c047123
--- /dev/null
+++ b/libs/WindowManager/Shell/shared/src/com/android/wm/shell/shared/bubbles/DragZone.kt
@@ -0,0 +1,59 @@
+/*
+ * 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.wm.shell.shared.bubbles
+
+import android.graphics.Rect
+
+/**
+ * Represents an invisible area on the screen that determines what happens to a dragged object if it
+ * is released in that area.
+ *
+ * [bounds] are the bounds of the drag zone. Drag zones have an associated drop target that serves
+ * as visual feedback hinting what would happen if the object is released. When a dragged object is
+ * dragged into a drag zone, the associated drop target will be displayed. Not all drag zones have
+ * drop targets; only those that are made visible by Bubbles do.
+ */
+sealed interface DragZone {
+
+ /** The bounds of this drag zone. */
+ val bounds: Rect
+
+ fun contains(x: Int, y: Int) = bounds.contains(x, y)
+
+ /** Represents the bubble drag area on the screen. */
+ sealed class Bubble(override val bounds: Rect) : DragZone {
+ data class Left(override val bounds: Rect, val dropTarget: Rect) : Bubble(bounds)
+ data class Right(override val bounds: Rect, val dropTarget: Rect) : Bubble(bounds)
+ }
+
+ /** Represents dragging to Desktop Window. */
+ data class DesktopWindow(override val bounds: Rect, val dropTarget: Rect) : DragZone
+
+ /** Represents dragging to Full Screen. */
+ data class FullScreen(override val bounds: Rect, val dropTarget: Rect) : DragZone
+
+ /** Represents dragging to dismiss. */
+ data class Dismiss(override val bounds: Rect) : DragZone
+
+ /** Represents dragging to enter Split or replace a Split app. */
+ sealed class Split(override val bounds: Rect) : DragZone {
+ data class Left(override val bounds: Rect) : Split(bounds)
+ data class Right(override val bounds: Rect) : Split(bounds)
+ data class Top(override val bounds: Rect) : Split(bounds)
+ data class Bottom(override val bounds: Rect) : Split(bounds)
+ }
+}
diff --git a/libs/WindowManager/Shell/shared/src/com/android/wm/shell/shared/bubbles/DragZoneFactory.kt b/libs/WindowManager/Shell/shared/src/com/android/wm/shell/shared/bubbles/DragZoneFactory.kt
new file mode 100644
index 000000000000..c2eef33881be
--- /dev/null
+++ b/libs/WindowManager/Shell/shared/src/com/android/wm/shell/shared/bubbles/DragZoneFactory.kt
@@ -0,0 +1,450 @@
+/*
+ * 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.wm.shell.shared.bubbles
+
+import android.graphics.Rect
+import com.android.wm.shell.shared.bubbles.DragZoneFactory.SplitScreenModeChecker.SplitScreenMode
+
+/** A class for creating drag zones for dragging bubble objects or dragging into bubbles. */
+class DragZoneFactory(
+ private val deviceConfig: DeviceConfig,
+ private val splitScreenModeChecker: SplitScreenModeChecker,
+ private val desktopWindowModeChecker: DesktopWindowModeChecker,
+) {
+
+ private val windowBounds: Rect
+ get() = deviceConfig.windowBounds
+
+ // TODO b/393172431: move these to xml
+ private val dismissDragZoneSize = if (deviceConfig.isSmallTablet) 140 else 200
+ private val bubbleDragZoneTabletSize = 200
+ private val bubbleDragZoneFoldableSize = 140
+ private val fullScreenDragZoneWidth = 512
+ private val fullScreenDragZoneHeight = 44
+ private val desktopWindowDragZoneWidth = 880
+ private val desktopWindowDragZoneHeight = 300
+ private val desktopWindowFromExpandedViewDragZoneWidth = 200
+ private val desktopWindowFromExpandedViewDragZoneHeight = 350
+ private val splitFromBubbleDragZoneHeight = 100
+ private val splitFromBubbleDragZoneWidth = 60
+ private val hSplitFromExpandedViewDragZoneWidth = 60
+ private val vSplitFromExpandedViewDragZoneWidth = 200
+ private val vSplitFromExpandedViewDragZoneHeightTablet = 285
+ private val vSplitFromExpandedViewDragZoneHeightFold = 150
+ private val vUnevenSplitFromExpandedViewDragZoneHeight = 96
+
+ /**
+ * Creates the list of drag zones for the dragged object.
+ *
+ * Drag zones may have overlap, but the list is sorted by priority where the first drag zone has
+ * the highest priority so it should be checked first.
+ */
+ fun createSortedDragZones(draggedObject: DraggedObject): List<DragZone> {
+ val dragZones = mutableListOf<DragZone>()
+ when (draggedObject) {
+ is DraggedObject.BubbleBar -> {
+ dragZones.add(createDismissDragZone())
+ dragZones.addAll(createBubbleDragZones())
+ }
+ is DraggedObject.Bubble -> {
+ dragZones.add(createDismissDragZone())
+ dragZones.addAll(createBubbleDragZones())
+ dragZones.add(createFullScreenDragZone())
+ if (shouldShowDesktopWindowDragZones()) {
+ dragZones.add(createDesktopWindowDragZoneForBubble())
+ }
+ dragZones.addAll(createSplitScreenDragZonesForBubble())
+ }
+ is DraggedObject.ExpandedView -> {
+ dragZones.add(createDismissDragZone())
+ dragZones.add(createFullScreenDragZone())
+ if (shouldShowDesktopWindowDragZones()) {
+ dragZones.add(createDesktopWindowDragZoneForExpandedView())
+ }
+ if (deviceConfig.isSmallTablet) {
+ dragZones.addAll(createSplitScreenDragZonesForExpandedViewOnFoldable())
+ } else {
+ dragZones.addAll(createSplitScreenDragZonesForExpandedViewOnTablet())
+ }
+ createBubbleDragZonesForExpandedView()
+ }
+ }
+ return dragZones
+ }
+
+ private fun createDismissDragZone(): DragZone {
+ return DragZone.Dismiss(
+ bounds =
+ Rect(
+ windowBounds.right / 2 - dismissDragZoneSize / 2,
+ windowBounds.bottom - dismissDragZoneSize,
+ windowBounds.right / 2 + dismissDragZoneSize / 2,
+ windowBounds.bottom
+ )
+ )
+ }
+
+ private fun createBubbleDragZones(): List<DragZone> {
+ val dragZoneSize =
+ if (deviceConfig.isSmallTablet) {
+ bubbleDragZoneFoldableSize
+ } else {
+ bubbleDragZoneTabletSize
+ }
+ return listOf(
+ DragZone.Bubble.Left(
+ bounds =
+ Rect(0, windowBounds.bottom - dragZoneSize, dragZoneSize, windowBounds.bottom),
+ dropTarget = Rect(0, 0, 0, 0),
+ ),
+ DragZone.Bubble.Right(
+ bounds =
+ Rect(
+ windowBounds.right - dragZoneSize,
+ windowBounds.bottom - dragZoneSize,
+ windowBounds.right,
+ windowBounds.bottom,
+ ),
+ dropTarget = Rect(0, 0, 0, 0),
+ )
+ )
+ }
+
+ private fun createBubbleDragZonesForExpandedView(): List<DragZone> {
+ return listOf(
+ DragZone.Bubble.Left(
+ bounds = Rect(0, 0, windowBounds.right / 2, windowBounds.bottom),
+ dropTarget = Rect(0, 0, 0, 0),
+ ),
+ DragZone.Bubble.Right(
+ bounds =
+ Rect(
+ windowBounds.right / 2,
+ 0,
+ windowBounds.right,
+ windowBounds.bottom,
+ ),
+ dropTarget = Rect(0, 0, 0, 0),
+ )
+ )
+ }
+
+ private fun createFullScreenDragZone(): DragZone {
+ return DragZone.FullScreen(
+ bounds =
+ Rect(
+ windowBounds.right / 2 - fullScreenDragZoneWidth / 2,
+ 0,
+ windowBounds.right / 2 + fullScreenDragZoneWidth / 2,
+ fullScreenDragZoneHeight
+ ),
+ dropTarget = Rect(0, 0, 0, 0)
+ )
+ }
+
+ private fun shouldShowDesktopWindowDragZones() =
+ !deviceConfig.isSmallTablet && desktopWindowModeChecker.isSupported()
+
+ private fun createDesktopWindowDragZoneForBubble(): DragZone {
+ return DragZone.DesktopWindow(
+ bounds =
+ if (deviceConfig.isLandscape) {
+ Rect(
+ windowBounds.right / 2 - desktopWindowDragZoneWidth / 2,
+ windowBounds.bottom / 2 - desktopWindowDragZoneHeight / 2,
+ windowBounds.right / 2 + desktopWindowDragZoneWidth / 2,
+ windowBounds.bottom / 2 + desktopWindowDragZoneHeight / 2
+ )
+ } else {
+ Rect(
+ 0,
+ windowBounds.bottom / 2 - desktopWindowDragZoneHeight / 2,
+ windowBounds.right,
+ windowBounds.bottom / 2 + desktopWindowDragZoneHeight / 2
+ )
+ },
+ dropTarget = Rect(0, 0, 0, 0)
+ )
+ }
+
+ private fun createDesktopWindowDragZoneForExpandedView(): DragZone {
+ return DragZone.DesktopWindow(
+ bounds =
+ Rect(
+ windowBounds.right / 2 - desktopWindowFromExpandedViewDragZoneWidth / 2,
+ windowBounds.bottom / 2 - desktopWindowFromExpandedViewDragZoneHeight / 2,
+ windowBounds.right / 2 + desktopWindowFromExpandedViewDragZoneWidth / 2,
+ windowBounds.bottom / 2 + desktopWindowFromExpandedViewDragZoneHeight / 2
+ ),
+ dropTarget = Rect(0, 0, 0, 0)
+ )
+ }
+
+ private fun createSplitScreenDragZonesForBubble(): List<DragZone> {
+ // for foldables in landscape mode or tables in portrait modes we have vertical split drag
+ // zones. otherwise we have horizontal split drag zones.
+ val isVerticalSplit = deviceConfig.isSmallTablet == deviceConfig.isLandscape
+ return if (isVerticalSplit) {
+ when (splitScreenModeChecker.getSplitScreenMode()) {
+ SplitScreenMode.SPLIT_50_50,
+ SplitScreenMode.NONE ->
+ listOf(
+ DragZone.Split.Top(
+ bounds = Rect(0, 0, windowBounds.right, windowBounds.bottom / 2),
+ ),
+ DragZone.Split.Bottom(
+ bounds =
+ Rect(
+ 0,
+ windowBounds.bottom / 2,
+ windowBounds.right,
+ windowBounds.bottom
+ ),
+ )
+ )
+ SplitScreenMode.SPLIT_90_10 -> {
+ listOf(
+ DragZone.Split.Top(
+ bounds =
+ Rect(
+ 0,
+ 0,
+ windowBounds.right,
+ windowBounds.bottom - splitFromBubbleDragZoneHeight
+ ),
+ ),
+ DragZone.Split.Bottom(
+ bounds =
+ Rect(
+ 0,
+ windowBounds.bottom - splitFromBubbleDragZoneHeight,
+ windowBounds.right,
+ windowBounds.bottom
+ ),
+ )
+ )
+ }
+ SplitScreenMode.SPLIT_10_90 -> {
+ listOf(
+ DragZone.Split.Top(
+ bounds = Rect(0, 0, windowBounds.right, splitFromBubbleDragZoneHeight),
+ ),
+ DragZone.Split.Bottom(
+ bounds =
+ Rect(
+ 0,
+ splitFromBubbleDragZoneHeight,
+ windowBounds.right,
+ windowBounds.bottom
+ ),
+ )
+ )
+ }
+ }
+ } else {
+ when (splitScreenModeChecker.getSplitScreenMode()) {
+ SplitScreenMode.SPLIT_50_50,
+ SplitScreenMode.NONE ->
+ listOf(
+ DragZone.Split.Left(
+ bounds = Rect(0, 0, windowBounds.right / 2, windowBounds.bottom),
+ ),
+ DragZone.Split.Right(
+ bounds =
+ Rect(
+ windowBounds.right / 2,
+ 0,
+ windowBounds.right,
+ windowBounds.bottom
+ ),
+ )
+ )
+ SplitScreenMode.SPLIT_90_10 ->
+ listOf(
+ DragZone.Split.Left(
+ bounds =
+ Rect(
+ 0,
+ 0,
+ windowBounds.right - splitFromBubbleDragZoneWidth,
+ windowBounds.bottom
+ ),
+ ),
+ DragZone.Split.Right(
+ bounds =
+ Rect(
+ windowBounds.right - splitFromBubbleDragZoneWidth,
+ 0,
+ windowBounds.right,
+ windowBounds.bottom
+ ),
+ )
+ )
+ SplitScreenMode.SPLIT_10_90 ->
+ listOf(
+ DragZone.Split.Left(
+ bounds = Rect(0, 0, splitFromBubbleDragZoneWidth, windowBounds.bottom),
+ ),
+ DragZone.Split.Right(
+ bounds =
+ Rect(
+ splitFromBubbleDragZoneWidth,
+ 0,
+ windowBounds.right,
+ windowBounds.bottom
+ ),
+ )
+ )
+ }
+ }
+ }
+
+ private fun createSplitScreenDragZonesForExpandedViewOnTablet(): List<DragZone> {
+ return if (deviceConfig.isLandscape) {
+ createHorizontalSplitDragZonesForExpandedView()
+ } else {
+ // for tablets in portrait mode, split drag zones appear below the full screen drag zone
+ // for the top split zone, and above the dismiss zone. Both are horizontally centered.
+ val splitZoneLeft = windowBounds.right / 2 - vSplitFromExpandedViewDragZoneWidth / 2
+ val splitZoneRight = splitZoneLeft + vSplitFromExpandedViewDragZoneWidth
+ val bottomSplitZoneBottom = windowBounds.bottom - dismissDragZoneSize
+ listOf(
+ DragZone.Split.Top(
+ bounds =
+ Rect(
+ splitZoneLeft,
+ fullScreenDragZoneHeight,
+ splitZoneRight,
+ fullScreenDragZoneHeight + vSplitFromExpandedViewDragZoneHeightTablet
+ ),
+ ),
+ DragZone.Split.Bottom(
+ bounds =
+ Rect(
+ splitZoneLeft,
+ bottomSplitZoneBottom - vSplitFromExpandedViewDragZoneHeightTablet,
+ splitZoneRight,
+ bottomSplitZoneBottom
+ ),
+ )
+ )
+ }
+ }
+
+ private fun createSplitScreenDragZonesForExpandedViewOnFoldable(): List<DragZone> {
+ return if (deviceConfig.isLandscape) {
+ // vertical split drag zones are aligned with the full screen drag zone width
+ val splitZoneLeft = windowBounds.right / 2 - fullScreenDragZoneWidth / 2
+ when (splitScreenModeChecker.getSplitScreenMode()) {
+ SplitScreenMode.SPLIT_50_50,
+ SplitScreenMode.NONE ->
+ listOf(
+ DragZone.Split.Top(
+ bounds =
+ Rect(
+ splitZoneLeft,
+ fullScreenDragZoneHeight,
+ splitZoneLeft + fullScreenDragZoneWidth,
+ fullScreenDragZoneHeight +
+ vSplitFromExpandedViewDragZoneHeightFold
+ ),
+ ),
+ DragZone.Split.Bottom(
+ bounds =
+ Rect(
+ splitZoneLeft,
+ windowBounds.bottom / 2,
+ splitZoneLeft + fullScreenDragZoneWidth,
+ windowBounds.bottom / 2 +
+ vSplitFromExpandedViewDragZoneHeightFold
+ ),
+ )
+ )
+ // TODO b/393172431: add this zone when it's defined
+ SplitScreenMode.SPLIT_10_90 -> listOf()
+ SplitScreenMode.SPLIT_90_10 ->
+ listOf(
+ DragZone.Split.Top(
+ bounds =
+ Rect(
+ splitZoneLeft,
+ fullScreenDragZoneHeight,
+ splitZoneLeft + fullScreenDragZoneWidth,
+ fullScreenDragZoneHeight +
+ vUnevenSplitFromExpandedViewDragZoneHeight
+ ),
+ ),
+ DragZone.Split.Bottom(
+ bounds =
+ Rect(
+ 0,
+ windowBounds.bottom -
+ vUnevenSplitFromExpandedViewDragZoneHeight,
+ windowBounds.right,
+ windowBounds.bottom
+ ),
+ )
+ )
+ }
+ } else {
+ // horizontal split drag zones
+ createHorizontalSplitDragZonesForExpandedView()
+ }
+ }
+
+ private fun createHorizontalSplitDragZonesForExpandedView(): List<DragZone> {
+ // horizontal split drag zones for expanded view appear on the edges of the screen from the
+ // top down until the dismiss drag zone height
+ return listOf(
+ DragZone.Split.Left(
+ bounds =
+ Rect(
+ 0,
+ 0,
+ hSplitFromExpandedViewDragZoneWidth,
+ windowBounds.bottom - dismissDragZoneSize
+ ),
+ ),
+ DragZone.Split.Right(
+ bounds =
+ Rect(
+ windowBounds.right - hSplitFromExpandedViewDragZoneWidth,
+ 0,
+ windowBounds.right,
+ windowBounds.bottom - dismissDragZoneSize
+ ),
+ )
+ )
+ }
+
+ /** Checks the current split screen mode. */
+ fun interface SplitScreenModeChecker {
+ enum class SplitScreenMode {
+ NONE,
+ SPLIT_50_50,
+ SPLIT_10_90,
+ SPLIT_90_10
+ }
+
+ fun getSplitScreenMode(): SplitScreenMode
+ }
+
+ /** Checks if desktop window mode is supported. */
+ fun interface DesktopWindowModeChecker {
+ fun isSupported(): Boolean
+ }
+}
diff --git a/libs/WindowManager/Shell/shared/src/com/android/wm/shell/shared/bubbles/DraggedObject.kt b/libs/WindowManager/Shell/shared/src/com/android/wm/shell/shared/bubbles/DraggedObject.kt
new file mode 100644
index 000000000000..028622798f34
--- /dev/null
+++ b/libs/WindowManager/Shell/shared/src/com/android/wm/shell/shared/bubbles/DraggedObject.kt
@@ -0,0 +1,27 @@
+/*
+ * 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.wm.shell.shared.bubbles
+
+/** A Bubble object being dragged. */
+sealed interface DraggedObject {
+ /** The initial location of the object at the start of the drag gesture. */
+ val initialLocation: BubbleBarLocation
+
+ data class Bubble(override val initialLocation: BubbleBarLocation) : DraggedObject
+ data class BubbleBar(override val initialLocation: BubbleBarLocation) : DraggedObject
+ data class ExpandedView(override val initialLocation: BubbleBarLocation) : DraggedObject
+}
diff --git a/libs/WindowManager/Shell/shared/src/com/android/wm/shell/shared/pip/PipContentOverlay.java b/libs/WindowManager/Shell/shared/src/com/android/wm/shell/shared/pip/PipContentOverlay.java
index 62ca5c687a2a..b1bc6e81e1bd 100644
--- a/libs/WindowManager/Shell/shared/src/com/android/wm/shell/shared/pip/PipContentOverlay.java
+++ b/libs/WindowManager/Shell/shared/src/com/android/wm/shell/shared/pip/PipContentOverlay.java
@@ -28,6 +28,7 @@ import android.graphics.Color;
import android.graphics.Matrix;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
+import android.hardware.HardwareBuffer;
import android.util.TypedValue;
import android.view.SurfaceControl;
import android.window.TaskSnapshot;
@@ -225,12 +226,17 @@ public abstract class PipContentOverlay {
@Override
public void attach(SurfaceControl.Transaction tx, SurfaceControl parentLeash) {
+ final HardwareBuffer buffer = mBitmap.getHardwareBuffer();
tx.show(mLeash);
tx.setLayer(mLeash, Integer.MAX_VALUE);
- tx.setBuffer(mLeash, mBitmap.getHardwareBuffer());
+ tx.setBuffer(mLeash, buffer);
tx.setAlpha(mLeash, 0f);
tx.reparent(mLeash, parentLeash);
tx.apply();
+ // Cleanup the bitmap and buffer after setting up the leash
+ mBitmap.recycle();
+ mBitmap = null;
+ buffer.close();
}
@Override
@@ -253,14 +259,6 @@ public abstract class PipContentOverlay {
.setAlpha(mLeash, fraction < 0.5f ? 0 : (fraction - 0.5f) * 2);
}
- @Override
- public void detach(SurfaceControl.Transaction tx) {
- super.detach(tx);
- if (mBitmap != null && !mBitmap.isRecycled()) {
- mBitmap.recycle();
- }
- }
-
private void prepareAppIconOverlay(Drawable appIcon) {
final Canvas canvas = new Canvas();
canvas.setBitmap(mBitmap);
@@ -282,7 +280,9 @@ public abstract class PipContentOverlay {
mOverlayHalfSize + mAppIconSizePx / 2);
appIcon.setBounds(appIconBounds);
appIcon.draw(canvas);
+ Bitmap oldBitmap = mBitmap;
mBitmap = mBitmap.copy(Bitmap.Config.HARDWARE, false /* mutable */);
+ oldBitmap.recycle();
}
}
}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/Bubble.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/Bubble.java
index 947dbd276d3a..d77c177437b8 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/Bubble.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/Bubble.java
@@ -192,10 +192,10 @@ public class Bubble implements BubbleViewProvider {
* that bubble being added back to the stack anyways.
*/
@Nullable
- private PendingIntent mIntent;
- private boolean mIntentActive;
+ private PendingIntent mPendingIntent;
+ private boolean mPendingIntentActive;
@Nullable
- private PendingIntent.CancelListener mIntentCancelListener;
+ private PendingIntent.CancelListener mPendingIntentCancelListener;
/**
* Sent when the bubble & notification are no longer visible to the user (i.e. no
@@ -205,12 +205,10 @@ public class Bubble implements BubbleViewProvider {
private PendingIntent mDeleteIntent;
/**
- * Used only for a special bubble in the stack that has {@link #mIsAppBubble} set to true.
- * There can only be one of these bubbles in the stack and this intent will be populated for
- * that bubble.
+ * Used for app & note bubbles.
*/
@Nullable
- private Intent mAppIntent;
+ private Intent mIntent;
/**
* Set while preparing a transition for animation. Several steps are needed before animation
@@ -275,7 +273,7 @@ public class Bubble implements BubbleViewProvider {
mMainExecutor = mainExecutor;
mBgExecutor = bgExecutor;
mTaskId = INVALID_TASK_ID;
- mAppIntent = intent;
+ mIntent = intent;
mDesiredHeight = Integer.MAX_VALUE;
mPackageName = intent.getPackage();
}
@@ -294,7 +292,7 @@ public class Bubble implements BubbleViewProvider {
mMainExecutor = mainExecutor;
mBgExecutor = bgExecutor;
mTaskId = INVALID_TASK_ID;
- mAppIntent = null;
+ mIntent = null;
mDesiredHeight = Integer.MAX_VALUE;
mPackageName = info.getPackage();
mShortcutInfo = info;
@@ -319,7 +317,7 @@ public class Bubble implements BubbleViewProvider {
mMainExecutor = mainExecutor;
mBgExecutor = bgExecutor;
mTaskId = task.taskId;
- mAppIntent = null;
+ mIntent = null;
mDesiredHeight = Integer.MAX_VALUE;
mPackageName = task.baseActivity.getPackageName();
}
@@ -413,9 +411,9 @@ public class Bubble implements BubbleViewProvider {
mGroupKey = entry.getGroupKey();
mLocusId = entry.getLocusId();
mBubbleMetadataFlagListener = listener;
- mIntentCancelListener = intent -> {
- if (mIntent != null) {
- mIntent.unregisterCancelListener(mIntentCancelListener);
+ mPendingIntentCancelListener = intent -> {
+ if (mPendingIntent != null) {
+ mPendingIntent.unregisterCancelListener(mPendingIntentCancelListener);
}
mainExecutor.execute(() -> {
intentCancelListener.onPendingIntentCanceled(this);
@@ -601,10 +599,10 @@ public class Bubble implements BubbleViewProvider {
if (cleanupTaskView) {
cleanupTaskView();
}
- if (mIntent != null) {
- mIntent.unregisterCancelListener(mIntentCancelListener);
+ if (mPendingIntent != null) {
+ mPendingIntent.unregisterCancelListener(mPendingIntentCancelListener);
}
- mIntentActive = false;
+ mPendingIntentActive = false;
}
/** Cleans-up the taskview associated with this bubble (possibly removing the task from wm) */
@@ -874,19 +872,19 @@ public class Bubble implements BubbleViewProvider {
mDesiredHeightResId = entry.getBubbleMetadata().getDesiredHeightResId();
mIcon = entry.getBubbleMetadata().getIcon();
- if (!mIntentActive || mIntent == null) {
- if (mIntent != null) {
- mIntent.unregisterCancelListener(mIntentCancelListener);
+ if (!mPendingIntentActive || mPendingIntent == null) {
+ if (mPendingIntent != null) {
+ mPendingIntent.unregisterCancelListener(mPendingIntentCancelListener);
}
- mIntent = entry.getBubbleMetadata().getIntent();
- if (mIntent != null) {
- mIntent.registerCancelListener(mIntentCancelListener);
+ mPendingIntent = entry.getBubbleMetadata().getIntent();
+ if (mPendingIntent != null) {
+ mPendingIntent.registerCancelListener(mPendingIntentCancelListener);
}
- } else if (mIntent != null && entry.getBubbleMetadata().getIntent() == null) {
+ } else if (mPendingIntent != null && entry.getBubbleMetadata().getIntent() == null) {
// Was an intent bubble now it's a shortcut bubble... still unregister the listener
- mIntent.unregisterCancelListener(mIntentCancelListener);
- mIntentActive = false;
- mIntent = null;
+ mPendingIntent.unregisterCancelListener(mPendingIntentCancelListener);
+ mPendingIntentActive = false;
+ mPendingIntent = null;
}
mDeleteIntent = entry.getBubbleMetadata().getDeleteIntent();
}
@@ -926,12 +924,15 @@ public class Bubble implements BubbleViewProvider {
* Sets if the intent used for this bubble is currently active (i.e. populating an
* expanded view, expanded or not).
*/
- void setIntentActive() {
- mIntentActive = true;
+ void setPendingIntentActive() {
+ mPendingIntentActive = true;
}
- boolean isIntentActive() {
- return mIntentActive;
+ /**
+ * Whether the pending intent of this bubble is active (i.e. has been sent).
+ */
+ boolean isPendingIntentActive() {
+ return mPendingIntentActive;
}
public InstanceId getInstanceId() {
@@ -1118,9 +1119,12 @@ public class Bubble implements BubbleViewProvider {
}
}
+ /**
+ * Returns the pending intent used to populate the bubble.
+ */
@Nullable
- PendingIntent getBubbleIntent() {
- return mIntent;
+ PendingIntent getPendingIntent() {
+ return mPendingIntent;
}
/**
@@ -1128,31 +1132,33 @@ public class Bubble implements BubbleViewProvider {
* intent for an app. In this case we don't show a badge on the icon.
*/
public boolean isAppLaunchIntent() {
- if (BubbleAnythingFlagHelper.enableCreateAnyBubble() && mAppIntent != null) {
- return mAppIntent.hasCategory("android.intent.category.LAUNCHER");
+ if (BubbleAnythingFlagHelper.enableCreateAnyBubble() && mIntent != null) {
+ return mIntent.hasCategory("android.intent.category.LAUNCHER");
}
return false;
}
+ /**
+ * Returns the pending intent to send when a bubble is dismissed (set via the notification API).
+ */
@Nullable
PendingIntent getDeleteIntent() {
return mDeleteIntent;
}
+ /**
+ * Returns the intent used to populate the bubble.
+ */
@Nullable
- @VisibleForTesting
- public Intent getAppBubbleIntent() {
- return mAppIntent;
+ public Intent getIntent() {
+ return mIntent;
}
/**
- * Sets the intent for a bubble that is an app bubble (one for which {@link #mIsAppBubble} is
- * true).
- *
- * @param appIntent The intent to set for the app bubble.
+ * Sets the intent used to populate the bubble.
*/
- void setAppBubbleIntent(Intent appIntent) {
- mAppIntent = appIntent;
+ void setIntent(Intent intent) {
+ mIntent = intent;
}
/**
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleController.java
index 9120e0894ccf..2c81945ffdbe 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleController.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleController.java
@@ -116,6 +116,7 @@ import com.android.wm.shell.shared.annotations.ShellMainThread;
import com.android.wm.shell.shared.bubbles.BubbleAnythingFlagHelper;
import com.android.wm.shell.shared.bubbles.BubbleBarLocation;
import com.android.wm.shell.shared.bubbles.BubbleBarUpdate;
+import com.android.wm.shell.shared.bubbles.BubbleDropTargetBoundsProvider;
import com.android.wm.shell.shared.bubbles.DeviceConfig;
import com.android.wm.shell.sysui.ConfigurationChangeListener;
import com.android.wm.shell.sysui.ShellCommandHandler;
@@ -419,10 +420,11 @@ public class BubbleController implements ConfigurationChangeListener,
mBubbleData.setSuppressionChangedListener(this::onBubbleMetadataFlagChanged);
mDataRepository.setSuppressionChangedListener(this::onBubbleMetadataFlagChanged);
mBubbleData.setPendingIntentCancelledListener(bubble -> {
- if (bubble.getBubbleIntent() == null) {
+ if (bubble.getPendingIntent() == null) {
return;
}
- if (bubble.isIntentActive() || mBubbleData.hasBubbleInStackWithKey(bubble.getKey())) {
+ if (bubble.isPendingIntentActive()
+ || mBubbleData.hasBubbleInStackWithKey(bubble.getKey())) {
bubble.setPendingIntentCanceled();
return;
}
@@ -924,6 +926,11 @@ public class BubbleController implements ConfigurationChangeListener,
return mBubblePositioner;
}
+ /** Provides bounds for drag zone drop targets */
+ public BubbleDropTargetBoundsProvider getBubbleDropTargetBoundsProvider() {
+ return mBubblePositioner;
+ }
+
BubbleIconFactory getIconFactory() {
return mBubbleIconFactory;
}
@@ -1663,7 +1670,7 @@ public class BubbleController implements ConfigurationChangeListener,
// It's in the overflow, so remove it & reinflate
mBubbleData.dismissBubbleWithKey(noteBubbleKey, Bubbles.DISMISS_NOTIF_CANCEL);
// Update the bubble entry in the overflow with the latest intent.
- b.setAppBubbleIntent(intent);
+ b.setIntent(intent);
} else {
// Notes bubble does not exist, lets add and expand it
b = Bubble.createNotesBubble(intent, user, icon, mMainExecutor,
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleExpandedView.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleExpandedView.java
index ac74a42d1359..ad9ab7a722ee 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleExpandedView.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleExpandedView.java
@@ -237,8 +237,7 @@ public class BubbleExpandedView extends LinearLayout {
PendingIntent pi = PendingIntent.getActivity(
context,
/* requestCode= */ 0,
- mBubble.getAppBubbleIntent()
- .addFlags(FLAG_ACTIVITY_MULTIPLE_TASK),
+ mBubble.getIntent().addFlags(FLAG_ACTIVITY_MULTIPLE_TASK),
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT,
/* options= */ null);
mTaskView.startActivity(pi, /* fillInIntent= */ null, options,
@@ -252,7 +251,7 @@ public class BubbleExpandedView extends LinearLayout {
} else {
options.setLaunchedFromBubble(true);
if (mBubble != null) {
- mBubble.setIntentActive();
+ mBubble.setPendingIntentActive();
}
final Intent fillInIntent = new Intent();
// Apply flags to make behaviour match documentLaunchMode=always.
@@ -920,7 +919,7 @@ public class BubbleExpandedView extends LinearLayout {
});
if (isNew) {
- mPendingIntent = mBubble.getBubbleIntent();
+ mPendingIntent = mBubble.getPendingIntent();
if ((mPendingIntent != null || mBubble.hasMetadataShortcutId())
&& mTaskView != null) {
setContentVisibility(false);
@@ -947,7 +946,7 @@ public class BubbleExpandedView extends LinearLayout {
*/
private boolean didBackingContentChange(Bubble newBubble) {
boolean prevWasIntentBased = mBubble != null && mPendingIntent != null;
- boolean newIsIntentBased = newBubble.getBubbleIntent() != null;
+ boolean newIsIntentBased = newBubble.getPendingIntent() != null;
return prevWasIntentBased != newIsIntentBased;
}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubblePositioner.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubblePositioner.java
index 8cf3f7afd46a..5273a7cf2432 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubblePositioner.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubblePositioner.java
@@ -27,19 +27,21 @@ import android.graphics.RectF;
import android.view.Surface;
import android.view.WindowManager;
+import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import com.android.internal.protolog.ProtoLog;
import com.android.launcher3.icons.IconNormalizer;
import com.android.wm.shell.R;
import com.android.wm.shell.shared.bubbles.BubbleBarLocation;
+import com.android.wm.shell.shared.bubbles.BubbleDropTargetBoundsProvider;
import com.android.wm.shell.shared.bubbles.DeviceConfig;
/**
* Keeps track of display size, configuration, and specific bubble sizes. One place for all
* placement and positioning calculations to refer to.
*/
-public class BubblePositioner {
+public class BubblePositioner implements BubbleDropTargetBoundsProvider {
/** The screen edge the bubble stack is pinned to */
public enum StackPinnedEdge {
@@ -100,6 +102,7 @@ public class BubblePositioner {
private int mManageButtonHeight;
private int mOverflowHeight;
private int mMinimumFlyoutWidthLargeScreen;
+ private int mBubbleBarExpandedViewDropTargetPadding;
private PointF mRestingStackPosition;
@@ -164,6 +167,8 @@ public class BubblePositioner {
res.getDimensionPixelSize(R.dimen.bubble_bar_expanded_view_width),
mPositionRect.width() - 2 * mExpandedViewPadding
);
+ mBubbleBarExpandedViewDropTargetPadding = res.getDimensionPixelSize(
+ R.dimen.bubble_bar_expanded_view_drop_target_padding);
if (mShowingInBubbleBar) {
mExpandedViewLargeScreenWidth = mExpandedViewBubbleBarWidth;
@@ -965,4 +970,14 @@ public class BubblePositioner {
int top = getExpandedViewBottomForBubbleBar() - height;
out.offsetTo(left, top);
}
+
+ @NonNull
+ @Override
+ public Rect getBubbleBarExpandedViewDropTargetBounds(boolean onLeft) {
+ Rect bounds = new Rect();
+ getBubbleBarExpandedViewBounds(onLeft, false, bounds);
+ bounds.inset(mBubbleBarExpandedViewDropTargetPadding,
+ mBubbleBarExpandedViewDropTargetPadding);
+ return bounds;
+ }
}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleTaskViewHelper.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleTaskViewHelper.java
index 83d311ed6cd9..0d89bb260bf5 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleTaskViewHelper.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleTaskViewHelper.java
@@ -119,7 +119,7 @@ public class BubbleTaskViewHelper {
PendingIntent pi = PendingIntent.getActivity(
context,
/* requestCode= */ 0,
- mBubble.getAppBubbleIntent()
+ mBubble.getIntent()
.addFlags(FLAG_ACTIVITY_MULTIPLE_TASK),
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT,
/* options= */ null);
@@ -133,7 +133,7 @@ public class BubbleTaskViewHelper {
} else {
options.setLaunchedFromBubble(true);
if (mBubble != null) {
- mBubble.setIntentActive();
+ mBubble.setPendingIntentActive();
}
final Intent fillInIntent = new Intent();
// Apply flags to make behaviour match documentLaunchMode=always.
@@ -231,7 +231,7 @@ public class BubbleTaskViewHelper {
boolean isNew = mBubble == null || didBackingContentChange(bubble);
mBubble = bubble;
if (isNew) {
- mPendingIntent = mBubble.getBubbleIntent();
+ mPendingIntent = mBubble.getPendingIntent();
return true;
}
return false;
@@ -276,7 +276,7 @@ public class BubbleTaskViewHelper {
*/
private boolean didBackingContentChange(Bubble newBubble) {
boolean prevWasIntentBased = mBubble != null && mPendingIntent != null;
- boolean newIsIntentBased = newBubble.getBubbleIntent() != null;
+ boolean newIsIntentBased = newBubble.getPendingIntent() != null;
return prevWasIntentBased != newIsIntentBased;
}
}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/common/InputChannelSupplier.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/common/InputChannelSupplier.kt
new file mode 100644
index 000000000000..41382047945b
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/common/InputChannelSupplier.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright 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.wm.shell.common
+
+import android.view.InputChannel
+import com.android.wm.shell.dagger.WMSingleton
+import java.util.function.Supplier
+import javax.inject.Inject
+
+/**
+ * An Injectable [Supplier<InputChannel>]. This can be used in place of kotlin default
+ * parameters values [builder = ::InputChannel] which requires the [@JvmOverloads] annotation to
+ * make this available in Java.
+ * This can be used every time a component needs the dependency to the default [Supplier] for
+ * [InputChannel]s.
+ */
+@WMSingleton
+class InputChannelSupplier @Inject constructor() : Supplier<InputChannel> {
+ override fun get(): InputChannel = InputChannel()
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/common/WindowSessionSupplier.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/common/WindowSessionSupplier.kt
new file mode 100644
index 000000000000..2c66e97f03e1
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/common/WindowSessionSupplier.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright 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.wm.shell.common
+
+import android.view.IWindowSession
+import android.view.WindowManagerGlobal
+import com.android.wm.shell.dagger.WMSingleton
+import java.util.function.Supplier
+import javax.inject.Inject
+
+/**
+ * An Injectable [Supplier<IWindowSession>]. This can be used in place of kotlin default
+ * parameters values [builder = WindowManagerGlobal::getWindowSession] which requires the
+ * [@JvmOverloads] annotation to make this available in Java.
+ * This can be used every time a component needs the dependency to the default [Supplier] for
+ * [IWindowSession]s.
+ */
+@WMSingleton
+class WindowSessionSupplier @Inject constructor() : Supplier<IWindowSession> {
+ override fun get(): IWindowSession = WindowManagerGlobal.getWindowSession()
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/common/transition/SurfaceBuilderSupplier.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/common/transition/SurfaceBuilderSupplier.kt
new file mode 100644
index 000000000000..0b6c06ac5649
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/common/transition/SurfaceBuilderSupplier.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright 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.wm.shell.common.transition
+
+import android.view.SurfaceControl
+import com.android.wm.shell.dagger.WMSingleton
+import java.util.function.Supplier
+import javax.inject.Inject
+
+/**
+ * An Injectable [Supplier<SurfaceControl.Builder>]. This can be used in place of kotlin default
+ * parameters values [builder = ::SurfaceControl.Builder] which requires the [@JvmOverloads]
+ * annotation to make this available in Java.
+ * This can be used every time a component needs the dependency to the default builder for
+ * [SurfaceControl]s.
+ */
+@WMSingleton
+class SurfaceBuilderSupplier @Inject constructor() : Supplier<SurfaceControl.Builder> {
+ override fun get(): SurfaceControl.Builder = SurfaceControl.Builder()
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/common/transition/TransactionSupplier.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/common/transition/TransactionSupplier.kt
new file mode 100644
index 000000000000..2d9899b4fccf
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/common/transition/TransactionSupplier.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright 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.wm.shell.common.transition
+
+import android.view.SurfaceControl
+import com.android.wm.shell.dagger.WMSingleton
+import java.util.function.Supplier
+import javax.inject.Inject
+
+/**
+ * An Injectable [Supplier<SurfaceControl.Transaction>]. This can be used in place of kotlin default
+ * parameters values [builder = ::SurfaceControl.Transaction] which requires the [@JvmOverloads]
+ * annotation to make this available in Java.
+ * This can be used every time a component needs the dependency to the default builder for
+ * [SurfaceControl.Transaction]s.
+ */
+@WMSingleton
+class TransactionSupplier @Inject constructor() : Supplier<SurfaceControl.Transaction> {
+ override fun get(): SurfaceControl.Transaction = SurfaceControl.Transaction()
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/letterbox/LetterboxGestureListener.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/letterbox/LetterboxGestureListener.kt
new file mode 100644
index 000000000000..f7afbb5bdaef
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/letterbox/LetterboxGestureListener.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright 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.wm.shell.compatui.letterbox
+
+import android.view.GestureDetector.OnContextClickListener
+import android.view.GestureDetector.OnDoubleTapListener
+import android.view.GestureDetector.OnGestureListener
+import android.view.MotionEvent
+
+/**
+ * Interface which unions all the interfaces related to gestures.
+ */
+interface LetterboxGestureListener : OnGestureListener, OnDoubleTapListener, OnContextClickListener
+
+/**
+ * Convenience class which provide an overrideable implementation of
+ * {@link LetterboxGestureListener}.
+ */
+object LetterboxGestureDelegate : LetterboxGestureListener {
+ override fun onDown(e: MotionEvent): Boolean = false
+
+ override fun onShowPress(e: MotionEvent) {
+ }
+
+ override fun onSingleTapUp(e: MotionEvent): Boolean = false
+
+ override fun onScroll(
+ e1: MotionEvent?,
+ e2: MotionEvent,
+ distanceX: Float,
+ distanceY: Float
+ ): Boolean = false
+
+ override fun onLongPress(e: MotionEvent) {
+ }
+
+ override fun onFling(
+ e1: MotionEvent?,
+ e2: MotionEvent,
+ velocityX: Float,
+ velocityY: Float
+ ): Boolean = false
+
+ override fun onSingleTapConfirmed(e: MotionEvent): Boolean = false
+
+ override fun onDoubleTap(e: MotionEvent): Boolean = false
+
+ override fun onDoubleTapEvent(e: MotionEvent): Boolean = false
+
+ override fun onContextClick(e: MotionEvent): Boolean = false
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/letterbox/LetterboxInputController.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/letterbox/LetterboxInputController.kt
new file mode 100644
index 000000000000..afd8e1519d24
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/letterbox/LetterboxInputController.kt
@@ -0,0 +1,113 @@
+/*
+ * Copyright 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.wm.shell.compatui.letterbox
+
+import android.content.Context
+import android.graphics.Rect
+import android.graphics.Region
+import android.os.Handler
+import android.view.SurfaceControl
+import android.view.SurfaceControl.Transaction
+import com.android.internal.protolog.ProtoLog
+import com.android.wm.shell.common.InputChannelSupplier
+import com.android.wm.shell.common.WindowSessionSupplier
+import com.android.wm.shell.compatui.letterbox.LetterboxUtils.Maps.runOnItem
+import com.android.wm.shell.dagger.WMSingleton
+import com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_APP_COMPAT
+import java.util.function.Supplier
+import javax.inject.Inject
+
+/**
+ * [LetterboxController] implementation responsible for handling the spy [SurfaceControl] we use
+ * to detect letterbox events.
+ */
+@WMSingleton
+class LetterboxInputController @Inject constructor(
+ private val context: Context,
+ private val handler: Handler,
+ private val inputSurfaceBuilder: LetterboxInputSurfaceBuilder,
+ private val listenerSupplier: Supplier<LetterboxGestureListener>,
+ private val windowSessionSupplier: WindowSessionSupplier,
+ private val inputChannelSupplier: InputChannelSupplier
+) : LetterboxController {
+
+ companion object {
+ @JvmStatic
+ private val TAG = "LetterboxInputController"
+ }
+
+ private val inputDetectorMap = mutableMapOf<LetterboxKey, LetterboxInputDetector>()
+
+ override fun createLetterboxSurface(
+ key: LetterboxKey,
+ transaction: Transaction,
+ parentLeash: SurfaceControl
+ ) {
+ inputDetectorMap.runOnItem(key, onMissed = { k, m ->
+ m[k] =
+ LetterboxInputDetector(
+ context,
+ handler,
+ listenerSupplier.get(),
+ inputSurfaceBuilder,
+ windowSessionSupplier,
+ inputChannelSupplier
+ ).apply {
+ start(transaction, parentLeash, key)
+ }
+ })
+ }
+
+ override fun destroyLetterboxSurface(
+ key: LetterboxKey,
+ transaction: Transaction
+ ) {
+ with(inputDetectorMap) {
+ runOnItem(key, onFound = { item ->
+ item.stop(transaction)
+ })
+ remove(key)
+ }
+ }
+
+ override fun updateLetterboxSurfaceVisibility(
+ key: LetterboxKey,
+ transaction: Transaction,
+ visible: Boolean
+ ) {
+ with(inputDetectorMap) {
+ runOnItem(key, onFound = { item ->
+ item.updateVisibility(transaction, visible)
+ })
+ }
+ }
+
+ override fun updateLetterboxSurfaceBounds(
+ key: LetterboxKey,
+ transaction: Transaction,
+ taskBounds: Rect,
+ activityBounds: Rect
+ ) {
+ inputDetectorMap.runOnItem(key, onFound = { item ->
+ item.updateTouchableRegion(transaction, Region(taskBounds))
+ })
+ }
+
+ override fun dump() {
+ ProtoLog.v(WM_SHELL_APP_COMPAT, "%s: %s", TAG, "${inputDetectorMap.keys}")
+ }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/letterbox/LetterboxInputDetector.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/letterbox/LetterboxInputDetector.kt
new file mode 100644
index 000000000000..812cc0161aae
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/letterbox/LetterboxInputDetector.kt
@@ -0,0 +1,230 @@
+/*
+ * Copyright 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.wm.shell.compatui.letterbox
+
+import android.content.Context
+import android.graphics.Region
+import android.os.Binder
+import android.os.Handler
+import android.os.IBinder
+import android.os.RemoteException
+import android.view.GestureDetector
+import android.view.IWindowSession
+import android.view.InputChannel
+import android.view.InputEvent
+import android.view.InputEventReceiver
+import android.view.MotionEvent
+import android.view.SurfaceControl
+import android.view.SurfaceControl.Transaction
+import android.view.WindowManager
+import android.window.InputTransferToken
+import com.android.internal.protolog.ProtoLog
+import com.android.wm.shell.common.InputChannelSupplier
+import com.android.wm.shell.common.WindowSessionSupplier
+import com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_APP_COMPAT
+
+/**
+ * This is responsible for detecting events on a given [SurfaceControl].
+ */
+class LetterboxInputDetector(
+ private val context: Context,
+ private val handler: Handler,
+ private val listener: LetterboxGestureListener,
+ private val inputSurfaceBuilder: LetterboxInputSurfaceBuilder,
+ private val windowSessionSupplier: WindowSessionSupplier,
+ private val inputChannelSupplier: InputChannelSupplier
+) {
+
+ companion object {
+ @JvmStatic
+ private val TAG = "LetterboxInputDetector"
+ }
+
+ private var state: InputDetectorState? = null
+
+ fun start(tx: Transaction, source: SurfaceControl, key: LetterboxKey) {
+ if (!isRunning()) {
+ val tmpState =
+ InputDetectorState(
+ context,
+ handler,
+ source,
+ key.displayId,
+ listener,
+ inputSurfaceBuilder,
+ windowSessionSupplier.get(),
+ inputChannelSupplier
+ )
+ if (tmpState.start(tx)) {
+ state = tmpState
+ } else {
+ ProtoLog.v(
+ WM_SHELL_APP_COMPAT,
+ "%s not started for %s on %s",
+ TAG,
+ "$source",
+ "$key"
+ )
+ }
+ }
+ }
+
+ fun updateTouchableRegion(tx: Transaction, region: Region) {
+ if (isRunning()) {
+ state?.setTouchableRegion(tx, region)
+ }
+ }
+
+ fun isRunning() = state != null
+
+ fun updateVisibility(tx: Transaction, visible: Boolean) {
+ if (isRunning()) {
+ state?.updateVisibility(tx, visible)
+ }
+ }
+
+ fun stop(tx: Transaction) {
+ if (isRunning()) {
+ state!!.stop(tx)
+ state = null
+ }
+ }
+
+ /**
+ * The state for a {@link SurfaceControl} for a given displayId.
+ */
+ private class InputDetectorState(
+ val context: Context,
+ val handler: Handler,
+ val source: SurfaceControl,
+ val displayId: Int,
+ val listener: LetterboxGestureListener,
+ val inputSurfaceBuilder: LetterboxInputSurfaceBuilder,
+ val windowSession: IWindowSession,
+ inputChannelSupplier: InputChannelSupplier
+ ) {
+
+ private val inputToken: IBinder
+ private val inputChannel: InputChannel
+ private var receiver: EventReceiver? = null
+ private var inputSurface: SurfaceControl? = null
+
+ init {
+ inputToken = Binder()
+ inputChannel = inputChannelSupplier.get()
+ }
+
+ fun start(tx: Transaction): Boolean {
+ val inputTransferToken = InputTransferToken()
+ try {
+ inputSurface =
+ inputSurfaceBuilder.createInputSurface(
+ tx,
+ source,
+ "Sink for $source",
+ "$TAG creation"
+ )
+ windowSession.grantInputChannel(
+ displayId,
+ inputSurface,
+ inputToken,
+ null,
+ WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
+ WindowManager.LayoutParams.PRIVATE_FLAG_TRUSTED_OVERLAY,
+ WindowManager.LayoutParams.INPUT_FEATURE_SPY,
+ WindowManager.LayoutParams.TYPE_INPUT_CONSUMER,
+ null,
+ inputTransferToken,
+ "$TAG of $source",
+ inputChannel
+ )
+
+ receiver = EventReceiver(context, inputChannel, handler, listener)
+ return true
+ } catch (e: RemoteException) {
+ e.rethrowFromSystemServer()
+ }
+ return false
+ }
+
+ fun setTouchableRegion(tx: Transaction, region: Region) {
+ try {
+ tx.setWindowCrop(inputSurface, region.bounds.width(), region.bounds.height())
+
+ windowSession.updateInputChannel(
+ inputChannel.token,
+ displayId,
+ inputSurface,
+ WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
+ WindowManager.LayoutParams.PRIVATE_FLAG_TRUSTED_OVERLAY,
+ WindowManager.LayoutParams.INPUT_FEATURE_SPY,
+ region
+ )
+ } catch (e: RemoteException) {
+ e.rethrowFromSystemServer()
+ }
+ }
+
+ fun updateVisibility(tx: Transaction, visible: Boolean) {
+ inputSurface?.let {
+ tx.setVisibility(it, visible)
+ }
+ }
+
+ fun stop(tx: Transaction) {
+ receiver?.dispose()
+ receiver = null
+ inputChannel.dispose()
+ windowSession.removeToken(inputToken)
+ inputSurface?.let { s ->
+ tx.remove(s)
+ }
+ }
+
+ // Removes the provided token
+ private fun IWindowSession.removeToken(token: IBinder) {
+ try {
+ remove(token)
+ } catch (e: RemoteException) {
+ e.rethrowFromSystemServer()
+ }
+ }
+ }
+
+ /**
+ * Reads from the provided {@link InputChannel} and identifies a specific event.
+ */
+ private class EventReceiver(
+ context: Context,
+ inputChannel: InputChannel,
+ uiHandler: Handler,
+ listener: LetterboxGestureListener
+ ) : InputEventReceiver(inputChannel, uiHandler.looper) {
+ private val eventDetector: GestureDetector
+
+ init {
+ eventDetector = GestureDetector(
+ context, listener,
+ uiHandler
+ )
+ }
+
+ override fun onInputEvent(event: InputEvent) {
+ finishInputEvent(event, eventDetector.onTouchEvent(event as MotionEvent))
+ }
+ }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/letterbox/LetterboxInputSurfaceBuilder.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/letterbox/LetterboxInputSurfaceBuilder.kt
new file mode 100644
index 000000000000..fd8d86576115
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/letterbox/LetterboxInputSurfaceBuilder.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright 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.wm.shell.compatui.letterbox
+
+import android.view.SurfaceControl
+import android.view.SurfaceControl.Transaction
+import com.android.wm.shell.common.transition.SurfaceBuilderSupplier
+import com.android.wm.shell.dagger.WMSingleton
+import javax.inject.Inject
+
+/**
+ * Component responsible for the actual creation of the Letterbox surfaces.
+ */
+@WMSingleton
+class LetterboxInputSurfaceBuilder @Inject constructor(
+ private val surfaceBuilderSupplier: SurfaceBuilderSupplier
+) {
+
+ companion object {
+ /*
+ * Letterbox spy surfaces need to stay above the activity layer which is 0.
+ */
+ // TODO(b/378673153): Consider adding this to [TaskConstants].
+ @JvmStatic
+ private val TASK_CHILD_LAYER_LETTERBOX_SPY = 1000
+ }
+
+ fun createInputSurface(
+ tx: Transaction,
+ parentLeash: SurfaceControl,
+ surfaceName: String,
+ callSite: String
+ ) = surfaceBuilderSupplier.get()
+ .setName(surfaceName)
+ .setContainerLayer()
+ .setParent(parentLeash)
+ .setCallsite(callSite)
+ .build().apply {
+ tx.setLayer(this, TASK_CHILD_LAYER_LETTERBOX_SPY)
+ .setTrustedOverlay(this, true)
+ .show(this)
+ .apply()
+ }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeVisualIndicator.java b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeVisualIndicator.java
index 621ccba40db2..27aed17762ff 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeVisualIndicator.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeVisualIndicator.java
@@ -60,6 +60,7 @@ import com.android.wm.shell.common.DisplayController;
import com.android.wm.shell.common.DisplayLayout;
import com.android.wm.shell.common.SyncTransactionQueue;
import com.android.wm.shell.shared.bubbles.BubbleAnythingFlagHelper;
+import com.android.wm.shell.shared.bubbles.BubbleDropTargetBoundsProvider;
import com.android.wm.shell.shared.desktopmode.DesktopModeStatus;
/**
@@ -76,7 +77,11 @@ public class DesktopModeVisualIndicator {
/** Indicates impending transition into split select on the left side */
TO_SPLIT_LEFT_INDICATOR,
/** Indicates impending transition into split select on the right side */
- TO_SPLIT_RIGHT_INDICATOR
+ TO_SPLIT_RIGHT_INDICATOR,
+ /** Indicates impending transition into bubble on the left side */
+ TO_BUBBLE_LEFT_INDICATOR,
+ /** Indicates impending transition into bubble on the right side */
+ TO_BUBBLE_RIGHT_INDICATOR
}
/**
@@ -115,6 +120,7 @@ public class DesktopModeVisualIndicator {
private final RootTaskDisplayAreaOrganizer mRootTdaOrganizer;
private final ActivityManager.RunningTaskInfo mTaskInfo;
private final SurfaceControl mTaskSurface;
+ private final @Nullable BubbleDropTargetBoundsProvider mBubbleBoundsProvider;
private SurfaceControl mLeash;
private final SyncTransactionQueue mSyncQueue;
@@ -129,13 +135,15 @@ public class DesktopModeVisualIndicator {
ActivityManager.RunningTaskInfo taskInfo, DisplayController displayController,
Context context, SurfaceControl taskSurface,
RootTaskDisplayAreaOrganizer taskDisplayAreaOrganizer,
- DragStartState dragStartState) {
+ DragStartState dragStartState,
+ @Nullable BubbleDropTargetBoundsProvider bubbleBoundsProvider) {
mSyncQueue = syncQueue;
mTaskInfo = taskInfo;
mDisplayController = displayController;
mContext = context;
mTaskSurface = taskSurface;
mRootTdaOrganizer = taskDisplayAreaOrganizer;
+ mBubbleBoundsProvider = bubbleBoundsProvider;
mCurrentType = NO_INDICATOR;
mDragStartState = dragStartState;
}
@@ -175,15 +183,24 @@ public class DesktopModeVisualIndicator {
captionHeight);
final Region splitRightRegion = calculateSplitRightRegion(layout, transitionAreaWidth,
captionHeight);
- if (fullscreenRegion.contains((int) inputCoordinates.x, (int) inputCoordinates.y)) {
+ final int x = (int) inputCoordinates.x;
+ final int y = (int) inputCoordinates.y;
+ if (fullscreenRegion.contains(x, y)) {
result = TO_FULLSCREEN_INDICATOR;
}
- if (splitLeftRegion.contains((int) inputCoordinates.x, (int) inputCoordinates.y)) {
+ if (splitLeftRegion.contains(x, y)) {
result = IndicatorType.TO_SPLIT_LEFT_INDICATOR;
}
- if (splitRightRegion.contains((int) inputCoordinates.x, (int) inputCoordinates.y)) {
+ if (splitRightRegion.contains(x, y)) {
result = IndicatorType.TO_SPLIT_RIGHT_INDICATOR;
}
+ if (BubbleAnythingFlagHelper.enableBubbleToFullscreen()) {
+ if (calculateBubbleLeftRegion(layout).contains(x, y)) {
+ result = IndicatorType.TO_BUBBLE_LEFT_INDICATOR;
+ } else if (calculateBubbleRightRegion(layout).contains(x, y)) {
+ result = IndicatorType.TO_BUBBLE_RIGHT_INDICATOR;
+ }
+ }
if (mDragStartState != DragStartState.DRAGGED_INTENT) {
transitionIndicator(result);
}
@@ -247,6 +264,25 @@ public class DesktopModeVisualIndicator {
return region;
}
+ @VisibleForTesting
+ Region calculateBubbleLeftRegion(DisplayLayout layout) {
+ int regionWidth = mContext.getResources().getDimensionPixelSize(
+ com.android.wm.shell.R.dimen.bubble_transform_area_width);
+ int regionHeight = mContext.getResources().getDimensionPixelSize(
+ com.android.wm.shell.R.dimen.bubble_transform_area_height);
+ return new Region(0, layout.height() - regionHeight, regionWidth, layout.height());
+ }
+
+ @VisibleForTesting
+ Region calculateBubbleRightRegion(DisplayLayout layout) {
+ int regionWidth = mContext.getResources().getDimensionPixelSize(
+ com.android.wm.shell.R.dimen.bubble_transform_area_width);
+ int regionHeight = mContext.getResources().getDimensionPixelSize(
+ com.android.wm.shell.R.dimen.bubble_transform_area_height);
+ return new Region(layout.width() - regionWidth, layout.height() - regionHeight,
+ layout.width(), layout.height());
+ }
+
/**
* Create a fullscreen indicator with no animation
*/
@@ -297,6 +333,11 @@ public class DesktopModeVisualIndicator {
});
}
+ @VisibleForTesting
+ Rect getIndicatorBounds() {
+ return mView.getBackground().getBounds();
+ }
+
/**
* Fade indicator in as provided type. Animator fades it in while expanding the bounds outwards.
*/
@@ -304,7 +345,8 @@ public class DesktopModeVisualIndicator {
mView.setBackgroundResource(R.drawable.desktop_windowing_transition_background);
final VisualIndicatorAnimator animator = VisualIndicatorAnimator
.fadeBoundsIn(mView, type,
- mDisplayController.getDisplayLayout(mTaskInfo.displayId));
+ mDisplayController.getDisplayLayout(mTaskInfo.displayId),
+ mBubbleBoundsProvider);
animator.start();
mCurrentType = type;
}
@@ -323,7 +365,8 @@ public class DesktopModeVisualIndicator {
}
final VisualIndicatorAnimator animator = VisualIndicatorAnimator
.fadeBoundsOut(mView, mCurrentType,
- mDisplayController.getDisplayLayout(mTaskInfo.displayId));
+ mDisplayController.getDisplayLayout(mTaskInfo.displayId),
+ mBubbleBoundsProvider);
animator.start();
if (finishCallback != null) {
animator.addListener(new AnimatorListenerAdapter() {
@@ -351,7 +394,7 @@ public class DesktopModeVisualIndicator {
} else {
final VisualIndicatorAnimator animator = VisualIndicatorAnimator.animateIndicatorType(
mView, mDisplayController.getDisplayLayout(mTaskInfo.displayId), mCurrentType,
- newType);
+ newType, mBubbleBoundsProvider);
mCurrentType = newType;
animator.start();
}
@@ -406,8 +449,9 @@ public class DesktopModeVisualIndicator {
}
private static VisualIndicatorAnimator fadeBoundsIn(
- @NonNull View view, IndicatorType type, @NonNull DisplayLayout displayLayout) {
- final Rect endBounds = getIndicatorBounds(displayLayout, type);
+ @NonNull View view, IndicatorType type, @NonNull DisplayLayout displayLayout,
+ @Nullable BubbleDropTargetBoundsProvider bubbleBoundsProvider) {
+ final Rect endBounds = getIndicatorBounds(displayLayout, type, bubbleBoundsProvider);
final Rect startBounds = getMinBounds(endBounds);
view.getBackground().setBounds(startBounds);
@@ -419,8 +463,9 @@ public class DesktopModeVisualIndicator {
}
private static VisualIndicatorAnimator fadeBoundsOut(
- @NonNull View view, IndicatorType type, @NonNull DisplayLayout displayLayout) {
- final Rect startBounds = getIndicatorBounds(displayLayout, type);
+ @NonNull View view, IndicatorType type, @NonNull DisplayLayout displayLayout,
+ @Nullable BubbleDropTargetBoundsProvider bubbleBoundsProvider) {
+ final Rect startBounds = getIndicatorBounds(displayLayout, type, bubbleBoundsProvider);
final Rect endBounds = getMinBounds(startBounds);
view.getBackground().setBounds(startBounds);
@@ -435,16 +480,19 @@ public class DesktopModeVisualIndicator {
* Create animator for visual indicator changing type (i.e., fullscreen to freeform,
* freeform to split, etc.)
*
- * @param view the view for this indicator
- * @param displayLayout information about the display the transitioning task is currently on
- * @param origType the original indicator type
- * @param newType the new indicator type
+ * @param view the view for this indicator
+ * @param displayLayout information about the display the transitioning task is
+ * currently on
+ * @param origType the original indicator type
+ * @param newType the new indicator type
+ * @param bubbleBoundsProvider provides bounds for bubbles indicators
*/
private static VisualIndicatorAnimator animateIndicatorType(@NonNull View view,
- @NonNull DisplayLayout displayLayout, IndicatorType origType,
- IndicatorType newType) {
- final Rect startBounds = getIndicatorBounds(displayLayout, origType);
- final Rect endBounds = getIndicatorBounds(displayLayout, newType);
+ @NonNull DisplayLayout displayLayout, IndicatorType origType, IndicatorType newType,
+ @Nullable BubbleDropTargetBoundsProvider bubbleBoundsProvider) {
+ final Rect startBounds = getIndicatorBounds(displayLayout, origType,
+ bubbleBoundsProvider);
+ final Rect endBounds = getIndicatorBounds(displayLayout, newType, bubbleBoundsProvider);
final VisualIndicatorAnimator animator = new VisualIndicatorAnimator(
view, startBounds, endBounds);
animator.setInterpolator(new DecelerateInterpolator());
@@ -453,7 +501,8 @@ public class DesktopModeVisualIndicator {
}
/** Calculates the bounds the indicator should have when fully faded in. */
- private static Rect getIndicatorBounds(DisplayLayout layout, IndicatorType type) {
+ private static Rect getIndicatorBounds(DisplayLayout layout, IndicatorType type,
+ @Nullable BubbleDropTargetBoundsProvider bubbleBoundsProvider) {
final Rect desktopStableBounds = new Rect();
layout.getStableBounds(desktopStableBounds);
final int padding = desktopStableBounds.top;
@@ -481,6 +530,18 @@ public class DesktopModeVisualIndicator {
return new Rect(desktopStableBounds.width() / 2 + padding, padding,
desktopStableBounds.width() - padding,
desktopStableBounds.height());
+ case TO_BUBBLE_LEFT_INDICATOR:
+ if (bubbleBoundsProvider == null) {
+ return new Rect();
+ }
+ return bubbleBoundsProvider.getBubbleBarExpandedViewDropTargetBounds(
+ /* onLeft= */ true);
+ case TO_BUBBLE_RIGHT_INDICATOR:
+ if (bubbleBoundsProvider == null) {
+ return new Rect();
+ }
+ return bubbleBoundsProvider.getBubbleBarExpandedViewDropTargetBounds(
+ /* onLeft= */ false);
default:
throw new IllegalArgumentException("Invalid indicator type provided.");
}
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 fb4016c4e7b6..8cf7cbd26d39 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
@@ -150,6 +150,7 @@ import java.util.Optional
import java.util.concurrent.Executor
import java.util.concurrent.TimeUnit
import java.util.function.Consumer
+import kotlin.jvm.optionals.getOrNull
/** Handles moving tasks in and out of desktop */
class DesktopTasksController(
@@ -571,32 +572,12 @@ class DesktopTasksController(
)
val taskIdToMinimize =
- if (Flags.enableMultipleDesktopsBackend()) {
- // Activate the desk first.
- prepareForDeskActivation(displayId, wct)
- desksOrganizer.activateDesk(wct, deskId)
- if (DesktopModeFlags.ENABLE_DESKTOP_WINDOWING_PERSISTENCE.isTrue()) {
- // TODO: 362720497 - do non-running tasks need to be restarted with
- // |wct#startTask|?
- }
- taskbarDesktopTaskListener?.onTaskbarCornerRoundingUpdate(
- doesAnyTaskRequireTaskbarRounding(displayId)
- )
- // TODO: 362720497 - activating a desk with the intention to move a new task to it
- // means we may need to minimize something in the activating desk. Do so here
- // similar
- // to how it's done in #bringDesktopAppsToFrontBeforeShowingNewTask instead of
- // returning null.
- null
- } else {
- // Bring other apps to front first.
- bringDesktopAppsToFrontBeforeShowingNewTask(task.displayId, wct, task.taskId)
- }
- if (Flags.enableMultipleDesktopsBackend()) {
- prepareMoveTaskToDesk(wct, task, deskId)
- } else {
- addMoveToDesktopChanges(wct, task)
- }
+ prepareMoveTaskToDeskAndActivate(
+ wct = wct,
+ displayId = displayId,
+ deskId = deskId,
+ task = task,
+ )
val transition: IBinder
if (remoteTransition != null) {
@@ -629,6 +610,48 @@ class DesktopTasksController(
}
}
+ /**
+ * Applies the necessary changes and operations to [wct] to move a task into a desk and
+ * activating that desk. This includes showing pre-existing tasks of that desk behind the new
+ * task (but minimizing one of them if needed) and showing Home and the desktop wallpaper.
+ *
+ * @return the id of the task that is being minimized, if any.
+ */
+ private fun prepareMoveTaskToDeskAndActivate(
+ wct: WindowContainerTransaction,
+ displayId: Int,
+ deskId: Int,
+ task: RunningTaskInfo,
+ ): Int? {
+ val taskIdToMinimize =
+ if (Flags.enableMultipleDesktopsBackend()) {
+ // Activate the desk first.
+ prepareForDeskActivation(displayId, wct)
+ desksOrganizer.activateDesk(wct, deskId)
+ if (DesktopModeFlags.ENABLE_DESKTOP_WINDOWING_PERSISTENCE.isTrue()) {
+ // TODO: b/362720497 - do non-running tasks need to be restarted with
+ // |wct#startTask|?
+ }
+ taskbarDesktopTaskListener?.onTaskbarCornerRoundingUpdate(
+ doesAnyTaskRequireTaskbarRounding(displayId)
+ )
+ // TODO: b/362720497 - activating a desk with the intention to move a new task to
+ // it means we may need to minimize something in the activating desk. Do so here
+ // similar to how it's done in #bringDesktopAppsToFrontBeforeShowingNewTask
+ // instead of returning null.
+ null
+ } else {
+ // Bring other apps to front first.
+ bringDesktopAppsToFrontBeforeShowingNewTask(displayId, wct, task.taskId)
+ }
+ if (Flags.enableMultipleDesktopsBackend()) {
+ prepareMoveTaskToDesk(wct, task, deskId)
+ } else {
+ addMoveToDesktopChanges(wct, task)
+ }
+ return taskIdToMinimize
+ }
+
private fun invokeCallbackToOverview(transition: IBinder, callback: IMoveToDesktopCallback?) {
// TODO: b/333524374 - Remove this later.
// This is a temporary implementation for adding CUJ end and
@@ -668,21 +691,34 @@ class DesktopTasksController(
* [startDragToDesktop].
*/
private fun finalizeDragToDesktop(taskInfo: RunningTaskInfo) {
+ val deskId =
+ checkNotNull(taskRepository.getDefaultDeskId(taskInfo.displayId)) {
+ "Expected a default desk to exist"
+ }
ProtoLog.v(
WM_SHELL_DESKTOP_MODE,
- "DesktopTasksController: finalizeDragToDesktop taskId=%d",
+ "DesktopTasksController: finalizeDragToDesktop taskId=%d deskId=%d",
taskInfo.taskId,
+ deskId,
)
val wct = WindowContainerTransaction()
exitSplitIfApplicable(wct, taskInfo)
- if (Flags.enablePerDisplayDesktopWallpaperActivity()) {
- moveHomeTask(taskInfo.displayId, wct)
- } else {
- moveHomeTask(context.displayId, wct)
+ if (!Flags.enableMultipleDesktopsBackend()) {
+ // |moveHomeTask| is also called in |bringDesktopAppsToFrontBeforeShowingNewTask|, so
+ // this shouldn't be necessary at all.
+ if (Flags.enablePerDisplayDesktopWallpaperActivity()) {
+ moveHomeTask(taskInfo.displayId, wct)
+ } else {
+ moveHomeTask(context.displayId, wct)
+ }
}
val taskIdToMinimize =
- bringDesktopAppsToFrontBeforeShowingNewTask(taskInfo.displayId, wct, taskInfo.taskId)
- addMoveToDesktopChanges(wct, taskInfo)
+ prepareMoveTaskToDeskAndActivate(
+ wct = wct,
+ displayId = taskInfo.displayId,
+ deskId = deskId,
+ task = taskInfo,
+ )
val exitResult =
desktopImmersiveController.exitImmersiveIfApplicable(
wct = wct,
@@ -699,6 +735,18 @@ class DesktopTasksController(
addPendingMinimizeTransition(it, taskId, MinimizeReason.TASK_LIMIT)
}
exitResult.asExit()?.runOnTransitionStart?.invoke(transition)
+ if (Flags.enableMultipleDesktopsBackend()) {
+ desksTransitionObserver.addPendingTransition(
+ DeskTransition.ActiveDeskWithTask(
+ token = transition,
+ displayId = taskInfo.displayId,
+ deskId = deskId,
+ enterTaskId = taskInfo.taskId,
+ )
+ )
+ } else {
+ taskRepository.setActiveDesk(displayId = taskInfo.displayId, deskId = deskId)
+ }
}
}
@@ -2706,6 +2754,7 @@ class DesktopTasksController(
taskSurface,
rootTaskDisplayAreaOrganizer,
dragStartState,
+ bubbleController.getOrNull()?.bubbleDropTargetBoundsProvider,
)
if (visualIndicator == null) visualIndicator = indicator
return indicator.updateIndicatorType(PointF(inputX, taskTop))
@@ -2788,7 +2837,11 @@ class DesktopTasksController(
desktopModeWindowDecoration,
)
}
- IndicatorType.NO_INDICATOR -> {
+ IndicatorType.NO_INDICATOR,
+ IndicatorType.TO_BUBBLE_LEFT_INDICATOR,
+ IndicatorType.TO_BUBBLE_RIGHT_INDICATOR -> {
+ // TODO(b/391928049): add support fof dragging desktop apps to a bubble
+
// Create a copy so that we can animate from the current bounds if we end up having
// to snap the surface back without a WCT change.
val destinationBounds = Rect(currentDragBounds)
@@ -2915,6 +2968,11 @@ class DesktopTasksController(
)
requestSplit(taskInfo, leftOrTop = false)
}
+ IndicatorType.TO_BUBBLE_LEFT_INDICATOR,
+ IndicatorType.TO_BUBBLE_RIGHT_INDICATOR -> {
+ // TODO(b/388851898): move to bubble
+ cancelDragToDesktop(taskInfo)
+ }
}
return indicatorType
}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipTouchHandler.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipTouchHandler.java
index f4c2a33079ba..ac94dac0e6a3 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipTouchHandler.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipTouchHandler.java
@@ -909,10 +909,6 @@ public class PipTouchHandler {
&& mMenuState != MENU_STATE_FULL) {
// If using pinch to zoom, double-tap functions as resizing between max/min size
if (mPipResizeGestureHandler.isUsingPinchToZoom()) {
- final boolean toExpand = mPipBoundsState.getBounds().width()
- < mPipBoundsState.getMaxSize().x
- && mPipBoundsState.getBounds().height()
- < mPipBoundsState.getMaxSize().y;
if (mMenuController.isMenuVisible()) {
mMenuController.hideMenu(ANIM_TYPE_NONE, false /* resize */);
}
@@ -931,6 +927,7 @@ public class PipTouchHandler {
} else {
animateToUnexpandedState(getUserResizeBounds());
}
+ mPipBoundsState.setHasUserResizedPip(true);
} else {
// Expand to fullscreen if this is a double tap
// the PiP should be frozen until the transition ends
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/TvPipMenuController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/TvPipMenuController.java
index e74870d4d139..5894ea8d0b5c 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/TvPipMenuController.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/TvPipMenuController.java
@@ -32,6 +32,7 @@ import android.view.View;
import android.view.ViewRootImpl;
import android.view.WindowManager;
import android.view.WindowManagerGlobal;
+import android.view.accessibility.AccessibilityManager;
import android.window.SurfaceSyncGroup;
import androidx.annotation.Nullable;
@@ -63,6 +64,8 @@ public class TvPipMenuController implements PipMenuController, TvPipMenuView.Lis
private TvPipMenuView mPipMenuView;
private TvPipBackgroundView mPipBackgroundView;
+ private final AccessibilityManager mA11yManager;
+
private boolean mIsReloading;
private static final int PIP_MENU_FORCE_CLOSE_DELAY_MS = 10_000;
private final Runnable mClosePipMenuRunnable = this::closeMenu;
@@ -107,6 +110,8 @@ public class TvPipMenuController implements PipMenuController, TvPipMenuView.Lis
mSystemWindows = systemWindows;
mMainHandler = mainHandler;
+ mA11yManager = context.getSystemService(AccessibilityManager.class);
+
// We need to "close" the menu the platform call for all the system dialogs to close (for
// example, on the Home button press).
final BroadcastReceiver closeSystemDialogsBroadcastReceiver = new BroadcastReceiver() {
@@ -499,7 +504,9 @@ public class TvPipMenuController implements PipMenuController, TvPipMenuView.Lis
switchToMenuMode(menuMode);
} else {
if (isMenuOpen(menuMode)) {
- mMainHandler.postDelayed(mClosePipMenuRunnable, PIP_MENU_FORCE_CLOSE_DELAY_MS);
+ if (!mA11yManager.isEnabled()) {
+ mMainHandler.postDelayed(mClosePipMenuRunnable, PIP_MENU_FORCE_CLOSE_DELAY_MS);
+ }
mMenuModeOnFocus = menuMode;
}
// Send a request to gain window focus if the menu is open, or lose window focus
@@ -594,8 +601,10 @@ public class TvPipMenuController implements PipMenuController, TvPipMenuView.Lis
public void onUserInteracting() {
ProtoLog.d(ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE,
"%s: onUserInteracting - mCurrentMenuMode=%s", TAG, getMenuModeString());
- mMainHandler.removeCallbacks(mClosePipMenuRunnable);
- mMainHandler.postDelayed(mClosePipMenuRunnable, PIP_MENU_FORCE_CLOSE_DELAY_MS);
+ if (mMainHandler.hasCallbacks(mClosePipMenuRunnable)) {
+ mMainHandler.removeCallbacks(mClosePipMenuRunnable);
+ mMainHandler.postDelayed(mClosePipMenuRunnable, PIP_MENU_FORCE_CLOSE_DELAY_MS);
+ }
}
@Override
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip2/phone/PipTouchHandler.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip2/phone/PipTouchHandler.java
index 35cd1a2e681f..e405f3339054 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/pip2/phone/PipTouchHandler.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip2/phone/PipTouchHandler.java
@@ -987,6 +987,7 @@ public class PipTouchHandler implements PipTransitionState.PipTransitionStateCha
} else {
animateToUnexpandedState(getUserResizeBounds());
}
+ mPipBoundsState.setHasUserResizedPip(true);
} else {
// Expand to fullscreen if this is a double tap
// the PiP should be frozen until the transition ends
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/taskview/TaskViewTransitions.java b/libs/WindowManager/Shell/src/com/android/wm/shell/taskview/TaskViewTransitions.java
index 9af23080351f..a6f872634ee9 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/taskview/TaskViewTransitions.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/taskview/TaskViewTransitions.java
@@ -52,6 +52,7 @@ import com.android.wm.shell.Flags;
import com.android.wm.shell.ShellTaskOrganizer;
import com.android.wm.shell.common.SyncTransactionQueue;
import com.android.wm.shell.shared.TransitionUtil;
+import com.android.wm.shell.shared.bubbles.BubbleAnythingFlagHelper;
import com.android.wm.shell.transition.Transitions;
import java.util.ArrayList;
@@ -571,7 +572,7 @@ public class TaskViewTransitions implements Transitions.TransitionHandler, TaskV
@NonNull SurfaceControl.Transaction startTransaction,
@NonNull SurfaceControl.Transaction finishTransaction,
@NonNull Transitions.TransitionFinishCallback finishCallback) {
- PendingTransition pending = findPending(transition);
+ final PendingTransition pending = findPending(transition);
if (pending != null) {
mPending.remove(pending);
}
@@ -586,10 +587,11 @@ public class TaskViewTransitions implements Transitions.TransitionHandler, TaskV
WindowContainerTransaction wct = null;
for (int i = 0; i < info.getChanges().size(); ++i) {
final TransitionInfo.Change chg = info.getChanges().get(i);
- if (chg.getTaskInfo() == null) continue;
+ final ActivityManager.RunningTaskInfo taskInfo = chg.getTaskInfo();
+ if (taskInfo == null) continue;
if (TransitionUtil.isClosingType(chg.getMode())) {
final boolean isHide = chg.getMode() == TRANSIT_TO_BACK;
- TaskViewTaskController tv = findTaskView(chg.getTaskInfo());
+ TaskViewTaskController tv = findTaskView(taskInfo);
if (tv == null && !isHide) {
// TaskView can be null when closing
changesHandled++;
@@ -599,7 +601,7 @@ public class TaskViewTransitions implements Transitions.TransitionHandler, TaskV
if (pending != null) {
Slog.w(TAG, "Found a non-TaskView task in a TaskView Transition. This "
+ "shouldn't happen, so there may be a visual artifact: "
- + chg.getTaskInfo().taskId);
+ + taskInfo.taskId);
}
continue;
}
@@ -615,40 +617,51 @@ public class TaskViewTransitions implements Transitions.TransitionHandler, TaskV
}
changesHandled++;
} else if (TransitionUtil.isOpeningType(chg.getMode())) {
- final boolean taskIsNew = chg.getMode() == TRANSIT_OPEN;
- final TaskViewTaskController tv;
- if (taskIsNew) {
- if (pending == null
- || !chg.getTaskInfo().containsLaunchCookie(pending.mLaunchCookie)) {
+ boolean isNewInTaskView = false;
+ TaskViewTaskController tv;
+ if (chg.getMode() == TRANSIT_OPEN) {
+ isNewInTaskView = true;
+ if (pending == null || !taskInfo.containsLaunchCookie(pending.mLaunchCookie)) {
Slog.e(TAG, "Found a launching TaskView in the wrong transition. All "
+ "TaskView launches should be initiated by shell and in their "
- + "own transition: " + chg.getTaskInfo().taskId);
+ + "own transition: " + taskInfo.taskId);
continue;
}
stillNeedsMatchingLaunch = false;
tv = pending.mTaskView;
} else {
- tv = findTaskView(chg.getTaskInfo());
- if (tv == null) {
- if (pending != null) {
- Slog.w(TAG, "Found a non-TaskView task in a TaskView Transition. This "
- + "shouldn't happen, so there may be a visual artifact: "
- + chg.getTaskInfo().taskId);
+ tv = findTaskView(taskInfo);
+ if (tv == null && pending != null) {
+ if (BubbleAnythingFlagHelper.enableCreateAnyBubble()
+ && chg.getMode() == TRANSIT_TO_FRONT
+ && pending.mTaskView.getPendingInfo() != null
+ && pending.mTaskView.getPendingInfo().taskId == taskInfo.taskId) {
+ // In this case an existing task, not currently in TaskView, is
+ // brought to the front to be moved into TaskView. This is still
+ // "new" from TaskView's perspective. (e.g. task being moved into a
+ // bubble)
+ isNewInTaskView = true;
+ stillNeedsMatchingLaunch = false;
+ tv = pending.mTaskView;
+ } else {
+ Slog.w(TAG, "Found a non-TaskView task in a TaskView Transition. "
+ + "This shouldn't happen, so there may be a visual "
+ + "artifact: " + taskInfo.taskId);
}
- continue;
}
+ if (tv == null) continue;
}
if (wct == null) wct = new WindowContainerTransaction();
- prepareOpenAnimation(tv, taskIsNew, startTransaction, finishTransaction,
- chg.getTaskInfo(), chg.getLeash(), wct);
+ prepareOpenAnimation(tv, isNewInTaskView, startTransaction, finishTransaction,
+ taskInfo, chg.getLeash(), wct);
changesHandled++;
} else if (chg.getMode() == TRANSIT_CHANGE) {
- TaskViewTaskController tv = findTaskView(chg.getTaskInfo());
+ TaskViewTaskController tv = findTaskView(taskInfo);
if (tv == null) {
if (pending != null) {
Slog.w(TAG, "Found a non-TaskView task in a TaskView Transition. This "
+ "shouldn't happen, so there may be a visual artifact: "
- + chg.getTaskInfo().taskId);
+ + taskInfo.taskId);
}
continue;
}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/transition/Transitions.java b/libs/WindowManager/Shell/src/com/android/wm/shell/transition/Transitions.java
index 72cbc4702ac8..c90f6cf62b7e 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/transition/Transitions.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/transition/Transitions.java
@@ -193,6 +193,9 @@ public class Transitions implements RemoteCallable<Transitions>,
/** Transition to end the recents transition */
public static final int TRANSIT_END_RECENTS_TRANSITION = TRANSIT_FIRST_CUSTOM + 22;
+ /** Transition type for app compat reachability. */
+ public static final int TRANSIT_MOVE_LETTERBOX_REACHABILITY = TRANSIT_FIRST_CUSTOM + 23;
+
/** Transition type for desktop mode transitions. */
public static final int TRANSIT_DESKTOP_MODE_TYPES =
WindowManager.TRANSIT_FIRST_CUSTOM + 100;
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorViewModel.java b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorViewModel.java
index a3a0baebcba1..e8d18c435a1d 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorViewModel.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorViewModel.java
@@ -1993,7 +1993,8 @@ public class DesktopModeWindowDecorViewModel implements WindowDecorViewModel,
continue;
}
if (decor.mTaskInfo.displayId == displayId
- && Flags.enableDesktopWindowingImmersiveHandleHiding()) {
+ && DesktopModeFlags
+ .ENABLE_DESKTOP_WINDOWING_IMMERSIVE_HANDLE_HIDING.isTrue()) {
decor.onInsetsStateChanged(insetsState);
}
if (!DesktopModeFlags.ENABLE_HANDLE_INPUT_FIX.isTrue()) {
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecoration.java b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecoration.java
index 3fdb05edbfa3..7e75e4072878 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecoration.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecoration.java
@@ -111,14 +111,14 @@ import kotlin.Unit;
import kotlin.jvm.functions.Function0;
import kotlin.jvm.functions.Function1;
+import kotlinx.coroutines.CoroutineScope;
+import kotlinx.coroutines.MainCoroutineDispatcher;
+
import java.util.List;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.function.Supplier;
-import kotlinx.coroutines.CoroutineScope;
-import kotlinx.coroutines.MainCoroutineDispatcher;
-
/**
* Defines visuals and behaviors of a window decoration of a caption bar and shadows. It works with
* {@link DesktopModeWindowDecorViewModel}.
@@ -731,11 +731,14 @@ public class DesktopModeWindowDecoration extends WindowDecoration<WindowDecorLin
} else {
// App header is visible since `mWindowDecorViewHolder` is of type
// [AppHeaderViewHolder].
- ((AppHeaderViewHolder) mWindowDecorViewHolder).runOnAppChipGlobalLayout(
- () -> {
- notifyAppHeaderStateChanged();
- return Unit.INSTANCE;
- });
+ final AppHeaderViewHolder appHeader = asAppHeader(mWindowDecorViewHolder);
+ if (appHeader != null) {
+ appHeader.runOnAppChipGlobalLayout(
+ () -> {
+ notifyAppHeaderStateChanged();
+ return Unit.INSTANCE;
+ });
+ }
}
}
@@ -766,11 +769,11 @@ public class DesktopModeWindowDecoration extends WindowDecoration<WindowDecorLin
}
private void notifyAppHeaderStateChanged() {
- if (isAppHandle(mWindowDecorViewHolder) || mWindowDecorViewHolder == null) {
+ final AppHeaderViewHolder appHeader = asAppHeader(mWindowDecorViewHolder);
+ if (appHeader == null) {
return;
}
- final Rect appChipPositionInWindow =
- ((AppHeaderViewHolder) mWindowDecorViewHolder).getAppChipLocationInWindow();
+ final Rect appChipPositionInWindow = appHeader.getAppChipLocationInWindow();
final Rect taskBounds = mTaskInfo.configuration.windowConfiguration.getBounds();
final Rect appChipGlobalPosition = new Rect(
taskBounds.left + appChipPositionInWindow.left,
diff --git a/libs/WindowManager/Shell/tests/e2e/desktopmode/flicker-service/Android.bp b/libs/WindowManager/Shell/tests/e2e/desktopmode/flicker-service/Android.bp
index 50581f7e01f3..7585c977809e 100644
--- a/libs/WindowManager/Shell/tests/e2e/desktopmode/flicker-service/Android.bp
+++ b/libs/WindowManager/Shell/tests/e2e/desktopmode/flicker-service/Android.bp
@@ -33,6 +33,7 @@ android_test {
"WMShellFlickerTestsBase",
"WMShellScenariosDesktopMode",
"WMShellTestUtils",
+ "ui-trace-collector",
],
data: ["trace_config/*"],
}
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/common/InputChannelSupplierTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/common/InputChannelSupplierTest.kt
new file mode 100644
index 000000000000..09c2faaa2670
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/common/InputChannelSupplierTest.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright 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.wm.shell.common
+
+import android.testing.AndroidTestingRunner
+import android.view.InputChannel
+import androidx.test.filters.SmallTest
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Tests for [InputChannelSupplier].
+ *
+ * Build/Install/Run:
+ * atest WMShellUnitTests:InputChannelSupplierTest
+ */
+@RunWith(AndroidTestingRunner::class)
+@SmallTest
+class InputChannelSupplierTest {
+
+ @Test
+ fun `InputChannelSupplier supplies an InputChannel`() {
+ val supplier = InputChannelSupplier()
+ SuppliersUtilsTest.assertSupplierProvidesValue(supplier) {
+ it is InputChannel
+ }
+ }
+}
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/common/SuppliersUtilsTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/common/SuppliersUtilsTest.kt
new file mode 100644
index 000000000000..8468c636542e
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/common/SuppliersUtilsTest.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright 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.wm.shell.common
+
+import java.util.function.Supplier
+
+/**
+ * Utility class we can use to test a []Supplier<T>] of any parameters type [T].
+ */
+class SuppliersUtilsTest {
+
+ companion object {
+ /**
+ * Allows to check that the object supplied is asserts what in [assertion].
+ */
+ fun <T> assertSupplierProvidesValue(supplier: Supplier<T>, assertion: (Any?) -> Boolean) {
+ assert(assertion(supplier.get())) { "Supplier didn't provided what is expected" }
+ }
+ }
+}
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/common/WindowSessionSupplierTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/common/WindowSessionSupplierTest.kt
new file mode 100644
index 000000000000..33e8d78d6a15
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/common/WindowSessionSupplierTest.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright 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.wm.shell.common
+
+import android.testing.AndroidTestingRunner
+import android.view.IWindowSession
+import androidx.test.filters.SmallTest
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Tests for [WindowSessionSupplier].
+ *
+ * Build/Install/Run:
+ * atest WMShellUnitTests:WindowSessionSupplierTest
+ */
+@RunWith(AndroidTestingRunner::class)
+@SmallTest
+class WindowSessionSupplierTest {
+
+ @Test
+ fun `InputChannelSupplier supplies an InputChannel`() {
+ val supplier = WindowSessionSupplier()
+ SuppliersUtilsTest.assertSupplierProvidesValue(supplier) {
+ it is IWindowSession
+ }
+ }
+}
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/common/transition/SurfaceBuilderSupplierTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/common/transition/SurfaceBuilderSupplierTest.kt
new file mode 100644
index 000000000000..f88f72356759
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/common/transition/SurfaceBuilderSupplierTest.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright 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.wm.shell.common.transition
+
+import android.testing.AndroidTestingRunner
+import android.view.SurfaceControl
+import androidx.test.filters.SmallTest
+import com.android.wm.shell.common.SuppliersUtilsTest
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Tests for [SurfaceBuilderSupplier].
+ *
+ * Build/Install/Run:
+ * atest WMShellUnitTests:SurfaceBuilderSupplierTest
+ */
+@RunWith(AndroidTestingRunner::class)
+@SmallTest
+class SurfaceBuilderSupplierTest {
+
+ @Test
+ fun `SurfaceBuilderSupplier supplies an SurfaceControl Builder`() {
+ val supplier = SurfaceBuilderSupplier()
+ SuppliersUtilsTest.assertSupplierProvidesValue(supplier) {
+ it is SurfaceControl.Builder
+ }
+ }
+}
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/common/transition/TransactionSupplierTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/common/transition/TransactionSupplierTest.kt
new file mode 100644
index 000000000000..12b4d8b5f96b
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/common/transition/TransactionSupplierTest.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright 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.wm.shell.common.transition
+
+import android.testing.AndroidTestingRunner
+import android.view.SurfaceControl
+import androidx.test.filters.SmallTest
+import com.android.wm.shell.common.SuppliersUtilsTest
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Tests for [TransactionSupplier].
+ *
+ * Build/Install/Run:
+ * atest WMShellUnitTests:TransactionSupplierTest
+ */
+@RunWith(AndroidTestingRunner::class)
+@SmallTest
+class TransactionSupplierTest {
+
+ @Test
+ fun `SurfaceBuilderSupplier supplies a Transaction`() {
+ val supplier = TransactionSupplier()
+ SuppliersUtilsTest.assertSupplierProvidesValue(supplier) {
+ it is SurfaceControl.Transaction
+ }
+ }
+}
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/compatui/letterbox/LetterboxControllerRobotTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/compatui/letterbox/LetterboxControllerRobotTest.kt
index 88cc981dd30c..e34884b103f6 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/compatui/letterbox/LetterboxControllerRobotTest.kt
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/compatui/letterbox/LetterboxControllerRobotTest.kt
@@ -33,10 +33,10 @@ abstract class LetterboxControllerRobotTest {
companion object {
@JvmStatic
- private val DISPLAY_ID = 1
+ val DISPLAY_ID = 1
@JvmStatic
- private val TASK_ID = 20
+ val TASK_ID = 20
}
lateinit var letterboxController: LetterboxController
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/compatui/letterbox/LetterboxGestureDelegateTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/compatui/letterbox/LetterboxGestureDelegateTest.kt
new file mode 100644
index 000000000000..bc3416a88918
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/compatui/letterbox/LetterboxGestureDelegateTest.kt
@@ -0,0 +1,75 @@
+/*
+ * Copyright 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.wm.shell.compatui.letterbox
+
+import android.testing.AndroidTestingRunner
+import androidx.test.filters.SmallTest
+import com.android.dx.mockito.inline.extended.ExtendedMockito.spyOn
+import com.android.wm.shell.compatui.letterbox.LetterboxEvents.motionEventAt
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.verify
+
+/**
+ * Tests for [LetterboxGestureDelegate].
+ *
+ * Build/Install/Run:
+ * atest WMShellUnitTests:LetterboxGestureDelegateTest
+ */
+@RunWith(AndroidTestingRunner::class)
+@SmallTest
+class LetterboxGestureDelegateTest {
+
+ class DelegateTest : LetterboxGestureListener by LetterboxGestureDelegate
+
+ val delegate = DelegateTest()
+
+ @Before
+ fun setUp() {
+ spyOn(LetterboxGestureDelegate)
+ }
+
+ @Test
+ fun `When delegating all methods are invoked`() {
+ val event = motionEventAt(0f, 0f)
+ with(delegate) {
+ onDown(event)
+ onShowPress(event)
+ onSingleTapUp(event)
+ onScroll(event, event, 0f, 0f)
+ onFling(event, event, 0f, 0f)
+ onLongPress(event)
+ onSingleTapConfirmed(event)
+ onDoubleTap(event)
+ onDoubleTapEvent(event)
+ onContextClick(event)
+ }
+ with(LetterboxGestureDelegate) {
+ verify(this).onDown(event)
+ verify(this).onShowPress(event)
+ verify(this).onSingleTapUp(event)
+ verify(this).onScroll(event, event, 0f, 0f)
+ verify(this).onFling(event, event, 0f, 0f)
+ verify(this).onLongPress(event)
+ verify(this).onSingleTapConfirmed(event)
+ verify(this).onDoubleTap(event)
+ verify(this).onDoubleTapEvent(event)
+ verify(this).onContextClick(event)
+ }
+ }
+}
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/compatui/letterbox/LetterboxInputControllerTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/compatui/letterbox/LetterboxInputControllerTest.kt
new file mode 100644
index 000000000000..fa95faee4b6e
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/compatui/letterbox/LetterboxInputControllerTest.kt
@@ -0,0 +1,203 @@
+/*
+ * Copyright 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.wm.shell.compatui.letterbox
+
+import android.content.Context
+import android.graphics.Rect
+import android.graphics.Region
+import android.os.Handler
+import android.os.Looper
+import android.testing.AndroidTestingRunner
+import android.view.IWindowSession
+import android.view.InputChannel
+import androidx.test.filters.SmallTest
+import com.android.wm.shell.ShellTestCase
+import com.android.wm.shell.common.InputChannelSupplier
+import com.android.wm.shell.common.WindowSessionSupplier
+import com.android.wm.shell.compatui.letterbox.LetterboxMatchers.asAnyMode
+import com.android.wm.shell.windowdecor.DesktopModeWindowDecorViewModelTestsBase.Companion.TAG
+import java.util.function.Consumer
+import java.util.function.Supplier
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+
+/**
+ * Tests for [LetterboxInputController].
+ *
+ * Build/Install/Run:
+ * atest WMShellUnitTests:LetterboxInputControllerTest
+ */
+@RunWith(AndroidTestingRunner::class)
+@SmallTest
+class LetterboxInputControllerTest : ShellTestCase() {
+
+ @Test
+ fun `When creation is requested the surface is created if not present`() {
+ runTestScenario { r ->
+ r.sendCreateSurfaceRequest()
+
+ r.checkInputSurfaceBuilderInvoked()
+ }
+ }
+
+ @Test
+ fun `When creation is requested multiple times the input surface is created once`() {
+ runTestScenario { r ->
+ r.sendCreateSurfaceRequest()
+ r.sendCreateSurfaceRequest()
+ r.sendCreateSurfaceRequest()
+ r.sendCreateSurfaceRequest()
+
+ r.checkInputSurfaceBuilderInvoked(times = 1)
+ }
+ }
+
+ @Test
+ fun `A different input surface is created for every key`() {
+ runTestScenario { r ->
+ r.sendCreateSurfaceRequest()
+ r.sendCreateSurfaceRequest()
+ r.sendCreateSurfaceRequest(displayId = 2)
+ r.sendCreateSurfaceRequest(displayId = 2, taskId = 2)
+ r.sendCreateSurfaceRequest(displayId = 2)
+ r.sendCreateSurfaceRequest(displayId = 2, taskId = 2)
+
+ r.checkInputSurfaceBuilderInvoked(times = 3)
+ }
+ }
+
+ @Test
+ fun `Created spy surface is removed once`() {
+ runTestScenario { r ->
+ r.sendCreateSurfaceRequest()
+ r.checkInputSurfaceBuilderInvoked()
+
+ r.sendDestroySurfaceRequest()
+ r.sendDestroySurfaceRequest()
+ r.sendDestroySurfaceRequest()
+
+ r.checkTransactionRemovedInvoked()
+ }
+ }
+ @Test
+ fun `Only existing surfaces receive visibility update`() {
+ runTestScenario { r ->
+ r.sendCreateSurfaceRequest()
+ r.sendUpdateSurfaceVisibilityRequest(visible = true)
+ r.sendUpdateSurfaceVisibilityRequest(visible = true, displayId = 20)
+
+ r.checkVisibilityUpdated(expectedVisibility = true)
+ }
+ }
+
+ @Test
+ fun `Only existing surfaces receive taskBounds update`() {
+ runTestScenario { r ->
+ r.sendUpdateSurfaceBoundsRequest(
+ taskBounds = Rect(0, 0, 2000, 1000),
+ activityBounds = Rect(500, 0, 1500, 1000)
+ )
+
+ r.checkUpdateSessionRegion(times = 0, region = Region(0, 0, 2000, 1000))
+ r.checkSurfaceSizeUpdated(times = 0, expectedWidth = 2000, expectedHeight = 1000)
+
+ r.resetTransitionTest()
+
+ r.sendCreateSurfaceRequest()
+ r.sendUpdateSurfaceBoundsRequest(
+ taskBounds = Rect(0, 0, 2000, 1000),
+ activityBounds = Rect(500, 0, 1500, 1000)
+ )
+ r.checkUpdateSessionRegion(region = Region(0, 0, 2000, 1000))
+ r.checkSurfaceSizeUpdated(expectedWidth = 2000, expectedHeight = 1000)
+ }
+ }
+
+ /**
+ * Runs a test scenario providing a Robot.
+ */
+ fun runTestScenario(consumer: Consumer<InputLetterboxControllerRobotTest>) {
+ consumer.accept(InputLetterboxControllerRobotTest(mContext).apply { initController() })
+ }
+
+ class InputLetterboxControllerRobotTest(private val context: Context) :
+ LetterboxControllerRobotTest() {
+
+ private val inputSurfaceBuilder: LetterboxInputSurfaceBuilder
+ private val handler = Handler(Looper.getMainLooper())
+ private val listener: LetterboxGestureListener
+ private val listenerSupplier: Supplier<LetterboxGestureListener>
+ private val windowSessionSupplier: WindowSessionSupplier
+ private val windowSession: IWindowSession
+ private val inputChannelSupplier: InputChannelSupplier
+
+ init {
+ inputSurfaceBuilder = getLetterboxInputSurfaceBuilderMock()
+ listener = mock<LetterboxGestureListener>()
+ listenerSupplier = mock<Supplier<LetterboxGestureListener>>()
+ doReturn(LetterboxGestureDelegate).`when`(listenerSupplier).get()
+ windowSessionSupplier = mock<WindowSessionSupplier>()
+ windowSession = mock<IWindowSession>()
+ doReturn(windowSession).`when`(windowSessionSupplier).get()
+ inputChannelSupplier = mock<InputChannelSupplier>()
+ val inputChannels = InputChannel.openInputChannelPair(TAG)
+ inputChannels.first().dispose()
+ doReturn(inputChannels[1]).`when`(inputChannelSupplier).get()
+ }
+
+ override fun buildController(): LetterboxController =
+ LetterboxInputController(
+ context,
+ handler,
+ inputSurfaceBuilder,
+ listenerSupplier,
+ windowSessionSupplier,
+ inputChannelSupplier
+ )
+
+ fun checkInputSurfaceBuilderInvoked(
+ times: Int = 1,
+ name: String = "",
+ callSite: String = ""
+ ) {
+ verify(inputSurfaceBuilder, times(times)).createInputSurface(
+ eq(transaction),
+ eq(parentLeash),
+ name.asAnyMode(),
+ callSite.asAnyMode()
+ )
+ }
+
+ fun checkUpdateSessionRegion(times: Int = 1, displayId: Int = DISPLAY_ID, region: Region) {
+ verify(windowSession, times(times)).updateInputChannel(
+ any(),
+ eq(displayId),
+ any(),
+ any(),
+ any(),
+ any(),
+ eq(region)
+ )
+ }
+ }
+}
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/compatui/letterbox/LetterboxTestUtils.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/compatui/letterbox/LetterboxTestUtils.kt
index 2c06dfda7917..3ce1fec32a16 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/compatui/letterbox/LetterboxTestUtils.kt
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/compatui/letterbox/LetterboxTestUtils.kt
@@ -16,6 +16,8 @@
package com.android.wm.shell.compatui.letterbox
+import android.view.MotionEvent.ACTION_DOWN
+import android.view.MotionEvent.obtain
import android.view.SurfaceControl
import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull
@@ -37,6 +39,18 @@ fun getTransactionMock(): SurfaceControl.Transaction = mock<SurfaceControl.Trans
doReturn(this).`when`(this).setWindowCrop(anyOrNull(), any(), any())
}
+/**
+ * @return A [LetterboxInputSurfaceBuilder] mock to use in tests.
+ */
+fun getLetterboxInputSurfaceBuilderMock() = mock<LetterboxInputSurfaceBuilder>().apply {
+ doReturn(SurfaceControl()).`when`(this).createInputSurface(
+ any(),
+ any(),
+ any(),
+ any()
+ )
+}
+
// Utility to make verification mode depending on a [Boolean].
fun Boolean.asMode(): VerificationMode = if (this) times(1) else never()
@@ -47,5 +61,10 @@ object LetterboxMatchers {
fun String.asAnyMode() = asAnyMode { this.isEmpty() }
}
+object LetterboxEvents {
+ fun motionEventAt(x: Float, y: Float) =
+ obtain(0, 10, ACTION_DOWN, x, y, 0)
+}
+
private inline fun <reified T : Any> T.asAnyMode(condition: () -> Boolean) =
(if (condition()) any() else eq(this))
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopModeVisualIndicatorTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopModeVisualIndicatorTest.kt
index 13b44977e9c7..a6575535faee 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopModeVisualIndicatorTest.kt
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopModeVisualIndicatorTest.kt
@@ -16,11 +16,11 @@
package com.android.wm.shell.desktopmode
+import android.animation.AnimatorTestRule
import android.app.ActivityManager.RunningTaskInfo
import android.graphics.PointF
import android.graphics.Rect
import android.platform.test.annotations.EnableFlags
-import android.platform.test.flag.junit.SetFlagsRule
import android.testing.AndroidTestingRunner
import android.testing.TestableLooper.RunWithLooper
import android.view.SurfaceControl
@@ -34,6 +34,7 @@ import com.android.wm.shell.ShellTestCase
import com.android.wm.shell.common.DisplayController
import com.android.wm.shell.common.DisplayLayout
import com.android.wm.shell.common.SyncTransactionQueue
+import com.android.wm.shell.shared.bubbles.BubbleDropTargetBoundsProvider
import com.android.wm.shell.shared.desktopmode.DesktopModeStatus
import com.google.common.truth.Truth.assertThat
import org.junit.Before
@@ -56,7 +57,7 @@ import org.mockito.kotlin.whenever
@EnableFlags(FLAG_ENABLE_DESKTOP_WINDOWING_MODE)
class DesktopModeVisualIndicatorTest : ShellTestCase() {
- @JvmField @Rule val setFlagsRule = SetFlagsRule()
+ @JvmField @Rule val animatorTestRule = AnimatorTestRule(this)
@JvmField
@Rule
@@ -69,6 +70,7 @@ class DesktopModeVisualIndicatorTest : ShellTestCase() {
@Mock private lateinit var taskSurface: SurfaceControl
@Mock private lateinit var taskDisplayAreaOrganizer: RootTaskDisplayAreaOrganizer
@Mock private lateinit var displayLayout: DisplayLayout
+ @Mock private lateinit var bubbleBoundsProvider: BubbleDropTargetBoundsProvider
private lateinit var visualIndicator: DesktopModeVisualIndicator
@@ -80,6 +82,8 @@ class DesktopModeVisualIndicatorTest : ShellTestCase() {
whenever(displayLayout.stableInsets()).thenReturn(STABLE_INSETS)
whenever(displayController.getDisplayLayout(anyInt())).thenReturn(displayLayout)
whenever(displayController.getDisplay(anyInt())).thenReturn(mContext.display)
+ whenever(bubbleBoundsProvider.getBubbleBarExpandedViewDropTargetBounds(any()))
+ .thenReturn(Rect())
taskInfo = DesktopTestHelpers.createFullscreenTask()
}
@@ -194,6 +198,40 @@ class DesktopModeVisualIndicatorTest : ShellTestCase() {
}
@Test
+ fun testBubbleLeftRegionCalculation() {
+ val bubbleRegionWidth =
+ context.resources.getDimensionPixelSize(R.dimen.bubble_transform_area_width)
+ val bubbleRegionHeight =
+ context.resources.getDimensionPixelSize(R.dimen.bubble_transform_area_height)
+ val expectedRect = Rect(0, 1600 - bubbleRegionHeight, bubbleRegionWidth, 1600)
+
+ createVisualIndicator(DesktopModeVisualIndicator.DragStartState.FROM_FULLSCREEN)
+ var testRegion = visualIndicator.calculateBubbleLeftRegion(displayLayout)
+ assertThat(testRegion.bounds).isEqualTo(expectedRect)
+
+ createVisualIndicator(DesktopModeVisualIndicator.DragStartState.FROM_SPLIT)
+ testRegion = visualIndicator.calculateBubbleLeftRegion(displayLayout)
+ assertThat(testRegion.bounds).isEqualTo(expectedRect)
+ }
+
+ @Test
+ fun testBubbleRightRegionCalculation() {
+ val bubbleRegionWidth =
+ context.resources.getDimensionPixelSize(R.dimen.bubble_transform_area_width)
+ val bubbleRegionHeight =
+ context.resources.getDimensionPixelSize(R.dimen.bubble_transform_area_height)
+ val expectedRect = Rect(2400 - bubbleRegionWidth, 1600 - bubbleRegionHeight, 2400, 1600)
+
+ createVisualIndicator(DesktopModeVisualIndicator.DragStartState.FROM_FULLSCREEN)
+ var testRegion = visualIndicator.calculateBubbleRightRegion(displayLayout)
+ assertThat(testRegion.bounds).isEqualTo(expectedRect)
+
+ createVisualIndicator(DesktopModeVisualIndicator.DragStartState.FROM_SPLIT)
+ testRegion = visualIndicator.calculateBubbleRightRegion(displayLayout)
+ assertThat(testRegion.bounds).isEqualTo(expectedRect)
+ }
+
+ @Test
fun testDefaultIndicators() {
createVisualIndicator(DesktopModeVisualIndicator.DragStartState.FROM_FULLSCREEN)
var result = visualIndicator.updateIndicatorType(PointF(-10000f, 500f))
@@ -219,31 +257,79 @@ class DesktopModeVisualIndicatorTest : ShellTestCase() {
fun testDefaultIndicatorWithNoDesktop() {
whenever(DesktopModeStatus.canEnterDesktopMode(any())).thenReturn(false)
+ // Fullscreen to center, no desktop indicator
createVisualIndicator(DesktopModeVisualIndicator.DragStartState.FROM_FULLSCREEN)
var result = visualIndicator.updateIndicatorType(PointF(500f, 500f))
assertThat(result).isEqualTo(DesktopModeVisualIndicator.IndicatorType.NO_INDICATOR)
-
+ // Fullscreen to split
result = visualIndicator.updateIndicatorType(PointF(10000f, 500f))
assertThat(result)
.isEqualTo(DesktopModeVisualIndicator.IndicatorType.TO_SPLIT_RIGHT_INDICATOR)
-
result = visualIndicator.updateIndicatorType(PointF(-10000f, 500f))
assertThat(result)
.isEqualTo(DesktopModeVisualIndicator.IndicatorType.TO_SPLIT_LEFT_INDICATOR)
-
+ // Fullscreen to bubble
+ result = visualIndicator.updateIndicatorType(PointF(100f, 1500f))
+ assertThat(result)
+ .isEqualTo(DesktopModeVisualIndicator.IndicatorType.TO_BUBBLE_LEFT_INDICATOR)
+ result = visualIndicator.updateIndicatorType(PointF(2300f, 1500f))
+ assertThat(result)
+ .isEqualTo(DesktopModeVisualIndicator.IndicatorType.TO_BUBBLE_RIGHT_INDICATOR)
+ // Split to center, no desktop indicator
createVisualIndicator(DesktopModeVisualIndicator.DragStartState.FROM_SPLIT)
result = visualIndicator.updateIndicatorType(PointF(500f, 500f))
assertThat(result).isEqualTo(DesktopModeVisualIndicator.IndicatorType.NO_INDICATOR)
-
+ // Split to fullscreen
result = visualIndicator.updateIndicatorType(PointF(500f, 0f))
assertThat(result)
.isEqualTo(DesktopModeVisualIndicator.IndicatorType.TO_FULLSCREEN_INDICATOR)
-
+ // Split to bubble
+ result = visualIndicator.updateIndicatorType(PointF(100f, 1500f))
+ assertThat(result)
+ .isEqualTo(DesktopModeVisualIndicator.IndicatorType.TO_BUBBLE_LEFT_INDICATOR)
+ result = visualIndicator.updateIndicatorType(PointF(2300f, 1500f))
+ assertThat(result)
+ .isEqualTo(DesktopModeVisualIndicator.IndicatorType.TO_BUBBLE_RIGHT_INDICATOR)
+ // Drag app to center, no desktop indicator
createVisualIndicator(DesktopModeVisualIndicator.DragStartState.DRAGGED_INTENT)
result = visualIndicator.updateIndicatorType(PointF(500f, 500f))
assertThat(result).isEqualTo(DesktopModeVisualIndicator.IndicatorType.NO_INDICATOR)
}
+ @Test
+ @EnableFlags(
+ com.android.wm.shell.Flags.FLAG_ENABLE_BUBBLE_TO_FULLSCREEN,
+ com.android.wm.shell.Flags.FLAG_ENABLE_CREATE_ANY_BUBBLE,
+ )
+ fun testBubbleLeftVisualIndicatorSize() {
+ val dropTargetBounds = Rect(100, 100, 500, 1500)
+ whenever(bubbleBoundsProvider.getBubbleBarExpandedViewDropTargetBounds(/* onLeft= */ true))
+ .thenReturn(dropTargetBounds)
+ createVisualIndicator(DesktopModeVisualIndicator.DragStartState.FROM_FULLSCREEN)
+ visualIndicator.updateIndicatorType(PointF(100f, 1500f))
+
+ animatorTestRule.advanceTimeBy(200)
+
+ assertThat(visualIndicator.indicatorBounds).isEqualTo(dropTargetBounds)
+ }
+
+ @Test
+ @EnableFlags(
+ com.android.wm.shell.Flags.FLAG_ENABLE_BUBBLE_TO_FULLSCREEN,
+ com.android.wm.shell.Flags.FLAG_ENABLE_CREATE_ANY_BUBBLE,
+ )
+ fun testBubbleRightVisualIndicatorSize() {
+ val dropTargetBounds = Rect(1900, 100, 2300, 1500)
+ whenever(bubbleBoundsProvider.getBubbleBarExpandedViewDropTargetBounds(/* onLeft= */ false))
+ .thenReturn(dropTargetBounds)
+ createVisualIndicator(DesktopModeVisualIndicator.DragStartState.FROM_FULLSCREEN)
+ visualIndicator.updateIndicatorType(PointF(2300f, 1500f))
+
+ animatorTestRule.advanceTimeBy(200)
+
+ assertThat(visualIndicator.indicatorBounds).isEqualTo(dropTargetBounds)
+ }
+
private fun createVisualIndicator(dragStartState: DesktopModeVisualIndicator.DragStartState) {
visualIndicator =
DesktopModeVisualIndicator(
@@ -254,6 +340,7 @@ class DesktopModeVisualIndicatorTest : ShellTestCase() {
taskSurface,
taskDisplayAreaOrganizer,
dragStartState,
+ bubbleBoundsProvider,
)
}
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 aa7944cc837f..bffbb99bf13b 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
@@ -4231,6 +4231,76 @@ class DesktopTasksControllerTest(flags: FlagsParameterization) : ShellTestCase()
}
@Test
+ @EnableFlags(Flags.FLAG_ENABLE_MULTIPLE_DESKTOPS_BACKEND)
+ fun dragToDesktop_movesTaskToDesk() {
+ val spyController = spy(controller)
+ whenever(spyController.getVisualIndicator()).thenReturn(desktopModeVisualIndicator)
+ whenever(desktopModeVisualIndicator.updateIndicatorType(anyOrNull()))
+ .thenReturn(DesktopModeVisualIndicator.IndicatorType.TO_DESKTOP_INDICATOR)
+ val task = setUpFullscreenTask(displayId = DEFAULT_DISPLAY)
+
+ spyController.onDragPositioningEndThroughStatusBar(PointF(200f, 200f), task, mockSurface)
+
+ val wct = getLatestDragToDesktopWct()
+ verify(desksOrganizer).moveTaskToDesk(wct, deskId = 0, task)
+ }
+
+ @Test
+ @EnableFlags(Flags.FLAG_ENABLE_MULTIPLE_DESKTOPS_BACKEND)
+ fun dragToDesktop_activatesDesk() {
+ val spyController = spy(controller)
+ whenever(spyController.getVisualIndicator()).thenReturn(desktopModeVisualIndicator)
+ whenever(desktopModeVisualIndicator.updateIndicatorType(anyOrNull()))
+ .thenReturn(DesktopModeVisualIndicator.IndicatorType.TO_DESKTOP_INDICATOR)
+ val task = setUpFullscreenTask(displayId = DEFAULT_DISPLAY)
+
+ spyController.onDragPositioningEndThroughStatusBar(PointF(200f, 200f), task, mockSurface)
+
+ val wct = getLatestDragToDesktopWct()
+ verify(desksOrganizer).activateDesk(wct, deskId = 0)
+ }
+
+ @Test
+ @EnableFlags(Flags.FLAG_ENABLE_MULTIPLE_DESKTOPS_BACKEND)
+ fun dragToDesktop_triggersEnterDesktopListener() {
+ val spyController = spy(controller)
+ whenever(spyController.getVisualIndicator()).thenReturn(desktopModeVisualIndicator)
+ whenever(desktopModeVisualIndicator.updateIndicatorType(anyOrNull()))
+ .thenReturn(DesktopModeVisualIndicator.IndicatorType.TO_DESKTOP_INDICATOR)
+ val task = setUpFullscreenTask(displayId = DEFAULT_DISPLAY)
+
+ spyController.onDragPositioningEndThroughStatusBar(PointF(200f, 200f), task, mockSurface)
+
+ verify(desktopModeEnterExitTransitionListener)
+ .onEnterDesktopModeTransitionStarted(FREEFORM_ANIMATION_DURATION)
+ }
+
+ @Test
+ @EnableFlags(Flags.FLAG_ENABLE_MULTIPLE_DESKTOPS_BACKEND)
+ fun dragToDesktop_multipleDesks_addsPendingTransition() {
+ val transition = Binder()
+ val spyController = spy(controller)
+ whenever(dragToDesktopTransitionHandler.finishDragToDesktopTransition(any()))
+ .thenReturn(transition)
+ whenever(spyController.getVisualIndicator()).thenReturn(desktopModeVisualIndicator)
+ whenever(desktopModeVisualIndicator.updateIndicatorType(anyOrNull()))
+ .thenReturn(DesktopModeVisualIndicator.IndicatorType.TO_DESKTOP_INDICATOR)
+ val task = setUpFullscreenTask(displayId = DEFAULT_DISPLAY)
+
+ spyController.onDragPositioningEndThroughStatusBar(PointF(200f, 200f), task, mockSurface)
+
+ verify(desksTransitionsObserver)
+ .addPendingTransition(
+ argThat {
+ this is DeskTransition.ActiveDeskWithTask &&
+ this.token == transition &&
+ this.deskId == 0 &&
+ this.enterTaskId == task.taskId
+ }
+ )
+ }
+
+ @Test
fun onDesktopDragMove_endsOutsideValidDragArea_snapsToValidBounds() {
val task = setUpFreeformTask()
val spyController = spy(controller)
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/shared/bubbles/DragZoneFactoryTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/shared/bubbles/DragZoneFactoryTest.kt
new file mode 100644
index 000000000000..e28d6ff8bf7f
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/shared/bubbles/DragZoneFactoryTest.kt
@@ -0,0 +1,249 @@
+/*
+ * 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.wm.shell.shared.bubbles
+
+import android.graphics.Insets
+import android.graphics.Rect
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.wm.shell.shared.bubbles.DragZoneFactory.DesktopWindowModeChecker
+import com.android.wm.shell.shared.bubbles.DragZoneFactory.SplitScreenModeChecker
+import com.android.wm.shell.shared.bubbles.DragZoneFactory.SplitScreenModeChecker.SplitScreenMode
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+/** Unit tests for [DragZoneFactory]. */
+class DragZoneFactoryTest {
+
+ private lateinit var dragZoneFactory: DragZoneFactory
+ private val tabletPortrait =
+ DeviceConfig(
+ windowBounds = Rect(0, 0, 1000, 2000),
+ isLargeScreen = true,
+ isSmallTablet = false,
+ isLandscape = false,
+ isRtl = false,
+ insets = Insets.of(0, 0, 0, 0)
+ )
+ private val tabletLandscape =
+ tabletPortrait.copy(windowBounds = Rect(0, 0, 2000, 1000), isLandscape = true)
+ private val foldablePortrait =
+ tabletPortrait.copy(windowBounds = Rect(0, 0, 800, 900), isSmallTablet = true)
+ private val foldableLandscape =
+ foldablePortrait.copy(windowBounds = Rect(0, 0, 900, 800), isLandscape = true)
+ private val splitScreenModeChecker = SplitScreenModeChecker { SplitScreenMode.NONE }
+ private var isDesktopWindowModeSupported = true
+ private val desktopWindowModeChecker = DesktopWindowModeChecker { isDesktopWindowModeSupported }
+
+ @Test
+ fun dragZonesForBubbleBar_tablet() {
+ dragZoneFactory =
+ DragZoneFactory(tabletPortrait, splitScreenModeChecker, desktopWindowModeChecker)
+ val dragZones =
+ dragZoneFactory.createSortedDragZones(DraggedObject.BubbleBar(BubbleBarLocation.LEFT))
+ val expectedZones: List<Class<out DragZone>> =
+ listOf(
+ DragZone.Dismiss::class.java,
+ DragZone.Bubble::class.java,
+ DragZone.Bubble::class.java,
+ )
+ dragZones.zip(expectedZones).forEach { (zone, expectedType) ->
+ assertThat(zone).isInstanceOf(expectedType)
+ }
+ }
+
+ @Test
+ fun dragZonesForBubble_tablet_portrait() {
+ dragZoneFactory =
+ DragZoneFactory(tabletPortrait, splitScreenModeChecker, desktopWindowModeChecker)
+ val dragZones =
+ dragZoneFactory.createSortedDragZones(DraggedObject.Bubble(BubbleBarLocation.LEFT))
+ val expectedZones: List<Class<out DragZone>> =
+ listOf(
+ DragZone.Dismiss::class.java,
+ DragZone.Bubble.Left::class.java,
+ DragZone.Bubble.Right::class.java,
+ DragZone.FullScreen::class.java,
+ DragZone.DesktopWindow::class.java,
+ DragZone.Split.Top::class.java,
+ DragZone.Split.Bottom::class.java,
+ )
+ dragZones.zip(expectedZones).forEach { (zone, expectedType) ->
+ assertThat(zone).isInstanceOf(expectedType)
+ }
+ }
+
+ @Test
+ fun dragZonesForBubble_tablet_landscape() {
+ dragZoneFactory = DragZoneFactory(tabletLandscape, splitScreenModeChecker, desktopWindowModeChecker)
+ val dragZones =
+ dragZoneFactory.createSortedDragZones(DraggedObject.Bubble(BubbleBarLocation.LEFT))
+ val expectedZones: List<Class<out DragZone>> =
+ listOf(
+ DragZone.Dismiss::class.java,
+ DragZone.Bubble.Left::class.java,
+ DragZone.Bubble.Right::class.java,
+ DragZone.FullScreen::class.java,
+ DragZone.DesktopWindow::class.java,
+ DragZone.Split.Left::class.java,
+ DragZone.Split.Right::class.java,
+ )
+ dragZones.zip(expectedZones).forEach { (zone, expectedType) ->
+ assertThat(zone).isInstanceOf(expectedType)
+ }
+ }
+
+ @Test
+ fun dragZonesForBubble_foldable_portrait() {
+ dragZoneFactory = DragZoneFactory(foldablePortrait, splitScreenModeChecker, desktopWindowModeChecker)
+ val dragZones =
+ dragZoneFactory.createSortedDragZones(DraggedObject.Bubble(BubbleBarLocation.LEFT))
+ val expectedZones: List<Class<out DragZone>> =
+ listOf(
+ DragZone.Dismiss::class.java,
+ DragZone.Bubble.Left::class.java,
+ DragZone.Bubble.Right::class.java,
+ DragZone.FullScreen::class.java,
+ DragZone.Split.Left::class.java,
+ DragZone.Split.Right::class.java,
+ )
+ dragZones.zip(expectedZones).forEach { (zone, expectedType) ->
+ assertThat(zone).isInstanceOf(expectedType)
+ }
+ }
+
+ @Test
+ fun dragZonesForBubble_foldable_landscape() {
+ dragZoneFactory = DragZoneFactory(foldableLandscape, splitScreenModeChecker, desktopWindowModeChecker)
+ val dragZones =
+ dragZoneFactory.createSortedDragZones(DraggedObject.Bubble(BubbleBarLocation.LEFT))
+ val expectedZones: List<Class<out DragZone>> =
+ listOf(
+ DragZone.Dismiss::class.java,
+ DragZone.Bubble.Left::class.java,
+ DragZone.Bubble.Right::class.java,
+ DragZone.FullScreen::class.java,
+ DragZone.Split.Top::class.java,
+ DragZone.Split.Bottom::class.java,
+ )
+ dragZones.zip(expectedZones).forEach { (zone, expectedType) ->
+ assertThat(zone).isInstanceOf(expectedType)
+ }
+ }
+
+ @Test
+ fun dragZonesForExpandedView_tablet_portrait() {
+ dragZoneFactory =
+ DragZoneFactory(tabletPortrait, splitScreenModeChecker, desktopWindowModeChecker)
+ val dragZones =
+ dragZoneFactory.createSortedDragZones(
+ DraggedObject.ExpandedView(BubbleBarLocation.LEFT)
+ )
+ val expectedZones: List<Class<out DragZone>> =
+ listOf(
+ DragZone.Dismiss::class.java,
+ DragZone.FullScreen::class.java,
+ DragZone.DesktopWindow::class.java,
+ DragZone.Split.Top::class.java,
+ DragZone.Split.Bottom::class.java,
+ DragZone.Bubble.Left::class.java,
+ DragZone.Bubble.Right::class.java,
+ )
+ dragZones.zip(expectedZones).forEach { (zone, expectedType) ->
+ assertThat(zone).isInstanceOf(expectedType)
+ }
+ }
+
+ @Test
+ fun dragZonesForExpandedView_tablet_landscape() {
+ dragZoneFactory = DragZoneFactory(tabletLandscape, splitScreenModeChecker, desktopWindowModeChecker)
+ val dragZones =
+ dragZoneFactory.createSortedDragZones(DraggedObject.ExpandedView(BubbleBarLocation.LEFT))
+ val expectedZones: List<Class<out DragZone>> =
+ listOf(
+ DragZone.Dismiss::class.java,
+ DragZone.FullScreen::class.java,
+ DragZone.DesktopWindow::class.java,
+ DragZone.Split.Left::class.java,
+ DragZone.Split.Right::class.java,
+ DragZone.Bubble.Left::class.java,
+ DragZone.Bubble.Right::class.java,
+ )
+ dragZones.zip(expectedZones).forEach { (zone, expectedType) ->
+ assertThat(zone).isInstanceOf(expectedType)
+ }
+ }
+
+ @Test
+ fun dragZonesForExpandedView_foldable_portrait() {
+ dragZoneFactory = DragZoneFactory(foldablePortrait, splitScreenModeChecker, desktopWindowModeChecker)
+ val dragZones =
+ dragZoneFactory.createSortedDragZones(DraggedObject.ExpandedView(BubbleBarLocation.LEFT))
+ val expectedZones: List<Class<out DragZone>> =
+ listOf(
+ DragZone.Dismiss::class.java,
+ DragZone.FullScreen::class.java,
+ DragZone.Split.Left::class.java,
+ DragZone.Split.Right::class.java,
+ DragZone.Bubble.Left::class.java,
+ DragZone.Bubble.Right::class.java,
+ )
+ dragZones.zip(expectedZones).forEach { (zone, expectedType) ->
+ assertThat(zone).isInstanceOf(expectedType)
+ }
+ }
+
+ @Test
+ fun dragZonesForExpandedView_foldable_landscape() {
+ dragZoneFactory = DragZoneFactory(foldableLandscape, splitScreenModeChecker, desktopWindowModeChecker)
+ val dragZones =
+ dragZoneFactory.createSortedDragZones(DraggedObject.ExpandedView(BubbleBarLocation.LEFT))
+ val expectedZones: List<Class<out DragZone>> =
+ listOf(
+ DragZone.Dismiss::class.java,
+ DragZone.FullScreen::class.java,
+ DragZone.Split.Top::class.java,
+ DragZone.Split.Bottom::class.java,
+ DragZone.Bubble.Left::class.java,
+ DragZone.Bubble.Right::class.java,
+ )
+ dragZones.zip(expectedZones).forEach { (zone, expectedType) ->
+ assertThat(zone).isInstanceOf(expectedType)
+ }
+ }
+
+ @Test
+ fun dragZonesForBubble_tablet_desktopModeDisabled() {
+ isDesktopWindowModeSupported = false
+ dragZoneFactory = DragZoneFactory(foldableLandscape, splitScreenModeChecker, desktopWindowModeChecker)
+ val dragZones =
+ dragZoneFactory.createSortedDragZones(DraggedObject.Bubble(BubbleBarLocation.LEFT))
+ assertThat(dragZones.filterIsInstance<DragZone.DesktopWindow>()).isEmpty()
+ }
+
+ @Test
+ fun dragZonesForExpandedView_tablet_desktopModeDisabled() {
+ isDesktopWindowModeSupported = false
+ dragZoneFactory = DragZoneFactory(foldableLandscape, splitScreenModeChecker, desktopWindowModeChecker)
+ val dragZones =
+ dragZoneFactory.createSortedDragZones(DraggedObject.ExpandedView(BubbleBarLocation.LEFT))
+ assertThat(dragZones.filterIsInstance<DragZone.DesktopWindow>()).isEmpty()
+ }
+}