diff --git a/application.backup b/application.backup new file mode 100644 index 0000000000..bbfdd13521 Binary files /dev/null and b/application.backup differ diff --git a/wmshell/shared/Android.bp b/wmshell/shared/Android.bp index af46ca298e..9bc79e855c 100644 --- a/wmshell/shared/Android.bp +++ b/wmshell/shared/Android.bp @@ -57,6 +57,7 @@ android_library { "androidx.core_core-animation", "androidx.dynamicanimation_dynamicanimation", "com_android_wm_shell_flags_lib", + "com.android.window.flags.window-aconfig-java", "jsr330", ], kotlincflags: ["-Xjvm-default=all"], @@ -91,5 +92,6 @@ java_library { ], static_libs: [ "com_android_wm_shell_flags_lib", + "com.android.window.flags.window-aconfig-java", ], } diff --git a/wmshell/shared/res/values/config.xml b/wmshell/shared/res/values/config.xml index a1d81cea09..552d398b07 100644 --- a/wmshell/shared/res/values/config.xml +++ b/wmshell/shared/res/values/config.xml @@ -15,4 +15,6 @@ limitations under the License. --> + + 336 \ No newline at end of file diff --git a/wmshell/shared/res/values/dimen.xml b/wmshell/shared/res/values/dimen.xml index c3987caa87..948a7ce43c 100644 --- a/wmshell/shared/res/values/dimen.xml +++ b/wmshell/shared/res/values/dimen.xml @@ -19,8 +19,7 @@ 96dp - 140dp - 200dp + 96dp 140dp 200dp 512dp @@ -39,16 +38,16 @@ 100dp - 2dp 28dp 2dp 20dp 100dp 130dp - 330 - 578 - 108 - 24 + 330dp + 578dp + 108dp + 24dp + 24dp 16dp diff --git a/wmshell/shared/src/com/android/wm/shell/shared/FocusTransitionListener.java b/wmshell/shared/src/com/android/wm/shell/shared/FocusTransitionListener.java index 02a799189f..ef69643bc3 100644 --- a/wmshell/shared/src/com/android/wm/shell/shared/FocusTransitionListener.java +++ b/wmshell/shared/src/com/android/wm/shell/shared/FocusTransitionListener.java @@ -16,6 +16,8 @@ package com.android.wm.shell.shared; +import android.app.ActivityManager; + import com.android.wm.shell.shared.annotations.ExternalThread; /** @@ -31,6 +33,6 @@ public interface FocusTransitionListener { /** * Called when the per-app or system-wide focus state has changed for a task. */ - default void onFocusedTaskChanged(int taskId, boolean isFocusedOnDisplay, - boolean isFocusedGlobally) {} + default void onFocusedTaskChanged(ActivityManager.RunningTaskInfo taskInfo, + boolean isFocusedOnDisplay, boolean isFocusedGlobally) {} } diff --git a/wmshell/shared/src/com/android/wm/shell/shared/GroupedTaskInfo.java b/wmshell/shared/src/com/android/wm/shell/shared/GroupedTaskInfo.java index 006dc1439d..ee69a66178 100644 --- a/wmshell/shared/src/com/android/wm/shell/shared/GroupedTaskInfo.java +++ b/wmshell/shared/src/com/android/wm/shell/shared/GroupedTaskInfo.java @@ -224,19 +224,20 @@ public class GroupedTaskInfo implements Parcelable { /** * Get primary {@link TaskInfo}. + * Nullable only if the group if TYPE_DESK, non-null for TYPE_FULLSCREEN and TYPE_SPLIT. * * @throws IllegalStateException if the group is TYPE_MIXED. */ - @NonNull + @Nullable public TaskInfo getTaskInfo1() { if (mType == TYPE_MIXED) { throw new IllegalStateException("No indexed tasks for a mixed task"); } - return mTasks.getFirst(); + return CollectionsKt.firstOrNull(mTasks); } /** - * Get secondary {@link TaskInfo}, used primarily for TYPE_SPLIT. + * Get secondary {@link TaskInfo}, used primarily for TYPE_SPLIT, not null for TYPE_SPLIT. * * @throws IllegalStateException if the group is TYPE_MIXED. */ diff --git a/wmshell/shared/src/com/android/wm/shell/shared/TransitionUtil.java b/wmshell/shared/src/com/android/wm/shell/shared/TransitionUtil.java index 851987269c..efb2183df5 100644 --- a/wmshell/shared/src/com/android/wm/shell/shared/TransitionUtil.java +++ b/wmshell/shared/src/com/android/wm/shell/shared/TransitionUtil.java @@ -17,6 +17,7 @@ package com.android.wm.shell.shared; import static android.app.ActivityTaskManager.INVALID_TASK_ID; +import static android.app.WindowConfiguration.WINDOWING_MODE_PINNED; import static android.view.RemoteAnimationTarget.MODE_CHANGING; import static android.view.RemoteAnimationTarget.MODE_CLOSING; import static android.view.RemoteAnimationTarget.MODE_OPENING; @@ -124,6 +125,12 @@ public class TransitionUtil { return isNonApp(change) && change.hasFlags(FLAG_IS_DIVIDER_BAR); } + /** Returns `true` if `change` is a pinned Task. */ + private static boolean isPipTask(TransitionInfo.Change change) { + return change.getTaskInfo() != null + && change.getTaskInfo().getWindowingMode() == WINDOWING_MODE_PINNED; + } + /** Returns `true` if `change` is an app's dim layer. */ public static boolean isDimLayer(TransitionInfo.Change change) { return isNonApp(change) && change.hasFlags(FLAG_IS_DIM_LAYER); @@ -310,9 +317,10 @@ public class TransitionUtil { // actual dim value). t.setAlpha(change.getLeash(), 1.0f); } - if (!isDividerBar(change)) { - // For divider, don't modify its inner leash position when creating the outer leash - // for the transition. In case the position being wrong after the transition finished. + if (!isDividerBar(change) && !isPipTask(change)) { + // For certain components such as Divider and PiP, don't modify its inner leash + // position when creating the outer leash for the transition. In case the position + // being wrong after the transition finished. t.setPosition(change.getLeash(), 0, 0); } t.setLayer(change.getLeash(), 0); diff --git a/wmshell/shared/src/com/android/wm/shell/shared/animation/MinimizeAnimator.kt b/wmshell/shared/src/com/android/wm/shell/shared/animation/MinimizeAnimator.kt index 4ecace0292..fc9648e316 100644 --- a/wmshell/shared/src/com/android/wm/shell/shared/animation/MinimizeAnimator.kt +++ b/wmshell/shared/src/com/android/wm/shell/shared/animation/MinimizeAnimator.kt @@ -23,9 +23,12 @@ import android.content.Context import android.os.Handler import android.view.Choreographer import android.view.SurfaceControl.Transaction +import android.window.DesktopExperienceFlags import android.window.TransitionInfo.Change import com.android.internal.jank.Cuj.CUJ_DESKTOP_MODE_MINIMIZE_WINDOW import com.android.internal.jank.InteractionJankMonitor +import com.android.wm.shell.shared.animation.WindowAnimator.BoundsAnimationParams.AnimationBounds +import java.time.Duration /** Creates minimization animation */ object MinimizeAnimator { @@ -38,6 +41,16 @@ object MinimizeAnimator { endOffsetYDp = 12f, endScale = 0.97f, interpolator = Interpolators.STANDARD_ACCELERATE, + animBounds = if (DesktopExperienceFlags.ENABLE_MULTIPLE_DESKTOPS_BACKEND.isTrue) { + // In some cases, nav-back on the last desktop task may cause it to be reparented + // into a fullscreen TDA before being minimized back into a desk by + // [DesktopBackNavTransitionObserver]. The minimize animation would then occur when + // the task is still fullscreen, which means it should use the start bounds for the + // minimize animation. + AnimationBounds.START + } else { + AnimationBounds.END + } ) /** @@ -48,6 +61,7 @@ object MinimizeAnimator { * @param animationHandler the Handler that the animation is running on. */ @JvmStatic + @JvmOverloads fun create( context: Context, change: Change, @@ -55,6 +69,7 @@ object MinimizeAnimator { onAnimFinish: (Animator) -> Unit, interactionJankMonitor: InteractionJankMonitor, animationHandler: Handler, + startAnimDelay: Duration = Duration.ZERO, ): Animator { val boundsAnimator = WindowAnimator.createBoundsAnimator( context.resources.displayMetrics, @@ -91,6 +106,7 @@ object MinimizeAnimator { } } return AnimatorSet().apply { + startDelay = startAnimDelay.toMillis() playTogether(boundsAnimator, alphaAnimator) addListener(listener) } diff --git a/wmshell/shared/src/com/android/wm/shell/shared/animation/PhysicsAnimator.kt b/wmshell/shared/src/com/android/wm/shell/shared/animation/PhysicsAnimator.kt index 812b358584..c5e2c64ba9 100644 --- a/wmshell/shared/src/com/android/wm/shell/shared/animation/PhysicsAnimator.kt +++ b/wmshell/shared/src/com/android/wm/shell/shared/animation/PhysicsAnimator.kt @@ -117,6 +117,9 @@ class PhysicsAnimator private constructor (target: T) { /** End actions to run when all animations have completed. */ private val endActions = ArrayList() + /** End actions to run when all animations have completed or canceled. */ + private val endOrCancelActions = ArrayList() + /** SpringConfig to use by default for properties whose springs were not provided. */ private var defaultSpring: SpringConfig = globalDefaultSpring @@ -384,7 +387,8 @@ class PhysicsAnimator private constructor (target: T) { * Adds a listener that will be called when a property stops animating. This is useful if * you care about a specific property ending, or want to use the end value/end velocity from a * particular property's animation. If you just want to run an action when all property - * animations have ended, use [withEndActions]. + * animations have ended, use [withEndActions]. If you want an action to run when all property + * animations have ended or canceled, use [withEndOrCancelActions]. */ fun addEndListener(listener: EndListener): PhysicsAnimator { endListeners.add(listener) @@ -393,8 +397,8 @@ class PhysicsAnimator private constructor (target: T) { /** * Adds end actions that will be run sequentially when animations for every property involved in - * this specific animation have ended (unless they were explicitly canceled). For example, if - * you call: + * this specific animation have ended (unless they were explicitly canceled, in which you should + * use [withEndOrCancelActions]). For example, if you call: * * animator * .spring(TRANSLATION_X, ...) @@ -424,6 +428,9 @@ class PhysicsAnimator private constructor (target: T) { * access to the animation's end value/velocity, or you want to run these actions even if the * animation is explicitly canceled, use [addEndListener]. End listeners have an allEnded param, * which indicates that all relevant animations have ended. + * + * These actions run after those added via [addEndListener], and before actions added via + * [withEndOrCancelActions]. */ fun withEndActions(vararg endActions: EndAction?): PhysicsAnimator { this.endActions.addAll(endActions.filterNotNull()) @@ -439,6 +446,15 @@ class PhysicsAnimator private constructor (target: T) { return this } + /** + * Like [withEndActions], but called if the animator is canceled as well. These actions are + * always run after those added via [addEndListener] and [withEndActions]. + */ + fun withEndOrCancelActions(vararg endOrCancelActions: Runnable?): PhysicsAnimator { + this.endOrCancelActions.addAll(endOrCancelActions.filterNotNull().map { it::run }) + return this + } + fun setDefaultSpringConfig(defaultSpring: SpringConfig) { this.defaultSpring = defaultSpring } @@ -581,7 +597,8 @@ class PhysicsAnimator private constructor (target: T) { getAnimatedProperties(), ArrayList(updateListeners), ArrayList(endListeners), - ArrayList(endActions))) + ArrayList(endActions), + ArrayList(endOrCancelActions))) // Actually start the DynamicAnimations. This is delayed until after the InternalListener is // constructed and added so that we don't miss the end listener firing for any animations @@ -599,6 +616,7 @@ class PhysicsAnimator private constructor (target: T) { updateListeners.clear() endListeners.clear() endActions.clear() + endOrCancelActions.clear() } /** Retrieves a spring animation for the given property, building one if needed. */ @@ -659,7 +677,8 @@ class PhysicsAnimator private constructor (target: T) { private var properties: Set>, private var updateListeners: List>, private var endListeners: List>, - private var endActions: List + private var endActions: List, + private var endOrCancelActions: List ) { /** The number of properties whose animations haven't ended. */ @@ -735,9 +754,12 @@ class PhysicsAnimator private constructor (target: T) { } // If all of the animations that this listener cares about have ended, run the end - // actions unless the animation was canceled. - if (allEnded && !canceled) { - endActions.forEach { it() } + // actions + if (allEnded) { + if (!canceled) { + endActions.forEach { it() } + } + endOrCancelActions.forEach { it() } } return allEnded @@ -785,7 +807,8 @@ class PhysicsAnimator private constructor (target: T) { * animator is under test. */ internal fun cancelInternal(properties: Set>) { - for (property in properties) { + val propertiesCopy = properties.toSet() + for (property in propertiesCopy) { flingAnimations[property]?.cancel() springAnimations[property]?.cancel() } @@ -911,8 +934,10 @@ class PhysicsAnimator private constructor (target: T) { * to respond to specific property animations concluding (such as hiding a view when ALPHA * ends, even if the corresponding TRANSLATION animations have not ended). * - * If you just want to run an action when all of the property animations have ended, you can - * use [PhysicsAnimator.withEndActions]. + * If you just want to run an action when all of the property animations have ended (but not + * canceled), you can use [PhysicsAnimator.withEndActions]. If you need to run an action + * regardless of whether the animations were canceled, use + * [PhysicsAnimator.withEndOrCancelActions]. * * @param target The animated object itself. * @param property The property whose animation has just ended. diff --git a/wmshell/shared/src/com/android/wm/shell/shared/animation/WindowAnimator.kt b/wmshell/shared/src/com/android/wm/shell/shared/animation/WindowAnimator.kt index d1c34a4ac1..dc756fe90b 100644 --- a/wmshell/shared/src/com/android/wm/shell/shared/animation/WindowAnimator.kt +++ b/wmshell/shared/src/com/android/wm/shell/shared/animation/WindowAnimator.kt @@ -26,6 +26,8 @@ import android.view.Choreographer import android.view.SurfaceControl import android.view.animation.Interpolator import android.window.TransitionInfo +import com.android.wm.shell.shared.animation.WindowAnimator.BoundsAnimationParams.AnimationBounds.END +import com.android.wm.shell.shared.animation.WindowAnimator.BoundsAnimationParams.AnimationBounds.START /** Creates animations that can be applied to windows/surfaces. */ object WindowAnimator { @@ -38,7 +40,10 @@ object WindowAnimator { val startScale: Float = 1f, val endScale: Float = 1f, val interpolator: Interpolator, - ) + val animBounds: AnimationBounds = END, + ) { + enum class AnimationBounds { START, END } + } /** * Creates an animator to reposition and scale the bounds of the leash of the given change. @@ -54,10 +59,14 @@ object WindowAnimator { change: TransitionInfo.Change, transaction: SurfaceControl.Transaction, ): ValueAnimator { + val bounds = when (boundsAnimDef.animBounds) { + START -> change.startAbsBounds + END -> change.endAbsBounds + } val startPos = getPosition( displayMetrics, - change.endAbsBounds, + bounds, boundsAnimDef.startScale, boundsAnimDef.startOffsetYDp, ) @@ -65,7 +74,7 @@ object WindowAnimator { val endPos = getPosition( displayMetrics, - change.endAbsBounds, + bounds, boundsAnimDef.endScale, boundsAnimDef.endOffsetYDp, ) diff --git a/wmshell/shared/src/com/android/wm/shell/shared/bubbles/BubbleAnythingFlagHelper.java b/wmshell/shared/src/com/android/wm/shell/shared/bubbles/BubbleAnythingFlagHelper.java index e1f1d0c32e..5eb094b092 100644 --- a/wmshell/shared/src/com/android/wm/shell/shared/bubbles/BubbleAnythingFlagHelper.java +++ b/wmshell/shared/src/com/android/wm/shell/shared/bubbles/BubbleAnythingFlagHelper.java @@ -31,6 +31,17 @@ public class BubbleAnythingFlagHelper { return enableBubbleAnything() || Flags.enableCreateAnyBubble(); } + /** Whether creating any bubble and force task excluded from recents are enabled. */ + public static boolean enableCreateAnyBubbleWithForceExcludedFromRecents() { + return Flags.enableCreateAnyBubble() + && com.android.window.flags.Flags.excludeTaskFromRecents(); + } + + /** Whether creating any bubble and app compat fixes for bubbles are enabled. */ + public static boolean enableCreateAnyBubbleWithAppCompatFixes() { + return Flags.enableCreateAnyBubble() && Flags.enableBubbleAppCompatFixes(); + } + /** * Whether creating any bubble and transforming to fullscreen, or the overall bubble anything * feature is enabled. @@ -41,6 +52,26 @@ public class BubbleAnythingFlagHelper { && Flags.enableCreateAnyBubble()); } + /** Whether creating a root task to manage the bubble tasks in the Core. */ + public static boolean enableRootTaskForBubble() { + // This is needed to prevent tasks being hidden and re-parented to TDA when move-to-back. + if (!enableCreateAnyBubbleWithForceExcludedFromRecents()) { + return false; + } + + // This is needed to allow the activity behind the root task remains in RESUMED state. + if (!com.android.window.flags.Flags.enableSeeThroughTaskFragments()) { + return false; + } + + // This is needed to allow the leaf task can be started in expected bounds. + if (!com.android.window.flags.Flags.respectLeafTaskBounds()) { + return false; + } + + return com.android.window.flags.Flags.rootTaskForBubble(); + } + /** Whether the overall bubble anything feature is enabled. */ public static boolean enableBubbleAnything() { return Flags.enableBubbleAnything(); diff --git a/wmshell/shared/src/com/android/wm/shell/shared/bubbles/BubbleBarLocation.kt b/wmshell/shared/src/com/android/wm/shell/shared/bubbles/BubbleBarLocation.kt index 6acd9dbe8b..2ddd273791 100644 --- a/wmshell/shared/src/com/android/wm/shell/shared/bubbles/BubbleBarLocation.kt +++ b/wmshell/shared/src/com/android/wm/shell/shared/bubbles/BubbleBarLocation.kt @@ -60,6 +60,18 @@ enum class BubbleBarLocation : Parcelable { override fun newArray(size: Int) = arrayOfNulls(size) } + + /** + * Checks whether locations are on the different sides from each other. If any of the + * locations is null returns false. + */ + fun isDifferentSides( + first: BubbleBarLocation?, + second: BubbleBarLocation?, + isRtl: Boolean + ): Boolean { + return first != null && second != null && first.isOnLeft(isRtl) != second.isOnLeft(isRtl) + } } /** Define set of constants that allow to determine why location changed. */ diff --git a/wmshell/shared/src/com/android/wm/shell/shared/bubbles/ContextUtils.kt b/wmshell/shared/src/com/android/wm/shell/shared/bubbles/ContextUtils.kt index 27db5297b7..0385d938ed 100644 --- a/wmshell/shared/src/com/android/wm/shell/shared/bubbles/ContextUtils.kt +++ b/wmshell/shared/src/com/android/wm/shell/shared/bubbles/ContextUtils.kt @@ -19,7 +19,6 @@ package com.android.wm.shell.shared.bubbles import android.content.Context import android.view.View import android.view.WindowManagerPolicyConstants -import com.android.internal.R /** Simplifies accessing context fields. */ object ContextUtils { @@ -27,7 +26,7 @@ object ContextUtils { /** Gets navigation mode. */ @JvmStatic val Context.navigationMode: Int - get() = resources.getInteger(R.integer.config_navBarInteractionMode) + get() = resources.getInteger(com.android.internal.R.integer.config_navBarInteractionMode) /** Returns whether the navigation mode is gestures. */ @JvmStatic diff --git a/wmshell/shared/src/com/android/wm/shell/shared/bubbles/DismissView.kt b/wmshell/shared/src/com/android/wm/shell/shared/bubbles/DismissView.kt index 2bb66b0bbc..c0830c9e3c 100644 --- a/wmshell/shared/src/com/android/wm/shell/shared/bubbles/DismissView.kt +++ b/wmshell/shared/src/com/android/wm/shell/shared/bubbles/DismissView.kt @@ -55,7 +55,13 @@ class DismissView(context: Context) : FrameLayout(context) { @DimenRes val targetSizeResId: Int, /** dimen resource id of the icon size in the dismiss target */ @DimenRes val iconSizeResId: Int, - /** dimen resource id of the bottom margin for the dismiss target */ + /** + * dimen resource id of the bottom margin for the dismiss target + * + * By default the margin is applied on top of the bottom navigation bar inset. To ignore + * the bottom navigation inset, and apply a margin relative to the bottom edge of the + * screen set [applyMarginOverNavBarInset] to `false`. + */ @DimenRes var bottomMarginResId: Int, /** dimen resource id of the height for dismiss area gradient */ @DimenRes val floatingGradientHeightResId: Int, @@ -64,7 +70,13 @@ class DismissView(context: Context) : FrameLayout(context) { /** drawable resource id of the dismiss target background */ @DrawableRes val backgroundResId: Int, /** drawable resource id of the icon for the dismiss target */ - @DrawableRes val iconResId: Int + @DrawableRes val iconResId: Int, + /** + * Whether the value provided in [bottomMarginResId] should be applied on top of the + * bottom navigation bar inset. If this is `false` the margin is relative to the bottom + * edge of the screen. + */ + val applyMarginOverNavBarInset: Boolean = true, ) companion object { @@ -95,9 +107,9 @@ class DismissView(context: Context) : FrameLayout(context) { } init { - setClipToPadding(false) - setClipChildren(false) - setVisibility(View.INVISIBLE) + clipToPadding = false + clipChildren = false + visibility = View.INVISIBLE addView(circle) } @@ -135,10 +147,12 @@ class DismissView(context: Context) : FrameLayout(context) { /** * Animates this view in. + * + * @return `true` if the view was shown, `false` otherwise */ - fun show() { - if (isShowing) return - val gradientDrawable = checkExists(gradientDrawable) ?: return + fun show(): Boolean { + if (isShowing) return false + val gradientDrawable = checkExists(gradientDrawable) ?: return false isShowing = true setVisibility(View.VISIBLE) val alphaAnim = ObjectAnimator.ofInt(gradientDrawable, GRADIENT_ALPHA, @@ -150,6 +164,7 @@ class DismissView(context: Context) : FrameLayout(context) { animator .spring(DynamicAnimation.TRANSLATION_Y, 0f, spring) .start() + return true } /** @@ -209,11 +224,16 @@ class DismissView(context: Context) : FrameLayout(context) { private fun updatePadding() { val config = checkExists(config) ?: return - val insets: WindowInsets = wm.getCurrentWindowMetrics().getWindowInsets() - val navInset = insets.getInsetsIgnoringVisibility( - WindowInsets.Type.navigationBars()) - setPadding(0, 0, 0, navInset.bottom + - resources.getDimensionPixelSize(config.bottomMarginResId)) + val bottomMargin = resources.getDimensionPixelSize(config.bottomMarginResId) + if (config.applyMarginOverNavBarInset) { + val insets: WindowInsets = wm.currentWindowMetrics.windowInsets + val navInset = insets.getInsetsIgnoringVisibility( + WindowInsets.Type.navigationBars() + ) + setPadding(0, 0, 0, navInset.bottom + bottomMargin) + } else { + setPadding(0, 0, 0, bottomMargin) + } } /** diff --git a/wmshell/shared/src/com/android/wm/shell/shared/bubbles/DragToBubblesZoneChangeListener.kt b/wmshell/shared/src/com/android/wm/shell/shared/bubbles/DragToBubblesZoneChangeListener.kt new file mode 100644 index 0000000000..73c951925e --- /dev/null +++ b/wmshell/shared/src/com/android/wm/shell/shared/bubbles/DragToBubblesZoneChangeListener.kt @@ -0,0 +1,132 @@ +/* + * 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 com.android.wm.shell.shared.bubbles.BubbleBarLocation.Companion.isDifferentSides +import com.android.wm.shell.shared.bubbles.DropTargetManager.DragZoneChangedListener + +/** + * Class that encapsulates common logic of reacting to the drag zone changes for dragging launcher + * icons to the bubble bar. + */ +class DragToBubblesZoneChangeListener( + private val isRtl: Boolean, + private val callback: Callback, +) : DragZoneChangedListener { + + private var lastUpdateLocation: BubbleBarLocation? = null + private val isLocationChangedFromOriginal: Boolean + get() = lastUpdateLocation != null + && isDifferentSides( + lastUpdateLocation, + callback.getStartingBubbleBarLocation(), + isRtl + ) + + override fun onInitialDragZoneSet(dragZone: DragZone?) {} + + override fun onDragZoneChanged( + draggedObject: DraggedObject, + from: DragZone?, + to: DragZone?, + ) { + val updateLocation = to.toBubbleBarLocation() + updateBubbleBarLocation(updateLocation) + lastUpdateLocation = updateLocation + } + + override fun onDragEnded(zone: DragZone?) { + updateBubbleBarLocation(updateLocation = null) + } + + fun updateBubbleBarLocation(updateLocation: BubbleBarLocation?) { + val updatedBefore = lastUpdateLocation != null + val originalLocation = callback.getStartingBubbleBarLocation() + val isLocationUpdated = isDifferentSides(lastUpdateLocation, updateLocation, isRtl) + if (shouldNotifyZoneChanged(updateLocation)) { + callback.onDragEnteredLocation(updateLocation) + } + if (!callback.hasBubbles()) { + // has no bubbles, so showing the pin view + if (updateLocation == null || !updatedBefore || isLocationUpdated) { + callback.bubbleBarPillowShownAtLocation(updateLocation) + } + return + } + if (updateLocation == null) { + if (isLocationChangedFromOriginal) { + callback.animateBubbleBarLocation(originalLocation) + } + return + } + if (updatedBefore && isLocationUpdated) { + // updated before and location updated - update to new location + callback.animateBubbleBarLocation(updateLocation) + return + } + if (!updatedBefore && isDifferentSides(originalLocation, updateLocation, isRtl)) { + // not updated before and location changed from original + callback.animateBubbleBarLocation(updateLocation) + } + } + + private fun DragZone?.toBubbleBarLocation(): BubbleBarLocation? { + return when (this) { + is DragZone.Bubble.Left -> BubbleBarLocation.LEFT + is DragZone.Bubble.Right -> BubbleBarLocation.RIGHT + else -> null + } + } + + private fun shouldNotifyZoneChanged(updateLocation: BubbleBarLocation?): Boolean { + // Notify if one is null and the other isn't (entering/exiting a general zone area) + // OR if both are non-null and they represent different sides. + return (lastUpdateLocation == null) != (updateLocation == null) || + isDifferentSides(lastUpdateLocation, updateLocation, isRtl) + } + + /** + * Callback interface for {@link DragToBubblesZoneChangeListener} to communicate drag-related + * events and request actions on the bubble bar. + * The primary purpose of this callback is to decouple the generic drag zone detection logic + * within {@code DragToBubblesZoneChangeListener} from the specific UI implementation details + * of the bubble bar. + */ + interface Callback { + /** The starting bubble bar location before the drag started. */ + fun getStartingBubbleBarLocation(): BubbleBarLocation + + /** Check if the bubble bar has any bubbles. */ + fun hasBubbles(): Boolean + + /** Called when need to animate the bubble bar location. */ + fun animateBubbleBarLocation(bubbleBarLocation: BubbleBarLocation) + + /** Called when the bubble bar pillow view is shown at position. */ + fun bubbleBarPillowShownAtLocation(bubbleBarLocation: BubbleBarLocation?) {} + + /** + * Called when a drag operation enters or exits a bubble bar location. + * + * @param bubbleBarLocation The [BubbleBarLocation] that the drag operation has entered. + * This will be non-null if the drag has entered a valid bubble bar + * location. It will be `null` if the drag operation has exited + * all bubble bar locations. + */ + fun onDragEnteredLocation(bubbleBarLocation: BubbleBarLocation?) {} + } +} diff --git a/wmshell/shared/src/com/android/wm/shell/shared/bubbles/DragZone.kt b/wmshell/shared/src/com/android/wm/shell/shared/bubbles/DragZone.kt index 6eff75c9a4..6043d64f28 100644 --- a/wmshell/shared/src/com/android/wm/shell/shared/bubbles/DragZone.kt +++ b/wmshell/shared/src/com/android/wm/shell/shared/bubbles/DragZone.kt @@ -17,6 +17,7 @@ package com.android.wm.shell.shared.bubbles import android.graphics.Rect +import kotlin.math.hypot /** * Represents an invisible area on the screen that determines what happens to a dragged object if it @@ -30,42 +31,78 @@ import android.graphics.Rect sealed interface DragZone { /** The bounds of this drag zone. */ - val bounds: Rect + val bounds: Bounds /** The bounds of the drop target associated with this drag zone. */ - val dropTarget: Rect? + val dropTarget: DropTargetRect? + + /** The bounds of the second drop target associated with this drag zone. */ + val secondDropTarget: DropTargetRect? 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, override val dropTarget: Rect) : DragZone { - data class Left(override val bounds: Rect, override val dropTarget: Rect) : - Bubble(bounds, dropTarget) + sealed interface Bounds { + fun contains(x: Int, y: Int) = + when (this) { + is RectZone -> rect.contains(x, y) + is CircleZone -> hypot((x - this.x).toFloat(), (y - this.y).toFloat()) < radius + } - data class Right(override val bounds: Rect, override val dropTarget: Rect) : - Bubble(bounds, dropTarget) + data class RectZone(val rect: Rect) : Bounds + + data class CircleZone(val x: Int, val y: Int, val radius: Int) : Bounds + } + + data class DropTargetRect(val rect: Rect, val cornerRadius: Float) + + /** Represents the bubble drag area on the screen. */ + sealed class Bubble( + override val bounds: Bounds.RectZone, + override val dropTarget: DropTargetRect?, + ) : DragZone { + data class Left( + override val bounds: Bounds.RectZone, + override val dropTarget: DropTargetRect?, + override val secondDropTarget: DropTargetRect? = null, + ) : Bubble(bounds, dropTarget) + + data class Right( + override val bounds: Bounds.RectZone, + override val dropTarget: DropTargetRect?, + override val secondDropTarget: DropTargetRect? = null, + ) : Bubble(bounds, dropTarget) } /** Represents dragging to Desktop Window. */ - data class DesktopWindow(override val bounds: Rect, override val dropTarget: Rect) : DragZone + data class DesktopWindow( + override val bounds: Bounds.RectZone, + override val dropTarget: DropTargetRect, + override val secondDropTarget: DropTargetRect? = null, + ) : DragZone /** Represents dragging to Full Screen. */ - data class FullScreen(override val bounds: Rect, override val dropTarget: Rect) : DragZone + data class FullScreen( + override val bounds: Bounds.RectZone, + override val dropTarget: DropTargetRect, + override val secondDropTarget: DropTargetRect? = null, + ) : DragZone /** Represents dragging to dismiss. */ - data class Dismiss(override val bounds: Rect) : DragZone { - override val dropTarget: Rect? = null + data class Dismiss(override val bounds: Bounds.CircleZone) : DragZone { + override val dropTarget: DropTargetRect? = null + override val secondDropTarget: DropTargetRect? = null } /** Represents dragging to enter Split or replace a Split app. */ - sealed class Split(override val bounds: Rect) : DragZone { - override val dropTarget: Rect? = null + sealed class Split(override val bounds: Bounds.RectZone) : DragZone { + override val dropTarget: DropTargetRect? = null + override val secondDropTarget: DropTargetRect? = null - data class Left(override val bounds: Rect) : Split(bounds) + data class Left(override val bounds: Bounds.RectZone) : Split(bounds) - data class Right(override val bounds: Rect) : Split(bounds) + data class Right(override val bounds: Bounds.RectZone) : Split(bounds) - data class Top(override val bounds: Rect) : Split(bounds) + data class Top(override val bounds: Bounds.RectZone) : Split(bounds) - data class Bottom(override val bounds: Rect) : Split(bounds) + data class Bottom(override val bounds: Bounds.RectZone) : Split(bounds) } } diff --git a/wmshell/shared/src/com/android/wm/shell/shared/bubbles/DragZoneFactory.kt b/wmshell/shared/src/com/android/wm/shell/shared/bubbles/DragZoneFactory.kt index 2c4e75ad72..0712ec7d19 100644 --- a/wmshell/shared/src/com/android/wm/shell/shared/bubbles/DragZoneFactory.kt +++ b/wmshell/shared/src/com/android/wm/shell/shared/bubbles/DragZoneFactory.kt @@ -20,6 +20,9 @@ import android.content.Context import android.graphics.Rect import android.util.TypedValue import androidx.annotation.DimenRes +import com.android.wm.shell.shared.bubbles.DragZone.Bounds.CircleZone +import com.android.wm.shell.shared.bubbles.DragZone.Bounds.RectZone +import com.android.wm.shell.shared.bubbles.DragZone.DropTargetRect import com.android.wm.shell.shared.bubbles.DragZoneFactory.SplitScreenModeChecker.SplitScreenMode /** A class for creating drag zones for dragging bubble objects or dragging into bubbles. */ @@ -28,12 +31,14 @@ class DragZoneFactory( private val deviceConfig: DeviceConfig, private val splitScreenModeChecker: SplitScreenModeChecker, private val desktopWindowModeChecker: DesktopWindowModeChecker, + private val bubbleBarPropertiesProvider: BubbleBarPropertiesProvider, ) { private val windowBounds: Rect get() = deviceConfig.windowBounds - private var dismissDragZoneSize = 0 + private var dismissDragZoneRadius = 0 + private var dismissDragZoneBottomMargin = 0 private var bubbleDragZoneTabletSize = 0 private var bubbleDragZoneFoldableSize = 0 private var fullScreenDragZoneWidth = 0 @@ -42,6 +47,7 @@ class DragZoneFactory( private var desktopWindowDragZoneHeight = 0 private var desktopWindowFromExpandedViewDragZoneWidth = 0 private var desktopWindowFromExpandedViewDragZoneHeight = 0 + private var desktopWindowFromExpandedViewDragZoneYOffset = 0 private var splitFromBubbleDragZoneHeight = 0 private var splitFromBubbleDragZoneWidth = 0 private var hSplitFromExpandedViewDragZoneWidth = 0 @@ -57,53 +63,98 @@ class DragZoneFactory( private var expandedViewDropTargetHeight = 0 private var expandedViewDropTargetPaddingBottom = 0 private var expandedViewDropTargetPaddingHorizontal = 0 + private var bubbleBarDropTargetPaddingHorizontal = 0 - private val fullScreenDropTarget: Rect - get() = - Rect(windowBounds).apply { - inset(fullScreenDropTargetPadding, fullScreenDropTargetPadding) - } + private var dropTargetCornerRadius = 0f - private val desktopWindowDropTarget: Rect + private val fullScreenDropTarget: DropTargetRect get() = - Rect(windowBounds).apply { - if (deviceConfig.isLandscape) { - inset( - /* dx= */ desktopWindowDropTargetPaddingLarge, - /* dy= */ desktopWindowDropTargetPaddingSmall - ) - } else { - inset( - /* dx= */ desktopWindowDropTargetPaddingSmall, - /* dy= */ desktopWindowDropTargetPaddingLarge - ) - } - } - - private val expandedViewDropTargetLeft: Rect - get() = - Rect( - expandedViewDropTargetPaddingHorizontal, - windowBounds.bottom - - expandedViewDropTargetPaddingBottom - - expandedViewDropTargetHeight, - expandedViewDropTargetWidth + expandedViewDropTargetPaddingHorizontal, - windowBounds.bottom - expandedViewDropTargetPaddingBottom + DropTargetRect( + Rect(windowBounds).apply { + inset(fullScreenDropTargetPadding, fullScreenDropTargetPadding) + }, + dropTargetCornerRadius ) - private val expandedViewDropTargetRight: Rect + private val desktopWindowDropTarget: DropTargetRect get() = - Rect( - windowBounds.right - - expandedViewDropTargetPaddingHorizontal - - expandedViewDropTargetWidth, - windowBounds.bottom - - expandedViewDropTargetPaddingBottom - - expandedViewDropTargetHeight, - windowBounds.right - expandedViewDropTargetPaddingHorizontal, - windowBounds.bottom - expandedViewDropTargetPaddingBottom + DropTargetRect( + Rect(windowBounds).apply { + if (deviceConfig.isLandscape) { + inset( + /* dx= */ desktopWindowDropTargetPaddingLarge, + /* dy= */ desktopWindowDropTargetPaddingSmall + ) + } else { + inset( + /* dx= */ desktopWindowDropTargetPaddingSmall, + /* dy= */ desktopWindowDropTargetPaddingLarge + ) + } + }, + dropTargetCornerRadius ) + private val expandedViewDropTargetLeft: DropTargetRect + get() = + DropTargetRect( + Rect( + expandedViewDropTargetPaddingHorizontal, + windowBounds.bottom - + expandedViewDropTargetPaddingBottom - + expandedViewDropTargetHeight, + expandedViewDropTargetWidth + expandedViewDropTargetPaddingHorizontal, + windowBounds.bottom - expandedViewDropTargetPaddingBottom + ), + dropTargetCornerRadius + ) + + private val expandedViewDropTargetRight: DropTargetRect + get() = + DropTargetRect( + Rect( + windowBounds.right - + expandedViewDropTargetPaddingHorizontal - + expandedViewDropTargetWidth, + windowBounds.bottom - + expandedViewDropTargetPaddingBottom - + expandedViewDropTargetHeight, + windowBounds.right - expandedViewDropTargetPaddingHorizontal, + windowBounds.bottom - expandedViewDropTargetPaddingBottom + ), + dropTargetCornerRadius + ) + + private val bubbleBarDropTargetLeft: DropTargetRect + get() { + val rect = + Rect( + bubbleBarDropTargetPaddingHorizontal, + windowBounds.bottom - + bubbleBarPropertiesProvider.getBottomPadding() - + bubbleBarPropertiesProvider.getHeight(), + bubbleBarDropTargetPaddingHorizontal + bubbleBarPropertiesProvider.getWidth(), + windowBounds.bottom - bubbleBarPropertiesProvider.getBottomPadding() + ) + return DropTargetRect(rect, rect.height() / 2f) + } + + private val bubbleBarDropTargetRight: DropTargetRect + get() { + val rect = + Rect( + windowBounds.right - + bubbleBarDropTargetPaddingHorizontal - + bubbleBarPropertiesProvider.getWidth(), + windowBounds.bottom - + bubbleBarPropertiesProvider.getBottomPadding() - + bubbleBarPropertiesProvider.getHeight(), + windowBounds.right - bubbleBarDropTargetPaddingHorizontal, + windowBounds.bottom - bubbleBarPropertiesProvider.getBottomPadding() + ) + return DropTargetRect(rect, rect.height() / 2f) + } + init { onConfigurationUpdated() } @@ -112,8 +163,8 @@ class DragZoneFactory( fun onConfigurationUpdated() { // TODO b/396539130: Use the shared xml resources once we can easily access them from // launcher - dismissDragZoneSize = - if (deviceConfig.isSmallTablet) 140.dpToPx() else 200.dpToPx() + dismissDragZoneRadius = 96.dpToPx() + dismissDragZoneBottomMargin = 12.dpToPx() bubbleDragZoneTabletSize = 200.dpToPx() bubbleDragZoneFoldableSize = 140.dpToPx() fullScreenDragZoneWidth = 512.dpToPx() @@ -122,6 +173,7 @@ class DragZoneFactory( desktopWindowDragZoneHeight = 300.dpToPx() desktopWindowFromExpandedViewDragZoneWidth = 200.dpToPx() desktopWindowFromExpandedViewDragZoneHeight = 350.dpToPx() + desktopWindowFromExpandedViewDragZoneYOffset = 25.dpToPx() splitFromBubbleDragZoneHeight = 100.dpToPx() splitFromBubbleDragZoneWidth = 60.dpToPx() hSplitFromExpandedViewDragZoneWidth = 60.dpToPx() @@ -136,6 +188,9 @@ class DragZoneFactory( expandedViewDropTargetHeight = 578.dpToPx() expandedViewDropTargetPaddingBottom = 108.dpToPx() expandedViewDropTargetPaddingHorizontal = 24.dpToPx() + bubbleBarDropTargetPaddingHorizontal = 24.dpToPx() + + dropTargetCornerRadius = 28.dpToPx().toFloat() } private fun Context.resolveDimension(@DimenRes dimension: Int) = @@ -160,7 +215,7 @@ class DragZoneFactory( when (draggedObject) { is DraggedObject.BubbleBar -> { dragZones.add(createDismissDragZone()) - dragZones.addAll(createBubbleHalfScreenDragZones()) + dragZones.addAll(createBubbleHalfScreenDragZones(forBubbleBar = true)) } is DraggedObject.Bubble -> { dragZones.add(createDismissDragZone()) @@ -182,65 +237,80 @@ class DragZoneFactory( } else { dragZones.addAll(createSplitScreenDragZonesForExpandedViewOnTablet()) } - dragZones.addAll(createBubbleHalfScreenDragZones()) + dragZones.addAll(createBubbleHalfScreenDragZones(forBubbleBar = false)) + } + is DraggedObject.LauncherIcon -> { + val showDropTarget = draggedObject.showDropTarget + val showSecondDropTarget = !draggedObject.bubbleBarHasBubbles + dragZones.addAll(createBubbleCornerDragZones(showDropTarget, showSecondDropTarget)) } } 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 createBubbleCornerDragZones(): List { + fun getBubbleBarDropRect(isLeftSide: Boolean): Rect { val dragZoneSize = if (deviceConfig.isSmallTablet) { bubbleDragZoneFoldableSize } else { bubbleDragZoneTabletSize } + return Rect( + if (isLeftSide) 0 else windowBounds.right - dragZoneSize, + windowBounds.bottom - dragZoneSize, + if (isLeftSide) dragZoneSize else windowBounds.right, + windowBounds.bottom + ) + } + + private fun createDismissDragZone(): DragZone { + return DragZone.Dismiss( + bounds = + CircleZone( + x = windowBounds.right / 2, + y = windowBounds.bottom - dismissDragZoneBottomMargin - dismissDragZoneRadius, + radius = dismissDragZoneRadius + ) + ) + } + + private fun createBubbleCornerDragZones( + showDropTarget: Boolean = true, + showSecondDropTarget: Boolean = false + ): List { return listOf( DragZone.Bubble.Left( - bounds = - Rect(0, windowBounds.bottom - dragZoneSize, dragZoneSize, windowBounds.bottom), - dropTarget = expandedViewDropTargetLeft, + bounds = RectZone(getBubbleBarDropRect(isLeftSide = true)), + dropTarget = if (showDropTarget) expandedViewDropTargetLeft else null, + secondDropTarget = if (showSecondDropTarget) bubbleBarDropTargetLeft else null ), DragZone.Bubble.Right( - bounds = - Rect( - windowBounds.right - dragZoneSize, - windowBounds.bottom - dragZoneSize, - windowBounds.right, - windowBounds.bottom, - ), - dropTarget = expandedViewDropTargetRight, + bounds = RectZone(getBubbleBarDropRect(isLeftSide = false)), + dropTarget = if (showDropTarget) expandedViewDropTargetRight else null, + secondDropTarget = if (showSecondDropTarget) bubbleBarDropTargetRight else null ) ) } - private fun createBubbleHalfScreenDragZones(): List { + private fun createBubbleHalfScreenDragZones(forBubbleBar: Boolean): List { return listOf( DragZone.Bubble.Left( - bounds = Rect(0, 0, windowBounds.right / 2, windowBounds.bottom), - dropTarget = expandedViewDropTargetLeft, + bounds = RectZone(Rect(0, 0, windowBounds.right / 2, windowBounds.bottom)), + dropTarget = + if (forBubbleBar) bubbleBarDropTargetLeft else expandedViewDropTargetLeft, ), DragZone.Bubble.Right( bounds = - Rect( - windowBounds.right / 2, - 0, - windowBounds.right, - windowBounds.bottom, + RectZone( + Rect( + windowBounds.right / 2, + 0, + windowBounds.right, + windowBounds.bottom, + ), ), - dropTarget = expandedViewDropTargetRight, + dropTarget = + if (forBubbleBar) bubbleBarDropTargetRight else expandedViewDropTargetRight, ) ) } @@ -248,11 +318,13 @@ class DragZoneFactory( private fun createFullScreenDragZone(): DragZone { return DragZone.FullScreen( bounds = - Rect( - windowBounds.right / 2 - fullScreenDragZoneWidth / 2, - 0, - windowBounds.right / 2 + fullScreenDragZoneWidth / 2, - fullScreenDragZoneHeight + RectZone( + Rect( + windowBounds.right / 2 - fullScreenDragZoneWidth / 2, + 0, + windowBounds.right / 2 + fullScreenDragZoneWidth / 2, + fullScreenDragZoneHeight + ), ), dropTarget = fullScreenDropTarget ) @@ -265,18 +337,22 @@ class DragZoneFactory( 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 + RectZone( + 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 + RectZone( + Rect( + 0, + windowBounds.bottom / 2 - desktopWindowDragZoneHeight / 2, + windowBounds.right, + windowBounds.bottom / 2 + desktopWindowDragZoneHeight / 2 + ) ) }, dropTarget = desktopWindowDropTarget @@ -286,11 +362,15 @@ class DragZoneFactory( 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 + RectZone( + Rect( + windowBounds.right / 2 - desktopWindowFromExpandedViewDragZoneWidth / 2, + windowBounds.bottom / 2 - desktopWindowFromExpandedViewDragZoneHeight / 2 - + desktopWindowFromExpandedViewDragZoneYOffset, + windowBounds.right / 2 + desktopWindowFromExpandedViewDragZoneWidth / 2, + windowBounds.bottom / 2 + desktopWindowFromExpandedViewDragZoneHeight / 2 - + desktopWindowFromExpandedViewDragZoneYOffset + ), ), dropTarget = desktopWindowDropTarget ) @@ -307,15 +387,18 @@ class DragZoneFactory( SplitScreenMode.NONE -> listOf( DragZone.Split.Top( - bounds = Rect(0, 0, windowBounds.right, windowBounds.bottom / 2), + bounds = + RectZone(Rect(0, 0, windowBounds.right, windowBounds.bottom / 2)), ), DragZone.Split.Bottom( bounds = - Rect( - 0, - windowBounds.bottom / 2, - windowBounds.right, - windowBounds.bottom + RectZone( + Rect( + 0, + windowBounds.bottom / 2, + windowBounds.right, + windowBounds.bottom + ), ), ) ) @@ -323,20 +406,24 @@ class DragZoneFactory( listOf( DragZone.Split.Top( bounds = - Rect( - 0, - 0, - windowBounds.right, - windowBounds.bottom - splitFromBubbleDragZoneHeight + RectZone( + Rect( + 0, + 0, + windowBounds.right, + windowBounds.bottom - splitFromBubbleDragZoneHeight + ), ), ), DragZone.Split.Bottom( bounds = - Rect( - 0, - windowBounds.bottom - splitFromBubbleDragZoneHeight, - windowBounds.right, - windowBounds.bottom + RectZone( + Rect( + 0, + windowBounds.bottom - splitFromBubbleDragZoneHeight, + windowBounds.right, + windowBounds.bottom + ), ), ) ) @@ -344,15 +431,20 @@ class DragZoneFactory( SplitScreenMode.SPLIT_10_90 -> { listOf( DragZone.Split.Top( - bounds = Rect(0, 0, windowBounds.right, splitFromBubbleDragZoneHeight), + bounds = + RectZone( + Rect(0, 0, windowBounds.right, splitFromBubbleDragZoneHeight) + ), ), DragZone.Split.Bottom( bounds = - Rect( - 0, - splitFromBubbleDragZoneHeight, - windowBounds.right, - windowBounds.bottom + RectZone( + Rect( + 0, + splitFromBubbleDragZoneHeight, + windowBounds.right, + windowBounds.bottom + ), ), ) ) @@ -365,15 +457,18 @@ class DragZoneFactory( SplitScreenMode.NONE -> listOf( DragZone.Split.Left( - bounds = Rect(0, 0, windowBounds.right / 2, windowBounds.bottom), + bounds = + RectZone(Rect(0, 0, windowBounds.right / 2, windowBounds.bottom)), ), DragZone.Split.Right( bounds = - Rect( - windowBounds.right / 2, - 0, - windowBounds.right, - windowBounds.bottom + RectZone( + Rect( + windowBounds.right / 2, + 0, + windowBounds.right, + windowBounds.bottom + ), ), ) ) @@ -381,35 +476,44 @@ class DragZoneFactory( listOf( DragZone.Split.Left( bounds = - Rect( - 0, - 0, - windowBounds.right - splitFromBubbleDragZoneWidth, - windowBounds.bottom + RectZone( + Rect( + 0, + 0, + windowBounds.right - splitFromBubbleDragZoneWidth, + windowBounds.bottom + ), ), ), DragZone.Split.Right( bounds = - Rect( - windowBounds.right - splitFromBubbleDragZoneWidth, - 0, - windowBounds.right, - windowBounds.bottom + RectZone( + Rect( + windowBounds.right - splitFromBubbleDragZoneWidth, + 0, + windowBounds.right, + windowBounds.bottom + ), ), ) ) SplitScreenMode.SPLIT_10_90 -> listOf( DragZone.Split.Left( - bounds = Rect(0, 0, splitFromBubbleDragZoneWidth, windowBounds.bottom), + bounds = + RectZone( + Rect(0, 0, splitFromBubbleDragZoneWidth, windowBounds.bottom) + ), ), DragZone.Split.Right( bounds = - Rect( - splitFromBubbleDragZoneWidth, - 0, - windowBounds.right, - windowBounds.bottom + RectZone( + Rect( + splitFromBubbleDragZoneWidth, + 0, + windowBounds.right, + windowBounds.bottom + ), ), ) ) @@ -422,27 +526,35 @@ class DragZoneFactory( 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. + // for the top split zone. the bottom edge of the bottom split zone starts at the + // dismiss zone upper half circle to cover the area outside of the dismiss circle but + // within the split zone width. Both are horizontally centered. val splitZoneLeft = windowBounds.right / 2 - vSplitFromExpandedViewDragZoneWidth / 2 val splitZoneRight = splitZoneLeft + vSplitFromExpandedViewDragZoneWidth - val bottomSplitZoneBottom = windowBounds.bottom - dismissDragZoneSize + val bottomSplitZoneBottom = + windowBounds.bottom - dismissDragZoneBottomMargin - dismissDragZoneRadius * 2 listOf( DragZone.Split.Top( bounds = - Rect( - splitZoneLeft, - fullScreenDragZoneHeight, - splitZoneRight, - fullScreenDragZoneHeight + vSplitFromExpandedViewDragZoneHeightTablet + RectZone( + Rect( + splitZoneLeft, + fullScreenDragZoneHeight, + splitZoneRight, + fullScreenDragZoneHeight + + vSplitFromExpandedViewDragZoneHeightTablet + ), ), ), DragZone.Split.Bottom( bounds = - Rect( - splitZoneLeft, - bottomSplitZoneBottom - vSplitFromExpandedViewDragZoneHeightTablet, - splitZoneRight, - bottomSplitZoneBottom + RectZone( + Rect( + splitZoneLeft, + bottomSplitZoneBottom - vSplitFromExpandedViewDragZoneHeightTablet, + splitZoneRight, + bottomSplitZoneBottom + ), ), ) ) @@ -460,22 +572,26 @@ class DragZoneFactory( listOf( DragZone.Split.Top( bounds = - Rect( - splitZoneLeft, - fullScreenDragZoneHeight, - splitZoneLeft + fullScreenDragZoneWidth, - fullScreenDragZoneHeight + - vSplitFromExpandedViewDragZoneHeightFoldTall + RectZone( + Rect( + splitZoneLeft, + fullScreenDragZoneHeight, + splitZoneLeft + fullScreenDragZoneWidth, + fullScreenDragZoneHeight + + vSplitFromExpandedViewDragZoneHeightFoldTall + ), ), ), DragZone.Split.Bottom( bounds = - Rect( - splitZoneLeft, - windowBounds.bottom / 2, - splitZoneLeft + fullScreenDragZoneWidth, - windowBounds.bottom / 2 + - vSplitFromExpandedViewDragZoneHeightFoldTall + RectZone( + Rect( + splitZoneLeft, + windowBounds.bottom / 2, + splitZoneLeft + fullScreenDragZoneWidth, + windowBounds.bottom / 2 + + vSplitFromExpandedViewDragZoneHeightFoldTall + ), ), ) ) @@ -483,21 +599,25 @@ class DragZoneFactory( listOf( DragZone.Split.Top( bounds = - Rect( - 0, - 0, - windowBounds.right, - vSplitFromExpandedViewDragZoneHeightFoldShort + RectZone( + Rect( + 0, + 0, + windowBounds.right, + vSplitFromExpandedViewDragZoneHeightFoldShort + ), ), ), DragZone.Split.Bottom( bounds = - Rect( - splitZoneLeft, - vSplitFromExpandedViewDragZoneHeightFoldShort, - splitZoneLeft + fullScreenDragZoneWidth, - vSplitFromExpandedViewDragZoneHeightFoldShort + - vSplitFromExpandedViewDragZoneHeightFoldTall + RectZone( + Rect( + splitZoneLeft, + vSplitFromExpandedViewDragZoneHeightFoldShort, + splitZoneLeft + fullScreenDragZoneWidth, + vSplitFromExpandedViewDragZoneHeightFoldShort + + vSplitFromExpandedViewDragZoneHeightFoldTall + ), ), ) ) @@ -505,22 +625,26 @@ class DragZoneFactory( listOf( DragZone.Split.Top( bounds = - Rect( - splitZoneLeft, - fullScreenDragZoneHeight, - splitZoneLeft + fullScreenDragZoneWidth, - fullScreenDragZoneHeight + - vSplitFromExpandedViewDragZoneHeightFoldTall + RectZone( + Rect( + splitZoneLeft, + fullScreenDragZoneHeight, + splitZoneLeft + fullScreenDragZoneWidth, + fullScreenDragZoneHeight + + vSplitFromExpandedViewDragZoneHeightFoldTall + ), ), ), DragZone.Split.Bottom( bounds = - Rect( - 0, - windowBounds.bottom - - vSplitFromExpandedViewDragZoneHeightFoldShort, - windowBounds.right, - windowBounds.bottom + RectZone( + Rect( + 0, + windowBounds.bottom - + vSplitFromExpandedViewDragZoneHeightFoldShort, + windowBounds.right, + windowBounds.bottom + ), ), ) ) @@ -534,23 +658,20 @@ class DragZoneFactory( private fun createHorizontalSplitDragZonesForExpandedView(): List { // horizontal split drag zones for expanded view appear on the edges of the screen from the // top down until the dismiss drag zone height + val bottomY = windowBounds.bottom - dismissDragZoneBottomMargin - dismissDragZoneRadius * 2 return listOf( DragZone.Split.Left( - bounds = - Rect( - 0, - 0, - hSplitFromExpandedViewDragZoneWidth, - windowBounds.bottom - dismissDragZoneSize - ), + bounds = RectZone(Rect(0, 0, hSplitFromExpandedViewDragZoneWidth, bottomY)) ), DragZone.Split.Right( bounds = - Rect( - windowBounds.right - hSplitFromExpandedViewDragZoneWidth, - 0, - windowBounds.right, - windowBounds.bottom - dismissDragZoneSize + RectZone( + Rect( + windowBounds.right - hSplitFromExpandedViewDragZoneWidth, + 0, + windowBounds.right, + bottomY + ), ), ) ) @@ -573,4 +694,13 @@ class DragZoneFactory( fun interface DesktopWindowModeChecker { fun isSupported(): Boolean } + + /** Bubble bar properties for generating a drop target. */ + interface BubbleBarPropertiesProvider { + fun getHeight(): Int = 0 + + fun getWidth(): Int = 0 + + fun getBottomPadding(): Int = 0 + } } diff --git a/wmshell/shared/src/com/android/wm/shell/shared/bubbles/DraggedObject.kt b/wmshell/shared/src/com/android/wm/shell/shared/bubbles/DraggedObject.kt index 028622798f..e92f725476 100644 --- a/wmshell/shared/src/com/android/wm/shell/shared/bubbles/DraggedObject.kt +++ b/wmshell/shared/src/com/android/wm/shell/shared/bubbles/DraggedObject.kt @@ -18,10 +18,17 @@ 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 + data class Bubble(val initialLocation: BubbleBarLocation) : DraggedObject + + data class BubbleBar(val initialLocation: BubbleBarLocation) : DraggedObject + + data class ExpandedView(val initialLocation: BubbleBarLocation) : DraggedObject + + // TODO(b/411505605) Remove onDropAction and move showDropTarget up + data class LauncherIcon( + val bubbleBarHasBubbles: Boolean, + val showDropTarget: Boolean = true, + val onDropAction: Runnable + ) : DraggedObject } diff --git a/wmshell/shared/src/com/android/wm/shell/shared/bubbles/DropTargetManager.kt b/wmshell/shared/src/com/android/wm/shell/shared/bubbles/DropTargetManager.kt index 8ce5e7b0e1..1a7858f487 100644 --- a/wmshell/shared/src/com/android/wm/shell/shared/bubbles/DropTargetManager.kt +++ b/wmshell/shared/src/com/android/wm/shell/shared/bubbles/DropTargetManager.kt @@ -17,14 +17,20 @@ package com.android.wm.shell.shared.bubbles import android.content.Context -import android.graphics.Rect import android.graphics.RectF +import android.util.TypedValue +import android.view.View import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout +import androidx.annotation.VisibleForTesting import androidx.core.animation.Animator import androidx.core.animation.AnimatorListenerAdapter import androidx.core.animation.ValueAnimator -import com.android.wm.shell.R +import com.android.wm.shell.shared.bubbles.DragZone.DropTargetRect +import com.android.wm.shell.shared.bubbles.DraggedObject.Bubble +import com.android.wm.shell.shared.bubbles.DraggedObject.BubbleBar +import com.android.wm.shell.shared.bubbles.DraggedObject.ExpandedView +import com.android.wm.shell.shared.bubbles.DraggedObject.LauncherIcon /** * Manages animating drop targets in response to dragging bubble icons or bubble expanded views @@ -37,15 +43,19 @@ class DropTargetManager( ) { private var state: DragState? = null - private val dropTargetView = DropTargetView(context) - private var animator: ValueAnimator? = null + + @VisibleForTesting val dropTargetView = DropTargetView(context) + @VisibleForTesting var secondDropTargetView: DropTargetView? = null private var morphRect: RectF = RectF(0f, 0f, 0f, 0f) private val isLayoutRtl = container.isLayoutRtl + private val viewAnimatorsMap = mutableMapOf() + private var onDropTargetsRemovedAction: Runnable? = null private companion object { const val MORPH_ANIM_DURATION = 250L const val DROP_TARGET_ALPHA_IN_DURATION = 150L const val DROP_TARGET_ALPHA_OUT_DURATION = 100L + const val DROP_TARGET_ELEVATION_DP = 2f } /** Must be called when a drag gesture is starting. */ @@ -53,22 +63,38 @@ class DropTargetManager( val state = DragState(dragZones, draggedObject) dragZoneChangedListener.onInitialDragZoneSet(state.initialDragZone) this.state = state - animator?.cancel() - setupDropTarget() + viewAnimatorsMap.values.forEach { it.cancel() } + setupDropTarget(dropTargetView) + if (dragZones.any { it.secondDropTarget != null }) { + secondDropTargetView = secondDropTargetView ?: DropTargetView(context) + setupDropTarget(secondDropTargetView) + } else { + secondDropTargetView?.let { container.removeView(it) } + secondDropTargetView = null + } } - private fun setupDropTarget() { - if (dropTargetView.parent != null) container.removeView(dropTargetView) - container.addView(dropTargetView, 0) - dropTargetView.alpha = 0f - dropTargetView.elevation = context.resources.getDimension(R.dimen.drop_target_elevation) + private fun setupDropTarget(view: View?) { + if (view == null) return + if (view.parent != null) container.removeView(view) + container.addView(view, 0) + view.alpha = 0f + + view.elevation = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + DROP_TARGET_ELEVATION_DP, context.resources.displayMetrics + ) // Match parent and the target is drawn within the view - dropTargetView.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) + view.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) } - /** Called when the user drags to a new location. */ - fun onDragUpdated(x: Int, y: Int) { - val state = state ?: return + /** + * Called when the user drags to a new location. + * + * @return DragZone that matches provided x and y coordinates. + */ + fun onDragUpdated(x: Int, y: Int): DragZone? { + val state = state ?: return null val oldDragZone = state.currentDragZone val newDragZone = state.getMatchingDragZone(x = x, y = y) state.currentDragZone = newDragZone @@ -76,95 +102,156 @@ class DropTargetManager( dragZoneChangedListener.onDragZoneChanged( draggedObject = state.draggedObject, from = oldDragZone, - to = newDragZone + to = newDragZone, ) updateDropTarget() } + return newDragZone } /** Called when the drag ended. */ fun onDragEnded() { val dropState = state ?: return - startFadeAnimation(from = dropTargetView.alpha, to = 0f) { + startFadeAnimation(dropTargetView, to = 0f) { container.removeView(dropTargetView) + onDropTargetRemoved() + } + startFadeAnimation(secondDropTargetView, to = 0f) { + container.removeView(secondDropTargetView) + secondDropTargetView = null + onDropTargetRemoved() } dragZoneChangedListener.onDragEnded(dropState.currentDragZone) state = null } + /** + * Runs the provided action once all drop target views are removed from the container. + * If there are no drop target views currently present or being animated, the action will be + * executed immediately. + */ + fun onDropTargetRemoved(action: Runnable) { + onDropTargetsRemovedAction = action + onDropTargetRemoved() + } + private fun updateDropTarget() { - val currentDragZone = state?.currentDragZone ?: return - val dropTargetBounds = currentDragZone.dropTarget + val dropState = state ?: return + val currentDragZone = dropState.currentDragZone + if (currentDragZone == null) { + startFadeAnimation(dropTargetView, to = 0f) + startFadeAnimation(secondDropTargetView, to = 0f) + return + } + val dropTargetRect = currentDragZone.dropTarget when { - dropTargetBounds == null -> startFadeAnimation(from = dropTargetView.alpha, to = 0f) + dropTargetRect == null -> startFadeAnimation(dropTargetView, to = 0f) + dropTargetView.alpha == 0f -> { - dropTargetView.update(RectF(dropTargetBounds)) - startFadeAnimation(from = 0f, to = 1f) + dropTargetView.update(RectF(dropTargetRect.rect), dropTargetRect.cornerRadius) + startFadeAnimation(dropTargetView, to = 1f) + } + + else -> startMorphAnimation(dropTargetRect) + } + + val secondDropTargetRect = currentDragZone.secondDropTarget + when { + secondDropTargetRect == null -> startFadeAnimation(secondDropTargetView, to = 0f) + else -> { + val secondDropTargetView = secondDropTargetView ?: return + secondDropTargetView.update( + RectF(secondDropTargetRect.rect), + secondDropTargetRect.cornerRadius, + ) + startFadeAnimation(secondDropTargetView, to = 1f) } - else -> startMorphAnimation(dropTargetBounds) } } - private fun startFadeAnimation(from: Float, to: Float, onEnd: (() -> Unit)? = null) { - animator?.cancel() + private fun startFadeAnimation(view: View?, to: Float, onEnd: (() -> Unit)? = null) { + if (view == null) return + val from = view.alpha + viewAnimatorsMap[view]?.cancel() val duration = if (from < to) DROP_TARGET_ALPHA_IN_DURATION else DROP_TARGET_ALPHA_OUT_DURATION val animator = ValueAnimator.ofFloat(from, to).setDuration(duration) - animator.addUpdateListener { _ -> dropTargetView.alpha = animator.animatedValue as Float } + animator.addUpdateListener { _ -> view.alpha = animator.animatedValue as Float } if (onEnd != null) { animator.doOnEnd(onEnd) } - this.animator = animator + viewAnimatorsMap[view] = animator animator.start() } - private fun startMorphAnimation(endBounds: Rect) { - animator?.cancel() + private fun startMorphAnimation(dropTargetRect: DropTargetRect) { + viewAnimatorsMap[dropTargetView]?.cancel() val startAlpha = dropTargetView.alpha val startRect = dropTargetView.getRect() + val endRect = dropTargetRect.rect val animator = ValueAnimator.ofFloat(0f, 1f).setDuration(MORPH_ANIM_DURATION) animator.addUpdateListener { _ -> val fraction = animator.animatedValue as Float dropTargetView.alpha = startAlpha + (1 - startAlpha) * fraction - morphRect.left = (startRect.left + (endBounds.left - startRect.left) * fraction) - morphRect.top = (startRect.top + (endBounds.top - startRect.top) * fraction) - morphRect.right = (startRect.right + (endBounds.right - startRect.right) * fraction) - morphRect.bottom = (startRect.bottom + (endBounds.bottom - startRect.bottom) * fraction) - dropTargetView.update(morphRect) + morphRect.left = (startRect.left + (endRect.left - startRect.left) * fraction) + morphRect.top = (startRect.top + (endRect.top - startRect.top) * fraction) + morphRect.right = (startRect.right + (endRect.right - startRect.right) * fraction) + morphRect.bottom = (startRect.bottom + (endRect.bottom - startRect.bottom) * fraction) + dropTargetView.update(morphRect, dropTargetRect.cornerRadius) } - this.animator = animator + viewAnimatorsMap[dropTargetView] = animator animator.start() } + private fun onDropTargetRemoved() { + val action = onDropTargetsRemovedAction ?: return + if ((0 until container.childCount).any { container.getChildAt(it) is DropTargetView }) { + return + } + onDropTargetsRemovedAction = null + action.run() + } + /** Stores the current drag state. */ private inner class DragState( private val dragZones: List, - val draggedObject: DraggedObject + val draggedObject: DraggedObject, ) { val initialDragZone = - if (draggedObject.initialLocation.isOnLeft(isLayoutRtl)) { - dragZones.filterIsInstance().first() - } else { - dragZones.filterIsInstance().first() + draggedObject.initialLocation?.let { + if (it.isOnLeft(isLayoutRtl)) { + dragZones.filterIsInstance().first() + } else { + dragZones.filterIsInstance().first() + } } - var currentDragZone: DragZone = initialDragZone + var currentDragZone: DragZone? = initialDragZone - fun getMatchingDragZone(x: Int, y: Int): DragZone { - return dragZones.firstOrNull { it.contains(x, y) } ?: currentDragZone + fun getMatchingDragZone(x: Int, y: Int): DragZone? { + return dragZones.firstOrNull { it.contains(x, y) } } } + private val DraggedObject.initialLocation: BubbleBarLocation? + get() = + when (this) { + is Bubble -> initialLocation + is BubbleBar -> initialLocation + is ExpandedView -> initialLocation + is LauncherIcon -> null + } + /** An interface to be notified when drag zones change. */ interface DragZoneChangedListener { /** An initial drag zone was set. Called when a drag starts. */ - fun onInitialDragZoneSet(dragZone: DragZone) + fun onInitialDragZoneSet(dragZone: DragZone?) /** Called when the object was dragged to a different drag zone. */ - fun onDragZoneChanged(draggedObject: DraggedObject, from: DragZone, to: DragZone) + fun onDragZoneChanged(draggedObject: DraggedObject, from: DragZone?, to: DragZone?) /** Called when the drag has ended with the zone it ended in. */ - fun onDragEnded(zone: DragZone) + fun onDragEnded(zone: DragZone?) } private fun Animator.doOnEnd(onEnd: () -> Unit) { diff --git a/wmshell/shared/src/com/android/wm/shell/shared/bubbles/DropTargetView.kt b/wmshell/shared/src/com/android/wm/shell/shared/bubbles/DropTargetView.kt index c57e3fc6c6..1a61255802 100644 --- a/wmshell/shared/src/com/android/wm/shell/shared/bubbles/DropTargetView.kt +++ b/wmshell/shared/src/com/android/wm/shell/shared/bubbles/DropTargetView.kt @@ -23,34 +23,34 @@ import android.graphics.RectF import android.util.TypedValue import android.view.View -/** - * Shows a drop target within this view. - */ +/** Shows a drop target within this view. */ class DropTargetView(context: Context) : View(context) { - private val rectPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = context.getColor(com.android.internal.R.color.materialColorPrimaryFixed) - style = Paint.Style.FILL - alpha = (0.35f * 255).toInt() - } + private val rectPaint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = context.getColor(com.android.internal.R.color.materialColorPrimaryFixed) + style = Paint.Style.FILL + alpha = (0.35f * 255).toInt() + } - private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = context.getColor(com.android.internal.R.color.materialColorPrimaryFixed) - style = Paint.Style.STROKE - strokeWidth = 2.dpToPx() - } + private val strokePaint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = context.getColor(com.android.internal.R.color.materialColorPrimaryFixed) + style = Paint.Style.STROKE + strokeWidth = 2.dpToPx() + } - private val cornerRadius = 28.dpToPx() + private var cornerRadius = 0f private val rect = RectF(0f, 0f, 0f, 0f) // TODO b/396539130: Use shared xml resources once we can access them in launcher private fun Int.dpToPx() = TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, - this.toFloat(), - context.resources.displayMetrics - ) + TypedValue.COMPLEX_UNIT_DIP, + this.toFloat(), + context.resources.displayMetrics + ) override fun onDraw(canvas: Canvas) { canvas.save() @@ -59,7 +59,8 @@ class DropTargetView(context: Context) : View(context) { canvas.restore() } - fun update(positionRect: RectF) { + fun update(positionRect: RectF, cornerRadius: Float) { + this.cornerRadius = cornerRadius rect.set(positionRect) invalidate() } diff --git a/wmshell/shared/src/com/android/wm/shell/shared/bubbles/OWNERS b/wmshell/shared/src/com/android/wm/shell/shared/bubbles/OWNERS index 08c7031497..290151a2e5 100644 --- a/wmshell/shared/src/com/android/wm/shell/shared/bubbles/OWNERS +++ b/wmshell/shared/src/com/android/wm/shell/shared/bubbles/OWNERS @@ -2,5 +2,4 @@ madym@google.com atsjenk@google.com liranb@google.com -sukeshram@google.com mpodolian@google.com diff --git a/wmshell/shared/src/com/android/wm/shell/shared/desktopmode/DesktopConfig.kt b/wmshell/shared/src/com/android/wm/shell/shared/desktopmode/DesktopConfig.kt new file mode 100644 index 0000000000..5a2d344714 --- /dev/null +++ b/wmshell/shared/src/com/android/wm/shell/shared/desktopmode/DesktopConfig.kt @@ -0,0 +1,95 @@ +/* + * 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.desktopmode + +import android.app.TaskInfo +import android.content.Context +import com.android.internal.annotations.VisibleForTesting +import java.io.PrintWriter + +/** + * Configuration of the desktop mode. Defines the parameters used by various features. + * + * This class shouldn't be used outside of WM Shell. + */ +@Suppress("INAPPLICABLE_JVM_NAME") +interface DesktopConfig { + /** + * Whether a window should be maximized when it's dragged to the top edge of the screen. + */ + @Deprecated("Deprecated with desktop-first based drag-to-maximize") + val shouldMaximizeWhenDragToTopEdge: Boolean + + /** Whether the override desktop density is enabled and valid. */ + @get:JvmName("useDesktopOverrideDensity") + val useDesktopOverrideDensity: Boolean + + /** The number of [WindowDecorViewHost] instances to warm up on system start. */ + val windowDecorPreWarmSize: Int + + /** + * The maximum size of the window decoration surface control view host pool, or zero if there + * should be no pooling. + */ + val windowDecorScvhPoolSize: Int + + /** + * Whether veiled resizing is enabled. + */ + val isVeiledResizeEnabled: Boolean + + /** Returns `true` if the app-to-web feature is using the build-time generic links list. */ + @get:JvmName("useAppToWebBuildTimeGenericLinks") + val useAppToWebBuildTimeGenericLinks: Boolean + + /** Returns whether to use rounded corners for windows. */ + @get:JvmName("useRoundedCorners") + val useRoundedCorners: Boolean + + /** + * Returns whether to use window shadows, [isFocusedWindow] indicating whether or not the window + * currently holds the focus. + */ + fun useWindowShadow(isFocusedWindow: Boolean): Boolean + + /** + * Whether we set opaque background for all freeform tasks. + * + * This might be done to prevent freeform tasks below from being visible if freeform task window + * above is translucent. Otherwise if fluid resize is enabled, add a background to freeform + * tasks. + */ + fun shouldSetBackground(taskInfo: TaskInfo): Boolean + + /** Returns the maximum limit on the number of tasks to show in on a desk at any one time. */ + val maxTaskLimit: Int + + /** Returns the maximum limit on the number of desks a user can create. */ + val maxDeskLimit: Int + + /** Override density for tasks when they're inside the desktop. */ + val desktopDensityOverride: Int + + /** Dumps DesktopModeStatus flags and configs. */ + fun dump(pw: PrintWriter, prefix: String) + + companion object { + /** Create a [DesktopConfig] from a context. Should only be used for testing. */ + @VisibleForTesting + fun fromContext(context: Context): DesktopConfig = DesktopConfigImpl(context) + } +} diff --git a/wmshell/shared/src/com/android/wm/shell/shared/desktopmode/DesktopConfigImpl.kt b/wmshell/shared/src/com/android/wm/shell/shared/desktopmode/DesktopConfigImpl.kt new file mode 100644 index 0000000000..0653361dc9 --- /dev/null +++ b/wmshell/shared/src/com/android/wm/shell/shared/desktopmode/DesktopConfigImpl.kt @@ -0,0 +1,195 @@ +/* + * 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.desktopmode + +import android.app.TaskInfo +import android.content.Context +import android.os.SystemProperties +import android.util.IndentingPrintWriter +import android.window.DesktopExperienceFlags +import android.window.DesktopModeFlags +import com.android.internal.R +import com.android.internal.annotations.VisibleForTesting +import com.android.wm.shell.shared.desktopmode.DesktopConfigImpl.Companion.WINDOW_DECOR_PRE_WARM_SIZE +import java.io.PrintWriter + +@VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE) +class DesktopConfigImpl( + private val context: Context, + private val desktopState: DesktopState, +) : DesktopConfig { + + constructor(context: Context) : this(context, DesktopState.fromContext(context)) + + override val shouldMaximizeWhenDragToTopEdge: Boolean + get() { + if (!DesktopExperienceFlags.ENABLE_DRAG_TO_MAXIMIZE.isTrue) return false + return SystemProperties.getBoolean( + ENABLE_DRAG_TO_MAXIMIZE_SYS_PROP, + context.getResources().getBoolean(R.bool.config_dragToMaximizeInDesktopMode), + ) + } + + override val useDesktopOverrideDensity: Boolean = + DESKTOP_DENSITY_OVERRIDE_ENABLED && isValidDesktopDensityOverrideSet() + + /** Return `true` if the override desktop density is set and within a valid range. */ + private fun isValidDesktopDensityOverrideSet() = + DESKTOP_DENSITY_OVERRIDE >= DESKTOP_DENSITY_MIN && + DESKTOP_DENSITY_OVERRIDE <= DESKTOP_DENSITY_MAX + + override val windowDecorPreWarmSize: Int = + SystemProperties.getInt(WINDOW_DECOR_PRE_WARM_SIZE_SYS_PROP, WINDOW_DECOR_PRE_WARM_SIZE) + + override val windowDecorScvhPoolSize: Int + get() { + if (!DesktopModeFlags.ENABLE_DESKTOP_WINDOWING_SCVH_CACHE.isTrue) return 0 + + if (maxTaskLimit > 0) return maxTaskLimit + + // TODO: b/368032552 - task limit equal to 0 means unlimited. Figure out what the pool + // size should be in that case. + return 0 + } + + override val isVeiledResizeEnabled: Boolean = + SystemProperties.getBoolean("persist.wm.debug.desktop_veiled_resizing", true) + + override val useAppToWebBuildTimeGenericLinks: Boolean = + SystemProperties.getBoolean( + "persist.wm.debug.use_app_to_web_build_time_generic_links", + true, + ) + + override val useRoundedCorners: Boolean = + SystemProperties.getBoolean("persist.wm.debug.desktop_use_rounded_corners", true) + + override fun useWindowShadow(isFocusedWindow: Boolean): Boolean = + USE_WINDOW_SHADOWS || (isFocusedWindow && USE_WINDOW_SHADOWS_FOCUSED_WINDOW) + + override fun shouldSetBackground(taskInfo: TaskInfo): Boolean = + taskInfo.isFreeform && + (!isVeiledResizeEnabled || + DesktopModeFlags.ENABLE_OPAQUE_BACKGROUND_FOR_TRANSPARENT_WINDOWS.isTrue) + + override val maxTaskLimit: Int = + SystemProperties.getInt( + MAX_TASK_LIMIT_SYS_PROP, + context.getResources().getInteger(R.integer.config_maxDesktopWindowingActiveTasks), + ) + + override val maxDeskLimit: Int = + SystemProperties.getInt( + MAX_DESK_LIMIT_SYS_PROP, + context.getResources().getInteger(R.integer.config_maxDesktopWindowingDesks), + ) + + override val desktopDensityOverride: Int = + SystemProperties.getInt("persist.wm.debug.desktop_mode_density", 284) + + override fun dump(pw: PrintWriter, prefix: String) { + val ipw = IndentingPrintWriter(pw, /* singleIndent= */ " ", /* prefix= */ prefix) + ipw.increaseIndent() + pw.println(TAG) + pw.println("maxTaskLimit=$maxTaskLimit") + + pw.print( + "maxTaskLimit config override=${ + context.getResources() + .getInteger(R.integer.config_maxDesktopWindowingActiveTasks) + }" + ) + + val maxTaskLimitHandle = SystemProperties.find(MAX_TASK_LIMIT_SYS_PROP) + pw.println("maxTaskLimit sysprop=${maxTaskLimitHandle?.getInt( /* def= */-1) ?: "null"}") + + pw.println("showAppHandle config override=${desktopState.overridesShowAppHandle}") + } + + companion object { + private const val TAG: String = "DesktopConfig" + + /** The minimum override density allowed for tasks inside the desktop. */ + private const val DESKTOP_DENSITY_MIN: Int = 100 + + /** The maximum override density allowed for tasks inside the desktop. */ + private const val DESKTOP_DENSITY_MAX: Int = 1000 + + /** The number of [WindowDecorViewHost] instances to warm up on system start. */ + private const val WINDOW_DECOR_PRE_WARM_SIZE: Int = 2 + + /** + * Sysprop declaring the number of [WindowDecorViewHost] instances to warm up on system + * start. + * + * If it is not defined, then [WINDOW_DECOR_PRE_WARM_SIZE] is used. + */ + private const val WINDOW_DECOR_PRE_WARM_SIZE_SYS_PROP = + "persist.wm.debug.desktop_window_decor_pre_warm_size" + + /** + * Sysprop declaring the maximum number of Tasks to show in Desktop Mode at any one time. + * + * If it is not defined, then `R.integer.config_maxDesktopWindowingActiveTasks` is used. + * + * The limit does NOT affect Picture-in-Picture, Bubbles, or System Modals (like a screen + * recording window, or Bluetooth pairing window). + */ + private const val MAX_TASK_LIMIT_SYS_PROP = "persist.wm.debug.desktop_max_task_limit" + + /** + * Sysprop declaring the maximum number of Desks a user can create. + * + * If it is not defined, then `R.integer.config_maxDesktopWindowingDesks` is used. + * + * The limit does NOT affect desks created by connecting additional displays. + */ + private const val MAX_DESK_LIMIT_SYS_PROP = "persist.wm.debug.desktop_max_desk_limit" + + /** + * Sysprop declaring whether to enable drag-to-maximize for desktop windows. + * + * If it is not defined, then `R.integer.config_dragToMaximizeInDesktopMode` + * is used. + */ + private const val ENABLE_DRAG_TO_MAXIMIZE_SYS_PROP = + "persist.wm.debug.enable_drag_to_maximize" + + /** Flag to indicate whether to apply shadows to windows in desktop mode. */ + private val USE_WINDOW_SHADOWS = + SystemProperties.getBoolean("persist.wm.debug.desktop_use_window_shadows", true) + + /** + * Flag to indicate whether to apply shadows to the focused window in desktop mode. + * + * Note: this flag is only relevant if USE_WINDOW_SHADOWS is false. + */ + private val USE_WINDOW_SHADOWS_FOCUSED_WINDOW = + SystemProperties.getBoolean( + "persist.wm.debug.desktop_use_window_shadows_focused_window", + false, + ) + + /** Whether the desktop density override is enabled. */ + private val DESKTOP_DENSITY_OVERRIDE_ENABLED = + SystemProperties.getBoolean("persist.wm.debug.desktop_mode_density_enabled", false) + + /** Override density for tasks when they're inside the desktop. */ + private val DESKTOP_DENSITY_OVERRIDE: Int = + SystemProperties.getInt("persist.wm.debug.desktop_mode_density", 284) + } +} diff --git a/wmshell/shared/src/com/android/wm/shell/shared/desktopmode/DesktopFirstListener.kt b/wmshell/shared/src/com/android/wm/shell/shared/desktopmode/DesktopFirstListener.kt new file mode 100644 index 0000000000..557d459e0b --- /dev/null +++ b/wmshell/shared/src/com/android/wm/shell/shared/desktopmode/DesktopFirstListener.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.desktopmode + +/** + * A listener that will receive callbacks about desktop-first state. + */ +fun interface DesktopFirstListener { + /** + * Called when the desktop-first state changes. + */ + fun onStateChanged(displayId: Int, isDesktopFirstEnabled: Boolean) +} diff --git a/wmshell/shared/src/com/android/wm/shell/shared/desktopmode/DesktopModeCompatPolicy.kt b/wmshell/shared/src/com/android/wm/shell/shared/desktopmode/DesktopModeCompatPolicy.kt index 529203f7de..4fbc18bcf0 100644 --- a/wmshell/shared/src/com/android/wm/shell/shared/desktopmode/DesktopModeCompatPolicy.kt +++ b/wmshell/shared/src/com/android/wm/shell/shared/desktopmode/DesktopModeCompatPolicy.kt @@ -20,6 +20,7 @@ import android.Manifest.permission.SYSTEM_ALERT_WINDOW import android.app.TaskInfo import android.content.Context import android.content.pm.PackageManager +import android.window.DesktopExperienceFlags import android.window.DesktopModeFlags import com.android.internal.R import com.android.internal.policy.DesktopModeCompatUtils @@ -43,28 +44,56 @@ class DesktopModeCompatPolicy(private val context: Context) { /** * If the top activity should be exempt from desktop windowing and forced back to fullscreen. - * Currently includes all system ui, default home and transparent stack activities. However if - * the top activity is not being displayed, regardless of its configuration, we will not exempt - * it as to remain in the desktop windowing environment. + * Currently includes all system ui, default home and transparent stack activities with the + * relevant permission or signature. However if the top activity is not being displayed, + * regardless of its configuration, we will not exempt it as to remain in the desktop windowing + * environment. */ - fun isTopActivityExemptFromDesktopWindowing(task: TaskInfo) = - isTopActivityExemptFromDesktopWindowing(task.baseActivity?.packageName, - task.numActivities, task.isTopActivityNoDisplay, task.isActivityStackTransparent, - task.userId) + fun isTopActivityExemptFromDesktopWindowing(task: TaskInfo): Boolean { + val packageName = task.baseActivity?.packageName ?: return false - fun isTopActivityExemptFromDesktopWindowing( + return when { + !DesktopModeFlags.ENABLE_DESKTOP_WINDOWING_MODALS_POLICY.isTrue -> false + // If activity is not being displayed, window mode change has no visual affect so leave + // unchanged. + task.isTopActivityNoDisplay -> false + // If activity belongs to system ui package, safe to force out of desktop. + isSystemUiTask(packageName) -> true + // If activity belongs to default home package, safe to force out of desktop. + isPartOfDefaultHomePackageOrNoHomeAvailable(packageName) -> true + // If all activities in task stack are transparent AND package has the relevant + // fullscreen transparent permission OR is signed with platform key, safe to force out + // of desktop. + isTransparentTask(task.isActivityStackTransparent, task.numActivities) && + (hasFullscreenTransparentPermission(packageName, task.userId) || + hasPlatformSignature(task)) -> true + + else -> false + } + } + + fun shouldDisableDesktopEntryPoints(task: TaskInfo) = shouldDisableDesktopEntryPoints( + task.baseActivity?.packageName, task.numActivities, task.isTopActivityNoDisplay, + task.isActivityStackTransparent) + + fun shouldDisableDesktopEntryPoints( packageName: String?, numActivities: Int, isTopActivityNoDisplay: Boolean, isActivityStackTransparent: Boolean, - userId: Int - ) = - DesktopModeFlags.ENABLE_DESKTOP_WINDOWING_MODALS_POLICY.isTrue && - ((isSystemUiTask(packageName) || - isPartOfDefaultHomePackageOrNoHomeAvailable(packageName) || - (isTransparentTask(isActivityStackTransparent, numActivities) && - hasFullscreenTransparentPermission(packageName, userId))) && - !isTopActivityNoDisplay) + ) = when { + // Activity will not be displayed, no need to show desktop entry point. + isTopActivityNoDisplay -> true + // If activity belongs to system ui package, hide desktop entry point. + isSystemUiTask(packageName) -> true + // If activity belongs to default home package, safe to force out of desktop. + isPartOfDefaultHomePackageOrNoHomeAvailable(packageName) -> true + // If all activities in task stack are transparent AND package has the relevant fullscreen + // transparent permission, safe to force out of desktop. + isTransparentTask(isActivityStackTransparent, numActivities) -> true + else -> false + } + /** @see DesktopModeCompatUtils.shouldExcludeCaptionFromAppBounds */ fun shouldExcludeCaptionFromAppBounds(taskInfo: TaskInfo): Boolean = @@ -86,11 +115,8 @@ class DesktopModeCompatPolicy(private val context: Context) { private fun isSystemUiTask(packageName: String?) = packageName == systemUiPackage // Checks if the app for the given package has the SYSTEM_ALERT_WINDOW permission. - private fun hasFullscreenTransparentPermission(packageName: String?, userId: Int): Boolean { + private fun hasFullscreenTransparentPermission(packageName: String, userId: Int): Boolean { if (DesktopModeFlags.ENABLE_MODALS_FULLSCREEN_WITH_PERMISSIONS.isTrue) { - if (packageName == null) { - return false - } return packageInfoCache.getOrPut("$userId@$packageName") { try { val packageInfo = pkgManager.getPackageInfoAsUser( @@ -104,8 +130,19 @@ class DesktopModeCompatPolicy(private val context: Context) { } } } - // If the flag is disabled we make this condition neutral. - return true + // If the ENABLE_MODALS_FULLSCREEN_WITH_PERMISSIONS flag is disabled, make neutral condition + // dependant on the ENABLE_MODALS_FULLSCREEN_WITH_PLATFORM_SIGNATURE flag. + return !DesktopExperienceFlags.ENABLE_MODALS_FULLSCREEN_WITH_PLATFORM_SIGNATURE.isTrue + } + + // Checks if the app is signed with the platform signature. + private fun hasPlatformSignature(task: TaskInfo): Boolean { + if (DesktopExperienceFlags.ENABLE_MODALS_FULLSCREEN_WITH_PLATFORM_SIGNATURE.isTrue) { + return task.topActivityInfo?.applicationInfo?.isSignedWithPlatformKey ?: false + } + // If the ENABLE_MODALS_FULLSCREEN_WITH_PLATFORM_SIGNATURE flag is disabled, make neutral + // condition dependant on the ENABLE_MODALS_FULLSCREEN_WITH_PERMISSIONS flag. + return !DesktopModeFlags.ENABLE_MODALS_FULLSCREEN_WITH_PERMISSIONS.isTrue } /** diff --git a/wmshell/shared/src/com/android/wm/shell/shared/desktopmode/DesktopModeStatus.java b/wmshell/shared/src/com/android/wm/shell/shared/desktopmode/DesktopModeStatus.java index ff66442443..084a5b4cac 100644 --- a/wmshell/shared/src/com/android/wm/shell/shared/desktopmode/DesktopModeStatus.java +++ b/wmshell/shared/src/com/android/wm/shell/shared/desktopmode/DesktopModeStatus.java @@ -17,14 +17,11 @@ package com.android.wm.shell.shared.desktopmode; import static android.hardware.display.DisplayManager.DISPLAY_CATEGORY_ALL_INCLUDING_DISABLED; -import static android.window.DesktopExperienceFlags.ENABLE_PROJECTED_DISPLAY_DESKTOP_MODE; -import static com.android.server.display.feature.flags.Flags.enableDisplayContentModeManagement; import static com.android.wm.shell.shared.bubbles.BubbleAnythingFlagHelper.enableBubbleToFullscreen; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import android.app.TaskInfo; +import android.annotation.NonNull; +import android.annotation.Nullable; import android.content.Context; import android.hardware.display.DisplayManager; import android.os.SystemProperties; @@ -37,46 +34,19 @@ import com.android.internal.R; import com.android.internal.annotations.VisibleForTesting; import com.android.window.flags.Flags; -import java.io.PrintWriter; import java.util.Arrays; /** * Constants for desktop mode feature + * + * @deprecated Use {@link DesktopState} or {@link DesktopConfig} instead. */ -// TODO(b/237575897): Move this file to the `com.android.wm.shell.shared.desktopmode` package +@Deprecated(forRemoval = true) public class DesktopModeStatus { - private static final String TAG = "DesktopModeStatus"; - @Nullable private static Boolean sIsLargeScreenDevice = null; - /** - * Flag to indicate whether task resizing is veiled. - */ - private static final boolean IS_VEILED_RESIZE_ENABLED = SystemProperties.getBoolean( - "persist.wm.debug.desktop_veiled_resizing", true); - - /** - * Flag to indicate is moving task to another display is enabled. - */ - public static final boolean IS_DISPLAY_CHANGE_ENABLED = SystemProperties.getBoolean( - "persist.wm.debug.desktop_change_display", false); - - /** - * Flag to indicate whether to apply shadows to windows in desktop mode. - */ - private static final boolean USE_WINDOW_SHADOWS = SystemProperties.getBoolean( - "persist.wm.debug.desktop_use_window_shadows", true); - - /** - * Flag to indicate whether to apply shadows to the focused window in desktop mode. - * - * Note: this flag is only relevant if USE_WINDOW_SHADOWS is false. - */ - private static final boolean USE_WINDOW_SHADOWS_FOCUSED_WINDOW = SystemProperties.getBoolean( - "persist.wm.debug.desktop_use_window_shadows_focused_window", false); - /** * Flag to indicate whether to use rounded corners for windows in desktop mode. */ @@ -89,27 +59,6 @@ public class DesktopModeStatus { private static final boolean ENFORCE_DEVICE_RESTRICTIONS = SystemProperties.getBoolean( "persist.wm.debug.desktop_mode_enforce_device_restrictions", true); - private static final boolean USE_APP_TO_WEB_BUILD_TIME_GENERIC_LINKS = - SystemProperties.getBoolean( - "persist.wm.debug.use_app_to_web_build_time_generic_links", true); - - /** Whether the desktop density override is enabled. */ - public static final boolean DESKTOP_DENSITY_OVERRIDE_ENABLED = - SystemProperties.getBoolean("persist.wm.debug.desktop_mode_density_enabled", false); - - /** Override density for tasks when they're inside the desktop. */ - public static final int DESKTOP_DENSITY_OVERRIDE = - SystemProperties.getInt("persist.wm.debug.desktop_mode_density", 284); - - /** The minimum override density allowed for tasks inside the desktop. */ - private static final int DESKTOP_DENSITY_MIN = 100; - - /** The maximum override density allowed for tasks inside the desktop. */ - private static final int DESKTOP_DENSITY_MAX = 1000; - - /** The number of [WindowDecorViewHost] instances to warm up on system start. */ - private static final int WINDOW_DECOR_PRE_WARM_SIZE = 2; - /** * Sysprop declaring whether to enters desktop mode by default when the windowing mode of the * display's root TaskDisplayArea is set to WINDOWING_MODE_FREEFORM. @@ -120,51 +69,6 @@ public class DesktopModeStatus { public static final String ENTER_DESKTOP_BY_DEFAULT_ON_FREEFORM_DISPLAY_SYS_PROP = "persist.wm.debug.enter_desktop_by_default_on_freeform_display"; - /** - * Sysprop declaring whether to enable drag-to-maximize for desktop windows. - * - *

If it is not defined, then {@code R.integer.config_dragToMaximizeInDesktopMode} - * is used. - */ - public static final String ENABLE_DRAG_TO_MAXIMIZE_SYS_PROP = - "persist.wm.debug.enable_drag_to_maximize"; - - /** - * Sysprop declaring the maximum number of Tasks to show in Desktop Mode at any one time. - * - *

If it is not defined, then {@code R.integer.config_maxDesktopWindowingActiveTasks} is - * used. - * - *

The limit does NOT affect Picture-in-Picture, Bubbles, or System Modals (like a screen - * recording window, or Bluetooth pairing window). - */ - private static final String MAX_TASK_LIMIT_SYS_PROP = "persist.wm.debug.desktop_max_task_limit"; - - /** - * Sysprop declaring the number of [WindowDecorViewHost] instances to warm up on system start. - * - *

If it is not defined, then [WINDOW_DECOR_PRE_WARM_SIZE] is used. - */ - private static final String WINDOW_DECOR_PRE_WARM_SIZE_SYS_PROP = - "persist.wm.debug.desktop_window_decor_pre_warm_size"; - - /** - * Return {@code true} if veiled resizing is active. If false, fluid resizing is used. - */ - public static boolean isVeiledResizeEnabled() { - return IS_VEILED_RESIZE_ENABLED; - } - - /** - * Return whether to use window shadows. - * - * @param isFocusedWindow whether the window to apply shadows to is focused - */ - public static boolean useWindowShadow(boolean isFocusedWindow) { - return USE_WINDOW_SHADOWS - || (USE_WINDOW_SHADOWS_FOCUSED_WINDOW && isFocusedWindow); - } - /** * Return whether to use rounded corners for windows. */ @@ -180,35 +84,6 @@ public class DesktopModeStatus { return ENFORCE_DEVICE_RESTRICTIONS; } - /** - * Return the maximum limit on the number of Tasks to show in Desktop Mode at any one time. - */ - public static int getMaxTaskLimit(@NonNull Context context) { - return SystemProperties.getInt(MAX_TASK_LIMIT_SYS_PROP, - context.getResources().getInteger(R.integer.config_maxDesktopWindowingActiveTasks)); - } - - /** - * Return the maximum size of the window decoration surface control view host pool, or zero if - * there should be no pooling. - */ - public static int getWindowDecorScvhPoolSize(@NonNull Context context) { - if (!DesktopModeFlags.ENABLE_DESKTOP_WINDOWING_SCVH_CACHE.isTrue()) return 0; - final int maxTaskLimit = getMaxTaskLimit(context); - if (maxTaskLimit > 0) { - return maxTaskLimit; - } - // TODO: b/368032552 - task limit equal to 0 means unlimited. Figure out what the pool - // size should be in that case. - return 0; - } - - /** The number of [WindowDecorViewHost] instances to warm up on system start. */ - public static int getWindowDecorPreWarmSize() { - return SystemProperties.getInt(WINDOW_DECOR_PRE_WARM_SIZE_SYS_PROP, - WINDOW_DECOR_PRE_WARM_SIZE); - } - /** * Return {@code true} if the current device supports desktop mode. */ @@ -244,7 +119,7 @@ public class DesktopModeStatus { */ public static boolean canShowDesktopExperienceDevOption(@NonNull Context context) { return Flags.showDesktopExperienceDevOption() - && isDeviceEligibleForDesktopMode(context); + && isDeviceEligibleForDesktopExperienceDevOption(context); } /** Returns if desktop mode dev option should be enabled if there is no user override. */ @@ -257,28 +132,20 @@ public class DesktopModeStatus { * Return {@code true} if desktop mode is enabled and can be entered on the current device. */ public static boolean canEnterDesktopMode(@NonNull Context context) { - try { - return (isDeviceEligibleForDesktopMode(context) - && DesktopModeFlags.ENABLE_DESKTOP_WINDOWING_MODE.isTrue()) - || isDesktopModeEnabledByDevOption(context); - } catch (Throwable e) { - // Lawnchair-TODO-Postmerge: All of the LC-Ignored MAY be only accessible to newer APIs. - // LC-Ignored - return false; - } + boolean isEligibleForDesktopMode = isDeviceEligibleForDesktopMode(context) && ( + DesktopExperienceFlags.ENABLE_PROJECTED_DISPLAY_DESKTOP_MODE.isTrue() + || canInternalDisplayHostDesktops(context)); + boolean desktopModeEnabled = + isEligibleForDesktopMode && DesktopModeFlags.ENABLE_DESKTOP_WINDOWING_MODE.isTrue(); + return desktopModeEnabled || isDesktopModeEnabledByDevOption(context); } /** * Check if Desktop mode should be enabled because the dev option is shown and enabled. */ private static boolean isDesktopModeEnabledByDevOption(@NonNull Context context) { - try { - return DesktopModeFlags.isDesktopModeForcedEnabled() + return DesktopModeFlags.isDesktopModeForcedEnabled() && canShowDesktopModeDevOption(context); - } catch (Throwable e) { - // LC-Ignored - return false; - } } /** @@ -297,14 +164,21 @@ public class DesktopModeStatus { } // TODO (b/395014779): Change this to use WM API - if ((display.getType() == Display.TYPE_EXTERNAL - || display.getType() == Display.TYPE_OVERLAY) - && enableDisplayContentModeManagement()) { - final WindowManager wm = context.getSystemService(WindowManager.class); - return wm != null && wm.shouldShowSystemDecors(display.getDisplayId()); + if (!DesktopExperienceFlags.ENABLE_DISPLAY_CONTENT_MODE_MANAGEMENT.isTrue()) { + return false; } + final WindowManager wm = context.getSystemService(WindowManager.class); + return wm != null && wm.isEligibleForDesktopMode(display.getDisplayId()); + } - return false; + /** + * Returns true if the multi-desks frontend should be enabled on the display. + */ + public static boolean isMultipleDesktopFrontendEnabledOnDisplay(@NonNull Context context, + Display display) { + return DesktopExperienceFlags.ENABLE_MULTIPLE_DESKTOPS_FRONTEND.isTrue() + && DesktopExperienceFlags.ENABLE_MULTIPLE_DESKTOPS_BACKEND.isTrue() + && isDesktopModeSupportedOnDisplay(context, display); } /** @@ -312,14 +186,9 @@ public class DesktopModeStatus { * frontend implementations). */ public static boolean enableMultipleDesktops(@NonNull Context context) { - try { - return DesktopExperienceFlags.ENABLE_MULTIPLE_DESKTOPS_BACKEND.isTrue() - && Flags.enableMultipleDesktopsFrontend() + return DesktopExperienceFlags.ENABLE_MULTIPLE_DESKTOPS_BACKEND.isTrue() + && DesktopExperienceFlags.ENABLE_MULTIPLE_DESKTOPS_FRONTEND.isTrue() && canEnterDesktopMode(context); - } catch (Throwable e) { - // LC-Ignored - return false; - } } /** @@ -327,27 +196,8 @@ public class DesktopModeStatus { * necessarily enabling desktop mode */ public static boolean overridesShowAppHandle(@NonNull Context context) { - try { - return (Flags.showAppHandleLargeScreens() || enableBubbleToFullscreen()) - && deviceHasLargeScreen(context); - } catch (Throwable t) { - return false; - } - } - - /** - * @return If {@code true} we set opaque background for all freeform tasks to prevent freeform - * tasks below from being visible if freeform task window above is translucent. - * Otherwise if fluid resize is enabled, add a background to freeform tasks. - */ - public static boolean shouldSetBackground(@NonNull TaskInfo taskInfo) { - try { - return taskInfo.isFreeform() && (!DesktopModeStatus.isVeiledResizeEnabled() - || DesktopModeFlags.ENABLE_OPAQUE_BACKGROUND_FOR_TRANSPARENT_WINDOWS.isTrue()); - } catch (Throwable e) { - // LC-Ignored - return false; - } + return (Flags.showAppHandleLargeScreens() || enableBubbleToFullscreen()) + && deviceHasLargeScreen(context); } /** @@ -358,64 +208,23 @@ public class DesktopModeStatus { return canEnterDesktopMode(context) || overridesShowAppHandle(context); } - /** - * Return {@code true} if the override desktop density is enabled and valid. - */ - public static boolean useDesktopOverrideDensity() { - return isDesktopDensityOverrideEnabled() && isValidDesktopDensityOverrideSet(); - } - - /** - * Returns {@code true} if the app-to-web feature is using the build-time generic links list. - */ - public static boolean useAppToWebBuildTimeGenericLinks() { - return USE_APP_TO_WEB_BUILD_TIME_GENERIC_LINKS; - } - - /** - * Return {@code true} if the override desktop density is enabled. - */ - private static boolean isDesktopDensityOverrideEnabled() { - return DESKTOP_DENSITY_OVERRIDE_ENABLED; - } - - /** - * Return {@code true} if the override desktop density is set and within a valid range. - */ - private static boolean isValidDesktopDensityOverrideSet() { - return DESKTOP_DENSITY_OVERRIDE >= DESKTOP_DENSITY_MIN - && DESKTOP_DENSITY_OVERRIDE <= DESKTOP_DENSITY_MAX; - } - - /** + /** * Return {@code true} if desktop mode is unrestricted and is supported on the device. */ public static boolean isDeviceEligibleForDesktopMode(@NonNull Context context) { if (!enforceDeviceRestrictions()) { return true; } - try { - // If projected display is enabled, #canInternalDisplayHostDesktops is no longer a - // requirement. - final boolean desktopModeSupported = ENABLE_PROJECTED_DISPLAY_DESKTOP_MODE.isTrue() - ? isDesktopModeSupported(context) : (isDesktopModeSupported(context) - && canInternalDisplayHostDesktops(context)); - final boolean desktopModeSupportedByDevOptions = + final boolean desktopModeSupportedByDevOptions = Flags.enableDesktopModeThroughDevOption() && isDesktopModeDevOptionSupported(context); - return desktopModeSupported || desktopModeSupportedByDevOptions; - } catch (Throwable e) { - // LC-Ignored - return false; - } + return isDesktopModeSupported(context) || desktopModeSupportedByDevOptions; } /** - * Return {@code true} if the developer option for desktop mode is unrestricted and is supported - * in the device. + * Return {@code true} if the developer option for desktop mode is supported on this device. * - * Note that, if {@link #isDeviceEligibleForDesktopMode(Context)} is true, then - * {@link #isDeviceEligibleForDesktopModeDevOption(Context)} is also true. + *

This method doesn't check if the developer option flag is enabled or not. */ private static boolean isDeviceEligibleForDesktopModeDevOption(@NonNull Context context) { if (!enforceDeviceRestrictions()) { @@ -426,6 +235,19 @@ public class DesktopModeStatus { return desktopModeSupported || isDesktopModeDevOptionSupported(context); } + /** + * Return {@code true} if the developer option for desktop experience is supported on this + * device. + * + *

This method doesn't check if the developer option flag is enabled or not. + */ + private static boolean isDeviceEligibleForDesktopExperienceDevOption(@NonNull Context context) { + if (!enforceDeviceRestrictions()) { + return true; + } + return isDesktopModeSupported(context) || isDesktopModeDevOptionSupported(context); + } + /** * @return {@code true} if this device has an internal large screen */ @@ -446,52 +268,14 @@ public class DesktopModeStatus { * of the display's root [TaskDisplayArea] is set to WINDOWING_MODE_FREEFORM. */ public static boolean enterDesktopByDefaultOnFreeformDisplay(@NonNull Context context) { - try { - if (!DesktopExperienceFlags.ENTER_DESKTOP_BY_DEFAULT_ON_FREEFORM_DISPLAYS.isTrue()) { - return false; - } - } catch (Throwable e) { - // LC-Ignored + if (DesktopExperienceFlags.ENABLE_DESKTOP_FIRST_BASED_DEFAULT_TO_DESKTOP_BUGFIX.isTrue()) { + return true; + } + if (!DesktopExperienceFlags.ENTER_DESKTOP_BY_DEFAULT_ON_FREEFORM_DISPLAYS.isTrue()) { return false; } - return SystemProperties.getBoolean(ENTER_DESKTOP_BY_DEFAULT_ON_FREEFORM_DISPLAY_SYS_PROP, context.getResources().getBoolean( R.bool.config_enterDesktopByDefaultOnFreeformDisplay)); } - - /** - * Return {@code true} if a window should be maximized when it's dragged to the top edge of the - * screen. - */ - public static boolean shouldMaximizeWhenDragToTopEdge(@NonNull Context context) { - try { - if (!DesktopExperienceFlags.ENABLE_DRAG_TO_MAXIMIZE.isTrue()) { - return false; - } - } catch (Throwable e) { - // LC-Ignored - return false; - } - return SystemProperties.getBoolean(ENABLE_DRAG_TO_MAXIMIZE_SYS_PROP, - context.getResources().getBoolean(R.bool.config_dragToMaximizeInDesktopMode)); - } - - /** Dumps DesktopModeStatus flags and configs. */ - public static void dump(PrintWriter pw, String prefix, Context context) { - String innerPrefix = prefix + " "; - pw.print(prefix); pw.println(TAG); - pw.print(innerPrefix); pw.print("maxTaskLimit="); pw.println(getMaxTaskLimit(context)); - - pw.print(innerPrefix); pw.print("maxTaskLimit config override="); - pw.println(context.getResources().getInteger( - R.integer.config_maxDesktopWindowingActiveTasks)); - - SystemProperties.Handle maxTaskLimitHandle = SystemProperties.find(MAX_TASK_LIMIT_SYS_PROP); - pw.print(innerPrefix); pw.print("maxTaskLimit sysprop="); - pw.println(maxTaskLimitHandle == null ? "null" : maxTaskLimitHandle.getInt(/* def= */ -1)); - - pw.print(innerPrefix); pw.print("showAppHandle config override="); - pw.println(overridesShowAppHandle(context)); - } } diff --git a/wmshell/shared/src/com/android/wm/shell/shared/desktopmode/DesktopModeTransitionSource.kt b/wmshell/shared/src/com/android/wm/shell/shared/desktopmode/DesktopModeTransitionSource.kt index 23498de724..89dec9750d 100644 --- a/wmshell/shared/src/com/android/wm/shell/shared/desktopmode/DesktopModeTransitionSource.kt +++ b/wmshell/shared/src/com/android/wm/shell/shared/desktopmode/DesktopModeTransitionSource.kt @@ -21,6 +21,8 @@ import android.os.Parcelable /** Transition source types for Desktop Mode. */ enum class DesktopModeTransitionSource : Parcelable { + /** Transitions that originated from an adb command. */ + ADB_COMMAND, /** Transitions that originated as a consequence of task dragging. */ TASK_DRAG, /** Transitions that originated from an app from Overview. */ diff --git a/wmshell/shared/src/com/android/wm/shell/shared/desktopmode/DesktopState.kt b/wmshell/shared/src/com/android/wm/shell/shared/desktopmode/DesktopState.kt new file mode 100644 index 0000000000..dcfa18a26d --- /dev/null +++ b/wmshell/shared/src/com/android/wm/shell/shared/desktopmode/DesktopState.kt @@ -0,0 +1,120 @@ +/* + * 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.desktopmode + +import android.content.Context +import android.view.Display + +/** + * Interface defining which features are available on the device. + * + * A feature may be specific to a task or a display and may change over time (e.g. + * [isDesktopModeSupportedOnDisplay] depends on user settings). + * + * This class is meant to be used in WM Shell, System UI and Launcher so they all understand what + * features are enabled on the current device. + */ +@Suppress("INAPPLICABLE_JVM_NAME") +interface DesktopState { + /** Returns if desktop mode is enabled and can be entered on the current device. */ + @get:JvmName("canEnterDesktopMode") + val canEnterDesktopMode: Boolean + + /** + * Whether desktop mode is enabled or app handles should be shown for other reasons. + */ + @get:JvmName("canEnterDesktopModeOrShowAppHandle") + val canEnterDesktopModeOrShowAppHandle: Boolean + get() = canEnterDesktopMode || overridesShowAppHandle + + /** Whether desktop experience dev option should be shown on current device. */ + @get:JvmName("canShowDesktopExperienceDevOption") + val canShowDesktopExperienceDevOption: Boolean + + /** Whether desktop mode dev option should be shown on current device. */ + @get:JvmName("canShowDesktopModeDevOption") + val canShowDesktopModeDevOption: Boolean + + /** + * Whether a display should enter desktop mode by default when the windowing mode of the + * display's root [TaskDisplayArea] is set to `WINDOWING_MODE_FREEFORM`. + */ + @Deprecated("Use isDisplayDesktopFirst() instead.", ReplaceWith("isDisplayDesktopFirst()")) + @get:JvmName("enterDesktopByDefaultOnFreeformDisplay") + val enterDesktopByDefaultOnFreeformDisplay: Boolean + + /** Whether desktop mode is unrestricted and is supported on the device. */ + val isDeviceEligibleForDesktopMode: Boolean + + /** + * Whether the multiple desktops feature is enabled for this device (both backend and + * frontend implementations). + */ + @get:JvmName("enableMultipleDesktops") + val enableMultipleDesktops: Boolean + + /** + * Returns true if the multi-desks frontend should be enabled on the display. + */ + fun isMultipleDesktopFrontendEnabledOnDisplay(display: Display): Boolean + + /** + * Returns true if the multi-desks frontend should be enabled on the display with [displayId]. + */ + fun isMultipleDesktopFrontendEnabledOnDisplay(displayId: Int): Boolean + + /** + * Checks if the display with id [displayId] should have desktop mode enabled or not. Internal + * and external displays have separate logic. + */ + fun isDesktopModeSupportedOnDisplay(displayId: Int): Boolean + + /** + * Checks if [display] should have desktop mode enabled or not. Internal and external displays + * have separate logic. + */ + fun isDesktopModeSupportedOnDisplay(display: Display): Boolean + + /** + * Check if the current device is in projected display mode. + * + * Note, if the device is not connected to any display, this will return false. + */ + fun isProjectedMode(): Boolean + + /** + * Whether the app handle should be shown on this device. + */ + @get:JvmName("overridesShowAppHandle") + val overridesShowAppHandle: Boolean + + /** + * Whether freeform windowing is enabled on the system. + */ + val isFreeformEnabled: Boolean + + /** + * Whether the home screen should be shown behind freeform tasks in the desktop. + */ + val shouldShowHomeBehindDesktop: Boolean + + companion object { + /** Creates a new [DesktopState] from a context. */ + @JvmStatic + fun fromContext(context: Context): DesktopState = DesktopStateImpl(context) + } +} diff --git a/wmshell/shared/src/com/android/wm/shell/shared/desktopmode/DesktopStateImpl.kt b/wmshell/shared/src/com/android/wm/shell/shared/desktopmode/DesktopStateImpl.kt new file mode 100644 index 0000000000..9761dbf96d --- /dev/null +++ b/wmshell/shared/src/com/android/wm/shell/shared/desktopmode/DesktopStateImpl.kt @@ -0,0 +1,174 @@ +/* + * 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.desktopmode + +import android.content.Context +import android.content.pm.PackageManager.FEATURE_FREEFORM_WINDOW_MANAGEMENT +import android.hardware.display.DisplayManager +import android.os.SystemProperties +import android.provider.Settings +import android.view.Display +import android.view.WindowManager +import android.window.DesktopExperienceFlags +import android.window.DesktopModeFlags +import com.android.internal.R +import com.android.internal.annotations.VisibleForTesting +import com.android.window.flags.Flags +import com.android.wm.shell.shared.bubbles.BubbleAnythingFlagHelper + +@VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE) +class DesktopStateImpl(context: Context) : DesktopState { + + private val windowManager = context.getSystemService(WindowManager::class.java) + private val displayManager = context.getSystemService(DisplayManager::class.java) + + private val enforceDeviceRestrictions = + SystemProperties.getBoolean(ENFORCE_DEVICE_RESTRICTIONS_SYS_PROP, true) + + private val isDesktopModeDevOptionSupported = + context.getResources().getBoolean(R.bool.config_isDesktopModeDevOptionSupported) + + private val isDesktopModeSupported = + context.getResources().getBoolean(R.bool.config_isDesktopModeSupported) + + private val canInternalDisplayHostDesktops = + context.getResources().getBoolean(R.bool.config_canInternalDisplayHostDesktops) + + private val isDeviceEligibleForDesktopModeDevOption = + if (!enforceDeviceRestrictions) { + true + } else { + val desktopModeSupportedOnInternalDisplay = + isDesktopModeSupported && canInternalDisplayHostDesktops + desktopModeSupportedOnInternalDisplay || isDesktopModeDevOptionSupported + } + + override val canShowDesktopModeDevOption: Boolean = + isDeviceEligibleForDesktopModeDevOption && Flags.showDesktopWindowingDevOption() + + private val isDesktopModeEnabledByDevOption = + DesktopModeFlags.isDesktopModeForcedEnabled() && canShowDesktopModeDevOption + + override val canEnterDesktopMode: Boolean = run { + val isEligibleForDesktopMode = + isDeviceEligibleForDesktopMode && + (DesktopExperienceFlags.ENABLE_PROJECTED_DISPLAY_DESKTOP_MODE.isTrue || + canInternalDisplayHostDesktops) + val desktopModeEnabled = + isEligibleForDesktopMode && DesktopModeFlags.ENABLE_DESKTOP_WINDOWING_MODE.isTrue + desktopModeEnabled || isDesktopModeEnabledByDevOption + } + + private val isDeviceEligibleForDesktopExperienceDevOption = + !enforceDeviceRestrictions || isDesktopModeSupported || isDesktopModeDevOptionSupported + + override val canShowDesktopExperienceDevOption: Boolean = + Flags.showDesktopExperienceDevOption() && isDeviceEligibleForDesktopExperienceDevOption + + override val enterDesktopByDefaultOnFreeformDisplay: Boolean = + DesktopExperienceFlags.ENABLE_DESKTOP_FIRST_BASED_DEFAULT_TO_DESKTOP_BUGFIX.isTrue || + DesktopExperienceFlags.ENTER_DESKTOP_BY_DEFAULT_ON_FREEFORM_DISPLAYS.isTrue && + SystemProperties.getBoolean( + ENTER_DESKTOP_BY_DEFAULT_ON_FREEFORM_DISPLAY_SYS_PROP, + context + .getResources() + .getBoolean(R.bool.config_enterDesktopByDefaultOnFreeformDisplay), + ) + + override val isDeviceEligibleForDesktopMode: Boolean + get() { + if (!enforceDeviceRestrictions) return true + val desktopModeSupportedByDevOptions = + Flags.enableDesktopModeThroughDevOption() && isDesktopModeDevOptionSupported + return isDesktopModeSupported || desktopModeSupportedByDevOptions + } + + override val enableMultipleDesktops: Boolean = + DesktopExperienceFlags.ENABLE_MULTIPLE_DESKTOPS_BACKEND.isTrue + && DesktopExperienceFlags.ENABLE_MULTIPLE_DESKTOPS_FRONTEND.isTrue + && canEnterDesktopMode + + override fun isMultipleDesktopFrontendEnabledOnDisplay(display: Display): Boolean = + DesktopExperienceFlags.ENABLE_MULTIPLE_DESKTOPS_FRONTEND.isTrue + && DesktopExperienceFlags.ENABLE_MULTIPLE_DESKTOPS_BACKEND.isTrue + && isDesktopModeSupportedOnDisplay(display) + + override fun isMultipleDesktopFrontendEnabledOnDisplay(displayId: Int): Boolean = + displayManager.getDisplay(displayId)?.let { isMultipleDesktopFrontendEnabledOnDisplay(it) } + ?: false + + override fun isDesktopModeSupportedOnDisplay(displayId: Int): Boolean = + displayManager.getDisplay(displayId)?.let { isDesktopModeSupportedOnDisplay(it) } ?: false + + override fun isDesktopModeSupportedOnDisplay(display: Display): Boolean { + if (!canEnterDesktopMode) return false + if (!enforceDeviceRestrictions) return true + if (display.type == Display.TYPE_INTERNAL) return canInternalDisplayHostDesktops + if (!DesktopExperienceFlags.ENABLE_DISPLAY_CONTENT_MODE_MANAGEMENT.isTrue) return false + return windowManager?.isEligibleForDesktopMode(display.displayId) ?: false + } + + override fun isProjectedMode(): Boolean { + if (!DesktopExperienceFlags.ENABLE_PROJECTED_DISPLAY_DESKTOP_MODE.isTrue) { + return false + } + + if (isDesktopModeSupportedOnDisplay(Display.DEFAULT_DISPLAY)) { + return false + } + + return displayManager.displays + ?.any { display -> isDesktopModeSupportedOnDisplay(display) + } ?: false + } + + private val deviceHasLargeScreen = + displayManager.getDisplays(DisplayManager.DISPLAY_CATEGORY_ALL_INCLUDING_DISABLED) + ?.filter { display -> display.type == Display.TYPE_INTERNAL } + ?.any { display -> + display.minSizeDimensionDp >= WindowManager.LARGE_SCREEN_SMALLEST_SCREEN_WIDTH_DP + } ?: false + + override val overridesShowAppHandle: Boolean = + (Flags.showAppHandleLargeScreens() || + BubbleAnythingFlagHelper.enableBubbleToFullscreen()) && deviceHasLargeScreen + + private val hasFreeformFeature = + context.getPackageManager().hasSystemFeature(FEATURE_FREEFORM_WINDOW_MANAGEMENT) + private val hasFreeformDevOption = + Settings.Global.getInt( + context.getContentResolver(), + Settings.Global.DEVELOPMENT_ENABLE_FREEFORM_WINDOWS_SUPPORT, + 0 + ) != 0 + override val isFreeformEnabled: Boolean = hasFreeformFeature || hasFreeformDevOption + + override val shouldShowHomeBehindDesktop: Boolean = + Flags.showHomeBehindDesktop() && context.resources.getBoolean( + R.bool.config_showHomeBehindDesktop + ) + + companion object { + @VisibleForTesting + const val ENFORCE_DEVICE_RESTRICTIONS_SYS_PROP = + "persist.wm.debug.desktop_mode_enforce_device_restrictions" + + @VisibleForTesting + const val ENTER_DESKTOP_BY_DEFAULT_ON_FREEFORM_DISPLAY_SYS_PROP = + "persist.wm.debug.enter_desktop_by_default_on_freeform_display" + } +} diff --git a/wmshell/shared/src/com/android/wm/shell/shared/desktopmode/FakeDesktopConfig.kt b/wmshell/shared/src/com/android/wm/shell/shared/desktopmode/FakeDesktopConfig.kt new file mode 100644 index 0000000000..63bc898b1c --- /dev/null +++ b/wmshell/shared/src/com/android/wm/shell/shared/desktopmode/FakeDesktopConfig.kt @@ -0,0 +1,55 @@ +/* + * 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.desktopmode + +import android.app.TaskInfo +import java.io.PrintWriter + +class FakeDesktopConfig : DesktopConfig { + + override var shouldMaximizeWhenDragToTopEdge: Boolean = false + override var useDesktopOverrideDensity: Boolean = false + + override var windowDecorPreWarmSize: Int = 0 + override var windowDecorScvhPoolSize: Int = 0 + override var isVeiledResizeEnabled: Boolean = true + override var useAppToWebBuildTimeGenericLinks: Boolean = false + override var useRoundedCorners: Boolean = true + + var useWindowShadowWhenFocused = true + var useWindowShadowWhenUnfocused = true + + override fun useWindowShadow(isFocusedWindow: Boolean): Boolean = + if (isFocusedWindow) useWindowShadowWhenFocused else useWindowShadowWhenUnfocused + + var defaultSetBackground = false + val overrideSetBackgroundPerTaskId = mutableMapOf() + + override fun shouldSetBackground(taskInfo: TaskInfo): Boolean = + overrideSetBackgroundPerTaskId[taskInfo.taskId] ?: defaultSetBackground + + override var maxTaskLimit: Int = 0 + + override var maxDeskLimit: Int = 0 + + override var desktopDensityOverride: Int = 284 + + override fun dump( + pw: PrintWriter, + prefix: String, + ) { } +} \ No newline at end of file diff --git a/wmshell/shared/src/com/android/wm/shell/shared/desktopmode/FakeDesktopState.kt b/wmshell/shared/src/com/android/wm/shell/shared/desktopmode/FakeDesktopState.kt new file mode 100644 index 0000000000..170613b1ac --- /dev/null +++ b/wmshell/shared/src/com/android/wm/shell/shared/desktopmode/FakeDesktopState.kt @@ -0,0 +1,74 @@ +/* + * 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.desktopmode + +import android.view.Display + +class FakeDesktopState : DesktopState { + + /** + * Change whether or not the system can enter desktop mode. + * + * This will be the default value for all displays. To change the value for a particular + * display, update [overrideDesktopModeSupportPerDisplay]. + * + * When set to `true`, [isFreeformEnabled] is also set to `true`, as this is what we want most + * of the time (if freeform is not enabled, desktop mode cannot really exist). + */ + override var canEnterDesktopMode: Boolean = false + set(value) { + field = value + if (value) isFreeformEnabled = true + } + + override var canShowDesktopExperienceDevOption: Boolean = false + override var canShowDesktopModeDevOption: Boolean = false + override var enterDesktopByDefaultOnFreeformDisplay: Boolean = false + override var isDeviceEligibleForDesktopMode: Boolean = false + override var enableMultipleDesktops: Boolean = false + + /** Override [canEnterDesktopMode] for a specific display. */ + val overrideDesktopModeSupportPerDisplay = mutableMapOf() + + override fun isMultipleDesktopFrontendEnabledOnDisplay(display: Display): Boolean = + enableMultipleDesktops && isDesktopModeSupportedOnDisplay(display) + + override fun isMultipleDesktopFrontendEnabledOnDisplay(displayId: Int): Boolean = + enableMultipleDesktops && isDesktopModeSupportedOnDisplay(displayId) + + /** + * This implementation returns [canEnterDesktopMode] unless overridden in + * [overrideDesktopModeSupportPerDisplay]. + */ + override fun isDesktopModeSupportedOnDisplay(displayId: Int): Boolean { + return overrideDesktopModeSupportPerDisplay[displayId] ?: canEnterDesktopMode + } + + override fun isDesktopModeSupportedOnDisplay(display: Display): Boolean { + return isDesktopModeSupportedOnDisplay(display.displayId) + } + + override fun isProjectedMode(): Boolean { + return false + } + + override var overridesShowAppHandle: Boolean = false + + override var isFreeformEnabled: Boolean = false + + override var shouldShowHomeBehindDesktop: Boolean = false +} \ No newline at end of file diff --git a/wmshell/shared/src/com/android/wm/shell/shared/multiinstance/ManageWindowsViewContainer.kt b/wmshell/shared/src/com/android/wm/shell/shared/multiinstance/ManageWindowsViewContainer.kt index f554aba545..ac54ac7a9d 100644 --- a/wmshell/shared/src/com/android/wm/shell/shared/multiinstance/ManageWindowsViewContainer.kt +++ b/wmshell/shared/src/com/android/wm/shell/shared/multiinstance/ManageWindowsViewContainer.kt @@ -34,7 +34,7 @@ import android.view.View.SCALE_Y import android.view.ViewGroup.MarginLayoutParams import android.widget.LinearLayout import android.window.TaskSnapshot -import com.android.wm.shell.R +import com.android.wm.shell.shared.R /** * View for the All Windows menu option, used by both Desktop Windowing and Taskbar. diff --git a/wmshell/shared/src/com/android/wm/shell/shared/pip/PipFlags.kt b/wmshell/shared/src/com/android/wm/shell/shared/pip/PipFlags.kt new file mode 100644 index 0000000000..4a5e4c950e --- /dev/null +++ b/wmshell/shared/src/com/android/wm/shell/shared/pip/PipFlags.kt @@ -0,0 +1,42 @@ +/* + * 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.pip + +import android.app.AppGlobals +import android.content.pm.PackageManager +import android.window.DesktopExperienceFlags.ENABLE_DESKTOP_WINDOWING_PIP +import com.android.wm.shell.Flags + +class PipFlags { + companion object { + /** + * Returns true if PiP2 implementation should be used. Special note: if PiP on Desktop + * Windowing is enabled, override the PiP2 gantry flag to be ON. + */ + @JvmStatic + val isPip2ExperimentEnabled: Boolean by lazy { + val isTv = AppGlobals.getPackageManager().hasSystemFeature( + PackageManager.FEATURE_LEANBACK, 0) + (Flags.enablePip2() || ENABLE_DESKTOP_WINDOWING_PIP.isTrue) && !isTv + } + + @JvmStatic + val isPipUmoExperienceEnabled: Boolean by lazy { + Flags.enablePipUmoExperience() + } + } +} diff --git a/wmshell/shared/src/com/android/wm/shell/shared/split/SplitBounds.java b/wmshell/shared/src/com/android/wm/shell/shared/split/SplitBounds.java index 5e17d75206..99c0dfeefc 100644 --- a/wmshell/shared/src/com/android/wm/shell/shared/split/SplitBounds.java +++ b/wmshell/shared/src/com/android/wm/shell/shared/split/SplitBounds.java @@ -17,7 +17,7 @@ package com.android.wm.shell.shared.split; import static android.app.ActivityTaskManager.INVALID_TASK_ID; -import androidx.annotation.NonNull; +import android.annotation.NonNull; import android.graphics.Rect; import android.os.Parcel; import android.os.Parcelable; diff --git a/wmshell/shared/src/com/android/wm/shell/shared/split/SplitScreenConstants.java b/wmshell/shared/src/com/android/wm/shell/shared/split/SplitScreenConstants.java index 759e711100..2d6779bab0 100644 --- a/wmshell/shared/src/com/android/wm/shell/shared/split/SplitScreenConstants.java +++ b/wmshell/shared/src/com/android/wm/shell/shared/split/SplitScreenConstants.java @@ -157,6 +157,12 @@ public class SplitScreenConstants { */ public static final int SNAP_TO_3_10_45_45 = 7; + /** + * A transitional state where the user has tapped an offscreen app, and the offscreen app is + * currently animating back onscreen. + */ + public static final int ANIMATING_OFFSCREEN_TAP = 100; + /** * These snap targets are used for split pairs in a stable, non-transient state. They may be * persisted in Launcher when the user saves an app pair. They are a subset of @@ -176,7 +182,7 @@ public class SplitScreenConstants { /** * These are all the valid "states" that split screen can be in. It's the set of - * {@link PersistentSnapPosition} + {@link #NOT_IN_SPLIT}. + * {@link PersistentSnapPosition} + {@link #NOT_IN_SPLIT} + other mid-animation states. */ @IntDef(value = { NOT_IN_SPLIT, // user is not in split screen @@ -189,10 +195,11 @@ public class SplitScreenConstants { SNAP_TO_3_33_33_33, SNAP_TO_3_45_45_10, SNAP_TO_3_10_45_45, + ANIMATING_OFFSCREEN_TAP // user tapped offscreen app to retrieve it }) public @interface SplitScreenState {} - /** Converts a {@link SplitScreenState} to a human-readable string. */ + /** Converts a {@link SplitScreenState} to a human-readable string, for debug use. */ public static String stateToString(@SplitScreenState int state) { return switch (state) { case NOT_IN_SPLIT -> "NOT_IN_SPLIT"; @@ -205,10 +212,38 @@ public class SplitScreenConstants { case SNAP_TO_3_33_33_33 -> "SNAP_TO_3_33_33_33"; case SNAP_TO_3_45_45_10 -> "SNAP_TO_3_45_45_10"; case SNAP_TO_3_10_45_45 -> "SNAP_TO_3_10_45_45"; + case ANIMATING_OFFSCREEN_TAP -> "ANIMATING_OFFSCREEN_TAP"; default -> "UNKNOWN"; }; } + /** Converts a {@link SnapPosition} to a string, for UI use. */ + public static String snapPositionToUIString(@SnapPosition int snapPosition) { + return switch (snapPosition) { + case SNAP_TO_START_AND_DISMISS -> "\u2715"; + case SNAP_TO_END_AND_DISMISS -> "\u2715"; + case SNAP_TO_2_33_66 -> "30:70"; + case SNAP_TO_2_50_50 -> "50:50"; + case SNAP_TO_2_66_33 -> "70:30"; + case SNAP_TO_2_90_10 -> "90:10"; + case SNAP_TO_2_10_90 -> "10:90"; + default -> "Split"; + }; + } + + /** + * Convenience method to convert between the IntDef's to avoid some errors + * @return {@code -1} if splitScreenState does not have a valid/corresponding + * PersistentSnapPosition + */ + @PersistentSnapPosition + public static int splitStateToSnapPosition(@SplitScreenState int splitScreenState) { + return switch (splitScreenState) { + case NOT_IN_SPLIT, SNAP_TO_NONE, ANIMATING_OFFSCREEN_TAP -> -1; + default -> splitScreenState; + }; + } + /** * Checks if the snapPosition in question is a {@link PersistentSnapPosition}. */ diff --git a/wmshell/src/TEST_MAPPING b/wmshell/src/TEST_MAPPING new file mode 100644 index 0000000000..952137451c --- /dev/null +++ b/wmshell/src/TEST_MAPPING @@ -0,0 +1,35 @@ +{ + "imports": [ + { + // Includes all flicker configs that rely on these scenarios + "path": "frameworks/base/libs/WindowManager/Shell/tests/e2e/desktopmode/flicker-service" + }, + { + "path": "frameworks/base/libs/WindowManager/Shell/tests/e2e/desktopmode/scenarios" + }, + { + "path": "frameworks/base/libs/WindowManager/Shell/tests/e2e/mediaprojection" + }, + { + "path": "frameworks/base/libs/WindowManager/Shell/tests/e2e/splitscreen/flicker-legacy" + }, + { + "path": "frameworks/base/libs/WindowManager/Shell/tests/e2e/splitscreen/flicker-service" + }, + { + "path": "frameworks/base/libs/WindowManager/Shell/tests/e2e/splitscreen/platinum" + }, + { + "path": "frameworks/base/libs/WindowManager/Shell/tests/flicker/appcompat" + }, + { + "path": "frameworks/base/libs/WindowManager/Shell/tests/flicker/bubble" + }, + { + "path": "frameworks/base/libs/WindowManager/Shell/tests/flicker/pip" + }, + { + "path": "frameworks/base/libs/WindowManager/Shell/tests/unittest" + } + ] +} \ No newline at end of file diff --git a/wmshell/src/com/android/wm/shell/EventLogTags.logtags b/wmshell/src/com/android/wm/shell/EventLogTags.logtags new file mode 100644 index 0000000000..b716e9e574 --- /dev/null +++ b/wmshell/src/com/android/wm/shell/EventLogTags.logtags @@ -0,0 +1,11 @@ +# See system/logging/logcat/event.logtags for a description of the format of this file. + +option java_package com.android.wm.shell + +# Do not change these names without updating the checkin_events setting in +# google3/googledata/wireless/android/provisioning/gservices.config !! +# + +38500 wm_shell_enter_desktop_mode (EnterReason|1|5),(SessionId|1|5) +38501 wm_shell_exit_desktop_mode (ExitReason|1|5),(SessionId|1|5) +38502 wm_shell_desktop_mode_task_update (TaskEvent|1|5),(InstanceId|1|5),(uid|1|5),(TaskHeight|1),(TaskWidth|1),(TaskX|1),(TaskY|1),(SessionId|1|5),(MinimiseReason|1|5),(UnminimiseReason|1|5),(VisibleTaskCount|1),(FocusReason|1|5) diff --git a/wmshell/src/com/android/wm/shell/ProtoLogController.java b/wmshell/src/com/android/wm/shell/ProtoLogController.java index ef9bf008b2..b855c24647 100644 --- a/wmshell/src/com/android/wm/shell/ProtoLogController.java +++ b/wmshell/src/com/android/wm/shell/ProtoLogController.java @@ -16,10 +16,9 @@ package com.android.wm.shell; -import com.android.internal.protolog.LegacyProtoLogImpl; +import com.android.internal.protolog.ProtoLog; import com.android.internal.protolog.common.ILogger; import com.android.internal.protolog.common.IProtoLog; -import com.android.internal.protolog.common.ProtoLog; import com.android.wm.shell.sysui.ShellCommandHandler; import com.android.wm.shell.sysui.ShellInit; @@ -29,7 +28,7 @@ import java.util.Arrays; /** * Controls the {@link ProtoLog} in WMShell via adb shell commands. * - * Use with {@code adb shell dumpsys activity service SystemUIService WMShell protolog ...}. + * Use with {@code adb shell wm shell protolog ...}. */ public class ProtoLogController implements ShellCommandHandler.ShellCommandActionHandler { private final ShellCommandHandler mShellCommandHandler; @@ -51,28 +50,16 @@ public class ProtoLogController implements ShellCommandHandler.ShellCommandActio final ILogger logger = pw::println; switch (args[0]) { case "status": { - if (android.tracing.Flags.perfettoProtologTracing()) { - pw.println("(Deprecated) legacy command. Use Perfetto commands instead."); - return false; - } - ((LegacyProtoLogImpl) mShellProtoLog).getStatus(); - return true; + pw.println("(Deprecated) legacy command. Use Perfetto commands instead."); + return false; } case "start": { - if (android.tracing.Flags.perfettoProtologTracing()) { - pw.println("(Deprecated) legacy command. Use Perfetto commands instead."); - return false; - } - ((LegacyProtoLogImpl) mShellProtoLog).startProtoLog(pw); - return true; + pw.println("(Deprecated) legacy command. Use Perfetto commands instead."); + return false; } case "stop": { - if (android.tracing.Flags.perfettoProtologTracing()) { - pw.println("(Deprecated) legacy command. Use Perfetto commands instead."); - return false; - } - ((LegacyProtoLogImpl) mShellProtoLog).stopProtoLog(pw, true); - return true; + pw.println("(Deprecated) legacy command. Use Perfetto commands instead."); + return false; } case "enable-text": { String[] groups = Arrays.copyOfRange(args, 1, args.length); @@ -101,17 +88,8 @@ public class ProtoLogController implements ShellCommandHandler.ShellCommandActio return mShellProtoLog.stopLoggingToLogcat(groups, logger) == 0; } case "save-for-bugreport": { - if (android.tracing.Flags.perfettoProtologTracing()) { - pw.println("(Deprecated) legacy command"); - return false; - } - if (!mShellProtoLog.isProtoEnabled()) { - pw.println("Logging to proto is not enabled for WMShell."); - return false; - } - ((LegacyProtoLogImpl) mShellProtoLog).stopProtoLog(pw, true /* writeToFile */); - ((LegacyProtoLogImpl) mShellProtoLog).startProtoLog(pw); - return true; + pw.println("(Deprecated) legacy command"); + return false; } default: { pw.println("Invalid command: " + args[0]); diff --git a/wmshell/src/com/android/wm/shell/RootDisplayAreaOrganizer.java b/wmshell/src/com/android/wm/shell/RootDisplayAreaOrganizer.java index 2e5448a9e8..d87725ccbd 100644 --- a/wmshell/src/com/android/wm/shell/RootDisplayAreaOrganizer.java +++ b/wmshell/src/com/android/wm/shell/RootDisplayAreaOrganizer.java @@ -25,11 +25,13 @@ import android.view.SurfaceControl; import android.window.DisplayAreaAppearedInfo; import android.window.DisplayAreaInfo; import android.window.DisplayAreaOrganizer; +import android.window.WindowContainerToken; import android.window.WindowContainerTransaction; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; -import com.android.internal.protolog.common.ProtoLog; +import com.android.internal.protolog.ProtoLog; import com.android.wm.shell.sysui.ShellInit; import java.io.PrintWriter; @@ -142,6 +144,11 @@ public class RootDisplayAreaOrganizer extends DisplayAreaOrganizer { return wct; } + @Nullable + public WindowContainerToken getDisplayTokenForDisplay(int displayId) { + return mDisplayAreasInfo.get(displayId).token; + } + public void dump(@NonNull PrintWriter pw, String prefix) { final String innerPrefix = prefix + " "; final String childPrefix = innerPrefix + " "; diff --git a/wmshell/src/com/android/wm/shell/RootTaskDisplayAreaOrganizer.java b/wmshell/src/com/android/wm/shell/RootTaskDisplayAreaOrganizer.java index 5143d41959..c53715c800 100644 --- a/wmshell/src/com/android/wm/shell/RootTaskDisplayAreaOrganizer.java +++ b/wmshell/src/com/android/wm/shell/RootTaskDisplayAreaOrganizer.java @@ -38,7 +38,7 @@ import android.window.SystemPerformanceHinter; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.android.internal.protolog.common.ProtoLog; +import com.android.internal.protolog.ProtoLog; import com.android.wm.shell.sysui.ShellInit; import java.io.PrintWriter; @@ -102,6 +102,14 @@ public class RootTaskDisplayAreaOrganizer extends DisplayAreaOrganizer { } } + /** Unregisters the given listener associated to the given display. */ + public void unregisterListener(int displayId, RootTaskDisplayAreaListener listener) { + final ArrayList listeners = mListeners.get(displayId); + if (listeners != null) { + listeners.remove(listener); + } + } + public void unregisterListener(RootTaskDisplayAreaListener listener) { for (int i = mListeners.size() - 1; i >= 0; --i) { final List listeners = mListeners.valueAt(i); @@ -115,6 +123,14 @@ public class RootTaskDisplayAreaOrganizer extends DisplayAreaOrganizer { b.setParent(sc); } + /** + * Sets the layer of {@param sc} to be relative to the TDA on {@param displayId}. + */ + public void relZToDisplayArea(int displayId, SurfaceControl sc, SurfaceControl.Transaction t, + int z) { + t.setRelativeLayer(sc, mLeashes.get(displayId), z); + } + /** * Re-parents the provided surface to the leash of the provided display. * @@ -230,6 +246,11 @@ public class RootTaskDisplayAreaOrganizer extends DisplayAreaOrganizer { return mDisplayAreasInfo.get(displayId); } + @Nullable + public SurfaceControl getDisplayAreaLeash(int displayId) { + return mLeashes.get(displayId); + } + /** * Applies the {@link DisplayAreaInfo} to the {@link DisplayAreaContext} specified by * {@link DisplayAreaInfo#displayId}. diff --git a/wmshell/src/com/android/wm/shell/ShellTaskOrganizer.java b/wmshell/src/com/android/wm/shell/ShellTaskOrganizer.java index 3ded7d2464..9c0a2a7957 100644 --- a/wmshell/src/com/android/wm/shell/ShellTaskOrganizer.java +++ b/wmshell/src/com/android/wm/shell/ShellTaskOrganizer.java @@ -23,40 +23,45 @@ import static android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN; import static android.app.WindowConfiguration.WINDOWING_MODE_MULTI_WINDOW; import static android.app.WindowConfiguration.WINDOWING_MODE_PINNED; import static android.app.WindowConfiguration.WINDOWING_MODE_UNDEFINED; +import static android.view.Display.DEFAULT_DISPLAY; +import static com.android.wm.shell.compatui.impl.CompatUIEventsKt.SIZE_COMPAT_RESTART_BUTTON_APPEARED; +import static com.android.wm.shell.compatui.impl.CompatUIEventsKt.SIZE_COMPAT_RESTART_BUTTON_CLICKED; import static com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_TASK_ORG; -import static com.android.wm.shell.transition.Transitions.ENABLE_SHELL_TRANSITIONS; import android.annotation.IntDef; import android.annotation.NonNull; import android.annotation.Nullable; import android.app.ActivityManager.RunningTaskInfo; -import android.app.CameraCompatTaskInfo.CameraCompatControlState; import android.app.TaskInfo; import android.app.WindowConfiguration; import android.content.LocusId; import android.content.pm.ActivityInfo; import android.graphics.Rect; import android.os.Binder; +import android.os.Debug; import android.os.IBinder; import android.util.ArrayMap; -import android.util.ArraySet; import android.util.Log; import android.util.SparseArray; import android.view.SurfaceControl; import android.window.ITaskOrganizerController; -import android.window.ScreenCapture; import android.window.StartingWindowInfo; import android.window.StartingWindowRemovalInfo; import android.window.TaskAppearedInfo; import android.window.TaskOrganizer; +import android.window.WindowContainerTransaction; +import android.window.WindowContainerTransactionCallback; import com.android.internal.annotations.VisibleForTesting; -import com.android.internal.protolog.common.ProtoLog; +import com.android.internal.protolog.ProtoLog; import com.android.internal.util.FrameworkStatsLog; -import com.android.wm.shell.common.ScreenshotUtils; import com.android.wm.shell.common.ShellExecutor; import com.android.wm.shell.compatui.CompatUIController; +import com.android.wm.shell.compatui.api.CompatUIHandler; +import com.android.wm.shell.compatui.api.CompatUIInfo; +import com.android.wm.shell.compatui.impl.CompatUIEvents.SizeCompatRestartButtonAppeared; +import com.android.wm.shell.compatui.impl.CompatUIEvents.SizeCompatRestartButtonClicked; import com.android.wm.shell.recents.RecentTasksController; import com.android.wm.shell.startingsurface.StartingWindowController; import com.android.wm.shell.sysui.ShellCommandHandler; @@ -69,14 +74,13 @@ import java.util.Arrays; import java.util.List; import java.util.Objects; import java.util.Optional; -import java.util.function.Consumer; +import java.util.concurrent.CopyOnWriteArrayList; /** * Unified task organizer for all components in the shell. * TODO(b/167582004): may consider consolidating this class and TaskOrganizer */ -public class ShellTaskOrganizer extends TaskOrganizer implements - CompatUIController.CompatUICallback { +public class ShellTaskOrganizer extends TaskOrganizer { private static final String TAG = "ShellTaskOrganizer"; // Intentionally using negative numbers here so the positive numbers can be used @@ -99,10 +103,9 @@ public class ShellTaskOrganizer extends TaskOrganizer implements /** * Callbacks for when the tasks change in the system. */ - public interface TaskListener { - default void onTaskAppeared(RunningTaskInfo taskInfo, SurfaceControl leash) {} - default void onTaskInfoChanged(RunningTaskInfo taskInfo) {} - default void onTaskVanished(RunningTaskInfo taskInfo) {} + public interface TaskListener extends TaskVanishedListener, TaskAppearedListener, + TaskInfoChangedListener { + default void onBackPressedOnTaskRoot(RunningTaskInfo taskInfo) {} /** Whether this task listener supports compat UI. */ default boolean supportCompatUI() { @@ -123,6 +126,50 @@ public class ShellTaskOrganizer extends TaskOrganizer implements default void dump(@NonNull PrintWriter pw, String prefix) {}; } + /** + * Limited scope callback to notify when a task is removed from the system. This signal is + * not synchronized with anything (or any transition), and should not be used in cases where + * that is necessary. + */ + public interface TaskVanishedListener { + /** + * Invoked when a Task is removed from Shell. + * + * @param taskInfo The RunningTaskInfo for the Task. + */ + default void onTaskVanished(RunningTaskInfo taskInfo) {} + } + + /** + * Limited scope callback to notify when a task is added from the system. This signal is + * not synchronized with anything (or any transition), and should not be used in cases where + * that is necessary. + */ + public interface TaskAppearedListener { + /** + * Invoked when a Task appears on Shell. Because the leash can be shared between different + * implementations, it's important to not apply changes in the related callback. + * + * @param taskInfo The RunningTaskInfo for the Task. + * @param leash The leash for the Task which should not be changed through this callback. + */ + default void onTaskAppeared(RunningTaskInfo taskInfo, SurfaceControl leash) {} + } + + /** + * Limited scope callback to notify when a task has updated. This signal is + * not synchronized with anything (or any transition), and should not be used in cases where + * that is necessary. + */ + public interface TaskInfoChangedListener { + /** + * Invoked when a Task is updated on Shell. + * + * @param taskInfo The RunningTaskInfo for the Task. + */ + default void onTaskInfoChanged(RunningTaskInfo taskInfo) {} + } + /** * Callbacks for events on a task with a locus id. */ @@ -158,14 +205,31 @@ public class ShellTaskOrganizer extends TaskOrganizer implements /** @see #setPendingLaunchCookieListener */ private final ArrayMap mLaunchCookieToListener = new ArrayMap<>(); + /** @see #setPendingTaskListener(int, TaskListener) */ + private final ArrayMap mPendingTaskToListener = new ArrayMap<>(); + // Keeps track of taskId's with visible locusIds. Used to notify any {@link LocusIdListener}s // that might be set. private final SparseArray mVisibleTasksWithLocusId = new SparseArray<>(); /** @see #addLocusIdListener */ - private final ArraySet mLocusIdListeners = new ArraySet<>(); + private final CopyOnWriteArrayList mLocusIdListeners = + new CopyOnWriteArrayList<>(); - private final ArraySet mFocusListeners = new ArraySet<>(); + private final CopyOnWriteArrayList mFocusListeners = + new CopyOnWriteArrayList<>(); + + // Listeners that should be notified when a task is vanished. + private final CopyOnWriteArrayList mTaskVanishedListeners = + new CopyOnWriteArrayList<>(); + + // Listeners that should be notified when a task has appeared. + private final CopyOnWriteArrayList mTaskAppearedListeners = + new CopyOnWriteArrayList<>(); + + // Listeners that should be notified when a task is updated + private final CopyOnWriteArrayList mTaskInfoChangedListeners = + new CopyOnWriteArrayList<>(); private final Object mLock = new Object(); private StartingWindowController mStartingWindow; @@ -182,12 +246,11 @@ public class ShellTaskOrganizer extends TaskOrganizer implements * In charge of showing compat UI. Can be {@code null} if the device doesn't support size * compat or if this isn't the main {@link ShellTaskOrganizer}. * - *

NOTE: only the main {@link ShellTaskOrganizer} should have a {@link CompatUIController}, - * and register itself as a {@link CompatUIController.CompatUICallback}. Subclasses should be - * initialized with a {@code null} {@link CompatUIController}. + *

NOTE: only the main {@link ShellTaskOrganizer} should have a {@link CompatUIHandler}, + * Subclasses should be initialized with a {@code null} {@link CompatUIHandler}. */ @Nullable - private final CompatUIController mCompatUI; + private final CompatUIHandler mCompatUI; @NonNull private final ShellCommandHandler mShellCommandHandler; @@ -211,7 +274,7 @@ public class ShellTaskOrganizer extends TaskOrganizer implements public ShellTaskOrganizer(ShellInit shellInit, ShellCommandHandler shellCommandHandler, - @Nullable CompatUIController compatUI, + @Nullable CompatUIHandler compatUI, Optional unfoldAnimationController, Optional recentTasks, ShellExecutor mainExecutor) { @@ -223,7 +286,7 @@ public class ShellTaskOrganizer extends TaskOrganizer implements protected ShellTaskOrganizer(ShellInit shellInit, ShellCommandHandler shellCommandHandler, ITaskOrganizerController taskOrganizerController, - @Nullable CompatUIController compatUI, + @Nullable CompatUIHandler compatUI, Optional unfoldAnimationController, Optional recentTasks, ShellExecutor mainExecutor) { @@ -240,7 +303,18 @@ public class ShellTaskOrganizer extends TaskOrganizer implements private void onInit() { mShellCommandHandler.addDumpCallback(this::dump, this); if (mCompatUI != null) { - mCompatUI.setCompatUICallback(this); + mCompatUI.setCallback(compatUIEvent -> { + switch(compatUIEvent.getEventId()) { + case SIZE_COMPAT_RESTART_BUTTON_APPEARED: + onSizeCompatRestartButtonAppeared(compatUIEvent.asType()); + break; + case SIZE_COMPAT_RESTART_BUTTON_CLICKED: + onSizeCompatRestartButtonClicked(compatUIEvent.asType()); + break; + default: + + } + }); } registerOrganizer(); } @@ -268,14 +342,38 @@ public class ShellTaskOrganizer extends TaskOrganizer implements } } + @Override + public void applyTransaction(@NonNull WindowContainerTransaction t) { + if (!t.isEmpty()) { + ProtoLog.v(WM_SHELL_TASK_ORG, "applyTransaction(): wct=%s caller=%s", + t, Debug.getCallers(4)); + } + super.applyTransaction(t); + } + + @Override + public int applySyncTransaction(@NonNull WindowContainerTransaction t, + @NonNull WindowContainerTransactionCallback callback) { + if (!t.isEmpty()) { + ProtoLog.v(WM_SHELL_TASK_ORG, "applySyncTransaction(): wct=%s caller=%s", + t, Debug.getCallers(4)); + } + return super.applySyncTransaction(t, callback); + } + /** * Creates a persistent root task in WM for a particular windowing-mode. * @param displayId The display to create the root task on. * @param windowingMode Windowing mode to put the root task in. * @param listener The listener to get the created task callback. + * + * @deprecated Use {@link #createRootTask(CreateRootTaskRequest, TaskListener)} */ public void createRootTask(int displayId, int windowingMode, TaskListener listener) { - createRootTask(displayId, windowingMode, listener, false /* removeWithTaskOrganizer */); + createRootTask(new CreateRootTaskRequest() + .setDisplayId(displayId) + .setWindowingMode(windowingMode), + listener); } /** @@ -284,14 +382,52 @@ public class ShellTaskOrganizer extends TaskOrganizer implements * @param windowingMode Windowing mode to put the root task in. * @param listener The listener to get the created task callback. * @param removeWithTaskOrganizer True if this task should be removed when organizer destroyed. + * + * @deprecated Use {@link #createRootTask(CreateRootTaskRequest, TaskListener)} */ public void createRootTask(int displayId, int windowingMode, TaskListener listener, boolean removeWithTaskOrganizer) { + createRootTask(new CreateRootTaskRequest() + .setDisplayId(displayId) + .setWindowingMode(windowingMode) + .setRemoveWithTaskOrganizer(removeWithTaskOrganizer), + listener); + } + + /** + * Creates a persistent root task in WM for a particular windowing-mode. + * @param displayId The display to create the root task on. + * @param windowingMode Windowing mode to put the root task in. + * @param listener The listener to get the created task callback. + * @param removeWithTaskOrganizer True if this task should be removed when organizer destroyed. + * @param reparentOnDisplayRemoval True if this task should be reparented on display removal. + * + * @deprecated Use {@link #createRootTask(CreateRootTaskRequest, TaskListener)} + */ + public void createRootTask(int displayId, int windowingMode, TaskListener listener, + boolean removeWithTaskOrganizer, boolean reparentOnDisplayRemoval) { + createRootTask(new CreateRootTaskRequest() + .setDisplayId(displayId) + .setWindowingMode(windowingMode) + .setRemoveWithTaskOrganizer(removeWithTaskOrganizer) + .setReparentOnDisplayRemoval(reparentOnDisplayRemoval), + listener); + } + + /** + * Creates a persistent root task in WM for a particular windowing-mode. + * @param request The data for this request + * @param listener The listener to get the created task callback. + * + * @hide + */ + public void createRootTask(@NonNull CreateRootTaskRequest request, TaskListener listener) { ProtoLog.v(WM_SHELL_TASK_ORG, "createRootTask() displayId=%d winMode=%d listener=%s" , - displayId, windowingMode, listener.toString()); + request.displayId, request.windowingMode, listener.toString()); final IBinder cookie = new Binder(); + request.setLaunchCookie(cookie); setPendingLaunchCookieListener(cookie, listener); - super.createRootTask(displayId, windowingMode, cookie, removeWithTaskOrganizer); + super.createRootTask(request); } /** @@ -302,19 +438,30 @@ public class ShellTaskOrganizer extends TaskOrganizer implements } /** - * Adds a listener for a specific task id. + * Adds a listener for a specific task id. This only applies if */ public void addListenerForTaskId(TaskListener listener, int taskId) { synchronized (mLock) { ProtoLog.v(WM_SHELL_TASK_ORG, "addListenerForTaskId taskId=%s", taskId); - if (mTaskListeners.get(taskId) != null) { - throw new IllegalArgumentException( - "Listener for taskId=" + taskId + " already exists"); + final TaskListener existingListener = mTaskListeners.get(taskId); + if (existingListener != null) { + if (existingListener == listener) { + // Same listener already registered + return; + } else { + throw new IllegalArgumentException( + "Listener for taskId=" + taskId + " already exists"); + } } final TaskAppearedInfo info = mTasks.get(taskId); if (info == null) { - throw new IllegalArgumentException("addListenerForTaskId unknown taskId=" + taskId); + ProtoLog.v(WM_SHELL_TASK_ORG, "Queueing pending listener"); + // The caller may have received a transition with the task before the organizer + // was notified of the task appearing, so set a pending task listener for the + // task to be retrieved when the task actually appears + mPendingTaskToListener.put(taskId, listener); + return; } final TaskListener oldListener = getTaskListener(info.getTaskInfo()); @@ -354,6 +501,14 @@ public class ShellTaskOrganizer extends TaskOrganizer implements public void removeListener(TaskListener listener) { synchronized (mLock) { ProtoLog.v(WM_SHELL_TASK_ORG, "Remove listener=%s", listener); + + // Remove all occurrences of the pending listener + for (int i = mPendingTaskToListener.size() - 1; i >= 0; --i) { + if (mPendingTaskToListener.valueAt(i) == listener) { + mPendingTaskToListener.removeAt(i); + } + } + final int index = mTaskListeners.indexOfValue(listener); if (index == -1) { Log.w(TAG, "No registered listener found"); @@ -369,7 +524,7 @@ public class ShellTaskOrganizer extends TaskOrganizer implements tasks.add(data); } - // Remove listener, there can be the multiple occurrences, so search the whole list. + // Remove occurrences of the listener for (int i = mTaskListeners.size() - 1; i >= 0; --i) { if (mTaskListeners.valueAt(i) == listener) { mTaskListeners.removeAt(i); @@ -387,9 +542,11 @@ public class ShellTaskOrganizer extends TaskOrganizer implements /** * Associated a listener to a pending launch cookie so we can route the task later once it - * appears. + * appears. If both this and a pending task-id listener is set, then this will take priority. */ public void setPendingLaunchCookieListener(IBinder cookie, TaskListener listener) { + ProtoLog.v(WM_SHELL_TASK_ORG, "setPendingLaunchCookieListener(): cookie=%s listener=%s", + cookie, listener); synchronized (mLock) { mLaunchCookieToListener.put(cookie, listener); } @@ -409,7 +566,7 @@ public class ShellTaskOrganizer extends TaskOrganizer implements } /** - * Removes listener. + * Removes a locus id listener. */ public void removeLocusIdListener(LocusIdListener listener) { synchronized (mLock) { @@ -430,7 +587,7 @@ public class ShellTaskOrganizer extends TaskOrganizer implements } /** - * Removes listener. + * Removes a focus listener. */ public void removeFocusListener(FocusListener listener) { synchronized (mLock) { @@ -438,6 +595,60 @@ public class ShellTaskOrganizer extends TaskOrganizer implements } } + /** + * Adds a listener to be notified when a task vanishes. + */ + public void addTaskVanishedListener(TaskVanishedListener listener) { + synchronized (mLock) { + mTaskVanishedListeners.add(listener); + } + } + + /** + * Removes a task-vanished listener. + */ + public void removeTaskVanishedListener(TaskVanishedListener listener) { + synchronized (mLock) { + mTaskVanishedListeners.remove(listener); + } + } + + /** + * Adds a listener to be notified when a task is appears. + */ + public void addTaskAppearedListener(TaskAppearedListener listener) { + synchronized (mLock) { + mTaskAppearedListeners.add(listener); + } + } + + /** + * Removes a task-appeared listener. + */ + public void removeTaskAppearedListener(TaskAppearedListener listener) { + synchronized (mLock) { + mTaskAppearedListeners.remove(listener); + } + } + + /** + * Adds a listener to be notified when a task is updated. + */ + public void addTaskInfoChangedListener(TaskInfoChangedListener listener) { + synchronized (mLock) { + mTaskInfoChangedListeners.add(listener); + } + } + + /** + * Removes a taskInfo-update listener. + */ + public void removeTaskInfoChangedListener(TaskInfoChangedListener listener) { + synchronized (mLock) { + mTaskInfoChangedListeners.remove(listener); + } + } + /** * Returns a surface which can be used to attach overlays to the home root task */ @@ -446,6 +657,21 @@ public class ShellTaskOrganizer extends TaskOrganizer implements return mHomeTaskOverlayContainer; } + /** + * Returns the home task surface, not for wide use. + */ + @Nullable + public SurfaceControl getHomeTaskSurface(int displayId) { + for (int i = 0; i < mTasks.size(); i++) { + final TaskAppearedInfo info = mTasks.valueAt(i); + if (info.getTaskInfo().getActivityType() == ACTIVITY_TYPE_HOME + && info.getTaskInfo().displayId == displayId) { + return info.getLeash(); + } + } + return null; + } + @Override public void addStartingWindow(StartingWindowInfo info) { if (mStartingWindow != null) { @@ -504,7 +730,7 @@ public class ShellTaskOrganizer extends TaskOrganizer implements mUnfoldAnimationController.onTaskAppeared(info.getTaskInfo(), info.getLeash()); } - if (info.getTaskInfo().getActivityType() == ACTIVITY_TYPE_HOME) { + if (isHomeTaskOnDefaultDisplay(info.getTaskInfo())) { ProtoLog.v(WM_SHELL_TASK_ORG, "Adding overlay to home task"); final SurfaceControl.Transaction t = new SurfaceControl.Transaction(); t.setLayer(mHomeTaskOverlayContainer, Integer.MAX_VALUE); @@ -515,21 +741,11 @@ public class ShellTaskOrganizer extends TaskOrganizer implements notifyLocusVisibilityIfNeeded(info.getTaskInfo()); notifyCompatUI(info.getTaskInfo(), listener); mRecentTasks.ifPresent(recentTasks -> recentTasks.onTaskAdded(info.getTaskInfo())); - } - - /** - * Take a screenshot of a task. - */ - public void screenshotTask(RunningTaskInfo taskInfo, Rect crop, - Consumer consumer) { - final TaskAppearedInfo info = mTasks.get(taskInfo.taskId); - if (info == null) { - return; + for (TaskAppearedListener l : mTaskAppearedListeners) { + l.onTaskAppeared(info.getTaskInfo(), info.getLeash()); } - ScreenshotUtils.captureLayer(info.getLeash(), crop, consumer); } - @Override public void onTaskInfoChanged(RunningTaskInfo taskInfo) { synchronized (mLock) { @@ -541,7 +757,8 @@ public class ShellTaskOrganizer extends TaskOrganizer implements final TaskAppearedInfo data = mTasks.get(taskInfo.taskId); final TaskListener oldListener = getTaskListener(data.getTaskInfo()); - final TaskListener newListener = getTaskListener(taskInfo); + final TaskListener newListener = getTaskListener(taskInfo, + true /* removeLaunchCookieIfNeeded */); mTasks.put(taskInfo.taskId, new TaskAppearedInfo(taskInfo, data.getLeash())); final boolean updated = updateTaskListenerIfNeeded( taskInfo, data.getLeash(), oldListener, newListener); @@ -555,8 +772,8 @@ public class ShellTaskOrganizer extends TaskOrganizer implements } final boolean windowModeChanged = data.getTaskInfo().getWindowingMode() != taskInfo.getWindowingMode(); - final boolean visibilityChanged = data.getTaskInfo().isVisible != taskInfo.isVisible; - if (windowModeChanged || visibilityChanged) { + if (windowModeChanged + || hasFreeformConfigurationChanged(data.getTaskInfo(), taskInfo)) { mRecentTasks.ifPresent(recentTasks -> recentTasks.onTaskRunningInfoChanged(taskInfo)); } @@ -569,14 +786,28 @@ public class ShellTaskOrganizer extends TaskOrganizer implements || mLastFocusedTaskInfo.getWindowingMode() != taskInfo.getWindowingMode()) && isFocusedOrHome; if (focusTaskChanged) { - for (int i = 0; i < mFocusListeners.size(); i++) { - mFocusListeners.valueAt(i).onFocusTaskChanged(taskInfo); + for (FocusListener focusListener : mFocusListeners) { + focusListener.onFocusTaskChanged(taskInfo); } mLastFocusedTaskInfo = taskInfo; } + for (TaskInfoChangedListener l : mTaskInfoChangedListeners) { + l.onTaskInfoChanged(taskInfo); + } } } + private boolean hasFreeformConfigurationChanged(RunningTaskInfo oldTaskInfo, + RunningTaskInfo newTaskInfo) { + if (newTaskInfo.getWindowingMode() != WINDOWING_MODE_FREEFORM) { + return false; + } + return oldTaskInfo.isVisible != newTaskInfo.isVisible + || !oldTaskInfo.positionInParent.equals(newTaskInfo.positionInParent) + || !Objects.equals(oldTaskInfo.configuration.windowConfiguration.getAppBounds(), + newTaskInfo.configuration.windowConfiguration.getAppBounds()); + } + @Override public void onBackPressedOnTaskRoot(RunningTaskInfo taskInfo) { synchronized (mLock) { @@ -608,16 +839,14 @@ public class ShellTaskOrganizer extends TaskOrganizer implements notifyCompatUI(taskInfo, null /* taskListener */); // Notify the recent tasks that a task has been removed mRecentTasks.ifPresent(recentTasks -> recentTasks.onTaskRemoved(taskInfo)); - if (taskInfo.getActivityType() == ACTIVITY_TYPE_HOME) { + if (isHomeTaskOnDefaultDisplay(taskInfo)) { SurfaceControl.Transaction t = new SurfaceControl.Transaction(); t.reparent(mHomeTaskOverlayContainer, null); t.apply(); ProtoLog.v(WM_SHELL_TASK_ORG, "Removing overlay surface"); } - - if (!ENABLE_SHELL_TRANSITIONS && (appearedInfo.getLeash() != null)) { - // Preemptively clean up the leash only if shell transitions are not enabled - appearedInfo.getLeash().release(); + for (TaskVanishedListener l : mTaskVanishedListeners) { + l.onTaskVanished(taskInfo); } } } @@ -638,6 +867,15 @@ public class ShellTaskOrganizer extends TaskOrganizer implements return result; } + /** Return list of {@link RunningTaskInfo}s on all the displays. */ + public ArrayList getRunningTasks() { + ArrayList result = new ArrayList<>(); + for (int i = 0; i < mTasks.size(); i++) { + result.add(mTasks.valueAt(i).getTaskInfo()); + } + return result; + } + /** Gets running task by taskId. Returns {@code null} if no such task observed. */ @Nullable public RunningTaskInfo getRunningTaskInfo(int taskId) { @@ -647,9 +885,27 @@ public class ShellTaskOrganizer extends TaskOrganizer implements } } + /** + * Shows/hides the given task surface. Not for general use as changing the task visibility may + * conflict with other Transitions. This is currently ONLY used to temporarily hide a task + * while a drag is in session. + */ + public void setTaskSurfaceVisibility(int taskId, boolean visible) { + synchronized (mLock) { + final TaskAppearedInfo info = mTasks.get(taskId); + if (info != null) { + SurfaceControl.Transaction t = new SurfaceControl.Transaction(); + t.setVisibility(info.getLeash(), visible); + t.apply(); + } + } + } + private boolean updateTaskListenerIfNeeded(RunningTaskInfo taskInfo, SurfaceControl leash, TaskListener oldListener, TaskListener newListener) { if (oldListener == newListener) return false; + ProtoLog.v(WM_SHELL_TASK_ORG, " Migrating from listener %s to %s", + oldListener, newListener); // TODO: We currently send vanished/appeared as the task moves between types, but // we should consider adding a different mode-changed callback if (oldListener != null) { @@ -689,50 +945,11 @@ public class ShellTaskOrganizer extends TaskOrganizer implements } private void notifyLocusIdChange(int taskId, LocusId locus, boolean visible) { - for (int i = 0; i < mLocusIdListeners.size(); i++) { - mLocusIdListeners.valueAt(i).onVisibilityChanged(taskId, locus, visible); + for (LocusIdListener l : mLocusIdListeners) { + l.onVisibilityChanged(taskId, locus, visible); } } - @Override - public void onSizeCompatRestartButtonAppeared(int taskId) { - final TaskAppearedInfo info; - synchronized (mLock) { - info = mTasks.get(taskId); - } - if (info == null) { - return; - } - logSizeCompatRestartButtonEventReported(info, - FrameworkStatsLog.SIZE_COMPAT_RESTART_BUTTON_EVENT_REPORTED__EVENT__APPEARED); - } - - @Override - public void onSizeCompatRestartButtonClicked(int taskId) { - final TaskAppearedInfo info; - synchronized (mLock) { - info = mTasks.get(taskId); - } - if (info == null) { - return; - } - logSizeCompatRestartButtonEventReported(info, - FrameworkStatsLog.SIZE_COMPAT_RESTART_BUTTON_EVENT_REPORTED__EVENT__CLICKED); - restartTaskTopActivityProcessIfVisible(info.getTaskInfo().token); - } - - @Override - public void onCameraControlStateUpdated(int taskId, @CameraCompatControlState int state) { - final TaskAppearedInfo info; - synchronized (mLock) { - info = mTasks.get(taskId); - } - if (info == null) { - return; - } - updateCameraCompatControlState(info.getTaskInfo().token, state); - } - /** Reparents a child window surface to the task surface. */ public void reparentChildSurfaceToTask(int taskId, SurfaceControl sc, SurfaceControl.Transaction t) { @@ -750,6 +967,35 @@ public class ShellTaskOrganizer extends TaskOrganizer implements taskListener.reparentChildSurfaceToTask(taskId, sc, t); } + @VisibleForTesting + void onSizeCompatRestartButtonAppeared(@NonNull SizeCompatRestartButtonAppeared compatUIEvent) { + final int taskId = compatUIEvent.getTaskId(); + final TaskAppearedInfo info; + synchronized (mLock) { + info = mTasks.get(taskId); + } + if (info == null) { + return; + } + logSizeCompatRestartButtonEventReported(info, + FrameworkStatsLog.SIZE_COMPAT_RESTART_BUTTON_EVENT_REPORTED__EVENT__APPEARED); + } + + @VisibleForTesting + void onSizeCompatRestartButtonClicked(@NonNull SizeCompatRestartButtonClicked compatUIEvent) { + final int taskId = compatUIEvent.getTaskId(); + final TaskAppearedInfo info; + synchronized (mLock) { + info = mTasks.get(taskId); + } + if (info == null) { + return; + } + logSizeCompatRestartButtonEventReported(info, + FrameworkStatsLog.SIZE_COMPAT_RESTART_BUTTON_EVENT_REPORTED__EVENT__CLICKED); + restartTaskTopActivityProcessIfVisible(info.getTaskInfo().token); + } + private void logSizeCompatRestartButtonEventReported(@NonNull TaskAppearedInfo info, int event) { ActivityInfo topActivityInfo = info.getTaskInfo().topActivityInfo; @@ -777,10 +1023,10 @@ public class ShellTaskOrganizer extends TaskOrganizer implements // on this Task if there is any. if (taskListener == null || !taskListener.supportCompatUI() || !taskInfo.appCompatTaskInfo.hasCompatUI() || !taskInfo.isVisible) { - mCompatUI.onCompatInfoChanged(taskInfo, null /* taskListener */); + mCompatUI.onCompatInfoChanged(new CompatUIInfo(taskInfo, null /* taskListener */)); return; } - mCompatUI.onCompatInfoChanged(taskInfo, taskListener); + mCompatUI.onCompatInfoChanged(new CompatUIInfo(taskInfo, taskListener)); } private TaskListener getTaskListener(RunningTaskInfo runningTaskInfo) { @@ -788,7 +1034,7 @@ public class ShellTaskOrganizer extends TaskOrganizer implements } private TaskListener getTaskListener(RunningTaskInfo runningTaskInfo, - boolean removeLaunchCookieIfNeeded) { + boolean removePendingIfNeeded) { final int taskId = runningTaskInfo.taskId; TaskListener listener; @@ -800,14 +1046,35 @@ public class ShellTaskOrganizer extends TaskOrganizer implements listener = mLaunchCookieToListener.get(cookie); if (listener == null) continue; - if (removeLaunchCookieIfNeeded) { + if (removePendingIfNeeded) { + ProtoLog.v(WM_SHELL_TASK_ORG, "Migrating cookie listener to task: taskId=%d", + taskId); // Remove the cookie and add the listener. mLaunchCookieToListener.remove(cookie); + if (mPendingTaskToListener.containsKey(taskId) + && mPendingTaskToListener.get(taskId) != listener) { + Log.w(TAG, "Conflicting pending task listeners reported for taskId=" + taskId); + } + mPendingTaskToListener.remove(taskId); mTaskListeners.put(taskId, listener); } return listener; } + // Next priority goes to the pending task id listener + if (mPendingTaskToListener.containsKey(taskId)) { + listener = mPendingTaskToListener.get(taskId); + if (listener != null) { + if (removePendingIfNeeded) { + ProtoLog.v(WM_SHELL_TASK_ORG, "Migrating pending listener to task: taskId=%d", + taskId); + mPendingTaskToListener.remove(taskId); + mTaskListeners.put(taskId, listener); + } + return listener; + } + } + // Next priority goes to taskId specific listeners. listener = mTaskListeners.get(taskId); if (listener != null) return listener; @@ -823,6 +1090,11 @@ public class ShellTaskOrganizer extends TaskOrganizer implements return mTaskListeners.get(taskListenerType); } + @VisibleForTesting + boolean hasTaskListener(int taskId) { + return mTaskListeners.contains(taskId); + } + @VisibleForTesting static @TaskListenerType int taskInfoToTaskListenerType(RunningTaskInfo runningTaskInfo) { switch (runningTaskInfo.getWindowingMode()) { @@ -857,6 +1129,17 @@ public class ShellTaskOrganizer extends TaskOrganizer implements } } + /** + * Return true if {@link RunningTaskInfo} is Home/Launcher activity type, plus it's the one on + * default display (rather than on external display). This is used to check if we need to + * reparent mHomeTaskOverlayContainer that is used for -1 screen on default display. + */ + @VisibleForTesting + static boolean isHomeTaskOnDefaultDisplay(RunningTaskInfo taskInfo) { + return taskInfo.getActivityType() == ACTIVITY_TYPE_HOME + && taskInfo.displayId == DEFAULT_DISPLAY; + } + public void dump(@NonNull PrintWriter pw, String prefix) { synchronized (mLock) { final String innerPrefix = prefix + " "; @@ -891,13 +1174,21 @@ public class ShellTaskOrganizer extends TaskOrganizer implements } pw.println(); - pw.println(innerPrefix + mLaunchCookieToListener.size() + " Launch Cookies"); + pw.println(innerPrefix + mLaunchCookieToListener.size() + + " Pending launch cookies listeners"); for (int i = mLaunchCookieToListener.size() - 1; i >= 0; --i) { final IBinder key = mLaunchCookieToListener.keyAt(i); final TaskListener listener = mLaunchCookieToListener.valueAt(i); pw.println(innerPrefix + "#" + i + " cookie=" + key + " listener=" + listener); } + pw.println(); + pw.println(innerPrefix + mPendingTaskToListener.size() + " Pending task listeners"); + for (int i = mPendingTaskToListener.size() - 1; i >= 0; --i) { + final int taskId = mPendingTaskToListener.keyAt(i); + final TaskListener listener = mPendingTaskToListener.valueAt(i); + pw.println(innerPrefix + "#" + i + " taskId=" + taskId + " listener=" + listener); + } } } } diff --git a/wmshell/src/com/android/wm/shell/activityembedding/ActivityEmbeddingAnimationAdapter.java b/wmshell/src/com/android/wm/shell/activityembedding/ActivityEmbeddingAnimationAdapter.java index 8d30db64a3..26c3626115 100644 --- a/wmshell/src/com/android/wm/shell/activityembedding/ActivityEmbeddingAnimationAdapter.java +++ b/wmshell/src/com/android/wm/shell/activityembedding/ActivityEmbeddingAnimationAdapter.java @@ -18,6 +18,7 @@ package com.android.wm.shell.activityembedding; import static android.graphics.Matrix.MTRANS_X; import static android.graphics.Matrix.MTRANS_Y; +import static android.window.TransitionInfo.FLAG_TRANSLUCENT; import android.annotation.CallSuper; import android.graphics.Point; @@ -146,6 +147,13 @@ class ActivityEmbeddingAnimationAdapter { /** To be overridden by subclasses to adjust the animation surface change. */ void onAnimationUpdateInner(@NonNull SurfaceControl.Transaction t) { // Update the surface position and alpha. + if (mAnimation.getExtensionEdges() != 0x0 + && !(mChange.hasFlags(FLAG_TRANSLUCENT) + && mChange.getActivityComponent() != null)) { + // Extend non-translucent activities + t.setEdgeExtensionEffect(mLeash, mAnimation.getExtensionEdges()); + } + mTransformation.getMatrix().postTranslate(mContentRelOffset.x, mContentRelOffset.y); t.setMatrix(mLeash, mTransformation.getMatrix(), mMatrix); t.setAlpha(mLeash, mTransformation.getAlpha()); @@ -165,7 +173,7 @@ class ActivityEmbeddingAnimationAdapter { if (!cropRect.intersect(mWholeAnimationBounds)) { // Hide the surface when it is outside of the animation area. t.setAlpha(mLeash, 0); - } else if (mAnimation.hasExtension()) { + } else if (mAnimation.getExtensionEdges() != 0) { // Allow the surface to be shown in its original bounds in case we want to use edge // extensions. cropRect.union(mContentBounds); @@ -180,6 +188,9 @@ class ActivityEmbeddingAnimationAdapter { @CallSuper void onAnimationEnd(@NonNull SurfaceControl.Transaction t) { onAnimationUpdate(t, mAnimation.getDuration()); + if (mAnimation.getExtensionEdges() != 0x0) { + t.setEdgeExtensionEffect(mLeash, /* edge */ 0); + } } final long getDurationHint() { diff --git a/wmshell/src/com/android/wm/shell/activityembedding/ActivityEmbeddingAnimationRunner.java b/wmshell/src/com/android/wm/shell/activityembedding/ActivityEmbeddingAnimationRunner.java index a426b206b0..85b7ac27da 100644 --- a/wmshell/src/com/android/wm/shell/activityembedding/ActivityEmbeddingAnimationRunner.java +++ b/wmshell/src/com/android/wm/shell/activityembedding/ActivityEmbeddingAnimationRunner.java @@ -20,17 +20,16 @@ import static android.view.WindowManager.TRANSIT_CHANGE; import static android.view.WindowManager.TRANSIT_CLOSE; import static android.view.WindowManagerPolicyConstants.TYPE_LAYER_OFFSET; import static android.window.TransitionInfo.FLAG_IS_BEHIND_STARTING_WINDOW; -import static android.window.TransitionInfo.FLAG_TRANSLUCENT; import static com.android.wm.shell.activityembedding.ActivityEmbeddingAnimationSpec.createShowSnapshotForClosingAnimation; import static com.android.wm.shell.transition.TransitionAnimationHelper.addBackgroundToTransition; -import static com.android.wm.shell.transition.TransitionAnimationHelper.edgeExtendWindow; import static com.android.wm.shell.transition.TransitionAnimationHelper.getTransitionBackgroundColorIfSet; import static com.android.wm.shell.transition.Transitions.TRANSIT_TASK_FRAGMENT_DRAG_RESIZE; import android.animation.Animator; import android.animation.ValueAnimator; import android.content.Context; +import android.graphics.Point; import android.graphics.Rect; import android.os.IBinder; import android.util.ArraySet; @@ -142,8 +141,6 @@ class ActivityEmbeddingAnimationRunner { // ending states. prepareForJumpCut(info, startTransaction); } else { - addEdgeExtensionIfNeeded(startTransaction, finishTransaction, - postStartTransactionCallbacks, adapters); addBackgroundColorIfNeeded(info, startTransaction, finishTransaction, adapters); for (ActivityEmbeddingAnimationAdapter adapter : adapters) { duration = Math.max(duration, adapter.getDurationHint()); @@ -263,8 +260,8 @@ class ActivityEmbeddingAnimationRunner { for (TransitionInfo.Change change : openingChanges) { final Animation animation = animationProvider.get(info, change, openingWholeScreenBounds); - if (animation.getDuration() == 0) { - continue; + if (shouldUseJumpCutForAnimation(animation)) { + return new ArrayList<>(); } final ActivityEmbeddingAnimationAdapter adapter = createOpenCloseAnimationAdapter( info, change, animation, openingWholeScreenBounds); @@ -288,8 +285,8 @@ class ActivityEmbeddingAnimationRunner { } final Animation animation = animationProvider.get(info, change, closingWholeScreenBounds); - if (animation.getDuration() == 0) { - continue; + if (shouldUseJumpCutForAnimation(animation)) { + return new ArrayList<>(); } final ActivityEmbeddingAnimationAdapter adapter = createOpenCloseAnimationAdapter( info, change, animation, closingWholeScreenBounds); @@ -326,41 +323,13 @@ class ActivityEmbeddingAnimationRunner { } } - /** Adds edge extension to the surfaces that have such an animation property. */ - private void addEdgeExtensionIfNeeded(@NonNull SurfaceControl.Transaction startTransaction, - @NonNull SurfaceControl.Transaction finishTransaction, - @NonNull List> postStartTransactionCallbacks, - @NonNull List adapters) { - for (ActivityEmbeddingAnimationAdapter adapter : adapters) { - final Animation animation = adapter.mAnimation; - if (!animation.hasExtension()) { - continue; - } - if (adapter.mChange.hasFlags(FLAG_TRANSLUCENT) - && adapter.mChange.getActivityComponent() != null) { - // Skip edge extension for translucent activity. - continue; - } - final TransitionInfo.Change change = adapter.mChange; - if (TransitionUtil.isOpeningType(adapter.mChange.getMode())) { - // Need to screenshot after startTransaction is applied otherwise activity - // may not be visible or ready yet. - postStartTransactionCallbacks.add( - t -> edgeExtendWindow(change, animation, t, finishTransaction)); - } else { - // Can screenshot now (before startTransaction is applied) - edgeExtendWindow(change, animation, startTransaction, finishTransaction); - } - } - } - /** Adds background color to the transition if any animation has such a property. */ private void addBackgroundColorIfNeeded(@NonNull TransitionInfo info, @NonNull SurfaceControl.Transaction startTransaction, @NonNull SurfaceControl.Transaction finishTransaction, @NonNull List adapters) { for (ActivityEmbeddingAnimationAdapter adapter : adapters) { - final int backgroundColor = getTransitionBackgroundColorIfSet(info, adapter.mChange, + final int backgroundColor = getTransitionBackgroundColorIfSet(adapter.mChange, adapter.mAnimation, 0 /* defaultColor */); if (backgroundColor != 0) { // We only need to show one color. @@ -398,7 +367,15 @@ class ActivityEmbeddingAnimationRunner { // This is because the TaskFragment surface/change won't contain the Activity's before its // reparent. Animation changeAnimation = null; - Rect parentBounds = new Rect(); + final Rect parentBounds = new Rect(); + // We use a single boolean value to record the backdrop override because the override used + // for overlay and we restrict to single overlay animation. We should fix the assumption + // if we allow multiple overlay transitions. + // The backdrop logic is mainly for animations of split animations. The backdrop should be + // disabled if there is any open/close target in the same transition as the change target. + // However, the overlay change animation usually contains one change target, and shows + // backdrop unexpectedly. + Boolean overrideShowBackdrop = null; for (TransitionInfo.Change change : info.getChanges()) { if (change.getMode() != TRANSIT_CHANGE || change.getStartAbsBounds().equals(change.getEndAbsBounds())) { @@ -421,21 +398,27 @@ class ActivityEmbeddingAnimationRunner { } } - // The TaskFragment may be enter/exit split, so we take the union of both as the parent - // size. - parentBounds.union(boundsAnimationChange.getStartAbsBounds()); - parentBounds.union(boundsAnimationChange.getEndAbsBounds()); - if (boundsAnimationChange != change) { - // Union the change starting bounds in case the activity is resized and reparented - // to a TaskFragment. In that case, the TaskFragment may not cover the activity's - // starting bounds. - parentBounds.union(change.getStartAbsBounds()); + final TransitionInfo.AnimationOptions options = boundsAnimationChange + .getAnimationOptions(); + if (options != null) { + final Animation overrideAnimation = + mAnimationSpec.loadCustomAnimation(options, TRANSIT_CHANGE); + if (overrideAnimation != null) { + overrideShowBackdrop = overrideAnimation.getShowBackdrop(); + } } + calculateParentBounds(change, parentBounds); // There are two animations in the array. The first one is for the start leash // (snapshot), and the second one is for the end leash (TaskFragment). - final Animation[] animations = mAnimationSpec.createChangeBoundsChangeAnimations(change, - parentBounds); + final Animation[] animations = + mAnimationSpec.createChangeBoundsChangeAnimations(change, parentBounds); + // Jump cut if either animation has zero for duration. + for (Animation animation : animations) { + if (shouldUseJumpCutForAnimation(animation)) { + return new ArrayList<>(); + } + } // Keep track as we might need to add background color for the animation. // Although there may be multiple change animation, record one of them is sufficient // because the background color will be added to the root leash for the whole animation. @@ -466,7 +449,7 @@ class ActivityEmbeddingAnimationRunner { // If there is no corresponding open/close window with the change, we should show background // color to cover the empty part of the screen. - boolean shouldShouldBackgroundColor = true; + boolean shouldShowBackgroundColor = true; // Handle the other windows that don't have bounds change in the same transition. for (TransitionInfo.Change change : info.getChanges()) { if (handledChanges.contains(change)) { @@ -483,16 +466,21 @@ class ActivityEmbeddingAnimationRunner { animation = ActivityEmbeddingAnimationSpec.createNoopAnimation(change); } else if (TransitionUtil.isClosingType(change.getMode())) { animation = mAnimationSpec.createChangeBoundsCloseAnimation(change, parentBounds); - shouldShouldBackgroundColor = false; + shouldShowBackgroundColor = false; } else { animation = mAnimationSpec.createChangeBoundsOpenAnimation(change, parentBounds); - shouldShouldBackgroundColor = false; + shouldShowBackgroundColor = false; + } + if (shouldUseJumpCutForAnimation(animation)) { + return new ArrayList<>(); } adapters.add(new ActivityEmbeddingAnimationAdapter(animation, change, TransitionUtil.getRootFor(change, info))); } - if (shouldShouldBackgroundColor && changeAnimation != null) { + shouldShowBackgroundColor = overrideShowBackdrop != null + ? overrideShowBackdrop : shouldShowBackgroundColor; + if (shouldShowBackgroundColor && changeAnimation != null) { // Change animation may leave part of the screen empty. Show background color to cover // that. changeAnimation.setShowBackdrop(true); @@ -501,6 +489,26 @@ class ActivityEmbeddingAnimationRunner { return adapters; } + /** + * Calculates parent bounds of the animation target by {@code change}. + */ + @VisibleForTesting + static void calculateParentBounds(@NonNull TransitionInfo.Change change, + @NonNull Rect outParentBounds) { + final Point endParentSize = change.getEndParentSize(); + if (endParentSize.equals(0, 0)) { + return; + } + final Point endRelPosition = change.getEndRelOffset(); + final Point endAbsPosition = new Point(change.getEndAbsBounds().left, + change.getEndAbsBounds().top); + final Point parentEndAbsPosition = new Point(endAbsPosition.x - endRelPosition.x, + endAbsPosition.y - endRelPosition.y); + outParentBounds.set(parentEndAbsPosition.x, parentEndAbsPosition.y, + parentEndAbsPosition.x + endParentSize.x, + parentEndAbsPosition.y + endParentSize.y); + } + /** * Takes a screenshot of the given {@code screenshotChange} surface if WM Core hasn't taken one. * The screenshot leash should be attached to the {@code animationChange} surface which we will @@ -595,6 +603,12 @@ class ActivityEmbeddingAnimationRunner { return true; } + /** Whether or not to use jump cut based on the animation. */ + @VisibleForTesting + static boolean shouldUseJumpCutForAnimation(@NonNull Animation animation) { + return animation.getDuration() == 0; + } + /** Updates the changes to end states in {@code startTransaction} for jump cut animation. */ private void prepareForJumpCut(@NonNull TransitionInfo info, @NonNull SurfaceControl.Transaction startTransaction) { diff --git a/wmshell/src/com/android/wm/shell/activityembedding/ActivityEmbeddingAnimationSpec.java b/wmshell/src/com/android/wm/shell/activityembedding/ActivityEmbeddingAnimationSpec.java index b9868629e6..2b9eda40cd 100644 --- a/wmshell/src/com/android/wm/shell/activityembedding/ActivityEmbeddingAnimationSpec.java +++ b/wmshell/src/com/android/wm/shell/activityembedding/ActivityEmbeddingAnimationSpec.java @@ -18,6 +18,8 @@ package com.android.wm.shell.activityembedding; import static android.app.ActivityOptions.ANIM_CUSTOM; +import static android.view.WindowManager.TRANSIT_CHANGE; +import static android.window.TransitionInfo.AnimationOptions.DEFAULT_ANIMATION_RESOURCES_ID; import static com.android.internal.policy.TransitionAnimation.WALLPAPER_TRANSITION_NONE; import static com.android.wm.shell.transition.TransitionAnimationHelper.loadAttributeAnimation; @@ -27,6 +29,8 @@ import android.annotation.NonNull; import android.annotation.Nullable; import android.content.Context; import android.graphics.Rect; +import android.util.Log; +import android.view.WindowManager; import android.view.animation.AlphaAnimation; import android.view.animation.Animation; import android.view.animation.AnimationSet; @@ -38,11 +42,9 @@ import android.view.animation.TranslateAnimation; import android.window.TransitionInfo; import com.android.internal.policy.TransitionAnimation; -import com.android.window.flags.Flags; import com.android.wm.shell.shared.TransitionUtil; /** Animation spec for ActivityEmbedding transition. */ -// TODO(b/206557124): provide an easier way to customize animation class ActivityEmbeddingAnimationSpec { private static final String TAG = "ActivityEmbeddingAnimSpec"; @@ -93,6 +95,11 @@ class ActivityEmbeddingAnimationSpec { @NonNull Animation createChangeBoundsOpenAnimation(@NonNull TransitionInfo.Change change, @NonNull Rect parentBounds) { + final Animation customAnimation = + loadCustomAnimation(change.getAnimationOptions(), TRANSIT_CHANGE); + if (customAnimation != null) { + return customAnimation; + } // Use end bounds for opening. final Rect bounds = change.getEndAbsBounds(); final int startLeft; @@ -121,6 +128,11 @@ class ActivityEmbeddingAnimationSpec { @NonNull Animation createChangeBoundsCloseAnimation(@NonNull TransitionInfo.Change change, @NonNull Rect parentBounds) { + final Animation customAnimation = + loadCustomAnimation(change.getAnimationOptions(), TRANSIT_CHANGE); + if (customAnimation != null) { + return customAnimation; + } // Use start bounds for closing. final Rect bounds = change.getStartAbsBounds(); final int endTop; @@ -153,6 +165,14 @@ class ActivityEmbeddingAnimationSpec { @NonNull Animation[] createChangeBoundsChangeAnimations(@NonNull TransitionInfo.Change change, @NonNull Rect parentBounds) { + // TODO(b/293658614): Support more complicated animations that may need more than a noop + // animation as the start leash. + final Animation noopAnimation = createNoopAnimation(change); + final Animation customAnimation = + loadCustomAnimation(change.getAnimationOptions(), TRANSIT_CHANGE); + if (customAnimation != null) { + return new Animation[]{noopAnimation, customAnimation}; + } // Both start bounds and end bounds are in screen coordinates. We will post translate // to the local coordinates in ActivityEmbeddingAnimationAdapter#onAnimationUpdate final Rect startBounds = change.getStartAbsBounds(); @@ -203,7 +223,8 @@ class ActivityEmbeddingAnimationSpec { Animation loadOpenAnimation(@NonNull TransitionInfo info, @NonNull TransitionInfo.Change change, @NonNull Rect wholeAnimationBounds) { final boolean isEnter = TransitionUtil.isOpeningType(change.getMode()); - final Animation customAnimation = loadCustomAnimation(info, change, isEnter); + final Animation customAnimation = + loadCustomAnimation(change.getAnimationOptions(), change.getMode()); final Animation animation; if (customAnimation != null) { animation = customAnimation; @@ -230,7 +251,8 @@ class ActivityEmbeddingAnimationSpec { Animation loadCloseAnimation(@NonNull TransitionInfo info, @NonNull TransitionInfo.Change change, @NonNull Rect wholeAnimationBounds) { final boolean isEnter = TransitionUtil.isOpeningType(change.getMode()); - final Animation customAnimation = loadCustomAnimation(info, change, isEnter); + final Animation customAnimation = + loadCustomAnimation(change.getAnimationOptions(), change.getMode()); final Animation animation; if (customAnimation != null) { animation = customAnimation; @@ -262,19 +284,31 @@ class ActivityEmbeddingAnimationSpec { } @Nullable - private Animation loadCustomAnimation(@NonNull TransitionInfo info, - @NonNull TransitionInfo.Change change, boolean isEnter) { - final TransitionInfo.AnimationOptions options; - if (Flags.moveAnimationOptionsToChange()) { - options = change.getAnimationOptions(); - } else { - options = info.getAnimationOptions(); - } + Animation loadCustomAnimation(@Nullable TransitionInfo.AnimationOptions options, + @WindowManager.TransitionType int mode) { if (options == null || options.getType() != ANIM_CUSTOM) { return null; } - final Animation anim = mTransitionAnimation.loadAnimationRes(options.getPackageName(), - isEnter ? options.getEnterResId() : options.getExitResId()); + final int resId; + if (TransitionUtil.isOpeningType(mode)) { + resId = options.getEnterResId(); + } else if (TransitionUtil.isClosingType(mode)) { + resId = options.getExitResId(); + } else if (mode == TRANSIT_CHANGE) { + resId = options.getChangeResId(); + } else { + Log.w(TAG, "Unknown transit type:" + mode); + resId = DEFAULT_ANIMATION_RESOURCES_ID; + } + // Use the default animation if the resources ID is not specified. + if (resId == DEFAULT_ANIMATION_RESOURCES_ID) { + return null; + } + + final Animation anim; + // TODO(b/293658614): Consider allowing custom animations from non-default packages. + // Enforce limiting to animations from the default "android" package for now. + anim = mTransitionAnimation.loadDefaultAnimationRes(resId); if (anim != null) { return anim; } diff --git a/wmshell/src/com/android/wm/shell/activityembedding/ActivityEmbeddingController.java b/wmshell/src/com/android/wm/shell/activityembedding/ActivityEmbeddingController.java index b4ef9f0fc2..e9d1ac64b7 100644 --- a/wmshell/src/com/android/wm/shell/activityembedding/ActivityEmbeddingController.java +++ b/wmshell/src/com/android/wm/shell/activityembedding/ActivityEmbeddingController.java @@ -40,7 +40,6 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.android.internal.annotations.VisibleForTesting; -import com.android.window.flags.Flags; import com.android.wm.shell.shared.TransitionUtil; import com.android.wm.shell.sysui.ShellInit; import com.android.wm.shell.transition.Transitions; @@ -81,9 +80,7 @@ public class ActivityEmbeddingController implements Transitions.TransitionHandle @Nullable public static ActivityEmbeddingController create(@NonNull Context context, @NonNull ShellInit shellInit, @NonNull Transitions transitions) { - return Transitions.ENABLE_SHELL_TRANSITIONS - ? new ActivityEmbeddingController(context, shellInit, transitions) - : null; + return new ActivityEmbeddingController(context, shellInit, transitions); } /** Registers to handle transitions. */ @@ -123,9 +120,6 @@ public class ActivityEmbeddingController implements Transitions.TransitionHandle } private boolean shouldAnimateAnimationOptions(@NonNull TransitionInfo info) { - if (!Flags.moveAnimationOptionsToChange()) { - return shouldAnimateAnimationOptions(info.getAnimationOptions()); - } for (TransitionInfo.Change change : info.getChanges()) { if (!shouldAnimateAnimationOptions(change.getAnimationOptions())) { // If any of override animation is not supported, don't animate the transition. @@ -168,7 +162,8 @@ public class ActivityEmbeddingController implements Transitions.TransitionHandle @Override public void mergeAnimation(@NonNull IBinder transition, @NonNull TransitionInfo info, - @NonNull SurfaceControl.Transaction t, @NonNull IBinder mergeTarget, + @NonNull SurfaceControl.Transaction startT, @NonNull SurfaceControl.Transaction finishT, + @NonNull IBinder mergeTarget, @NonNull Transitions.TransitionFinishCallback finishCallback) { mAnimationRunner.cancelAnimationFromMerge(); } diff --git a/wmshell/src/com/android/wm/shell/animation/FlingAnimationUtils.java b/wmshell/src/com/android/wm/shell/animation/FlingAnimationUtils.java index 26edd7d226..be1f71e939 100644 --- a/wmshell/src/com/android/wm/shell/animation/FlingAnimationUtils.java +++ b/wmshell/src/com/android/wm/shell/animation/FlingAnimationUtils.java @@ -23,6 +23,8 @@ import android.view.ViewPropertyAnimator; import android.view.animation.Interpolator; import android.view.animation.PathInterpolator; +import com.android.wm.shell.shared.animation.Interpolators; + import javax.inject.Inject; /** diff --git a/wmshell/src/com/android/wm/shell/animation/SizeChangeAnimation.java b/wmshell/src/com/android/wm/shell/animation/SizeChangeAnimation.java new file mode 100644 index 0000000000..7116677603 --- /dev/null +++ b/wmshell/src/com/android/wm/shell/animation/SizeChangeAnimation.java @@ -0,0 +1,326 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.wm.shell.animation; + +import static com.android.wm.shell.transition.DefaultSurfaceAnimator.setupValueAnimator; + +import android.animation.Animator; +import android.animation.ValueAnimator; +import android.annotation.Nullable; +import android.graphics.Matrix; +import android.graphics.Rect; +import android.view.Choreographer; +import android.view.SurfaceControl; +import android.view.View; +import android.view.animation.AlphaAnimation; +import android.view.animation.Animation; +import android.view.animation.AnimationSet; +import android.view.animation.ClipRectAnimation; +import android.view.animation.ScaleAnimation; +import android.view.animation.Transformation; +import android.view.animation.TranslateAnimation; + +import com.android.wm.shell.shared.animation.Interpolators; + +import java.util.function.Consumer; + +/** + * Animation implementation for size-changing window container animations. Ported from + * {@link com.android.server.wm.WindowChangeAnimationSpec}. + *

+ * This animation behaves slightly differently depending on whether the window is growing + * or shrinking: + *

    + *
  • If growing, it will do a clip-reveal after quicker fade-out/scale of the smaller (old) + * snapshot. + *
  • If shrinking, it will do an opposite clip-reveal on the old snapshot followed by a quicker + * fade-out of the bigger (old) snapshot while simultaneously shrinking the new window into + * place. + *
+ */ +public class SizeChangeAnimation { + private final Rect mTmpRect = new Rect(); + final Transformation mTmpTransform = new Transformation(); + final Matrix mTmpMatrix = new Matrix(); + final float[] mTmpFloats = new float[9]; + final float[] mTmpVecs = new float[4]; + + private final Animation mAnimation; + private final Animation mSnapshotAnim; + + private final ValueAnimator mAnimator = ValueAnimator.ofFloat(0f, 1f); + + /** + * The maximum of stretching applied to any surface during interpolation (since the animation + * is a combination of stretching/cropping/fading). + */ + private static final float DEFAULT_SCALE_FACTOR = 0.7f; + + /** + * Since this animation is made of several sub-animations, we want to pre-arrange the + * sub-animations on a "virtual timeline" and then drive the overall progress in lock-step. + * + * To do this, we have a single value-animator which animates progress from 0-1 with an + * arbitrary duration and interpolator. Then we convert the progress to a frame in our virtual + * timeline to get the interpolated transforms. + * + * The APIs for arranging the sub-animations use integral frame numbers, so we need to pick + * an integral "duration" for our virtual timeline. That's what this constant specifies. It + * is effectively an animation "resolution" since it divides-up the 0-1 interpolation-space. + */ + private static final int ANIMATION_RESOLUTION = 1000; + + /** + * Initialize a size-change animation from start to end bounds + */ + public SizeChangeAnimation(Rect startBounds, Rect endBounds) { + this(startBounds, endBounds, 1f, DEFAULT_SCALE_FACTOR); + } + + /** + * Initialize a size-change animation from start to end bounds. + *

+ * Allows specifying the initial scale factor, {@code initialScale}, that is applied to the + * start bounds. This can be useful for example when a task is scaled down when the size change + * animation starts. + *

+ * By default the max scale applied to any surface is {@link #DEFAULT_SCALE_FACTOR}. Use + * {@code scaleFactor} to override it. + */ + public SizeChangeAnimation(Rect startBounds, Rect endBounds, float initialScale, + float scaleFactor) { + mAnimation = buildContainerAnimation(startBounds, endBounds, initialScale, scaleFactor); + mSnapshotAnim = buildSnapshotAnimation(startBounds, endBounds, scaleFactor); + } + + /** + * Initialize a size-change animation for a container leash. + */ + public void initialize(SurfaceControl leash, SurfaceControl snapshot, + SurfaceControl.Transaction startT) { + startT.reparent(snapshot, leash); + startT.setPosition(snapshot, 0, 0); + startT.show(snapshot); + startT.show(leash); + apply(startT, leash, snapshot, 0.f); + } + + /** + * Initialize a size-change animation for a view containing the leash surface(s). + * + * Note that this **will** apply {@param startToApply}! + */ + public void initialize(View view, SurfaceControl leash, SurfaceControl snapshot, + SurfaceControl.Transaction startToApply) { + startToApply.reparent(snapshot, leash); + startToApply.setPosition(snapshot, 0, 0); + startToApply.show(snapshot); + startToApply.show(leash); + apply(view, startToApply, leash, snapshot, 0.f); + } + + private ValueAnimator buildAnimatorInner(ValueAnimator.AnimatorUpdateListener updater, + SurfaceControl leash, SurfaceControl snapshot, Consumer onFinish, + SurfaceControl.Transaction transaction, @Nullable View view) { + return setupValueAnimator(mAnimator, updater, (anim) -> { + transaction.reparent(snapshot, null); + if (view != null) { + view.setClipBounds(null); + view.setAnimationMatrix(null); + transaction.setCrop(leash, null); + } + transaction.apply(); + transaction.close(); + onFinish.accept(anim); + }); + } + + /** + * Build an animator which works on a pair of surface controls (where the snapshot is assumed + * to be a child of the main leash). + * + * @param onFinish Called when animation finishes. This is called on the anim thread! + */ + public ValueAnimator buildAnimator(SurfaceControl leash, SurfaceControl snapshot, + Consumer onFinish) { + final SurfaceControl.Transaction transaction = new SurfaceControl.Transaction(); + Choreographer choreographer = Choreographer.getInstance(); + return buildAnimatorInner(animator -> { + // The finish callback in buildSurfaceAnimation will ensure that the animation ends + // with fraction 1. + final float progress = Math.clamp(animator.getAnimatedFraction(), 0.f, 1.f); + apply(transaction, leash, snapshot, progress); + transaction.setFrameTimelineVsync(choreographer.getVsyncId()); + transaction.apply(); + }, leash, snapshot, onFinish, transaction, null /* view */); + } + + /** + * Build an animator which works on a view that contains a pair of surface controls (where + * the snapshot is assumed to be a child of the main leash). + * + * @param onFinish Called when animation finishes. This is called on the anim thread! + */ + public ValueAnimator buildViewAnimator(View view, SurfaceControl leash, + SurfaceControl snapshot, Consumer onFinish) { + final SurfaceControl.Transaction transaction = new SurfaceControl.Transaction(); + return buildAnimatorInner(animator -> { + // The finish callback in buildSurfaceAnimation will ensure that the animation ends + // with fraction 1. + final float progress = Math.clamp(animator.getAnimatedFraction(), 0.f, 1.f); + apply(view, transaction, leash, snapshot, progress); + }, leash, snapshot, onFinish, transaction, view); + } + + /** Animation for the whole container (snapshot is inside this container). */ + private static AnimationSet buildContainerAnimation(Rect startBounds, Rect endBounds, + float initialScale, float scaleFactor) { + final long duration = ANIMATION_RESOLUTION; + boolean growing = endBounds.width() - startBounds.width() + + endBounds.height() - startBounds.height() >= 0; + long scalePeriod = (long) (duration * scaleFactor); + float startScaleX = scaleFactor * ((float) startBounds.width()) / endBounds.width() + + (1.f - scaleFactor); + float startScaleY = scaleFactor * ((float) startBounds.height()) / endBounds.height() + + (1.f - scaleFactor); + final AnimationSet animSet = new AnimationSet(true); + // Use a linear interpolator so the driving ValueAnimator sets the interpolation + animSet.setInterpolator(Interpolators.LINEAR); + + final Animation scaleAnim = new ScaleAnimation(startScaleX, 1, startScaleY, 1); + scaleAnim.setDuration(scalePeriod); + long scaleStartOffset = 0; + if (!growing) { + scaleStartOffset = duration - scalePeriod; + } + scaleAnim.setStartOffset(scaleStartOffset); + animSet.addAnimation(scaleAnim); + + if (initialScale != 1f) { + final Animation initialScaleAnim = new ScaleAnimation(initialScale, 1f, initialScale, + 1f); + initialScaleAnim.setDuration(scalePeriod); + initialScaleAnim.setStartOffset(scaleStartOffset); + animSet.addAnimation(initialScaleAnim); + } + + final Animation translateAnim = new TranslateAnimation(startBounds.left, + endBounds.left, startBounds.top, endBounds.top); + translateAnim.setDuration(duration); + animSet.addAnimation(translateAnim); + Rect startClip = new Rect(startBounds); + startClip.scale(initialScale); + Rect endClip = new Rect(endBounds); + startClip.offsetTo(0, 0); + endClip.offsetTo(0, 0); + final Animation clipAnim = new ClipRectAnimation(startClip, endClip); + clipAnim.setDuration(duration); + animSet.addAnimation(clipAnim); + animSet.initialize(startBounds.width(), startBounds.height(), + endBounds.width(), endBounds.height()); + return animSet; + } + + /** The snapshot surface is assumed to be a child of the container surface. */ + private static AnimationSet buildSnapshotAnimation(Rect startBounds, Rect endBounds, + float scaleFactor) { + final long duration = ANIMATION_RESOLUTION; + boolean growing = endBounds.width() - startBounds.width() + + endBounds.height() - startBounds.height() >= 0; + long scalePeriod = (long) (duration * scaleFactor); + float endScaleX = 1.f / (scaleFactor * ((float) startBounds.width()) / endBounds.width() + + (1.f - scaleFactor)); + float endScaleY = 1.f / (scaleFactor * ((float) startBounds.height()) / endBounds.height() + + (1.f - scaleFactor)); + + AnimationSet snapAnimSet = new AnimationSet(true); + // Use a linear interpolator so the driving ValueAnimator sets the interpolation + snapAnimSet.setInterpolator(Interpolators.LINEAR); + // Animation for the "old-state" snapshot that is atop the task. + final Animation snapAlphaAnim = new AlphaAnimation(1.f, 0.f); + snapAlphaAnim.setDuration(scalePeriod); + if (!growing) { + snapAlphaAnim.setStartOffset(duration - scalePeriod); + } + snapAnimSet.addAnimation(snapAlphaAnim); + final Animation snapScaleAnim = + new ScaleAnimation(endScaleX, endScaleX, endScaleY, endScaleY); + snapScaleAnim.setDuration(duration); + snapAnimSet.addAnimation(snapScaleAnim); + snapAnimSet.initialize(startBounds.width(), startBounds.height(), + endBounds.width(), endBounds.height()); + return snapAnimSet; + } + + private void calcCurrentClipBounds(Rect outClip, Transformation fromTransform) { + // The following applies an inverse scale to the clip-rect so that it crops "after" the + // scale instead of before. + mTmpVecs[1] = mTmpVecs[2] = 0; + mTmpVecs[0] = mTmpVecs[3] = 1; + fromTransform.getMatrix().mapVectors(mTmpVecs); + + mTmpVecs[0] = 1.f / mTmpVecs[0]; + mTmpVecs[3] = 1.f / mTmpVecs[3]; + final Rect clipRect = fromTransform.getClipRect(); + outClip.left = (int) (clipRect.left * mTmpVecs[0] + 0.5f); + outClip.right = (int) (clipRect.right * mTmpVecs[0] + 0.5f); + outClip.top = (int) (clipRect.top * mTmpVecs[3] + 0.5f); + outClip.bottom = (int) (clipRect.bottom * mTmpVecs[3] + 0.5f); + } + + private void apply(SurfaceControl.Transaction t, SurfaceControl leash, SurfaceControl snapshot, + float progress) { + long currentPlayTime = (long) (((float) ANIMATION_RESOLUTION) * progress); + // update thumbnail surface + mSnapshotAnim.getTransformation(currentPlayTime, mTmpTransform); + t.setMatrix(snapshot, mTmpTransform.getMatrix(), mTmpFloats); + t.setAlpha(snapshot, mTmpTransform.getAlpha()); + + // update container surface + mAnimation.getTransformation(currentPlayTime, mTmpTransform); + final Matrix matrix = mTmpTransform.getMatrix(); + t.setMatrix(leash, matrix, mTmpFloats); + + calcCurrentClipBounds(mTmpRect, mTmpTransform); + t.setCrop(leash, mTmpRect); + } + + private void apply(View view, SurfaceControl.Transaction tmpT, SurfaceControl leash, + SurfaceControl snapshot, float progress) { + long currentPlayTime = (long) (((float) ANIMATION_RESOLUTION) * progress); + // update thumbnail surface + mSnapshotAnim.getTransformation(currentPlayTime, mTmpTransform); + tmpT.setMatrix(snapshot, mTmpTransform.getMatrix(), mTmpFloats); + tmpT.setAlpha(snapshot, mTmpTransform.getAlpha()); + + // update container surface + mAnimation.getTransformation(currentPlayTime, mTmpTransform); + final Matrix matrix = mTmpTransform.getMatrix(); + mTmpMatrix.set(matrix); + // animationMatrix is applied after getTranslation, so "move" the translate to the end. + mTmpMatrix.preTranslate(-view.getTranslationX(), -view.getTranslationY()); + mTmpMatrix.postTranslate(view.getTranslationX(), view.getTranslationY()); + view.setAnimationMatrix(mTmpMatrix); + + calcCurrentClipBounds(mTmpRect, mTmpTransform); + tmpT.setCrop(leash, mTmpRect); + view.setClipBounds(mTmpRect); + + // this takes stuff out of mTmpT so mTmpT can be re-used immediately + view.getViewRootImpl().applyTransactionOnDraw(tmpT); + } +} diff --git a/wmshell/src/com/android/wm/shell/apptoweb/AppToWebGenericLinksParser.kt b/wmshell/src/com/android/wm/shell/apptoweb/AppToWebGenericLinksParser.kt new file mode 100644 index 0000000000..ea833fd356 --- /dev/null +++ b/wmshell/src/com/android/wm/shell/apptoweb/AppToWebGenericLinksParser.kt @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.wm.shell.apptoweb + +import android.content.Context +import android.provider.DeviceConfig +import android.webkit.URLUtil +import com.android.internal.annotations.VisibleForTesting +import com.android.wm.shell.R +import com.android.wm.shell.common.ShellExecutor +import com.android.wm.shell.shared.annotations.ShellMainThread +import com.android.wm.shell.shared.desktopmode.DesktopConfig + +/** + * Retrieves the build-time or server-side generic links list and parses and stores the + * package-to-url pairs. + */ +class AppToWebGenericLinksParser( + private val context: Context, + @ShellMainThread private val mainExecutor: ShellExecutor, + private val desktopConfig: DesktopConfig, +) { + private val genericLinksMap: MutableMap = mutableMapOf() + + init { + // If using the server-side generic links list, register a listener + if (!desktopConfig.useAppToWebBuildTimeGenericLinks) { + DeviceConfigListener() + } + + updateGenericLinksMap() + } + + /** Returns the generic link associated with the [packageName] or null if there is none. */ + fun getGenericLink(packageName: String): String? = genericLinksMap[packageName] + + private fun updateGenericLinksMap() { + val genericLinksList = + if (desktopConfig.useAppToWebBuildTimeGenericLinks) { + context.resources.getString(R.string.generic_links_list) + } else { + DeviceConfig.getString(NAMESPACE, FLAG_GENERIC_LINKS, /* defaultValue= */ "") + } ?: return + + parseGenericLinkList(genericLinksList) + } + + private fun parseGenericLinkList(genericLinksList: String) { + val newEntries = + genericLinksList + .split(" ") + .filter { it.contains(':') } + .map { + val (packageName, url) = it.split(':', limit = 2) + return@map packageName to url + } + .filter { URLUtil.isNetworkUrl(it.second) } + + genericLinksMap.clear() + genericLinksMap.putAll(newEntries) + } + + /** + * Listens for changes to the server-side generic links list and updates the package to url map + * if [DesktopModeStatus#useBuildTimeGenericLinkList()] is set to false. + */ + inner class DeviceConfigListener : DeviceConfig.OnPropertiesChangedListener { + init { + DeviceConfig.addOnPropertiesChangedListener(NAMESPACE, mainExecutor, this) + } + + override fun onPropertiesChanged(properties: DeviceConfig.Properties) { + if (properties.keyset.contains(FLAG_GENERIC_LINKS)) { + updateGenericLinksMap() + } + } + } + + companion object { + private const val NAMESPACE = DeviceConfig.NAMESPACE_APP_COMPAT_OVERRIDES + @VisibleForTesting const val FLAG_GENERIC_LINKS = "generic_links_flag" + } +} diff --git a/wmshell/src/com/android/wm/shell/apptoweb/AppToWebUtils.kt b/wmshell/src/com/android/wm/shell/apptoweb/AppToWebUtils.kt new file mode 100644 index 0000000000..c218e2eae2 --- /dev/null +++ b/wmshell/src/com/android/wm/shell/apptoweb/AppToWebUtils.kt @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:JvmName("AppToWebUtils") + +package com.android.wm.shell.apptoweb + +import android.app.assist.AssistContent +import android.content.Context +import android.content.Intent +import android.content.Intent.ACTION_VIEW +import android.content.Intent.FLAG_ACTIVITY_NEW_TASK +import android.content.pm.PackageManager +import android.content.pm.verify.domain.DomainVerificationManager +import android.content.pm.verify.domain.DomainVerificationUserState +import android.net.Uri +import android.view.Display +import com.android.internal.protolog.ProtoLog +import com.android.wm.shell.protolog.ShellProtoLogGroup +import com.android.wm.shell.shared.bubbles.BubbleAnythingFlagHelper +import com.android.wm.shell.shared.desktopmode.DesktopState + +private const val TAG = "AppToWebUtils" + +private val GenericBrowserIntent = Intent() + .setAction(ACTION_VIEW) + .addCategory(Intent.CATEGORY_BROWSABLE) + .setData(Uri.parse("http:")) + +/** + * Check if app links can be shown + */ +fun canShowAppLinks(display: Display, desktopState: DesktopState): Boolean { + if (BubbleAnythingFlagHelper.enableBubbleToFullscreen()) { + return desktopState.isDesktopModeSupportedOnDisplay(display) + } + return true +} + +/** + * Returns a boolean indicating whether a given package is a browser app. + */ +fun isBrowserApp(context: Context, packageName: String, userId: Int): Boolean { + GenericBrowserIntent.setPackage(packageName) + val list = context.packageManager.queryIntentActivitiesAsUser( + GenericBrowserIntent, PackageManager.MATCH_ALL, userId + ) + + list.forEach { + if (it.activityInfo != null && it.handleAllWebDataURI) { + return true + } + } + return false +} + +/** + * Returns intent if there is a browser application available to handle the uri. Otherwise, returns + * null. + */ +fun getBrowserIntent(uri: Uri, packageManager: PackageManager, userId: Int): Intent? { + val intent = Intent.makeMainSelectorActivity(Intent.ACTION_MAIN, Intent.CATEGORY_APP_BROWSER) + .setData(uri) + .addFlags(FLAG_ACTIVITY_NEW_TASK) + // If there is a browser application available to handle the intent, return the intent. + // Otherwise, return null. + val resolveInfo = packageManager.resolveActivityAsUser(intent, /* flags= */ 0, userId) + ?: return null + intent.setComponent(resolveInfo.componentInfo.componentName) + return intent +} + +/** + * Returns intent if there is a non-browser application available to handle the uri. Otherwise, + * returns null. + */ +fun getAppIntent(uri: Uri, packageManager: PackageManager, userId: Int): Intent? { + val intent = Intent(ACTION_VIEW, uri).addFlags(FLAG_ACTIVITY_NEW_TASK) + val resolveInfo = packageManager.resolveActivityAsUser(intent, /* flags= */ 0, userId) + ?: return null + // If there is a non-browser application available to handle the intent, return the intent. + // Otherwise, return null. + if (resolveInfo.activityInfo != null && !resolveInfo.handleAllWebDataURI) { + intent.setComponent(resolveInfo.componentInfo.componentName) + return intent + } + return null +} + +/** + * Returns the [DomainVerificationUserState] of the user associated with the given + * [DomainVerificationManager] and the given package. + */ +fun getDomainVerificationUserState( + manager: DomainVerificationManager, + packageName: String +): DomainVerificationUserState? { + try { + return manager.getDomainVerificationUserState(packageName) + } catch (e: PackageManager.NameNotFoundException) { + ProtoLog.w( + ShellProtoLogGroup.WM_SHELL_DESKTOP_MODE, + "%s: Failed to get domain verification user state: %s", + TAG, + e.message!! + ) + return null + } +} + +/** + * Returns the web uri from the given [AssistContent]. + */ +fun AssistContent.getSessionWebUri(): Uri? { + return sessionTransferUri ?: webUri +} diff --git a/wmshell/src/com/android/wm/shell/apptoweb/AssistContentRequester.kt b/wmshell/src/com/android/wm/shell/apptoweb/AssistContentRequester.kt new file mode 100644 index 0000000000..249185eca3 --- /dev/null +++ b/wmshell/src/com/android/wm/shell/apptoweb/AssistContentRequester.kt @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.wm.shell.apptoweb + +import android.app.ActivityTaskManager +import android.app.IActivityTaskManager +import android.app.IAssistDataReceiver +import android.app.assist.AssistContent +import android.content.Context +import android.graphics.Bitmap +import android.os.Bundle +import android.os.RemoteException +import android.util.Slog +import java.lang.ref.WeakReference +import java.util.Collections +import java.util.WeakHashMap +import java.util.concurrent.Executor + +/** + * Can be used to request the AssistContent from a provided task id, useful for getting the web uri + * if provided from the task. + */ +class AssistContentRequester( + context: Context, + private val callBackExecutor: Executor, + private val systemInteractionExecutor: Executor +) { + interface Callback { + // Called when the [AssistContent] of the requested task is available. + fun onAssistContentAvailable(assistContent: AssistContent?) + } + + private val activityTaskManager: IActivityTaskManager = ActivityTaskManager.getService() + private val attributionTag: String? = context.attributionTag + private val packageName: String = context.applicationContext.packageName + + // If system loses the callback, our internal cache of original callback will also get cleared. + private val pendingCallbacks = Collections.synchronizedMap(WeakHashMap()) + + /** + * Request the [AssistContent] from the task with the provided id. + * + * @param taskId to query for the content. + * @param callback to call when the content is available, called on the main thread. + */ + fun requestAssistContent(taskId: Int, callback: Callback) { + // ActivityTaskManager interaction here is synchronous, so call off the main thread. + systemInteractionExecutor.execute { + try { + val success = activityTaskManager.requestAssistDataForTask( + AssistDataReceiver(callback, this), + taskId, + packageName, + attributionTag, + false /* fetchStructure */ + ) + if (!success) { + executeOnMainExecutor { callback.onAssistContentAvailable(null) } + } + } catch (e: RemoteException) { + Slog.e(TAG, "Requesting assist content failed for task: $taskId", e) + } + } + } + + private fun executeOnMainExecutor(callback: Runnable) { + callBackExecutor.execute(callback) + } + + private class AssistDataReceiver( + callback: Callback, + parent: AssistContentRequester + ) : IAssistDataReceiver.Stub() { + // The AssistDataReceiver binder callback object is passed to a system server, that may + // keep hold of it for longer than the lifetime of the AssistContentRequester object, + // potentially causing a memory leak. In the callback passed to the system server, only + // keep a weak reference to the parent object and lookup its callback if it still exists. + private val parentRef: WeakReference + private val callbackKey = Any() + + init { + parent.pendingCallbacks[callbackKey] = callback + parentRef = WeakReference(parent) + } + + override fun onHandleAssistData(data: Bundle?) { + val content = data?.getParcelable(ASSIST_KEY_CONTENT, AssistContent::class.java) + if (content == null) { + Slog.d(TAG, "Received AssistData, but no AssistContent found") + return + } + val requester = parentRef.get() + if (requester != null) { + val callback = requester.pendingCallbacks[callbackKey] + if (callback != null) { + requester.executeOnMainExecutor { callback.onAssistContentAvailable(content) } + } else { + Slog.d(TAG, "Callback received after calling UI was disposed of") + } + } else { + Slog.d(TAG, "Callback received after Requester was collected") + } + } + + override fun onHandleAssistScreenshot(screenshot: Bitmap) {} + } + + companion object { + private const val TAG = "AssistContentRequester" + private const val ASSIST_KEY_CONTENT = "content" + } +} \ No newline at end of file diff --git a/wmshell/src/com/android/wm/shell/apptoweb/OWNERS b/wmshell/src/com/android/wm/shell/apptoweb/OWNERS new file mode 100644 index 0000000000..7e55786036 --- /dev/null +++ b/wmshell/src/com/android/wm/shell/apptoweb/OWNERS @@ -0,0 +1,7 @@ +atsjenk@google.com +jorgegil@google.com +madym@google.com +mattsziklay@google.com +mdehaini@google.com +pbdr@google.com +vaniadesmonda@google.com diff --git a/wmshell/src/com/android/wm/shell/apptoweb/OpenByDefaultDialog.kt b/wmshell/src/com/android/wm/shell/apptoweb/OpenByDefaultDialog.kt new file mode 100644 index 0000000000..ec3637aacf --- /dev/null +++ b/wmshell/src/com/android/wm/shell/apptoweb/OpenByDefaultDialog.kt @@ -0,0 +1,219 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.wm.shell.apptoweb + +import android.app.ActivityManager.RunningTaskInfo +import android.content.Context +import android.content.pm.PackageManager.NameNotFoundException +import android.content.pm.verify.domain.DomainVerificationManager +import android.graphics.Bitmap +import android.graphics.PixelFormat +import android.util.Slog +import android.view.LayoutInflater +import android.view.SurfaceControl +import android.view.SurfaceControlViewHost +import android.view.WindowManager +import android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE +import android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL +import android.view.WindowManager.LayoutParams.TYPE_APPLICATION_PANEL +import android.view.WindowlessWindowManager +import android.widget.ImageView +import android.widget.RadioButton +import android.widget.TextView +import android.window.TaskConstants +import com.android.wm.shell.R +import com.android.wm.shell.common.DisplayController +import com.android.wm.shell.shared.annotations.ShellBackgroundThread +import com.android.wm.shell.shared.annotations.ShellMainThread +import com.android.wm.shell.windowdecor.additionalviewcontainer.AdditionalViewHostViewContainer +import com.android.wm.shell.windowdecor.common.WindowDecorTaskResourceLoader +import java.util.function.Supplier +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.MainCoroutineDispatcher +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + + +/** + * Window manager for the open by default settings dialog + */ +internal class OpenByDefaultDialog( + private val context: Context, + private val taskInfo: RunningTaskInfo, + private val taskSurface: SurfaceControl, + private val displayController: DisplayController, + private val taskResourceLoader: WindowDecorTaskResourceLoader, + private val surfaceControlTransactionSupplier: Supplier, + @ShellMainThread private val mainDispatcher: MainCoroutineDispatcher, + @ShellBackgroundThread private val bgScope: CoroutineScope, + private val listener: DialogLifecycleListener, +) { + private lateinit var dialog: OpenByDefaultDialogView + private lateinit var viewHost: SurfaceControlViewHost + private lateinit var dialogSurfaceControl: SurfaceControl + private var dialogContainer: AdditionalViewHostViewContainer? = null + private lateinit var appIconView: ImageView + private lateinit var appNameView: TextView + + private lateinit var openInAppButton: RadioButton + private lateinit var openInBrowserButton: RadioButton + + private val domainVerificationManager = + context.getSystemService(DomainVerificationManager::class.java)!! + private val packageName = taskInfo.baseActivity?.packageName!! + + private var loadAppInfoJob: Job? = null + + init { + createDialog() + initializeRadioButtons() + loadAppInfoJob = bgScope.launch { + if (!isActive) return@launch + val name = taskResourceLoader.getName(taskInfo) + val icon = taskResourceLoader.getHeaderIcon(taskInfo) + withContext(mainDispatcher.immediate) { + if (!isActive) return@withContext + bindAppInfo(icon, name) + } + } + } + + /** Creates an open by default settings dialog. */ + fun createDialog() { + val t = SurfaceControl.Transaction() + val taskBounds = taskInfo.configuration.windowConfiguration.bounds + + dialog = LayoutInflater.from(context) + .inflate( + R.layout.open_by_default_settings_dialog, + null /* root */ + ) as OpenByDefaultDialogView + appIconView = dialog.requireViewById(R.id.application_icon) + appNameView = dialog.requireViewById(R.id.application_name) + + val display = displayController.getDisplay(taskInfo.displayId) + val builder: SurfaceControl.Builder = SurfaceControl.Builder() + dialogSurfaceControl = builder + .setName("Open by Default Dialog of Task=" + taskInfo.taskId) + .setContainerLayer() + .setParent(taskSurface) + .setCallsite("OpenByDefaultDialog#createDialog") + .build() + t.setPosition(dialogSurfaceControl, 0f, 0f) + .setWindowCrop(dialogSurfaceControl, taskBounds.width(), taskBounds.height()) + .setLayer(dialogSurfaceControl, TaskConstants.TASK_CHILD_LAYER_SETTINGS_DIALOG) + .show(dialogSurfaceControl) + val lp = WindowManager.LayoutParams( + taskBounds.width(), + taskBounds.height(), + TYPE_APPLICATION_PANEL, + FLAG_NOT_FOCUSABLE or FLAG_NOT_TOUCH_MODAL, + PixelFormat.TRANSLUCENT) + lp.title = "Open by default settings dialog of task=" + taskInfo.taskId + lp.setTrustedOverlay() + val windowManager = WindowlessWindowManager( + taskInfo.configuration, + dialogSurfaceControl, null /* hostInputToken */ + ) + viewHost = SurfaceControlViewHost(context, display, windowManager, "Dialog").apply { + setView(dialog, lp) + rootSurfaceControl.applyTransactionOnDraw(t) + } + dialogContainer = AdditionalViewHostViewContainer( + dialogSurfaceControl, viewHost, surfaceControlTransactionSupplier) + + dialog.setDismissOnClickListener{ + closeMenu() + } + + dialog.setConfirmButtonClickListener { + setDefaultLinkHandlingSetting() + closeMenu() + } + + listener.onDialogCreated() + } + + private fun initializeRadioButtons() { + openInAppButton = dialog.requireViewById(R.id.open_in_app_button) + openInBrowserButton = dialog.requireViewById(R.id.open_in_browser_button) + + val userState = + getDomainVerificationUserState(domainVerificationManager, packageName) ?: return + val openInApp = userState.isLinkHandlingAllowed + openInAppButton.isChecked = openInApp + openInBrowserButton.isChecked = !openInApp + } + + private fun setDefaultLinkHandlingSetting() { + try { + domainVerificationManager.setDomainVerificationLinkHandlingAllowed( + packageName, openInAppButton.isChecked) + } catch (e: NameNotFoundException) { + Slog.e( + TAG, + "Failed to change link handling policy due to the package name is not found: " + e + ) + } + } + + private fun closeMenu() { + loadAppInfoJob?.cancel() + dialogContainer?.releaseView() + dialogContainer = null + listener.onDialogDismissed() + } + + private fun bindAppInfo( + appIconBitmap: Bitmap, + appName: CharSequence + ) { + appIconView.setImageBitmap(appIconBitmap) + appNameView.text = appName + } + + /** + * Relayout the dialog to the new task bounds. + */ + fun relayout( + taskInfo: RunningTaskInfo, + ) { + val t = surfaceControlTransactionSupplier.get() + val taskBounds = taskInfo.configuration.windowConfiguration.bounds + t.setWindowCrop(dialogSurfaceControl, taskBounds.width(), taskBounds.height()) + viewHost.rootSurfaceControl.applyTransactionOnDraw(t) + viewHost.relayout(taskBounds.width(), taskBounds.height()) + } + + /** + * Defines interface for classes that can listen to lifecycle events of open by default settings + * dialog. + */ + interface DialogLifecycleListener { + /** Called when open by default dialog view has been created. */ + fun onDialogCreated() + + /** Called when open by default dialog view has been released. */ + fun onDialogDismissed() + } + + companion object { + private const val TAG = "OpenByDefaultDialog" + } +} diff --git a/wmshell/src/com/android/wm/shell/apptoweb/OpenByDefaultDialogView.kt b/wmshell/src/com/android/wm/shell/apptoweb/OpenByDefaultDialogView.kt new file mode 100644 index 0000000000..1b914f419d --- /dev/null +++ b/wmshell/src/com/android/wm/shell/apptoweb/OpenByDefaultDialogView.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.wm.shell.apptoweb +import android.content.Context +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.View +import android.widget.Button +import androidx.constraintlayout.widget.ConstraintLayout +import com.android.wm.shell.R + +/** View for open by default settings dialog for an application which allows the user to change + * where links will open by default, in the default browser or in the application. */ +class OpenByDefaultDialogView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + defStyleRes: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr, defStyleRes) { + + private lateinit var dialogContainer: View + private lateinit var backgroundDim: Drawable + + fun setDismissOnClickListener(callback: (View) -> Unit) { + // Clicks on the background dim should also dismiss the dialog. + setOnClickListener(callback) + // We add a no-op on-click listener to the dialog container so that clicks on it won't + // propagate to the listener of the layout (which represents the background dim). + dialogContainer.setOnClickListener { } + } + + fun setConfirmButtonClickListener(callback: (View) -> Unit) { + val dismissButton = dialogContainer.requireViewById