diff options
Diffstat (limited to 'libs')
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() + } +} |