().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(
+ R.id.open_by_default_settings_dialog_confirm_button
+ )
+ dismissButton.setOnClickListener(callback)
+ }
+
+ override fun onFinishInflate() {
+ super.onFinishInflate()
+ dialogContainer = requireViewById(R.id.open_by_default_dialog_container)
+ backgroundDim = background.mutate()
+ backgroundDim.alpha = 128
+ }
+}
diff --git a/wmshell/src/com/android/wm/shell/appzoomout/AppZoomOut.java b/wmshell/src/com/android/wm/shell/appzoomout/AppZoomOut.java
new file mode 100644
index 0000000000..af59994ac6
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/appzoomout/AppZoomOut.java
@@ -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.appzoomout;
+
+import android.os.Handler;
+
+import com.android.wm.shell.shared.annotations.ExternalThread;
+
+/**
+ * Interface to engage with the app zoom out feature.
+ */
+@ExternalThread
+public interface AppZoomOut {
+
+ /**
+ * Called when the zoom out progress is updated, which is used to scale down the current app
+ * surface from fullscreen to the max pushback level we want to apply. {@param progress} ranges
+ * between [0,1], 0 when fullscreen, 1 when it's at the max pushback level.
+ */
+ void setProgress(float progress);
+
+ /**
+ * Sets the squeeze effect progress.
+ *
+ * The {@code progress} parameter determines the current zoom level and surface crop, ranging
+ * from {@code 0f} to {@code 1f}.
+ *
+ * A value of {@code 0f} indicates no scaling and cropping (content is displayed at its
+ * original size).
+ * A value of {@code 0.0f} represents the maximum zoom-out, effectively scaling and cropping
+ * the content to the max pushback level
+ * Values between {@code 0.0f} and {@code 1.0f} represent intermediate zoom levels.
+ *
+ *
+ * @param progress The progress to set the squeeze zoom effect to.
+ * @param vsyncId The vsync id to align the frame to.
+ * @param sysuiMainHandler The main handler from SystemUI (required for CUJ tracking)
+ */
+ void setTopLevelProgress(float progress, long vsyncId, Handler sysuiMainHandler);
+
+}
diff --git a/wmshell/src/com/android/wm/shell/appzoomout/AppZoomOutController.java b/wmshell/src/com/android/wm/shell/appzoomout/AppZoomOutController.java
new file mode 100644
index 0000000000..4b1b956f67
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/appzoomout/AppZoomOutController.java
@@ -0,0 +1,211 @@
+/*
+ * 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.appzoomout;
+
+import static android.app.WindowConfiguration.ROTATION_UNDEFINED;
+import static android.view.Display.DEFAULT_DISPLAY;
+
+import static com.android.systemui.Flags.spatialModelAppPushback;
+import static com.android.systemui.Flags.spatialModelPushbackInShader;
+import static com.android.systemui.shared.Flags.enableLppAssistInvocationEffect;
+
+import android.app.ActivityManager;
+import android.app.WindowConfiguration;
+import android.content.Context;
+import android.content.res.Configuration;
+import android.os.Handler;
+import android.util.Slog;
+import android.window.DisplayAreaInfo;
+import android.window.DisplayAreaOrganizer;
+import android.window.WindowContainerTransaction;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+
+import com.android.internal.jank.InteractionJankMonitor;
+import com.android.wm.shell.ShellTaskOrganizer;
+import com.android.wm.shell.common.DisplayChangeController;
+import com.android.wm.shell.common.DisplayController;
+import com.android.wm.shell.common.DisplayLayout;
+import com.android.wm.shell.common.RemoteCallable;
+import com.android.wm.shell.common.ShellExecutor;
+import com.android.wm.shell.shared.annotations.ExternalThread;
+import com.android.wm.shell.shared.annotations.ShellMainThread;
+import com.android.wm.shell.sysui.ShellInit;
+
+/** Class that manages the app zoom out UI and states. */
+public class AppZoomOutController implements RemoteCallable,
+ ShellTaskOrganizer.FocusListener, DisplayChangeController.OnDisplayChangingListener {
+
+ private static final String TAG = "AppZoomOutController";
+
+ private final Context mContext;
+ private final ShellTaskOrganizer mTaskOrganizer;
+ private final DisplayController mDisplayController;
+ private final AppZoomOutDisplayAreaOrganizer mAppDisplayAreaOrganizer;
+ private final TopLevelZoomOutDisplayAreaOrganizer mTopLevelDisplayAreaOrganizer;
+ private final ShellExecutor mMainExecutor;
+ private final AppZoomOutImpl mImpl = new AppZoomOutImpl();
+
+ private final DisplayController.OnDisplaysChangedListener mDisplaysChangedListener =
+ new DisplayController.OnDisplaysChangedListener() {
+ @Override
+ public void onDisplayConfigurationChanged(int displayId, Configuration newConfig) {
+ if (displayId != DEFAULT_DISPLAY) {
+ return;
+ }
+ updateDisplayLayout(displayId);
+ }
+
+ @Override
+ public void onDisplayAdded(int displayId) {
+ if (displayId != DEFAULT_DISPLAY) {
+ return;
+ }
+ updateDisplayLayout(displayId);
+ }
+ };
+
+
+ public static AppZoomOutController create(Context context, ShellInit shellInit,
+ ShellTaskOrganizer shellTaskOrganizer, DisplayController displayController,
+ DisplayLayout displayLayout, @ShellMainThread ShellExecutor mainExecutor,
+ InteractionJankMonitor interactionJankMonitor) {
+ AppZoomOutDisplayAreaOrganizer appDisplayAreaOrganizer = new AppZoomOutDisplayAreaOrganizer(
+ context, displayLayout, mainExecutor);
+ TopLevelZoomOutDisplayAreaOrganizer topLevelDisplayAreaOrganizer =
+ new TopLevelZoomOutDisplayAreaOrganizer(displayLayout, context, mainExecutor,
+ interactionJankMonitor);
+ return new AppZoomOutController(context, shellInit, shellTaskOrganizer, displayController,
+ appDisplayAreaOrganizer, topLevelDisplayAreaOrganizer, mainExecutor);
+ }
+
+ @VisibleForTesting
+ AppZoomOutController(Context context, ShellInit shellInit,
+ ShellTaskOrganizer shellTaskOrganizer, DisplayController displayController,
+ AppZoomOutDisplayAreaOrganizer appDisplayAreaOrganizer,
+ TopLevelZoomOutDisplayAreaOrganizer topLevelDisplayAreaOrganizer,
+ @ShellMainThread ShellExecutor mainExecutor) {
+ mContext = context;
+ mTaskOrganizer = shellTaskOrganizer;
+ mDisplayController = displayController;
+ mAppDisplayAreaOrganizer = appDisplayAreaOrganizer;
+ mTopLevelDisplayAreaOrganizer = topLevelDisplayAreaOrganizer;
+ mMainExecutor = mainExecutor;
+
+ if (spatialModelAppPushback() || enableLppAssistInvocationEffect()) {
+ shellInit.addInitCallback(this::onInit, this);
+ }
+ }
+
+ private void onInit() {
+ mTaskOrganizer.addFocusListener(this);
+
+ mDisplayController.addDisplayWindowListener(mDisplaysChangedListener);
+ mDisplayController.addDisplayChangingController(this);
+ updateDisplayLayout(mContext.getDisplayId());
+
+ if (spatialModelAppPushback()) {
+ mAppDisplayAreaOrganizer.registerOrganizer();
+ }
+ if (enableLppAssistInvocationEffect()) {
+ mTopLevelDisplayAreaOrganizer.registerOrganizer();
+ }
+ }
+
+ public AppZoomOut asAppZoomOut() {
+ return mImpl;
+ }
+
+ public void setProgress(float progress) {
+ if (!spatialModelPushbackInShader()) {
+ mAppDisplayAreaOrganizer.setProgress(progress);
+ }
+ }
+
+ /**
+ * Scales all content on the screen belonging to
+ * {@link DisplayAreaOrganizer#FEATURE_WINDOWED_MAGNIFICATION} and applies a cropping.
+ *
+ * @param progress progress to be applied to the top-level zoom effect.
+ * @param vsyncId The vsync id to align the frame to.
+ * @param sysuiMainHandler The main handler from SystemUI (required for CUJ tracking)
+ */
+ private void setTopLevelProgress(float progress, long vsyncId, Handler sysuiMainHandler) {
+ if (enableLppAssistInvocationEffect()) {
+ mTopLevelDisplayAreaOrganizer.setProgress(progress, vsyncId, sysuiMainHandler);
+ }
+ }
+
+ void updateDisplayLayout(int displayId) {
+ final DisplayLayout newDisplayLayout = mDisplayController.getDisplayLayout(displayId);
+ if (newDisplayLayout == null) {
+ Slog.w(TAG, "Failed to get new DisplayLayout.");
+ return;
+ }
+ mAppDisplayAreaOrganizer.setDisplayLayout(newDisplayLayout);
+ if (enableLppAssistInvocationEffect()) {
+ mTopLevelDisplayAreaOrganizer.setDisplayLayout(newDisplayLayout);
+ }
+ }
+
+ @Override
+ public void onFocusTaskChanged(ActivityManager.RunningTaskInfo taskInfo) {
+ if (taskInfo == null) {
+ return;
+ }
+ if (taskInfo.getActivityType() == WindowConfiguration.ACTIVITY_TYPE_HOME) {
+ mAppDisplayAreaOrganizer.setIsHomeTaskFocused(taskInfo.isFocused);
+ }
+ }
+
+ @Override
+ public void onDisplayChange(int displayId, int fromRotation, int toRotation,
+ @Nullable DisplayAreaInfo newDisplayAreaInfo, WindowContainerTransaction wct) {
+ // TODO: verify if there is synchronization issues.
+ if (toRotation != ROTATION_UNDEFINED) {
+ mAppDisplayAreaOrganizer.onRotateDisplay(mContext, toRotation);
+ if (enableLppAssistInvocationEffect()) {
+ mTopLevelDisplayAreaOrganizer.onRotateDisplay(mContext, toRotation);
+ }
+ }
+ }
+
+ @Override
+ public Context getContext() {
+ return mContext;
+ }
+
+ @Override
+ public ShellExecutor getRemoteCallExecutor() {
+ return mMainExecutor;
+ }
+
+ @ExternalThread
+ private class AppZoomOutImpl implements AppZoomOut {
+ @Override
+ public void setProgress(float progress) {
+ mMainExecutor.execute(() -> AppZoomOutController.this.setProgress(progress));
+ }
+
+ @Override
+ public void setTopLevelProgress(float progress, long vsyncId, Handler sysuiMainHandler) {
+ mMainExecutor.execute(() -> AppZoomOutController.this.setTopLevelProgress(progress,
+ vsyncId, sysuiMainHandler));
+ }
+ }
+}
diff --git a/wmshell/src/com/android/wm/shell/appzoomout/AppZoomOutDisplayAreaOrganizer.java b/wmshell/src/com/android/wm/shell/appzoomout/AppZoomOutDisplayAreaOrganizer.java
new file mode 100644
index 0000000000..1c37461b2d
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/appzoomout/AppZoomOutDisplayAreaOrganizer.java
@@ -0,0 +1,157 @@
+/*
+ * 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.appzoomout;
+
+import android.annotation.Nullable;
+import android.content.Context;
+import android.util.ArrayMap;
+import android.view.SurfaceControl;
+import android.window.DisplayAreaAppearedInfo;
+import android.window.DisplayAreaInfo;
+import android.window.DisplayAreaOrganizer;
+import android.window.WindowContainerToken;
+
+import com.android.internal.policy.ScreenDecorationsUtils;
+import com.android.wm.shell.common.DisplayLayout;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Executor;
+
+/** Display area organizer that manages the app zoom out UI and states. */
+public class AppZoomOutDisplayAreaOrganizer extends DisplayAreaOrganizer {
+
+ private static final float PUSHBACK_SCALE_FOR_LAUNCHER = 0.05f;
+ private static final float PUSHBACK_SCALE_FOR_APP = 0.025f;
+ private static final float INVALID_PROGRESS = -1;
+
+ private final DisplayLayout mDisplayLayout = new DisplayLayout();
+ private final Context mContext;
+ private final float mCornerRadius;
+ private final Map mDisplayAreaTokenMap =
+ new ArrayMap<>();
+
+ private float mProgress = INVALID_PROGRESS;
+ // Denote whether the home task is focused, null when it's not yet initialized.
+ @Nullable private Boolean mIsHomeTaskFocused;
+
+ public AppZoomOutDisplayAreaOrganizer(Context context,
+ DisplayLayout displayLayout, Executor mainExecutor) {
+ super(mainExecutor);
+ mContext = context;
+ mCornerRadius = ScreenDecorationsUtils.getWindowCornerRadius(mContext);
+ setDisplayLayout(displayLayout);
+ }
+
+ @Override
+ public void onDisplayAreaAppeared(DisplayAreaInfo displayAreaInfo, SurfaceControl leash) {
+ leash.setUnreleasedWarningCallSite(
+ "AppZoomOutDisplayAreaOrganizer.onDisplayAreaAppeared");
+ mDisplayAreaTokenMap.put(displayAreaInfo.token, leash);
+ }
+
+ @Override
+ public void onDisplayAreaVanished(DisplayAreaInfo displayAreaInfo) {
+ final SurfaceControl leash = mDisplayAreaTokenMap.get(displayAreaInfo.token);
+ if (leash != null) {
+ leash.release();
+ }
+ mDisplayAreaTokenMap.remove(displayAreaInfo.token);
+ }
+
+ public void registerOrganizer() {
+ final List displayAreaInfos = registerOrganizer(
+ AppZoomOutDisplayAreaOrganizer.FEATURE_APP_ZOOM_OUT);
+ for (int i = 0; i < displayAreaInfos.size(); i++) {
+ final DisplayAreaAppearedInfo info = displayAreaInfos.get(i);
+ onDisplayAreaAppeared(info.getDisplayAreaInfo(), info.getLeash());
+ }
+ }
+
+ @Override
+ public void unregisterOrganizer() {
+ super.unregisterOrganizer();
+ reset();
+ }
+
+ void setProgress(float progress) {
+ if (mProgress == progress) {
+ return;
+ }
+
+ mProgress = progress;
+ apply();
+ }
+
+ void setIsHomeTaskFocused(boolean isHomeTaskFocused) {
+ if (mIsHomeTaskFocused != null && mIsHomeTaskFocused == isHomeTaskFocused) {
+ return;
+ }
+
+ mIsHomeTaskFocused = isHomeTaskFocused;
+ apply();
+ }
+
+ private void apply() {
+ if (mIsHomeTaskFocused == null || mProgress == INVALID_PROGRESS) {
+ return;
+ }
+
+ SurfaceControl.Transaction tx = new SurfaceControl.Transaction();
+ float scale = mProgress * (mIsHomeTaskFocused
+ ? PUSHBACK_SCALE_FOR_LAUNCHER : PUSHBACK_SCALE_FOR_APP);
+ mDisplayAreaTokenMap.forEach((token, leash) -> updateSurface(tx, leash, scale));
+ tx.apply();
+ }
+
+ void setDisplayLayout(DisplayLayout displayLayout) {
+ mDisplayLayout.set(displayLayout);
+ }
+
+ private void reset() {
+ setProgress(0);
+ mProgress = INVALID_PROGRESS;
+ mIsHomeTaskFocused = null;
+ }
+
+ private void updateSurface(SurfaceControl.Transaction tx, SurfaceControl leash, float scale) {
+ if (scale == 0) {
+ // Reset when scale is set back to 0.
+ tx
+ .setCrop(leash, null)
+ .setScale(leash, 1, 1)
+ .setPosition(leash, 0, 0)
+ .setCornerRadius(leash, 0);
+ return;
+ }
+
+ tx
+ // Rounded corner can only be applied if a crop is set.
+ .setCrop(leash, 0, 0, mDisplayLayout.width(), mDisplayLayout.height())
+ .setScale(leash, 1 - scale, 1 - scale)
+ .setPosition(leash, scale * mDisplayLayout.width() * 0.5f,
+ scale * mDisplayLayout.height() * 0.5f)
+ .setCornerRadius(leash, mCornerRadius * (1 - scale));
+ }
+
+ void onRotateDisplay(Context context, int toRotation) {
+ if (mDisplayLayout.rotation() == toRotation) {
+ return;
+ }
+ mDisplayLayout.rotateTo(context.getResources(), toRotation);
+ }
+}
diff --git a/wmshell/src/com/android/wm/shell/appzoomout/TopLevelZoomOutDisplayAreaOrganizer.kt b/wmshell/src/com/android/wm/shell/appzoomout/TopLevelZoomOutDisplayAreaOrganizer.kt
new file mode 100644
index 0000000000..12197cff4a
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/appzoomout/TopLevelZoomOutDisplayAreaOrganizer.kt
@@ -0,0 +1,220 @@
+/*
+ * 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.appzoomout
+
+import android.content.Context
+import android.os.Handler
+import android.util.ArrayMap
+import android.view.Display
+import android.view.SurfaceControl
+import android.window.DisplayAreaInfo
+import android.window.DisplayAreaOrganizer
+import android.window.WindowContainerToken
+import com.android.internal.jank.Cuj.CUJ_LPP_ASSIST_INVOCATION_EFFECT
+import com.android.internal.jank.InteractionJankMonitor
+import com.android.internal.policy.ScreenDecorationsUtils
+import com.android.wm.shell.common.DisplayLayout
+import java.util.concurrent.Executor
+import kotlin.math.max
+
+private const val SqueezeEffectMaxThicknessDp = 16
+// Defines the amount the squeeze border overlaps the shrinking content on the shorter display edge.
+// At full progress, the overlap is 4 dp on the shorter display edge. On the longer display edge, it
+// will be more than 4 dp, depending on the display aspect ratio.
+private const val SqueezeEffectOverlapShortEdgeThicknessDp = 4
+
+/** Display area organizer that manages the top level zoom out UI and states. */
+class TopLevelZoomOutDisplayAreaOrganizer(
+ displayLayout: DisplayLayout,
+ private val context: Context,
+ mainExecutor: Executor,
+ private val interactionJankMonitor: InteractionJankMonitor,
+) : DisplayAreaOrganizer(mainExecutor) {
+
+ private val mDisplayAreaTokenMap: MutableMap = ArrayMap()
+ private val mDisplayLayout = DisplayLayout()
+ private var cornerRadius = 1f
+ private var mProgress = 0f
+ private var isCujOnSuccessPath = false
+
+ init {
+ setDisplayLayout(displayLayout)
+ }
+
+ override fun onDisplayAreaAppeared(displayAreaInfo: DisplayAreaInfo, leash: SurfaceControl) {
+ leash.setUnreleasedWarningCallSite("TopLevelZoomDisplayAreaOrganizer.onDisplayAreaAppeared")
+ if (displayAreaInfo.displayId == Display.DEFAULT_DISPLAY) {
+ mDisplayAreaTokenMap[displayAreaInfo.token] = leash
+ }
+ }
+
+ override fun onDisplayAreaVanished(displayAreaInfo: DisplayAreaInfo) {
+ val leash = mDisplayAreaTokenMap[displayAreaInfo.token]
+ leash?.release()
+ mDisplayAreaTokenMap.remove(displayAreaInfo.token)
+ }
+
+ /**
+ * Registers the TopLevelZoomOutDisplayAreaOrganizer to manage the display area of
+ * [DisplayAreaOrganizer.FEATURE_WINDOWED_MAGNIFICATION].
+ */
+ fun registerOrganizer() {
+ val displayAreaInfos = registerOrganizer(FEATURE_WINDOWED_MAGNIFICATION)
+ for (i in displayAreaInfos.indices) {
+ val info = displayAreaInfos[i]
+ onDisplayAreaAppeared(info.displayAreaInfo, info.leash)
+ }
+ }
+
+ override fun unregisterOrganizer() {
+ super.unregisterOrganizer()
+ reset()
+ }
+
+ fun setProgress(progress: Float, vsyncId: Long, sysuiMainHandler: Handler?) {
+ if (mProgress == progress) {
+ return
+ }
+
+ updateCuj(lastProgress = mProgress, progress = progress, sysuiMainHandler = sysuiMainHandler)
+ mProgress = progress
+ apply(vsyncId)
+ }
+
+ private fun apply(vsyncId: Long) {
+ val tx = SurfaceControl.Transaction()
+ mDisplayAreaTokenMap.values.forEach { leash: SurfaceControl ->
+ updateSurface(tx, leash, mProgress, vsyncId)
+ }
+ tx.apply()
+ }
+
+ private fun reset() {
+ setProgress(0f, 0, null)
+ }
+
+ private fun updateSurface(
+ tx: SurfaceControl.Transaction,
+ leash: SurfaceControl,
+ progress: Float,
+ vsyncId: Long,
+ ) {
+ if (progress == 0f) {
+ // Reset when scale is set back to 0.
+ tx
+ .setCrop(leash, null)
+ .setScale(leash, 1f, 1f)
+ .setPosition(leash, 0f, 0f)
+ .setCornerRadius(leash, 0f)
+ return
+ }
+ // Get display dimensions once
+ val displayWidth = mDisplayLayout.width()
+ val displayHeight = mDisplayLayout.height()
+ val displayWidthF = displayWidth.toFloat()
+ val displayHeightF = displayHeight.toFloat()
+
+ // Convert DP thickness values to pixels
+ val maxThicknessPx = mDisplayLayout.dpToPx(SqueezeEffectMaxThicknessDp)
+ val overlapShortEdgeThicknessPx = mDisplayLayout.dpToPx(SqueezeEffectOverlapShortEdgeThicknessDp)
+
+ // Determine the longer edge of the display
+ val longEdgePx = max(displayWidth, displayHeight) // Will be Int, but division with Float promotes
+
+ // Calculate the potential for zooming based on thickness parameters
+ // This represents how much the content "shrinks" due to the squeeze effect on both sides.
+ val zoomPotentialPx = (maxThicknessPx - overlapShortEdgeThicknessPx) * 2f
+
+ val zoomOutScale = 1f - (progress * zoomPotentialPx / longEdgePx)
+
+ // Calculate the current thickness of the squeeze effect based on progress
+ val squeezeThickness = maxThicknessPx * progress
+
+ // Calculate the X and Y offsets needed to center the scaled content.
+ // These values are also used to adjust the crop region.
+ // (1f - zoomOutScale) is the percentage of size reduction.
+ // Half of this reduction, applied to the width/height, gives the offset for centering.
+ val positionXOffset = (1f - zoomOutScale) * displayWidthF * 0.5f
+ val positionYOffset = (1f - zoomOutScale) * displayHeightF * 0.5f
+
+ // Calculate crop values.
+ // The squeezeThickness acts as an initial margin/inset.
+ // This margin is then reduced by the positionOffset, because as the view scales down
+ // and moves towards the center, less cropping is needed to achieve the same visual margin
+ // relative to the scaled content.
+ val horizontalCrop = squeezeThickness - positionXOffset
+ val verticalCrop = squeezeThickness - positionYOffset
+
+ // Calculate the right and bottom crop coordinates
+ val cropRight = displayWidthF - horizontalCrop
+ val cropBottom = displayHeightF - verticalCrop
+
+ tx
+ .setCrop(leash, horizontalCrop, verticalCrop, cropRight, cropBottom)
+ .setCornerRadius(leash, cornerRadius * zoomOutScale)
+ .setScale(leash, zoomOutScale, zoomOutScale)
+ .setPosition(leash, positionXOffset, positionYOffset)
+ .setFrameTimelineVsync(vsyncId)
+ }
+
+ private fun updateCuj(lastProgress: Float, progress: Float, sysuiMainHandler: Handler?) {
+ // TODO(b/418136893): Send clearer start/cancel/end signals from SysUI instead
+ if (progress == 1f) {
+ // If the animation reaches a progress of 1f, it means that assistant is being launched
+ // for sure and the animation could not have been cancelled (and can no longer be
+ // cancelled).
+ isCujOnSuccessPath = true
+ }
+ if (lastProgress == 0f && progress > 0f) {
+ // A new squeeze effect animation starts (progress starts moving away from 0f). Start
+ // the CUJ
+ mDisplayAreaTokenMap.values.firstOrNull()?.let {
+ interactionJankMonitor.begin(
+ it,
+ context,
+ sysuiMainHandler,
+ CUJ_LPP_ASSIST_INVOCATION_EFFECT
+ )
+ }
+ }
+ if (lastProgress > 0f && progress == 0f) {
+ // The progress is back at 0f, which marks the end of the animation.
+ if (isCujOnSuccessPath) {
+ // In case the isCujOnSuccessPath flag is set, assistant must have been launched
+ // and we can mark the end of the CUJ
+ interactionJankMonitor.end(CUJ_LPP_ASSIST_INVOCATION_EFFECT)
+ } else {
+ // If the isCujOnSuccessPath flag is not set, it means that the animation must have
+ // been cancelled. Mark the CUJ as cancelled.
+ interactionJankMonitor.cancel(CUJ_LPP_ASSIST_INVOCATION_EFFECT)
+ }
+ // Reset the isCujOnSuccessPath flag
+ isCujOnSuccessPath = false
+ }
+ }
+
+ fun setDisplayLayout(displayLayout: DisplayLayout) {
+ mDisplayLayout.set(displayLayout)
+ cornerRadius = ScreenDecorationsUtils.getWindowCornerRadius(context)
+ }
+
+ fun onRotateDisplay(context: Context, toRotation: Int) {
+ if (mDisplayLayout.rotation() == toRotation) {
+ return
+ }
+ mDisplayLayout.rotateTo(context.resources, toRotation)
+ }
+}
diff --git a/wmshell/src/com/android/wm/shell/back/BackAnimation.java b/wmshell/src/com/android/wm/shell/back/BackAnimation.java
index 196f89d579..c01ad1d323 100644
--- a/wmshell/src/com/android/wm/shell/back/BackAnimation.java
+++ b/wmshell/src/com/android/wm/shell/back/BackAnimation.java
@@ -33,8 +33,6 @@ public interface BackAnimation {
*
* @param touchX the X touch position of the {@link MotionEvent}.
* @param touchY the Y touch position of the {@link MotionEvent}.
- * @param velocityX the X velocity computed from the {@link MotionEvent}.
- * @param velocityY the Y velocity computed from the {@link MotionEvent}.
* @param keyAction the original {@link KeyEvent#getAction()} when the event was dispatched to
* the process. This is forwarded separately because the input pipeline may mutate
* the {#event} action state later.
@@ -43,10 +41,9 @@ public interface BackAnimation {
void onBackMotion(
float touchX,
float touchY,
- float velocityX,
- float velocityY,
int keyAction,
- @BackEvent.SwipeEdge int swipeEdge);
+ @BackEvent.SwipeEdge int swipeEdge,
+ int displayId);
/**
* Called when the back swipe threshold is crossed.
@@ -107,4 +104,24 @@ public interface BackAnimation {
* @param pilferCallback the callback to pilfer pointers.
*/
void setPilferPointerCallback(Runnable pilferCallback);
+
+ /**
+ * Set a callback to requestTopUi.
+ * @param topUiRequest the callback to requestTopUi.
+ */
+ void setTopUiRequestCallback(TopUiRequest topUiRequest);
+
+ /**
+ * Callback to request SysUi to call
+ * {@link android.app.IActivityManager#setHasTopUi(boolean)}.
+ */
+ interface TopUiRequest {
+
+ /**
+ * Request {@link android.app.IActivityManager#setHasTopUi(boolean)} to be called.
+ * @param requestTopUi whether topUi should be requested or not
+ * @param tag tag of the request-source
+ */
+ void requestTopUi(boolean requestTopUi, String tag);
+ }
}
diff --git a/wmshell/src/com/android/wm/shell/back/BackAnimationBackground.java b/wmshell/src/com/android/wm/shell/back/BackAnimationBackground.java
index d754d04e6b..5f63e55f01 100644
--- a/wmshell/src/com/android/wm/shell/back/BackAnimationBackground.java
+++ b/wmshell/src/com/android/wm/shell/back/BackAnimationBackground.java
@@ -20,9 +20,11 @@ import static android.view.Display.DEFAULT_DISPLAY;
import static android.view.WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS;
import android.annotation.NonNull;
+import android.annotation.Nullable;
import android.graphics.Color;
import android.graphics.Rect;
import android.view.SurfaceControl;
+import android.window.DesktopExperienceFlags;
import com.android.internal.graphics.ColorUtils;
import com.android.internal.view.AppearanceRegion;
@@ -58,7 +60,24 @@ public class BackAnimationBackground {
* @param statusbarHeight The height of the statusbar (in px).
*/
public void ensureBackground(Rect startRect, int color,
- @NonNull SurfaceControl.Transaction transaction, int statusbarHeight) {
+ @NonNull SurfaceControl.Transaction transaction, int statusbarHeight, int displayId) {
+ ensureBackground(startRect, color, transaction, statusbarHeight,
+ null /* cropBounds */, 0 /* cornerRadius */, displayId);
+ }
+
+ /**
+ * Ensures the back animation background color layer is present.
+ *
+ * @param startRect The start bounds of the closing target.
+ * @param color The background color.
+ * @param transaction The animation transaction.
+ * @param statusbarHeight The height of the statusbar (in px).
+ * @param cropBounds The crop bounds of the surface, set to non-empty to show wallpaper.
+ * @param cornerRadius The radius of corner, only work when cropBounds is not empty.
+ */
+ public void ensureBackground(Rect startRect, int color,
+ @NonNull SurfaceControl.Transaction transaction, int statusbarHeight,
+ @Nullable Rect cropBounds, float cornerRadius, int displayId) {
if (mBackgroundSurface != null) {
return;
}
@@ -73,11 +92,19 @@ public class BackAnimationBackground {
.setCallsite("BackAnimationBackground")
.setColorLayer();
- mRootTaskDisplayAreaOrganizer.attachToDisplayArea(DEFAULT_DISPLAY, colorLayerBuilder);
+ if (DesktopExperienceFlags.ENABLE_MULTIDISPLAY_TRACKPAD_BACK_GESTURE.isTrue()) {
+ mRootTaskDisplayAreaOrganizer.attachToDisplayArea(displayId, colorLayerBuilder);
+ } else {
+ mRootTaskDisplayAreaOrganizer.attachToDisplayArea(DEFAULT_DISPLAY, colorLayerBuilder);
+ }
mBackgroundSurface = colorLayerBuilder.build();
transaction.setColor(mBackgroundSurface, colorComponents)
.setLayer(mBackgroundSurface, BACKGROUND_LAYER)
.show(mBackgroundSurface);
+ if (cropBounds != null && !cropBounds.isEmpty()) {
+ transaction.setCrop(mBackgroundSurface, cropBounds)
+ .setCornerRadius(mBackgroundSurface, cornerRadius);
+ }
mStartBounds = startRect;
mIsRequestingStatusBarAppearance = false;
mStatusbarHeight = statusbarHeight;
diff --git a/wmshell/src/com/android/wm/shell/back/BackAnimationController.java b/wmshell/src/com/android/wm/shell/back/BackAnimationController.java
index 7041ea307b..88494807d7 100644
--- a/wmshell/src/com/android/wm/shell/back/BackAnimationController.java
+++ b/wmshell/src/com/android/wm/shell/back/BackAnimationController.java
@@ -16,31 +16,44 @@
package com.android.wm.shell.back;
+import static android.app.ActivityTaskManager.INVALID_TASK_ID;
+import static android.view.Display.INVALID_DISPLAY;
+import static android.view.RemoteAnimationTarget.MODE_CLOSING;
+import static android.view.RemoteAnimationTarget.MODE_OPENING;
+import static android.view.WindowManager.TRANSIT_CHANGE;
+import static android.view.WindowManager.TRANSIT_CLOSE_PREPARE_BACK_NAVIGATION;
+import static android.window.BackEvent.EDGE_NONE;
+import static android.window.DesktopExperienceFlags.ENABLE_INDEPENDENT_BACK_IN_PROJECTED;
+import static android.window.TransitionInfo.FLAG_BACK_GESTURE_ANIMATED;
+import static android.window.TransitionInfo.FLAG_IS_WALLPAPER;
+import static android.window.TransitionInfo.FLAG_MOVED_TO_TOP;
+import static android.window.TransitionInfo.FLAG_SHOW_WALLPAPER;
+
import static com.android.internal.jank.InteractionJankMonitor.CUJ_PREDICTIVE_BACK_HOME;
-import static com.android.window.flags.Flags.predictiveBackSystemAnims;
+import static com.android.systemui.Flags.predictiveBackDelayWmTransition;
+import static com.android.window.flags.Flags.unifyBackNavigationTransition;
import static com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_BACK_PREVIEW;
-import static com.android.wm.shell.sysui.ShellSharedConstants.KEY_EXTRA_SHELL_BACK_ANIMATION;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.annotation.SuppressLint;
+import android.app.ActivityManager;
import android.app.ActivityTaskManager;
import android.app.IActivityTaskManager;
-import android.content.ContentResolver;
+import android.app.TaskInfo;
+import android.content.ComponentName;
import android.content.Context;
import android.content.res.Configuration;
-import android.database.ContentObserver;
+import android.graphics.Point;
import android.graphics.Rect;
import android.hardware.input.InputManager;
-import android.net.Uri;
+import android.hardware.input.KeyGestureEvent;
import android.os.Bundle;
import android.os.Handler;
+import android.os.IBinder;
import android.os.RemoteCallback;
import android.os.RemoteException;
import android.os.SystemClock;
-import android.os.SystemProperties;
-import android.os.UserHandle;
-import android.provider.Settings.Global;
import android.util.Log;
import android.view.IRemoteAnimationRunner;
import android.view.InputDevice;
@@ -48,6 +61,7 @@ import android.view.KeyCharacterMap;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.RemoteAnimationTarget;
+import android.view.SurfaceControl;
import android.view.WindowManager;
import android.window.BackAnimationAdapter;
import android.window.BackEvent;
@@ -55,26 +69,37 @@ import android.window.BackMotionEvent;
import android.window.BackNavigationInfo;
import android.window.BackTouchTracker;
import android.window.IBackAnimationFinishedCallback;
+import android.window.IBackAnimationHandoffHandler;
import android.window.IBackAnimationRunner;
import android.window.IOnBackInvokedCallback;
+import android.window.TransitionInfo;
+import android.window.TransitionRequestInfo;
+import android.window.WindowAnimationState;
+import android.window.WindowContainerToken;
+import android.window.WindowContainerTransaction;
import com.android.internal.annotations.VisibleForTesting;
-import com.android.internal.protolog.common.ProtoLog;
+import com.android.internal.protolog.ProtoLog;
import com.android.internal.util.LatencyTracker;
import com.android.internal.view.AppearanceRegion;
+import com.android.systemui.animation.TransitionAnimator;
+import com.android.window.flags.Flags;
import com.android.wm.shell.R;
import com.android.wm.shell.common.ExternalInterfaceBinder;
import com.android.wm.shell.common.RemoteCallable;
import com.android.wm.shell.common.ShellExecutor;
-import com.android.wm.shell.shared.annotations.ShellBackgroundThread;
+import com.android.wm.shell.shared.TransitionUtil;
import com.android.wm.shell.shared.annotations.ShellMainThread;
import com.android.wm.shell.sysui.ConfigurationChangeListener;
import com.android.wm.shell.sysui.ShellCommandHandler;
import com.android.wm.shell.sysui.ShellController;
import com.android.wm.shell.sysui.ShellInit;
+import com.android.wm.shell.transition.Transitions;
import java.io.PrintWriter;
-import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Predicate;
/**
* Controls the window animation run when a user initiates a back gesture.
@@ -82,25 +107,20 @@ import java.util.concurrent.atomic.AtomicBoolean;
public class BackAnimationController implements RemoteCallable,
ConfigurationChangeListener {
private static final String TAG = "ShellBackPreview";
- private static final int SETTING_VALUE_OFF = 0;
- private static final int SETTING_VALUE_ON = 1;
- public static final boolean IS_ENABLED =
- SystemProperties.getInt("persist.wm.debug.predictive_back",
- SETTING_VALUE_ON) == SETTING_VALUE_ON;
- /** Predictive back animation developer option */
- private final AtomicBoolean mEnableAnimations = new AtomicBoolean(false);
/**
* Max duration to wait for an animation to finish before triggering the real back.
*/
private static final long MAX_ANIMATION_DURATION = 2000;
private final LatencyTracker mLatencyTracker;
+ @ShellMainThread private final Handler mHandler;
/** True when a back gesture is ongoing */
- private boolean mBackGestureStarted = false;
+ @VisibleForTesting public boolean mBackGestureStarted = false;
/** Tracks if an uninterruptible animation is in progress */
private boolean mPostCommitAnimationInProgress = false;
+ private boolean mRealCallbackInvoked = false;
/** Tracks if we should start the back gesture on the next motion move event */
private boolean mShouldStartOnNextMoveEvent = false;
@@ -114,14 +134,17 @@ public class BackAnimationController implements RemoteCallable {
ProtoLog.w(WM_SHELL_BACK_PREVIEW, "Animation didn't finish in %d ms. Resetting...",
MAX_ANIMATION_DURATION);
@@ -149,7 +175,8 @@ public class BackAnimationController implements RemoteCallable {
+ if (mBackGestureStarted && result != null && result.getBoolean(
+ BackNavigationInfo.KEY_TOUCH_GESTURE_TRANSFERRED)) {
+ // Host app won't able to process motion event anymore, so pilfer
+ // pointers anyway.
+ if (mBackNavigationInfo != null) {
+ mBackNavigationInfo.disableAppProgressGenerationAllowed();
+ }
+ tryPilferPointers();
+ return;
+ }
if (!mBackGestureStarted || mPostCommitAnimationInProgress) {
// If an uninterruptible animation is already in progress, we should
// ignore this due to it may cause focus lost. (alpha = 0)
@@ -164,6 +201,8 @@ public class BackAnimationController implements RemoteCallable onMotionEvent(
- /* touchX = */ touchX,
- /* touchY = */ touchY,
- /* velocityX = */ velocityX,
- /* velocityY = */ velocityY,
- /* keyAction = */ keyAction,
- /* swipeEdge = */ swipeEdge));
+ mShellExecutor.execute(
+ () -> onMotionEvent(touchX, touchY, keyAction, swipeEdge, displayId));
}
@Override
public void onThresholdCrossed() {
- BackAnimationController.this.onThresholdCrossed();
+ if (predictiveBackDelayWmTransition()) {
+ mShellExecutor.execute(BackAnimationController.this::onThresholdCrossed);
+ } else {
+ BackAnimationController.this.onThresholdCrossed();
+ }
}
@Override
@@ -357,9 +373,14 @@ public class BackAnimationController implements RemoteCallable mRequestTopUiCallback = topUiRequest);
+ }
}
- private static class IBackAnimationImpl extends IBackAnimation.Stub
+ private class IBackAnimationImpl extends IBackAnimation.Stub
implements ExternalInterfaceBinder {
private BackAnimationController mController;
@@ -377,7 +398,8 @@ public class BackAnimationController implements RemoteCallable start queued back navigation "
+ "AND post commit animation");
- injectBackKey();
+ injectBackKey(mBackAnimationAdapter.mOriginDisplayId);
finishBackNavigation(true);
mCurrentTracker.reset();
} else if (!mCurrentTracker.isFinished()) {
@@ -889,16 +999,17 @@ public class BackAnimationController implements RemoteCallable mShellExecutor.execute(this::onBackAnimationFinished));
if (mApps.length >= 1) {
- mCurrentTracker.updateStartLocation();
- BackMotionEvent startEvent = mCurrentTracker.createStartEvent(mApps[0]);
+ BackMotionEvent startEvent = mCurrentTracker.createStartEvent(
+ Flags.removeDepartTargetFromMotion() ? null : mApps[0]);
dispatchOnBackStarted(mActiveCallback, startEvent);
+ if (startEvent.getSwipeEdge() == EDGE_NONE) {
+ // TODO(b/373544911): onBackStarted is dispatched here so that
+ // WindowOnBackInvokedDispatcher knows about the back navigation and intercepts
+ // touch events while it's active. It would be cleaner and safer to disable
+ // multitouch altogether (same as in gesture-nav).
+ dispatchOnBackStarted(mBackNavigationInfo.getOnBackInvokedCallback(), startEvent);
+ }
+ }
+ }
+
+ private void requestTopUi(boolean hasTopUi, int backType) {
+ if (mRequestTopUiCallback != null && (backType == BackNavigationInfo.TYPE_CROSS_TASK
+ || backType == BackNavigationInfo.TYPE_CROSS_ACTIVITY)) {
+ mRequestTopUiCallback.requestTopUi(hasTopUi, TAG);
}
}
@@ -977,14 +1102,30 @@ public class BackAnimationController implements RemoteCallable up gesture happened before animation
+ // start, we have to trigger the uninterruptible transition
+ // to finish the back animation.
+ startPostCommitAnimation();
+ }
+ }
+
private void createAdapter() {
IBackAnimationRunner runner =
new IBackAnimationRunner.Stub() {
@Override
public void onAnimationStart(
RemoteAnimationTarget[] apps,
- RemoteAnimationTarget[] wallpapers,
- RemoteAnimationTarget[] nonApps,
+ IBinder token,
IBackAnimationFinishedCallback finishedCallback) {
mShellExecutor.execute(
() -> {
@@ -995,20 +1136,11 @@ public class BackAnimationController implements RemoteCallable up gesture happened before animation
- // start, we have to trigger the uninterruptible transition
- // to finish the back animation.
- startPostCommitAnimation();
+ // app only visible after transition ready, break for now.
+ if (token != null) {
+ return;
}
+ kickStartAnimation();
});
}
@@ -1031,12 +1163,30 @@ public class BackAnimationController implements RemoteCallable {
+ if (event.getKeyGestureType() == KeyGestureEvent.KEY_GESTURE_TYPE_BACK) {
+ mShellExecutor.execute(() -> {
+ if (mBackGestureStarted) {
+ Log.w(TAG, "Back gesture is running, ignore request");
+ return;
+ }
+ onMotionEvent(0, 0, KeyEvent.ACTION_DOWN, EDGE_NONE, INVALID_DISPLAY);
+ setTriggerBack(true);
+ onMotionEvent(0, 0, KeyEvent.ACTION_UP, EDGE_NONE, INVALID_DISPLAY);
+ });
+ } else {
+ Log.w(TAG, "Unsupported gesture " + event + " received!");
+ }
+ });
+ }
+
/**
* Description of current BackAnimationController state.
*/
private void dump(PrintWriter pw, String prefix) {
pw.println(prefix + "BackAnimationController state:");
- pw.println(prefix + " mEnableAnimations=" + mEnableAnimations.get());
pw.println(prefix + " mBackGestureStarted=" + mBackGestureStarted);
pw.println(prefix + " mPostCommitAnimationInProgress=" + mPostCommitAnimationInProgress);
pw.println(prefix + " mShouldStartOnNextMoveEvent=" + mShouldStartOnNextMoveEvent);
@@ -1048,4 +1198,684 @@ public class BackAnimationController implements RemoteCallable openSurfaces = new ArrayList<>();
+ int tmpSize;
+ for (int j = init.getChanges().size() - 1; j >= 0; --j) {
+ final TransitionInfo.Change change = init.getChanges().get(j);
+ if (change.hasFlags(FLAG_BACK_GESTURE_ANIMATED)
+ && TransitionUtil.isOpeningMode(change.getMode())) {
+ final ComponentName openComponent = findComponentName(change);
+ final int openTaskId = findTaskId(change);
+ final WindowContainerToken openToken = findToken(change);
+ if (openComponent == null && openTaskId == INVALID_TASK_ID
+ && openToken == null) {
+ continue;
+ }
+ openSurfaces.add(change.getLeash());
+ if (change.hasFlags(FLAG_SHOW_WALLPAPER)) {
+ openShowWallpaper = true;
+ }
+ }
+ }
+ if (openSurfaces.isEmpty()) {
+ // This shouldn't happen, but if that happen, consume the initial transition anyway.
+ Log.e(TAG, "Unable to merge following transition, cannot find the gesture "
+ + "animated target from the open transition=" + mOpenTransitionInfo);
+ mOpenTransitionInfo = null;
+ return;
+ }
+ // Find first non-prepare open target
+ boolean isOpen = false;
+ tmpSize = info.getChanges().size();
+ for (int j = 0; j < tmpSize; ++j) {
+ final TransitionInfo.Change change = info.getChanges().get(j);
+ if (isOpenSurfaceMatched(openSurfaces, change)) {
+ // This is original close target, potential be close, but cannot determine
+ // from it.
+ if (change.hasFlags(FLAG_BACK_GESTURE_ANIMATED)) {
+ isOpen = !TransitionUtil.isClosingMode(change.getMode());
+ } else {
+ isOpen = TransitionUtil.isOpeningMode(change.getMode());
+ break;
+ }
+ }
+ }
+ if (!isOpen) {
+ // Close transition, the transition info should be:
+ // init info(open A & wallpaper) => init info(open A & change B & wallpaper)
+ // current info(close B target) => current info(change A & close B)
+ // remove init info(open/change A target & wallpaper)
+ boolean moveToTop = false;
+ boolean excludeOpenTarget = false;
+ boolean mergePredictive = false;
+ for (int j = info.getChanges().size() - 1; j >= 0; --j) {
+ final TransitionInfo.Change change = info.getChanges().get(j);
+ if (isOpenSurfaceMatched(openSurfaces, change)) {
+ if (TransitionUtil.isClosingMode(change.getMode())) {
+ excludeOpenTarget = true;
+ }
+ moveToTop = change.hasFlags(FLAG_MOVED_TO_TOP);
+ info.getChanges().remove(j);
+ } else if ((openShowWallpaper && change.hasFlags(FLAG_IS_WALLPAPER))) {
+ info.getChanges().remove(j);
+ } else if (!mergePredictive && TransitionUtil.isClosingMode(change.getMode())) {
+ mergePredictive = true;
+ }
+ }
+ // Ignore merge if there is no close target
+ if (!info.getChanges().isEmpty() && mergePredictive) {
+ tmpSize = init.getChanges().size();
+ for (int i = 0; i < tmpSize; ++i) {
+ final TransitionInfo.Change change = init.getChanges().get(i);
+ if (change.hasFlags(FLAG_IS_WALLPAPER)) {
+ continue;
+ }
+ if (isOpenSurfaceMatched(openSurfaces, change)) {
+ if (excludeOpenTarget) {
+ // App has triggered another change during predictive back
+ // transition, filter out predictive back target.
+ continue;
+ }
+ if (moveToTop) {
+ change.setFlags(change.getFlags() | FLAG_MOVED_TO_TOP);
+ }
+ } else if (Flags.unifyBackNavigationTransition()
+ && change.hasFlags(FLAG_BACK_GESTURE_ANIMATED)
+ && change.getMode() == TRANSIT_CHANGE
+ && isCloseChangeExist(info, change)) {
+ // This is the original top target, don't add it into current transition
+ // if it is closing.
+ continue;
+ }
+ info.getChanges().add(i, change);
+ }
+ }
+ } else {
+ // Open transition, the transition info should be:
+ // init info(open A & wallpaper)
+ // current info(open C target + close B target + close A & wallpaper)
+
+ // If close target isn't back navigated, filter out close A & wallpaper because the
+ // (open C + close B) pair didn't participant prepare close
+ boolean nonBackOpen = false;
+ boolean nonBackClose = false;
+ tmpSize = info.getChanges().size();
+ for (int j = 0; j < tmpSize; ++j) {
+ final TransitionInfo.Change change = info.getChanges().get(j);
+ if (!change.hasFlags(FLAG_BACK_GESTURE_ANIMATED)
+ && canBeTransitionTarget(change)) {
+ final int mode = change.getMode();
+ nonBackOpen |= TransitionUtil.isOpeningMode(mode);
+ nonBackClose |= TransitionUtil.isClosingMode(mode);
+ }
+ }
+ if (nonBackClose && nonBackOpen) {
+ for (int j = info.getChanges().size() - 1; j >= 0; --j) {
+ final TransitionInfo.Change change = info.getChanges().get(j);
+ if (isOpenSurfaceMatched(openSurfaces, change)) {
+ info.getChanges().remove(j);
+ } else if ((openShowWallpaper && change.hasFlags(FLAG_IS_WALLPAPER))) {
+ info.getChanges().remove(j);
+ }
+ }
+ }
+ }
+ ProtoLog.d(WM_SHELL_BACK_PREVIEW, "Back animation transition, merge pending "
+ + "transitions result=%s", info);
+ // Only handle one merge transition request.
+ mOpenTransitionInfo = null;
+ }
+
+ @Override
+ public void mergeAnimation(@NonNull IBinder transition, @NonNull TransitionInfo info,
+ @NonNull SurfaceControl.Transaction startT,
+ @NonNull SurfaceControl.Transaction finishT,
+ @NonNull IBinder mergeTarget,
+ @NonNull Transitions.TransitionFinishCallback finishCallback) {
+ if (mClosePrepareTransition == transition) {
+ mClosePrepareTransition = null;
+ }
+ // try to handle unexpected transition
+ if (mOpenTransitionInfo != null) {
+ mergePendingTransitions(info);
+ }
+
+ if (info.getType() == TRANSIT_CLOSE_PREPARE_BACK_NAVIGATION
+ && !mCloseTransitionRequested && info.getChanges().isEmpty() && mApps == null) {
+ finishCallback.onTransitionFinished(null);
+ startT.apply();
+ applyFinishOpenTransition();
+ return;
+ }
+ if (isNotGestureBackTransition(info) || shouldCancelAnimation(info)
+ || !mCloseTransitionRequested) {
+ if (mPrepareOpenTransition != null) {
+ applyFinishOpenTransition();
+ }
+ return;
+ }
+ // Handle the commit transition if this handler is running the open transition.
+ finishCallback.onTransitionFinished(null);
+ startT.apply();
+ if (mCloseTransitionRequested) {
+ if (mApps == null || mApps.length == 0) {
+ // animation was done
+ applyFinishOpenTransition();
+ mCloseTransitionRequested = false;
+ } else {
+ // we are animating, wait until animation finish
+ mOnAnimationFinishCallback = () -> {
+ applyFinishOpenTransition();
+ mCloseTransitionRequested = false;
+ };
+ }
+ }
+ }
+
+ // Cancel close animation if something happen unexpected, let another handler to handle
+ private boolean shouldCancelAnimation(@NonNull TransitionInfo info) {
+ final boolean noCloseAllowed = !mCloseTransitionRequested
+ && info.getType() == WindowManager.TRANSIT_PREPARE_BACK_NAVIGATION;
+ boolean unableToHandle = false;
+ boolean filterTargets = false;
+ for (int i = info.getChanges().size() - 1; i >= 0; --i) {
+ final TransitionInfo.Change c = info.getChanges().get(i);
+ final boolean backGestureAnimated = c.hasFlags(FLAG_BACK_GESTURE_ANIMATED);
+ if (!backGestureAnimated && !c.hasFlags(FLAG_IS_WALLPAPER)) {
+ // something we cannot handle?
+ unableToHandle = true;
+ filterTargets = true;
+ } else if (noCloseAllowed && backGestureAnimated
+ && TransitionUtil.isClosingMode(c.getMode())) {
+ // Prepare back navigation shouldn't contain close change, unless top app
+ // request close.
+ unableToHandle = true;
+ }
+ }
+ if (!unableToHandle) {
+ return false;
+ }
+ if (!filterTargets) {
+ return true;
+ }
+ if (TransitionUtil.isOpeningType(info.getType())
+ || TransitionUtil.isClosingType(info.getType())) {
+ boolean removeWallpaper = false;
+ for (int i = info.getChanges().size() - 1; i >= 0; --i) {
+ final TransitionInfo.Change c = info.getChanges().get(i);
+ // filter out opening target, keep original closing target in this transition
+ if (c.hasFlags(FLAG_BACK_GESTURE_ANIMATED)
+ && TransitionUtil.isOpeningMode(c.getMode())) {
+ info.getChanges().remove(i);
+ removeWallpaper |= c.hasFlags(FLAG_SHOW_WALLPAPER);
+ }
+ }
+ if (removeWallpaper) {
+ for (int i = info.getChanges().size() - 1; i >= 0; --i) {
+ final TransitionInfo.Change c = info.getChanges().get(i);
+ if (c.hasFlags(FLAG_IS_WALLPAPER)) {
+ info.getChanges().remove(i);
+ }
+ }
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Check whether this transition is prepare for predictive back animation, which could
+ * happen when core make an activity become visible.
+ */
+ @VisibleForTesting
+ boolean handlePrepareTransition(@NonNull IBinder transition,
+ @NonNull TransitionInfo info,
+ @NonNull SurfaceControl.Transaction st,
+ @NonNull SurfaceControl.Transaction ft,
+ @NonNull Transitions.TransitionFinishCallback finishCallback) {
+ if (info.getType() != WindowManager.TRANSIT_PREPARE_BACK_NAVIGATION) {
+ return false;
+ }
+ // Must have open target, must not have close target.
+ if (hasAnimationInMode(info, TransitionUtil::isClosingMode)
+ || !hasAnimationInMode(info, TransitionUtil::isOpeningMode)) {
+ return false;
+ }
+ SurfaceControl openingLeash = null;
+ SurfaceControl closingLeash = null;
+ if (mApps != null) {
+ for (int i = mApps.length - 1; i >= 0; --i) {
+ if (mApps[i].mode == MODE_OPENING) {
+ openingLeash = mApps[i].leash;
+ } else if (mApps[i].mode == MODE_CLOSING) {
+ closingLeash = mApps[i].leash;
+ }
+ }
+ }
+ if (openingLeash != null && closingLeash != null) {
+ int rootIdx = -1;
+ for (int i = info.getChanges().size() - 1; i >= 0; --i) {
+ final TransitionInfo.Change c = info.getChanges().get(i);
+ if (TransitionUtil.isOpeningMode(c.getMode())) {
+ final Point offset = c.getEndRelOffset();
+ st.setPosition(c.getLeash(), offset.x, offset.y);
+ st.reparent(c.getLeash(), openingLeash);
+ st.setAlpha(c.getLeash(), 1.0f);
+ rootIdx = TransitionUtil.rootIndexFor(c, info);
+ } else if (c.hasFlags(FLAG_BACK_GESTURE_ANIMATED)
+ && c.getMode() == TRANSIT_CHANGE) {
+ st.reparent(c.getLeash(), closingLeash);
+ }
+ }
+ // The root leash and the leash of opening target should actually in the same level,
+ // but since the root leash is created after opening target, it will have higher
+ // layer in surface flinger. Move the root leash to lower level, so it won't affect
+ // the playing animation.
+ if (rootIdx >= 0 && info.getRootCount() > 0) {
+ st.setLayer(info.getRoot(rootIdx).getLeash(), -1);
+ }
+ }
+ st.apply();
+ // In case other transition handler took the handleRequest before this class.
+ mPrepareOpenTransition = transition;
+ mFinishOpenTransaction = ft;
+ mFinishOpenTransitionCallback = finishCallback;
+ mOpenTransitionInfo = info;
+ return true;
+ }
+
+ /**
+ * Check whether this transition is triggered from back gesture commitment.
+ * Reparent the transition targets to animation leashes, so the animation won't be broken.
+ */
+ @VisibleForTesting
+ boolean handleCloseTransition(@NonNull TransitionInfo info,
+ @NonNull SurfaceControl.Transaction st,
+ @NonNull SurfaceControl.Transaction ft,
+ @NonNull Transitions.TransitionFinishCallback finishCallback) {
+ if (!mCloseTransitionRequested) {
+ return false;
+ }
+ // must have close target
+ if (!hasAnimationInMode(info, TransitionUtil::isClosingMode)) {
+ return false;
+ }
+ if (mApps == null) {
+ // animation is done
+ applyAndFinish(st, ft, finishCallback);
+ return true;
+ }
+ SurfaceControl openingLeash = null;
+ SurfaceControl closingLeash = null;
+ for (int i = mApps.length - 1; i >= 0; --i) {
+ if (mApps[i].mode == MODE_OPENING) {
+ openingLeash = mApps[i].leash;
+ }
+ if (mApps[i].mode == MODE_CLOSING) {
+ closingLeash = mApps[i].leash;
+ }
+ }
+ if (openingLeash != null && closingLeash != null) {
+ for (int i = info.getChanges().size() - 1; i >= 0; --i) {
+ final TransitionInfo.Change c = info.getChanges().get(i);
+ if (c.hasFlags(FLAG_IS_WALLPAPER)) {
+ st.setAlpha(c.getLeash(), 1.0f);
+ continue;
+ }
+ if (TransitionUtil.isOpeningMode(c.getMode())) {
+ final Point offset = c.getEndRelOffset();
+ st.setPosition(c.getLeash(), offset.x, offset.y);
+ st.reparent(c.getLeash(), openingLeash);
+ st.setAlpha(c.getLeash(), 1.0f);
+ } else if (TransitionUtil.isClosingMode(c.getMode())) {
+ st.reparent(c.getLeash(), closingLeash);
+ }
+ }
+ }
+ st.apply();
+ // mApps must exists
+ mOnAnimationFinishCallback = () -> {
+ ft.apply();
+ finishCallback.onTransitionFinished(null);
+ mCloseTransitionRequested = false;
+ };
+ return true;
+ }
+
+ @Nullable
+ @Override
+ public WindowContainerTransaction handleRequest(
+ @NonNull IBinder transition,
+ @NonNull TransitionRequestInfo request) {
+ final int type = request.getType();
+ if (type == WindowManager.TRANSIT_PREPARE_BACK_NAVIGATION) {
+ mPrepareOpenTransition = transition;
+ return new WindowContainerTransaction();
+ }
+ if (type == WindowManager.TRANSIT_CLOSE_PREPARE_BACK_NAVIGATION) {
+ return new WindowContainerTransaction();
+ }
+ if (TransitionUtil.isClosingType(request.getType()) && mCloseTransitionRequested) {
+ return new WindowContainerTransaction();
+ }
+ return null;
+ }
+
+ private static boolean checkTakeoverFlags() {
+ return TransitionAnimator.Companion.longLivedReturnAnimationsEnabled()
+ && Flags.unifyBackNavigationTransition();
+ }
+ }
+
+ private static boolean isNotGestureBackTransition(@NonNull TransitionInfo info) {
+ return !hasAnimationInMode(info, TransitionUtil::isOpenOrCloseMode);
+ }
+
+ private static boolean hasAnimationInMode(@NonNull TransitionInfo info,
+ Predicate mode) {
+ for (int i = info.getChanges().size() - 1; i >= 0; --i) {
+ final TransitionInfo.Change c = info.getChanges().get(i);
+ if (c.hasFlags(FLAG_BACK_GESTURE_ANIMATED) && mode.test(c.getMode())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static WindowContainerToken findToken(TransitionInfo.Change change) {
+ return change.getContainer();
+ }
+
+ private static ComponentName findComponentName(TransitionInfo.Change change) {
+ final ComponentName componentName = change.getActivityComponent();
+ if (componentName != null) {
+ return componentName;
+ }
+ final TaskInfo taskInfo = change.getTaskInfo();
+ if (taskInfo != null) {
+ return taskInfo.topActivity;
+ }
+ return null;
+ }
+
+ private static int findTaskId(TransitionInfo.Change change) {
+ final TaskInfo taskInfo = change.getTaskInfo();
+ if (taskInfo != null) {
+ return taskInfo.taskId;
+ }
+ return INVALID_TASK_ID;
+ }
+
+ static boolean isOpenSurfaceMatched(@NonNull ArrayList openSurfaces,
+ TransitionInfo.Change change) {
+ for (int i = openSurfaces.size() - 1; i >= 0; --i) {
+ if (openSurfaces.get(i).isSameSurface(change.getLeash())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static boolean canBeTransitionTarget(TransitionInfo.Change change) {
+ return findComponentName(change) != null || findTaskId(change) != INVALID_TASK_ID;
+ }
+
+ private static boolean isCloseChangeExist(TransitionInfo info, TransitionInfo.Change change) {
+ for (int j = info.getChanges().size() - 1; j >= 0; --j) {
+ final TransitionInfo.Change current = info.getChanges().get(j);
+ if (TransitionUtil.isClosingMode(current.getMode())
+ && change.getLeash().isSameSurface(current.getLeash())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ // Record the latest back gesture happen on which task.
+ static class BackTransitionObserver implements Transitions.TransitionObserver {
+ int mFocusedTaskId = INVALID_TASK_ID;
+ IBinder mFocusTaskMonitorToken;
+ private BackTransitionHandler mBackTransitionHandler;
+ void setBackTransitionHandler(BackTransitionHandler handler) {
+ mBackTransitionHandler = handler;
+ }
+
+ void update(int focusedTaskId) {
+ mFocusedTaskId = focusedTaskId;
+ }
+
+ @Override
+ public void onTransitionReady(@NonNull IBinder transition, @NonNull TransitionInfo info,
+ @NonNull SurfaceControl.Transaction startTransaction,
+ @NonNull SurfaceControl.Transaction finishTransaction) {
+ if (mFocusedTaskId == INVALID_TASK_ID) {
+ return;
+ }
+ for (int i = info.getChanges().size() - 1; i >= 0; --i) {
+ final TransitionInfo.Change c = info.getChanges().get(i);
+ if (c.getTaskInfo() != null && c.getTaskInfo().taskId == mFocusedTaskId) {
+ mFocusTaskMonitorToken = transition;
+ break;
+ }
+ }
+ // Transition happen but the task isn't involved, reset.
+ if (mFocusTaskMonitorToken == null) {
+ mFocusedTaskId = INVALID_TASK_ID;
+ }
+ }
+
+ @Override
+ public void onTransitionMerged(@NonNull IBinder merged, @NonNull IBinder playing) {
+ if (mFocusTaskMonitorToken == merged) {
+ mFocusTaskMonitorToken = playing;
+ }
+ if (mBackTransitionHandler.mClosePrepareTransition == merged) {
+ mBackTransitionHandler.mClosePrepareTransition = null;
+ }
+ }
+
+ @Override
+ public void onTransitionFinished(@NonNull IBinder transition, boolean aborted) {
+ if (mFocusTaskMonitorToken == transition) {
+ mFocusedTaskId = INVALID_TASK_ID;
+ }
+ if (mBackTransitionHandler.mClosePrepareTransition == transition) {
+ mBackTransitionHandler.mClosePrepareTransition = null;
+ }
+ }
+ }
}
diff --git a/wmshell/src/com/android/wm/shell/back/BackAnimationRunner.java b/wmshell/src/com/android/wm/shell/back/BackAnimationRunner.java
index 4988a9481d..b9fccc1c41 100644
--- a/wmshell/src/com/android/wm/shell/back/BackAnimationRunner.java
+++ b/wmshell/src/com/android/wm/shell/back/BackAnimationRunner.java
@@ -20,17 +20,22 @@ import static android.view.WindowManager.TRANSIT_OLD_UNSET;
import android.annotation.NonNull;
import android.content.Context;
+import android.os.Handler;
import android.os.RemoteException;
import android.util.Log;
import android.view.IRemoteAnimationFinishedCallback;
import android.view.IRemoteAnimationRunner;
import android.view.RemoteAnimationTarget;
+import android.view.SurfaceControl;
import android.window.IBackAnimationRunner;
import android.window.IOnBackInvokedCallback;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.jank.Cuj.CujType;
-import com.android.wm.shell.common.InteractionJankMonitorUtils;
+import com.android.internal.jank.InteractionJankMonitor;
+import com.android.wm.shell.shared.annotations.ShellMainThread;
+
+import java.lang.ref.WeakReference;
/**
* Used to register the animation callback and runner, it will trigger result if gesture was finish
@@ -45,6 +50,8 @@ public class BackAnimationRunner {
private final IRemoteAnimationRunner mRunner;
private final @CujType int mCujType;
private final Context mContext;
+ @ShellMainThread
+ private final Handler mHandler;
// Whether we are waiting to receive onAnimationStart
private boolean mWaitingAnimation;
@@ -56,18 +63,35 @@ public class BackAnimationRunner {
@NonNull IOnBackInvokedCallback callback,
@NonNull IRemoteAnimationRunner runner,
@NonNull Context context,
- @CujType int cujType) {
+ @CujType int cujType,
+ @ShellMainThread Handler handler) {
mCallback = callback;
mRunner = runner;
mCujType = cujType;
mContext = context;
+ mHandler = handler;
}
public BackAnimationRunner(
@NonNull IOnBackInvokedCallback callback,
@NonNull IRemoteAnimationRunner runner,
- @NonNull Context context) {
- this(callback, runner, context, NO_CUJ);
+ @NonNull Context context,
+ @ShellMainThread Handler handler
+ ) {
+ this(callback, runner, context, NO_CUJ, handler);
+ }
+
+ /**
+ * @deprecated Use {@link BackAnimationRunner} constructor providing an handler for the ui
+ * thread of the animation.
+ */
+ @Deprecated
+ public BackAnimationRunner(
+ @NonNull IOnBackInvokedCallback callback,
+ @NonNull IRemoteAnimationRunner runner,
+ @NonNull Context context
+ ) {
+ this(callback, runner, context, NO_CUJ, context.getMainThreadHandler());
}
/** Returns the registered animation runner */
@@ -80,35 +104,95 @@ public class BackAnimationRunner {
return mCallback;
}
+ private Runnable mFinishedCallback;
+ private RemoteAnimationTarget[] mApps;
+ private RemoteAnimationFinishedStub mRemoteCallback;
+
+ private static class RemoteAnimationFinishedStub extends IRemoteAnimationFinishedCallback.Stub {
+ //the binder callback should not hold strong reference to it to avoid memory leak.
+ private final WeakReference mRunnerRef;
+ private boolean mAbandoned;
+
+ private RemoteAnimationFinishedStub(BackAnimationRunner runner) {
+ mRunnerRef = new WeakReference<>(runner);
+ }
+
+ @Override
+ public void onAnimationFinished() {
+ synchronized (this) {
+ if (mAbandoned) {
+ return;
+ }
+ }
+ final BackAnimationRunner runner = mRunnerRef.get();
+ if (runner == null) {
+ return;
+ }
+ runner.onAnimationFinish(this);
+ }
+
+ void abandon() {
+ synchronized (this) {
+ mAbandoned = true;
+ final BackAnimationRunner runner = mRunnerRef.get();
+ if (runner == null) {
+ return;
+ }
+ if (runner.shouldMonitorCUJ(runner.mApps)) {
+ InteractionJankMonitor.getInstance().end(runner.mCujType);
+ }
+ }
+ }
+ }
+
/**
* Called from {@link IBackAnimationRunner}, it will deliver these
* {@link RemoteAnimationTarget}s to the corresponding runner.
*/
void startAnimation(RemoteAnimationTarget[] apps, RemoteAnimationTarget[] wallpapers,
RemoteAnimationTarget[] nonApps, Runnable finishedCallback) {
- final IRemoteAnimationFinishedCallback callback =
- new IRemoteAnimationFinishedCallback.Stub() {
- @Override
- public void onAnimationFinished() {
- if (shouldMonitorCUJ(apps)) {
- InteractionJankMonitorUtils.endTracing(mCujType);
- }
- finishedCallback.run();
- }
- };
+ if (mRemoteCallback != null) {
+ mRemoteCallback.abandon();
+ mRemoteCallback = null;
+ }
+ mRemoteCallback = new RemoteAnimationFinishedStub(this);
+ mFinishedCallback = finishedCallback;
+ mApps = apps;
mWaitingAnimation = false;
if (shouldMonitorCUJ(apps)) {
- InteractionJankMonitorUtils.beginTracing(
- mCujType, mContext, apps[0].leash, /* tag */ null);
+ InteractionJankMonitor.getInstance().begin(
+ apps[0].leash, mContext, mHandler, mCujType);
}
try {
getRunner().onAnimationStart(TRANSIT_OLD_UNSET, apps, wallpapers,
- nonApps, callback);
+ nonApps, mRemoteCallback);
} catch (RemoteException e) {
Log.w(TAG, "Failed call onAnimationStart", e);
}
}
+ void onAnimationFinish(RemoteAnimationFinishedStub finished) {
+ mHandler.post(() -> {
+ if (mRemoteCallback != null && finished != mRemoteCallback) {
+ return;
+ }
+ if (shouldMonitorCUJ(mApps)) {
+ InteractionJankMonitor.getInstance().end(mCujType);
+ }
+
+ mFinishedCallback.run();
+ for (int i = mApps.length - 1; i >= 0; --i) {
+ final SurfaceControl sc = mApps[i].leash;
+ if (sc != null && sc.isValid()) {
+ sc.release();
+ }
+ }
+ mApps = null;
+ mFinishedCallback = null;
+ mRemoteCallback = null;
+ });
+ }
+
@VisibleForTesting
boolean shouldMonitorCUJ(RemoteAnimationTarget[] apps) {
return apps.length > 0 && mCujType != NO_CUJ;
diff --git a/wmshell/src/com/android/wm/shell/back/CrossActivityBackAnimation.kt b/wmshell/src/com/android/wm/shell/back/CrossActivityBackAnimation.kt
index 169e122c35..349b27dacd 100644
--- a/wmshell/src/com/android/wm/shell/back/CrossActivityBackAnimation.kt
+++ b/wmshell/src/com/android/wm/shell/back/CrossActivityBackAnimation.kt
@@ -26,6 +26,7 @@ import android.graphics.Matrix
import android.graphics.PointF
import android.graphics.Rect
import android.graphics.RectF
+import android.os.Handler
import android.os.RemoteException
import android.util.TimeUtils
import android.view.Choreographer
@@ -38,9 +39,12 @@ import android.view.animation.DecelerateInterpolator
import android.view.animation.Interpolator
import android.view.animation.Transformation
import android.window.BackEvent
+import android.window.BackEvent.EDGE_LEFT
+import android.window.BackEvent.EDGE_RIGHT
import android.window.BackMotionEvent
import android.window.BackNavigationInfo
import android.window.BackProgressAnimator
+import android.window.DesktopExperienceFlags
import android.window.IOnBackInvokedCallback
import com.android.internal.dynamicanimation.animation.FloatValueHolder
import com.android.internal.dynamicanimation.animation.SpringAnimation
@@ -48,11 +52,13 @@ import com.android.internal.dynamicanimation.animation.SpringForce
import com.android.internal.jank.Cuj
import com.android.internal.policy.ScreenDecorationsUtils
import com.android.internal.policy.SystemBarUtils
-import com.android.internal.protolog.common.ProtoLog
+import com.android.internal.protolog.ProtoLog
+import com.android.window.flags.Flags.predictiveBackTimestampApi
import com.android.wm.shell.R
import com.android.wm.shell.RootTaskDisplayAreaOrganizer
-import com.android.wm.shell.animation.Interpolators
import com.android.wm.shell.protolog.ShellProtoLogGroup
+import com.android.wm.shell.shared.animation.Interpolators
+import com.android.wm.shell.shared.annotations.ShellMainThread
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
@@ -61,7 +67,8 @@ abstract class CrossActivityBackAnimation(
private val context: Context,
private val background: BackAnimationBackground,
private val rootTaskDisplayAreaOrganizer: RootTaskDisplayAreaOrganizer,
- protected val transaction: SurfaceControl.Transaction
+ protected val transaction: SurfaceControl.Transaction,
+ @ShellMainThread handler: Handler,
) : ShellBackAnimation() {
protected val startClosingRect = RectF()
@@ -80,7 +87,13 @@ abstract class CrossActivityBackAnimation(
private var statusbarHeight = SystemBarUtils.getStatusBarHeight(context)
private val backAnimationRunner =
- BackAnimationRunner(Callback(), Runner(), context, Cuj.CUJ_PREDICTIVE_BACK_CROSS_ACTIVITY)
+ BackAnimationRunner(
+ Callback(),
+ Runner(),
+ context,
+ Cuj.CUJ_PREDICTIVE_BACK_CROSS_ACTIVITY,
+ handler,
+ )
private val initialTouchPos = PointF()
private val transformMatrix = Matrix()
private val tmpFloat9 = FloatArray(9)
@@ -109,7 +122,9 @@ abstract class CrossActivityBackAnimation(
private val postCommitFlingSpring = SpringForce(SPRING_SCALE)
.setStiffness(SpringForce.STIFFNESS_LOW)
.setDampingRatio(SpringForce.DAMPING_RATIO_LOW_BOUNCY)
+ private var swipeEdge = EDGE_LEFT
protected var gestureProgress = 0f
+ private val velocityTracker = ProgressVelocityTracker()
/** Background color to be used during the animation, also see [getBackgroundColor] */
protected var customizedBackgroundColor = 0
@@ -166,11 +181,12 @@ abstract class CrossActivityBackAnimation(
)
return
}
+ swipeEdge = backMotionEvent.swipeEdge
triggerBack = backMotionEvent.triggerBack
initialTouchPos.set(backMotionEvent.touchX, backMotionEvent.touchY)
transaction.setAnimationTransaction()
- isLetterboxed = closingTarget!!.taskInfo.appCompatTaskInfo.topActivityBoundsLetterboxed
+ isLetterboxed = closingTarget!!.taskInfo.appCompatTaskInfo.isTopActivityLetterboxed
enteringHasSameLetterbox =
isLetterboxed && closingTarget!!.localBounds.equals(enteringTarget!!.localBounds)
@@ -189,10 +205,14 @@ abstract class CrossActivityBackAnimation(
preparePreCommitEnteringRectMovement()
background.ensureBackground(
- closingTarget!!.windowConfiguration.bounds,
- getBackgroundColor(),
- transaction,
- statusbarHeight
+ closingTarget!!.windowConfiguration.bounds,
+ getBackgroundColor(),
+ transaction,
+ statusbarHeight,
+ if (closingTarget!!.windowConfiguration.tasksAreFloating())
+ closingTarget!!.localBounds else null,
+ cornerRadius,
+ closingTarget!!.taskInfo.getDisplayId()
)
ensureScrimLayer()
if (isLetterboxed && enteringHasSameLetterbox) {
@@ -229,6 +249,9 @@ abstract class CrossActivityBackAnimation(
)
applyTransaction()
background.customizeStatusBarAppearance(currentClosingRect.top.toInt())
+ if (predictiveBackTimestampApi()) {
+ velocityTracker.addPosition(backEvent.frameTimeMillis, progress)
+ }
}
private fun getYOffset(centeredRect: RectF, touchY: Float): Float {
@@ -260,10 +283,19 @@ abstract class CrossActivityBackAnimation(
// kick off spring animation with the current velocity from the pre-commit phase, this
// affects the scaling of the closing and/or opening activity during post-commit
- val startVelocity =
- if (gestureProgress < 0.1f) -DEFAULT_FLING_VELOCITY else -velocity * SPRING_SCALE
+
+ var startVelocity = if (predictiveBackTimestampApi()) {
+ // pronounce fling animation more for gestures
+ val velocityFactor = if (swipeEdge == EDGE_LEFT || swipeEdge == EDGE_RIGHT) 2f else 1f
+ velocity * SPRING_SCALE * (1f - MAX_SCALE) * velocityFactor
+ } else {
+ velocity * SPRING_SCALE
+ }
+ if (gestureProgress < 0.1f) {
+ startVelocity = startVelocity.coerceAtLeast(DEFAULT_FLING_VELOCITY)
+ }
val flingAnimation = SpringAnimation(postCommitFlingScale, SPRING_SCALE)
- .setStartVelocity(startVelocity.coerceIn(-MAX_FLING_VELOCITY, 0f))
+ .setStartVelocity(-startVelocity.coerceIn(0f, MAX_FLING_VELOCITY))
.setStartValue(SPRING_SCALE)
.setSpring(postCommitFlingSpring)
flingAnimation.start()
@@ -326,6 +358,7 @@ abstract class CrossActivityBackAnimation(
lastPostCommitFlingScale = SPRING_SCALE
gestureProgress = 0f
triggerBack = false
+ velocityTracker.resetTracking()
}
protected fun applyTransform(
@@ -378,7 +411,12 @@ abstract class CrossActivityBackAnimation(
.setOpaque(false)
.setHidden(false)
- rootTaskDisplayAreaOrganizer.attachToDisplayArea(Display.DEFAULT_DISPLAY, scrimBuilder)
+ if (DesktopExperienceFlags.ENABLE_MULTIDISPLAY_TRACKPAD_BACK_GESTURE.isTrue()) {
+ rootTaskDisplayAreaOrganizer.attachToDisplayArea(
+ closingTarget!!.taskInfo.getDisplayId(), scrimBuilder)
+ } else {
+ rootTaskDisplayAreaOrganizer.attachToDisplayArea(Display.DEFAULT_DISPLAY, scrimBuilder)
+ }
scrimLayer = scrimBuilder.build()
val colorComponents = floatArrayOf(0f, 0f, 0f)
maxScrimAlpha = if (isDarkTheme) MAX_SCRIM_ALPHA_DARK else MAX_SCRIM_ALPHA_LIGHT
@@ -442,7 +480,13 @@ abstract class CrossActivityBackAnimation(
.setOpaque(true)
.setHidden(false)
- rootTaskDisplayAreaOrganizer.attachToDisplayArea(Display.DEFAULT_DISPLAY, letterboxBuilder)
+ if (DesktopExperienceFlags.ENABLE_MULTIDISPLAY_TRACKPAD_BACK_GESTURE.isTrue()) {
+ rootTaskDisplayAreaOrganizer.attachToDisplayArea(
+ closingTarget!!.taskInfo.getDisplayId(), letterboxBuilder)
+ } else {
+ rootTaskDisplayAreaOrganizer.attachToDisplayArea(
+ Display.DEFAULT_DISPLAY, letterboxBuilder)
+ }
val layer = letterboxBuilder.build()
val colorComponents =
floatArrayOf(
@@ -508,7 +552,11 @@ abstract class CrossActivityBackAnimation(
override fun onBackInvoked() {
triggerBack = true
progressAnimator.reset()
- onGestureCommitted(progressAnimator.velocity)
+ if (predictiveBackTimestampApi()) {
+ onGestureCommitted(velocityTracker.calculateVelocity())
+ } else {
+ onGestureCommitted(progressAnimator.velocity)
+ }
}
}
diff --git a/wmshell/src/com/android/wm/shell/back/CrossTaskBackAnimation.java b/wmshell/src/com/android/wm/shell/back/CrossTaskBackAnimation.java
index 381914a58c..f5b0e359e0 100644
--- a/wmshell/src/com/android/wm/shell/back/CrossTaskBackAnimation.java
+++ b/wmshell/src/com/android/wm/shell/back/CrossTaskBackAnimation.java
@@ -21,7 +21,9 @@ import static android.view.RemoteAnimationTarget.MODE_OPENING;
import static android.window.BackEvent.EDGE_RIGHT;
import static com.android.internal.jank.InteractionJankMonitor.CUJ_PREDICTIVE_BACK_CROSS_TASK;
+import static com.android.window.flags.Flags.predictiveBackTimestampApi;
import static com.android.wm.shell.back.BackAnimationConstants.UPDATE_SYSUI_FLAGS_THRESHOLD;
+import static com.android.wm.shell.back.CrossActivityBackAnimationKt.scaleCentered;
import static com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_BACK_PREVIEW;
import android.animation.Animator;
@@ -34,7 +36,9 @@ import android.graphics.Matrix;
import android.graphics.PointF;
import android.graphics.Rect;
import android.graphics.RectF;
+import android.os.Handler;
import android.os.RemoteException;
+import android.util.TimeUtils;
import android.view.Choreographer;
import android.view.IRemoteAnimationFinishedCallback;
import android.view.IRemoteAnimationRunner;
@@ -47,11 +51,14 @@ import android.window.BackMotionEvent;
import android.window.BackProgressAnimator;
import android.window.IOnBackInvokedCallback;
+import com.android.internal.dynamicanimation.animation.FloatValueHolder;
+import com.android.internal.dynamicanimation.animation.SpringAnimation;
+import com.android.internal.dynamicanimation.animation.SpringForce;
import com.android.internal.policy.ScreenDecorationsUtils;
import com.android.internal.policy.SystemBarUtils;
-import com.android.internal.protolog.common.ProtoLog;
+import com.android.internal.protolog.ProtoLog;
import com.android.wm.shell.R;
-import com.android.wm.shell.animation.Interpolators;
+import com.android.wm.shell.shared.animation.Interpolators;
import com.android.wm.shell.shared.annotations.ShellMainThread;
import javax.inject.Inject;
@@ -69,7 +76,6 @@ import javax.inject.Inject;
* IOnBackInvokedCallback} with WM Shell and receives back dispatches when a back navigation to
* launcher starts.
*/
-@ShellMainThread
public class CrossTaskBackAnimation extends ShellBackAnimation {
private static final int BACKGROUNDCOLOR = 0x43433A;
@@ -81,6 +87,11 @@ public class CrossTaskBackAnimation extends ShellBackAnimation {
/** Duration of post animation after gesture committed. */
private static final int POST_ANIMATION_DURATION_MS = 500;
+ private static final float SPRING_SCALE = 100f;
+ private static final float DEFAULT_FLING_VELOCITY = 320f;
+ private static final float MAX_FLING_VELOCITY = 1000f;
+ private static final float FLING_SPRING_STIFFNESS = 320f;
+
private final Rect mStartTaskRect = new Rect();
private float mCornerRadius;
private int mStatusbarHeight;
@@ -114,10 +125,18 @@ public class CrossTaskBackAnimation extends ShellBackAnimation {
private float mInterWindowMargin;
private float mVerticalMargin;
+ private final FloatValueHolder mPostCommitFlingScale = new FloatValueHolder(SPRING_SCALE);
+ private final SpringForce mPostCommitFlingSpring = new SpringForce(SPRING_SCALE)
+ .setStiffness(FLING_SPRING_STIFFNESS)
+ .setDampingRatio(1f);
+ private final ProgressVelocityTracker mVelocityTracker = new ProgressVelocityTracker();
+ private float mGestureProgress = 0f;
+
@Inject
- public CrossTaskBackAnimation(Context context, BackAnimationBackground background) {
+ public CrossTaskBackAnimation(Context context, BackAnimationBackground background,
+ @ShellMainThread Handler handler) {
mBackAnimationRunner = new BackAnimationRunner(
- new Callback(), new Runner(), context, CUJ_PREDICTIVE_BACK_CROSS_TASK);
+ new Callback(), new Runner(), context, CUJ_PREDICTIVE_BACK_CROSS_TASK, handler);
mBackground = background;
mContext = context;
loadResources();
@@ -156,7 +175,8 @@ public class CrossTaskBackAnimation extends ShellBackAnimation {
// Draw background.
mBackground.ensureBackground(mClosingTarget.windowConfiguration.getBounds(),
- BACKGROUNDCOLOR, mTransaction, mStatusbarHeight);
+ BACKGROUNDCOLOR, mTransaction, mStatusbarHeight,
+ mClosingTarget.taskInfo.getDisplayId());
mInterWindowMargin = mContext.getResources()
.getDimension(R.dimen.cross_task_back_inter_window_margin);
mVerticalMargin = mContext.getResources()
@@ -167,6 +187,7 @@ public class CrossTaskBackAnimation extends ShellBackAnimation {
if (mEnteringTarget == null || mClosingTarget == null) {
return;
}
+ mGestureProgress = progress;
float touchY = event.getTouchY();
@@ -228,6 +249,8 @@ public class CrossTaskBackAnimation extends ShellBackAnimation {
}
mClosingCurrentRect.set(left, top, left + width, top + height);
+
+ applyFlingScale(mClosingCurrentRect);
applyTransform(mClosingTarget.leash, mClosingCurrentRect, mCornerRadius);
}
@@ -238,9 +261,19 @@ public class CrossTaskBackAnimation extends ShellBackAnimation {
float height = mapRange(progress, mEnteringStartRect.height(), mStartTaskRect.height());
mEnteringCurrentRect.set(left, top, left + width, top + height);
+
+ applyFlingScale(mEnteringCurrentRect);
applyTransform(mEnteringTarget.leash, mEnteringCurrentRect, mCornerRadius);
}
+ private void applyFlingScale(RectF rect) {
+ // apply a scale to the rect to account for fling velocity
+ final float flingScale = Math.min(mPostCommitFlingScale.getValue() / SPRING_SCALE, 1f);
+ if (flingScale >= 1f) return;
+ scaleCentered(rect, flingScale, /* pivotX */ rect.right,
+ /* pivotY */ rect.top + rect.height() / 2);
+ }
+
/** Transform the target window to match the target rect. */
private void applyTransform(SurfaceControl leash, RectF targetRect, float cornerRadius) {
if (leash == null || !leash.isValid()) {
@@ -279,6 +312,8 @@ public class CrossTaskBackAnimation extends ShellBackAnimation {
mTransformMatrix.reset();
mClosingCurrentRect.setEmpty();
mInitialTouchPos.set(0, 0);
+ mGestureProgress = 0;
+ mVelocityTracker.resetTracking();
if (mFinishCallback != null) {
try {
@@ -297,7 +332,12 @@ public class CrossTaskBackAnimation extends ShellBackAnimation {
}
float progress = backEvent.getProgress();
mTouchPos.set(backEvent.getTouchX(), backEvent.getTouchY());
- updateGestureBackProgress(getInterpolatedProgress(progress), backEvent);
+ float interpolatedProgress = getInterpolatedProgress(progress);
+ if (predictiveBackTimestampApi()) {
+ mVelocityTracker.addPosition(backEvent.getFrameTimeMillis(),
+ interpolatedProgress * SPRING_SCALE);
+ }
+ updateGestureBackProgress(interpolatedProgress, backEvent);
}
private void onGestureCommitted() {
@@ -306,6 +346,24 @@ public class CrossTaskBackAnimation extends ShellBackAnimation {
return;
}
+ if (predictiveBackTimestampApi()) {
+ // kick off spring animation with the current velocity from the pre-commit phase, this
+ // affects the scaling of the closing and/or opening task during post-commit
+ float startVelocity = mGestureProgress < 0.1f
+ ? -DEFAULT_FLING_VELOCITY : -mVelocityTracker.calculateVelocity();
+ SpringAnimation flingAnimation =
+ new SpringAnimation(mPostCommitFlingScale, SPRING_SCALE)
+ .setStartVelocity(Math.max(-MAX_FLING_VELOCITY, Math.min(0f, startVelocity)))
+ .setStartValue(SPRING_SCALE)
+ .setMinimumVisibleChange(0.1f)
+ .setSpring(mPostCommitFlingSpring);
+ flingAnimation.start();
+ // do an animation-frame immediately to prevent idle frame
+ flingAnimation.doAnimationFrame(
+ Choreographer.getInstance().getLastFrameTimeNanos() / TimeUtils.NANOS_PER_MS
+ );
+ }
+
// We enter phase 2 of the animation, the starting coordinates for phase 2 are the current
// coordinate of the gesture driven phase.
mEnteringCurrentRect.round(mEnteringStartRect);
diff --git a/wmshell/src/com/android/wm/shell/back/CustomCrossActivityBackAnimation.kt b/wmshell/src/com/android/wm/shell/back/CustomCrossActivityBackAnimation.kt
index 9ebab63834..2f7666b218 100644
--- a/wmshell/src/com/android/wm/shell/back/CustomCrossActivityBackAnimation.kt
+++ b/wmshell/src/com/android/wm/shell/back/CustomCrossActivityBackAnimation.kt
@@ -18,6 +18,7 @@ package com.android.wm.shell.back
import android.content.Context
import android.graphics.Rect
import android.graphics.RectF
+import android.os.Handler
import android.util.MathUtils
import android.view.SurfaceControl
import android.view.animation.Animation
@@ -27,9 +28,10 @@ import android.window.BackMotionEvent
import android.window.BackNavigationInfo
import com.android.internal.R
import com.android.internal.policy.TransitionAnimation
-import com.android.internal.protolog.common.ProtoLog
+import com.android.internal.protolog.ProtoLog
import com.android.wm.shell.RootTaskDisplayAreaOrganizer
import com.android.wm.shell.protolog.ShellProtoLogGroup
+import com.android.wm.shell.shared.annotations.ShellMainThread
import javax.inject.Inject
import kotlin.math.max
import kotlin.math.min
@@ -40,13 +42,15 @@ class CustomCrossActivityBackAnimation(
background: BackAnimationBackground,
rootTaskDisplayAreaOrganizer: RootTaskDisplayAreaOrganizer,
transaction: SurfaceControl.Transaction,
- private val customAnimationLoader: CustomAnimationLoader
+ private val customAnimationLoader: CustomAnimationLoader,
+ @ShellMainThread handler: Handler,
) :
CrossActivityBackAnimation(
context,
background,
rootTaskDisplayAreaOrganizer,
- transaction
+ transaction,
+ handler
) {
private var enterAnimation: Animation? = null
@@ -59,7 +63,8 @@ class CustomCrossActivityBackAnimation(
constructor(
context: Context,
background: BackAnimationBackground,
- rootTaskDisplayAreaOrganizer: RootTaskDisplayAreaOrganizer
+ rootTaskDisplayAreaOrganizer: RootTaskDisplayAreaOrganizer,
+ @ShellMainThread handler: Handler,
) : this(
context,
background,
@@ -67,7 +72,8 @@ class CustomCrossActivityBackAnimation(
SurfaceControl.Transaction(),
CustomAnimationLoader(
TransitionAnimation(context, false /* debug */, "CustomCrossActivityBackAnimation")
- )
+ ),
+ handler,
)
override fun preparePreCommitClosingRectMovement(swipeEdge: Int) {
diff --git a/wmshell/src/com/android/wm/shell/back/DefaultCrossActivityBackAnimation.kt b/wmshell/src/com/android/wm/shell/back/DefaultCrossActivityBackAnimation.kt
index c747e1e989..eecd769400 100644
--- a/wmshell/src/com/android/wm/shell/back/DefaultCrossActivityBackAnimation.kt
+++ b/wmshell/src/com/android/wm/shell/back/DefaultCrossActivityBackAnimation.kt
@@ -16,11 +16,13 @@
package com.android.wm.shell.back
import android.content.Context
+import android.os.Handler
import android.view.SurfaceControl
import android.window.BackEvent
import com.android.wm.shell.R
import com.android.wm.shell.RootTaskDisplayAreaOrganizer
-import com.android.wm.shell.animation.Interpolators
+import com.android.wm.shell.shared.animation.Interpolators
+import com.android.wm.shell.shared.annotations.ShellMainThread
import javax.inject.Inject
import kotlin.math.max
@@ -30,13 +32,15 @@ class DefaultCrossActivityBackAnimation
constructor(
context: Context,
background: BackAnimationBackground,
- rootTaskDisplayAreaOrganizer: RootTaskDisplayAreaOrganizer
+ rootTaskDisplayAreaOrganizer: RootTaskDisplayAreaOrganizer,
+ @ShellMainThread handler: Handler,
) :
CrossActivityBackAnimation(
context,
background,
rootTaskDisplayAreaOrganizer,
- SurfaceControl.Transaction()
+ SurfaceControl.Transaction(),
+ handler
) {
private val postCommitInterpolator = Interpolators.EMPHASIZED
diff --git a/wmshell/src/com/android/wm/shell/back/ProgressVelocityTracker.kt b/wmshell/src/com/android/wm/shell/back/ProgressVelocityTracker.kt
new file mode 100644
index 0000000000..6bbda0fd7b
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/back/ProgressVelocityTracker.kt
@@ -0,0 +1,50 @@
+/*
+ * 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.back
+
+import android.view.MotionEvent
+import android.view.VelocityTracker
+
+internal class ProgressVelocityTracker {
+ private val velocityTracker: VelocityTracker = VelocityTracker.obtain()
+ private var downTime = -1L
+
+ fun addPosition(timeMillis: Long, position: Float) {
+ if (downTime == -1L) downTime = timeMillis
+ velocityTracker.addMovement(
+ MotionEvent.obtain(
+ /* downTime */ downTime,
+ /* eventTime */ timeMillis,
+ /* action */ MotionEvent.ACTION_MOVE,
+ /* x */ position,
+ /* y */ 0f,
+ /* metaState */0
+ )
+ )
+ }
+
+ /** calculates current velocity (unit: progress per second) */
+ fun calculateVelocity(): Float {
+ velocityTracker.computeCurrentVelocity(1000)
+ return velocityTracker.xVelocity
+ }
+
+ fun resetTracking() {
+ velocityTracker.clear()
+ downTime = -1L
+ }
+}
\ No newline at end of file
diff --git a/wmshell/src/com/android/wm/shell/back/ShellBackAnimationRegistry.java b/wmshell/src/com/android/wm/shell/back/ShellBackAnimationRegistry.java
index 6fafa75e2f..ae2c7b3adb 100644
--- a/wmshell/src/com/android/wm/shell/back/ShellBackAnimationRegistry.java
+++ b/wmshell/src/com/android/wm/shell/back/ShellBackAnimationRegistry.java
@@ -23,6 +23,8 @@ import android.util.Log;
import android.util.SparseArray;
import android.window.BackNavigationInfo;
+import java.util.ArrayList;
+
/** Registry for all types of default back animations */
public class ShellBackAnimationRegistry {
private static final String TAG = "ShellBackPreview";
@@ -31,6 +33,8 @@ public class ShellBackAnimationRegistry {
private ShellBackAnimation mDefaultCrossActivityAnimation;
private final ShellBackAnimation mCustomizeActivityAnimation;
private final ShellBackAnimation mCrossTaskAnimation;
+ private boolean mSupportedAnimatorsChanged = false;
+ private final ArrayList mSupportedAnimators = new ArrayList<>();
public ShellBackAnimationRegistry(
@ShellBackAnimation.CrossActivity @Nullable ShellBackAnimation crossActivityAnimation,
@@ -60,7 +64,7 @@ public class ShellBackAnimationRegistry {
mDefaultCrossActivityAnimation = crossActivityAnimation;
mCustomizeActivityAnimation = customizeActivityAnimation;
mCrossTaskAnimation = crossTaskAnimation;
-
+ updateSupportedAnimators();
// TODO(b/236760237): register dialog close animation when it's completed.
}
@@ -71,6 +75,7 @@ public class ShellBackAnimationRegistry {
if (BackNavigationInfo.TYPE_CROSS_ACTIVITY == type) {
mDefaultCrossActivityAnimation = null;
}
+ updateSupportedAnimators();
}
void unregisterAnimation(@BackNavigationInfo.BackTargetType int type) {
@@ -79,6 +84,24 @@ public class ShellBackAnimationRegistry {
if (BackNavigationInfo.TYPE_CROSS_ACTIVITY == type) {
mDefaultCrossActivityAnimation = null;
}
+ updateSupportedAnimators();
+ }
+
+ private void updateSupportedAnimators() {
+ mSupportedAnimators.clear();
+ for (int i = mAnimationDefinition.size() - 1; i >= 0; --i) {
+ mSupportedAnimators.add(mAnimationDefinition.keyAt(i));
+ }
+ mSupportedAnimatorsChanged = true;
+ }
+
+ boolean hasSupportedAnimatorsChanged() {
+ return mSupportedAnimatorsChanged;
+ }
+
+ ArrayList getSupportedAnimators() {
+ mSupportedAnimatorsChanged = false;
+ return mSupportedAnimators;
}
/**
diff --git a/wmshell/src/com/android/wm/shell/back/TEST_MAPPING b/wmshell/src/com/android/wm/shell/back/TEST_MAPPING
index f02559f361..df3a369feb 100644
--- a/wmshell/src/com/android/wm/shell/back/TEST_MAPPING
+++ b/wmshell/src/com/android/wm/shell/back/TEST_MAPPING
@@ -1,32 +1,10 @@
{
"presubmit": [
{
- "name": "WMShellUnitTests",
- "options": [
- {
- "exclude-annotation": "androidx.test.filters.FlakyTest"
- },
- {
- "include-filter": "com.android.wm.shell.back"
- }
- ]
+ "name": "WMShellUnitTests_shell_back"
},
{
- "name": "CtsWindowManagerDeviceBackNavigation",
- "options": [
- {
- "exclude-annotation": "androidx.test.filters.FlakyTest"
- },
- {
- "include-filter": "android.server.wm.backnavigation.BackGestureInvokedTest"
- },
- {
- "include-filter": "android.server.wm.backnavigation.BackNavigationTests"
- },
- {
- "include-filter": "android.server.wm.backnavigation.OnBackInvokedCallbackGestureTest"
- }
- ]
+ "name": "CtsWindowManagerDeviceBackNavigation_com_android_wm_shell_back"
}
]
}
diff --git a/wmshell/src/com/android/wm/shell/bubbles/BadgedImageView.java b/wmshell/src/com/android/wm/shell/bubbles/BadgedImageView.java
index f9a1d940c7..0f1bf5e097 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/BadgedImageView.java
+++ b/wmshell/src/com/android/wm/shell/bubbles/BadgedImageView.java
@@ -15,6 +15,8 @@
*/
package com.android.wm.shell.bubbles;
+import static com.android.launcher3.icons.IconNormalizer.ICON_VISIBLE_AREA_FACTOR;
+
import android.annotation.DrawableRes;
import android.annotation.Nullable;
import android.content.Context;
@@ -35,9 +37,8 @@ import android.widget.ImageView;
import androidx.constraintlayout.widget.ConstraintLayout;
import com.android.launcher3.icons.DotRenderer;
-import com.android.launcher3.icons.IconNormalizer;
import com.android.wm.shell.R;
-import com.android.wm.shell.animation.Interpolators;
+import com.android.wm.shell.shared.animation.Interpolators;
import java.util.EnumSet;
@@ -132,7 +133,7 @@ public class BadgedImageView extends ConstraintLayout {
private void getOutline(Outline outline) {
final int bubbleSize = mPositioner.getBubbleSize();
- final int normalizedSize = IconNormalizer.getNormalizedCircleSize(bubbleSize);
+ final int normalizedSize = Math.round(ICON_VISIBLE_AREA_FACTOR * bubbleSize);
final int inset = (bubbleSize - normalizedSize) / 2;
outline.setOval(inset, inset, inset + normalizedSize, inset + normalizedSize);
}
@@ -357,7 +358,9 @@ public class BadgedImageView extends ConstraintLayout {
void showBadge() {
Bitmap appBadgeBitmap = mBubble.getAppBadge();
- if (appBadgeBitmap == null) {
+ final boolean showAppBadge = (mBubble instanceof Bubble)
+ && ((Bubble) mBubble).showAppBadge();
+ if (appBadgeBitmap == null || !showAppBadge) {
mAppIcon.setVisibility(GONE);
return;
}
diff --git a/wmshell/src/com/android/wm/shell/bubbles/Bubble.java b/wmshell/src/com/android/wm/shell/bubbles/Bubble.java
index 1279fc42c0..8cc30ee804 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/Bubble.java
+++ b/wmshell/src/com/android/wm/shell/bubbles/Bubble.java
@@ -16,10 +16,10 @@
package com.android.wm.shell.bubbles;
import static android.app.ActivityTaskManager.INVALID_TASK_ID;
-import static android.os.AsyncTask.Status.FINISHED;
import static com.android.internal.annotations.VisibleForTesting.Visibility.PRIVATE;
import static com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_BUBBLES;
+import static com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_BUBBLES_NOISY;
import android.annotation.DimenRes;
import android.annotation.Hide;
@@ -28,6 +28,7 @@ import android.annotation.Nullable;
import android.app.Notification;
import android.app.PendingIntent;
import android.app.Person;
+import android.app.TaskInfo;
import android.content.Context;
import android.content.Intent;
import android.content.LocusId;
@@ -39,6 +40,7 @@ import android.graphics.Bitmap;
import android.graphics.Path;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.Icon;
+import android.os.IBinder;
import android.os.Parcelable;
import android.os.UserHandle;
import android.provider.Settings;
@@ -48,11 +50,17 @@ import android.util.Log;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.logging.InstanceId;
-import com.android.internal.protolog.common.ProtoLog;
+import com.android.internal.protolog.ProtoLog;
import com.android.launcher3.icons.BubbleIconFactory;
+import com.android.wm.shell.bubbles.appinfo.BubbleAppInfoProvider;
import com.android.wm.shell.bubbles.bar.BubbleBarExpandedView;
import com.android.wm.shell.bubbles.bar.BubbleBarLayerView;
-import com.android.wm.shell.common.bubbles.BubbleInfo;
+import com.android.wm.shell.common.ComponentUtils;
+import com.android.wm.shell.shared.annotations.ShellBackgroundThread;
+import com.android.wm.shell.shared.annotations.ShellMainThread;
+import com.android.wm.shell.shared.bubbles.BubbleInfo;
+import com.android.wm.shell.shared.bubbles.ParcelableFlyoutMessage;
+import com.android.wm.shell.taskview.TaskView;
import java.io.PrintWriter;
import java.util.List;
@@ -65,19 +73,36 @@ import java.util.concurrent.Executor;
public class Bubble implements BubbleViewProvider {
private static final String TAG = "Bubble";
- /** A string suffix used in app bubbles' {@link #mKey}. */
+ /** A string prefix used in app bubbles' {@link #mKey}. */
public static final String KEY_APP_BUBBLE = "key_app_bubble";
- /** Whether the bubble is an app bubble. */
- private final boolean mIsAppBubble;
+ /** A string prefix used in note bubbles' {@link #mKey}. */
+ public static final String KEY_NOTE_BUBBLE = "key_note_bubble";
+
+ /** The possible types a bubble may be. */
+ public enum BubbleType {
+ /** Chat is from a notification. */
+ TYPE_CHAT,
+ /** Notes are from the note taking API. */
+ TYPE_NOTE,
+ /** Shortcuts from bubble anything, based on {@link ShortcutInfo}. */
+ TYPE_SHORTCUT,
+ /** Apps are from bubble anything. */
+ TYPE_APP,
+ }
+
+ private final BubbleType mType;
private final String mKey;
@Nullable
private final String mGroupKey;
@Nullable
private final LocusId mLocusId;
+ @Nullable
+ private IBinder mClientToken;
private final Executor mMainExecutor;
+ private final Executor mBgExecutor;
private long mLastUpdated;
private long mLastAccessed;
@@ -110,6 +135,7 @@ public class Bubble implements BubbleViewProvider {
@Nullable
private BubbleTaskView mBubbleTaskView;
+ @Nullable
private BubbleViewInfoTask mInflationTask;
private boolean mInflateSynchronously;
private boolean mPendingIntentCanceled;
@@ -175,10 +201,10 @@ public class Bubble implements BubbleViewProvider {
* that bubble being added back to the stack anyways.
*/
@Nullable
- private PendingIntent mIntent;
- private boolean mIntentActive;
+ private PendingIntent mPendingIntent;
+ private boolean mPendingIntentActive;
@Nullable
- private PendingIntent.CancelListener mIntentCancelListener;
+ private PendingIntent.CancelListener mPendingIntentCancelListener;
/**
* Sent when the bubble & notification are no longer visible to the user (i.e. no
@@ -188,21 +214,27 @@ public class Bubble implements BubbleViewProvider {
private PendingIntent mDeleteIntent;
/**
- * Used only for a special bubble in the stack that has {@link #mIsAppBubble} set to true.
- * There can only be one of these bubbles in the stack and this intent will be populated for
- * that bubble.
+ * Used for app & note bubbles.
*/
@Nullable
- private Intent mAppIntent;
+ private Intent mIntent;
+
+ /**
+ * Set while preparing a transition for animation. Several steps are needed before animation
+ * starts, so this is used to detect and route associated events to the coordinating transition.
+ */
+ @Nullable
+ private BubbleTransitions.BubbleTransition mPreparingTransition;
/**
* Create a bubble with limited information based on given {@link ShortcutInfo}.
* Note: Currently this is only being used when the bubble is persisted to disk.
*/
- @VisibleForTesting(visibility = PRIVATE)
public Bubble(@NonNull final String key, @NonNull final ShortcutInfo shortcutInfo,
final int desiredHeight, final int desiredHeightResId, @Nullable final String title,
- int taskId, @Nullable final String locus, boolean isDismissable, Executor mainExecutor,
+ int taskId, @Nullable final String locus, boolean isDismissable,
+ @ShellMainThread Executor mainExecutor,
+ @ShellBackgroundThread Executor bgExecutor,
final Bubbles.BubbleMetadataFlagListener listener) {
Objects.requireNonNull(key);
Objects.requireNonNull(shortcutInfo);
@@ -221,46 +253,163 @@ public class Bubble implements BubbleViewProvider {
mTitle = title;
mShowBubbleUpdateDot = false;
mMainExecutor = mainExecutor;
+ mBgExecutor = bgExecutor;
mTaskId = taskId;
mBubbleMetadataFlagListener = listener;
- mIsAppBubble = false;
+ // TODO (b/394085999) read/write type to xml
+ mType = BubbleType.TYPE_CHAT;
}
private Bubble(
Intent intent,
UserHandle user,
@Nullable Icon icon,
- boolean isAppBubble,
+ BubbleType type,
String key,
- Executor mainExecutor) {
+ @ShellMainThread Executor mainExecutor,
+ @ShellBackgroundThread Executor bgExecutor) {
mGroupKey = null;
mLocusId = null;
mFlags = 0;
mUser = user;
mIcon = icon;
- mIsAppBubble = isAppBubble;
+ mType = type;
mKey = key;
mShowBubbleUpdateDot = false;
mMainExecutor = mainExecutor;
+ mBgExecutor = bgExecutor;
mTaskId = INVALID_TASK_ID;
- mAppIntent = intent;
+ mIntent = intent;
mDesiredHeight = Integer.MAX_VALUE;
mPackageName = intent.getPackage();
-
}
- /** Creates an app bubble. */
- public static Bubble createAppBubble(
- Intent intent,
+ private Bubble(
+ PendingIntent intent,
+ UserHandle user,
+ String key,
+ @ShellMainThread Executor mainExecutor,
+ @ShellBackgroundThread Executor bgExecutor) {
+ mGroupKey = null;
+ mLocusId = null;
+ mFlags = 0;
+ mUser = user;
+ mIcon = null;
+ mType = BubbleType.TYPE_APP;
+ mKey = key;
+ mShowBubbleUpdateDot = false;
+ mMainExecutor = mainExecutor;
+ mBgExecutor = bgExecutor;
+ mTaskId = INVALID_TASK_ID;
+ mPendingIntent = intent;
+ mIntent = null;
+ mDesiredHeight = Integer.MAX_VALUE;
+ mPackageName = ComponentUtils.getPackageName(intent);
+ }
+
+ private Bubble(ShortcutInfo info, @ShellMainThread Executor mainExecutor,
+ @ShellBackgroundThread Executor bgExecutor) {
+ mGroupKey = null;
+ mLocusId = null;
+ mFlags = 0;
+ mUser = info.getUserHandle();
+ mIcon = info.getIcon();
+ mType = BubbleType.TYPE_SHORTCUT;
+ mKey = getBubbleKeyForShortcut(info);
+ mShowBubbleUpdateDot = false;
+ mMainExecutor = mainExecutor;
+ mBgExecutor = bgExecutor;
+ mTaskId = INVALID_TASK_ID;
+ mIntent = null;
+ mDesiredHeight = Integer.MAX_VALUE;
+ mPackageName = info.getPackage();
+ mShortcutInfo = info;
+ }
+
+ private Bubble(
+ TaskInfo task,
UserHandle user,
@Nullable Icon icon,
- Executor mainExecutor) {
+ String key,
+ @ShellMainThread Executor mainExecutor,
+ @ShellBackgroundThread Executor bgExecutor) {
+ mGroupKey = null;
+ mLocusId = null;
+ mFlags = 0;
+ mUser = user;
+ mIcon = icon;
+ mType = BubbleType.TYPE_APP;
+ mKey = key;
+ mShowBubbleUpdateDot = false;
+ mMainExecutor = mainExecutor;
+ mBgExecutor = bgExecutor;
+ mTaskId = task.taskId;
+ mIntent = task.baseIntent;
+ mDesiredHeight = Integer.MAX_VALUE;
+ mPackageName = task.baseActivity.getPackageName();
+ }
+
+ /** Creates a note taking bubble. */
+ public static Bubble createNotesBubble(Intent intent, UserHandle user, @Nullable Icon icon,
+ @ShellMainThread Executor mainExecutor, @ShellBackgroundThread Executor bgExecutor) {
return new Bubble(intent,
user,
icon,
- /* isAppBubble= */ true,
- /* key= */ getAppBubbleKeyForApp(intent.getPackage(), user),
- mainExecutor);
+ BubbleType.TYPE_NOTE,
+ getNoteBubbleKeyForApp(intent.getPackage(), user),
+ mainExecutor, bgExecutor);
+ }
+
+ /** Creates an app bubble. */
+ public static Bubble createAppBubble(PendingIntent intent, UserHandle user,
+ @ShellMainThread Executor mainExecutor, @ShellBackgroundThread Executor bgExecutor) {
+ return new Bubble(intent,
+ user,
+ /* key= */ getAppBubbleKeyForApp(ComponentUtils.getPackageName(intent), user),
+ mainExecutor, bgExecutor);
+ }
+
+ /** Creates an app bubble. */
+ public static Bubble createAppBubble(Intent intent, UserHandle user, @Nullable Icon icon,
+ @ShellMainThread Executor mainExecutor, @ShellBackgroundThread Executor bgExecutor) {
+ return new Bubble(intent,
+ user,
+ icon,
+ BubbleType.TYPE_APP,
+ getAppBubbleKeyForApp(ComponentUtils.getPackageName(intent), user),
+ mainExecutor, bgExecutor);
+ }
+
+ /** Creates an app bubble that can be controlled by a client. */
+ public static Bubble createClientControlledAppBubble(Intent intent, UserHandle user,
+ @Nullable Icon icon, IBinder clientToken, @ShellMainThread Executor mainExecutor,
+ @ShellBackgroundThread Executor bgExecutor) {
+ Bubble b = new Bubble(intent,
+ user,
+ icon,
+ // TODO(b/407149510): Consider using a dedicated type.
+ BubbleType.TYPE_APP,
+ getAppBubbleKeyForApp(ComponentUtils.getPackageName(intent), user),
+ mainExecutor, bgExecutor);
+ b.mClientToken = clientToken;
+ return b;
+ }
+
+ /** Creates a task bubble. */
+ public static Bubble createTaskBubble(TaskInfo info, UserHandle user, @Nullable Icon icon,
+ @ShellMainThread Executor mainExecutor, @ShellBackgroundThread Executor bgExecutor) {
+ return new Bubble(info,
+ user,
+ icon,
+ getAppBubbleKeyForTask(info),
+ mainExecutor, bgExecutor);
+ }
+
+ /** Creates a shortcut bubble. */
+ public static Bubble createShortcutBubble(
+ ShortcutInfo info,
+ @ShellMainThread Executor mainExecutor, @ShellBackgroundThread Executor bgExecutor) {
+ return new Bubble(info, mainExecutor, bgExecutor);
}
/**
@@ -273,25 +422,56 @@ public class Bubble implements BubbleViewProvider {
return KEY_APP_BUBBLE + ":" + user.getIdentifier() + ":" + packageName;
}
+ /**
+ * Returns the key for a note bubble from an app with package name, {@code packageName} on an
+ * Android user, {@code user}.
+ */
+ public static String getNoteBubbleKeyForApp(String packageName, UserHandle user) {
+ Objects.requireNonNull(packageName);
+ Objects.requireNonNull(user);
+ return KEY_NOTE_BUBBLE + ":" + user.getIdentifier() + ":" + packageName;
+ }
+
+ /**
+ * Returns the key for a shortcut bubble using {@code packageName}, {@code user}, and the
+ * {@code shortcutInfo} id.
+ */
+ public static String getBubbleKeyForShortcut(ShortcutInfo info) {
+ return info.getPackage() + ":" + info.getUserId() + ":" + info.getId();
+ }
+
+ /**
+ * Returns the key for an app bubble from an app with package name, {@code packageName} on an
+ * Android user, {@code user}.
+ */
+ public static String getAppBubbleKeyForTask(TaskInfo taskInfo) {
+ Objects.requireNonNull(taskInfo);
+ return KEY_APP_BUBBLE + ":" + taskInfo.taskId;
+ }
+
+ /**
+ * Creates a chat bubble based on a notification (contents of {@link BubbleEntry}.
+ */
@VisibleForTesting(visibility = PRIVATE)
public Bubble(@NonNull final BubbleEntry entry,
final Bubbles.BubbleMetadataFlagListener listener,
final Bubbles.PendingIntentCanceledListener intentCancelListener,
- Executor mainExecutor) {
- mIsAppBubble = false;
+ @ShellMainThread Executor mainExecutor, @ShellBackgroundThread Executor bgExecutor) {
+ mType = BubbleType.TYPE_CHAT;
mKey = entry.getKey();
mGroupKey = entry.getGroupKey();
mLocusId = entry.getLocusId();
mBubbleMetadataFlagListener = listener;
- mIntentCancelListener = intent -> {
- if (mIntent != null) {
- mIntent.unregisterCancelListener(mIntentCancelListener);
+ mPendingIntentCancelListener = intent -> {
+ if (mPendingIntent != null) {
+ mPendingIntent.unregisterCancelListener(mPendingIntentCancelListener);
}
mainExecutor.execute(() -> {
intentCancelListener.onPendingIntentCanceled(this);
});
};
mMainExecutor = mainExecutor;
+ mBgExecutor = bgExecutor;
mTaskId = INVALID_TASK_ID;
setEntry(entry);
}
@@ -306,7 +486,23 @@ public class Bubble implements BubbleViewProvider {
getPackageName(),
getTitle(),
getAppName(),
- isImportantConversation());
+ isImportantConversation(),
+ showAppBadge(),
+ getParcelableFlyoutMessage());
+ }
+
+ /** Creates a parcelable flyout message to send to launcher. */
+ @Nullable
+ private ParcelableFlyoutMessage getParcelableFlyoutMessage() {
+ if (mFlyoutMessage == null) {
+ return null;
+ }
+ // the icon is only used in group chats
+ Icon icon = mFlyoutMessage.isGroupChat ? mFlyoutMessage.senderIcon : null;
+ String title =
+ mFlyoutMessage.senderName == null ? null : mFlyoutMessage.senderName.toString();
+ String message = mFlyoutMessage.message == null ? null : mFlyoutMessage.message.toString();
+ return new ParcelableFlyoutMessage(icon, title, message);
}
@Override
@@ -398,6 +594,11 @@ public class Bubble implements BubbleViewProvider {
return mTitle;
}
+ @Nullable
+ public IBinder getClientToken() {
+ return mClientToken;
+ }
+
/**
* Returns the existing {@link #mBubbleTaskView} if it's not {@code null}. Otherwise a new
* instance of {@link BubbleTaskView} is created.
@@ -409,6 +610,10 @@ public class Bubble implements BubbleViewProvider {
return mBubbleTaskView;
}
+ public TaskView getTaskView() {
+ return mBubbleTaskView.getTaskView();
+ }
+
/**
* @return the ShortcutInfo id if it exists, or the metadata shortcut id otherwise.
*/
@@ -426,6 +631,11 @@ public class Bubble implements BubbleViewProvider {
return (mMetadataShortcutId != null && !mMetadataShortcutId.isEmpty());
}
+ @Nullable
+ public BubbleTransitions.BubbleTransition getPreparingTransition() {
+ return mPreparingTransition;
+ }
+
/**
* Call this to clean up the task for the bubble. Ensure this is always called when done with
* the bubble.
@@ -446,17 +656,19 @@ public class Bubble implements BubbleViewProvider {
if (cleanupTaskView) {
cleanupTaskView();
}
- if (mIntent != null) {
- mIntent.unregisterCancelListener(mIntentCancelListener);
+ if (mPendingIntent != null) {
+ mPendingIntent.unregisterCancelListener(mPendingIntentCancelListener);
}
- mIntentActive = false;
+ mPendingIntentActive = false;
}
- private void cleanupTaskView() {
+ /** Cleans-up the taskview associated with this bubble (possibly removing the task from wm) */
+ public void cleanupTaskView() {
if (mBubbleTaskView != null) {
mBubbleTaskView.cleanup();
mBubbleTaskView = null;
}
+ mTaskId = INVALID_TASK_ID;
}
/**
@@ -473,7 +685,7 @@ public class Bubble implements BubbleViewProvider {
* If we're switching between bar and floating modes, pass {@code false} on
* {@code cleanupTaskView} to avoid recreating it in the new mode.
*/
- void cleanupViews(boolean cleanupTaskView) {
+ public void cleanupViews(boolean cleanupTaskView) {
cleanupExpandedView(cleanupTaskView);
mIconView = null;
}
@@ -495,6 +707,21 @@ public class Bubble implements BubbleViewProvider {
mInflateSynchronously = inflateSynchronously;
}
+ /**
+ * Sets the current bubble-transition that is coordinating a change in this bubble.
+ */
+ @VisibleForTesting
+ public void setPreparingTransition(BubbleTransitions.BubbleTransition transit) {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "setPreparingTransition: transit=%s", transit);
+ mPreparingTransition = transit;
+ }
+
+ /** Whether this bubble is currently converting to bubble bar. */
+ public boolean isConvertingToBar() {
+ return getPreparingTransition() != null
+ && getPreparingTransition().isConvertingBubbleToBar();
+ }
+
/**
* Sets whether this bubble is considered text changed. This method is purely for
* testing.
@@ -524,9 +751,11 @@ public class Bubble implements BubbleViewProvider {
@Nullable BubbleStackView stackView,
@Nullable BubbleBarLayerView layerView,
BubbleIconFactory iconFactory,
+ BubbleAppInfoProvider appInfoProvider,
boolean skipInflation) {
- if (isBubbleLoading()) {
- mInflationTask.cancel(true /* mayInterruptIfRunning */);
+ ProtoLog.v(WM_SHELL_BUBBLES, "Inflate bubble key=%s", getKey());
+ if (mInflationTask != null && !mInflationTask.isFinished()) {
+ mInflationTask.cancel();
}
mInflationTask = new BubbleViewInfoTask(this,
context,
@@ -536,20 +765,18 @@ public class Bubble implements BubbleViewProvider {
stackView,
layerView,
iconFactory,
+ appInfoProvider,
skipInflation,
callback,
- mMainExecutor);
+ mMainExecutor,
+ mBgExecutor);
if (mInflateSynchronously) {
- mInflationTask.onPostExecute(mInflationTask.doInBackground());
+ mInflationTask.startSync();
} else {
- mInflationTask.execute();
+ mInflationTask.start();
}
}
- private boolean isBubbleLoading() {
- return mInflationTask != null && mInflationTask.getStatus() != FINISHED;
- }
-
boolean isInflated() {
return (mIconView != null && mExpandedView != null) || mBubbleBarExpandedView != null;
}
@@ -558,7 +785,7 @@ public class Bubble implements BubbleViewProvider {
if (mInflationTask == null) {
return;
}
- mInflationTask.cancel(true /* mayInterruptIfRunning */);
+ mInflationTask.cancel();
}
void setViewInfo(BubbleViewInfoTask.BubbleViewInfo info) {
@@ -639,19 +866,19 @@ public class Bubble implements BubbleViewProvider {
mDesiredHeightResId = entry.getBubbleMetadata().getDesiredHeightResId();
mIcon = entry.getBubbleMetadata().getIcon();
- if (!mIntentActive || mIntent == null) {
- if (mIntent != null) {
- mIntent.unregisterCancelListener(mIntentCancelListener);
+ if (!mPendingIntentActive || mPendingIntent == null) {
+ if (mPendingIntent != null) {
+ mPendingIntent.unregisterCancelListener(mPendingIntentCancelListener);
}
- mIntent = entry.getBubbleMetadata().getIntent();
- if (mIntent != null) {
- mIntent.registerCancelListener(mIntentCancelListener);
+ mPendingIntent = entry.getBubbleMetadata().getIntent();
+ if (mPendingIntent != null) {
+ mPendingIntent.registerCancelListener(mPendingIntentCancelListener);
}
- } else if (mIntent != null && entry.getBubbleMetadata().getIntent() == null) {
+ } else if (mPendingIntent != null && entry.getBubbleMetadata().getIntent() == null) {
// Was an intent bubble now it's a shortcut bubble... still unregister the listener
- mIntent.unregisterCancelListener(mIntentCancelListener);
- mIntentActive = false;
- mIntent = null;
+ mPendingIntent.unregisterCancelListener(mPendingIntentCancelListener);
+ mPendingIntentActive = false;
+ mPendingIntent = null;
}
mDeleteIntent = entry.getBubbleMetadata().getDeleteIntent();
}
@@ -691,12 +918,15 @@ public class Bubble implements BubbleViewProvider {
* Sets if the intent used for this bubble is currently active (i.e. populating an
* expanded view, expanded or not).
*/
- void setIntentActive() {
- mIntentActive = true;
+ void setPendingIntentActive() {
+ mPendingIntentActive = true;
}
- boolean isIntentActive() {
- return mIntentActive;
+ /**
+ * Whether the pending intent of this bubble is active (i.e. has been sent).
+ */
+ boolean isPendingIntentActive() {
+ return mPendingIntentActive;
}
public InstanceId getInstanceId() {
@@ -768,13 +998,6 @@ public class Bubble implements BubbleViewProvider {
return mIsImportantConversation;
}
- /**
- * Whether this bubble is conversation
- */
- public boolean isConversation() {
- return null != mShortcutInfo;
- }
-
/**
* Sets whether this notification should be suppressed in the shade.
*/
@@ -856,6 +1079,15 @@ public class Bubble implements BubbleViewProvider {
return mFlyoutMessage;
}
+ /**
+ * Sets the flyout message directly. Only used from {@link BubbleMultitaskingDelegate} to show
+ * fly-outs for special app-controlled bubbles. Normally the messages should come from
+ * notifications instead, so this shouldn't be used in most cases.
+ */
+ void setFlyoutMessage(FlyoutMessage newMessage) {
+ mFlyoutMessage = newMessage;
+ }
+
int getRawDesiredHeight() {
return mDesiredHeight;
}
@@ -883,26 +1115,70 @@ public class Bubble implements BubbleViewProvider {
}
}
+ /**
+ * Returns the pending intent used to populate the bubble.
+ */
@Nullable
- PendingIntent getBubbleIntent() {
- return mIntent;
+ PendingIntent getPendingIntent() {
+ return mPendingIntent;
}
+ /**
+ * Whether an app badge should be shown for this bubble.
+ */
+ public boolean showAppBadge() {
+ return isChat() || isShortcut() || isNote();
+ }
+
+ /**
+ * Returns the pending intent to send when a bubble is dismissed (set via the notification API).
+ */
@Nullable
PendingIntent getDeleteIntent() {
return mDeleteIntent;
}
+ /**
+ * Returns the intent used to populate the bubble.
+ */
@Nullable
- Intent getAppBubbleIntent() {
- return mAppIntent;
+ public Intent getIntent() {
+ return mIntent;
}
/**
- * Returns whether this bubble is from an app versus a notification.
+ * Sets the intent used to populate the bubble.
*/
- public boolean isAppBubble() {
- return mIsAppBubble;
+ void setIntent(Intent intent) {
+ mIntent = intent;
+ }
+
+ /**
+ * Returns whether this bubble is a conversation from the notification API.
+ */
+ public boolean isChat() {
+ return mType == BubbleType.TYPE_CHAT;
+ }
+
+ /**
+ * Returns whether this bubble is a note from the note taking API.
+ */
+ public boolean isNote() {
+ return mType == BubbleType.TYPE_NOTE;
+ }
+
+ /**
+ * Returns whether this bubble is a shortcut.
+ */
+ public boolean isShortcut() {
+ return mType == BubbleType.TYPE_SHORTCUT;
+ }
+
+ /**
+ * Returns whether this bubble is an app.
+ */
+ public boolean isApp() {
+ return mType == BubbleType.TYPE_APP;
}
/** Creates open app settings intent */
@@ -1020,9 +1296,14 @@ public class Bubble implements BubbleViewProvider {
pw.print(" autoExpand: "); pw.println(shouldAutoExpand());
pw.print(" isDismissable: "); pw.println(mIsDismissable);
pw.println(" bubbleMetadataFlagListener null?: " + (mBubbleMetadataFlagListener == null));
+ pw.println(" preparingTransition null?: " + (mPreparingTransition == null));
+ pw.println(" isConvertingToBar: " + isConvertingToBar());
if (mExpandedView != null) {
mExpandedView.dump(pw, " ");
}
+ if (mBubbleBarExpandedView != null) {
+ mBubbleBarExpandedView.dump(pw, " ");
+ }
}
@Override
diff --git a/wmshell/src/com/android/wm/shell/bubbles/BubbleController.java b/wmshell/src/com/android/wm/shell/bubbles/BubbleController.java
index 33474091dd..c00fb64d7e 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/BubbleController.java
+++ b/wmshell/src/com/android/wm/shell/bubbles/BubbleController.java
@@ -16,9 +16,11 @@
package com.android.wm.shell.bubbles;
+import static android.app.WindowConfiguration.WINDOWING_MODE_MULTI_WINDOW;
import static android.service.notification.NotificationListenerService.NOTIFICATION_CHANNEL_OR_GROUP_DELETED;
import static android.service.notification.NotificationListenerService.NOTIFICATION_CHANNEL_OR_GROUP_UPDATED;
import static android.service.notification.NotificationListenerService.REASON_CANCEL;
+import static android.view.Display.INVALID_DISPLAY;
import static android.view.View.INVISIBLE;
import static android.view.View.VISIBLE;
import static android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS;
@@ -35,20 +37,23 @@ import static com.android.wm.shell.bubbles.Bubbles.DISMISS_PACKAGE_REMOVED;
import static com.android.wm.shell.bubbles.Bubbles.DISMISS_SHORTCUT_REMOVED;
import static com.android.wm.shell.bubbles.Bubbles.DISMISS_USER_CHANGED;
import static com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_BUBBLES;
-import static com.android.wm.shell.sysui.ShellSharedConstants.KEY_EXTRA_SHELL_BUBBLES;
+import static com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_BUBBLES_NOISY;
+import static com.android.wm.shell.transition.Transitions.TRANSIT_BUBBLE_CONVERT_FLOATING_TO_BAR;
import android.annotation.BinderThread;
import android.annotation.NonNull;
import android.annotation.UserIdInt;
import android.app.ActivityManager;
+import android.app.ActivityOptions;
+import android.app.ActivityTaskManager;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.PendingIntent;
+import android.app.TaskInfo;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
-import android.content.pm.ActivityInfo;
import android.content.pm.LauncherApps;
import android.content.pm.PackageManager;
import android.content.pm.ShortcutInfo;
@@ -61,6 +66,7 @@ import android.graphics.drawable.Icon;
import android.os.Binder;
import android.os.Bundle;
import android.os.Handler;
+import android.os.IBinder;
import android.os.RemoteException;
import android.os.ServiceManager;
import android.os.UserHandle;
@@ -69,6 +75,7 @@ import android.service.notification.NotificationListenerService;
import android.service.notification.NotificationListenerService.RankingMap;
import android.util.Log;
import android.util.Pair;
+import android.util.Slog;
import android.util.SparseArray;
import android.view.IWindowManager;
import android.view.SurfaceControl;
@@ -77,50 +84,66 @@ import android.view.ViewGroup;
import android.view.ViewRootImpl;
import android.view.WindowInsets;
import android.view.WindowManager;
+import android.window.IMultitaskingController;
+import android.window.IMultitaskingControllerCallback;
import android.window.ScreenCapture;
import android.window.ScreenCapture.SynchronousScreenCaptureListener;
+import android.window.TaskOrganizer;
+import android.window.TransitionInfo;
+import android.window.WindowContainerToken;
+import android.window.WindowContainerTransaction;
import androidx.annotation.MainThread;
import androidx.annotation.Nullable;
import com.android.internal.annotations.VisibleForTesting;
-import com.android.internal.protolog.common.ProtoLog;
+import com.android.internal.protolog.ProtoLog;
import com.android.internal.statusbar.IStatusBarService;
import com.android.internal.util.CollectionUtils;
import com.android.launcher3.icons.BubbleIconFactory;
import com.android.wm.shell.Flags;
import com.android.wm.shell.R;
import com.android.wm.shell.ShellTaskOrganizer;
-import com.android.wm.shell.WindowManagerShellWrapper;
+import com.android.wm.shell.bubbles.appinfo.BubbleAppInfoProvider;
import com.android.wm.shell.bubbles.bar.BubbleBarLayerView;
-import com.android.wm.shell.bubbles.properties.BubbleProperties;
import com.android.wm.shell.bubbles.shortcut.BubbleShortcutHelper;
import com.android.wm.shell.common.DisplayController;
+import com.android.wm.shell.common.DisplayImeController;
+import com.android.wm.shell.common.DisplayInsetsController;
import com.android.wm.shell.common.ExternalInterfaceBinder;
import com.android.wm.shell.common.FloatingContentCoordinator;
+import com.android.wm.shell.common.HomeIntentProvider;
+import com.android.wm.shell.common.ImeListener;
import com.android.wm.shell.common.RemoteCallable;
import com.android.wm.shell.common.ShellExecutor;
import com.android.wm.shell.common.SingleInstanceRemoteListener;
import com.android.wm.shell.common.SyncTransactionQueue;
-import com.android.wm.shell.common.TaskStackListenerCallback;
import com.android.wm.shell.common.TaskStackListenerImpl;
-import com.android.wm.shell.common.bubbles.BubbleBarLocation;
-import com.android.wm.shell.common.bubbles.BubbleBarUpdate;
import com.android.wm.shell.draganddrop.DragAndDropController;
import com.android.wm.shell.onehanded.OneHandedController;
import com.android.wm.shell.onehanded.OneHandedTransitionCallback;
-import com.android.wm.shell.pip.PinnedStackListenerForwarder;
import com.android.wm.shell.shared.annotations.ShellBackgroundThread;
import com.android.wm.shell.shared.annotations.ShellMainThread;
+import com.android.wm.shell.shared.bubbles.BubbleAnythingFlagHelper;
+import com.android.wm.shell.shared.bubbles.BubbleBarLocation;
+import com.android.wm.shell.shared.bubbles.BubbleBarLocation.UpdateSource;
+import com.android.wm.shell.shared.bubbles.BubbleBarUpdate;
+import com.android.wm.shell.shared.bubbles.BubbleDropTargetBoundsProvider;
+import com.android.wm.shell.shared.bubbles.ContextUtils;
+import com.android.wm.shell.shared.bubbles.DeviceConfig;
+import com.android.wm.shell.splitscreen.SplitScreenController;
import com.android.wm.shell.sysui.ConfigurationChangeListener;
import com.android.wm.shell.sysui.ShellCommandHandler;
import com.android.wm.shell.sysui.ShellController;
import com.android.wm.shell.sysui.ShellInit;
import com.android.wm.shell.taskview.TaskView;
+import com.android.wm.shell.taskview.TaskViewController;
import com.android.wm.shell.taskview.TaskViewTaskController;
import com.android.wm.shell.taskview.TaskViewTransitions;
import com.android.wm.shell.transition.Transitions;
+import dagger.Lazy;
+
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.HashMap;
@@ -142,7 +165,8 @@ import java.util.function.IntConsumer;
* The controller manages addition, removal, and visible state of bubbles on screen.
*/
public class BubbleController implements ConfigurationChangeListener,
- RemoteCallable, Bubbles.SysuiProxy.Provider {
+ RemoteCallable, Bubbles.SysuiProxy.Provider,
+ BubbleTaskUnfoldTransitionMerger {
private static final String TAG = TAG_WITH_CLASS_NAME ? "BubbleController" : TAG_BUBBLES;
@@ -183,7 +207,8 @@ public class BubbleController implements ConfigurationChangeListener,
@Nullable private final BubbleStackView.SurfaceSynchronizer mSurfaceSynchronizer;
private final FloatingContentCoordinator mFloatingContentCoordinator;
private final BubbleDataRepository mDataRepository;
- private final WindowManagerShellWrapper mWindowManagerShellWrapper;
+ private final DisplayInsetsController mDisplayInsetsController;
+ private final DisplayImeController mDisplayImeController;
private final UserManager mUserManager;
private final LauncherApps mLauncherApps;
private final IStatusBarService mBarService;
@@ -191,15 +216,17 @@ public class BubbleController implements ConfigurationChangeListener,
private final TaskStackListenerImpl mTaskStackListener;
private final ShellTaskOrganizer mTaskOrganizer;
private final DisplayController mDisplayController;
- private final TaskViewTransitions mTaskViewTransitions;
+ private final TaskViewController mTaskViewController;
private final Transitions mTransitions;
- private final SyncTransactionQueue mSyncQueue;
private final ShellController mShellController;
private final ShellCommandHandler mShellCommandHandler;
private final IWindowManager mWmService;
- private final BubbleProperties mBubbleProperties;
private final BubbleTaskViewFactory mBubbleTaskViewFactory;
private final BubbleExpandedViewManager mExpandedViewManager;
+ private final ResizabilityChecker mResizabilityChecker;
+ private final HomeIntentProvider mHomeIntentProvider;
+ private final BubbleAppInfoProvider mAppInfoProvider;
+ private final Lazy> mSplitScreenController;
// Used to post to main UI thread
private final ShellExecutor mMainExecutor;
@@ -210,10 +237,13 @@ public class BubbleController implements ConfigurationChangeListener,
private final BubbleData mBubbleData;
@Nullable private BubbleStackView mStackView;
@Nullable private BubbleBarLayerView mLayerView;
+ @Nullable private ActivityManager.RunningTaskInfo mAppBubbleRootTaskInfo;
private BubbleIconFactory mBubbleIconFactory;
private final BubblePositioner mBubblePositioner;
private Bubbles.SysuiProxy mSysuiProxy;
+ @Nullable private Runnable mOnImeHidden;
+
// Tracks the id of the current (foreground) user.
private int mCurrentUserId;
// Current profiles of the user (e.g. user with a workprofile)
@@ -272,12 +302,29 @@ public class BubbleController implements ConfigurationChangeListener,
private final Optional mOneHandedOptional;
/** Drag and drop controller to register listener for onDragStarted. */
private final DragAndDropController mDragAndDropController;
- /** Used to send bubble events to launcher. */
+ /**
+ * Used to send bubble events to launcher.
+ * Set when taskbar is created in launcher and bubble bar gets initialized.
+ * Can be cleared during Launcher lifecycle changes, for example when taskbar gets recreated
+ * during rotation.
+ */
+ @Nullable
private Bubbles.BubbleStateListener mBubbleStateListener;
-
+ /** True when launcher can show the bubble bar. */
+ private boolean mLauncherHasBubbleBar;
+ /**
+ * Used to track previous navigation mode to detect switch to buttons navigation. Set to
+ * true to switch the bubble bar to the opposite side for 3 nav buttons mode on device boot.
+ */
+ private boolean mIsPrevNavModeGestures = true;
/** Used to send updates to the views from {@link #mBubbleDataListener}. */
private BubbleViewCallback mBubbleViewCallback;
+ private final BubbleTransitions mBubbleTransitions;
+
+ // Experimental listener for app requests for bubble actions.
+ private BubbleMultitaskingDelegate mBubbleMultitaskingDelegate;
+
public BubbleController(Context context,
ShellInit shellInit,
ShellCommandHandler shellCommandHandler,
@@ -286,9 +333,11 @@ public class BubbleController implements ConfigurationChangeListener,
@Nullable BubbleStackView.SurfaceSynchronizer synchronizer,
FloatingContentCoordinator floatingContentCoordinator,
BubbleDataRepository dataRepository,
+ BubbleTransitions bubbleTransitions,
@Nullable IStatusBarService statusBarService,
WindowManager windowManager,
- WindowManagerShellWrapper windowManagerShellWrapper,
+ DisplayInsetsController displayInsetsController,
+ DisplayImeController displayImeController,
UserManager userManager,
LauncherApps launcherApps,
BubbleLogger bubbleLogger,
@@ -301,11 +350,14 @@ public class BubbleController implements ConfigurationChangeListener,
@ShellMainThread ShellExecutor mainExecutor,
@ShellMainThread Handler mainHandler,
@ShellBackgroundThread ShellExecutor bgExecutor,
- TaskViewTransitions taskViewTransitions,
+ @NonNull TaskViewTransitions taskViewTransitions,
Transitions transitions,
SyncTransactionQueue syncQueue,
IWindowManager wmService,
- BubbleProperties bubbleProperties) {
+ ResizabilityChecker resizabilityChecker,
+ HomeIntentProvider homeIntentProvider,
+ BubbleAppInfoProvider appInfoProvider,
+ Lazy> splitScreenController) {
mContext = context;
mShellCommandHandler = shellCommandHandler;
mShellController = shellController;
@@ -315,7 +367,8 @@ public class BubbleController implements ConfigurationChangeListener,
ServiceManager.getService(Context.STATUS_BAR_SERVICE))
: statusBarService;
mWindowManager = windowManager;
- mWindowManagerShellWrapper = windowManagerShellWrapper;
+ mDisplayInsetsController = displayInsetsController;
+ mDisplayImeController = displayImeController;
mUserManager = userManager;
mFloatingContentCoordinator = floatingContentCoordinator;
mDataRepository = dataRepository;
@@ -338,24 +391,29 @@ public class BubbleController implements ConfigurationChangeListener,
context.getResources().getDimensionPixelSize(
com.android.internal.R.dimen.importance_ring_stroke_width));
mDisplayController = displayController;
- mTaskViewTransitions = taskViewTransitions;
+ mTaskViewController = new BubbleTaskViewController(taskViewTransitions);
mTransitions = transitions;
mOneHandedOptional = oneHandedOptional;
mDragAndDropController = dragAndDropController;
- mSyncQueue = syncQueue;
mWmService = wmService;
- mBubbleProperties = bubbleProperties;
- shellInit.addInitCallback(this::onInit, this);
+ mBubbleTransitions = bubbleTransitions;
+ mBubbleTransitions.setBubbleController(this);
mBubbleTaskViewFactory = new BubbleTaskViewFactory() {
@Override
public BubbleTaskView create() {
TaskViewTaskController taskViewTaskController = new TaskViewTaskController(
- context, organizer, taskViewTransitions, syncQueue);
- TaskView taskView = new TaskView(context, taskViewTaskController);
+ context, organizer, mTaskViewController, syncQueue);
+ TaskView taskView = new TaskView(context, mTaskViewController,
+ taskViewTaskController);
return new BubbleTaskView(taskView, mainExecutor);
}
};
mExpandedViewManager = BubbleExpandedViewManager.fromBubbleController(this);
+ mResizabilityChecker = resizabilityChecker;
+ mHomeIntentProvider = homeIntentProvider;
+ mAppInfoProvider = appInfoProvider;
+ mSplitScreenController = splitScreenController;
+ shellInit.addInitCallback(this::onInit, this);
}
private void registerOneHandedState(OneHandedController oneHanded) {
@@ -388,23 +446,27 @@ public class BubbleController implements ConfigurationChangeListener,
mBubbleData.setListener(mBubbleDataListener);
mBubbleData.setSuppressionChangedListener(this::onBubbleMetadataFlagChanged);
mDataRepository.setSuppressionChangedListener(this::onBubbleMetadataFlagChanged);
-
mBubbleData.setPendingIntentCancelledListener(bubble -> {
- if (bubble.getBubbleIntent() == null) {
+ if (bubble.getPendingIntent() == null) {
return;
}
- if (bubble.isIntentActive() || mBubbleData.hasBubbleInStackWithKey(bubble.getKey())) {
+ if (bubble.isPendingIntentActive()
+ || mBubbleData.hasBubbleInStackWithKey(bubble.getKey())) {
bubble.setPendingIntentCanceled();
return;
}
mMainExecutor.execute(() -> removeBubble(bubble.getKey(), DISMISS_INVALID_INTENT));
});
- try {
- mWindowManagerShellWrapper.addPinnedStackListener(new BubblesImeListener());
- } catch (RemoteException e) {
- e.printStackTrace();
- }
+ BubblesImeListener bubblesImeListener =
+ new BubblesImeListener(mDisplayController, mContext.getDisplayId());
+ // the insets controller is notified whenever the IME visibility changes whether the IME is
+ // requested by a bubbled task or non-bubbled task. in the latter case, we need to update
+ // the position of the stack to avoid overlapping with the IME.
+ mDisplayInsetsController.addInsetsChangedListener(mContext.getDisplayId(),
+ bubblesImeListener);
+ // the ime controller is notified when the IME is requested only by a bubbled task.
+ mDisplayImeController.addPositionProcessor(bubblesImeListener);
mBubbleData.setCurrentUserId(mCurrentUserId);
@@ -449,33 +511,11 @@ public class BubbleController implements ConfigurationChangeListener,
}
}, mMainHandler);
- mTransitions.registerObserver(new BubblesTransitionObserver(this, mBubbleData));
+ mTransitions.registerObserver(new BubblesTransitionObserver(this, mBubbleData,
+ mBubbleTransitions.mTaskViewTransitions, mSplitScreenController));
- mTaskStackListener.addListener(new TaskStackListenerCallback() {
- @Override
- public void onActivityRestartAttempt(ActivityManager.RunningTaskInfo task,
- boolean homeTaskVisible, boolean clearedTask, boolean wasVisible) {
- for (Bubble b : mBubbleData.getBubbles()) {
- if (task.taskId == b.getTaskId()) {
- ProtoLog.d(WM_SHELL_BUBBLES,
- "onActivityRestartAttempt - taskId=%d selecting matching bubble=%s",
- task.taskId, b.getKey());
- mBubbleData.setSelectedBubbleAndExpandStack(b);
- return;
- }
- }
- for (Bubble b : mBubbleData.getOverflowBubbles()) {
- if (task.taskId == b.getTaskId()) {
- ProtoLog.d(WM_SHELL_BUBBLES, "onActivityRestartAttempt - taskId=%d "
- + "selecting matching overflow bubble=%s",
- task.taskId, b.getKey());
- promoteBubbleFromOverflow(b);
- mBubbleData.setExpanded(true);
- return;
- }
- }
- }
- });
+ mTaskStackListener.addListener(
+ new BubbleTaskStackListener(this, mBubbleData, mSplitScreenController));
mDisplayController.addDisplayChangingController(
(displayId, fromRotation, toRotation, newDisplayAreaInfo, t) -> {
@@ -518,9 +558,47 @@ public class BubbleController implements ConfigurationChangeListener,
}
mShellController.addConfigurationChangeListener(this);
- mShellController.addExternalInterface(KEY_EXTRA_SHELL_BUBBLES,
+ mShellController.addExternalInterface(IBubbles.DESCRIPTOR,
this::createExternalInterface, this);
mShellCommandHandler.addDumpCallback(this::dump, this);
+
+ if (com.android.window.flags.Flags.enableExperimentalBubblesController()) {
+ try {
+ final BubbleMultitaskingDelegate delegate = new BubbleMultitaskingDelegate(
+ this, mBubbleData, mCurrentUserId);
+ final IMultitaskingController mtController = ActivityTaskManager.getService()
+ .getWindowOrganizerController().getMultitaskingController();
+ final IMultitaskingControllerCallback callback =
+ mtController.setMultitaskingDelegate(delegate);
+ mBubbleMultitaskingDelegate = delegate;
+ mBubbleMultitaskingDelegate.setControllerCallback(callback);
+ } catch (RemoteException e) {
+ Slog.e(TAG, "Failed to register Bubble multitasking delegate.", e);
+ }
+ }
+
+ if (BubbleAnythingFlagHelper.enableRootTaskForBubble()) {
+ // Create a root-task in WM Core. The app bubble tasks will be positioned as the leaf
+ // tasks under this root-task.
+ // The app bubble should be dismissed with proper transition (such as need to convert
+ // it to fullscreen) if the bubble task is no longer be a leaf task under this leaf
+ // task.
+ mTaskOrganizer.createRootTask(
+ new TaskOrganizer.CreateRootTaskRequest()
+ .setName("Bubbles")
+ .setDisplayId(mContext.getDisplayId())
+ .setWindowingMode(WINDOWING_MODE_MULTI_WINDOW),
+ new ShellTaskOrganizer.TaskListener() {
+ @Override
+ public void onTaskAppeared(ActivityManager.RunningTaskInfo taskInfo,
+ SurfaceControl leash) {
+ if (mAppBubbleRootTaskInfo != null) {
+ return;
+ }
+ mAppBubbleRootTaskInfo = taskInfo;
+ }
+ });
+ }
}
private ExternalInterfaceBinder createExternalInterface() {
@@ -541,6 +619,10 @@ public class BubbleController implements ConfigurationChangeListener,
return mMainExecutor;
}
+ ShellExecutor getBackgroundExecutor() {
+ return mBackgroundExecutor;
+ }
+
@Override
public Context getContext() {
return mContext;
@@ -554,16 +636,19 @@ public class BubbleController implements ConfigurationChangeListener,
/**
* Sets a listener to be notified of bubble updates. This is used by launcher so that
* it may render bubbles in itself. Only one listener is supported.
- *
- * If bubble bar is supported, bubble views will be updated to switch to bar mode.
*/
+ @VisibleForTesting
public void registerBubbleStateListener(Bubbles.BubbleStateListener listener) {
- mBubbleProperties.refresh();
- if (canShowAsBubbleBar() && listener != null) {
+ final boolean bubbleBarAllowed = Flags.enableBubbleBar()
+ && (mBubblePositioner.isLargeScreen() || Flags.enableBubbleBarOnPhones())
+ && listener != null;
+ if (bubbleBarAllowed) {
// Only set the listener if we can show the bubble bar.
mBubbleStateListener = listener;
- setUpBubbleViewsForMode();
- sendInitialListenerUpdate();
+ if (mLauncherHasBubbleBar) {
+ // Launcher is ready, send initial listener update.
+ sendInitialListenerUpdate();
+ }
} else {
mBubbleStateListener = null;
}
@@ -571,14 +656,31 @@ public class BubbleController implements ConfigurationChangeListener,
/**
* Unregisters the {@link Bubbles.BubbleStateListener}.
- *
- *
If there's an existing listener, then we're switching back to stack mode and bubble views
- * will be updated accordingly.
*/
+ @VisibleForTesting
public void unregisterBubbleStateListener() {
- mBubbleProperties.refresh();
- if (mBubbleStateListener != null) {
- mBubbleStateListener = null;
+ mBubbleStateListener = null;
+ }
+
+ /**
+ * Store whether Launcher can show bubbles in Bubble Bar.
+ *
+ * If {@code launcherHasBubbleBar} is set to {@code true}, bubble views will be updated to
+ * switch to bar mode. If it is set to {@code false}, bubble views will be updated to switch
+ * to stack mode.
+ */
+ @VisibleForTesting
+ public void setLauncherHasBubbleBar(boolean launcherHasBubbleBar) {
+ if (launcherHasBubbleBar == mLauncherHasBubbleBar) return;
+ mLauncherHasBubbleBar = launcherHasBubbleBar;
+ if (mLauncherHasBubbleBar) {
+ setUpBubbleViewsForMode();
+ if (mBubbleStateListener != null) {
+ // Listener got set first, send it an initial update.
+ sendInitialListenerUpdate();
+ }
+ } else {
+ unregisterBubbleStateListener();
setUpBubbleViewsForMode();
}
}
@@ -589,6 +691,13 @@ public class BubbleController implements ConfigurationChangeListener,
*/
private void sendInitialListenerUpdate() {
if (mBubbleStateListener != null) {
+ boolean isCurrentNavModeGestures = ContextUtils.isGestureNavigationMode(mContext);
+ if (mIsPrevNavModeGestures && !isCurrentNavModeGestures) {
+ BubbleBarLocation bubbleBarLocation = ContextUtils.isRtl(mContext)
+ ? BubbleBarLocation.RIGHT : BubbleBarLocation.LEFT;
+ mBubblePositioner.setBubbleBarLocation(bubbleBarLocation);
+ }
+ mIsPrevNavModeGestures = isCurrentNavModeGestures;
BubbleBarUpdate update = mBubbleData.getInitialStateForBubbleBar();
mBubbleStateListener.onBubbleStateChange(update);
}
@@ -597,9 +706,18 @@ public class BubbleController implements ConfigurationChangeListener,
/**
* Hides the current input method, wherever it may be focused, via InputMethodManagerInternal.
*/
- void hideCurrentInputMethod() {
+ void hideCurrentInputMethod(@Nullable Runnable onImeHidden) {
+ mOnImeHidden = onImeHidden;
mBubblePositioner.setImeVisible(false /* visible */, 0 /* height */);
int displayId = mWindowManager.getDefaultDisplay().getDisplayId();
+ // if the device is locked we can't use the status bar service to hide the IME because
+ // the IME state is frozen and it will lead to internal IME state going out of sync. This
+ // will make the IME visible when the device is unlocked. Instead we use
+ // DisplayImeController directly to make sure the state is correct when the device unlocks.
+ if (isDeviceLocked()) {
+ mDisplayImeController.hideImeForBubblesWhenLocked(displayId);
+ return;
+ }
try {
mBarService.hideCurrentInputMethodForBubbles(displayId);
} catch (RemoteException e) {
@@ -607,6 +725,14 @@ public class BubbleController implements ConfigurationChangeListener,
}
}
+ /**
+ * Allows callers to reset runnable scheduled to run after IME is hidden by
+ * {@link #hideCurrentInputMethod(Runnable)}
+ */
+ void clearImeHiddenRunnable() {
+ mOnImeHidden = null;
+ }
+
/**
* Called when the status bar has become visible or invisible (either permanently or
* temporarily).
@@ -639,8 +765,20 @@ public class BubbleController implements ConfigurationChangeListener,
? mNotifEntryToExpandOnShadeUnlock.getKey() : "null"));
mIsStatusBarShade = isShade;
if (!mIsStatusBarShade && didChange) {
- // Only collapse stack on change
- collapseStack();
+ if (mBubbleData.isExpanded()) {
+ // If the IME is visible, hide it first and then collapse.
+ if (mBubblePositioner.isImeVisible()) {
+ hideCurrentInputMethod(this::collapseStack);
+ } else {
+ collapseStack();
+ }
+ } else if (mOnImeHidden != null) {
+ // a request to collapse started before we're notified that the device is locking.
+ // we're currently waiting for the IME to collapse, before mOnImeHidden can be
+ // executed, which may not happen since the screen may already be off. hide the IME
+ // immediately now that we're locked and pass the same runnable so it can complete.
+ hideCurrentInputMethod(mOnImeHidden);
+ }
}
if (mNotifEntryToExpandOnShadeUnlock != null) {
@@ -677,6 +815,9 @@ public class BubbleController implements ConfigurationChangeListener,
restoreBubbles(newUserId);
mBubbleData.setCurrentUserId(newUserId);
+ if (mBubbleMultitaskingDelegate != null) {
+ mBubbleMultitaskingDelegate.setCurrentUserId(newUserId);
+ }
}
/** Called when the profiles for the current user change. **/
@@ -706,14 +847,11 @@ public class BubbleController implements ConfigurationChangeListener,
}
}
- /** Whether bubbles are showing in the bubble bar. */
+ /** Whether bubbles would be shown with the bubble bar UI. */
public boolean isShowingAsBubbleBar() {
- return canShowAsBubbleBar() && mBubbleStateListener != null;
- }
-
- /** Whether the current configuration supports showing as bubble bar. */
- private boolean canShowAsBubbleBar() {
- return mBubbleProperties.isBubbleBarEnabled() && mBubblePositioner.isLargeScreen();
+ return Flags.enableBubbleBar()
+ && (mBubblePositioner.isLargeScreen() || Flags.enableBubbleBarOnPhones())
+ && mLauncherHasBubbleBar;
}
/**
@@ -722,7 +860,7 @@ public class BubbleController implements ConfigurationChangeListener,
*/
@Nullable
public BubbleBarLocation getBubbleBarLocation() {
- if (canShowAsBubbleBar()) {
+ if (isShowingAsBubbleBar()) {
return mBubblePositioner.getBubbleBarLocation();
}
return null;
@@ -731,26 +869,103 @@ public class BubbleController implements ConfigurationChangeListener,
/**
* Update bubble bar location and trigger and update to listeners
*/
- public void setBubbleBarLocation(BubbleBarLocation bubbleBarLocation) {
- if (canShowAsBubbleBar()) {
+ public void setBubbleBarLocation(BubbleBarLocation bubbleBarLocation,
+ @UpdateSource int source) {
+ if (isShowingAsBubbleBar()) {
+ updateExpandedViewForBubbleBarLocation(bubbleBarLocation, source);
+ if (mBubbleStateListener != null) {
+ BubbleBarUpdate bubbleBarUpdate = new BubbleBarUpdate();
+ bubbleBarUpdate.bubbleBarLocation = bubbleBarLocation;
+ mBubbleStateListener.onBubbleStateChange(bubbleBarUpdate);
+ }
+ }
+ }
+
+ private void updateExpandedViewForBubbleBarLocation(BubbleBarLocation bubbleBarLocation,
+ @UpdateSource int source) {
+ if (isShowingAsBubbleBar()) {
+ BubbleBarLocation previousLocation = mBubblePositioner.getBubbleBarLocation();
mBubblePositioner.setBubbleBarLocation(bubbleBarLocation);
- BubbleBarUpdate bubbleBarUpdate = new BubbleBarUpdate();
- bubbleBarUpdate.bubbleBarLocation = bubbleBarLocation;
- mBubbleStateListener.onBubbleStateChange(bubbleBarUpdate);
+ if (mLayerView != null && !mLayerView.isExpandedViewDragged()) {
+ mLayerView.updateExpandedView();
+ }
+ logBubbleBarLocationIfChanged(bubbleBarLocation, previousLocation, source);
+ }
+ }
+
+ private void logBubbleBarLocationIfChanged(BubbleBarLocation location,
+ BubbleBarLocation previous,
+ @UpdateSource int source) {
+ if (mLayerView == null) {
+ return;
+ }
+ boolean isRtl = mLayerView.isLayoutRtl();
+ boolean wasLeft = previous.isOnLeft(isRtl);
+ boolean onLeft = location.isOnLeft(isRtl);
+ if (wasLeft == onLeft) {
+ // No changes, skip logging
+ return;
+ }
+ switch (source) {
+ case UpdateSource.DRAG_BAR:
+ case UpdateSource.A11Y_ACTION_BAR:
+ mLogger.log(onLeft ? BubbleLogger.Event.BUBBLE_BAR_MOVED_LEFT_DRAG_BAR
+ : BubbleLogger.Event.BUBBLE_BAR_MOVED_RIGHT_DRAG_BAR);
+ break;
+ case UpdateSource.DRAG_BUBBLE:
+ case UpdateSource.A11Y_ACTION_BUBBLE:
+ mLogger.log(onLeft ? BubbleLogger.Event.BUBBLE_BAR_MOVED_LEFT_DRAG_BUBBLE
+ : BubbleLogger.Event.BUBBLE_BAR_MOVED_RIGHT_DRAG_BUBBLE);
+ break;
+ case UpdateSource.DRAG_EXP_VIEW:
+ case UpdateSource.A11Y_ACTION_EXP_VIEW:
+ // TODO(b/349845968): move logging from BubbleBarLayerView to here
+ break;
+ case UpdateSource.APP_ICON_DRAG:
+ mLogger.log(onLeft ? BubbleLogger.Event.BUBBLE_BAR_MOVED_LEFT_APP_ICON_DROP
+ : BubbleLogger.Event.BUBBLE_BAR_MOVED_RIGHT_APP_ICON_DROP);
+ break;
+ case UpdateSource.DRAG_TASK:
+ mLogger.log(onLeft ? BubbleLogger.Event.BUBBLE_BAR_MOVED_LEFT_DRAG_TASK
+ : BubbleLogger.Event.BUBBLE_BAR_MOVED_RIGHT_DRAG_TASK);
+ break;
}
}
/**
* Animate bubble bar to the given location. The location change is transient. It does not
* update the state of the bubble bar.
- * To update bubble bar pinned location, use {@link #setBubbleBarLocation(BubbleBarLocation)}.
+ * To update bubble bar pinned location, use
+ * {@link #setBubbleBarLocation(BubbleBarLocation, int)}.
*/
public void animateBubbleBarLocation(BubbleBarLocation bubbleBarLocation) {
- if (canShowAsBubbleBar()) {
+ if (isShowingAsBubbleBar() && mBubbleStateListener != null) {
mBubbleStateListener.animateBubbleBarLocation(bubbleBarLocation);
}
}
+ /**
+ * Show bubble bar pin view given location.
+ */
+ public void showBubbleBarPinAtLocation(@Nullable BubbleBarLocation bubbleBarLocation) {
+ if (isShowingAsBubbleBar() && mBubbleStateListener != null) {
+ mBubbleStateListener.showBubbleBarPillowAt(bubbleBarLocation);
+ }
+ }
+
+ private void showBubbleBarExpandedViewDropTarget(BubbleBarLocation bubbleBarLocation) {
+ ensureBubbleViewsAndWindowCreated();
+ if (mLayerView != null) {
+ mLayerView.showBubbleBarExtendedViewDropTarget(bubbleBarLocation);
+ }
+ }
+
+ private void hideBubbleBarExpandedViewDropTarget() {
+ if (mLayerView != null) {
+ mLayerView.hideBubbleBarExpandedViewDropTarget();
+ }
+ }
+
/** Whether this userId belongs to the current user. */
private boolean isCurrentProfile(int userId) {
return userId == UserHandle.USER_ALL
@@ -783,20 +998,17 @@ public class BubbleController implements ConfigurationChangeListener,
return mTaskOrganizer;
}
- SyncTransactionQueue getSyncTransactionQueue() {
- return mSyncQueue;
- }
-
- TaskViewTransitions getTaskViewTransitions() {
- return mTaskViewTransitions;
- }
-
/** Contains information to help position things on the screen. */
@VisibleForTesting
public BubblePositioner getPositioner() {
return mBubblePositioner;
}
+ /** Provides bounds for drag zone drop targets */
+ public BubbleDropTargetBoundsProvider getBubbleDropTargetBoundsProvider() {
+ return mBubblePositioner;
+ }
+
BubbleIconFactory getIconFactory() {
return mBubbleIconFactory;
}
@@ -821,7 +1033,7 @@ public class BubbleController implements ConfigurationChangeListener,
// window to show this in, but we use a separate code path.
// TODO(b/273312602): consider foldables where we do need a stack view when folded
if (mLayerView == null) {
- mLayerView = new BubbleBarLayerView(mContext, this, mBubbleData);
+ mLayerView = new BubbleBarLayerView(mContext, this, mBubbleData, mLogger);
mLayerView.setUnBubbleConversationCallback(mSysuiProxy::onUnbubbleConversation);
}
} else {
@@ -988,11 +1200,29 @@ public class BubbleController implements ConfigurationChangeListener,
|| SYSTEM_DIALOG_REASON_GESTURE_NAV.equals(reason);
if ((Intent.ACTION_CLOSE_SYSTEM_DIALOGS.equals(action) && validReasonToCollapse)
|| Intent.ACTION_SCREEN_OFF.equals(action)) {
- mMainExecutor.execute(() -> collapseStack());
+ // if we're converting the bubble to a different mode, don't collapse since we want
+ // the bubble to stay expanded.
+ if (!isSelectedBubbleConvertingMode()) {
+ mMainExecutor.execute(() -> collapseStack());
+ }
}
}
};
+ /** Returns the broadcast receiver registered for bubbles. */
+ @VisibleForTesting
+ BroadcastReceiver getBroadcastReceiver() {
+ return mBroadcastReceiver;
+ }
+
+ private boolean isSelectedBubbleConvertingMode() {
+ if (mBubbleData.getSelectedBubble() instanceof Bubble) {
+ Bubble bubble = (Bubble) mBubbleData.getSelectedBubble();
+ return bubble.isConvertingToBar();
+ }
+ return false;
+ }
+
private void registerShortcutBroadcastReceiver() {
IntentFilter shortcutFilter = new IntentFilter();
shortcutFilter.addAction(BubbleShortcutHelper.ACTION_SHOW_BUBBLES);
@@ -1091,6 +1321,7 @@ public class BubbleController implements ConfigurationChangeListener,
mStackView,
mLayerView,
mBubbleIconFactory,
+ mAppInfoProvider,
false /* skipInflation */);
}
for (Bubble b : mBubbleData.getOverflowBubbles()) {
@@ -1102,14 +1333,19 @@ public class BubbleController implements ConfigurationChangeListener,
mStackView,
mLayerView,
mBubbleIconFactory,
+ mAppInfoProvider,
false /* skipInflation */);
}
}
@Override
public void onConfigurationChanged(Configuration newConfig) {
+ DeviceConfig deviceConfig = DeviceConfig.create(mContext, mWindowManager);
if (mBubblePositioner != null) {
- mBubblePositioner.update(DeviceConfig.create(mContext, mWindowManager));
+ mBubblePositioner.update(deviceConfig);
+ }
+ if (mLayerView != null) {
+ mLayerView.update(deviceConfig);
}
if (mStackView != null && newConfig != null) {
if (newConfig.densityDpi != mDensityDpi
@@ -1174,7 +1410,6 @@ public class BubbleController implements ConfigurationChangeListener,
* Whether or not there are bubbles present, regardless of them being visible on the
* screen (e.g. if on AOD).
*/
- @VisibleForTesting
public boolean hasBubbles() {
if (mStackView == null && mLayerView == null) {
return false;
@@ -1182,6 +1417,21 @@ public class BubbleController implements ConfigurationChangeListener,
return mBubbleData.hasBubbles() || mBubbleData.isShowingOverflow();
}
+ /** Returns whether the given task is a non-transient bubble. */
+ public boolean hasStableBubbleForTask(int taskId) {
+ final Bubble bubble = mBubbleData.getBubbleInStackWithTaskId(taskId);
+ return bubble != null && bubble.getPreparingTransition() == null;
+ }
+
+ /** Returns whether the given task should be an App Bubble */
+ public boolean shouldBeAppBubble(@NonNull ActivityManager.RunningTaskInfo taskInfo) {
+ if (com.android.window.flags.Flags.rootTaskForBubble()) {
+ return mAppBubbleRootTaskInfo != null
+ && taskInfo.parentTaskId == mAppBubbleRootTaskInfo.taskId;
+ }
+ return taskInfo.isAppBubble;
+ }
+
public boolean isStackExpanded() {
return mBubbleData.isExpanded();
}
@@ -1198,7 +1448,7 @@ public class BubbleController implements ConfigurationChangeListener,
*/
public void startBubbleDrag(String bubbleKey) {
if (mBubbleData.getSelectedBubble() != null) {
- mBubbleBarViewCallback.expansionChanged(/* isExpanded = */ false);
+ collapseExpandedViewForBubbleBar();
}
if (mBubbleStateListener != null) {
boolean overflow = BubbleOverflow.KEY.equals(bubbleKey);
@@ -1216,13 +1466,14 @@ public class BubbleController implements ConfigurationChangeListener,
* Will be called only when bubble bar is expanded.
*
* @param location location where bubble was released
- * @param topOnScreen top coordinate of the bubble bar on the screen after release
+ * @param bubbleBarTopToScreenBottom the distance between the top coordinate of the bubble
+ * bar and the bottom of the screen after release
*/
- public void stopBubbleDrag(BubbleBarLocation location, int topOnScreen) {
+ public void stopBubbleDrag(BubbleBarLocation location, int bubbleBarTopToScreenBottom) {
mBubblePositioner.setBubbleBarLocation(location);
- mBubblePositioner.setBubbleBarTopOnScreen(topOnScreen);
+ mBubblePositioner.updateBubbleBarTopOnScreen(bubbleBarTopToScreenBottom);
if (mBubbleData.getSelectedBubble() != null) {
- mBubbleBarViewCallback.expansionChanged(/* isExpanded = */ true);
+ showExpandedViewForBubbleBar();
}
}
@@ -1230,13 +1481,29 @@ public class BubbleController implements ConfigurationChangeListener,
* A bubble was dragged and is released in dismiss target in Launcher.
*
* @param bubbleKey key of the bubble being dragged to dismiss target
+ * @param timestamp the timestamp of the removal
*/
- public void dragBubbleToDismiss(String bubbleKey) {
- String selectedBubbleKey = mBubbleData.getSelectedBubbleKey();
- removeBubble(bubbleKey, Bubbles.DISMISS_USER_GESTURE);
- if (selectedBubbleKey != null && !selectedBubbleKey.equals(bubbleKey)) {
- // We did not remove the selected bubble. Expand it again
- mBubbleBarViewCallback.expansionChanged(/* isExpanded = */ true);
+ public void dragBubbleToDismiss(String bubbleKey, long timestamp) {
+ final String selectedBubbleKey = mBubbleData.getSelectedBubbleKey();
+ final Bubble bubbleToDismiss = mBubbleData.getAnyBubbleWithKey(bubbleKey);
+ if (bubbleToDismiss != null) {
+ mBubbleData.dismissBubbleWithKey(
+ bubbleKey, Bubbles.DISMISS_USER_GESTURE_FROM_LAUNCHER, timestamp);
+ mLogger.log(bubbleToDismiss,
+ BubbleLogger.Event.BUBBLE_BAR_BUBBLE_DISMISSED_DRAG_BUBBLE);
+ }
+ if (mBubbleData.hasBubbles()) {
+ // We still have bubbles, if we dragged an individual bubble to dismiss we were expanded
+ // so re-expand to whatever is selected.
+ showExpandedViewForBubbleBar();
+ if (bubbleKey.equals(selectedBubbleKey)) {
+ // We dragged the selected bubble to dismiss, log switch event
+ if (mBubbleData.getSelectedBubble() instanceof Bubble) {
+ // Log only bubbles as overflow can't be dragged
+ mLogger.log((Bubble) mBubbleData.getSelectedBubble(),
+ BubbleLogger.Event.BUBBLE_BAR_BUBBLE_SWITCHED);
+ }
+ }
}
}
@@ -1251,17 +1518,21 @@ public class BubbleController implements ConfigurationChangeListener,
@VisibleForTesting
public boolean isBubbleNotificationSuppressedFromShade(String key, String groupKey) {
- boolean isSuppressedBubble = (mBubbleData.hasAnyBubbleWithKey(key)
- && !mBubbleData.getAnyBubbleWithkey(key).showInShade());
+ final boolean isSuppressedBubble = (mBubbleData.hasAnyBubbleWithKey(key)
+ && !mBubbleData.getAnyBubbleWithKey(key).showInShade());
- boolean isSuppressedSummary = mBubbleData.isSummarySuppressed(groupKey);
- boolean isSummary = key.equals(mBubbleData.getSummaryKey(groupKey));
+ final boolean isSuppressedSummary = mBubbleData.isSummarySuppressed(groupKey);
+ final boolean isSummary = key.equals(mBubbleData.getSummaryKey(groupKey));
return (isSummary && isSuppressedSummary) || isSuppressedBubble;
}
/** Promote the provided bubble from the overflow view. */
public void promoteBubbleFromOverflow(Bubble bubble) {
- mLogger.log(bubble, BubbleLogger.Event.BUBBLE_OVERFLOW_REMOVE_BACK_TO_STACK);
+ if (isShowingAsBubbleBar()) {
+ mLogger.log(bubble, BubbleLogger.Event.BUBBLE_BAR_OVERFLOW_REMOVE_BACK_TO_BAR);
+ } else {
+ mLogger.log(bubble, BubbleLogger.Event.BUBBLE_OVERFLOW_REMOVE_BACK_TO_STACK);
+ }
ProtoLog.d(WM_SHELL_BUBBLES, "promoteBubbleFromOverflow=%s", bubble.getKey());
bubble.setInflateSynchronously(mInflateSynchronously);
bubble.setShouldAutoExpand(true);
@@ -1276,23 +1547,30 @@ public class BubbleController implements ConfigurationChangeListener,
*
This is used by external callers (launcher).
*/
@VisibleForTesting
- public void expandStackAndSelectBubbleFromLauncher(String key, int topOnScreen) {
- mBubblePositioner.setBubbleBarTopOnScreen(topOnScreen);
+ public void expandStackAndSelectBubbleFromLauncher(String key, int bubbleBarTopToScreenBottom) {
+ mBubblePositioner.updateBubbleBarTopOnScreen(bubbleBarTopToScreenBottom);
if (BubbleOverflow.KEY.equals(key)) {
mBubbleData.setSelectedBubbleFromLauncher(mBubbleData.getOverflow());
mLayerView.showExpandedView(mBubbleData.getOverflow());
+ mLogger.log(BubbleLogger.Event.BUBBLE_BAR_OVERFLOW_SELECTED);
return;
}
- Bubble b = mBubbleData.getAnyBubbleWithkey(key);
+ final Bubble b = mBubbleData.getAnyBubbleWithKey(key);
if (b == null) {
return;
}
+ final boolean wasExpanded = (mLayerView != null && mLayerView.isExpanded());
if (mBubbleData.hasBubbleInStackWithKey(b.getKey())) {
// already in the stack
mBubbleData.setSelectedBubbleFromLauncher(b);
mLayerView.showExpandedView(b);
+ if (wasExpanded) {
+ mLogger.log(b, BubbleLogger.Event.BUBBLE_BAR_BUBBLE_SWITCHED);
+ } else {
+ mLogger.log(b, BubbleLogger.Event.BUBBLE_BAR_EXPANDED);
+ }
} else if (mBubbleData.hasOverflowBubbleWithKey(b.getKey())) {
// TODO: (b/271468319) handle overflow
} else {
@@ -1327,6 +1605,137 @@ public class BubbleController implements ConfigurationChangeListener,
}
}
+ /**
+ * Expands and selects a bubble created or found via the provided shortcut info.
+ *
+ * @param info the shortcut info for the bubble.
+ * @param bubbleBarLocation optional location in case bubble bar should be repositioned.
+ */
+ public void expandStackAndSelectBubble(ShortcutInfo info,
+ @Nullable BubbleBarLocation bubbleBarLocation) {
+ if (!BubbleAnythingFlagHelper.enableCreateAnyBubble()) return;
+ Bubble b = mBubbleData.getOrCreateBubble(info); // Removes from overflow
+ ProtoLog.v(WM_SHELL_BUBBLES, "expandStackAndSelectBubble - shortcut=%s", info);
+ expandStackAndSelectAppBubble(b, bubbleBarLocation, UpdateSource.APP_ICON_DRAG);
+ }
+
+ /**
+ * Expands and selects a bubble created or found for this app.
+ *
+ * @param intent the intent for the bubble.
+ */
+ public void expandStackAndSelectBubble(Intent intent, UserHandle user,
+ @Nullable BubbleBarLocation bubbleBarLocation) {
+ if (!BubbleAnythingFlagHelper.enableCreateAnyBubble()) return;
+ Bubble b = mBubbleData.getOrCreateBubble(intent, user); // Removes from overflow
+ ProtoLog.v(WM_SHELL_BUBBLES, "expandStackAndSelectBubble - intent=%s", intent);
+ expandStackAndSelectAppBubble(b, bubbleBarLocation, UpdateSource.APP_ICON_DRAG);
+ }
+
+ /**
+ * Expands and selects a bubble created or found for this app.
+ *
+ * @param pendingIntent the intent for the bubble.
+ * @param bubbleBarLocation optional location in case bubble bar should be repositioned.
+ */
+ public void expandStackAndSelectBubble(PendingIntent pendingIntent, UserHandle user,
+ @Nullable BubbleBarLocation bubbleBarLocation) {
+ if (!BubbleAnythingFlagHelper.enableCreateAnyBubble()) return;
+ Bubble b = mBubbleData.getOrCreateBubble(pendingIntent, user); // Removes from overflow
+ ProtoLog.v(WM_SHELL_BUBBLES, "expandStackAndSelectBubble - pendingIntent=%s",
+ pendingIntent);
+ expandStackAndSelectAppBubble(b, bubbleBarLocation, UpdateSource.APP_ICON_DRAG);
+ }
+
+ void expandStackAndSelectAppBubble(Bubble b, @Nullable BubbleBarLocation bubbleBarLocation,
+ @UpdateSource int source) {
+ if (!BubbleAnythingFlagHelper.enableCreateAnyBubble()) return;
+ BubbleBarLocation updateLocation = isShowingAsBubbleBar() ? bubbleBarLocation : null;
+ if (updateLocation != null) {
+ // does not update the bubble bar location of the bubble bar, just expanded view
+ updateExpandedViewForBubbleBarLocation(updateLocation, source);
+ }
+ if (b.isInflated()) {
+ // mBubbleData should be updated with the new location to update the bubble bar location
+ mBubbleData.setSelectedBubbleAndExpandStack(b, updateLocation);
+ } else {
+ b.enable(Notification.BubbleMetadata.FLAG_AUTO_EXPAND_BUBBLE);
+
+ if (isShowingAsBubbleBar()) {
+ ensureBubbleViewsAndWindowCreated();
+ mBubbleTransitions.startLaunchIntoOrConvertToBubble(b, mExpandedViewManager,
+ mBubbleTaskViewFactory, mBubblePositioner, mStackView, mLayerView,
+ mBubbleIconFactory, mInflateSynchronously, bubbleBarLocation);
+ } else {
+ inflateAndAdd(b, /* suppressFlyout= */ true, /* showInShade= */ false,
+ updateLocation);
+ }
+ }
+ }
+
+ /**
+ * Expands and selects a bubble created from a running task in a different mode.
+ *
+ * @param taskInfo the task.
+ * @param dragData optional information about the task when it is being dragged into a bubble
+ */
+ public void expandStackAndSelectBubble(ActivityManager.RunningTaskInfo taskInfo,
+ @Nullable BubbleTransitions.DragData dragData) {
+ if (!BubbleAnythingFlagHelper.enableCreateAnyBubble()) return;
+ Bubble b = mBubbleData.getOrCreateBubble(taskInfo); // Removes from overflow
+ ProtoLog.v(WM_SHELL_BUBBLES, "expandStackAndSelectBubble - taskId=%s", taskInfo.taskId);
+ BubbleBarLocation location = null;
+ if (dragData != null) {
+ location =
+ dragData.isReleasedOnLeft() ? BubbleBarLocation.LEFT : BubbleBarLocation.RIGHT;
+ }
+ if (b.isInflated()) {
+ mBubbleData.setSelectedBubbleAndExpandStack(b, location);
+ } else {
+ if (location != null) {
+ setBubbleBarLocation(location, UpdateSource.DRAG_TASK);
+ }
+ b.enable(Notification.BubbleMetadata.FLAG_AUTO_EXPAND_BUBBLE);
+ // Lazy init stack view when a bubble is created
+ ensureBubbleViewsAndWindowCreated();
+ mBubbleTransitions.startConvertToBubble(b, taskInfo, mExpandedViewManager,
+ mBubbleTaskViewFactory, mBubblePositioner, mStackView, mLayerView,
+ mBubbleIconFactory, mHomeIntentProvider, dragData, mInflateSynchronously);
+ }
+ }
+
+ /**
+ * Expands and selects a bubble created from a running task in a different mode.
+ *
+ * @param taskInfo the task.
+ */
+ @Nullable
+ public Transitions.TransitionHandler expandStackAndSelectBubbleForExistingTransition(
+ @NonNull ActivityManager.RunningTaskInfo taskInfo,
+ @NonNull IBinder transition,
+ Consumer onInflatedCallback) {
+ if (!BubbleAnythingFlagHelper.enableCreateAnyBubble()) return null;
+
+ Bubble b = mBubbleData.getBubbleInStackWithTaskId(taskInfo.taskId);
+ if (b != null) {
+ // Reuse the existing bubble
+ mBubbleData.setSelectedBubbleAndExpandStack(b, BubbleBarLocation.DEFAULT);
+ } else {
+ // Create a new bubble and show it, remove from overflow
+ b = mBubbleData.getOrCreateBubble(taskInfo);
+ }
+ ProtoLog.v(WM_SHELL_BUBBLES, "expandStackAndSelectBubbleForExistingTransition() taskId=%s",
+ taskInfo.taskId);
+ b.enable(Notification.BubbleMetadata.FLAG_AUTO_EXPAND_BUBBLE);
+
+ // Lazy init stack view when a bubble is created
+ ensureBubbleViewsAndWindowCreated();
+ return mBubbleTransitions.startLaunchNewTaskBubbleForExistingTransition(b,
+ mExpandedViewManager, mBubbleTaskViewFactory, mBubblePositioner, mStackView,
+ mLayerView, mBubbleIconFactory, mInflateSynchronously, transition,
+ onInflatedCallback);
+ }
+
/**
* Expands and selects a bubble based on the provided {@link BubbleEntry}. If no bubble
* exists for this entry, and it is able to bubble, a new bubble will be created.
@@ -1385,76 +1794,80 @@ public class BubbleController implements ConfigurationChangeListener,
/**
* This method has different behavior depending on:
- * - if an app bubble exists
- * - if an app bubble is expanded
+ * - if a notes bubble exists
+ * - if a notes bubble is expanded
*
- * If no app bubble exists, this will add and expand a bubble with the provided intent. The
+ * If no notes bubble exists, this will add and expand a bubble with the provided intent. The
* intent must be explicit (i.e. include a package name or fully qualified component class name)
* and the activity for it should be resizable.
*
- * If an app bubble exists, this will toggle the visibility of it, i.e. if the app bubble is
- * expanded, calling this method will collapse it. If the app bubble is not expanded, calling
+ * If a notes bubble exists, this will toggle the visibility of it, i.e. if the notes bubble is
+ * expanded, calling this method will collapse it. If the notes bubble is not expanded, calling
* this method will expand it.
*
* These bubbles are not backed by a notification and remain until the user dismisses
* the bubble or bubble stack.
*
- * Some notes:
- * - Only one app bubble is supported at a time, regardless of users. Multi-users support is
- * tracked in b/273533235.
- * - Calling this method with a different intent than the existing app bubble will do nothing
+ * Some details:
+ * - Calling this method with a different intent than the existing bubble will do nothing
*
* @param intent the intent to display in the bubble expanded view.
* @param user the {@link UserHandle} of the user to start this activity for.
* @param icon the {@link Icon} to use for the bubble view.
*/
- public void showOrHideAppBubble(Intent intent, UserHandle user, @Nullable Icon icon) {
+ public void showOrHideNotesBubble(Intent intent, UserHandle user, @Nullable Icon icon) {
if (intent == null || intent.getPackage() == null) {
- Log.w(TAG, "App bubble failed to show, invalid intent: " + intent
+ Log.w(TAG, "Notes bubble failed to show, invalid intent: " + intent
+ ((intent != null) ? " with package: " + intent.getPackage() : " "));
return;
}
- String appBubbleKey = Bubble.getAppBubbleKeyForApp(intent.getPackage(), user);
+ String noteBubbleKey = Bubble.getNoteBubbleKeyForApp(intent.getPackage(), user);
PackageManager packageManager = getPackageManagerForUser(mContext, user.getIdentifier());
- if (!isResizableActivity(intent, packageManager, appBubbleKey)) return; // logs errors
+ if (!mResizabilityChecker.isResizableActivity(intent, packageManager, noteBubbleKey)) {
+ // resize check logs any errors
+ return;
+ }
- Bubble existingAppBubble = mBubbleData.getBubbleInStackWithKey(appBubbleKey);
+ Bubble existingNotebubble = mBubbleData.getBubbleInStackWithKey(noteBubbleKey);
ProtoLog.d(WM_SHELL_BUBBLES,
- "showOrHideAppBubble, key=%s existingAppBubble=%s stackVisibility=%s "
+ "showOrHideNotesBubble, key=%s existingAppBubble=%s stackVisibility=%s "
+ "statusBarShade=%s",
- appBubbleKey, existingAppBubble,
+ noteBubbleKey, existingNotebubble,
(mStackView != null ? mStackView.getVisibility() : "null"),
mIsStatusBarShade);
- if (existingAppBubble != null) {
+ if (existingNotebubble != null) {
BubbleViewProvider selectedBubble = mBubbleData.getSelectedBubble();
if (isStackExpanded()) {
- if (selectedBubble != null && appBubbleKey.equals(selectedBubble.getKey())) {
- ProtoLog.d(WM_SHELL_BUBBLES, "collapseStack for %s", appBubbleKey);
- // App bubble is expanded, lets collapse
+ if (selectedBubble != null && noteBubbleKey.equals(selectedBubble.getKey())) {
+ ProtoLog.d(WM_SHELL_BUBBLES, "collapseStack for %s", noteBubbleKey);
+ // Notes bubble is expanded, lets collapse
collapseStack();
} else {
- ProtoLog.d(WM_SHELL_BUBBLES, "setSelected for %s", appBubbleKey);
- // App bubble is not selected, select it
- mBubbleData.setSelectedBubble(existingAppBubble);
+ ProtoLog.d(WM_SHELL_BUBBLES, "setSelected for %s", noteBubbleKey);
+ // Notes bubble is not selected, select it
+ mBubbleData.setSelectedBubble(existingNotebubble);
}
} else {
- ProtoLog.d(WM_SHELL_BUBBLES, "setSelectedBubbleAndExpandStack %s", appBubbleKey);
- // App bubble is not selected, select it & expand
- mBubbleData.setSelectedBubbleAndExpandStack(existingAppBubble);
+ ProtoLog.d(WM_SHELL_BUBBLES, "setSelectedBubbleAndExpandStack %s", noteBubbleKey);
+ // Notes bubble is not selected, select it & expand
+ mBubbleData.setSelectedBubbleAndExpandStack(existingNotebubble);
}
} else {
// Check if it exists in the overflow
- Bubble b = mBubbleData.getOverflowBubbleWithKey(appBubbleKey);
+ Bubble b = mBubbleData.getOverflowBubbleWithKey(noteBubbleKey);
if (b != null) {
// It's in the overflow, so remove it & reinflate
- mBubbleData.dismissBubbleWithKey(appBubbleKey, Bubbles.DISMISS_NOTIF_CANCEL);
+ mBubbleData.dismissBubbleWithKey(noteBubbleKey, Bubbles.DISMISS_NOTIF_CANCEL);
+ // Update the bubble entry in the overflow with the latest intent.
+ b.setIntent(intent);
} else {
- // App bubble does not exist, lets add and expand it
- b = Bubble.createAppBubble(intent, user, icon, mMainExecutor);
+ // Notes bubble does not exist, lets add and expand it
+ b = Bubble.createNotesBubble(intent, user, icon, mMainExecutor,
+ mBackgroundExecutor);
}
- ProtoLog.d(WM_SHELL_BUBBLES, "inflateAndAdd %s", appBubbleKey);
+ ProtoLog.d(WM_SHELL_BUBBLES, "inflateAndAdd %s", noteBubbleKey);
b.setShouldAutoExpand(true);
inflateAndAdd(b, /* suppressFlyout= */ true, /* showInShade= */ false);
}
@@ -1502,9 +1915,9 @@ public class BubbleController implements ConfigurationChangeListener,
}
}
- /** Sets the app bubble's taskId which is cached for SysUI. */
- public void setAppBubbleTaskId(String key, int taskId) {
- mImpl.mCachedState.setAppBubbleTaskId(key, taskId);
+ /** Sets the note bubble's taskId which is cached for SysUI. */
+ public void setNoteBubbleTaskId(String key, int taskId) {
+ mImpl.mCachedState.setNoteBubbleTaskId(key, taskId);
}
/**
@@ -1516,7 +1929,7 @@ public class BubbleController implements ConfigurationChangeListener,
}
mOverflowDataLoadNeeded = false;
List users = mUserManager.getAliveUsers();
- List userIds = users.stream().map(userInfo -> userInfo.id).collect(Collectors.toList());
+ List userIds = users.stream().map(userInfo -> userInfo.id).toList();
mDataRepository.loadBubbles(mCurrentUserId, userIds, (bubbles) -> {
bubbles.forEach(bubble -> {
if (mBubbleData.hasAnyBubbleWithKey(bubble.getKey())) {
@@ -1532,6 +1945,7 @@ public class BubbleController implements ConfigurationChangeListener,
mStackView,
mLayerView,
mBubbleIconFactory,
+ mAppInfoProvider,
true /* skipInflation */);
});
return null;
@@ -1539,6 +1953,18 @@ public class BubbleController implements ConfigurationChangeListener,
}
void setUpBubbleViewsForMode() {
+ if (mBubbleData.isExpanded() && !mBubbleData.isShowingOverflow()
+ && isShowingAsBubbleBar()) {
+ Bubble bubble = (Bubble) mBubbleData.getSelectedBubble();
+ if (bubble != null) {
+ mBubbleTransitions.startFloatingToBarConversion(bubble, mBubblePositioner);
+ TaskView taskView = bubble.getTaskView();
+ taskView.setIsMovingWindows(true);
+ ViewGroup parent = (ViewGroup) taskView.getParent();
+ parent.removeView(taskView);
+ }
+ }
+
mBubbleViewCallback = isShowingAsBubbleBar()
? mBubbleBarViewCallback
: mBubbleStackViewCallback;
@@ -1571,8 +1997,11 @@ public class BubbleController implements ConfigurationChangeListener,
if (!isShowingAsBubbleBar()) {
callback = b -> {
if (mStackView != null) {
+ b.setSuppressFlyout(true);
mStackView.addBubble(b);
- mStackView.setSelectedBubble(b);
+ if (b.getKey().equals(mBubbleData.getSelectedBubbleKey())) {
+ mStackView.setSelectedBubble(b);
+ }
} else {
Log.w(TAG, "Tried to add a bubble to the stack but the stack is null");
}
@@ -1584,6 +2013,11 @@ public class BubbleController implements ConfigurationChangeListener,
}
};
}
+ // TODO (b/380105874): Remove this after properly transitioning the expanded bubble from bar
+ // to floating
+ if (mStackView != null && mBubbleData.isExpanded()) {
+ mBubbleData.collapseNoUpdate();
+ }
for (int i = mBubbleData.getBubbles().size() - 1; i >= 0; i--) {
Bubble bubble = mBubbleData.getBubbles().get(i);
bubble.inflate(callback,
@@ -1594,8 +2028,19 @@ public class BubbleController implements ConfigurationChangeListener,
mStackView,
mLayerView,
mBubbleIconFactory,
+ mAppInfoProvider,
false /* skipInflation */);
}
+ if (mBubbleData.isShowingOverflow()) {
+ BubbleOverflow bubbleOverflow = mBubbleData.getOverflow();
+ if (isShowingAsBubbleBar()) {
+ bubbleOverflow.initializeForBubbleBar(mExpandedViewManager, mBubblePositioner);
+ mLayerView.showExpandedView(bubbleOverflow);
+ } else {
+ bubbleOverflow.initialize(mExpandedViewManager, mStackView, mBubblePositioner);
+ }
+
+ }
}
/**
@@ -1609,31 +2054,32 @@ public class BubbleController implements ConfigurationChangeListener,
public void updateBubble(BubbleEntry notif, boolean suppressFlyout, boolean showInShade) {
// If this is an interruptive notif, mark that it's interrupted
mSysuiProxy.setNotificationInterruption(notif.getKey());
- boolean isNonInterruptiveNotExpanding = !notif.getRanking().isTextChanged()
+ final boolean isNonInterruptiveNotExpanding = !notif.getRanking().isTextChanged()
&& (notif.getBubbleMetadata() != null
&& !notif.getBubbleMetadata().getAutoExpandBubble());
+ final Bubble bubble;
if (isNonInterruptiveNotExpanding
&& mBubbleData.hasOverflowBubbleWithKey(notif.getKey())) {
// Update the bubble but don't promote it out of overflow
- Bubble b = mBubbleData.getOverflowBubbleWithKey(notif.getKey());
+ bubble = mBubbleData.getOverflowBubbleWithKey(notif.getKey());
if (notif.isBubble()) {
notif.setFlagBubble(false);
}
- updateNotNotifyingEntry(b, notif, showInShade);
+ updateNotNotifyingEntry(bubble, notif, showInShade);
} else if (mBubbleData.hasAnyBubbleWithKey(notif.getKey())
&& isNonInterruptiveNotExpanding) {
- Bubble b = mBubbleData.getAnyBubbleWithkey(notif.getKey());
- if (b != null) {
- updateNotNotifyingEntry(b, notif, showInShade);
+ bubble = mBubbleData.getAnyBubbleWithKey(notif.getKey());
+ if (bubble != null) {
+ updateNotNotifyingEntry(bubble, notif, showInShade);
}
} else if (mBubbleData.isSuppressedWithLocusId(notif.getLocusId())) {
// Update the bubble but don't promote it out of overflow
- Bubble b = mBubbleData.getSuppressedBubbleWithKey(notif.getKey());
- if (b != null) {
- updateNotNotifyingEntry(b, notif, showInShade);
+ bubble = mBubbleData.getSuppressedBubbleWithKey(notif.getKey());
+ if (bubble != null) {
+ updateNotNotifyingEntry(bubble, notif, showInShade);
}
} else {
- Bubble bubble = mBubbleData.getOrCreateBubble(notif, null /* persistedBubble */);
+ bubble = mBubbleData.getOrCreateBubble(notif, null /* persistedBubble */);
if (notif.shouldSuppressNotificationList()) {
// If we're suppressing notifs for DND, we don't want the bubbles to randomly
// expand when DND turns off so flip the flag.
@@ -1662,11 +2108,22 @@ public class BubbleController implements ConfigurationChangeListener,
@VisibleForTesting
public void inflateAndAdd(Bubble bubble, boolean suppressFlyout, boolean showInShade) {
+ inflateAndAdd(bubble, suppressFlyout, showInShade, /* bubbleBarLocation= */ null);
+ }
+
+ /**
+ * Inflates and adds a bubble. Updates Bubble Bar location if bubbles
+ * are shown in the Bubble Bar and the location is not null.
+ */
+ @VisibleForTesting
+ public void inflateAndAdd(Bubble bubble, boolean suppressFlyout, boolean showInShade,
+ @Nullable BubbleBarLocation bubbleBarLocation) {
// Lazy init stack view when a bubble is created
ensureBubbleViewsAndWindowCreated();
bubble.setInflateSynchronously(mInflateSynchronously);
bubble.inflate(
- b -> mBubbleData.notificationEntryUpdated(b, suppressFlyout, showInShade),
+ b -> mBubbleData.notificationEntryUpdated(b, suppressFlyout, showInShade,
+ bubbleBarLocation),
mContext,
mExpandedViewManager,
mBubbleTaskViewFactory,
@@ -1674,6 +2131,7 @@ public class BubbleController implements ConfigurationChangeListener,
mStackView,
mLayerView,
mBubbleIconFactory,
+ mAppInfoProvider,
false /* skipInflation */);
}
@@ -1698,6 +2156,9 @@ public class BubbleController implements ConfigurationChangeListener,
@MainThread
public void removeAllBubbles(@Bubbles.DismissReason int reason) {
mBubbleData.dismissAll(reason);
+ if (reason == Bubbles.DISMISS_USER_GESTURE) {
+ mLogger.log(BubbleLogger.Event.BUBBLE_BAR_DISMISSED_DRAG_BAR);
+ }
}
private void onEntryAdded(BubbleEntry entry) {
@@ -1848,6 +2309,41 @@ public class BubbleController implements ConfigurationChangeListener,
});
}
+ @Override
+ public boolean mergeTaskWithUnfold(@NonNull ActivityManager.RunningTaskInfo taskInfo,
+ @NonNull TransitionInfo info,
+ @NonNull TransitionInfo.Change change,
+ @NonNull SurfaceControl.Transaction startT,
+ @NonNull SurfaceControl.Transaction finishT) {
+ if (!mBubbleTransitions.mTaskViewTransitions.isTaskViewTask(taskInfo)) {
+ // if this task isn't managed by bubble transitions just bail.
+ return false;
+ }
+ if (isShowingAsBubbleBar()) {
+ if (info.getType() != TRANSIT_BUBBLE_CONVERT_FLOATING_TO_BAR) {
+ return false;
+ }
+ if (mBubbleData.getSelectedBubble() instanceof Bubble) {
+ Bubble bubble = (Bubble) mBubbleData.getSelectedBubble();
+ if (bubble.getPreparingTransition() != null) {
+ bubble.getPreparingTransition().mergeWithUnfold(change.getLeash(), finishT);
+ }
+ return true;
+ }
+ return false;
+ }
+
+ boolean merged = mBubbleTransitions.mTaskViewTransitions.updateBoundsForUnfold(
+ change.getEndAbsBounds(), startT, finishT, change.getTaskInfo(), change.getLeash());
+ if (merged) {
+ BubbleViewProvider selectedBubble = mBubbleData.getSelectedBubble();
+ if (selectedBubble != null && selectedBubble.getExpandedView() != null) {
+ selectedBubble.getExpandedView().onContainerClipUpdate();
+ }
+ }
+ return merged;
+ }
+
/** When bubbles are floating, this will be used to notify the floating views. */
private final BubbleViewCallback mBubbleStackViewCallback = new BubbleViewCallback() {
@Override
@@ -1915,7 +2411,12 @@ public class BubbleController implements ConfigurationChangeListener,
@Override
public void removeBubble(Bubble removedBubble) {
if (mLayerView != null) {
+ final BubbleTransitions.BubbleTransition bubbleTransit =
+ removedBubble.getPreparingTransition();
mLayerView.removeBubble(removedBubble, () -> {
+ if (bubbleTransit != null) {
+ bubbleTransit.continueCollapse();
+ }
if (!mBubbleData.hasBubbles() && !isStackExpanded()) {
mLayerView.setVisibility(INVISIBLE);
removeFromWindowManagerMaybe();
@@ -1926,11 +2427,15 @@ public class BubbleController implements ConfigurationChangeListener,
@Override
public void addBubble(Bubble addedBubble) {
+ // Only log metrics event
+ mLogger.log(addedBubble, BubbleLogger.Event.BUBBLE_BAR_BUBBLE_POSTED);
// Nothing to do for adds, these are handled by launcher / in the bubble bar.
}
@Override
public void updateBubble(Bubble updatedBubble) {
+ // Only log metrics event
+ mLogger.log(updatedBubble, BubbleLogger.Event.BUBBLE_BAR_BUBBLE_UPDATED);
// Nothing to do for updates, these are handled by launcher / in the bubble bar.
}
@@ -1946,31 +2451,36 @@ public class BubbleController implements ConfigurationChangeListener,
@Override
public void suppressionChanged(Bubble bubble, boolean isSuppressed) {
- if (mLayerView != null) {
- // TODO (b/273316505) handle suppression changes, although might not need to
- // to do anything on the layerview side for this...
- }
+ // Nothing to do for our views, handled by launcher / in the bubble bar.
}
@Override
public void expansionChanged(boolean isExpanded) {
- if (mLayerView != null) {
- if (!isExpanded) {
- mLayerView.collapse();
- } else {
- BubbleViewProvider selectedBubble = mBubbleData.getSelectedBubble();
- if (selectedBubble != null) {
- mLayerView.showExpandedView(selectedBubble);
- }
- }
+ // in bubble bar mode, let the request to show the expanded view come from launcher.
+ // only collapse here if we're collapsing.
+ if (!isExpanded) {
+ collapseExpandedViewForBubbleBar();
+ }
+
+ BubbleLogger.Event event = isExpanded ? BubbleLogger.Event.BUBBLE_BAR_EXPANDED
+ : BubbleLogger.Event.BUBBLE_BAR_COLLAPSED;
+ BubbleViewProvider selectedBubble = mBubbleData.getSelectedBubble();
+ if (selectedBubble instanceof Bubble) {
+ mLogger.log((Bubble) selectedBubble, event);
+ } else {
+ mLogger.log(event);
}
}
@Override
public void selectionChanged(BubbleViewProvider selectedBubble) {
// Only need to update the layer view if we're currently expanded for selection changes.
- if (mLayerView != null && isStackExpanded()) {
+ if (mLayerView != null && mLayerView.isExpanded()) {
mLayerView.showExpandedView(selectedBubble);
+ if (selectedBubble instanceof Bubble) {
+ mLogger.log((Bubble) selectedBubble,
+ BubbleLogger.Event.BUBBLE_BAR_BUBBLE_SWITCHED);
+ }
}
}
};
@@ -1983,7 +2493,8 @@ public class BubbleController implements ConfigurationChangeListener,
ProtoLog.d(WM_SHELL_BUBBLES, "mBubbleDataListener#applyUpdate:"
+ " added=%s removed=%b updated=%s orderChanged=%b expansionChanged=%b"
+ " expanded=%b selectionChanged=%b selected=%s"
- + " suppressed=%s unsupressed=%s shouldShowEducation=%b showOverflowChanged=%b",
+ + " suppressed=%s unsupressed=%s shouldShowEducation=%b showOverflowChanged=%b"
+ + " bubbleBarLocation=%s",
update.addedBubble != null ? update.addedBubble.getKey() : "null",
!update.removedBubbles.isEmpty(),
update.updatedBubble != null ? update.updatedBubble.getKey() : "null",
@@ -1992,7 +2503,9 @@ public class BubbleController implements ConfigurationChangeListener,
update.selectedBubble != null ? update.selectedBubble.getKey() : "null",
update.suppressedBubble != null ? update.suppressedBubble.getKey() : "null",
update.unsuppressedBubble != null ? update.unsuppressedBubble.getKey() : "null",
- update.shouldShowEducation, update.showOverflowChanged);
+ update.shouldShowEducation, update.showOverflowChanged,
+ update.mBubbleBarLocation != null ? update.mBubbleBarLocation.toString()
+ : "null");
ensureBubbleViewsAndWindowCreated();
@@ -2020,6 +2533,9 @@ public class BubbleController implements ConfigurationChangeListener,
@Bubbles.DismissReason final int reason = removed.second;
mBubbleViewCallback.removeBubble(bubble);
+ if (bubble.getClientToken() != null && mBubbleMultitaskingDelegate != null) {
+ mBubbleMultitaskingDelegate.onBubbleRemoved(bubble.getClientToken(), reason);
+ }
// Leave the notification in place if we're dismissing due to user switching, or
// because DND is suppressing the bubble. In both of those cases, we need to be able
@@ -2092,6 +2608,16 @@ public class BubbleController implements ConfigurationChangeListener,
mSysuiProxy.requestNotificationShadeTopUi(true, TAG);
}
+ if (Flags.enableBubbleSwipeUpCleanup() && !update.removedBubbles.isEmpty()
+ && !mBubbleData.hasBubbles()) {
+ // This update removed all the bubbles. Send an update to SystemUI to mark the stack
+ // collapsed. This should be sent by the UI classes (BubbleStackView or
+ // BubbleBarLayerView), but if we fail to send this, home gesture stops working.
+ // To avoid leaving the device in a bad state, add a failsafe call here to clean
+ // up the state.
+ mSysuiProxy.onStackExpandChanged(false);
+ }
+
mSysuiProxy.notifyInvalidateNotifications("BubbleData.Listener.applyUpdate");
updateBubbleViews();
@@ -2101,13 +2627,39 @@ public class BubbleController implements ConfigurationChangeListener,
if (isShowingAsBubbleBar()) {
BubbleBarUpdate bubbleBarUpdate = update.toBubbleBarUpdate();
// Some updates aren't relevant to the bubble bar so check first.
- if (bubbleBarUpdate.anythingChanged()) {
+ if (bubbleBarUpdate.anythingChanged() && mBubbleStateListener != null) {
mBubbleStateListener.onBubbleStateChange(bubbleBarUpdate);
}
}
}
};
+ private void showExpandedViewForBubbleBar() {
+ BubbleViewProvider selectedBubble = mBubbleData.getSelectedBubble();
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "Controller.showExpandedViewForBubbleBar: bubble=%s",
+ selectedBubble);
+ if (selectedBubble == null) return;
+ if (selectedBubble instanceof Bubble) {
+ final Bubble bubble = (Bubble) selectedBubble;
+ if (bubble.getPreparingTransition() != null) {
+ bubble.getPreparingTransition().continueExpand();
+ return;
+ }
+ }
+ if (mLayerView == null) return;
+ mLayerView.showExpandedView(selectedBubble);
+ }
+
+ private void collapseExpandedViewForBubbleBar() {
+ if (mLayerView != null && mLayerView.isExpanded()) {
+ if (mBubblePositioner.isImeVisible()) {
+ // If we're collapsing, hide the IME
+ hideCurrentInputMethod(null);
+ }
+ mLayerView.collapse();
+ }
+ }
+
private void updateOverflowButtonDot() {
BubbleOverflow overflow = mBubbleData.getOverflow();
if (overflow == null) return;
@@ -2154,12 +2706,12 @@ public class BubbleController implements ConfigurationChangeListener,
BubbleEntry summary, @Nullable List children, IntConsumer removeCallback) {
if (children != null) {
for (int i = 0; i < children.size(); i++) {
- BubbleEntry child = children.get(i);
+ final BubbleEntry child = children.get(i);
if (mBubbleData.hasAnyBubbleWithKey(child.getKey())) {
// Suppress the bubbled child
// As far as group manager is concerned, once a child is no longer shown
// in the shade, it is essentially removed.
- Bubble bubbleChild = mBubbleData.getAnyBubbleWithkey(child.getKey());
+ final Bubble bubbleChild = mBubbleData.getAnyBubbleWithKey(child.getKey());
if (bubbleChild != null) {
bubbleChild.setSuppressNotification(true);
bubbleChild.setShowDot(false /* show */);
@@ -2174,7 +2726,6 @@ public class BubbleController implements ConfigurationChangeListener,
// And since all children are removed, remove the summary.
removeCallback.accept(-1);
- // TODO: (b/145659174) remove references to mSuppressedGroupKeys once fully migrated
mBubbleData.addSummaryToSuppress(summary.getStatusBarNotification().getGroupKey(),
summary.getKey());
}
@@ -2241,6 +2792,28 @@ public class BubbleController implements ConfigurationChangeListener,
return mLayerView;
}
+ @Nullable
+ public ActivityManager.RunningTaskInfo getAppBubbleRootTaskInfo() {
+ return mAppBubbleRootTaskInfo;
+ }
+
+ /**
+ * Returns the id of the display to which the current Bubble view is attached if it is currently
+ * showing, {@link INVALID_DISPLAY} otherwise.
+ */
+ @VisibleForTesting
+ public int getCurrentViewDisplayId() {
+ if (isShowingAsBubbleBar() && mLayerView != null && mLayerView.getDisplay() != null) {
+ return mLayerView.getDisplay().getDisplayId();
+ }
+
+ if (!isShowingAsBubbleBar() && mStackView != null && mStackView.getDisplay() != null) {
+ return mStackView.getDisplay().getDisplayId();
+ }
+
+ return INVALID_DISPLAY;
+ }
+
/**
* Check if notification panel is in an expanded state.
* Makes a call to System UI process and delivers the result via {@code callback} on the
@@ -2281,6 +2854,15 @@ public class BubbleController implements ConfigurationChangeListener,
mBubbleData.setSelectedBubbleAndExpandStack(bubbleToSelect);
}
+ private void moveDraggedBubbleToFullscreen(String key, Point dropLocation) {
+ Bubble b = mBubbleData.getBubbleInStackWithKey(key);
+ mBubbleTransitions.startDraggedBubbleIconToFullscreen(b, dropLocation);
+ }
+
+ private boolean isDeviceLocked() {
+ return !mIsStatusBarShade;
+ }
+
/**
* Description of current bubble state.
*/
@@ -2289,6 +2871,10 @@ public class BubbleController implements ConfigurationChangeListener,
pw.print(prefix); pw.println(" currentUserId= " + mCurrentUserId);
pw.print(prefix); pw.println(" isStatusBarShade= " + mIsStatusBarShade);
pw.print(prefix); pw.println(" isShowingAsBubbleBar= " + isShowingAsBubbleBar());
+ pw.print(prefix); pw.println(" launcherHasBubbleBar= " + mLauncherHasBubbleBar);
+ pw.print(prefix); pw.println(" bubbleStateListenerSet= " + (mBubbleStateListener != null));
+ pw.print(prefix); pw.println(" stackViewSet= " + (mStackView != null));
+ pw.print(prefix); pw.println(" layerViewSet= " + (mLayerView != null));
pw.print(prefix); pw.println(" isImeVisible= " + mBubblePositioner.isImeVisible());
pw.println();
@@ -2301,6 +2887,9 @@ public class BubbleController implements ConfigurationChangeListener,
pw.println();
mImpl.mCachedState.dump(pw);
+
+ pw.println();
+ mBubbleTransitions.mTaskViewTransitions.dump(pw);
}
/**
@@ -2313,7 +2902,8 @@ public class BubbleController implements ConfigurationChangeListener,
* @param context the context to use.
* @param entry the entry to bubble.
*/
- static boolean canLaunchInTaskView(Context context, BubbleEntry entry) {
+ boolean canLaunchInTaskView(Context context, BubbleEntry entry) {
+ if (BubbleAnythingFlagHelper.enableCreateAnyBubble()) return true;
PendingIntent intent = entry.getBubbleMetadata() != null
? entry.getBubbleMetadata().getIntent()
: null;
@@ -2327,29 +2917,12 @@ public class BubbleController implements ConfigurationChangeListener,
}
PackageManager packageManager = getPackageManagerForUser(
context, entry.getStatusBarNotification().getUser().getIdentifier());
- return isResizableActivity(intent.getIntent(), packageManager, entry.getKey());
+ return mResizabilityChecker.isResizableActivity(intent.getIntent(), packageManager,
+ entry.getKey());
}
- static boolean isResizableActivity(Intent intent, PackageManager packageManager, String key) {
- if (intent == null) {
- Log.w(TAG, "Unable to send as bubble: " + key + " null intent");
- return false;
- }
- ActivityInfo info = intent.resolveActivityInfo(packageManager, 0);
- if (info == null) {
- Log.w(TAG, "Unable to send as bubble: " + key
- + " couldn't find activity info for intent: " + intent);
- return false;
- }
- if (!ActivityInfo.isResizeableMode(info.resizeMode)) {
- Log.w(TAG, "Unable to send as bubble: " + key
- + " activity is not resizable for intent: " + intent);
- return false;
- }
- return true;
- }
-
- static PackageManager getPackageManagerForUser(Context context, int userId) {
+ /** Gets the {@link PackageManager} for the user's context. */
+ public static PackageManager getPackageManagerForUser(Context context, int userId) {
Context contextForUser = context;
// UserHandle defines special userId as negative values, e.g. USER_ALL
if (userId >= 0) {
@@ -2367,14 +2940,59 @@ public class BubbleController implements ConfigurationChangeListener,
return contextForUser.getPackageManager();
}
- /** PinnedStackListener that dispatches IME visibility updates to the stack. */
- //TODO(b/170442945): Better way to do this / insets listener?
- private class BubblesImeListener extends PinnedStackListenerForwarder.PinnedTaskListener {
+ /** {@link ImeListener} that dispatches IME visibility updates to the stack. */
+ private class BubblesImeListener extends ImeListener implements
+ DisplayImeController.ImePositionProcessor {
+
+ BubblesImeListener(DisplayController displayController, int displayId) {
+ super(displayController, displayId);
+ }
+
@Override
- public void onImeVisibilityChanged(boolean imeVisible, int imeHeight) {
- mBubblePositioner.setImeVisible(imeVisible, imeHeight);
+ protected void onImeVisibilityChanged(boolean imeVisible, int imeHeight) {
+ if (getDisplayId() != mContext.getDisplayId()) {
+ return;
+ }
+ // the imeHeight here is actually the ime inset; it only includes the part of the ime
+ // that overlaps with the Bubbles window. adjust it to include the bottom screen inset,
+ // so we have the total height of the ime.
+ int totalImeHeight = imeHeight + mBubblePositioner.getInsets().bottom;
+ mBubblePositioner.setImeVisible(imeVisible, totalImeHeight);
if (mStackView != null) {
mStackView.setImeVisible(imeVisible);
+ if (!imeVisible && mOnImeHidden != null) {
+ mOnImeHidden.run();
+ mOnImeHidden = null;
+ }
+ }
+ }
+
+ @Override
+ public int onImeStartPositioning(int displayId, int hiddenTop, int shownTop,
+ boolean showing, boolean isFloating, SurfaceControl.Transaction t) {
+ if (mContext.getDisplayId() != displayId) {
+ return IME_ANIMATION_DEFAULT;
+ }
+
+ if (showing) {
+ mBubblePositioner.setImeVisible(true, hiddenTop - shownTop);
+ } else {
+ mBubblePositioner.setImeVisible(false, 0);
+ }
+ if (mStackView != null) {
+ mStackView.setImeVisible(showing);
+ }
+
+ return IME_ANIMATION_DEFAULT;
+ }
+
+ @Override
+ public void onImePositionChanged(int displayId, int imeTop, SurfaceControl.Transaction t) {
+ if (mContext.getDisplayId() != displayId) {
+ return;
+ }
+ if (mLayerView != null) {
+ mLayerView.onImeTopChanged(imeTop);
}
}
}
@@ -2400,6 +3018,11 @@ public class BubbleController implements ConfigurationChangeListener,
public void animateBubbleBarLocation(BubbleBarLocation location) {
mListener.call(l -> l.animateBubbleBarLocation(location));
}
+
+ @Override
+ public void showBubbleBarPillowAt(@Nullable BubbleBarLocation location) {
+ mListener.call(l -> l.showBubbleBarPillowAt(location));
+ }
};
IBubblesImpl(BubbleController controller) {
@@ -2430,54 +3053,171 @@ public class BubbleController implements ConfigurationChangeListener,
}
@Override
- public void showBubble(String key, int topOnScreen) {
- mMainExecutor.execute(
- () -> mController.expandStackAndSelectBubbleFromLauncher(key, topOnScreen));
+ public void showShortcutBubble(ShortcutInfo info, @Nullable BubbleBarLocation location) {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "IBubbles.showShortcutBubble: info=%s loc=%s",
+ info, location);
+ executeRemoteCallWithTaskPermission(
+ mController,
+ "showShortcutBubble",
+ (controller) -> controller.expandStackAndSelectBubble(info, location));
+ }
+
+ @Override
+ public void showAppBubble(Intent intent, UserHandle user,
+ @Nullable BubbleBarLocation location) {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "IBubbles.showAppBubble: intent=%s user=%s loc=%s",
+ intent, user, location);
+ executeRemoteCallWithTaskPermission(
+ mController,
+ "showAppBubble",
+ (controller) -> controller.expandStackAndSelectBubble(intent, user, location));
+ }
+
+ @Override
+ public void showBubble(String key, int bubbleBarTopToScreenBottom) {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY,
+ "IBubbles.showBubble: key=%s bubbleBarTopToScreenBottom=%d",
+ key, bubbleBarTopToScreenBottom);
+ executeRemoteCallWithTaskPermission(
+ mController,
+ "showBubble",
+ (controller) ->
+ controller.expandStackAndSelectBubbleFromLauncher(
+ key, bubbleBarTopToScreenBottom));
}
@Override
public void removeAllBubbles() {
- mMainExecutor.execute(() -> mController.removeAllBubbles(Bubbles.DISMISS_USER_GESTURE));
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "IBubbles.removeAllBubbles");
+ executeRemoteCallWithTaskPermission(
+ mController,
+ "removeAllBubbles",
+ (controller) -> controller.removeAllBubbles(Bubbles.DISMISS_USER_GESTURE));
}
@Override
public void collapseBubbles() {
- mMainExecutor.execute(() -> mController.collapseStack());
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "IBubbles.collapseBubbles");
+ executeRemoteCallWithTaskPermission(
+ mController,
+ "collapseBubbles",
+ (controller) -> {
+ if (mBubbleData.getSelectedBubble() instanceof Bubble) {
+ if (((Bubble) mBubbleData.getSelectedBubble()).getPreparingTransition()
+ != null) {
+ // Currently preparing a transition which will, itself, collapse the
+ // bubble.
+ // For transition preparation, the timing of bubble-collapse must be
+ // in sync with the rest of the set-up.
+ return;
+ }
+ }
+ controller.collapseStack();
+ });
}
@Override
public void startBubbleDrag(String bubbleKey) {
- mMainExecutor.execute(() -> mController.startBubbleDrag(bubbleKey));
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "IBubbles.startBubbleDrag: key=%s", bubbleKey);
+ executeRemoteCallWithTaskPermission(
+ mController,
+ "startBubbleDrag",
+ (controller) -> controller.startBubbleDrag(bubbleKey));
}
@Override
- public void stopBubbleDrag(BubbleBarLocation location, int topOnScreen) {
- mMainExecutor.execute(() -> mController.stopBubbleDrag(location, topOnScreen));
+ public void stopBubbleDrag(BubbleBarLocation location, int bubbleBarTopToScreenBottom) {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY,
+ "IBubbles.stopBubbleDrag: log=%s bubbleBarTopToScreenBottom=%d",
+ location, bubbleBarTopToScreenBottom);
+ executeRemoteCallWithTaskPermission(
+ mController,
+ "stopBubbleDrag",
+ (controller) ->
+ controller.stopBubbleDrag(location, bubbleBarTopToScreenBottom));
}
@Override
- public void dragBubbleToDismiss(String key) {
- mMainExecutor.execute(() -> mController.dragBubbleToDismiss(key));
+ public void dragBubbleToDismiss(String key, long timestamp) {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "IBubbles.dragBubbleToDismiss: key=%s time=%d",
+ key, timestamp);
+ executeRemoteCallWithTaskPermission(
+ mController,
+ "dragBubbleToDismiss",
+ (controller) -> controller.dragBubbleToDismiss(key, timestamp));
}
@Override
public void showUserEducation(int positionX, int positionY) {
- mMainExecutor.execute(() ->
- mController.showUserEducation(new Point(positionX, positionY)));
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "IBubbles.showUserEducation: pos=[%d, %d]",
+ positionX, positionY);
+ executeRemoteCallWithTaskPermission(
+ mController,
+ "showUserEducation",
+ (controller) -> controller.showUserEducation(new Point(positionX, positionY)));
}
@Override
- public void setBubbleBarLocation(BubbleBarLocation location) {
- mMainExecutor.execute(() ->
- mController.setBubbleBarLocation(location));
+ public void setBubbleBarLocation(BubbleBarLocation location,
+ @UpdateSource int source) {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "IBubbles.setBubbleBarLocation: loc=%s src=%d",
+ location, source);
+ executeRemoteCallWithTaskPermission(
+ mController,
+ "setBubbleBarLocation",
+ (controller) -> controller.setBubbleBarLocation(location, source));
}
@Override
- public void updateBubbleBarTopOnScreen(int topOnScreen) {
- mMainExecutor.execute(() -> {
- mBubblePositioner.setBubbleBarTopOnScreen(topOnScreen);
- if (mLayerView != null) mLayerView.updateExpandedView();
- });
+ public void updateBubbleBarTopToScreenBottom(int bubbleBarTopToScreenBottom) {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY,
+ "IBubbles.updateBubbleBarTopOnScreen: bubbleBarTopToScreenBottom=%d",
+ bubbleBarTopToScreenBottom);
+ executeRemoteCallWithTaskPermission(
+ mController,
+ "updateBubbleBarTopToScreenBottom",
+ (controller) -> {
+ mBubblePositioner.updateBubbleBarTopOnScreen(bubbleBarTopToScreenBottom);
+ if (isSelectedBubbleConvertingMode()) {
+ // if we're in the process of converting the selected bubble to bar mode
+ // we just received an updated bubble bar relative position so we can
+ // now continue converting the bubble
+ ((Bubble) mBubbleData.getSelectedBubble()).getPreparingTransition()
+ .continueConvert(mLayerView);
+ }
+ if (mLayerView != null) mLayerView.updateExpandedView();
+ });
+ }
+
+ @Override
+ public void showExpandedView() {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "IBubbles.showExpandedView");
+ executeRemoteCallWithTaskPermission(
+ mController,
+ "showExpandedView",
+ (controller) -> {
+ if (mLayerView != null) {
+ showExpandedViewForBubbleBar();
+ }
+ });
+ }
+
+ @Override
+ public void moveDraggedBubbleToFullscreen(String key, Point dropLocation) {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "IBubbles.moveDraggedBubbleToFullscreen: key=%s "
+ + "loc=%s", key, dropLocation);
+ executeRemoteCallWithTaskPermission(
+ mController,
+ "moveDraggedBubbleToFullscreen",
+ (controller) -> controller.moveDraggedBubbleToFullscreen(key, dropLocation));
+ }
+
+ @Override
+ public void setHasBubbleBar(boolean hasBubbleBar) {
+ executeRemoteCallWithTaskPermission(
+ mController,
+ "setHasBubbleBar",
+ (controller) -> controller.setLauncherHasBubbleBar(hasBubbleBar));
}
}
@@ -2491,7 +3231,7 @@ public class BubbleController implements ConfigurationChangeListener,
private HashMap mSuppressedGroupToNotifKeys = new HashMap<>();
private HashMap mShortcutIdToBubble = new HashMap<>();
- private HashMap mAppBubbleTaskIds = new HashMap();
+ private HashMap mNoteBubbleTaskIds = new HashMap();
private ArrayList mTmpBubbles = new ArrayList<>();
@@ -2523,20 +3263,20 @@ public class BubbleController implements ConfigurationChangeListener,
mSuppressedBubbleKeys.clear();
mShortcutIdToBubble.clear();
- mAppBubbleTaskIds.clear();
+ mNoteBubbleTaskIds.clear();
for (Bubble b : mTmpBubbles) {
mShortcutIdToBubble.put(b.getShortcutId(), b);
updateBubbleSuppressedState(b);
- if (b.isAppBubble()) {
- mAppBubbleTaskIds.put(b.getKey(), b.getTaskId());
+ if (b.isNote()) {
+ mNoteBubbleTaskIds.put(b.getKey(), b.getTaskId());
}
}
}
- /** Sets the app bubble's taskId which is cached for SysUI. */
- synchronized void setAppBubbleTaskId(String key, int taskId) {
- mAppBubbleTaskIds.put(key, taskId);
+ /** Sets the note bubble's taskId which is cached for SysUI. */
+ synchronized void setNoteBubbleTaskId(String key, int taskId) {
+ mNoteBubbleTaskIds.put(key, taskId);
}
/**
@@ -2588,7 +3328,7 @@ public class BubbleController implements ConfigurationChangeListener,
pw.println(" suppressing: " + key);
}
- pw.println("mAppBubbleTaskIds: " + mAppBubbleTaskIds.values());
+ pw.println("mNoteBubbleTaskIds: " + mNoteBubbleTaskIds.values());
}
}
@@ -2610,13 +3350,6 @@ public class BubbleController implements ConfigurationChangeListener,
return mCachedState.getBubbleWithShortcutId(shortcutId);
}
- @Override
- public void collapseStack() {
- mMainExecutor.execute(() -> {
- BubbleController.this.collapseStack();
- });
- }
-
@Override
public void expandStackAndSelectBubble(BubbleEntry entry) {
mMainExecutor.execute(() -> {
@@ -2624,6 +3357,14 @@ public class BubbleController implements ConfigurationChangeListener,
});
}
+ @Override
+ public void expandStackAndSelectBubble(ShortcutInfo info) {
+ mMainExecutor.execute(() ->
+ BubbleController.this
+ .expandStackAndSelectBubble(info, /* bubbleBarLocation = */ null)
+ );
+ }
+
@Override
public void expandStackAndSelectBubble(Bubble bubble) {
mMainExecutor.execute(() -> {
@@ -2632,14 +3373,14 @@ public class BubbleController implements ConfigurationChangeListener,
}
@Override
- public void showOrHideAppBubble(Intent intent, UserHandle user, @Nullable Icon icon) {
+ public void showOrHideNoteBubble(Intent intent, UserHandle user, @Nullable Icon icon) {
mMainExecutor.execute(
- () -> BubbleController.this.showOrHideAppBubble(intent, user, icon));
+ () -> BubbleController.this.showOrHideNotesBubble(intent, user, icon));
}
@Override
- public boolean isAppBubbleTaskId(int taskId) {
- return mCachedState.mAppBubbleTaskIds.values().contains(taskId);
+ public boolean isNoteBubbleTaskId(int taskId) {
+ return mCachedState.mNoteBubbleTaskIds.values().contains(taskId);
}
@Override
@@ -2817,4 +3558,90 @@ public class BubbleController implements ConfigurationChangeListener,
return mKeyToShownInShadeMap.get(key);
}
}
+
+ @VisibleForTesting(visibility = VisibleForTesting.Visibility.PRIVATE)
+ class BubbleTaskViewController implements TaskViewController {
+ private final TaskViewTransitions mBaseTransitions;
+
+ BubbleTaskViewController(@NonNull TaskViewTransitions baseTransitions) {
+ mBaseTransitions = baseTransitions;
+ }
+
+ @Override
+ public void registerTaskView(TaskViewTaskController tv) {
+ mBaseTransitions.registerTaskView(tv);
+ }
+
+ @Override
+ public void unregisterTaskView(TaskViewTaskController tv) {
+ mBaseTransitions.unregisterTaskView(tv);
+ }
+
+ @Override
+ public void startShortcutActivity(@NonNull TaskViewTaskController destination,
+ @NonNull ShortcutInfo shortcut, @NonNull ActivityOptions options,
+ @Nullable Rect launchBounds) {
+ mBaseTransitions.startShortcutActivity(destination, shortcut, options, launchBounds);
+ }
+
+ @Override
+ public void startActivity(@NonNull TaskViewTaskController destination,
+ @NonNull PendingIntent pendingIntent, @Nullable Intent fillInIntent,
+ @NonNull ActivityOptions options, @Nullable Rect launchBounds) {
+ mBaseTransitions.startActivity(destination, pendingIntent, fillInIntent,
+ options, launchBounds);
+ }
+
+ @Override
+ public void startRootTask(@NonNull TaskViewTaskController destination,
+ ActivityManager.RunningTaskInfo taskInfo, SurfaceControl leash,
+ @Nullable WindowContainerTransaction wct) {
+ mBaseTransitions.startRootTask(destination, taskInfo, leash, wct);
+ }
+
+ @Override
+ public void removeTaskView(@NonNull TaskViewTaskController taskView,
+ @Nullable WindowContainerToken taskToken) {
+ mBaseTransitions.removeTaskView(taskView, taskToken);
+ }
+
+ @Override
+ public void moveTaskViewToFullscreen(@NonNull TaskViewTaskController taskView) {
+ final TaskInfo tinfo = taskView.getTaskInfo();
+ if (tinfo == null) {
+ return;
+ }
+ Bubble bub = mBubbleData.getBubbleInStackWithTaskId(tinfo.taskId);
+ if (bub == null) {
+ return;
+ }
+ mBubbleTransitions.startConvertFromBubble(bub, tinfo);
+ }
+
+ @Override
+ public void setTaskViewVisible(TaskViewTaskController taskView, boolean visible) {
+ if (BubbleAnythingFlagHelper.enableCreateAnyBubbleWithForceExcludedFromRecents()) {
+ // When removing the last bubble, BubbleData has already removed the bubble from
+ // the stack before this call occurs. Without this check, the TO_BACK transition
+ // would trigger DesktopModeWindowDecorViewModel#onTaskChanging, which
+ // incorrectly creates desktop mode window decorations for the removed bubble task
+ // since AppHandleAndHeaderVisibilityHelper#allowedForTask can't find the task in
+ // the bubble stack anymore. These decorations then "leak" because the task will be
+ // closed in the subsequent CLOSE transition. See b/416655338 for more details.
+ if (!visible && !mBubbleData.hasBubbleInStackWithTaskView(taskView)) {
+ return;
+ }
+ // Use reorder instead of always-on-top with hidden.
+ mBaseTransitions.setTaskViewVisible(taskView, visible, true /* reorder */,
+ false /* toggleHiddenOnReorder */);
+ } else {
+ mBaseTransitions.setTaskViewVisible(taskView, visible);
+ }
+ }
+
+ @Override
+ public void setTaskBounds(TaskViewTaskController taskView, Rect boundsOnScreen) {
+ mBaseTransitions.setTaskBounds(taskView, boundsOnScreen);
+ }
+ }
}
diff --git a/wmshell/src/com/android/wm/shell/bubbles/BubbleData.java b/wmshell/src/com/android/wm/shell/bubbles/BubbleData.java
index 761e025984..979c39e185 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/BubbleData.java
+++ b/wmshell/src/com/android/wm/shell/bubbles/BubbleData.java
@@ -18,14 +18,17 @@ package com.android.wm.shell.bubbles;
import static com.android.internal.annotations.VisibleForTesting.Visibility.PRIVATE;
import static com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_BUBBLES;
import static com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_WITH_CLASS_NAME;
+import static com.android.wm.shell.bubbles.Bubbles.dismissReasonToString;
import static com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_BUBBLES;
import android.annotation.NonNull;
import android.app.PendingIntent;
+import android.app.TaskInfo;
import android.content.Context;
+import android.content.Intent;
import android.content.LocusId;
import android.content.pm.ShortcutInfo;
-import android.text.TextUtils;
+import android.os.UserHandle;
import android.util.ArrayMap;
import android.util.ArraySet;
import android.util.Log;
@@ -35,15 +38,20 @@ import android.view.View;
import androidx.annotation.Nullable;
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.R;
import com.android.wm.shell.bubbles.Bubbles.DismissReason;
-import com.android.wm.shell.common.bubbles.BubbleBarUpdate;
-import com.android.wm.shell.common.bubbles.RemovedBubble;
+import com.android.wm.shell.shared.annotations.ShellBackgroundThread;
+import com.android.wm.shell.shared.annotations.ShellMainThread;
+import com.android.wm.shell.shared.bubbles.BubbleBarLocation;
+import com.android.wm.shell.shared.bubbles.BubbleBarUpdate;
+import com.android.wm.shell.shared.bubbles.RemovedBubble;
+import com.android.wm.shell.taskview.TaskViewTaskController;
import java.io.PrintWriter;
import java.util.ArrayList;
+import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
@@ -86,6 +94,8 @@ public class BubbleData {
@Nullable Bubble suppressedBubble;
@Nullable Bubble unsuppressedBubble;
@Nullable String suppressedSummaryGroup;
+ @Nullable
+ BubbleBarLocation mBubbleBarLocation;
// Pair with Bubble and @DismissReason Integer
final List> removedBubbles = new ArrayList<>();
@@ -111,6 +121,7 @@ public class BubbleData {
|| unsuppressedBubble != null
|| suppressedSummaryChanged
|| suppressedSummaryGroup != null
+ || mBubbleBarLocation != null
|| showOverflowChanged;
}
@@ -150,8 +161,11 @@ public class BubbleData {
: null;
for (int i = 0; i < removedBubbles.size(); i++) {
Pair pair = removedBubbles.get(i);
- bubbleBarUpdate.removedBubbles.add(
- new RemovedBubble(pair.first.getKey(), pair.second));
+ // if the removal happened in launcher, don't send it back
+ if (pair.second != Bubbles.DISMISS_USER_GESTURE_FROM_LAUNCHER) {
+ bubbleBarUpdate.removedBubbles.add(
+ new RemovedBubble(pair.first.getKey(), pair.second));
+ }
}
if (orderChanged) {
// Include the new order
@@ -161,6 +175,7 @@ public class BubbleData {
}
bubbleBarUpdate.showOverflowChanged = showOverflowChanged;
bubbleBarUpdate.showOverflow = !overflowBubbles.isEmpty();
+ bubbleBarUpdate.bubbleBarLocation = mBubbleBarLocation;
return bubbleBarUpdate;
}
@@ -171,6 +186,7 @@ public class BubbleData {
BubbleBarUpdate getInitialState() {
BubbleBarUpdate bubbleBarUpdate = BubbleBarUpdate.createInitialState();
bubbleBarUpdate.shouldShowEducation = shouldShowEducation;
+ bubbleBarUpdate.showOverflow = !overflowBubbles.isEmpty();
for (int i = 0; i < bubbles.size(); i++) {
bubbleBarUpdate.currentBubbleList.add(bubbles.get(i).asBubbleBarBubble());
}
@@ -195,6 +211,7 @@ public class BubbleData {
private final BubblePositioner mPositioner;
private final BubbleEducationController mEducationController;
private final Executor mMainExecutor;
+ private final Executor mBgExecutor;
/** Bubbles that are actively in the stack. */
private final List mBubbles;
/** Bubbles that aged out to overflow. */
@@ -240,12 +257,14 @@ public class BubbleData {
private HashMap mSuppressedGroupKeys = new HashMap<>();
public BubbleData(Context context, BubbleLogger bubbleLogger, BubblePositioner positioner,
- BubbleEducationController educationController, Executor mainExecutor) {
+ BubbleEducationController educationController, @ShellMainThread Executor mainExecutor,
+ @ShellBackgroundThread Executor bgExecutor) {
mContext = context;
mLogger = bubbleLogger;
mPositioner = positioner;
mEducationController = educationController;
mMainExecutor = mainExecutor;
+ mBgExecutor = bgExecutor;
mOverflow = new BubbleOverflow(context, positioner);
mBubbles = new ArrayList<>();
mOverflowBubbles = new ArrayList<>();
@@ -291,6 +310,11 @@ public class BubbleData {
return !mBubbles.isEmpty();
}
+ boolean hasBubbleInStackWithTaskView(@NonNull TaskViewTaskController taskView) {
+ return getBubbleWithPredicate(mBubbles,
+ b -> b.getTaskView().getController() == taskView) != null;
+ }
+
public boolean hasOverflowBubbles() {
return !mOverflowBubbles.isEmpty();
}
@@ -348,6 +372,11 @@ public class BubbleData {
dispatchPendingChanges();
}
+ /** Sets the expanded state to false without dispatching changes. */
+ public void collapseNoUpdate() {
+ mExpanded = false;
+ }
+
/**
* Sets the selected bubble and expands it, but doesn't dispatch changes
* to {@link BubbleData.Listener}. This is used for updates coming from launcher whose views
@@ -384,8 +413,23 @@ public class BubbleData {
* {@link #setExpanded(boolean)} immediately after, which will generate 2 separate updates.
*/
public void setSelectedBubbleAndExpandStack(BubbleViewProvider bubble) {
+ setSelectedBubbleAndExpandStack(bubble, /* bubbleBarLocation = */ null);
+ }
+
+ /**
+ * Sets the selected bubble and expands it. Also updates bubble bar location if the
+ * bubbleBarLocation is not {@code null}
+ *
+ * This dispatches a single state update for 3 changes and should be used instead of
+ * calling {@link BubbleController#setBubbleBarLocation(BubbleBarLocation, int)} followed by
+ * {@link #setSelectedBubbleAndExpandStack(BubbleViewProvider)} immediately after, which will
+ * generate 2 separate updates.
+ */
+ public void setSelectedBubbleAndExpandStack(BubbleViewProvider bubble,
+ @Nullable BubbleBarLocation bubbleBarLocation) {
setSelectedBubbleInternal(bubble);
setExpandedInternal(true);
+ mStateChange.mBubbleBarLocation = bubbleBarLocation;
dispatchPendingChanges();
}
@@ -417,23 +461,20 @@ public class BubbleData {
Bubble bubbleToReturn = getBubbleInStackWithKey(key);
if (bubbleToReturn == null) {
- bubbleToReturn = getOverflowBubbleWithKey(key);
- if (bubbleToReturn != null) {
- // Promoting from overflow
- mOverflowBubbles.remove(bubbleToReturn);
- if (mOverflowBubbles.isEmpty()) {
- mStateChange.showOverflowChanged = true;
+ // Check if it's in the overflow
+ bubbleToReturn = findAndRemoveBubbleFromOverflow(key);
+ if (bubbleToReturn == null) {
+ if (entry != null) {
+ // Not in the overflow, have an entry, so it's a new bubble
+ bubbleToReturn = new Bubble(entry,
+ mBubbleMetadataFlagListener,
+ mCancelledListener,
+ mMainExecutor,
+ mBgExecutor);
+ } else {
+ // If there's no entry it must be a persisted bubble
+ bubbleToReturn = persistedBubble;
}
- } else if (mPendingBubbles.containsKey(key)) {
- // Update while it was pending
- bubbleToReturn = mPendingBubbles.get(key);
- } else if (entry != null) {
- // New bubble
- bubbleToReturn = new Bubble(entry, mBubbleMetadataFlagListener, mCancelledListener,
- mMainExecutor);
- } else {
- // Persisted bubble being promoted
- bubbleToReturn = persistedBubble;
}
}
@@ -444,14 +485,85 @@ public class BubbleData {
return bubbleToReturn;
}
+ Bubble getOrCreateBubble(ShortcutInfo info) {
+ String bubbleKey = Bubble.getBubbleKeyForShortcut(info);
+ Bubble bubbleToReturn = findAndRemoveBubbleFromOverflow(bubbleKey);
+ if (bubbleToReturn == null) {
+ bubbleToReturn = Bubble.createShortcutBubble(info, mMainExecutor, mBgExecutor);
+ }
+ return bubbleToReturn;
+ }
+
+ Bubble getOrCreateBubble(Intent intent, UserHandle user) {
+ String bubbleKey = Bubble.getAppBubbleKeyForApp(intent.getPackage(), user);
+ Bubble bubbleToReturn = findAndRemoveBubbleFromOverflow(bubbleKey);
+ if (bubbleToReturn == null) {
+ bubbleToReturn = Bubble.createAppBubble(intent, user, null, mMainExecutor, mBgExecutor);
+ }
+ return bubbleToReturn;
+ }
+
+ Bubble getOrCreateBubble(PendingIntent pendingIntent, UserHandle user) {
+ String bubbleKey = Bubble.getAppBubbleKeyForApp(pendingIntent.getCreatorPackage(), user);
+ Bubble bubbleToReturn = findAndRemoveBubbleFromOverflow(bubbleKey);
+ if (bubbleToReturn == null) {
+ bubbleToReturn = Bubble.createAppBubble(pendingIntent, user, mMainExecutor,
+ mBgExecutor);
+ }
+ return bubbleToReturn;
+ }
+
+ Bubble getOrCreateBubble(TaskInfo taskInfo) {
+ UserHandle user = UserHandle.of(mCurrentUserId);
+ String bubbleKey = Bubble.getAppBubbleKeyForTask(taskInfo);
+ Bubble bubbleToReturn = findAndRemoveBubbleFromOverflow(bubbleKey);
+ if (bubbleToReturn == null) {
+ bubbleToReturn = Bubble.createTaskBubble(taskInfo, user, null, mMainExecutor,
+ mBgExecutor);
+ }
+ return bubbleToReturn;
+ }
+
+ @Nullable
+ private Bubble findAndRemoveBubbleFromOverflow(String key) {
+ Bubble bubbleToReturn = getBubbleInStackWithKey(key);
+ if (bubbleToReturn != null) {
+ return bubbleToReturn;
+ }
+ bubbleToReturn = getOverflowBubbleWithKey(key);
+ if (bubbleToReturn != null) {
+ mOverflowBubbles.remove(bubbleToReturn);
+ // Promoting from overflow
+ mOverflowBubbles.remove(bubbleToReturn);
+ if (mOverflowBubbles.isEmpty()) {
+ mStateChange.showOverflowChanged = true;
+ }
+ } else if (mPendingBubbles.containsKey(key)) {
+ bubbleToReturn = mPendingBubbles.get(key);
+ }
+ return bubbleToReturn;
+ }
+
+ /**
+ * Calls {@link #notificationEntryUpdated(Bubble, boolean, boolean, BubbleBarLocation)} passing
+ * {@code null} for bubbleBarLocation.
+ *
+ * @see #notificationEntryUpdated(Bubble, boolean, boolean, BubbleBarLocation)
+ */
+ void notificationEntryUpdated(Bubble bubble, boolean suppressFlyout, boolean showInShade) {
+ notificationEntryUpdated(bubble, suppressFlyout, showInShade, /* bubbleBarLocation = */
+ null);
+ }
+
/**
* When this method is called it is expected that all info in the bubble has completed loading.
* @see Bubble#inflate(BubbleViewInfoTask.Callback, Context, BubbleExpandedViewManager,
- * BubbleTaskViewFactory, BubblePositioner, BubbleStackView,
+ * BubbleTaskViewFactory, BubblePositioner, BubbleLogger, BubbleStackView,
* com.android.wm.shell.bubbles.bar.BubbleBarLayerView,
* com.android.launcher3.icons.BubbleIconFactory, boolean)
*/
- void notificationEntryUpdated(Bubble bubble, boolean suppressFlyout, boolean showInShade) {
+ void notificationEntryUpdated(Bubble bubble, boolean suppressFlyout, boolean showInShade,
+ @Nullable BubbleBarLocation bubbleBarLocation) {
mPendingBubbles.remove(bubble.getKey()); // No longer pending once we're here
Bubble prevBubble = getBubbleInStackWithKey(bubble.getKey());
suppressFlyout |= !bubble.isTextChanged();
@@ -499,15 +611,38 @@ public class BubbleData {
doSuppress(bubble);
}
}
+ mStateChange.mBubbleBarLocation = bubbleBarLocation;
dispatchPendingChanges();
}
+ /** Dismisses the bubble with the matching key, if it exists. */
+ public void dismissBubbleWithKey(String key, @DismissReason int reason) {
+ dismissBubbleWithKey(key, reason, mTimeSource.currentTimeMillis());
+ }
+
/**
* Dismisses the bubble with the matching key, if it exists.
+ *
+ *
This is used when the bubble was dismissed in launcher, where the {@code removalTimestamp}
+ * represents when the removal happened and can be used to check whether or not the bubble has
+ * been updated after the removal. If no updates, it's safe to remove the bubble, otherwise the
+ * removal is ignored.
*/
- public void dismissBubbleWithKey(String key, @DismissReason int reason) {
- doRemove(key, reason);
- dispatchPendingChanges();
+ public void dismissBubbleWithKey(String key, @DismissReason int reason, long removalTimestamp) {
+ boolean shouldRemove = true;
+ // if the bubble was removed from launcher, verify that the removal happened after the last
+ // time it was updated
+ if (reason == Bubbles.DISMISS_USER_GESTURE_FROM_LAUNCHER) {
+ // if the bubble was removed from launcher it must be active.
+ Bubble bubble = getBubbleInStackWithKey(key);
+ if (bubble != null && bubble.getLastActivity() > removalTimestamp) {
+ shouldRemove = false;
+ }
+ }
+ if (shouldRemove) {
+ doRemove(key, reason);
+ dispatchPendingChanges();
+ }
}
/**
@@ -599,7 +734,7 @@ public class BubbleData {
/** Removes all bubbles for the given user. */
public void removeBubblesForUser(int userId) {
- List removedBubbles = filterAllBubbles(bubble ->
+ final List removedBubbles = filterAllBubbles(bubble ->
userId == bubble.getUser().getIdentifier());
for (Bubble b : removedBubbles) {
doRemove(b.getKey(), Bubbles.DISMISS_USER_ACCOUNT_REMOVED);
@@ -685,11 +820,14 @@ public class BubbleData {
if (hasOverflowBubbleWithKey(key)
&& shouldRemoveHiddenBubble) {
Bubble b = getOverflowBubbleWithKey(key);
- ProtoLog.d(WM_SHELL_BUBBLES, "doRemove - cancel overflow bubble=%s", key);
+ ProtoLog.d(WM_SHELL_BUBBLES, "doRemove - cancel overflow bubble=%s reason=%s",
+ key, dismissReasonToString(reason));
if (b != null) {
b.stopInflation();
}
- mLogger.logOverflowRemove(b, reason);
+ if (!mPositioner.isShowingInBubbleBar()) {
+ mLogger.logStackOverflowRemove(b, reason);
+ }
mOverflowBubbles.remove(b);
mStateChange.bubbleRemoved(b, reason);
mStateChange.removedOverflowBubble = b;
@@ -697,7 +835,8 @@ public class BubbleData {
}
if (hasSuppressedBubbleWithKey(key) && shouldRemoveHiddenBubble) {
Bubble b = getSuppressedBubbleWithKey(key);
- ProtoLog.d(WM_SHELL_BUBBLES, "doRemove - cancel suppressed bubble=%s", key);
+ ProtoLog.d(WM_SHELL_BUBBLES, "doRemove - cancel suppressed bubble=%s reason=%s",
+ key, dismissReasonToString(reason));
if (b != null) {
mSuppressedBubbles.remove(b.getLocusId());
b.stopInflation();
@@ -707,7 +846,8 @@ public class BubbleData {
return;
}
Bubble bubbleToRemove = mBubbles.get(indexToRemove);
- ProtoLog.d(WM_SHELL_BUBBLES, "doRemove=%s", bubbleToRemove.getKey());
+ ProtoLog.d(WM_SHELL_BUBBLES, "doRemove=%s reason=%s", bubbleToRemove.getKey(),
+ dismissReasonToString(reason));
bubbleToRemove.stopInflation();
overflowBubble(reason, bubbleToRemove);
@@ -732,11 +872,33 @@ public class BubbleData {
setNewSelectedIndex(indexToRemove);
}
maybeSendDeleteIntent(reason, bubbleToRemove);
+
+ if (mPositioner.isShowingInBubbleBar()) {
+ logBubbleBarBubbleRemoved(bubbleToRemove, reason);
+ }
+ }
+
+ private void logBubbleBarBubbleRemoved(Bubble bubble, @DismissReason int reason) {
+ switch (reason) {
+ case Bubbles.DISMISS_NOTIF_CANCEL:
+ mLogger.log(bubble, BubbleLogger.Event.BUBBLE_BAR_BUBBLE_REMOVED_CANCELED);
+ break;
+ case Bubbles.DISMISS_TASK_FINISHED:
+ mLogger.log(bubble, BubbleLogger.Event.BUBBLE_BAR_BUBBLE_ACTIVITY_FINISH);
+ break;
+ case Bubbles.DISMISS_BLOCKED:
+ case Bubbles.DISMISS_NO_LONGER_BUBBLE:
+ mLogger.log(bubble, BubbleLogger.Event.BUBBLE_BAR_BUBBLE_REMOVED_BLOCKED);
+ break;
+ default:
+ // skip logging other events
+ }
}
private void setNewSelectedIndex(int indexOfSelected) {
if (mBubbles.isEmpty()) {
- Log.w(TAG, "Bubbles list empty when attempting to select index: " + indexOfSelected);
+ Log.w(TAG, "Bubbles list empty when attempting to select index: "
+ + indexOfSelected);
return;
}
// Move selection to the new bubble at the same position.
@@ -791,8 +953,9 @@ public class BubbleData {
|| reason == Bubbles.DISMISS_RELOAD_FROM_DISK)) {
return;
}
- ProtoLog.d(WM_SHELL_BUBBLES, "overflowBubble=%s", bubble.getKey());
- mLogger.logOverflowAdd(bubble, reason);
+ ProtoLog.d(WM_SHELL_BUBBLES, "overflowBubble=%s reason=%s", bubble.getKey(),
+ dismissReasonToString(reason));
+ mLogger.logOverflowAdd(bubble, mPositioner.isShowingInBubbleBar(), reason);
if (mOverflowBubbles.isEmpty()) {
mStateChange.showOverflowChanged = true;
}
@@ -805,14 +968,17 @@ public class BubbleData {
Bubble oldest = mOverflowBubbles.get(mOverflowBubbles.size() - 1);
ProtoLog.d(WM_SHELL_BUBBLES, "overflow full, remove=%s", oldest.getKey());
mStateChange.bubbleRemoved(oldest, Bubbles.DISMISS_OVERFLOW_MAX_REACHED);
- mLogger.log(bubble, BubbleLogger.Event.BUBBLE_OVERFLOW_REMOVE_MAX_REACHED);
+ if (!mPositioner.isShowingInBubbleBar()) {
+ // Only logged for bubbles in stack view
+ mLogger.log(bubble, BubbleLogger.Event.BUBBLE_OVERFLOW_REMOVE_MAX_REACHED);
+ }
mOverflowBubbles.remove(oldest);
mStateChange.removedOverflowBubble = oldest;
}
}
public void dismissAll(@DismissReason int reason) {
- ProtoLog.d(WM_SHELL_BUBBLES, "dismissAll reason=%d", reason);
+ ProtoLog.d(WM_SHELL_BUBBLES, "dismissAll reason=%s", dismissReasonToString(reason));
if (mBubbles.isEmpty() && mSuppressedBubbles.isEmpty()) {
return;
}
@@ -1068,7 +1234,7 @@ public class BubbleData {
@VisibleForTesting(visibility = PRIVATE)
@Nullable
- Bubble getAnyBubbleWithkey(String key) {
+ Bubble getAnyBubbleWithKey(String key) {
Bubble b = getBubbleInStackWithKey(key);
if (b == null) {
b = getOverflowBubbleWithKey(key);
@@ -1079,77 +1245,38 @@ public class BubbleData {
return b;
}
- /** @return any bubble (in the stack or the overflow) that matches the provided shortcutId. */
+ /** @return the bubble in the stack that matches the provided taskId. */
@Nullable
- Bubble getAnyBubbleWithShortcutId(String shortcutId) {
- if (TextUtils.isEmpty(shortcutId)) {
- return null;
- }
- for (int i = 0; i < mBubbles.size(); i++) {
- Bubble bubble = mBubbles.get(i);
- String bubbleShortcutId = bubble.getShortcutInfo() != null
- ? bubble.getShortcutInfo().getId()
- : bubble.getMetadataShortcutId();
- if (shortcutId.equals(bubbleShortcutId)) {
- return bubble;
- }
- }
-
- for (int i = 0; i < mOverflowBubbles.size(); i++) {
- Bubble bubble = mOverflowBubbles.get(i);
- String bubbleShortcutId = bubble.getShortcutInfo() != null
- ? bubble.getShortcutInfo().getId()
- : bubble.getMetadataShortcutId();
- if (shortcutId.equals(bubbleShortcutId)) {
- return bubble;
- }
- }
- return null;
+ Bubble getBubbleInStackWithTaskId(int taskId) {
+ return getBubbleWithPredicate(mBubbles, b -> b.getTaskId() == taskId);
}
- @VisibleForTesting(visibility = PRIVATE)
+ /** @return the bubble in the stack that matches the provided key. */
@Nullable
public Bubble getBubbleInStackWithKey(String key) {
- for (int i = 0; i < mBubbles.size(); i++) {
- Bubble bubble = mBubbles.get(i);
- if (bubble.getKey().equals(key)) {
- return bubble;
- }
- }
- return null;
+ return getBubbleWithPredicate(mBubbles, b -> b.getKey().equals(key));
}
+ /** @return the bubble in the stack that matches the provided locusId. */
@Nullable
- private Bubble getBubbleInStackWithLocusId(LocusId locusId) {
- if (locusId == null) return null;
- for (int i = 0; i < mBubbles.size(); i++) {
- Bubble bubble = mBubbles.get(i);
- if (locusId.equals(bubble.getLocusId())) {
- return bubble;
- }
+ private Bubble getBubbleInStackWithLocusId(@Nullable LocusId locusId) {
+ if (locusId == null) {
+ return null;
}
- return null;
+ return getBubbleWithPredicate(mBubbles, b -> locusId.equals(b.getLocusId()));
}
+ /** @return the bubble in the stack that matches the provided icon view. */
@Nullable
- Bubble getBubbleWithView(View view) {
- for (int i = 0; i < mBubbles.size(); i++) {
- Bubble bubble = mBubbles.get(i);
- if (bubble.getIconView() != null && bubble.getIconView().equals(view)) {
- return bubble;
- }
- }
- return null;
+ Bubble getBubbleInStackWithView(View view) {
+ return getBubbleWithPredicate(mBubbles, b ->
+ b.getIconView() != null && b.getIconView().equals(view));
}
+ /** @return the overflow bubble that matches the provided key. */
+ @Nullable
public Bubble getOverflowBubbleWithKey(String key) {
- for (int i = 0; i < mOverflowBubbles.size(); i++) {
- Bubble bubble = mOverflowBubbles.get(i);
- if (bubble.getKey().equals(key)) {
- return bubble;
- }
- }
- return null;
+ return getBubbleWithPredicate(mOverflowBubbles, b -> b.getKey().equals(key));
}
/**
@@ -1161,12 +1288,7 @@ public class BubbleData {
@Nullable
@VisibleForTesting(visibility = PRIVATE)
public Bubble getSuppressedBubbleWithKey(String key) {
- for (Bubble b : mSuppressedBubbles.values()) {
- if (b.getKey().equals(key)) {
- return b;
- }
- }
- return null;
+ return getBubbleWithPredicate(mSuppressedBubbles.values(), b -> b.getKey().equals(key));
}
/**
@@ -1175,11 +1297,32 @@ public class BubbleData {
* @param key notification key
* @return bubble that matches or null
*/
+ @Nullable
@VisibleForTesting(visibility = PRIVATE)
public Bubble getPendingBubbleWithKey(String key) {
- for (Bubble b : mPendingBubbles.values()) {
- if (b.getKey().equals(key)) {
- return b;
+ return getBubbleWithPredicate(mPendingBubbles.values(), b -> b.getKey().equals(key));
+ }
+
+ @Nullable
+ private static Bubble getBubbleWithPredicate(@NonNull final List bubbles,
+ @NonNull final Predicate predicate) {
+ // Uses an indexed for loop for optimized performance when iterating over ArrayLists.
+ for (int i = 0; i < bubbles.size(); i++) {
+ final Bubble bubble = bubbles.get(i);
+ if (predicate.test(bubble)) {
+ return bubble;
+ }
+ }
+ return null;
+ }
+
+ @Nullable
+ private static Bubble getBubbleWithPredicate(@NonNull final Collection bubbles,
+ @NonNull final Predicate predicate) {
+ // Uses an enhanced for loop for general collections, which may not support indexed access.
+ for (final Bubble bubble : bubbles) {
+ if (predicate.test(bubble)) {
+ return bubble;
}
}
return null;
@@ -1190,7 +1333,7 @@ public class BubbleData {
* bubbles (i.e. pending, suppressed, active, and overflowed).
*/
private List filterAllBubbles(Predicate predicate) {
- ArrayList matchingBubbles = new ArrayList<>();
+ final ArrayList matchingBubbles = new ArrayList<>();
for (Bubble b : mPendingBubbles.values()) {
if (predicate.test(b)) {
matchingBubbles.add(b);
diff --git a/wmshell/src/com/android/wm/shell/bubbles/BubbleDataRepository.kt b/wmshell/src/com/android/wm/shell/bubbles/BubbleDataRepository.kt
index df12999afc..818ba45bec 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/BubbleDataRepository.kt
+++ b/wmshell/src/com/android/wm/shell/bubbles/BubbleDataRepository.kt
@@ -31,6 +31,9 @@ import com.android.wm.shell.bubbles.storage.BubbleEntity
import com.android.wm.shell.bubbles.storage.BubblePersistentRepository
import com.android.wm.shell.bubbles.storage.BubbleVolatileRepository
import com.android.wm.shell.common.ShellExecutor
+import com.android.wm.shell.shared.annotations.ShellBackgroundThread
+import com.android.wm.shell.shared.annotations.ShellMainThread
+import java.util.concurrent.Executor
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -41,7 +44,8 @@ import kotlinx.coroutines.yield
class BubbleDataRepository(
private val launcherApps: LauncherApps,
- private val mainExecutor: ShellExecutor,
+ @ShellMainThread private val mainExecutor: ShellExecutor,
+ @ShellBackgroundThread private val bgExecutor: Executor,
private val persistentRepository: BubblePersistentRepository,
) {
private val volatileRepository = BubbleVolatileRepository(launcherApps)
@@ -259,8 +263,8 @@ class BubbleDataRepository(
entity.locus,
entity.isDismissable,
mainExecutor,
- bubbleMetadataFlagListener
- )
+ bgExecutor,
+ bubbleMetadataFlagListener)
}
}
mainExecutor.execute { cb(bubbles) }
diff --git a/wmshell/src/com/android/wm/shell/bubbles/BubbleEducationController.kt b/wmshell/src/com/android/wm/shell/bubbles/BubbleEducationController.kt
index bd4708259b..ed23986d0f 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/BubbleEducationController.kt
+++ b/wmshell/src/com/android/wm/shell/bubbles/BubbleEducationController.kt
@@ -83,4 +83,4 @@ class BubbleEducationController(private val context: Context) {
/** Convenience extension method to check if the bubble is a conversation bubble */
private val BubbleViewProvider.isConversationBubble: Boolean
- get() = if (this is Bubble) isConversation else false
+ get() = if (this is Bubble) isChat else false
diff --git a/wmshell/src/com/android/wm/shell/bubbles/BubbleExpandedView.java b/wmshell/src/com/android/wm/shell/bubbles/BubbleExpandedView.java
index c7ccd50af5..8cb66596c3 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/BubbleExpandedView.java
+++ b/wmshell/src/com/android/wm/shell/bubbles/BubbleExpandedView.java
@@ -16,7 +16,7 @@
package com.android.wm.shell.bubbles;
-import static android.app.ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED;
+import static android.app.ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOW_ALWAYS;
import static android.app.ActivityTaskManager.INVALID_TASK_ID;
import static android.content.Intent.FLAG_ACTIVITY_MULTIPLE_TASK;
import static android.content.Intent.FLAG_ACTIVITY_NEW_DOCUMENT;
@@ -26,10 +26,13 @@ import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT;
import static com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_BUBBLES;
import static com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_WITH_CLASS_NAME;
import static com.android.wm.shell.bubbles.BubblePositioner.MAX_HEIGHT;
+import static com.android.wm.shell.bubbles.util.BubbleUtils.getEnterBubbleTransaction;
import static com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_BUBBLES;
+import static com.android.wm.shell.shared.TypefaceUtils.setTypeface;
import android.annotation.NonNull;
import android.annotation.SuppressLint;
+import android.app.ActivityManager.RunningTaskInfo;
import android.app.ActivityOptions;
import android.app.PendingIntent;
import android.content.ComponentName;
@@ -38,7 +41,6 @@ import android.content.Intent;
import android.content.res.Resources;
import android.content.res.TypedArray;
import android.graphics.Bitmap;
-import android.graphics.Color;
import android.graphics.CornerPathEffect;
import android.graphics.Outline;
import android.graphics.Paint;
@@ -62,17 +64,22 @@ import android.view.accessibility.AccessibilityNodeInfo;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import android.window.ScreenCapture;
+import android.window.WindowContainerToken;
+import android.window.WindowContainerTransaction;
import androidx.annotation.Nullable;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.policy.ScreenDecorationsUtils;
-import com.android.internal.protolog.common.ProtoLog;
+import com.android.internal.protolog.ProtoLog;
import com.android.wm.shell.Flags;
import com.android.wm.shell.R;
import com.android.wm.shell.common.AlphaOptimizedButton;
-import com.android.wm.shell.common.TriangleShape;
+import com.android.wm.shell.shared.TriangleShape;
+import com.android.wm.shell.shared.TypefaceUtils;
+import com.android.wm.shell.shared.bubbles.BubbleAnythingFlagHelper;
import com.android.wm.shell.taskview.TaskView;
+import com.android.wm.shell.taskview.TaskViewTaskController;
import java.io.PrintWriter;
@@ -197,6 +204,8 @@ public class BubbleExpandedView extends LinearLayout {
*/
private final FrameLayout mExpandedViewContainer = new FrameLayout(getContext());
+ private TaskView.Listener mCurrentTaskViewListener;
+
private final TaskView.Listener mTaskViewListener = new TaskView.Listener() {
private boolean mInitialized = false;
private boolean mDestroyed = false;
@@ -222,39 +231,60 @@ public class BubbleExpandedView extends LinearLayout {
Rect launchBounds = new Rect();
mTaskView.getBoundsOnScreen(launchBounds);
- options.setTaskAlwaysOnTop(true);
- options.setLaunchedFromBubble(true);
+ options.setTaskAlwaysOnTop(true /* alwaysOnTop */);
options.setPendingIntentBackgroundActivityStartMode(
- MODE_BACKGROUND_ACTIVITY_START_ALLOWED);
- options.setPendingIntentBackgroundActivityLaunchAllowedByPermission(true);
+ MODE_BACKGROUND_ACTIVITY_START_ALLOW_ALWAYS);
- Intent fillInIntent = new Intent();
- // Apply flags to make behaviour match documentLaunchMode=always.
- fillInIntent.addFlags(FLAG_ACTIVITY_NEW_DOCUMENT);
- fillInIntent.addFlags(FLAG_ACTIVITY_MULTIPLE_TASK);
+ final boolean isShortcutBubble = (mBubble.hasMetadataShortcutId()
+ || (mBubble.isShortcut()
+ && BubbleAnythingFlagHelper.enableCreateAnyBubble()));
- if (mBubble.isAppBubble()) {
+ // TODO - currently based on type, really it's what the "launch item" is.
+ if (mBubble.isApp() || mBubble.isNote()) {
Context context =
mContext.createContextAsUser(
mBubble.getUser(), Context.CONTEXT_RESTRICTED);
+ Intent fillInIntent = new Intent();
PendingIntent pi = PendingIntent.getActivity(
context,
/* requestCode= */ 0,
- mBubble.getAppBubbleIntent()
- .addFlags(FLAG_ACTIVITY_NEW_DOCUMENT)
- .addFlags(FLAG_ACTIVITY_MULTIPLE_TASK),
- PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT,
+ mBubble.getIntent(),
+ // Needs to be mutable for the fillInIntent
+ PendingIntent.FLAG_MUTABLE | PendingIntent.FLAG_UPDATE_CURRENT,
/* options= */ null);
- mTaskView.startActivity(pi, /* fillInIntent= */ null, options,
- launchBounds);
- } else if (!mIsOverflow && mBubble.hasMetadataShortcutId()) {
- options.setApplyActivityFlagsForBubbles(true);
+ final WindowContainerToken rootToken = mManager.getAppBubbleRootTaskToken();
+ if (rootToken != null) {
+ options.setLaunchRootTask(rootToken);
+ } else {
+ options.setLaunchNextToBubble(true /* launchNextToBubble */);
+ }
+ mTaskView.startActivity(pi, fillInIntent, options, launchBounds);
+ } else if (!mIsOverflow && isShortcutBubble) {
+ ProtoLog.v(WM_SHELL_BUBBLES, "startingShortcutBubble=%s", getBubbleKey());
+ if (mBubble.isChat()) {
+ options.setLaunchedFromBubble(true);
+ options.setApplyActivityFlagsForBubbles(true);
+ } else {
+ final WindowContainerToken rootToken =
+ mManager.getAppBubbleRootTaskToken();
+ if (rootToken != null) {
+ options.setLaunchRootTask(rootToken);
+ } else {
+ options.setLaunchNextToBubble(true /* launchNextToBubble */);
+ }
+ options.setApplyMultipleTaskFlagForShortcut(true);
+ }
mTaskView.startShortcutActivity(mBubble.getShortcutInfo(),
options, launchBounds);
} else {
+ options.setLaunchedFromBubble(true);
if (mBubble != null) {
- mBubble.setIntentActive();
+ mBubble.setPendingIntentActive();
}
+ final Intent fillInIntent = new Intent();
+ // Apply flags to make behaviour match documentLaunchMode=always.
+ fillInIntent.addFlags(FLAG_ACTIVITY_NEW_DOCUMENT);
+ fillInIntent.addFlags(FLAG_ACTIVITY_MULTIPLE_TASK);
mTaskView.startActivity(mPendingIntent, fillInIntent, options,
launchBounds);
}
@@ -262,8 +292,8 @@ public class BubbleExpandedView extends LinearLayout {
// If there's a runtime exception here then there's something
// wrong with the intent, we can't really recover / try to populate
// the bubble again so we'll just remove it.
- Log.w(TAG, "Exception while displaying bubble: " + getBubbleKey()
- + ", " + e.getMessage() + "; removing bubble");
+ Log.e(TAG, "Exception while displaying bubble: " + getBubbleKey()
+ + "; removing bubble", e);
mManager.removeBubble(getBubbleKey(), Bubbles.DISMISS_INVALID_INTENT);
}
});
@@ -282,11 +312,18 @@ public class BubbleExpandedView extends LinearLayout {
// The taskId is saved to use for removeTask, preventing appearance in recent tasks.
mTaskId = taskId;
- if (mBubble != null && mBubble.isAppBubble()) {
+ if (mBubble != null && mBubble.isNote()) {
// Let the controller know sooner what the taskId is.
- mManager.setAppBubbleTaskId(mBubble.getKey(), mTaskId);
+ mManager.setNoteBubbleTaskId(mBubble.getKey(), mTaskId);
}
+ final TaskViewTaskController tvc = mTaskView.getController();
+ final boolean isAppBubble = mBubble != null
+ && (mBubble.isApp() || mBubble.isShortcut());
+ final WindowContainerTransaction wct = getEnterBubbleTransaction(
+ tvc.getTaskToken(), isAppBubble);
+ tvc.getTaskOrganizer().applyTransaction(wct);
+
// With the task org, the taskAppeared callback will only happen once the task has
// already drawn
setContentVisibility(true);
@@ -373,6 +410,7 @@ public class BubbleExpandedView extends LinearLayout {
// ==> activity view
// ==> manage button
bringChildToFront(mManageButton);
+ setManageClickListener();
applyThemeAttrs();
@@ -449,7 +487,47 @@ public class BubbleExpandedView extends LinearLayout {
mTaskView = bubbleTaskView.getTaskView();
// reset the insets that might left after TaskView is shown in BubbleBarExpandedView
mTaskView.setCaptionInsets(null);
- bubbleTaskView.setDelegateListener(mTaskViewListener);
+ if (Flags.enableBubbleTaskViewListener()) {
+ mCurrentTaskViewListener = new BubbleTaskViewListener(mContext, bubbleTaskView,
+ /* viewParent= */ this, expandedViewManager,
+ new BubbleTaskViewListener.Callback() {
+ @Override
+ public void onTaskCreated() {
+ // The taskId is saved to use for removeTask,
+ // preventing appearance in recent tasks.
+ BubbleTaskViewListener listener = mCurrentTaskViewListener != null
+ ? ((BubbleTaskViewListener) mCurrentTaskViewListener)
+ : null;
+ mTaskId = listener != null
+ ? listener.getTaskId()
+ : bubbleTaskView.getTaskId();
+ setContentVisibility(true);
+ }
+
+ @Override
+ public void onContentVisibilityChanged(boolean visible) {
+ setContentVisibility(visible);
+ }
+
+ @Override
+ public void onBackPressed() {
+ mStackView.onBackPressed();
+ }
+
+ @Override
+ public void onTaskRemovalStarted() {
+ // nothing to do / handled in listener.
+ }
+
+ @Override
+ public void onTaskInfoChanged(RunningTaskInfo taskInfo) {
+ // nothing to do / handled in listener.
+ }
+ });
+ } else {
+ mCurrentTaskViewListener = mTaskViewListener;
+ bubbleTaskView.setDelegateListener(mCurrentTaskViewListener);
+ }
// set a fixed width so it is not recalculated as part of a rotation. the width will be
// updated manually after the rotation.
@@ -460,9 +538,12 @@ public class BubbleExpandedView extends LinearLayout {
}
mExpandedViewContainer.addView(mTaskView, lp);
bringChildToFront(mTaskView);
- if (bubbleTaskView.isCreated()) {
- mTaskViewListener.onTaskCreated(
- bubbleTaskView.getTaskId(), bubbleTaskView.getComponentName());
+
+ if (!Flags.enableBubbleTaskViewListener()) {
+ if (bubbleTaskView.isCreated()) {
+ mCurrentTaskViewListener.onTaskCreated(
+ bubbleTaskView.getTaskId(), bubbleTaskView.getComponentName());
+ }
}
}
}
@@ -502,7 +583,9 @@ public class BubbleExpandedView extends LinearLayout {
mManageButton = (AlphaOptimizedButton) LayoutInflater.from(ctw).inflate(
R.layout.bubble_manage_button, this /* parent */, false /* attach */);
addView(mManageButton);
+ setTypeface(mManageButton, TypefaceUtils.FontFamily.GSF_LABEL_LARGE);
mManageButton.setVisibility(visibility);
+ setManageClickListener();
post(() -> {
int touchAreaHeight =
getResources().getDimensionPixelSize(
@@ -538,15 +621,15 @@ public class BubbleExpandedView extends LinearLayout {
void applyThemeAttrs() {
final TypedArray ta = mContext.obtainStyledAttributes(new int[]{
- android.R.attr.dialogCornerRadius,
- com.android.internal.R.attr.materialColorSurfaceBright,
- com.android.internal.R.attr.materialColorSurfaceContainerHigh});
+ android.R.attr.dialogCornerRadius});
boolean supportsRoundedCorners = ScreenDecorationsUtils.supportsRoundedCornersOnWindows(
mContext.getResources());
mCornerRadius = supportsRoundedCorners ? ta.getDimensionPixelSize(0, 0) : 0;
- mBackgroundColorFloating = ta.getColor(1, Color.WHITE);
+ mBackgroundColorFloating = mContext.getColor(
+ com.android.internal.R.color.materialColorSurfaceBright);
mExpandedViewContainer.setBackgroundColor(mBackgroundColorFloating);
- final int manageMenuBg = ta.getColor(2, Color.WHITE);
+ final int manageMenuBg = mContext.getColor(
+ com.android.internal.R.color.materialColorSurfaceContainerHigh);
ta.recycle();
if (mManageButton != null) {
mManageButton.getBackground().setColorFilter(manageMenuBg, PorterDuff.Mode.SRC_IN);
@@ -559,6 +642,10 @@ public class BubbleExpandedView extends LinearLayout {
updateManageButtonIfExists();
}
+ public float getCornerRadius() {
+ return mCornerRadius;
+ }
+
/**
* Updates the size and visuals of the pointer if {@link #mPointerView} is initialized.
* Does nothing otherwise.
@@ -647,9 +734,8 @@ public class BubbleExpandedView extends LinearLayout {
}
}
- // TODO: Could listener be passed when we pass StackView / can we avoid setting this like this
- void setManageClickListener(OnClickListener manageClickListener) {
- mManageButton.setOnClickListener(manageClickListener);
+ private void setManageClickListener() {
+ mManageButton.setOnClickListener(v -> mStackView.onManageBubbleClicked());
}
/**
@@ -685,11 +771,6 @@ public class BubbleExpandedView extends LinearLayout {
}
}
- /** Sets the alpha for the pointer. */
- public void setPointerAlpha(float alpha) {
- mPointerView.setAlpha(alpha);
- }
-
/**
* Get alpha from underlying {@code TaskView} if this view is for a bubble.
* Or get alpha for the overflow view if this view is for overflow.
@@ -792,25 +873,8 @@ public class BubbleExpandedView extends LinearLayout {
onContainerClipUpdate();
}
- /**
- * Sets the clipping for the view.
- */
- public void setTaskViewClip(Rect rect) {
- mLeftClip = rect.left;
- mTopClip = rect.top;
- mRightClip = rect.right;
- mBottomClip = rect.bottom;
- onContainerClipUpdate();
- }
-
- /**
- * Returns a rect representing the clipping for the view.
- */
- public Rect getTaskViewClip() {
- return new Rect(mLeftClip, mTopClip, mRightClip, mBottom);
- }
-
- private void onContainerClipUpdate() {
+ /** Updates the clip bounds. */
+ public void onContainerClipUpdate() {
if (mTopClip == 0 && mBottomClip == 0 && mRightClip == 0 && mLeftClip == 0) {
if (mIsClipping) {
mIsClipping = false;
@@ -916,8 +980,17 @@ public class BubbleExpandedView extends LinearLayout {
Log.w(TAG, "Stack is null for bubble: " + bubble);
return;
}
- boolean isNew = mBubble == null || didBackingContentChange(bubble);
- if (isNew || bubble.getKey().equals(mBubble.getKey())) {
+ boolean isNew;
+ if (mCurrentTaskViewListener instanceof BubbleTaskViewListener) {
+ isNew = ((BubbleTaskViewListener) mCurrentTaskViewListener).setBubble(bubble);
+ } else {
+ isNew = mBubble == null || didBackingContentChange(bubble);
+ }
+ boolean isUpdate = bubble != null && mBubble != null
+ && bubble.getKey().equals(mBubble.getKey());
+ ProtoLog.d(WM_SHELL_BUBBLES, "BubbleExpandedView - update bubble=%s; isNew=%b; isUpdate=%b",
+ bubble.getKey(), isNew, isUpdate);
+ if (isNew || isUpdate) {
mBubble = bubble;
mManageButton.setContentDescription(getResources().getString(
R.string.bubbles_settings_button_description, bubble.getAppName()));
@@ -935,7 +1008,7 @@ public class BubbleExpandedView extends LinearLayout {
});
if (isNew) {
- mPendingIntent = mBubble.getBubbleIntent();
+ mPendingIntent = mBubble.getPendingIntent();
if ((mPendingIntent != null || mBubble.hasMetadataShortcutId())
&& mTaskView != null) {
setContentVisibility(false);
@@ -962,7 +1035,7 @@ public class BubbleExpandedView extends LinearLayout {
*/
private boolean didBackingContentChange(Bubble newBubble) {
boolean prevWasIntentBased = mBubble != null && mPendingIntent != null;
- boolean newIsIntentBased = newBubble.getBubbleIntent() != null;
+ boolean newIsIntentBased = newBubble.getPendingIntent() != null;
return prevWasIntentBased != newIsIntentBased;
}
@@ -1115,13 +1188,6 @@ public class BubbleExpandedView extends LinearLayout {
return mCurrentPointer == mRightPointer;
}
- /**
- * Return width of the current pointer
- */
- public int getPointerWidth() {
- return mPointerWidth;
- }
-
/**
* Position of the manage button displayed in the expanded view. Used for placing user
* education about the manage button.
@@ -1148,5 +1214,7 @@ public class BubbleExpandedView extends LinearLayout {
pw.print(prefix); pw.println("BubbleExpandedView:");
pw.print(prefix); pw.print(" taskId: "); pw.println(mTaskId);
pw.print(prefix); pw.print(" stackView: "); pw.println(mStackView);
+ pw.print(prefix); pw.print(" contentVisibility: "); pw.println(mIsContentVisible);
+ pw.print(prefix); pw.print(" isAnimating: "); pw.println(mIsAnimating);
}
}
diff --git a/wmshell/src/com/android/wm/shell/bubbles/BubbleExpandedViewManager.kt b/wmshell/src/com/android/wm/shell/bubbles/BubbleExpandedViewManager.kt
index 3d9bf032c1..8f78252c85 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/BubbleExpandedViewManager.kt
+++ b/wmshell/src/com/android/wm/shell/bubbles/BubbleExpandedViewManager.kt
@@ -16,6 +16,10 @@
package com.android.wm.shell.bubbles
+import android.app.ActivityManager
+import android.window.WindowContainerToken
+import com.android.wm.shell.shared.bubbles.BubbleBarLocation
+
/** Manager interface for bubble expanded views. */
interface BubbleExpandedViewManager {
@@ -26,10 +30,16 @@ interface BubbleExpandedViewManager {
fun promoteBubbleFromOverflow(bubble: Bubble)
fun removeBubble(key: String, reason: Int)
fun dismissBubble(bubble: Bubble, reason: Int)
- fun setAppBubbleTaskId(key: String, taskId: Int)
+ fun setNoteBubbleTaskId(key: String, taskId: Int)
fun isStackExpanded(): Boolean
fun isShowingAsBubbleBar(): Boolean
fun hideCurrentInputMethod()
+ fun updateBubbleBarLocation(
+ location: BubbleBarLocation,
+ @BubbleBarLocation.UpdateSource source: Int,
+ )
+ fun getAppBubbleRootTaskToken(): WindowContainerToken?
+ fun shouldBeAppBubble(taskInfo: ActivityManager.RunningTaskInfo): Boolean
companion object {
/**
@@ -67,8 +77,8 @@ interface BubbleExpandedViewManager {
controller.dismissBubble(bubble, reason)
}
- override fun setAppBubbleTaskId(key: String, taskId: Int) {
- controller.setAppBubbleTaskId(key, taskId)
+ override fun setNoteBubbleTaskId(key: String, taskId: Int) {
+ controller.setNoteBubbleTaskId(key, taskId)
}
override fun isStackExpanded(): Boolean = controller.isStackExpanded
@@ -76,8 +86,21 @@ interface BubbleExpandedViewManager {
override fun isShowingAsBubbleBar(): Boolean = controller.isShowingAsBubbleBar
override fun hideCurrentInputMethod() {
- controller.hideCurrentInputMethod()
+ controller.hideCurrentInputMethod(null)
}
+
+ override fun updateBubbleBarLocation(
+ location: BubbleBarLocation,
+ @BubbleBarLocation.UpdateSource source: Int,
+ ) {
+ controller.setBubbleBarLocation(location, source)
+ }
+
+ override fun getAppBubbleRootTaskToken(): WindowContainerToken? =
+ controller.appBubbleRootTaskInfo?.token
+
+ override fun shouldBeAppBubble(taskInfo: ActivityManager.RunningTaskInfo): Boolean =
+ controller.shouldBeAppBubble(taskInfo)
}
}
}
diff --git a/wmshell/src/com/android/wm/shell/bubbles/BubbleExpandedViewTransitionAnimator.java b/wmshell/src/com/android/wm/shell/bubbles/BubbleExpandedViewTransitionAnimator.java
new file mode 100644
index 0000000000..39f918c3e3
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/bubbles/BubbleExpandedViewTransitionAnimator.java
@@ -0,0 +1,88 @@
+/*
+ * 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.bubbles;
+
+import android.annotation.Nullable;
+import android.graphics.Rect;
+import android.view.SurfaceControl;
+import android.view.View;
+
+import androidx.annotation.NonNull;
+
+import com.android.wm.shell.bubbles.bar.BubbleBarLayerView;
+
+/**
+ * {@link BubbleTransitions} needs to perform various actions on the bubble expanded view and its
+ * parent. There are two variants of the expanded view -- one belonging to {@link BubbleStackView}
+ * and one belonging to {@link BubbleBarLayerView}. This interface is implemented by the view
+ * parents to allow the transitions to modify and animate the expanded view.
+ *
+ * TODO (b/349844986):
+ * Ideally we could have a single type of expanded view (or view animator) used by both stackView
+ * & layerView and then the transitions could perhaps operate on that directly.
+ */
+public interface BubbleExpandedViewTransitionAnimator {
+
+ /**
+ * Whether bubble UI is currently expanded.
+ */
+ boolean isExpanded();
+
+ /**
+ * Whether it's possible to expand {@param bubble} right now. This is {@code false} if the
+ * bubble has no view or if the bubble is already showing.
+ */
+ boolean canExpandView(BubbleViewProvider bubble);
+
+ /**
+ * Call to prepare the provided {@param bubble} to be animated.
+ *
+ * Should make the current expanded bubble visible immediately so it gets a surface that can be
+ * animated. Since the surface may not be ready yet, it should keep the TaskView alpha=0.
+ */
+ BubbleViewProvider prepareConvertedView(BubbleViewProvider bubble);
+
+ /**
+ * Animates a visible task into the bubble expanded view.
+ *
+ * @param startT A transaction with first-frame work. This *must* be applied here!
+ * @param startBounds The starting bounds of the task being converted into a bubble.
+ * @param startScale The starting scale of the task being converted into a bubble.
+ * @param snapshot A snapshot of the task being converted into a bubble.
+ * @param taskLeash The taskLeash of the task being converted into a bubble.
+ * @param animFinish A runnable to run at the end of the animation.
+ */
+ void animateConvert(@NonNull SurfaceControl.Transaction startT,
+ @NonNull Rect startBounds, float startScale, @NonNull SurfaceControl snapshot,
+ SurfaceControl taskLeash, Runnable animFinish);
+
+ /**
+ * Animates a non-visible task into the bubble expanded view -- since there's no task
+ * visible this just needs to expand the bubble stack (or animate out the previously
+ * selected bubble if already expanded).
+ *
+ * @param previousBubble If non-null, this is a bubble that is already showing before the new
+ * bubble is expanded.
+ * @param animFinish If non-null, the callback triggered after the expand animation completes
+ */
+ void animateExpand(@Nullable BubbleViewProvider previousBubble, @Nullable Runnable animFinish);
+
+ /**
+ * Bubble transitions calls this when a view should be removed from the parent.
+ */
+ void removeViewFromTransition(View view);
+}
diff --git a/wmshell/src/com/android/wm/shell/bubbles/BubbleFlyoutView.java b/wmshell/src/com/android/wm/shell/bubbles/BubbleFlyoutView.java
index 42de401d9d..92007a4df7 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/BubbleFlyoutView.java
+++ b/wmshell/src/com/android/wm/shell/bubbles/BubbleFlyoutView.java
@@ -19,8 +19,8 @@ package com.android.wm.shell.bubbles;
import static android.graphics.Paint.ANTI_ALIAS_FLAG;
import static android.graphics.Paint.FILTER_BITMAP_FLAG;
-import static com.android.wm.shell.animation.Interpolators.ALPHA_IN;
-import static com.android.wm.shell.animation.Interpolators.ALPHA_OUT;
+import static com.android.wm.shell.shared.animation.Interpolators.ALPHA_IN;
+import static com.android.wm.shell.shared.animation.Interpolators.ALPHA_OUT;
import android.animation.ArgbEvaluator;
import android.content.Context;
@@ -28,7 +28,6 @@ import android.content.res.Configuration;
import android.content.res.Resources;
import android.content.res.TypedArray;
import android.graphics.Canvas;
-import android.graphics.Color;
import android.graphics.Matrix;
import android.graphics.Outline;
import android.graphics.Paint;
@@ -50,7 +49,8 @@ import android.widget.TextView;
import androidx.annotation.Nullable;
import com.android.wm.shell.R;
-import com.android.wm.shell.common.TriangleShape;
+import com.android.wm.shell.shared.TriangleShape;
+import com.android.wm.shell.shared.TypefaceUtils;
/**
* Flyout view that appears as a 'chat bubble' alongside the bubble stack. The flyout can visually
@@ -166,8 +166,10 @@ public class BubbleFlyoutView extends FrameLayout {
LayoutInflater.from(context).inflate(R.layout.bubble_flyout, this, true);
mFlyoutTextContainer = findViewById(R.id.bubble_flyout_text_container);
mSenderText = findViewById(R.id.bubble_flyout_name);
+ TypefaceUtils.setTypeface(mSenderText, TypefaceUtils.FontFamily.GSF_LABEL_LARGE);
mSenderAvatar = findViewById(R.id.bubble_flyout_avatar);
mMessageText = mFlyoutTextContainer.findViewById(R.id.bubble_flyout_text);
+ TypefaceUtils.setTypeface(mMessageText, TypefaceUtils.FontFamily.GSF_BODY_MEDIUM);
final Resources res = getResources();
mFlyoutPadding = res.getDimensionPixelSize(R.dimen.bubble_flyout_padding_x);
@@ -209,7 +211,7 @@ public class BubbleFlyoutView extends FrameLayout {
mPointerSize, mPointerSize, false /* isPointingLeft */));
mRightTriangleShape.setBounds(0, 0, mPointerSize, mPointerSize);
- applyConfigurationColors(getResources().getConfiguration());
+ applyConfigurationColors();
}
@Override
@@ -440,29 +442,23 @@ public class BubbleFlyoutView extends FrameLayout {
boolean flagsChanged = nightModeFlags != mNightModeFlags;
if (flagsChanged) {
mNightModeFlags = nightModeFlags;
- applyConfigurationColors(configuration);
+ applyConfigurationColors();
}
return flagsChanged;
}
- private void applyConfigurationColors(Configuration configuration) {
- int nightModeFlags = configuration.uiMode & Configuration.UI_MODE_NIGHT_MASK;
- boolean isNightModeOn = nightModeFlags == Configuration.UI_MODE_NIGHT_YES;
- try (TypedArray ta = mContext.obtainStyledAttributes(
- new int[]{
- com.android.internal.R.attr.materialColorSurfaceContainer,
- com.android.internal.R.attr.materialColorOnSurface,
- com.android.internal.R.attr.materialColorOnSurfaceVariant})) {
- mFloatingBackgroundColor = ta.getColor(0,
- isNightModeOn ? Color.BLACK : Color.WHITE);
- mSenderText.setTextColor(ta.getColor(1,
- isNightModeOn ? Color.WHITE : Color.BLACK));
- mMessageText.setTextColor(ta.getColor(2,
- isNightModeOn ? Color.WHITE : Color.BLACK));
- mBgPaint.setColor(mFloatingBackgroundColor);
- mLeftTriangleShape.getPaint().setColor(mFloatingBackgroundColor);
- mRightTriangleShape.getPaint().setColor(mFloatingBackgroundColor);
- }
+ private void applyConfigurationColors() {
+ mFloatingBackgroundColor = mContext.getColor(
+ com.android.internal.R.color.materialColorSurfaceContainer);
+ mSenderText.setTextColor(
+ mContext.getColor(com.android.internal.R.color.materialColorOnSurface));
+ mMessageText.setTextColor(
+ mContext.getColor(com.android.internal.R.color.materialColorOnSurfaceVariant));
+
+ mBgPaint.setColor(mFloatingBackgroundColor);
+ mLeftTriangleShape.getPaint().setColor(mFloatingBackgroundColor);
+ mRightTriangleShape.getPaint().setColor(mFloatingBackgroundColor);
+
}
/**
diff --git a/wmshell/src/com/android/wm/shell/bubbles/BubbleLogger.java b/wmshell/src/com/android/wm/shell/bubbles/BubbleLogger.java
index c88a58be14..a0c473173b 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/BubbleLogger.java
+++ b/wmshell/src/com/android/wm/shell/bubbles/BubbleLogger.java
@@ -16,11 +16,12 @@
package com.android.wm.shell.bubbles;
-import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.logging.UiEvent;
import com.android.internal.logging.UiEventLogger;
import com.android.internal.util.FrameworkStatsLog;
+import javax.inject.Inject;
+
/**
* Implementation of UiEventLogger for logging bubble UI events.
*
@@ -33,9 +34,10 @@ public class BubbleLogger {
/**
* Bubble UI event.
*/
- @VisibleForTesting
public enum Event implements UiEventLogger.UiEventEnum {
+ // region bubble events
+
@UiEvent(doc = "User dismissed the bubble via gesture, add bubble to overflow.")
BUBBLE_OVERFLOW_ADD_USER_GESTURE(483),
@@ -64,7 +66,104 @@ public class BubbleLogger {
BUBBLE_OVERFLOW_SELECTED(600),
@UiEvent(doc = "Restore bubble to overflow after phone reboot.")
- BUBBLE_OVERFLOW_RECOVER(691);
+ BUBBLE_OVERFLOW_RECOVER(691),
+
+ // endregion
+
+ // region bubble bar events
+
+ @UiEvent(doc = "new bubble posted")
+ BUBBLE_BAR_BUBBLE_POSTED(1927),
+
+ @UiEvent(doc = "existing bubble updated")
+ BUBBLE_BAR_BUBBLE_UPDATED(1928),
+
+ @UiEvent(doc = "expanded a bubble from bubble bar")
+ BUBBLE_BAR_EXPANDED(1929),
+
+ @UiEvent(doc = "bubble bar collapsed")
+ BUBBLE_BAR_COLLAPSED(1930),
+
+ @UiEvent(doc = "dismissed single bubble from bubble bar by dragging it to dismiss target")
+ BUBBLE_BAR_BUBBLE_DISMISSED_DRAG_BUBBLE(1931),
+
+ @UiEvent(doc = "dismissed single bubble from bubble bar by dragging the expanded view to "
+ + "dismiss target")
+ BUBBLE_BAR_BUBBLE_DISMISSED_DRAG_EXP_VIEW(1932),
+
+ @UiEvent(doc = "dismiss bubble from app handle menu")
+ BUBBLE_BAR_BUBBLE_DISMISSED_APP_MENU(1933),
+
+ @UiEvent(doc = "bubble is dismissed due to app finishing the bubble activity")
+ BUBBLE_BAR_BUBBLE_ACTIVITY_FINISH(1934),
+
+ @UiEvent(doc = "dismissed the bubble bar by dragging it to dismiss target")
+ BUBBLE_BAR_DISMISSED_DRAG_BAR(1935),
+
+ @UiEvent(doc = "bubble bar moved to the left edge of the screen by dragging from the "
+ + "expanded view")
+ BUBBLE_BAR_MOVED_LEFT_DRAG_EXP_VIEW(1936),
+
+ @UiEvent(doc = "bubble bar moved to the left edge of the screen by dragging from a single"
+ + " bubble")
+ BUBBLE_BAR_MOVED_LEFT_DRAG_BUBBLE(1937),
+
+ @UiEvent(doc = "bubble bar moved to the left edge of the screen by dragging the bubble bar")
+ BUBBLE_BAR_MOVED_LEFT_DRAG_BAR(1938),
+
+ @UiEvent(doc = "bubble bar moved to the right edge of the screen by dragging from the "
+ + "expanded view")
+ BUBBLE_BAR_MOVED_RIGHT_DRAG_EXP_VIEW(1939),
+
+ @UiEvent(doc = "bubble bar moved to the right edge of the screen by dragging from a "
+ + "single bubble")
+ BUBBLE_BAR_MOVED_RIGHT_DRAG_BUBBLE(1940),
+
+ @UiEvent(doc = "bubble bar moved to the right edge of the screen by dragging the bubble "
+ + "bar")
+ BUBBLE_BAR_MOVED_RIGHT_DRAG_BAR(1941),
+
+ @UiEvent(doc = "stop bubbling conversation from app handle menu")
+ BUBBLE_BAR_APP_MENU_OPT_OUT(1942),
+
+ @UiEvent(doc = "open app settings from app handle menu")
+ BUBBLE_BAR_APP_MENU_GO_TO_SETTINGS(1943),
+
+ @UiEvent(doc = "flyout shown for a bubble")
+ BUBBLE_BAR_FLYOUT(1944),
+
+ @UiEvent(doc = "notification for the bubble was canceled")
+ BUBBLE_BAR_BUBBLE_REMOVED_CANCELED(1945),
+
+ @UiEvent(doc = "user turned off bubbles from settings")
+ BUBBLE_BAR_BUBBLE_REMOVED_BLOCKED(1946),
+
+ @UiEvent(doc = "bubble bar overflow opened")
+ BUBBLE_BAR_OVERFLOW_SELECTED(1947),
+
+ @UiEvent(doc = "max number of bubbles was reached in bubble bar, move bubble to overflow")
+ BUBBLE_BAR_OVERFLOW_ADD_AGED(1948),
+
+ @UiEvent(doc = "bubble promoted from overflow back to bubble bar")
+ BUBBLE_BAR_OVERFLOW_REMOVE_BACK_TO_BAR(1949),
+
+ @UiEvent(doc = "application icon is dropped in the BubbleBar left drop zone")
+ BUBBLE_BAR_MOVED_LEFT_APP_ICON_DROP(2082),
+
+ @UiEvent(doc = "application icon is dropped in the BubbleBar right drop zone")
+ BUBBLE_BAR_MOVED_RIGHT_APP_ICON_DROP(2083),
+
+ @UiEvent(doc = "while bubble bar is expanded, switch to another/existing bubble")
+ BUBBLE_BAR_BUBBLE_SWITCHED(1977),
+
+ @UiEvent(doc = "bubble bar moved to the left edge of the screen by dragging a task")
+ BUBBLE_BAR_MOVED_LEFT_DRAG_TASK(2146),
+
+ @UiEvent(doc = "bubble bar moved to the right edge of the screen by dragging a task")
+ BUBBLE_BAR_MOVED_RIGHT_DRAG_TASK(2147),
+
+ // endregion
+ ;
private final int mId;
@@ -78,23 +177,32 @@ public class BubbleLogger {
}
}
+ @Inject
public BubbleLogger(UiEventLogger uiEventLogger) {
mUiEventLogger = uiEventLogger;
}
/**
- * @param b Bubble involved in this UI event
- * @param e UI event
+ * Log an UIEvent
+ */
+ public void log(UiEventLogger.UiEventEnum e) {
+ mUiEventLogger.log(e);
+ }
+
+ /**
+ * Log an UIEvent with the given bubble info
*/
public void log(Bubble b, UiEventLogger.UiEventEnum e) {
mUiEventLogger.logWithInstanceId(e, b.getAppUid(), b.getPackageName(), b.getInstanceId());
}
/**
+ * Log when a bubble is removed from overflow in stack view
+ *
* @param b Bubble removed from overflow
* @param r Reason that bubble was removed
*/
- public void logOverflowRemove(Bubble b, @Bubbles.DismissReason int r) {
+ public void logStackOverflowRemove(Bubble b, @Bubbles.DismissReason int r) {
if (r == Bubbles.DISMISS_NOTIF_CANCEL) {
log(b, BubbleLogger.Event.BUBBLE_OVERFLOW_REMOVE_CANCEL);
} else if (r == Bubbles.DISMISS_GROUP_CANCELLED) {
@@ -110,13 +218,19 @@ public class BubbleLogger {
* @param b Bubble added to overflow
* @param r Reason that bubble was added to overflow
*/
- public void logOverflowAdd(Bubble b, @Bubbles.DismissReason int r) {
- if (r == Bubbles.DISMISS_AGED) {
- log(b, Event.BUBBLE_OVERFLOW_ADD_AGED);
- } else if (r == Bubbles.DISMISS_USER_GESTURE) {
- log(b, Event.BUBBLE_OVERFLOW_ADD_USER_GESTURE);
- } else if (r == Bubbles.DISMISS_RELOAD_FROM_DISK) {
- log(b, Event.BUBBLE_OVERFLOW_RECOVER);
+ public void logOverflowAdd(Bubble b, boolean bubbleBar, @Bubbles.DismissReason int r) {
+ if (bubbleBar) {
+ if (r == Bubbles.DISMISS_AGED) {
+ log(b, Event.BUBBLE_BAR_OVERFLOW_ADD_AGED);
+ }
+ } else {
+ if (r == Bubbles.DISMISS_AGED) {
+ log(b, Event.BUBBLE_OVERFLOW_ADD_AGED);
+ } else if (r == Bubbles.DISMISS_USER_GESTURE) {
+ log(b, Event.BUBBLE_OVERFLOW_ADD_USER_GESTURE);
+ } else if (r == Bubbles.DISMISS_RELOAD_FROM_DISK) {
+ log(b, Event.BUBBLE_OVERFLOW_RECOVER);
+ }
}
}
diff --git a/wmshell/src/com/android/wm/shell/bubbles/BubbleMultitaskingDelegate.java b/wmshell/src/com/android/wm/shell/bubbles/BubbleMultitaskingDelegate.java
new file mode 100644
index 0000000000..8bf0bb6659
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/bubbles/BubbleMultitaskingDelegate.java
@@ -0,0 +1,227 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.wm.shell.bubbles;
+
+import static android.app.Notification.BubbleMetadata.FLAG_SUPPRESS_NOTIFICATION;
+import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK;
+import static android.content.Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED;
+
+import static com.android.wm.shell.bubbles.Bubbles.DISMISS_NO_LONGER_BUBBLE;
+import static com.android.wm.shell.shared.bubbles.BubbleBarLocation.UpdateSource.A11Y_ACTION_BUBBLE;
+
+import android.annotation.BinderThread;
+import android.annotation.Nullable;
+import android.annotation.SuppressLint;
+import android.content.Intent;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.os.UserHandle;
+import android.text.TextUtils;
+import android.util.Slog;
+import android.window.IMultitaskingControllerCallback;
+import android.window.IMultitaskingDelegate;
+
+import com.android.wm.shell.common.ShellExecutor;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * The implementation of the delegate that accepts requests from the client and interacts with
+ * {@link BubbleController} to create, change or remove bubbles.
+ */
+public class BubbleMultitaskingDelegate extends IMultitaskingDelegate.Stub {
+
+ private static final boolean DEBUG = false;
+ private static final String TAG = BubbleMultitaskingDelegate.class.getSimpleName();
+
+ private final BubbleController mController;
+ private final ShellExecutor mMainExecutor;
+ private final ShellExecutor mBgExecutor;
+ private final BubbleData mBubbleData;
+ private int mCurrentUserId;
+ private IMultitaskingControllerCallback mControllerCallback;
+
+ @SuppressLint("MissingPermission")
+ BubbleMultitaskingDelegate(BubbleController controller, BubbleData bubbleData,
+ int currentUserId) {
+ mController = controller;
+ mMainExecutor = controller.getMainExecutor();
+ mBgExecutor = controller.getBackgroundExecutor();
+ mBubbleData = bubbleData;
+ mCurrentUserId = currentUserId;
+ }
+
+ void setControllerCallback(IMultitaskingControllerCallback callback) {
+ mControllerCallback = callback;
+ }
+
+ @BinderThread
+ @Override
+ public void createBubble(IBinder token, Intent intent, boolean collapsed) {
+ if (DEBUG) {
+ Slog.d(TAG, "Handling create bubble request");
+ }
+ Objects.requireNonNull(token);
+ Objects.requireNonNull(intent.getComponent());
+ Intent bubbleIntent = new Intent(intent);
+ bubbleIntent.setPackage(intent.getComponent().getPackageName());
+ bubbleIntent.setFlags(FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_RESET_TASK_IF_NEEDED);
+ mMainExecutor.execute(
+ () -> {
+ if (getBubbleWithToken(token) != null) {
+ Slog.e(TAG, "Skip creating bubble - found one with the same token.");
+ return;
+ }
+
+ Bubble b = Bubble.createClientControlledAppBubble(bubbleIntent,
+ new UserHandle(mCurrentUserId), null, token, mMainExecutor,
+ mBgExecutor);
+ if (collapsed) {
+ mController.inflateAndAdd(b, false, false);
+ if (DEBUG) {
+ Slog.d(TAG, "Created a collapsed bubble");
+ }
+ } else {
+ mController.expandStackAndSelectAppBubble(b, null /* bubbleBarLocation */,
+ A11Y_ACTION_BUBBLE); // Any update source - location doesn't change
+ if (DEBUG) {
+ Slog.d(TAG, "Created an expanded bubble");
+ }
+ }
+ });
+ }
+
+ @BinderThread
+ @Override
+ public void updateBubbleState(IBinder token, boolean collapse) throws RemoteException {
+ if (DEBUG) {
+ Slog.d(TAG, "Handling bubble state update request.");
+ }
+ Objects.requireNonNull(token);
+ mMainExecutor.execute(
+ () -> {
+ if (getBubbleWithToken(token) == null) {
+ Slog.e(TAG, "Skip updating bubble state - none found for the token.");
+ return;
+ }
+ if (collapse) {
+ mController.collapseStack();
+ if (DEBUG) {
+ Slog.d(TAG, "Collapsed bubble stack");
+ }
+ } else {
+ final Bubble bubble = getBubbleWithToken(token);
+ mController.expandStackAndSelectAppBubble(bubble,
+ null /* bubbleBarLocation */, A11Y_ACTION_BUBBLE);
+ if (DEBUG) {
+ Slog.d(TAG, "Expanded bubbles");
+ }
+ }
+ });
+ }
+
+ @BinderThread
+ @Override
+ public void updateBubbleMessage(IBinder token, String message) throws RemoteException {
+ if (DEBUG) {
+ Slog.d(TAG, "Handling update bubble request.");
+ }
+ Objects.requireNonNull(token);
+ mMainExecutor.execute(
+ () -> {
+ final Bubble bubble = getBubbleWithToken(token);
+ if (bubble == null) {
+ Slog.e(TAG, "Skip updating bubble message - none found for the token.");
+ return;
+ }
+
+ // Update the flyout message directly.
+ // TODO(b/407149510): this should be refactored into an organized bubble message
+ // update flow, since normally flyout messages are updated through notifications
+ // pipeline and this initial implementation cuts in directly.
+ Bubble.FlyoutMessage bubbleMessage = bubble.getFlyoutMessage();
+ if (bubbleMessage == null) {
+ bubbleMessage = new Bubble.FlyoutMessage();
+ bubble.setFlyoutMessage(bubbleMessage);
+ }
+ bubbleMessage.message = message;
+ bubble.setTextChangedForTest(true);
+ bubble.setSuppressNotification(false);
+ bubble.disable(FLAG_SUPPRESS_NOTIFICATION);
+ mBubbleData.notificationEntryUpdated(bubble,
+ TextUtils.isEmpty(message) /* suppressFlyout */,
+ true /* showInShade */);
+ if (DEBUG) {
+ Slog.d(TAG, "Updated bubble message");
+ }
+ });
+ }
+
+ @BinderThread
+ @Override
+ public void removeBubble(IBinder token) throws RemoteException {
+ if (DEBUG) {
+ Slog.d(TAG, "Handling remove bubble request.");
+ }
+ Objects.requireNonNull(token);
+ mMainExecutor.execute(
+ () -> {
+ final Bubble bubble = getBubbleWithToken(token);
+ if (bubble == null) {
+ Slog.e(TAG, "Skip removing bubble - none found for the token.");
+ return;
+ }
+
+ mController.removeBubble(bubble.getKey(), DISMISS_NO_LONGER_BUBBLE);
+
+ if (DEBUG) {
+ Slog.d(TAG, "Removed the bubble");
+ }
+ });
+ }
+
+ void onBubbleRemoved(IBinder clientToken, int reason) {
+ if (DEBUG) {
+ Slog.d(TAG, "Notifying controller about bubble removal, reason: " + reason);
+ }
+ mBgExecutor.execute(() -> {
+ if (mControllerCallback != null) {
+ try {
+ mControllerCallback.onBubbleRemoved(clientToken);
+ } catch (RemoteException e) {
+ Slog.e(TAG, "Error reporting bubble removal, reason: " + reason, e);
+ }
+ }
+ });
+ }
+
+ void setCurrentUserId(int uid) {
+ mCurrentUserId = uid;
+ }
+
+ @Nullable
+ private Bubble getBubbleWithToken(IBinder token) {
+ List bubbleList = mBubbleData.getBubbles();
+ for (Bubble b : bubbleList) {
+ if (token == b.getClientToken()) {
+ return b;
+ }
+ }
+ return null;
+ }
+}
diff --git a/wmshell/src/com/android/wm/shell/bubbles/BubbleOverflow.kt b/wmshell/src/com/android/wm/shell/bubbles/BubbleOverflow.kt
index f32974e176..214f8d9cf4 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/BubbleOverflow.kt
+++ b/wmshell/src/com/android/wm/shell/bubbles/BubbleOverflow.kt
@@ -19,7 +19,6 @@ package com.android.wm.shell.bubbles
import android.app.ActivityTaskManager.INVALID_TASK_ID
import android.content.Context
import android.graphics.Bitmap
-import android.graphics.Color
import android.graphics.Matrix
import android.graphics.Path
import android.graphics.drawable.AdaptiveIconDrawable
@@ -73,14 +72,18 @@ class BubbleOverflow(private val context: Context, private val positioner: Bubbl
fun initializeForBubbleBar(
expandedViewManager: BubbleExpandedViewManager,
- positioner: BubblePositioner
+ positioner: BubblePositioner,
) {
createBubbleBarExpandedView()
.initialize(
expandedViewManager,
positioner,
/* isOverflow= */ true,
- /* bubbleTaskView= */ null
+ /* bubble= */ null,
+ /* bubbleTaskView= */ null,
+ /* mainExecutor= */ null,
+ /* backgroundExecutor= */ null,
+ /* regionSamplingProvider= */ null,
)
}
@@ -112,18 +115,8 @@ class BubbleOverflow(private val context: Context, private val positioner: Bubbl
val res = context.resources
// Set overflow button accent color, dot color
-
- val typedArray =
- context.obtainStyledAttributes(
- intArrayOf(
- com.android.internal.R.attr.materialColorPrimaryFixed,
- com.android.internal.R.attr.materialColorOnPrimaryFixed
- )
- )
-
- val colorAccent = typedArray.getColor(0, Color.WHITE)
- val shapeColor = typedArray.getColor(1, Color.BLACK)
- typedArray.recycle()
+ val colorAccent = context.getColor(com.android.internal.R.color.materialColorPrimaryFixed)
+ val shapeColor = context.getColor(com.android.internal.R.color.materialColorOnPrimaryFixed)
dotColor = colorAccent
overflowBtn?.iconDrawable?.setTint(shapeColor)
@@ -142,23 +135,16 @@ class BubbleOverflow(private val context: Context, private val positioner: Bubbl
// Update bitmap
val fg = InsetDrawable(overflowBtn?.iconDrawable, overflowIconInset)
- bitmap =
- iconFactory
- .createBadgedIconBitmap(AdaptiveIconDrawable(ColorDrawable(colorAccent), fg))
- .icon
+ val drawable = AdaptiveIconDrawable(ColorDrawable(colorAccent), fg)
+ val bubbleBitmapScale = FloatArray(1)
+ bitmap = iconFactory.getBubbleBitmap(drawable, bubbleBitmapScale)
// Update dot path
dotPath =
PathParser.createPathFromPathData(
res.getString(com.android.internal.R.string.config_icon_mask)
)
- val scale =
- iconFactory.normalizer.getScale(
- iconView!!.iconDrawable,
- null /* outBounds */,
- null /* path */,
- null /* outMaskShape */
- )
+ val scale = bubbleBitmapScale[0]
val radius = BadgedImageView.DEFAULT_PATH_SIZE / 2f
val matrix = Matrix()
matrix.setScale(
@@ -218,29 +204,17 @@ class BubbleOverflow(private val context: Context, private val positioner: Bubbl
override fun getBubbleBarExpandedView(): BubbleBarExpandedView? = bubbleBarExpandedView
- override fun getDotColor(): Int {
- return dotColor
- }
+ override fun getDotColor() = dotColor
- override fun getAppBadge(): Bitmap? {
- return null
- }
+ override fun getAppBadge() = null
- override fun getRawAppBadge(): Bitmap? {
- return null
- }
+ override fun getRawAppBadge() = null
- override fun getBubbleIcon(): Bitmap {
- return bitmap
- }
+ override fun getBubbleIcon() = bitmap
- override fun showDot(): Boolean {
- return showDot
- }
+ override fun showDot() = showDot
- override fun getDotPath(): Path? {
- return dotPath
- }
+ override fun getDotPath() = dotPath
override fun setTaskViewVisibility(visible: Boolean) {
// Overflow does not have a TaskView.
@@ -264,13 +238,9 @@ class BubbleOverflow(private val context: Context, private val positioner: Bubbl
return overflowBtn
}
- override fun getKey(): String {
- return KEY
- }
+ override fun getKey() = KEY
- override fun getTaskId(): Int {
- return if (expandedView != null) expandedView!!.taskId else INVALID_TASK_ID
- }
+ override fun getTaskId() = INVALID_TASK_ID
companion object {
const val KEY = "Overflow"
diff --git a/wmshell/src/com/android/wm/shell/bubbles/BubbleOverflowContainerView.java b/wmshell/src/com/android/wm/shell/bubbles/BubbleOverflowContainerView.java
index 18e04d14c7..e901e0c07f 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/BubbleOverflowContainerView.java
+++ b/wmshell/src/com/android/wm/shell/bubbles/BubbleOverflowContainerView.java
@@ -42,10 +42,11 @@ import androidx.annotation.Nullable;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
-import com.android.internal.protolog.common.ProtoLog;
+import com.android.internal.protolog.ProtoLog;
import com.android.internal.util.ContrastColorUtil;
import com.android.wm.shell.Flags;
import com.android.wm.shell.R;
+import com.android.wm.shell.shared.TypefaceUtils;
import java.util.ArrayList;
import java.util.List;
@@ -226,16 +227,18 @@ public class BubbleOverflowContainerView extends LinearLayout {
? res.getColor(R.color.bubbles_dark)
: res.getColor(R.color.bubbles_light));
- final TypedArray typedArray = getContext().obtainStyledAttributes(new int[] {
- com.android.internal.R.attr.materialColorSurfaceBright,
- com.android.internal.R.attr.materialColorOnSurface});
- int bgColor = typedArray.getColor(0, isNightMode ? Color.BLACK : Color.WHITE);
- int textColor = typedArray.getColor(1, isNightMode ? Color.WHITE : Color.BLACK);
- textColor = ContrastColorUtil.ensureTextContrast(textColor, bgColor, isNightMode);
- typedArray.recycle();
+
+ int bgColor = getContext().getColor(
+ com.android.internal.R.color.materialColorSurfaceBright);
+ int textColor = getContext().getColor(com.android.internal.R.color.materialColorOnSurface);
+
setBackgroundColor(bgColor);
mEmptyStateTitle.setTextColor(textColor);
mEmptyStateSubtitle.setTextColor(textColor);
+ TypefaceUtils.setTypeface(mEmptyStateTitle,
+ TypefaceUtils.FontFamily.GSF_BODY_MEDIUM_EMPHASIZED);
+ TypefaceUtils.setTypeface(mEmptyStateSubtitle, TypefaceUtils.FontFamily.GSF_BODY_MEDIUM);
+
}
public void updateFontSize() {
@@ -324,6 +327,7 @@ class BubbleOverflowAdapter extends RecyclerView.Adapter
+ * TODO: b/417226976
+ * Never used for the overflow or for floating mode on large screen -- bubble bar & phone
+ * floating only.
+ */
+ public void getTaskViewRestBounds(Rect out) {
+ if (isShowingInBubbleBar()) {
+ getBubbleBarExpandedViewBounds(isBubbleBarOnLeft(), false /* isOverflow */, out);
+ } else {
+ final int top = getExpandedViewYTopAligned();
+ // Can assume left false because that only matters for floating on large screen which
+ // is never used here.
+ final int width = getTaskViewContentWidth(false /* onLeft */);
+ // TODO (b/419347947): this assumes max height for the bubble, chat bubbles can have
+ // variable height if the developer overrides; will matter for move chat to fullscreen
+ final int height = getMaxExpandedViewHeight(false /* overflow */);
+ final int[] paddings = getExpandedViewContainerPadding(false /* onLeft */,
+ false /* overflow */);
+ out.set(paddings[0], top, paddings[0] + width, top + height);
+ }
+ }
+
//
// Bubble bar specific sizes below.
//
@@ -830,6 +896,13 @@ public class BubblePositioner {
mShowingInBubbleBar = showingInBubbleBar;
}
+ /**
+ * Whether bubbles ar showing in the bubble bar from launcher.
+ */
+ boolean isShowingInBubbleBar() {
+ return mShowingInBubbleBar;
+ }
+
public void setBubbleBarLocation(BubbleBarLocation location) {
mBubbleBarLocation = location;
}
@@ -845,11 +918,9 @@ public class BubblePositioner {
return mBubbleBarLocation.isOnLeft(mDeviceConfig.isRtl());
}
- /**
- * Set top coordinate of bubble bar on screen
- */
- public void setBubbleBarTopOnScreen(int topOnScreen) {
- mBubbleBarTopOnScreen = topOnScreen;
+ /** Updates the top coordinate of bubble bar on screen. */
+ public void updateBubbleBarTopOnScreen(int bubbleBarTopToScreenBottom) {
+ mBubbleBarTopOnScreen = getScreenRect().bottom - bubbleBarTopToScreenBottom;
}
/**
@@ -863,7 +934,7 @@ public class BubblePositioner {
* How wide the expanded view should be when showing from the bubble bar.
*/
public int getExpandedViewWidthForBubbleBar(boolean isOverflow) {
- return isOverflow ? mOverflowWidth : mExpandedViewLargeScreenWidth;
+ return isOverflow ? mOverflowWidth : mExpandedViewBubbleBarWidth;
}
/**
@@ -873,7 +944,7 @@ public class BubblePositioner {
if (isOverflow) {
return mOverflowHeight;
} else {
- return getBubbleBarExpandedViewHeightForLandscape();
+ return getBubbleBarExpandedViewHeight();
}
}
@@ -894,18 +965,23 @@ public class BubblePositioner {
* | bottom inset ↕ | ↓
* |----------------------| --- mScreenRect.bottom
*/
- private int getBubbleBarExpandedViewHeightForLandscape() {
+ private int getBubbleBarExpandedViewHeight() {
int heightOfBubbleBarContainer =
mScreenRect.height() - getExpandedViewBottomForBubbleBar();
- // getting landscape height from screen rect
- int expandedViewHeight = Math.min(mScreenRect.width(), mScreenRect.height());
+ int expandedViewHeight;
+ if (Flags.enableBubbleBarOnPhones() && !mDeviceConfig.isLargeScreen()) {
+ // we're on a phone, use the max / height
+ expandedViewHeight = Math.max(mScreenRect.width(), mScreenRect.height());
+ } else {
+ // getting landscape height from screen rect
+ expandedViewHeight = Math.min(mScreenRect.width(), mScreenRect.height());
+ }
expandedViewHeight -= heightOfBubbleBarContainer; /* removing bubble container height */
expandedViewHeight -= mInsets.top; /* removing top inset */
expandedViewHeight -= mExpandedViewPadding; /* removing spacing */
return expandedViewHeight;
}
-
/** The bottom position of the expanded view when showing above the bubble bar. */
public int getExpandedViewBottomForBubbleBar() {
return mBubbleBarTopOnScreen - mExpandedViewPadding;
@@ -937,4 +1013,36 @@ public class BubblePositioner {
int top = getExpandedViewBottomForBubbleBar() - height;
out.offsetTo(left, top);
}
+
+ @NonNull
+ @Override
+ public Rect getBubbleBarExpandedViewDropTargetBounds(boolean onLeft) {
+ Rect bounds = new Rect();
+ getBubbleBarExpandedViewBounds(onLeft, false, bounds);
+ // Position based on expanded view bounds and adjust the size
+ if (onLeft) {
+ bounds.right = bounds.left + mBarExpViewDropTargetWidth;
+ } else {
+ bounds.left = bounds.right - mBarExpViewDropTargetWidth;
+ }
+ bounds.bottom = mScreenRect.bottom - mBarExpViewDropTargetPaddingBottom;
+ bounds.top = bounds.bottom - mBarExpViewDropTargetHeight;
+ return bounds;
+ }
+
+ @NonNull
+ @Override
+ public Rect getBarDropTargetBounds(boolean onLeft) {
+ Rect bounds = getBubbleBarExpandedViewDropTargetBounds(onLeft);
+ bounds.top = getBubbleBarTopOnScreen();
+ bounds.bottom = bounds.top + mBarDropTargetHeight;
+ if (onLeft) {
+ // Keep the left edge from expanded view
+ bounds.right = bounds.left + mBarDropTargetWidth;
+ } else {
+ // Keep the right edge from expanded view
+ bounds.left = bounds.right - mBarDropTargetWidth;
+ }
+ return bounds;
+ }
}
diff --git a/wmshell/src/com/android/wm/shell/bubbles/BubbleResizabilityChecker.kt b/wmshell/src/com/android/wm/shell/bubbles/BubbleResizabilityChecker.kt
new file mode 100644
index 0000000000..6ca0821515
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/bubbles/BubbleResizabilityChecker.kt
@@ -0,0 +1,58 @@
+/*
+ * 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.bubbles
+
+import android.content.Intent
+import android.content.pm.ActivityInfo
+import android.content.pm.PackageManager
+import android.util.Log
+
+/**
+ * Checks if an intent is resizable to display in a bubble.
+ */
+class BubbleResizabilityChecker : ResizabilityChecker {
+
+ override fun isResizableActivity(
+ intent: Intent?,
+ packageManager: PackageManager, key: String
+ ): Boolean {
+ if (intent == null) {
+ Log.w(TAG, "Unable to send as bubble: $key null intent")
+ return false
+ }
+ val info = intent.resolveActivityInfo(packageManager, 0)
+ if (info == null) {
+ Log.w(
+ TAG, ("Unable to send as bubble: " + key
+ + " couldn't find activity info for intent: " + intent)
+ )
+ return false
+ }
+ if (!ActivityInfo.isResizeableMode(info.resizeMode)) {
+ Log.w(
+ TAG, ("Unable to send as bubble: " + key
+ + " activity is not resizable for intent: " + intent)
+ )
+ return false
+ }
+ return true
+ }
+
+ companion object {
+ private const val TAG = "BubbleResizeChecker"
+ }
+}
\ No newline at end of file
diff --git a/wmshell/src/com/android/wm/shell/bubbles/BubbleStackView.java b/wmshell/src/com/android/wm/shell/bubbles/BubbleStackView.java
index 94c54699e6..b88ee61e77 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/BubbleStackView.java
+++ b/wmshell/src/com/android/wm/shell/bubbles/BubbleStackView.java
@@ -19,15 +19,16 @@ package com.android.wm.shell.bubbles;
import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT;
-import static com.android.wm.shell.animation.Interpolators.ALPHA_IN;
-import static com.android.wm.shell.animation.Interpolators.ALPHA_OUT;
import static com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_BUBBLES;
import static com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_WITH_CLASS_NAME;
import static com.android.wm.shell.bubbles.BubblePositioner.NUM_VISIBLE_WHEN_RESTING;
import static com.android.wm.shell.bubbles.BubblePositioner.StackPinnedEdge.LEFT;
import static com.android.wm.shell.bubbles.BubblePositioner.StackPinnedEdge.RIGHT;
-import static com.android.wm.shell.common.bubbles.BubbleConstants.BUBBLE_EXPANDED_SCRIM_ALPHA;
import static com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_BUBBLES;
+import static com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_BUBBLES_NOISY;
+import static com.android.wm.shell.shared.animation.Interpolators.ALPHA_IN;
+import static com.android.wm.shell.shared.animation.Interpolators.ALPHA_OUT;
+import static com.android.wm.shell.shared.bubbles.BubbleConstants.BUBBLE_EXPANDED_SCRIM_ALPHA;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
@@ -40,7 +41,6 @@ import android.content.Context;
import android.content.Intent;
import android.content.res.Resources;
import android.content.res.TypedArray;
-import android.graphics.Color;
import android.graphics.Outline;
import android.graphics.PointF;
import android.graphics.PorterDuff;
@@ -53,6 +53,7 @@ import android.util.Log;
import android.view.Choreographer;
import android.view.LayoutInflater;
import android.view.MotionEvent;
+import android.view.SurfaceControl;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.view.View;
@@ -61,7 +62,6 @@ import android.view.ViewOutlineProvider;
import android.view.ViewPropertyAnimator;
import android.view.ViewTreeObserver;
import android.view.WindowManager;
-import android.view.WindowManagerPolicyConstants;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
import android.widget.FrameLayout;
@@ -78,11 +78,10 @@ import androidx.dynamicanimation.animation.SpringForce;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.policy.ScreenDecorationsUtils;
-import com.android.internal.protolog.common.ProtoLog;
+import com.android.internal.protolog.ProtoLog;
import com.android.internal.util.FrameworkStatsLog;
import com.android.wm.shell.Flags;
import com.android.wm.shell.R;
-import com.android.wm.shell.animation.Interpolators;
import com.android.wm.shell.bubbles.BubblesNavBarMotionEventHandler.MotionEventListener;
import com.android.wm.shell.bubbles.animation.AnimatableScaleMatrix;
import com.android.wm.shell.bubbles.animation.ExpandedAnimationController;
@@ -92,10 +91,16 @@ import com.android.wm.shell.bubbles.animation.PhysicsAnimationLayout;
import com.android.wm.shell.bubbles.animation.StackAnimationController;
import com.android.wm.shell.common.FloatingContentCoordinator;
import com.android.wm.shell.common.ShellExecutor;
-import com.android.wm.shell.common.bubbles.DismissView;
-import com.android.wm.shell.common.bubbles.RelativeTouchListener;
-import com.android.wm.shell.shared.magnetictarget.MagnetizedObject;
+import com.android.wm.shell.shared.TypefaceUtils;
+import com.android.wm.shell.shared.TypefaceUtils.FontFamily;
+import com.android.wm.shell.shared.animation.Interpolators;
import com.android.wm.shell.shared.animation.PhysicsAnimator;
+import com.android.wm.shell.shared.bubbles.BubbleAnythingFlagHelper;
+import com.android.wm.shell.shared.bubbles.ContextUtils;
+import com.android.wm.shell.shared.bubbles.DeviceConfig;
+import com.android.wm.shell.shared.bubbles.DismissView;
+import com.android.wm.shell.shared.bubbles.RelativeTouchListener;
+import com.android.wm.shell.shared.magnetictarget.MagnetizedObject;
import java.io.PrintWriter;
import java.math.BigDecimal;
@@ -111,7 +116,8 @@ import java.util.stream.Collectors;
* Renders bubbles in a stack and handles animating expanded and collapsed states.
*/
public class BubbleStackView extends FrameLayout
- implements ViewTreeObserver.OnComputeInternalInsetsListener {
+ implements ViewTreeObserver.OnComputeInternalInsetsListener,
+ BubbleExpandedViewTransitionAnimator {
private static final String TAG = TAG_WITH_CLASS_NAME ? "BubbleStackView" : TAG_BUBBLES;
/** How far the flyout needs to be dragged before it's dismissed regardless of velocity. */
@@ -177,6 +183,12 @@ public class BubbleStackView extends FrameLayout
*/
private final ShellExecutor mMainExecutor;
private Runnable mDelayedAnimation;
+ /**
+ * Runnable set after a bubble is created via a transition; should run after expand or switch
+ * animation is complete.
+ */
+ @Nullable
+ private Runnable mAfterTransitionRunnable = null;
/**
* Interface to synchronize {@link View} state and the screen.
@@ -282,6 +294,7 @@ public class BubbleStackView extends FrameLayout
private int mCornerRadius;
@Nullable private BubbleViewProvider mExpandedBubble;
private boolean mIsExpanded;
+ private boolean mIsImeVisible = false;
/** Whether the stack is currently on the left side of the screen, or animating there. */
private boolean mStackOnLeftOrWillBe = true;
@@ -339,6 +352,7 @@ public class BubbleStackView extends FrameLayout
pw.println(mExpandedViewContainer.getAnimationMatrix());
pw.print(" stack visibility : "); pw.println(getVisibility());
pw.print(" temporarilyInvisible: "); pw.println(mTemporarilyInvisible);
+ pw.print(" expandedViewTemporarilyHidden: "); pw.println(mExpandedViewTemporarilyHidden);
mStackAnimationController.dump(pw);
mExpandedAnimationController.dump(pw);
@@ -497,7 +511,7 @@ public class BubbleStackView extends FrameLayout
view /* bubble */,
mDismissView.getHeight() /* translationYBy */,
() -> dismissBubbleIfExists(
- mBubbleData.getBubbleWithView(view)) /* after */);
+ mBubbleData.getBubbleInStackWithView(view)) /* after */);
}
mDismissView.hide();
@@ -558,7 +572,7 @@ public class BubbleStackView extends FrameLayout
return;
}
- final Bubble clickedBubble = mBubbleData.getBubbleWithView(view);
+ final Bubble clickedBubble = mBubbleData.getBubbleInStackWithView(view);
// If the bubble has since left us, ignore the click.
if (clickedBubble == null) {
@@ -622,6 +636,9 @@ public class BubbleStackView extends FrameLayout
}
if (mBubbleData.isExpanded()) {
+ // TODO(b/417749498): Delete debug log
+ ProtoLog.v(
+ WM_SHELL_BUBBLES_NOISY, "BubbleStackView.bubbleDrag.onDown: isExpanded");
if (mManageEduView != null) {
mManageEduView.hide();
}
@@ -633,11 +650,12 @@ public class BubbleStackView extends FrameLayout
mMagneticTarget,
mIndividualBubbleMagnetListener);
- hideCurrentInputMethod();
-
// Save the magnetized individual bubble so we can dispatch touch events to it.
mMagnetizedObject = mExpandedAnimationController.getMagnetizedBubbleDraggingOut();
} else {
+ // TODO(b/417749498): Delete debug log
+ ProtoLog.v(
+ WM_SHELL_BUBBLES_NOISY, "BubbleStackView.bubbleDrag.onDown: isCollapsed");
// If we're collapsed, prepare to drag the stack. Cancel active animations, set the
// animation controller, and hide the flyout.
mStackAnimationController.cancelStackPositionAnimations();
@@ -666,13 +684,36 @@ public class BubbleStackView extends FrameLayout
@Override
public void onMove(@NonNull View v, @NonNull MotionEvent ev, float viewInitialX,
float viewInitialY, float dx, float dy) {
+ // TODO(b/417749498): Delete debug log
+ ProtoLog.v(
+ WM_SHELL_BUBBLES_NOISY,
+ "BubbleStackView.bubbleDrag.onMove: dx=%f, dy=%f",
+ dx,
+ dy);
// If we're expanding or collapsing, ignore all touch events.
if (mIsExpansionAnimating || mShowedUserEducationInTouchListenerActive) {
+ // TODO(b/417749498): Delete debug log
+ ProtoLog.v(
+ WM_SHELL_BUBBLES_NOISY,
+ "BubbleStackView.bubbleDrag.onMove: ignore isExpansionAnimating=%b"
+ + " showedUserEDU=%b",
+ mIsExpansionAnimating,
+ mShowedUserEducationInTouchListenerActive);
return;
}
+ if (mIsExpanded && mPositioner.isImeVisible()) {
+ hideCurrentInputMethod();
+ }
+
// Show the dismiss target, if we haven't already.
- mDismissView.show();
+ boolean shown = mDismissView.show();
+ // TODO(b/417749498): Delete debug log
+ ProtoLog.v(
+ WM_SHELL_BUBBLES_NOISY,
+ "BubbleStackView.bubbleDrag.onMove: dismissView(%d) shown=%b",
+ mDismissView.hashCode(),
+ shown);
if (mIsExpanded && mExpandedBubble != null && v.equals(mExpandedBubble.getIconView())) {
// Hide the expanded view if we're dragging out the expanded bubble, and we haven't
@@ -702,16 +743,29 @@ public class BubbleStackView extends FrameLayout
float viewInitialY, float dx, float dy, float velX, float velY) {
// If we're expanding or collapsing, ignore all touch events.
if (mIsExpansionAnimating) {
+ // TODO(b/417749498): Delete debug log
+ ProtoLog.v(
+ WM_SHELL_BUBBLES_NOISY,
+ "BubbleStackView.bubbleDrag.onUp: isExpansionAnimating ignore");
return;
}
if (mShowedUserEducationInTouchListenerActive) {
mShowedUserEducationInTouchListenerActive = false;
+ // TODO(b/417749498): Delete debug log
+ ProtoLog.v(
+ WM_SHELL_BUBBLES_NOISY,
+ "BubbleStackView.bubbleDrag.onUp: showed user EDU ignore");
return;
}
// First, see if the magnetized object consumes the event - if so, the bubble was
// released in the target or flung out of it, and we should ignore the event.
if (!passEventToMagnetizedObject(ev)) {
+ // TODO(b/417749498): Delete debug log
+ ProtoLog.v(
+ WM_SHELL_BUBBLES_NOISY,
+ "BubbleStackView.bubbleDrag.onUp: magnetized object not handling"
+ + " event");
if (mBubbleData.isExpanded()) {
mExpandedAnimationController.snapBubbleBack(v, velX, velY);
@@ -729,7 +783,16 @@ public class BubbleStackView extends FrameLayout
logBubbleEvent(null /* no bubble associated with bubble stack move */,
FrameworkStatsLog.BUBBLE_UICHANGED__ACTION__STACK_MOVED);
}
+ // TODO(b/417749498): Delete debug log
+ ProtoLog.v(
+ WM_SHELL_BUBBLES_NOISY,
+ "BubbleStackView.bubbleDrag.onUp: hide dismiss view");
mDismissView.hide();
+ } else {
+ // TODO(b/417749498): Delete debug log
+ ProtoLog.v(
+ WM_SHELL_BUBBLES_NOISY,
+ "BubbleStackView.bubbleDrag.onUp: magnetized object handling up");
}
onDraggingEnded();
@@ -742,6 +805,8 @@ public class BubbleStackView extends FrameLayout
@Override
public void onCancel(@NonNull View v, @NonNull MotionEvent ev, float viewInitialX,
float viewInitialY) {
+ // TODO(b/417749498): Delete debug log
+ ProtoLog.v(WM_SHELL_BUBBLES_NOISY, "BubbleStackView.bubbleDrag.onCancel");
animateStashedState(false /* stashImmediately */);
}
};
@@ -1127,6 +1192,8 @@ public class BubbleStackView extends FrameLayout
if (expandedView != null) {
// We need to be Z ordered on top in order for alpha animations to work.
expandedView.setSurfaceZOrderedOnTop(true);
+ ProtoLog.d(WM_SHELL_BUBBLES, "expandedViewAlphaAnimation - start=%s",
+ expandedView.getBubbleKey());
expandedView.setAnimating(true);
mExpandedViewContainer.setVisibility(VISIBLE);
}
@@ -1142,6 +1209,8 @@ public class BubbleStackView extends FrameLayout
// = 0f remains in effect.
&& !mExpandedViewTemporarilyHidden) {
expandedView.setSurfaceZOrderedOnTop(false);
+ ProtoLog.d(WM_SHELL_BUBBLES, "expandedViewAlphaAnimation - end=%s",
+ expandedView.getBubbleKey());
expandedView.setAnimating(false);
}
}
@@ -1170,6 +1239,58 @@ public class BubbleStackView extends FrameLayout
});
}
+ @Override
+ public boolean canExpandView(BubbleViewProvider b) {
+ if (mExpandedBubble != null && mIsExpanded && b.getKey().equals(mExpandedBubble.getKey())) {
+ // Already showing this bubble so can't expand it.
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY,
+ "BubbleStackView.canExpandView - false %s already expanded", b.getKey());
+ return false;
+ }
+ if (b instanceof Bubble) {
+ BubbleTransitions.BubbleTransition transition = ((Bubble) b).getPreparingTransition();
+ if (transition != null) {
+ // StackView doesn't need to wait for launcher to expand, if we're able to expand,
+ // mark it as ready now.
+ transition.continueExpand();
+ }
+ }
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "BubbleStackView.canExpandView - true");
+ return true;
+ }
+
+ @Override
+ public BubbleViewProvider prepareConvertedView(BubbleViewProvider b) {
+ // TODO b/419347947 - if we support converting visible tasks to bubbles in the future
+ // this might have to do some stuff.
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "BubbleStackView.prepareConvertedView - doing nothing");
+ return b;
+ }
+
+ @Override
+ public void animateConvert(@NonNull SurfaceControl.Transaction startT,
+ @NonNull Rect startBounds, float startScale, @NonNull SurfaceControl snapshot,
+ SurfaceControl taskLeash, Runnable animFinish) {
+ // TODO b/419347947 - if we support converting visible tasks to bubbles in the future
+ // this will have to do some stuff.
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "BubbleStackView.animateConvert - doing nothing");
+ }
+
+ @Override
+ public void animateExpand(@Nullable BubbleViewProvider previousBubble,
+ @Nullable Runnable animFinish) {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "BubbleStackView.animateExpand -- caching runnable");
+ mAfterTransitionRunnable = animFinish;
+ }
+
+ @Override
+ public void removeViewFromTransition(View view) {
+ // TODO b/419347947 - if we support converting visible tasks to bubbles in the future
+ // this will have to do some stuff.
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY,
+ "BubbleStackView.removeViewFromTransition - doing nothing");
+ }
+
/**
* Reset state related to dragging.
*/
@@ -1312,7 +1433,7 @@ public class BubbleStackView extends FrameLayout
mBubbleContainer.bringToFront();
}
- // TODO: Create ManageMenuView and move setup / animations there
+ // TODO (b/402196554) : Create ManageMenuView and move setup / animations there
private void setUpManageMenu() {
if (mManageMenu != null) {
removeView(mManageMenu);
@@ -1322,10 +1443,9 @@ public class BubbleStackView extends FrameLayout
R.layout.bubble_manage_menu, this, false);
mManageMenu.setVisibility(View.INVISIBLE);
- final TypedArray ta = mContext.obtainStyledAttributes(new int[]{
- com.android.internal.R.attr.materialColorSurfaceBright});
- final int menuBackgroundColor = ta.getColor(0, Color.WHITE);
- ta.recycle();
+ final int menuBackgroundColor = mContext.getColor(
+ com.android.internal.R.color.materialColorSurfaceBright);
+
mManageMenu.getBackground().setColorFilter(menuBackgroundColor, PorterDuff.Mode.SRC_IN);
PhysicsAnimator.getInstance(mManageMenu).setDefaultSpringConfig(mManageSpringConfig);
@@ -1371,25 +1491,48 @@ public class BubbleStackView extends FrameLayout
mManageSettingsIcon = mManageMenu.findViewById(R.id.bubble_manage_menu_settings_icon);
mManageSettingsText = mManageMenu.findViewById(R.id.bubble_manage_menu_settings_name);
+ View fullscreenView = mManageMenu.findViewById(
+ R.id.bubble_manage_menu_fullscreen_container);
+ if (BubbleAnythingFlagHelper.enableBubbleToFullscreen()) {
+ fullscreenView.setVisibility(VISIBLE);
+ fullscreenView.setOnClickListener(
+ view -> {
+ showManageMenu(false /* show */);
+ BubbleExpandedView expandedView = getExpandedView();
+ if (expandedView != null && expandedView.getTaskView() != null) {
+ expandedView.getTaskView().moveToFullscreen();
+ }
+ });
+ } else {
+ fullscreenView.setVisibility(GONE);
+ }
+
// The menu itself should respect locale direction so the icons are on the correct side.
mManageMenu.setLayoutDirection(LAYOUT_DIRECTION_LOCALE);
addView(mManageMenu);
- updateManageButtonListener();
+
+ // Doesn't seem to work unless view is added; so set font after.
+ TypefaceUtils.setTypeface(findViewById(R.id.manage_dismiss), FontFamily.GSF_LABEL_LARGE);
+ TypefaceUtils.setTypeface(findViewById(R.id.manage_dont_bubble),
+ FontFamily.GSF_LABEL_LARGE);
+ TypefaceUtils.setTypeface(mManageSettingsText, FontFamily.GSF_LABEL_LARGE);
+ TypefaceUtils.setTypeface(findViewById(R.id.bubble_manage_menu_fullscreen_title),
+ FontFamily.GSF_LABEL_LARGE);
}
/**
* Whether the selected bubble is conversation bubble
*/
- private boolean isConversationBubble() {
+ private boolean isChat() {
BubbleViewProvider bubble = mBubbleData.getSelectedBubble();
- return bubble instanceof Bubble && ((Bubble) bubble).isConversation();
+ return bubble instanceof Bubble && ((Bubble) bubble).isChat();
}
/**
* Whether the educational view should show for the expanded view "manage" menu.
*/
private boolean shouldShowManageEdu() {
- if (!isConversationBubble()) {
+ if (!isChat()) {
// We only show user education for conversation bubbles right now
return false;
}
@@ -1436,7 +1579,7 @@ public class BubbleStackView extends FrameLayout
* Whether education view should show for the collapsed stack.
*/
private boolean shouldShowStackEdu() {
- if (!isConversationBubble()) {
+ if (!isChat()) {
// We only show user education for conversation bubbles right now
return false;
}
@@ -1544,8 +1687,21 @@ public class BubbleStackView extends FrameLayout
private void updateOverflow() {
mBubbleOverflow.update();
if (mShowingOverflow) {
- mBubbleContainer.reorderView(mBubbleOverflow.getIconView(),
- mBubbleContainer.getChildCount() - 1 /* index */);
+ View overflow = mBubbleOverflow.getIconView();
+ if (overflow != null) {
+ ViewGroup parent = (ViewGroup) overflow.getParent();
+ if (parent != null && parent != mBubbleContainer) {
+ Log.w(TAG, "Found an unexpected parent for the overflow icon before "
+ + "reordering. Removing it directly. Parent = " + parent);
+ if (parent instanceof PhysicsAnimationLayout) {
+ ((PhysicsAnimationLayout) parent).removeViewNoAnimation(overflow);
+ } else {
+ parent.removeView(overflow);
+ }
+ }
+ mBubbleContainer.reorderView(overflow,
+ mBubbleContainer.getChildCount() - 1 /* index */);
+ }
}
updateOverflowVisibility();
}
@@ -1602,6 +1758,11 @@ public class BubbleStackView extends FrameLayout
getResources().getColor(android.R.color.system_neutral1_1000)));
mManageMenuScrim.setBackgroundDrawable(new ColorDrawable(
getResources().getColor(android.R.color.system_neutral1_1000)));
+ if (mShowingManage) {
+ // the manage menu location depends on the manage button location which may need a
+ // layout pass, so post this to the looper
+ post(() -> showManageMenu(true));
+ }
}
/**
@@ -1696,6 +1857,7 @@ public class BubbleStackView extends FrameLayout
getViewTreeObserver().removeOnPreDrawListener(mViewUpdater);
getViewTreeObserver().removeOnDrawListener(mSystemGestureExcludeUpdater);
getViewTreeObserver().removeOnComputeInternalInsetsListener(this);
+ stopMonitoringSwipeUpGesture();
}
@Override
@@ -1919,6 +2081,7 @@ public class BubbleStackView extends FrameLayout
/**
* Whether the stack of bubbles is expanded or not.
*/
+ @Override
public boolean isExpanded() {
return mIsExpanded;
}
@@ -1965,12 +2128,11 @@ public class BubbleStackView extends FrameLayout
return;
}
- if (firstBubble && bubble.isAppBubble() && !mPositioner.hasUserModifiedDefaultPosition()) {
- // TODO (b/294284894): update language around "app bubble" here
- // If it's an app bubble and we don't have a previous resting position, update the
- // controllers to use the default position for the app bubble (it'd be different from
+ if (firstBubble && bubble.isNote() && !mPositioner.hasUserModifiedDefaultPosition()) {
+ // If it's an note bubble and we don't have a previous resting position, update the
+ // controllers to use the default position for the note bubble (it'd be different from
// the position initialized with the controllers originally).
- PointF startPosition = mPositioner.getDefaultStartPosition(true /* isAppBubble */);
+ PointF startPosition = mPositioner.getDefaultStartPosition(true /* isNoteBubble */);
mStackOnLeftOrWillBe = mPositioner.isStackOnLeft(startPosition);
mStackAnimationController.setStackPosition(startPosition);
mExpandedAnimationController.setCollapsePoint(startPosition);
@@ -2005,6 +2167,7 @@ public class BubbleStackView extends FrameLayout
// and then remove our views (removing the icon view triggers the removal of the
// bubble window so do that at the end of the animation so we see the scrim animate).
BadgedImageView iconView = bubble.getIconView();
+ final BubbleViewProvider expandedBubbleBeforeScrim = mExpandedBubble;
showScrim(false, () -> {
mRemovingLastBubbleWhileExpanded = false;
bubble.cleanupExpandedView();
@@ -2013,7 +2176,17 @@ public class BubbleStackView extends FrameLayout
}
bubble.cleanupViews(); // cleans up the icon view
updateExpandedView(); // resets state for no expanded bubble
- mExpandedBubble = null;
+ // Bubble keys may not have changed if we receive an update to the same bubble.
+ // Compare bubble object instances to see if the expanded bubble has changed.
+ if (expandedBubbleBeforeScrim == mExpandedBubble) {
+ // Only clear expanded bubble if it has not changed since the scrim animation
+ // started.
+ // Scrim animation can take some time run and it is possible for a new bubble
+ // to be added while the animation is running. This causes the expanded
+ // bubble to change. Make sure we only clear the expanded bubble if it did
+ // not change between when the scrim animation started and completed.
+ mExpandedBubble = null;
+ }
});
logBubbleEvent(bubble, FrameworkStatsLog.BUBBLE_UICHANGED__ACTION__DISMISSED);
return;
@@ -2155,36 +2328,48 @@ public class BubbleStackView extends FrameLayout
final BubbleViewProvider previouslySelected = mExpandedBubble;
mExpandedBubble = bubbleToSelect;
mExpandedViewAnimationController.setExpandedView(getExpandedView());
-
+ final String previouslySelectedKey = previouslySelected != null
+ ? previouslySelected.getKey()
+ : "null";
+ final String newlySelectedKey = mExpandedBubble != null
+ ? mExpandedBubble.getKey()
+ : "null";
+ ProtoLog.d(WM_SHELL_BUBBLES, "showNewlySelectedBubble b=%s, previouslySelected=%s,"
+ + " mIsExpanded=%b", newlySelectedKey, previouslySelectedKey, mIsExpanded);
if (mIsExpanded) {
- hideCurrentInputMethod();
-
- if (Flags.enableRetrievableBubbles()) {
- if (mBubbleData.getBubbles().size() == 1) {
- // First bubble, check if overflow visibility needs to change
- updateOverflowVisibility();
+ Runnable onImeHidden = () -> {
+ if (Flags.enableRetrievableBubbles()) {
+ if (mBubbleData.getBubbles().size() == 1) {
+ // First bubble, check if overflow visibility needs to change
+ updateOverflowVisibility();
+ }
}
+
+ // Make the container of the expanded view transparent before removing the expanded
+ // view from it. Otherwise a punch hole created by {@link android.view.SurfaceView}
+ // in the expanded view becomes visible on the screen. See b/126856255
+ mExpandedViewContainer.setAlpha(0.0f);
+ mSurfaceSynchronizer.syncSurfaceAndRun(() -> {
+ if (previouslySelected != null) {
+ previouslySelected.setTaskViewVisibility(false);
+ }
+
+ updateExpandedBubble();
+ requestUpdate();
+
+ logBubbleEvent(previouslySelected,
+ FrameworkStatsLog.BUBBLE_UICHANGED__ACTION__COLLAPSED);
+ logBubbleEvent(bubbleToSelect,
+ FrameworkStatsLog.BUBBLE_UICHANGED__ACTION__EXPANDED);
+ notifyExpansionChanged(previouslySelected, false /* expanded */);
+ notifyExpansionChanged(bubbleToSelect, true /* expanded */);
+ });
+ };
+ if (mPositioner.isImeVisible()) {
+ hideCurrentInputMethod(onImeHidden);
+ } else {
+ onImeHidden.run();
}
-
- // Make the container of the expanded view transparent before removing the expanded view
- // from it. Otherwise a punch hole created by {@link android.view.SurfaceView} in the
- // expanded view becomes visible on the screen. See b/126856255
- mExpandedViewContainer.setAlpha(0.0f);
- mSurfaceSynchronizer.syncSurfaceAndRun(() -> {
- if (previouslySelected != null) {
- previouslySelected.setTaskViewVisibility(false);
- }
-
- updateExpandedBubble();
- requestUpdate();
-
- logBubbleEvent(previouslySelected,
- FrameworkStatsLog.BUBBLE_UICHANGED__ACTION__COLLAPSED);
- logBubbleEvent(bubbleToSelect,
- FrameworkStatsLog.BUBBLE_UICHANGED__ACTION__EXPANDED);
- notifyExpansionChanged(previouslySelected, false /* expanded */);
- notifyExpansionChanged(bubbleToSelect, true /* expanded */);
- });
}
}
@@ -2208,29 +2393,47 @@ public class BubbleStackView extends FrameLayout
boolean wasExpanded = mIsExpanded;
- hideCurrentInputMethod();
+ // Do the actual expansion/collapse after the IME is hidden if it's currently visible in
+ // order to avoid flickers
+ Runnable onImeHidden = () -> {
+ mSysuiProxyProvider.getSysuiProxy().onStackExpandChanged(shouldExpand);
- mSysuiProxyProvider.getSysuiProxy().onStackExpandChanged(shouldExpand);
+ if (wasExpanded) {
+ stopMonitoringSwipeUpGesture();
+ animateCollapse();
+ showManageMenu(false);
+ logBubbleEvent(mExpandedBubble,
+ FrameworkStatsLog.BUBBLE_UICHANGED__ACTION__COLLAPSED);
+ } else {
+ animateExpansion();
+ // TODO: move next line to BubbleData
+ logBubbleEvent(mExpandedBubble,
+ FrameworkStatsLog.BUBBLE_UICHANGED__ACTION__EXPANDED);
+ logBubbleEvent(mExpandedBubble,
+ FrameworkStatsLog.BUBBLE_UICHANGED__ACTION__STACK_EXPANDED);
+ mManager.checkNotificationPanelExpandedState(notifPanelExpanded -> {
+ if (!notifPanelExpanded && mIsExpanded) {
+ startMonitoringSwipeUpGesture();
+ }
+ });
+ }
+ notifyExpansionChanged(mExpandedBubble, mIsExpanded);
+ announceExpandForAccessibility(mExpandedBubble, mIsExpanded);
+ };
- if (wasExpanded) {
- stopMonitoringSwipeUpGesture();
- animateCollapse();
- showManageMenu(false);
- logBubbleEvent(mExpandedBubble, FrameworkStatsLog.BUBBLE_UICHANGED__ACTION__COLLAPSED);
+ if (mPositioner.isImeVisible()) {
+ hideCurrentInputMethod(onImeHidden);
} else {
- animateExpansion();
- // TODO: move next line to BubbleData
- logBubbleEvent(mExpandedBubble, FrameworkStatsLog.BUBBLE_UICHANGED__ACTION__EXPANDED);
- logBubbleEvent(mExpandedBubble,
- FrameworkStatsLog.BUBBLE_UICHANGED__ACTION__STACK_EXPANDED);
- mManager.checkNotificationPanelExpandedState(notifPanelExpanded -> {
- if (!notifPanelExpanded && mIsExpanded) {
- startMonitoringSwipeUpGesture();
- }
- });
+ if (Flags.enableBubbleSwipeUpCleanup()) {
+ // Clear out the existing runnable if one was scheduled to run after IME was hidden.
+ // IME hide action can take time or in some cases not trigger at all. And we can
+ // get a second call to expand in during it. Make sure we don't run a previous
+ // runnable in that case.
+ mManager.clearImeHiddenRunnable();
+ }
+ // the IME is already hidden, so run the runnable immediately
+ onImeHidden.run();
}
- notifyExpansionChanged(mExpandedBubble, mIsExpanded);
- announceExpandForAccessibility(mExpandedBubble, mIsExpanded);
}
/**
@@ -2249,7 +2452,7 @@ public class BubbleStackView extends FrameLayout
void startMonitoringSwipeUpGesture() {
stopMonitoringSwipeUpGestureInternal();
- if (isGestureNavEnabled()) {
+ if (ContextUtils.isGestureNavigationMode(mContext)) {
mBubblesNavBarGestureTracker = new BubblesNavBarGestureTracker(mContext, mPositioner);
mBubblesNavBarGestureTracker.start(mSwipeUpListener);
setOnTouchListener(mContainerSwipeListener);
@@ -2284,16 +2487,11 @@ public class BubbleStackView extends FrameLayout
}
}
- private boolean isGestureNavEnabled() {
- return mContext.getResources().getInteger(
- com.android.internal.R.integer.config_navBarInteractionMode)
- == WindowManagerPolicyConstants.NAV_BAR_MODE_GESTURAL;
- }
-
/**
* Stop monitoring for swipe up gesture
*/
- void stopMonitoringSwipeUpGesture() {
+ @VisibleForTesting
+ public void stopMonitoringSwipeUpGesture() {
stopMonitoringSwipeUpGestureInternal();
}
@@ -2353,7 +2551,17 @@ public class BubbleStackView extends FrameLayout
* not.
*/
void hideCurrentInputMethod() {
- mManager.hideCurrentInputMethod();
+ mManager.hideCurrentInputMethod(null);
+ }
+
+ /**
+ * Hides the IME similar to {@link #hideCurrentInputMethod()} but also runs {@code onImeHidden}
+ * after after the IME is hidden.
+ *
+ * @see #hideCurrentInputMethod()
+ */
+ void hideCurrentInputMethod(Runnable onImeHidden) {
+ mManager.hideCurrentInputMethod(onImeHidden);
}
/** Set the stack position to whatever the positioner says. */
@@ -2557,6 +2765,8 @@ public class BubbleStackView extends FrameLayout
expandedView.setContentAlpha(0f);
expandedView.setBackgroundAlpha(0f);
+ ProtoLog.d(WM_SHELL_BUBBLES, "animateBubbleExpansion, setAnimating true for bubble=%s",
+ expandedView.getBubbleKey());
// We'll be starting the alpha animation after a slight delay, so set this flag early
// here.
expandedView.setAnimating(true);
@@ -2594,6 +2804,12 @@ public class BubbleStackView extends FrameLayout
expView.setSurfaceZOrderedOnTop(false);
}
})
+ .withEndOrCancelActions(() -> {
+ if (mAfterTransitionRunnable != null) {
+ mAfterTransitionRunnable.run();
+ mAfterTransitionRunnable = null;
+ }
+ })
.start();
};
mMainExecutor.executeDelayed(mDelayedAnimation, startDelay);
@@ -2721,6 +2937,11 @@ public class BubbleStackView extends FrameLayout
mAnimatingOutSurfaceAlphaAnimator.reverse();
mExpandedViewAlphaAnimator.start();
+ if (mExpandedBubble != null) {
+ ProtoLog.d(WM_SHELL_BUBBLES, "animateSwitchBubbles, switchingTo b=%s",
+ mExpandedBubble.getKey());
+ }
+
if (mPositioner.showBubblesVertically()) {
float translationX = mStackAnimationController.isStackOnLeftSide()
? mAnimatingOutSurfaceContainer.getTranslationX() + mBubbleSize * 2
@@ -2801,6 +3022,12 @@ public class BubbleStackView extends FrameLayout
expandedView.setAnimating(false);
}
})
+ .withEndOrCancelActions(() -> {
+ if (mAfterTransitionRunnable != null) {
+ mAfterTransitionRunnable.run();
+ mAfterTransitionRunnable = null;
+ }
+ })
.start();
}, 25);
}
@@ -2838,6 +3065,10 @@ public class BubbleStackView extends FrameLayout
* and clip the expanded view.
*/
public void setImeVisible(boolean visible) {
+ if (mIsImeVisible == visible) {
+ return;
+ }
+ mIsImeVisible = visible;
if ((mIsExpansionAnimating || mIsBubbleSwitchAnimating) && mIsExpanded) {
// This will update the animation so the bubbles move to position for the IME
mExpandedAnimationController.expandFromStack(() -> {
@@ -2871,6 +3102,9 @@ public class BubbleStackView extends FrameLayout
if (mIsExpanded) {
mExpandedViewAnimationController.animateForImeVisibilityChange(visible);
BubbleExpandedView expandedView = getExpandedView();
+ if (expandedView != null) {
+ expandedView.setImeVisible(visible);
+ }
if (mPositioner.showBubblesVertically() && expandedView != null) {
float selectedY = mPositioner.getExpandedBubbleXY(getState().selectedIndex,
getState()).y;
@@ -3262,20 +3496,16 @@ public class BubbleStackView extends FrameLayout
// name and icon.
if (show) {
final Bubble bubble = mBubbleData.getBubbleInStackWithKey(mExpandedBubble.getKey());
- if (bubble != null && !bubble.isAppBubble()) {
- // Setup options for non app bubbles
+ if (bubble != null && bubble.isChat()) {
+ // Setup options for chat bubbles
mManageDontBubbleView.setVisibility(VISIBLE);
mManageSettingsIcon.setImageBitmap(bubble.getRawAppBadge());
mManageSettingsText.setText(getResources().getString(
R.string.bubbles_app_settings, bubble.getAppName()));
mManageSettingsView.setVisibility(VISIBLE);
} else {
- // Setup options for app bubbles
- // App bubbles have no conversations
- // so we don't show the option to not bubble conversation
+ // Not a chat bubble, so don't show conversation / notification settings
mManageDontBubbleView.setVisibility(GONE);
- // App bubbles are not notification based
- // so we don't show the option to go to notification settings
mManageSettingsView.setVisibility(GONE);
}
}
@@ -3375,14 +3605,6 @@ public class BubbleStackView extends FrameLayout
mExpandedViewContainer.setAlpha(0f);
mExpandedViewContainer.addView(bev);
- postDelayed(() -> {
- // Set the Manage button click handler from postDelayed. This appears to resolve
- // a race condition with adding the BubbleExpandedView view to the expanded view
- // container. Due to the race condition the click handler sometimes is not set up
- // correctly and is never called.
- updateManageButtonListener();
- }, 0);
-
if (!mIsExpansionAnimating) {
mIsBubbleSwitchAnimating = true;
mSurfaceSynchronizer.syncSurfaceAndRun(() -> {
@@ -3392,13 +3614,8 @@ public class BubbleStackView extends FrameLayout
}
}
- private void updateManageButtonListener() {
- BubbleExpandedView bev = getExpandedView();
- if (mIsExpanded && bev != null) {
- bev.setManageClickListener((view) -> {
- showManageMenu(true /* show */);
- });
- }
+ void onManageBubbleClicked() {
+ showManageMenu(true /* show */);
}
/**
@@ -3505,7 +3722,7 @@ public class BubbleStackView extends FrameLayout
}
}
- private void updateExpandedView() {
+ void updateExpandedView() {
boolean isOverflowExpanded = mExpandedBubble != null
&& BubbleOverflow.KEY.equals(mExpandedBubble.getKey());
int[] paddings = mPositioner.getExpandedViewContainerPadding(
diff --git a/wmshell/src/com/android/wm/shell/bubbles/BubbleStackViewManager.kt b/wmshell/src/com/android/wm/shell/bubbles/BubbleStackViewManager.kt
index fb597a0566..043cddd8cc 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/BubbleStackViewManager.kt
+++ b/wmshell/src/com/android/wm/shell/bubbles/BubbleStackViewManager.kt
@@ -34,7 +34,10 @@ interface BubbleStackViewManager {
fun checkNotificationPanelExpandedState(callback: Consumer)
/** Requests to hide the current input method. */
- fun hideCurrentInputMethod()
+ fun hideCurrentInputMethod(onImeHidden: Runnable?)
+
+ /** Allows callers to clear the runnable set by [hideCurrentInputMethod]. */
+ fun clearImeHiddenRunnable()
companion object {
@@ -52,8 +55,12 @@ interface BubbleStackViewManager {
controller.isNotificationPanelExpanded(callback)
}
- override fun hideCurrentInputMethod() {
- controller.hideCurrentInputMethod()
+ override fun hideCurrentInputMethod(onImeHidden: Runnable?) {
+ controller.hideCurrentInputMethod(onImeHidden)
+ }
+
+ override fun clearImeHiddenRunnable() {
+ controller.clearImeHiddenRunnable()
}
}
}
diff --git a/wmshell/src/com/android/wm/shell/bubbles/BubbleTaskStackListener.kt b/wmshell/src/com/android/wm/shell/bubbles/BubbleTaskStackListener.kt
new file mode 100644
index 0000000000..8e480a9256
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/bubbles/BubbleTaskStackListener.kt
@@ -0,0 +1,135 @@
+/*
+ * 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.
+ */
+
+// Exports bubble task utilities (e.g., `isBubbleToFullscreen`) for Java interop.
+@file:JvmName("BubbleTaskUtils")
+
+package com.android.wm.shell.bubbles
+
+import android.app.ActivityManager
+import android.app.WindowConfiguration.ACTIVITY_TYPE_STANDARD
+import com.android.internal.protolog.ProtoLog
+import com.android.wm.shell.ShellTaskOrganizer
+import com.android.wm.shell.bubbles.util.BubbleUtils.getExitBubbleTransaction
+import com.android.wm.shell.bubbles.util.BubbleUtils.isBubbleToFullscreen
+import com.android.wm.shell.common.TaskStackListenerCallback
+import com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_BUBBLES
+import com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_BUBBLES_NOISY
+import com.android.wm.shell.splitscreen.SplitScreenController
+import com.android.wm.shell.taskview.TaskViewTaskController
+import dagger.Lazy
+import java.util.Optional
+
+/**
+ * Listens for task stack changes and handles bubble interactions when activities are restarted.
+ *
+ * This class monitors task stack events to determine how bubbles should behave when their
+ * associated activities are restarted. It handles scenarios where bubbles should be expanded
+ * or moved to fullscreen based on the task's windowing mode.
+ *
+ * @property bubbleController The [BubbleController] to manage bubble promotions and expansions.
+ * @property bubbleData The [BubbleData] to access and update bubble information.
+ */
+class BubbleTaskStackListener(
+ private val bubbleController: BubbleController,
+ private val bubbleData: BubbleData,
+ private val splitScreenController: Lazy>
+) : TaskStackListenerCallback {
+
+ override fun onActivityRestartAttempt(
+ task: ActivityManager.RunningTaskInfo,
+ homeTaskVisible: Boolean,
+ clearedTask: Boolean,
+ wasVisible: Boolean,
+ ) {
+ ProtoLog.d(
+ WM_SHELL_BUBBLES_NOISY,
+ "BubbleTaskStackListener.onActivityRestartAttempt(): taskId=%d",
+ task.taskId)
+ val taskId = task.taskId
+ bubbleData.getBubbleInStackWithTaskId(taskId)?.let { bubble ->
+ when {
+ isBubbleToFullscreen(task) -> moveCollapsedInStackBubbleToFullscreen(bubble, task)
+ isBubbleToSplit(task) -> return // skip split task restarts
+ !isAppBubbleMovingToFront(task) -> selectAndExpandInStackBubble(bubble, task)
+ }
+ }
+ }
+
+ private fun isBubbleToSplit(task: ActivityManager.RunningTaskInfo): Boolean {
+ return task.hasParentTask() && splitScreenController.get()
+ .map { it.isTaskRootOrStageRoot(task.parentTaskId) }
+ .orElse(false)
+ }
+
+ /**
+ * Returns whether the given bubble task restart should move the app bubble to front
+ * and be handled in DefaultMixedTransition#animateEnterBubblesFromBubble.
+ * This occurs when a startActivity call resolves to an existing activity, causing the
+ * task to move to front, and the mixed transition will then expand the bubble.
+ */
+ private fun isAppBubbleMovingToFront(task: ActivityManager.RunningTaskInfo): Boolean {
+ return task.activityType == ACTIVITY_TYPE_STANDARD
+ && bubbleController.shouldBeAppBubble(task)
+ }
+
+ /** Selects and expands a bubble that is currently in the stack. */
+ private fun selectAndExpandInStackBubble(
+ bubble: Bubble,
+ task: ActivityManager.RunningTaskInfo,
+ ) {
+ ProtoLog.d(
+ WM_SHELL_BUBBLES,
+ "selectAndExpandInStackBubble - taskId=%d selecting matching bubble=%s",
+ task.taskId,
+ bubble.key,
+ )
+ bubbleData.setSelectedBubbleAndExpandStack(bubble)
+ }
+
+ /** Moves a collapsed bubble that is currently in the stack to fullscreen. */
+ private fun moveCollapsedInStackBubbleToFullscreen(
+ bubble: Bubble,
+ task: ActivityManager.RunningTaskInfo,
+ ) {
+ ProtoLog.d(
+ WM_SHELL_BUBBLES,
+ "moveCollapsedInStackBubbleToFullscreen - taskId=%d " +
+ "moving matching bubble=%s to fullscreen",
+ task.taskId,
+ bubble.key
+ )
+ collapsedBubbleToFullscreenInternal(bubble, task)
+ }
+
+ /** Internal function to move a collapsed bubble to fullscreen task. */
+ private fun collapsedBubbleToFullscreenInternal(
+ bubble: Bubble,
+ task: ActivityManager.RunningTaskInfo,
+ ) {
+ ProtoLog.d(
+ WM_SHELL_BUBBLES_NOISY,
+ "BubbleTaskStackListener.collapsedBubbleToFullscreenInternal(): taskId=%d",
+ task.taskId)
+ val taskViewTaskController: TaskViewTaskController = bubble.taskView.controller
+ val taskOrganizer: ShellTaskOrganizer = taskViewTaskController.taskOrganizer
+
+ val wct = getExitBubbleTransaction(task.token, bubble.taskView.captionInsetsOwner)
+ taskOrganizer.applyTransaction(wct)
+
+ taskViewTaskController.notifyTaskRemovalStarted(task)
+ }
+}
diff --git a/wmshell/src/com/android/wm/shell/bubbles/BubbleTaskUnfoldTransitionMerger.kt b/wmshell/src/com/android/wm/shell/bubbles/BubbleTaskUnfoldTransitionMerger.kt
new file mode 100644
index 0000000000..509cb55fbb
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/bubbles/BubbleTaskUnfoldTransitionMerger.kt
@@ -0,0 +1,34 @@
+/*
+ * 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.bubbles
+
+import android.app.ActivityManager
+import android.view.SurfaceControl
+import android.window.TransitionInfo
+
+/** Merges a bubble task transition with the unfold transition. */
+interface BubbleTaskUnfoldTransitionMerger {
+
+ /** Attempts to merge the transition. Returns `true` if the change was merged. */
+ fun mergeTaskWithUnfold(
+ taskInfo: ActivityManager.RunningTaskInfo,
+ info: TransitionInfo,
+ change: TransitionInfo.Change,
+ startT: SurfaceControl.Transaction,
+ finishT: SurfaceControl.Transaction,
+ ): Boolean
+}
diff --git a/wmshell/src/com/android/wm/shell/bubbles/BubbleTaskView.kt b/wmshell/src/com/android/wm/shell/bubbles/BubbleTaskView.kt
index 65f8e48eb8..0a37e9cf3d 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/BubbleTaskView.kt
+++ b/wmshell/src/com/android/wm/shell/bubbles/BubbleTaskView.kt
@@ -16,14 +16,12 @@
package com.android.wm.shell.bubbles
-import android.app.ActivityTaskManager
+import android.app.ActivityManager.RunningTaskInfo
import android.app.ActivityTaskManager.INVALID_TASK_ID
import android.content.ComponentName
-import android.os.RemoteException
-import android.util.Log
import androidx.annotation.VisibleForTesting
+import com.android.wm.shell.bubbles.util.BubbleUtils.isBubbleToFullscreen
import com.android.wm.shell.taskview.TaskView
-import com.android.wm.shell.transition.Transitions.ENABLE_SHELL_TRANSITIONS
import java.util.concurrent.Executor
/**
@@ -45,6 +43,16 @@ class BubbleTaskView(val taskView: TaskView, executor: Executor) {
var componentName: ComponentName? = null
private set
+ /**
+ * Whether the task view is visible and has a surface. Note that this does not check the alpha
+ * value of the task view.
+ *
+ * When this is `true` it is safe to start showing the task view. Otherwise if this is `false`
+ * callers should wait for it to be visible which will be indicated either by a call to
+ * [TaskView.Listener.onTaskCreated] or [TaskView.Listener.onTaskVisibilityChanged]. */
+ var isVisible = false
+ private set
+
/** [TaskView.Listener] for users of this class. */
var delegateListener: TaskView.Listener? = null
@@ -55,6 +63,10 @@ class BubbleTaskView(val taskView: TaskView, executor: Executor) {
delegateListener?.onInitialized()
}
+ override fun onSurfaceAlreadyCreated() {
+ delegateListener?.onSurfaceAlreadyCreated()
+ }
+
override fun onReleased() {
delegateListener?.onReleased()
}
@@ -64,9 +76,12 @@ class BubbleTaskView(val taskView: TaskView, executor: Executor) {
this@BubbleTaskView.taskId = taskId
isCreated = true
componentName = name
+ // when the task is created it is visible
+ isVisible = true
}
override fun onTaskVisibilityChanged(taskId: Int, visible: Boolean) {
+ this@BubbleTaskView.isVisible = visible
delegateListener?.onTaskVisibilityChanged(taskId, visible)
}
@@ -74,6 +89,10 @@ class BubbleTaskView(val taskView: TaskView, executor: Executor) {
delegateListener?.onTaskRemovalStarted(taskId)
}
+ override fun onTaskInfoChanged(taskInfo: RunningTaskInfo?) {
+ delegateListener?.onTaskInfoChanged(taskInfo)
+ }
+
override fun onBackPressedOnTaskRoot(taskId: Int) {
delegateListener?.onBackPressedOnTaskRoot(taskId)
}
@@ -89,21 +108,10 @@ class BubbleTaskView(val taskView: TaskView, executor: Executor) {
* This should be called after all other cleanup animations have finished.
*/
fun cleanup() {
- if (taskId != INVALID_TASK_ID) {
- // Ensure the task is removed from WM
- if (ENABLE_SHELL_TRANSITIONS) {
- taskView.removeTask()
- } else {
- try {
- ActivityTaskManager.getService().removeTask(taskId)
- } catch (e: RemoteException) {
- Log.w(TAG, e.message ?: "")
- }
- }
+ if (isBubbleToFullscreen(taskView.taskInfo)) {
+ taskView.unregisterTask()
+ } else {
+ taskView.removeTask()
}
}
-
- private companion object {
- const val TAG = "BubbleTaskView"
- }
}
diff --git a/wmshell/src/com/android/wm/shell/bubbles/BubbleTaskViewListener.java b/wmshell/src/com/android/wm/shell/bubbles/BubbleTaskViewListener.java
new file mode 100644
index 0000000000..77d92918b5
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/bubbles/BubbleTaskViewListener.java
@@ -0,0 +1,332 @@
+/*
+ * 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.bubbles;
+
+import static android.app.ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOW_ALWAYS;
+import static android.app.ActivityTaskManager.INVALID_TASK_ID;
+import static android.content.Intent.FLAG_ACTIVITY_MULTIPLE_TASK;
+import static android.content.Intent.FLAG_ACTIVITY_NEW_DOCUMENT;
+
+import static com.android.wm.shell.bubbles.util.BubbleUtils.getEnterBubbleTransaction;
+import static com.android.wm.shell.bubbles.util.BubbleUtils.getExitBubbleTransaction;
+import static com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_BUBBLES;
+
+import android.app.ActivityManager;
+import android.app.ActivityOptions;
+import android.app.ActivityTaskManager;
+import android.app.PendingIntent;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Rect;
+import android.util.Log;
+import android.view.View;
+import android.view.ViewGroup;
+import android.window.WindowContainerToken;
+import android.window.WindowContainerTransaction;
+
+import androidx.annotation.Nullable;
+
+import com.android.internal.protolog.ProtoLog;
+import com.android.wm.shell.shared.bubbles.BubbleAnythingFlagHelper;
+import com.android.wm.shell.taskview.TaskView;
+import com.android.wm.shell.taskview.TaskViewTaskController;
+
+/**
+ * A listener that works with task views for bubbles, manages launching the appropriate
+ * content into the task view from the bubble and sends updates of task view events back to
+ * the parent view via {@link BubbleTaskViewListener.Callback}.
+ */
+public class BubbleTaskViewListener implements TaskView.Listener {
+ private static final String TAG = BubbleTaskViewListener.class.getSimpleName();
+
+ /**
+ * Callback to let the view parent of TaskView to be notified of different events.
+ */
+ public interface Callback {
+
+ /** Called when the task is first created. */
+ void onTaskCreated();
+
+ /** Called when the visibility of the task changes. */
+ void onContentVisibilityChanged(boolean visible);
+
+ /** Called when back is pressed on the task root. */
+ void onBackPressed();
+
+ /** Called when task removal has started. */
+ void onTaskRemovalStarted();
+
+ /** Called when the task's info has changed. */
+ void onTaskInfoChanged(ActivityManager.RunningTaskInfo taskInfo);
+ }
+
+ private final Context mContext;
+ private final BubbleExpandedViewManager mExpandedViewManager;
+ private final BubbleTaskViewListener.Callback mCallback;
+ private final View mParentView;
+
+ private Bubble mBubble;
+ @Nullable
+ private PendingIntent mPendingIntent;
+ private int mTaskId = INVALID_TASK_ID;
+ private TaskView mTaskView;
+
+ private boolean mInitialized = false;
+ private boolean mDestroyed = false;
+
+ public BubbleTaskViewListener(Context context, BubbleTaskView bubbleTaskView, View parentView,
+ BubbleExpandedViewManager manager, BubbleTaskViewListener.Callback callback) {
+ mContext = context;
+ mTaskView = bubbleTaskView.getTaskView();
+ mParentView = parentView;
+ mExpandedViewManager = manager;
+ mCallback = callback;
+ bubbleTaskView.setDelegateListener(this);
+ if (bubbleTaskView.isCreated()) {
+ mTaskId = bubbleTaskView.getTaskId();
+ callback.onTaskCreated();
+ }
+ }
+
+ @Override
+ public void onInitialized() {
+ ProtoLog.d(WM_SHELL_BUBBLES, "onInitialized: destroyed=%b initialized=%b bubble=%s",
+ mDestroyed, mInitialized, getBubbleKey());
+
+ if (mDestroyed || mInitialized) {
+ return;
+ }
+
+ // Custom options so there is no activity transition animation
+ ActivityOptions options = ActivityOptions.makeCustomAnimation(mContext,
+ 0 /* enterResId */, 0 /* exitResId */);
+
+ Rect launchBounds = new Rect();
+ mTaskView.getBoundsOnScreen(launchBounds);
+
+ // TODO: I notice inconsistencies in lifecycle
+ // Post to keep the lifecycle normal
+ // TODO - currently based on type, really it's what the "launch item" is.
+ mParentView.post(() -> {
+ ProtoLog.d(WM_SHELL_BUBBLES,
+ "onInitialized: calling startActivity, bubble=%s hasPreparingTransition=%b",
+ getBubbleKey(), mBubble.getPreparingTransition() != null);
+ try {
+ options.setTaskAlwaysOnTop(true /* alwaysOnTop */);
+ options.setPendingIntentBackgroundActivityStartMode(
+ MODE_BACKGROUND_ACTIVITY_START_ALLOW_ALWAYS);
+ final boolean isShortcutBubble = (mBubble.hasMetadataShortcutId()
+ || (mBubble.isShortcut()
+ && BubbleAnythingFlagHelper.enableCreateAnyBubble()));
+ if (mBubble.getPreparingTransition() != null) {
+ mBubble.getPreparingTransition().surfaceCreated();
+ } else if (mBubble.isApp() || mBubble.isNote()) {
+ Context context =
+ mContext.createContextAsUser(
+ mBubble.getUser(), Context.CONTEXT_RESTRICTED);
+ Intent fillInIntent = new Intent();
+ // First try get pending intent from the bubble
+ PendingIntent pi = mBubble.getPendingIntent();
+ if (pi == null) {
+ // If null - create new one based on the bubble intent
+ pi = PendingIntent.getActivity(
+ context,
+ /* requestCode= */ 0,
+ mBubble.getIntent(),
+ // Needs to be mutable for the fillInIntent
+ PendingIntent.FLAG_MUTABLE | PendingIntent.FLAG_UPDATE_CURRENT,
+ /* options= */ null);
+ }
+ final WindowContainerToken rootToken =
+ mExpandedViewManager.getAppBubbleRootTaskToken();
+ if (rootToken != null) {
+ options.setLaunchRootTask(rootToken);
+ } else {
+ options.setLaunchNextToBubble(true /* launchNextToBubble */);
+ }
+ mTaskView.startActivity(pi, fillInIntent, options, launchBounds);
+ } else if (isShortcutBubble) {
+ if (mBubble.isChat()) {
+ options.setLaunchedFromBubble(true);
+ options.setApplyActivityFlagsForBubbles(true);
+ } else {
+ final WindowContainerToken rootToken =
+ mExpandedViewManager.getAppBubbleRootTaskToken();
+ if (rootToken != null) {
+ options.setLaunchRootTask(rootToken);
+ } else {
+ options.setLaunchNextToBubble(true /* launchNextToBubble */);
+ }
+ options.setApplyMultipleTaskFlagForShortcut(true);
+ }
+ mTaskView.startShortcutActivity(mBubble.getShortcutInfo(),
+ options, launchBounds);
+ } else {
+ options.setLaunchedFromBubble(true);
+ if (mBubble != null) {
+ mBubble.setPendingIntentActive();
+ }
+ final Intent fillInIntent = new Intent();
+ // Apply flags to make behaviour match documentLaunchMode=always.
+ fillInIntent.addFlags(FLAG_ACTIVITY_NEW_DOCUMENT);
+ fillInIntent.addFlags(FLAG_ACTIVITY_MULTIPLE_TASK);
+ mTaskView.startActivity(mPendingIntent, fillInIntent, options,
+ launchBounds);
+ }
+ } catch (RuntimeException e) {
+ // If there's a runtime exception here then there's something
+ // wrong with the intent, we can't really recover / try to populate
+ // the bubble again so we'll just remove it.
+ Log.e(TAG, "Exception while displaying bubble: " + getBubbleKey()
+ + "; removing bubble", e);
+ mExpandedViewManager.removeBubble(
+ getBubbleKey(), Bubbles.DISMISS_INVALID_INTENT);
+ }
+ mInitialized = true;
+ });
+ }
+
+ @Override
+ public void onSurfaceAlreadyCreated() {
+ ProtoLog.d(WM_SHELL_BUBBLES, "onSurfaceCreated: bubble=%s", getBubbleKey());
+ if (mBubble.getPreparingTransition() != null) {
+ mBubble.getPreparingTransition().surfaceCreated();
+ }
+ }
+
+ @Override
+ public void onReleased() {
+ mDestroyed = true;
+ }
+
+ @Override
+ public void onTaskCreated(int taskId, ComponentName name) {
+ ProtoLog.d(WM_SHELL_BUBBLES, "onTaskCreated: taskId=%d bubble=%s",
+ taskId, getBubbleKey());
+ // The taskId is saved to use for removeTask, preventing appearance in recent tasks.
+ mTaskId = taskId;
+
+ if (mBubble != null && mBubble.isNote()) {
+ // Let the controller know sooner what the taskId is.
+ mExpandedViewManager.setNoteBubbleTaskId(mBubble.getKey(), mTaskId);
+ }
+
+ final TaskViewTaskController tvc = mTaskView.getController();
+ final boolean isAppBubble = mBubble != null && (mBubble.isApp() || mBubble.isShortcut());
+ final WindowContainerTransaction wct = getEnterBubbleTransaction(
+ tvc.getTaskToken(), isAppBubble);
+ tvc.getTaskOrganizer().applyTransaction(wct);
+
+ // With the task org, the taskAppeared callback will only happen once the task has
+ // already drawn
+ mCallback.onTaskCreated();
+ }
+
+ @Override
+ public void onTaskVisibilityChanged(int taskId, boolean visible) {
+ mCallback.onContentVisibilityChanged(visible);
+ }
+
+ @Override
+ public void onTaskRemovalStarted(int taskId) {
+ ProtoLog.d(WM_SHELL_BUBBLES, "onTaskRemovalStarted: taskId=%d bubble=%s",
+ taskId, getBubbleKey());
+ if (mBubble != null) {
+ mExpandedViewManager.removeBubble(mBubble.getKey(), Bubbles.DISMISS_TASK_FINISHED);
+ }
+ if (mTaskView != null) {
+ final TaskViewTaskController tvc = mTaskView.getController();
+ final ActivityManager.RunningTaskInfo taskInfo = tvc.getTaskInfo();
+ if (taskInfo != null && taskInfo.isRunning
+ && mExpandedViewManager.shouldBeAppBubble(taskInfo)) {
+ final WindowContainerTransaction wct = getExitBubbleTransaction(taskInfo.token,
+ mTaskView.getCaptionInsetsOwner());
+ tvc.getTaskOrganizer().applyTransaction(wct);
+ }
+ mTaskView.release();
+ ((ViewGroup) mParentView).removeView(mTaskView);
+ mTaskView = null;
+ }
+ mCallback.onTaskRemovalStarted();
+ }
+
+ @Override
+ public void onTaskInfoChanged(ActivityManager.RunningTaskInfo taskInfo) {
+ if (BubbleAnythingFlagHelper.enableCreateAnyBubble()) {
+ mCallback.onTaskInfoChanged(taskInfo);
+ }
+ }
+
+ @Override
+ public void onBackPressedOnTaskRoot(int taskId) {
+ if (mTaskId == taskId && mExpandedViewManager.isStackExpanded()) {
+ mCallback.onBackPressed();
+ }
+ }
+
+ /**
+ * Sets the bubble or updates the bubble used to populate the view.
+ *
+ * @return true if the bubble is new or if the launch content of the bubble changed from the
+ * previous bubble.
+ */
+ public boolean setBubble(Bubble bubble) {
+ boolean isNew = mBubble == null || didBackingContentChange(bubble);
+ mBubble = bubble;
+ if (isNew) {
+ mPendingIntent = mBubble.getPendingIntent();
+ }
+ return isNew;
+ }
+
+ /** Returns the TaskView associated with this view. */
+ @Nullable
+ public TaskView getTaskView() {
+ return mTaskView;
+ }
+
+ /**
+ * Returns the task id associated with the task in this view. If the task doesn't exist then
+ * {@link ActivityTaskManager#INVALID_TASK_ID}.
+ */
+ public int getTaskId() {
+ return mTaskId;
+ }
+
+ private String getBubbleKey() {
+ return mBubble != null ? mBubble.getKey() : "";
+ }
+
+ // TODO (b/274980695): Is this still relevant?
+ /**
+ * Bubbles are backed by a pending intent or a shortcut, once the activity is
+ * started we never change it / restart it on notification updates -- unless the bubble's
+ * backing data switches.
+ *
+ * This indicates if the new bubble is backed by a different data source than what was
+ * previously shown here (e.g. previously a pending intent & now a shortcut).
+ *
+ * @param newBubble the bubble this view is being updated with.
+ * @return true if the backing content has changed.
+ */
+ private boolean didBackingContentChange(Bubble newBubble) {
+ boolean prevWasIntentBased = mBubble != null && mPendingIntent != null;
+ boolean newIsIntentBased = newBubble.getPendingIntent() != null;
+ return prevWasIntentBased != newIsIntentBased;
+ }
+}
diff --git a/wmshell/src/com/android/wm/shell/bubbles/BubbleTransitions.java b/wmshell/src/com/android/wm/shell/bubbles/BubbleTransitions.java
new file mode 100644
index 0000000000..8371eff1da
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/bubbles/BubbleTransitions.java
@@ -0,0 +1,1821 @@
+/*
+ * 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.bubbles;
+
+import static android.app.ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOW_ALWAYS;
+import static android.app.ActivityTaskManager.INVALID_TASK_ID;
+import static android.app.PendingIntent.FLAG_IMMUTABLE;
+import static android.app.PendingIntent.FLAG_ONE_SHOT;
+import static android.app.WindowConfiguration.WINDOWING_MODE_MULTI_WINDOW;
+import static android.view.View.INVISIBLE;
+import static android.view.WindowManager.TRANSIT_CHANGE;
+import static android.view.WindowManager.TRANSIT_OPEN;
+import static android.view.WindowManager.TRANSIT_TO_FRONT;
+
+import static com.android.wm.shell.bubbles.util.BubbleUtils.getEnterBubbleTransaction;
+import static com.android.wm.shell.bubbles.util.BubbleUtils.getExitBubbleTransaction;
+import static com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_BUBBLES;
+import static com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_BUBBLES_NOISY;
+import static com.android.wm.shell.shared.TransitionUtil.isOpeningMode;
+import static com.android.wm.shell.transition.Transitions.TRANSIT_BUBBLE_CONVERT_FLOATING_TO_BAR;
+import static com.android.wm.shell.transition.Transitions.TRANSIT_CONVERT_TO_BUBBLE;
+
+import android.app.ActivityManager;
+import android.app.ActivityOptions;
+import android.app.PendingIntent;
+import android.app.TaskInfo;
+import android.content.Context;
+import android.content.pm.LauncherApps;
+import android.graphics.Point;
+import android.graphics.PointF;
+import android.graphics.Rect;
+import android.os.Binder;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.util.Log;
+import android.util.Slog;
+import android.view.SurfaceControl;
+import android.view.SurfaceView;
+import android.view.View;
+import android.window.TransitionInfo;
+import android.window.TransitionRequestInfo;
+import android.window.WindowContainerToken;
+import android.window.WindowContainerTransaction;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.animation.Animator;
+import androidx.core.animation.Animator.AnimatorUpdateListener;
+import androidx.core.animation.AnimatorListenerAdapter;
+import androidx.core.animation.ValueAnimator;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.protolog.ProtoLog;
+import com.android.launcher3.icons.BubbleIconFactory;
+import com.android.wm.shell.ShellTaskOrganizer;
+import com.android.wm.shell.bubbles.appinfo.BubbleAppInfoProvider;
+import com.android.wm.shell.bubbles.bar.BubbleBarExpandedView;
+import com.android.wm.shell.bubbles.bar.BubbleBarLayerView;
+import com.android.wm.shell.common.HomeIntentProvider;
+import com.android.wm.shell.shared.bubbles.BubbleAnythingFlagHelper;
+import com.android.wm.shell.shared.bubbles.BubbleBarLocation;
+import com.android.wm.shell.taskview.TaskView;
+import com.android.wm.shell.taskview.TaskViewRepository;
+import com.android.wm.shell.taskview.TaskViewTaskController;
+import com.android.wm.shell.taskview.TaskViewTransitions;
+import com.android.wm.shell.transition.Transitions;
+import com.android.wm.shell.transition.Transitions.TransitionHandler;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.Executor;
+import java.util.function.Consumer;
+
+/**
+ * Implements transition coordination for bubble operations.
+ */
+public class BubbleTransitions {
+ private static final String TAG = "BubbleTransitions";
+
+ /**
+ * Multiplier used to convert a view elevation to an "equivalent" shadow-radius. This is the
+ * same multiple used by skia and surface-outsets in WMS.
+ */
+ private static final float ELEVATION_TO_RADIUS = 2;
+
+ @NonNull final Transitions mTransitions;
+ @NonNull final ShellTaskOrganizer mTaskOrganizer;
+ @NonNull final TaskViewRepository mRepository;
+ @NonNull final Executor mMainExecutor;
+ @NonNull final BubbleData mBubbleData;
+ @NonNull final TaskViewTransitions mTaskViewTransitions;
+ @NonNull final Context mContext;
+ @NonNull final BubbleAppInfoProvider mAppInfoProvider;
+
+ @VisibleForTesting
+ // Map of a launch cookie (used to start an activity) to the associated transition handler
+ final Map mPendingEnterTransitions =
+ new HashMap<>();
+
+ @VisibleForTesting
+ // Map of a running transition token to the associated transition handler
+ final Map mEnterTransitions =
+ new HashMap<>();
+
+ private BubbleController mBubbleController;
+
+ public BubbleTransitions(Context context,
+ @NonNull Transitions transitions, @NonNull ShellTaskOrganizer organizer,
+ @NonNull TaskViewRepository repository, @NonNull BubbleData bubbleData,
+ @NonNull TaskViewTransitions taskViewTransitions,
+ @NonNull BubbleAppInfoProvider appInfoProvider) {
+ mTransitions = transitions;
+ mTaskOrganizer = organizer;
+ mRepository = repository;
+ mMainExecutor = transitions.getMainExecutor();
+ mBubbleData = bubbleData;
+ mTaskViewTransitions = taskViewTransitions;
+ mContext = context;
+ mAppInfoProvider = appInfoProvider;
+ }
+
+ void setBubbleController(BubbleController controller) {
+ mBubbleController = controller;
+ }
+
+ /**
+ * Returns whether the given Task should be an App Bubble.
+ */
+ public boolean shouldBeAppBubble(@NonNull ActivityManager.RunningTaskInfo taskInfo) {
+ return mBubbleController.shouldBeAppBubble(taskInfo);
+ }
+
+ /**
+ * Returns whether bubbles are showing as the bubble bar.
+ */
+ public boolean isShowingAsBubbleBar() {
+ return mBubbleController.isShowingAsBubbleBar();
+ }
+
+ /**
+ * Returns whether there is an existing bubble with the given task id.
+ */
+ public boolean hasBubbleWithTaskId(int taskId) {
+ return mBubbleData.getBubbleInStackWithTaskId(taskId) != null;
+ }
+
+ /**
+ * Returns whether there is a pending transition for the given request.
+ */
+ public boolean hasPendingEnterTransition(@NonNull TransitionRequestInfo info) {
+ if (info.getTriggerTask() == null) {
+ return false;
+ }
+ for (IBinder cookie : info.getTriggerTask().launchCookies) {
+ if (mPendingEnterTransitions.containsKey(cookie)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * This is called to "convert" a pending enter transition into an active/running transition.
+ * It is also only called after we've confirmed that this is a valid transition into a bubble,
+ * ie. `hasPendingEnterTransition()` has been called.
+ */
+ @NonNull
+ public TransitionHandler storePendingEnterTransition(IBinder transition,
+ TransitionRequestInfo info) throws IllegalStateException {
+ for (IBinder cookie : info.getTriggerTask().launchCookies) {
+ final TransitionHandler handler = mPendingEnterTransitions.remove(cookie);
+ if (handler != null) {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "Transferring pending to playing transition for"
+ + "cookie=%s", cookie);
+ mPendingEnterTransitions.remove(cookie);
+ mEnterTransitions.put(transition, handler);
+ return handler;
+ }
+ }
+ throw new IllegalStateException("Expected pending enter transition for the given request");
+ }
+
+ /**
+ * Returns the transition handler for the given `transition`, only non-null if called after
+ * `storePendingEnterTransition()` (which may not be the case if the transition is consumed).
+ */
+ @Nullable
+ public TransitionHandler getRunningEnterTransition(@NonNull IBinder transition)
+ throws IllegalStateException {
+ if (mEnterTransitions.containsKey(transition)) {
+ return mEnterTransitions.get(transition);
+ }
+ return null;
+ }
+
+ /** Notifies when the unfold transition is starting. */
+ public void notifyUnfoldTransitionStarting(@NonNull IBinder transition) {
+ // this is used to block task view transitions from coming in while the unfold transition is
+ // playing, and allows us to create a specific transition and merge it into unfold. for now
+ // we only do this when switching from floating bubbles to bar bubbles so guard this with
+ // the bubble bar flag, but once these are combined we should be able to remove this.
+ if (com.android.wm.shell.Flags.enableBubbleBar()
+ && mBubbleData.getSelectedBubble() instanceof Bubble) {
+ Bubble bubble = (Bubble) mBubbleData.getSelectedBubble();
+ mTaskViewTransitions.enqueueExternal(
+ bubble.getTaskView().getController(), () -> transition);
+ }
+ }
+
+ /** Notifies when the unfold transition has finished. */
+ public void notifyUnfoldTransitionFinished(@NonNull IBinder transition) {
+ if (com.android.wm.shell.Flags.enableBubbleBar()) {
+ mTaskViewTransitions.onExternalDone(transition);
+ }
+ }
+
+ /**
+ * Starts a new launch or convert transition to show the given bubble.
+ */
+ public BubbleTransition startLaunchIntoOrConvertToBubble(Bubble bubble,
+ BubbleExpandedViewManager expandedViewManager, BubbleTaskViewFactory factory,
+ BubblePositioner positioner, BubbleStackView stackView,
+ BubbleBarLayerView layerView, BubbleIconFactory iconFactory,
+ boolean inflateSync, @Nullable BubbleBarLocation bubbleBarLocation) {
+ return new LaunchOrConvertToBubble(bubble, mContext, expandedViewManager, factory,
+ positioner, stackView, layerView, iconFactory, inflateSync, bubbleBarLocation);
+ }
+
+ /**
+ * Initiates axed bubble-to-bubble launch/existing bubble convert for the given transition.
+ *
+ * @return whether a new transition was started for the launch
+ */
+ public boolean startBubbleToBubbleLaunchOrExistingBubbleConvert(@NonNull IBinder transition,
+ @NonNull ActivityManager.RunningTaskInfo launchingTask,
+ @NonNull Consumer onInflatedCallback) {
+ TransitionHandler handler =
+ mBubbleController.expandStackAndSelectBubbleForExistingTransition(
+ launchingTask, transition, onInflatedCallback);
+ if (handler != null) {
+ mEnterTransitions.put(transition, handler);
+ }
+ return handler != null;
+ }
+
+ /**
+ * Starts a new launch or convert transition to show the given bubble.
+ */
+ public TransitionHandler startLaunchNewTaskBubbleForExistingTransition(Bubble bubble,
+ BubbleExpandedViewManager expandedViewManager, BubbleTaskViewFactory factory,
+ BubblePositioner positioner, BubbleStackView stackView,
+ BubbleBarLayerView layerView, BubbleIconFactory iconFactory,
+ boolean inflateSync, IBinder transition,
+ Consumer onInflatedCallback) {
+ return new LaunchNewTaskBubbleForExistingTransition(bubble, mContext, expandedViewManager,
+ factory, positioner, stackView, layerView, iconFactory, inflateSync, transition,
+ onInflatedCallback);
+ }
+
+ /**
+ * Starts a convert-to-bubble transition.
+ *
+ * @see ConvertToBubble
+ */
+ public BubbleTransition startConvertToBubble(Bubble bubble, TaskInfo taskInfo,
+ BubbleExpandedViewManager expandedViewManager, BubbleTaskViewFactory factory,
+ BubblePositioner positioner, BubbleStackView stackView, BubbleBarLayerView layerView,
+ BubbleIconFactory iconFactory, HomeIntentProvider homeIntentProvider, DragData dragData,
+ boolean inflateSync) {
+ return new ConvertToBubble(bubble, taskInfo, mContext, expandedViewManager, factory,
+ positioner, stackView, layerView, iconFactory, homeIntentProvider, dragData,
+ inflateSync);
+ }
+
+ /**
+ * Starts a convert-from-bubble transition.
+ *
+ * @see ConvertFromBubble
+ */
+ public BubbleTransition startConvertFromBubble(Bubble bubble,
+ TaskInfo taskInfo) {
+ return new ConvertFromBubble(bubble, taskInfo);
+ }
+
+ /** Starts a transition that converts a floating expanded bubble to a bar bubble. */
+ public BubbleTransition startFloatingToBarConversion(Bubble bubble,
+ BubblePositioner positioner) {
+ return new FloatingToBarConversion(bubble, positioner);
+ }
+
+ /** Starts a transition that converts a dragged bubble icon to a full screen task. */
+ public BubbleTransition startDraggedBubbleIconToFullscreen(Bubble bubble, Point dropLocation) {
+ return new DraggedBubbleIconToFullscreen(bubble, dropLocation);
+ }
+
+ /**
+ * Plucks the task-surface out of an ancestor view while making the view invisible. This helper
+ * attempts to do this seamlessly (ie. view becomes invisible in sync with task reparent).
+ */
+ private void pluck(SurfaceControl taskLeash, View fromView, SurfaceControl dest,
+ float destX, float destY, float cornerRadius, SurfaceControl.Transaction t,
+ Runnable onPlucked) {
+ SurfaceControl.Transaction pluckT = new SurfaceControl.Transaction();
+ pluckT.reparent(taskLeash, dest);
+ t.reparent(taskLeash, dest);
+ pluckT.setPosition(taskLeash, destX, destY);
+ t.setPosition(taskLeash, destX, destY);
+ pluckT.show(taskLeash);
+ pluckT.setAlpha(taskLeash, 1.f);
+ float shadowRadius = fromView.getElevation() * ELEVATION_TO_RADIUS;
+ pluckT.setShadowRadius(taskLeash, shadowRadius);
+ pluckT.setCornerRadius(taskLeash, cornerRadius);
+ t.setShadowRadius(taskLeash, shadowRadius);
+ t.setCornerRadius(taskLeash, cornerRadius);
+
+ // Need to remove the taskview AFTER applying the startTransaction because it isn't
+ // synchronized.
+ pluckT.addTransactionCommittedListener(mMainExecutor, onPlucked::run);
+ fromView.getViewRootImpl().applyTransactionOnDraw(pluckT);
+ fromView.setVisibility(INVISIBLE);
+ }
+
+ /**
+ * Interface to a bubble-specific transition. Bubble transitions have a multi-step lifecycle
+ * in order to coordinate with the bubble view logic. These steps are communicated on this
+ * interface.
+ */
+ public interface BubbleTransition {
+ default void surfaceCreated() {}
+ default void continueExpand() {}
+ default void skip() {}
+ default void continueCollapse() {}
+ /** Continues the conversion transition. */
+ default void continueConvert(BubbleBarLayerView layerView) {}
+ /** Merge this transition with the unfold transition. */
+ default void mergeWithUnfold(
+ SurfaceControl taskLeash, SurfaceControl.Transaction finishT) {}
+ /** Whether this transition is for converting a floating bubble to a bubble bar bubble. */
+ default boolean isConvertingBubbleToBar() {
+ return (this instanceof FloatingToBarConversion);
+ }
+ }
+
+ /**
+ * Information about the task when it is being dragged to a bubble.
+ */
+ public static class DragData {
+ private final boolean mReleasedOnLeft;
+ private final float mTaskScale;
+ private final float mCornerRadius;
+ private final PointF mDragPosition;
+
+ /**
+ * @param releasedOnLeft true if the bubble was released in the left drop target
+ * @param taskScale the scale of the task when it was dragged to bubble
+ * @param cornerRadius the corner radius of the task when it was dragged to bubble
+ * @param dragPosition the position of the task when it was dragged to bubble
+ */
+ public DragData(boolean releasedOnLeft, float taskScale, float cornerRadius,
+ @Nullable PointF dragPosition) {
+ mReleasedOnLeft = releasedOnLeft;
+ mTaskScale = taskScale;
+ mCornerRadius = cornerRadius;
+ mDragPosition = dragPosition != null ? dragPosition : new PointF(0, 0);
+ }
+
+ /**
+ * @return true if the bubble was released in the left drop target
+ */
+ public boolean isReleasedOnLeft() {
+ return mReleasedOnLeft;
+ }
+
+ /**
+ * @return the scale of the task when it was dragged to bubble
+ */
+ public float getTaskScale() {
+ return mTaskScale;
+ }
+
+ /**
+ * @return the corner radius of the task when it was dragged to bubble
+ */
+ public float getCornerRadius() {
+ return mCornerRadius;
+ }
+
+ /**
+ * @return position of the task when it was dragged to bubble
+ */
+ public PointF getDragPosition() {
+ return mDragPosition;
+ }
+ }
+
+ /**
+ * Keeps track of internal state of different steps of a BubbleTransition. Serves as a gating
+ * mechanism to block animations or updates until necessary states are set.
+ */
+ private static class TransitionProgress {
+
+ private final Bubble mBubble;
+ private boolean mTransitionReady;
+ private boolean mInflated;
+ private boolean mReadyToExpand;
+ private boolean mSurfaceReady;
+
+ TransitionProgress(Bubble bubble) {
+ mBubble = bubble;
+ }
+
+ void setInflated() {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "TransitionProgress.setInflated()");
+ mInflated = true;
+ onUpdate();
+ }
+
+ void setTransitionReady() {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "TransitionProgress.setTransitionReady()");
+ mTransitionReady = true;
+ onUpdate();
+ }
+
+ void setReadyToExpand() {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "TransitionProgress.setReadyToExpand()");
+ mReadyToExpand = true;
+ onUpdate();
+ }
+
+ void setSurfaceReady() {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "TransitionProgress.setSurfaceReady()");
+ mSurfaceReady = true;
+ onUpdate();
+ }
+
+ boolean isReadyToAnimate() {
+ // Animation only depends on transition and surface state
+ return mTransitionReady && mSurfaceReady && mInflated;
+ }
+
+ private void onUpdate() {
+ if (mTransitionReady && mReadyToExpand && mSurfaceReady && mInflated) {
+ // Clear the transition from bubble when all the steps are ready
+ mBubble.setPreparingTransition(null);
+ }
+ }
+ }
+
+ /**
+ * Starts a new bubble for an existing playing transition.
+ * TODO(b/408328557): To be consolidated with LaunchOrConvertToBubble and ConvertToBubble
+ */
+ @VisibleForTesting
+ class LaunchNewTaskBubbleForExistingTransition implements TransitionHandler, BubbleTransition {
+ final BubblePositioner mPositioner;
+ final BubbleExpandedViewTransitionAnimator mExpandedViewAnimator;
+ private final TransitionProgress mTransitionProgress;
+ Bubble mBubble;
+ IBinder mTransition;
+ Transitions.TransitionFinishCallback mFinishCb;
+ WindowContainerTransaction mFinishWct = null;
+ final Rect mStartBounds = new Rect();
+ SurfaceControl mSnapshot = null;
+ // The task info is resolved once we find the task from the transition info using the
+ // pending launch cookie otherwise
+ @Nullable
+ TaskInfo mTaskInfo;
+ BubbleViewProvider mPriorBubble = null;
+ // Whether we should play the convert-task animation, or the launch-task animation
+ private boolean mPlayConvertTaskAnimation;
+
+ private SurfaceControl.Transaction mFinishT;
+ private SurfaceControl mTaskLeash;
+
+ LaunchNewTaskBubbleForExistingTransition(Bubble bubble, Context context,
+ BubbleExpandedViewManager expandedViewManager, BubbleTaskViewFactory factory,
+ BubblePositioner positioner, BubbleStackView stackView,
+ BubbleBarLayerView layerView, BubbleIconFactory iconFactory,
+ boolean inflateSync, IBinder transition,
+ Consumer onInflatedCallback) {
+ if (layerView != null) {
+ mExpandedViewAnimator = layerView;
+ } else {
+ mExpandedViewAnimator = stackView;
+ }
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "LaunchNewTaskBubble(): expanded=%s",
+ mExpandedViewAnimator.isExpanded());
+ mBubble = bubble;
+ mTransition = transition;
+ mTransitionProgress = new TransitionProgress(bubble);
+ mPositioner = positioner;
+ mBubble.setInflateSynchronously(inflateSync);
+ mBubble.setPreparingTransition(this);
+ mBubble.inflate(
+ b -> {
+ onInflated(b);
+ onInflatedCallback.accept(LaunchNewTaskBubbleForExistingTransition.this);
+ },
+ context,
+ expandedViewManager,
+ factory,
+ positioner,
+ stackView,
+ layerView,
+ iconFactory,
+ mAppInfoProvider,
+ false /* skipInflation */);
+ }
+
+ @VisibleForTesting
+ void onInflated(Bubble b) {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "LaunchNewTaskBubble.onInflated()");
+ if (b != mBubble) {
+ throw new IllegalArgumentException("inflate callback doesn't match bubble");
+ }
+ if (!mBubble.isShortcut() && !mBubble.isApp()) {
+ throw new IllegalArgumentException("Unsupported bubble type");
+ }
+ final TaskView tv = b.getTaskView();
+ tv.setSurfaceLifecycle(SurfaceView.SURFACE_LIFECYCLE_FOLLOWS_ATTACHMENT);
+ final TaskViewRepository.TaskViewState state = mRepository.byTaskView(
+ tv.getController());
+ if (state != null) {
+ state.mVisible = true;
+ }
+ mTransitionProgress.setInflated();
+ // Remove any intermediate queued transitions that were started as a result of the
+ // inflation (the task view will be in the right bounds)
+ mTaskViewTransitions.removePendingTransitions(tv.getController());
+ mTaskViewTransitions.enqueueExternal(tv.getController(), () -> {
+ return mTransition;
+ });
+ }
+
+ @Override
+ public void skip() {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "LaunchNewTaskBubble.skip()");
+ mBubble.setPreparingTransition(null);
+ cleanup();
+ }
+
+ @Override
+ public WindowContainerTransaction handleRequest(@NonNull IBinder transition,
+ @Nullable TransitionRequestInfo request) {
+ return null;
+ }
+
+ @Override
+ public void mergeAnimation(@NonNull IBinder transition, @NonNull TransitionInfo info,
+ @NonNull SurfaceControl.Transaction startT,
+ @NonNull SurfaceControl.Transaction finishT,
+ @NonNull IBinder mergeTarget,
+ @NonNull Transitions.TransitionFinishCallback finishCallback) {
+ }
+
+ @Override
+ public void onTransitionConsumed(@NonNull IBinder transition, boolean aborted,
+ @NonNull SurfaceControl.Transaction finishTransaction) {
+ if (!aborted) return;
+ mTaskViewTransitions.onExternalDone(mTransition);
+ mTransition = null;
+ }
+
+ /**
+ * @return true As DefaultMixedTransition assumes that this transition will be handled by
+ * this handler in all cases.
+ */
+ @Override
+ public boolean startAnimation(@NonNull IBinder transition,
+ @NonNull TransitionInfo info,
+ @NonNull SurfaceControl.Transaction startTransaction,
+ @NonNull SurfaceControl.Transaction finishTransaction,
+ @NonNull Transitions.TransitionFinishCallback finishCallback) {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "LaunchNewTaskBubble.startAnimation()");
+
+ // Identify the task that we are converting or launching. Note, we iterate back to front
+ // so that we can adjust alpha for revealed surfaces as needed.
+ boolean found = false;
+ mPlayConvertTaskAnimation = false;
+ for (int i = info.getChanges().size() - 1; i >= 0; i--) {
+ final TransitionInfo.Change chg = info.getChanges().get(i);
+ final boolean isLaunchedTask = (chg.getTaskInfo() != null)
+ && (chg.getMode() == TRANSIT_CHANGE || isOpeningMode(chg.getMode()));
+ if (isLaunchedTask) {
+ mStartBounds.set(chg.getStartAbsBounds());
+ // Converting a task into taskview, so treat as "new"
+ mFinishWct = new WindowContainerTransaction();
+ mTaskInfo = chg.getTaskInfo();
+ mFinishT = finishTransaction;
+ mTaskLeash = chg.getLeash();
+ mSnapshot = chg.getSnapshot();
+ // TODO: This should be set for the CHANGE transition, but for some reason there
+ // is no snapshot, so fallback to the open transition for now
+ mPlayConvertTaskAnimation = false;
+ found = true;
+ } else {
+ // In core-initiated launches, the transition is of an OPEN type, and we need to
+ // manually show the surfaces behind the newly bubbled task
+ if (info.getType() == TRANSIT_OPEN && isOpeningMode(chg.getMode())) {
+ startTransaction.setAlpha(chg.getLeash(), 1f);
+ }
+ }
+ }
+ if (!found) {
+ Slog.w(TAG, "Expected a TaskView conversion in this transition but didn't get "
+ + "one, cleaning up the task view");
+ mBubble.getTaskView().getController().setTaskNotFound();
+ mTaskViewTransitions.onExternalDone(mTransition);
+ finishCallback.onTransitionFinished(null /* finishWct */);
+ return true;
+ }
+ mFinishCb = finishCallback;
+
+ // Now update state (and talk to launcher) in parallel with snapshot stuff
+ mBubbleData.notificationEntryUpdated(mBubble, /* suppressFlyout= */ true,
+ /* showInShade= */ false);
+
+ if (mPlayConvertTaskAnimation) {
+ final int left = mStartBounds.left - info.getRoot(0).getOffset().x;
+ final int top = mStartBounds.top - info.getRoot(0).getOffset().y;
+ startTransaction.setPosition(mTaskLeash, left, top);
+ startTransaction.show(mSnapshot);
+ // Move snapshot to root so that it remains visible while task is moved to taskview
+ startTransaction.reparent(mSnapshot, info.getRoot(0).getLeash());
+ startTransaction.setPosition(mSnapshot, left, top);
+ startTransaction.setLayer(mSnapshot, Integer.MAX_VALUE);
+ } else {
+ final int left = mStartBounds.left - info.getRoot(0).getOffset().x;
+ final int top = mStartBounds.top - info.getRoot(0).getOffset().y;
+ startTransaction.setPosition(mTaskLeash, left, top);
+ }
+ startTransaction.apply();
+
+ mTaskViewTransitions.onExternalDone(mTransition);
+ mTransitionProgress.setTransitionReady();
+ startExpandAnim();
+ return true;
+ }
+
+ private void startExpandAnim() {
+ if (mExpandedViewAnimator.canExpandView(mBubble)) {
+ mPriorBubble = mExpandedViewAnimator.prepareConvertedView(mBubble);
+ } else if (mExpandedViewAnimator.isExpanded()) {
+ mTransitionProgress.setReadyToExpand();
+ }
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "LaunchNewTaskBubble.startExpandAnim(): "
+ + "readyToAnimate=%b", mTransitionProgress.isReadyToAnimate());
+ if (mTransitionProgress.isReadyToAnimate()) {
+ playAnimation();
+ }
+ }
+
+ @Override
+ public void continueExpand() {
+ mTransitionProgress.setReadyToExpand();
+ }
+
+ @Override
+ public void surfaceCreated() {
+ mTransitionProgress.setSurfaceReady();
+ mMainExecutor.execute(() -> {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY,
+ "LaunchNewTaskBubble.surfaceCreated(): mTaskLeash=%s readyToAnimate=%b",
+ mTaskLeash, mTransitionProgress.isReadyToAnimate());
+ final TaskViewTaskController tvc = mBubble.getTaskView().getController();
+ final TaskViewRepository.TaskViewState state = mRepository.byTaskView(tvc);
+ if (state == null) return;
+ state.mVisible = true;
+ if (mTransitionProgress.isReadyToAnimate()) {
+ playAnimation();
+ }
+ });
+ }
+
+ private void playAnimation() {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY,
+ "LaunchNewTaskBubble.playAnimation(): playConvert=%b",
+ mPlayConvertTaskAnimation);
+ final TaskViewTaskController tv = mBubble.getTaskView().getController();
+ final SurfaceControl.Transaction startT = new SurfaceControl.Transaction();
+ // Set task position to 0,0 as it will be placed inside the TaskView
+ startT.setPosition(mTaskLeash, 0, 0)
+ .reparent(mTaskLeash, mBubble.getTaskView().getSurfaceControl())
+ .setAlpha(mTaskLeash, 1f)
+ .show(mTaskLeash);
+ mTaskViewTransitions.prepareOpenAnimation(tv, true /* new */, startT, mFinishT,
+ (ActivityManager.RunningTaskInfo) mTaskInfo, mTaskLeash, mFinishWct);
+ // Add the task view task listener manually since we aren't going through
+ // TaskViewTransitions (which normally sets up the listener via a pending launch cookie)
+ // Note: In this path, because a new task is being started, the transition may receive
+ // the transition for the task before the organizer does
+ mTaskOrganizer.addListenerForTaskId(tv, mTaskInfo.taskId);
+
+ if (mFinishWct.isEmpty()) {
+ mFinishWct = null;
+ }
+
+ float startScale = 1f;
+ if (mPlayConvertTaskAnimation) {
+ mExpandedViewAnimator.animateConvert(startT, mStartBounds, startScale, mSnapshot,
+ mTaskLeash,
+ this::cleanup);
+ } else {
+ startT.apply();
+ mExpandedViewAnimator.animateExpand(null, this::cleanup);
+ }
+ }
+
+ private void cleanup() {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "LaunchNewTaskBubble.cleanup()");
+ mFinishCb.onTransitionFinished(mFinishWct);
+ mFinishCb = null;
+ }
+ }
+
+ /**
+ * Starts a new transition into a bubble, which will either play a launch animation (if the task
+ * was not previously visible) or a convert animation (if the task is currently visible).
+ */
+ @VisibleForTesting
+ class LaunchOrConvertToBubble implements TransitionHandler, BubbleTransition {
+ final BubbleExpandedViewTransitionAnimator mExpandedViewAnimator;
+ final BubblePositioner mPositioner;
+ private final TransitionProgress mTransitionProgress;
+ Bubble mBubble;
+ IBinder mTransition;
+ IBinder mPlayingTransition;
+ Transitions.TransitionFinishCallback mFinishCb;
+ WindowContainerTransaction mFinishWct = null;
+ final Rect mStartBounds = new Rect();
+ SurfaceControl mSnapshot = null;
+ // The task info is resolved once we find the task from the transition info using the
+ // pending launch cookie otherwise
+ @Nullable
+ TaskInfo mTaskInfo;
+ @Nullable
+ ActivityOptions.LaunchCookie mLaunchCookie;
+ BubbleViewProvider mPriorBubble = null;
+ // Whether we should play the convert-task animation, or the launch-task animation
+ private boolean mPlayConvertTaskAnimation;
+
+ private SurfaceControl.Transaction mFinishT;
+ private SurfaceControl mTaskLeash;
+ @Nullable
+ private BubbleBarLocation mBubbleBarLocation;
+
+ LaunchOrConvertToBubble(Bubble bubble, Context context,
+ BubbleExpandedViewManager expandedViewManager, BubbleTaskViewFactory factory,
+ BubblePositioner positioner, BubbleStackView stackView,
+ BubbleBarLayerView layerView, BubbleIconFactory iconFactory,
+ boolean inflateSync, @Nullable BubbleBarLocation bubbleBarLocation) {
+ if (layerView != null) {
+ mExpandedViewAnimator = layerView;
+ } else {
+ mExpandedViewAnimator = stackView;
+ }
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "LaunchOrConvert(): expanded=%s",
+ mExpandedViewAnimator.isExpanded());
+ mBubble = bubble;
+ mTransitionProgress = new TransitionProgress(bubble);
+ mPositioner = positioner;
+ mBubble.setInflateSynchronously(inflateSync);
+ mBubble.setPreparingTransition(this);
+ mBubbleBarLocation = bubbleBarLocation;
+ mBubble.inflate(
+ this::onInflated,
+ context,
+ expandedViewManager,
+ factory,
+ positioner,
+ stackView,
+ layerView,
+ iconFactory,
+ mAppInfoProvider,
+ false /* skipInflation */);
+ }
+
+ @VisibleForTesting
+ void onInflated(Bubble b) {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "LaunchOrConvert.onInflated()");
+ if (b != mBubble) {
+ throw new IllegalArgumentException("inflate callback doesn't match bubble");
+ }
+ if (!mBubble.isShortcut() && !mBubble.isApp()) {
+ throw new IllegalArgumentException("Unsupported bubble type");
+ }
+ final Rect launchBounds = new Rect();
+ mPositioner.getTaskViewRestBounds(launchBounds);
+
+ final TaskView tv = b.getTaskView();
+ tv.setSurfaceLifecycle(SurfaceView.SURFACE_LIFECYCLE_FOLLOWS_ATTACHMENT);
+ final TaskViewRepository.TaskViewState state = mRepository.byTaskView(
+ tv.getController());
+ if (state != null) {
+ state.mVisible = true;
+ }
+ mTransitionProgress.setInflated();
+ mTaskViewTransitions.enqueueExternal(tv.getController(), () -> {
+ // We need to convert the next launch into a bubble
+ mLaunchCookie = new ActivityOptions.LaunchCookie();
+ mPendingEnterTransitions.put(mLaunchCookie.binder, this);
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "Starting activity with pending cookie=%s",
+ mLaunchCookie.binder);
+
+ final ActivityOptions opts = ActivityOptions.makeBasic();
+ opts.setLaunchCookie(mLaunchCookie);
+ opts.setTaskAlwaysOnTop(true);
+ opts.setReparentLeafTaskToTda(true);
+ final ActivityManager.RunningTaskInfo rootInfo =
+ mBubbleController.getAppBubbleRootTaskInfo();
+ if (rootInfo != null) {
+ opts.setLaunchRootTask(rootInfo.token);
+ } else {
+ opts.setLaunchNextToBubble(true);
+ }
+ opts.setLaunchWindowingMode(WINDOWING_MODE_MULTI_WINDOW);
+ opts.setLaunchBounds(launchBounds);
+ if (mBubble.isShortcut()) {
+ final LauncherApps launcherApps = mContext.getSystemService(
+ LauncherApps.class);
+ launcherApps.startShortcut(mBubble.getShortcutInfo(),
+ null /* sourceBounds */, opts.toBundle());
+ } else if (mBubble.isApp()) {
+ final ActivityOptions sendOpts = ActivityOptions.makeBasic();
+ sendOpts.setPendingIntentBackgroundActivityStartMode(
+ MODE_BACKGROUND_ACTIVITY_START_ALLOW_ALWAYS);
+ final Bundle sendOptsBundle = sendOpts.toBundle();
+ final PendingIntent intent;
+ if (mBubble.getPendingIntent() != null) {
+ intent = mBubble.getPendingIntent();
+ sendOptsBundle.putAll(opts.toBundle());
+ } else {
+ opts.setPendingIntentCreatorBackgroundActivityStartMode(
+ MODE_BACKGROUND_ACTIVITY_START_ALLOW_ALWAYS);
+ intent = PendingIntent.getActivityAsUser(mContext, 0,
+ mBubble.getIntent(), FLAG_IMMUTABLE | FLAG_ONE_SHOT,
+ opts.toBundle(), mBubble.getUser());
+ }
+ try {
+ intent.send(sendOptsBundle);
+ } catch (PendingIntent.CanceledException e) {
+ Log.w(TAG, "Failed to launch app bubble");
+ }
+ }
+
+ // Add the task view task listener manually since we aren't going through
+ // TaskViewTransitions (which normally sets up the listener via a pending launch cookie
+ mTaskOrganizer.setPendingLaunchCookieListener(mLaunchCookie.binder,
+ mBubble.getTaskView().getController());
+
+ // We use a stub transition here since we don't know what is incoming, but it
+ // won't actually match any transition when queried in TaskViewTransitions,
+ // which is Ok since we don't want TaskViewTransitions to handle this anyways.
+ // However, we do need to use it whenever calling onExternalDone() instead of
+ // the incoming transition.
+ ProtoLog.d(WM_SHELL_BUBBLES, "starting activity");
+ mTransition = new Binder();
+ return mTransition;
+ });
+ }
+
+ @Override
+ public void skip() {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "LaunchOrConvert.skip()");
+ mBubble.setPreparingTransition(null);
+ cleanup();
+ }
+
+ @Override
+ public WindowContainerTransaction handleRequest(@NonNull IBinder transition,
+ @Nullable TransitionRequestInfo request) {
+ return null;
+ }
+
+ @Override
+ public void mergeAnimation(@NonNull IBinder transition, @NonNull TransitionInfo info,
+ @NonNull SurfaceControl.Transaction startT,
+ @NonNull SurfaceControl.Transaction finishT,
+ @NonNull IBinder mergeTarget,
+ @NonNull Transitions.TransitionFinishCallback finishCallback) {
+ }
+
+ @Override
+ public void onTransitionConsumed(@NonNull IBinder transition, boolean aborted,
+ @NonNull SurfaceControl.Transaction finishTransaction) {
+ if (!aborted) return;
+ mTaskViewTransitions.onExternalDone(mTransition);
+ mTransition = null;
+ if (mLaunchCookie != null) {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "Removing pending transition for cookie=%s",
+ mLaunchCookie.binder);
+ mPendingEnterTransitions.remove(mLaunchCookie.binder);
+ }
+ mEnterTransitions.remove(transition);
+ }
+
+ /**
+ * @return true As DefaultMixedTransition assumes that this transition will be handled by
+ * this handler in all cases.
+ */
+ @Override
+ public boolean startAnimation(@NonNull IBinder transition,
+ @NonNull TransitionInfo info,
+ @NonNull SurfaceControl.Transaction startTransaction,
+ @NonNull SurfaceControl.Transaction finishTransaction,
+ @NonNull Transitions.TransitionFinishCallback finishCallback) {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "LaunchOrConvert.startAnimation()");
+ mPlayingTransition = transition;
+
+ // Identify the task that we are converting or launching. Note, we iterate back to front
+ // so that we can adjust alpha for revealed surfaces as needed.
+ boolean found = false;
+ mPlayConvertTaskAnimation = false;
+ for (int i = info.getChanges().size() - 1; i >= 0; i--) {
+ final TransitionInfo.Change chg = info.getChanges().get(i);
+ final boolean isLaunchedTask = (chg.getTaskInfo() != null)
+ && (chg.getMode() == TRANSIT_CHANGE || isOpeningMode(chg.getMode()))
+ && (chg.getTaskInfo().launchCookies.contains(mLaunchCookie.binder));
+ if (isLaunchedTask) {
+ mStartBounds.set(chg.getStartAbsBounds());
+ // Converting a task into taskview, so treat as "new"
+ mFinishWct = new WindowContainerTransaction();
+ mTaskInfo = chg.getTaskInfo();
+ mFinishT = finishTransaction;
+ mTaskLeash = chg.getLeash();
+ mSnapshot = chg.getSnapshot();
+ mPlayConvertTaskAnimation = !isOpeningMode(chg.getMode()) && mSnapshot != null;
+ found = true;
+ } else {
+ // In core-initiated launches, the transition is of an OPEN type, and we need to
+ // manually show the surfaces behind the newly bubbled task
+ if (info.getType() == TRANSIT_OPEN && isOpeningMode(chg.getMode())) {
+ startTransaction.setAlpha(chg.getLeash(), 1f);
+ }
+ }
+ }
+ if (!found) {
+ Slog.w(TAG, "Expected a TaskView conversion in this transition but didn't get "
+ + "one, cleaning up the task view");
+ mBubble.getTaskView().getController().setTaskNotFound();
+ mTaskViewTransitions.onExternalDone(mTransition);
+ finishCallback.onTransitionFinished(null /* finishWct */);
+ return true;
+ }
+ mFinishCb = finishCallback;
+
+ // Now update state (and talk to launcher) in parallel with snapshot stuff
+ mBubbleData.notificationEntryUpdated(mBubble, /* suppressFlyout= */ true,
+ /* showInShade= */ false, mBubbleBarLocation);
+
+ if (mPlayConvertTaskAnimation) {
+ final int left = mStartBounds.left - info.getRoot(0).getOffset().x;
+ final int top = mStartBounds.top - info.getRoot(0).getOffset().y;
+ startTransaction.setPosition(mTaskLeash, left, top);
+ startTransaction.show(mSnapshot);
+ // Move snapshot to root so that it remains visible while task is moved to taskview
+ startTransaction.reparent(mSnapshot, info.getRoot(0).getLeash());
+ startTransaction.setPosition(mSnapshot, left, top);
+ startTransaction.setLayer(mSnapshot, Integer.MAX_VALUE);
+ } else {
+ final int left = mStartBounds.left - info.getRoot(0).getOffset().x;
+ final int top = mStartBounds.top - info.getRoot(0).getOffset().y;
+ startTransaction.setPosition(mTaskLeash, left, top);
+ }
+ startTransaction.apply();
+
+ mTaskViewTransitions.onExternalDone(mTransition);
+ mTransitionProgress.setTransitionReady();
+ startExpandAnim();
+ return true;
+ }
+
+ private void startExpandAnim() {
+ final boolean animate = mExpandedViewAnimator.canExpandView(mBubble);
+ if (animate) {
+ mPriorBubble = mExpandedViewAnimator.prepareConvertedView(mBubble);
+ }
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "LaunchOrConvert.startExpandAnim(): "
+ + "readyToAnimate=%b", mTransitionProgress.isReadyToAnimate());
+ if (mPriorBubble != null) {
+ // TODO: an animation. For now though, just remove it.
+ final BubbleBarExpandedView priorView = mPriorBubble.getBubbleBarExpandedView();
+ mExpandedViewAnimator.removeViewFromTransition(priorView);
+ mPriorBubble = null;
+ }
+ if (!animate || mTransitionProgress.isReadyToAnimate()) {
+ playAnimation(animate);
+ }
+ }
+
+ @Override
+ public void continueExpand() {
+ mTransitionProgress.setReadyToExpand();
+ }
+
+ @Override
+ public void surfaceCreated() {
+ mTransitionProgress.setSurfaceReady();
+ mMainExecutor.execute(() -> {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY,
+ "LaunchOrConvert.surfaceCreated(): mTaskLeash=%s readyToAnimate=%b",
+ mTaskLeash, mTransitionProgress.isReadyToAnimate());
+ final TaskViewTaskController tvc = mBubble.getTaskView().getController();
+ final TaskViewRepository.TaskViewState state = mRepository.byTaskView(tvc);
+ if (state == null) return;
+ state.mVisible = true;
+ if (mTransitionProgress.isReadyToAnimate()) {
+ playAnimation(true /* animate */);
+ }
+ });
+ }
+
+ private void playAnimation(boolean animate) {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY,
+ "LaunchOrConvert.playAnimation(): playConvert=%b",
+ mPlayConvertTaskAnimation);
+ final TaskViewTaskController tv = mBubble.getTaskView().getController();
+ final SurfaceControl.Transaction startT = new SurfaceControl.Transaction();
+ // Set task position to 0,0 as it will be placed inside the TaskView
+ startT.setPosition(mTaskLeash, 0, 0);
+ if (!mPlayConvertTaskAnimation) {
+ startT.reparent(mTaskLeash, mBubble.getTaskView().getSurfaceControl())
+ .setAlpha(mTaskLeash, 1f)
+ .show(mTaskLeash);
+ }
+ mTaskViewTransitions.prepareOpenAnimation(tv, true /* new */, startT, mFinishT,
+ (ActivityManager.RunningTaskInfo) mTaskInfo, mTaskLeash, mFinishWct);
+
+ if (mFinishWct.isEmpty()) {
+ mFinishWct = null;
+ }
+
+ if (animate) {
+ float startScale = 1f;
+ if (mPlayConvertTaskAnimation) {
+ mExpandedViewAnimator.animateConvert(startT,
+ mStartBounds,
+ startScale,
+ mSnapshot,
+ mTaskLeash,
+ this::cleanup);
+ } else {
+ startT.apply();
+ mExpandedViewAnimator.animateExpand(null, this::cleanup);
+ }
+ } else {
+ startT.apply();
+ cleanup();
+ }
+ }
+
+ private void cleanup() {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "LaunchNewTaskBubble.cleanup(): removeCookie=%s",
+ mLaunchCookie.binder);
+ mFinishCb.onTransitionFinished(mFinishWct);
+ mFinishCb = null;
+ mPendingEnterTransitions.remove(mLaunchCookie.binder);
+ mEnterTransitions.remove(mPlayingTransition);
+ }
+ }
+
+ /**
+ * BubbleTransition that coordinates the process of a non-bubble task becoming a bubble. The
+ * steps are as follows:
+ *
+ * 1. Start inflating the bubble view
+ * 2. Once inflated (but not-yet visible), tell WM to do the shell-transition.
+ * 3. When the transition becomes ready, notify Launcher in parallel
+ * 4. Wait for surface to be created
+ * 5. Once surface is ready, animate the task to a bubble
+ *
+ * While the animation is pending, we keep a reference to the pending transition in the bubble.
+ * This allows us to check in other parts of the code that this bubble will be shown via the
+ * transition animation.
+ *
+ * startAnimation, continueExpand and surfaceCreated are set-up to happen in either order,
+ * to support UX/timing adjustments.
+ */
+ @VisibleForTesting
+ class ConvertToBubble implements Transitions.TransitionHandler, BubbleTransition {
+ final BubbleExpandedViewTransitionAnimator mExpandedViewAnimator;
+ final BubblePositioner mPositioner;
+ final HomeIntentProvider mHomeIntentProvider;
+ Bubble mBubble;
+ @Nullable
+ DragData mDragData;
+ IBinder mTransition;
+ Transitions.TransitionFinishCallback mFinishCb;
+ WindowContainerTransaction mFinishWct = null;
+ final Rect mStartBounds = new Rect();
+ SurfaceControl mSnapshot = null;
+ TaskInfo mTaskInfo;
+ BubbleViewProvider mPriorBubble = null;
+
+ private final TransitionProgress mTransitionProgress;
+ private SurfaceControl.Transaction mFinishT;
+ private SurfaceControl mTaskLeash;
+
+ ConvertToBubble(Bubble bubble, TaskInfo taskInfo, Context context,
+ BubbleExpandedViewManager expandedViewManager, BubbleTaskViewFactory factory,
+ BubblePositioner positioner, BubbleStackView stackView,
+ BubbleBarLayerView layerView, BubbleIconFactory iconFactory,
+ HomeIntentProvider homeIntentProvider, @Nullable DragData dragData,
+ boolean inflateSync) {
+ if (layerView != null) {
+ mExpandedViewAnimator = layerView;
+ } else {
+ mExpandedViewAnimator = stackView;
+ }
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "ConvertToBubble(): expanded=%s",
+ mExpandedViewAnimator.isExpanded());
+ mBubble = bubble;
+ mTransitionProgress = new TransitionProgress(bubble);
+ mTaskInfo = taskInfo;
+ mPositioner = positioner;
+ mHomeIntentProvider = homeIntentProvider;
+ mDragData = dragData;
+ mBubble.setInflateSynchronously(inflateSync);
+ mBubble.setPreparingTransition(this);
+ mBubble.inflate(
+ this::onInflated,
+ context,
+ expandedViewManager,
+ factory,
+ positioner,
+ stackView,
+ layerView,
+ iconFactory,
+ mAppInfoProvider,
+ false /* skipInflation */);
+ }
+
+ @VisibleForTesting
+ void onInflated(Bubble b) {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "ConvertToBubble.onInflated()");
+ if (b != mBubble) {
+ throw new IllegalArgumentException("inflate callback doesn't match bubble");
+ }
+ final Rect launchBounds = new Rect();
+ mPositioner.getTaskViewRestBounds(launchBounds);
+ final boolean reparentToTda =
+ mTaskInfo.getWindowingMode() == WINDOWING_MODE_MULTI_WINDOW
+ && mTaskInfo.getParentTaskId() != INVALID_TASK_ID;
+
+ final WindowContainerTransaction wct = getEnterBubbleTransaction(
+ mTaskInfo.token, true /* isAppBubble */, reparentToTda);
+ mHomeIntentProvider.addLaunchHomePendingIntent(wct, mTaskInfo.displayId,
+ mTaskInfo.userId);
+
+ wct.setBounds(mTaskInfo.token, launchBounds);
+
+ final TaskView tv = b.getTaskView();
+ tv.setSurfaceLifecycle(SurfaceView.SURFACE_LIFECYCLE_FOLLOWS_ATTACHMENT);
+ final TaskViewRepository.TaskViewState state = mRepository.byTaskView(
+ tv.getController());
+ if (state != null) {
+ state.mVisible = true;
+ }
+ mTransitionProgress.setInflated();
+ mTaskViewTransitions.enqueueExternal(tv.getController(), () -> {
+ mTransition = mTransitions.startTransition(TRANSIT_CONVERT_TO_BUBBLE, wct, this);
+ return mTransition;
+ });
+ }
+
+ @Override
+ public void skip() {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "ConvertToBubble.skip()");
+ mBubble.setPreparingTransition(null);
+ mFinishCb.onTransitionFinished(mFinishWct);
+ mFinishCb = null;
+ }
+
+ @Override
+ public WindowContainerTransaction handleRequest(@NonNull IBinder transition,
+ @Nullable TransitionRequestInfo request) {
+ return null;
+ }
+
+ @Override
+ public void mergeAnimation(@NonNull IBinder transition, @NonNull TransitionInfo info,
+ @NonNull SurfaceControl.Transaction startT,
+ @NonNull SurfaceControl.Transaction finishT,
+ @NonNull IBinder mergeTarget,
+ @NonNull Transitions.TransitionFinishCallback finishCallback) {
+ }
+
+ @Override
+ public void onTransitionConsumed(@NonNull IBinder transition, boolean aborted,
+ @NonNull SurfaceControl.Transaction finishTransaction) {
+ if (!aborted) return;
+ mTransition = null;
+ mTaskViewTransitions.onExternalDone(transition);
+ }
+
+ @Override
+ public boolean startAnimation(@NonNull IBinder transition,
+ @NonNull TransitionInfo info,
+ @NonNull SurfaceControl.Transaction startTransaction,
+ @NonNull SurfaceControl.Transaction finishTransaction,
+ @NonNull Transitions.TransitionFinishCallback finishCallback) {
+ if (mTransition != transition) return false;
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "ConvertToBubble.startAnimation()");
+ boolean found = false;
+ for (int i = 0; i < info.getChanges().size(); ++i) {
+ final TransitionInfo.Change chg = info.getChanges().get(i);
+ if (chg.getTaskInfo() == null) continue;
+ if (chg.getMode() != TRANSIT_CHANGE && chg.getMode() != TRANSIT_TO_FRONT) continue;
+ if (!mTaskInfo.token.equals(chg.getTaskInfo().token)) continue;
+ mStartBounds.set(chg.getStartAbsBounds());
+ // Converting a task into taskview, so treat as "new"
+ mFinishWct = new WindowContainerTransaction();
+ mTaskInfo = chg.getTaskInfo();
+ mFinishT = finishTransaction;
+ mTaskLeash = chg.getLeash();
+ found = true;
+ mSnapshot = chg.getSnapshot();
+ break;
+ }
+ if (!found) {
+ Slog.w(TAG, "Expected a TaskView conversion in this transition but didn't get "
+ + "one, cleaning up the task view");
+ mBubble.getTaskView().getController().setTaskNotFound();
+ mTaskViewTransitions.onExternalDone(transition);
+ return false;
+ }
+ mFinishCb = finishCallback;
+
+ if (mDragData != null) {
+ mStartBounds.offsetTo((int) mDragData.getDragPosition().x,
+ (int) mDragData.getDragPosition().y);
+ startTransaction.setScale(mSnapshot, mDragData.getTaskScale(),
+ mDragData.getTaskScale());
+ startTransaction.setCornerRadius(mSnapshot, mDragData.getCornerRadius());
+ }
+
+ // Now update state (and talk to launcher) in parallel with snapshot stuff
+ mBubbleData.notificationEntryUpdated(mBubble, /* suppressFlyout= */ true,
+ /* showInShade= */ false);
+
+ final int left = mStartBounds.left - info.getRoot(0).getOffset().x;
+ final int top = mStartBounds.top - info.getRoot(0).getOffset().y;
+ startTransaction.setPosition(mTaskLeash, left, top);
+ startTransaction.show(mSnapshot);
+ // Move snapshot to root so that it remains visible while task is moved to taskview
+ startTransaction.reparent(mSnapshot, info.getRoot(0).getLeash());
+ startTransaction.setPosition(mSnapshot, left, top);
+ startTransaction.setLayer(mSnapshot, Integer.MAX_VALUE);
+
+ startTransaction.apply();
+
+ mTaskViewTransitions.onExternalDone(transition);
+ mTransitionProgress.setTransitionReady();
+ startExpandAnim();
+ return true;
+ }
+
+ private void startExpandAnim() {
+ final boolean animate = mExpandedViewAnimator.canExpandView(mBubble);
+ if (animate) {
+ mPriorBubble = mExpandedViewAnimator.prepareConvertedView(mBubble);
+ }
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "ConvertToBubble.startExpandAnim(): "
+ + "readyToAnimate=%b", mTransitionProgress.isReadyToAnimate());
+ if (mPriorBubble != null) {
+ // TODO: an animation. For now though, just remove it.
+ final BubbleBarExpandedView priorView = mPriorBubble.getBubbleBarExpandedView();
+ mExpandedViewAnimator.removeViewFromTransition(priorView);
+ mPriorBubble = null;
+ }
+ if (!animate || mTransitionProgress.isReadyToAnimate()) {
+ playAnimation(animate);
+ }
+ }
+
+ @Override
+ public void continueExpand() {
+ mTransitionProgress.setReadyToExpand();
+ }
+
+ @Override
+ public void surfaceCreated() {
+ mTransitionProgress.setSurfaceReady();
+ mMainExecutor.execute(() -> {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY,
+ "ConvertToBubble.surfaceCreated(): mTaskLeash=%s readyToAnimate=%b",
+ mTaskLeash, mTransitionProgress.isReadyToAnimate());
+ final TaskViewTaskController tvc = mBubble.getTaskView().getController();
+ final TaskViewRepository.TaskViewState state = mRepository.byTaskView(tvc);
+ if (state == null) return;
+ state.mVisible = true;
+ if (mTransitionProgress.isReadyToAnimate()) {
+ playAnimation(true /* animate */);
+ }
+ });
+ }
+
+ private void playAnimation(boolean animate) {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "ConvertToBubble.playAnimation()");
+ final TaskViewTaskController tv = mBubble.getTaskView().getController();
+ final SurfaceControl.Transaction startT = new SurfaceControl.Transaction();
+ // Set task position to 0,0 as it will be placed inside the TaskView
+ startT.setPosition(mTaskLeash, 0, 0);
+ mTaskViewTransitions.prepareOpenAnimation(tv, true /* new */, startT, mFinishT,
+ (ActivityManager.RunningTaskInfo) mTaskInfo, mTaskLeash, mFinishWct);
+ // Add the task view task listener manually since we aren't going through
+ // TaskViewTransitions (which normally sets up the listener via a pending launch cookie)
+ mTaskOrganizer.addListenerForTaskId(tv, mTaskInfo.taskId);
+
+ if (mFinishWct.isEmpty()) {
+ mFinishWct = null;
+ }
+
+ if (animate) {
+ float startScale = mDragData != null ? mDragData.getTaskScale() : 1f;
+ mExpandedViewAnimator.animateConvert(startT,
+ mStartBounds,
+ startScale,
+ mSnapshot,
+ mTaskLeash,
+ () -> {
+ mFinishCb.onTransitionFinished(mFinishWct);
+ mFinishCb = null;
+ });
+ } else {
+ startT.apply();
+ mFinishCb.onTransitionFinished(mFinishWct);
+ mFinishCb = null;
+ }
+ }
+ }
+
+ /**
+ * BubbleTransition that coordinates the setup for moving a task out of a bubble. The actual
+ * animation is owned by the "receiver" of the task; however, because Bubbles uses TaskView,
+ * we need to do some extra coordination work to get the task surface out of the view
+ * "seamlessly".
+ *
+ * The process here looks like:
+ * 1. Send transition to WM for leaving bubbles mode
+ * 2. in startAnimation, set-up a "pluck" operation to pull the task surface out of taskview
+ * 3. Once "plucked", remove the view (calls continueCollapse when surfaces can be cleaned-up)
+ * 4. Then re-dispatch the transition animation so that the "receiver" can animate it.
+ *
+ * So, constructor -> startAnimation -> continueCollapse -> re-dispatch.
+ */
+ @VisibleForTesting
+ class ConvertFromBubble implements TransitionHandler, BubbleTransition {
+ @NonNull final Bubble mBubble;
+ IBinder mTransition;
+ TaskInfo mTaskInfo;
+ SurfaceControl mTaskLeash;
+ SurfaceControl mRootLeash;
+
+ ConvertFromBubble(@NonNull Bubble bubble, TaskInfo taskInfo) {
+ mBubble = bubble;
+ mTaskInfo = taskInfo;
+
+ mBubble.setPreparingTransition(this);
+ final WindowContainerToken token = mTaskInfo.getToken();
+ final Binder captionInsetsOwner = mBubble.getTaskView().getCaptionInsetsOwner();
+ final WindowContainerTransaction wct =
+ getExitBubbleTransaction(token, captionInsetsOwner);
+ mTaskViewTransitions.enqueueExternal(
+ mBubble.getTaskView().getController(),
+ () -> {
+ mTransition = mTransitions.startTransition(TRANSIT_CHANGE, wct, this);
+ return mTransition;
+ });
+ }
+
+ @Override
+ public void skip() {
+ mBubble.setPreparingTransition(null);
+ final TaskViewTaskController tv =
+ mBubble.getTaskView().getController();
+ tv.notifyTaskRemovalStarted(tv.getTaskInfo());
+ mTaskLeash = null;
+ }
+
+ @Override
+ public WindowContainerTransaction handleRequest(@NonNull IBinder transition,
+ @Nullable TransitionRequestInfo request) {
+ return null;
+ }
+
+ @Override
+ public void mergeAnimation(@NonNull IBinder transition, @NonNull TransitionInfo info,
+ @NonNull SurfaceControl.Transaction startT,
+ @NonNull SurfaceControl.Transaction finishT,
+ @NonNull IBinder mergeTarget,
+ @NonNull Transitions.TransitionFinishCallback finishCallback) {
+ }
+
+ @Override
+ public void onTransitionConsumed(@NonNull IBinder transition, boolean aborted,
+ @NonNull SurfaceControl.Transaction finishTransaction) {
+ if (!aborted) return;
+ mTransition = null;
+ skip();
+ mTaskViewTransitions.onExternalDone(transition);
+ }
+
+ @Override
+ public boolean startAnimation(@NonNull IBinder transition,
+ @NonNull TransitionInfo info,
+ @NonNull SurfaceControl.Transaction startTransaction,
+ @NonNull SurfaceControl.Transaction finishTransaction,
+ @NonNull Transitions.TransitionFinishCallback finishCallback) {
+ if (mTransition != transition) return false;
+
+ final TaskViewTaskController tv =
+ mBubble.getTaskView().getController();
+ if (tv == null) {
+ mTaskViewTransitions.onExternalDone(transition);
+ return false;
+ }
+
+ TransitionInfo.Change taskChg = null;
+
+ boolean found = false;
+ for (int i = 0; i < info.getChanges().size(); ++i) {
+ final TransitionInfo.Change chg = info.getChanges().get(i);
+ if (chg.getTaskInfo() == null) continue;
+ if (chg.getMode() != TRANSIT_CHANGE) continue;
+ if (!mTaskInfo.token.equals(chg.getTaskInfo().token)) continue;
+ found = true;
+ mRepository.remove(tv);
+ taskChg = chg;
+ break;
+ }
+
+ if (!found) {
+ Slog.w(TAG, "Expected a TaskView conversion in this transition but didn't get "
+ + "one, cleaning up the task view");
+ tv.setTaskNotFound();
+ skip();
+ mTaskViewTransitions.onExternalDone(transition);
+ return false;
+ }
+
+ mTaskLeash = taskChg.getLeash();
+ mRootLeash = info.getRoot(0).getLeash();
+
+ SurfaceControl dest = getExpandedView(mBubble).getViewRootImpl().getSurfaceControl();
+ final Runnable onPlucked = () -> {
+ // Need to remove the taskview AFTER applying the startTransaction because
+ // it isn't synchronized.
+ tv.notifyTaskRemovalStarted(tv.getTaskInfo());
+ // Unset after removeView so it can be used to pick a different animation.
+ mBubble.setPreparingTransition(null);
+ mBubbleData.setExpanded(false /* expanded */);
+ };
+ if (dest != null) {
+ pluck(mTaskLeash, getExpandedView(mBubble), dest,
+ taskChg.getStartAbsBounds().left - info.getRoot(0).getOffset().x,
+ taskChg.getStartAbsBounds().top - info.getRoot(0).getOffset().y,
+ getCornerRadius(mBubble), startTransaction,
+ onPlucked);
+ getExpandedView(mBubble).post(() -> mTransitions.dispatchTransition(
+ mTransition, info, startTransaction, finishTransaction, finishCallback,
+ null));
+ } else {
+ onPlucked.run();
+ mTransitions.dispatchTransition(mTransition, info, startTransaction,
+ finishTransaction, finishCallback, null);
+ }
+
+ mTaskViewTransitions.onExternalDone(transition);
+ return true;
+ }
+
+ @Override
+ public void continueCollapse() {
+ mBubble.cleanupTaskView();
+ if (mTaskLeash == null || !mTaskLeash.isValid() || !mRootLeash.isValid()) return;
+ SurfaceControl.Transaction t = new SurfaceControl.Transaction();
+ t.reparent(mTaskLeash, mRootLeash);
+ t.apply();
+ }
+
+ private View getExpandedView(@NonNull Bubble bubble) {
+ if (bubble.getBubbleBarExpandedView() != null) {
+ return bubble.getBubbleBarExpandedView();
+ }
+ return bubble.getExpandedView();
+ }
+
+ private float getCornerRadius(@NonNull Bubble bubble) {
+ if (bubble.getBubbleBarExpandedView() != null) {
+ return bubble.getBubbleBarExpandedView().getCornerRadius();
+ }
+ return bubble.getExpandedView().getCornerRadius();
+ }
+ }
+
+ /**
+ * A transition that converts a dragged bubble icon to a full screen window.
+ *
+ * This transition assumes that the bubble is invisible so it is simply sent to front.
+ */
+ class DraggedBubbleIconToFullscreen implements TransitionHandler, BubbleTransition {
+
+ IBinder mTransition;
+ final Bubble mBubble;
+ final Point mDropLocation;
+ final TransactionProvider mTransactionProvider;
+
+ DraggedBubbleIconToFullscreen(Bubble bubble, Point dropLocation) {
+ this(bubble, dropLocation, SurfaceControl.Transaction::new);
+ }
+
+ @VisibleForTesting
+ DraggedBubbleIconToFullscreen(Bubble bubble, Point dropLocation,
+ TransactionProvider transactionProvider) {
+ mBubble = bubble;
+ mDropLocation = dropLocation;
+ mTransactionProvider = transactionProvider;
+ bubble.setPreparingTransition(this);
+ final WindowContainerToken token = bubble.getTaskView().getTaskInfo().getToken();
+ final Binder captionInsetsOwner = bubble.getTaskView().getCaptionInsetsOwner();
+ final WindowContainerTransaction wct =
+ getExitBubbleTransaction(token, captionInsetsOwner);
+ wct.reorder(token, /* onTop= */ true);
+ if (!BubbleAnythingFlagHelper.enableCreateAnyBubbleWithForceExcludedFromRecents()) {
+ wct.setHidden(token, false);
+ }
+ mTaskViewTransitions.enqueueExternal(bubble.getTaskView().getController(), () -> {
+ mTransition = mTransitions.startTransition(TRANSIT_TO_FRONT, wct, this);
+ return mTransition;
+ });
+ }
+
+ @Override
+ public void skip() {
+ mBubble.setPreparingTransition(null);
+ }
+
+ @Override
+ public boolean startAnimation(@NonNull IBinder transition, @NonNull TransitionInfo info,
+ @NonNull SurfaceControl.Transaction startTransaction,
+ @NonNull SurfaceControl.Transaction finishTransaction,
+ @NonNull Transitions.TransitionFinishCallback finishCallback) {
+ if (mTransition != transition) {
+ return false;
+ }
+
+ final TaskViewTaskController taskViewTaskController =
+ mBubble.getTaskView().getController();
+ if (taskViewTaskController == null) {
+ mTaskViewTransitions.onExternalDone(transition);
+ finishCallback.onTransitionFinished(null);
+ return true;
+ }
+
+ TransitionInfo.Change change = findTransitionChange(info);
+ if (change == null) {
+ Slog.w(TAG, "Expected a TaskView transition to front but didn't find "
+ + "one, cleaning up the task view");
+ taskViewTaskController.setTaskNotFound();
+ mTaskViewTransitions.onExternalDone(transition);
+ finishCallback.onTransitionFinished(null);
+ return true;
+ }
+ mRepository.remove(taskViewTaskController);
+
+ final SurfaceControl taskLeash = change.getLeash();
+ // set the initial position of the task with 0 scale
+ startTransaction.setPosition(taskLeash, mDropLocation.x, mDropLocation.y);
+ startTransaction.setScale(taskLeash, 0, 0);
+ startTransaction.apply();
+
+ final SurfaceControl.Transaction animT = mTransactionProvider.get();
+ ValueAnimator animator = ValueAnimator.ofFloat(0, 1);
+ animator.setDuration(250);
+ animator.addUpdateListener(new AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(@NonNull Animator animation) {
+ float progress = animator.getAnimatedFraction();
+ float x = mDropLocation.x * (1 - progress);
+ float y = mDropLocation.y * (1 - progress);
+ animT.setPosition(taskLeash, x, y);
+ animT.setScale(taskLeash, progress, progress);
+ animT.apply();
+ }
+ });
+ animator.addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(@NonNull Animator animation) {
+ animT.close();
+ finishCallback.onTransitionFinished(null);
+ }
+ });
+ animator.start();
+ taskViewTaskController.notifyTaskRemovalStarted(mBubble.getTaskView().getTaskInfo());
+ mTaskViewTransitions.onExternalDone(transition);
+ return true;
+ }
+
+ private TransitionInfo.Change findTransitionChange(TransitionInfo info) {
+ TransitionInfo.Change result = null;
+ WindowContainerToken token = mBubble.getTaskView().getTaskInfo().getToken();
+ for (int i = 0; i < info.getChanges().size(); ++i) {
+ final TransitionInfo.Change change = info.getChanges().get(i);
+ if (change.getTaskInfo() == null) {
+ continue;
+ }
+ if (change.getMode() != TRANSIT_TO_FRONT) {
+ continue;
+ }
+ if (!token.equals(change.getTaskInfo().token)) {
+ continue;
+ }
+ result = change;
+ break;
+ }
+ return result;
+ }
+
+ @Override
+ public void mergeAnimation(@NonNull IBinder transition, @NonNull TransitionInfo info,
+ @NonNull SurfaceControl.Transaction startTransaction,
+ @NonNull SurfaceControl.Transaction finishTransaction,
+ @NonNull IBinder mergeTarget,
+ @NonNull Transitions.TransitionFinishCallback finishCallback) {
+ }
+
+ @Override
+ public WindowContainerTransaction handleRequest(@NonNull IBinder transition,
+ @NonNull TransitionRequestInfo request) {
+ return null;
+ }
+
+ @Override
+ public void onTransitionConsumed(@NonNull IBinder transition, boolean aborted,
+ @Nullable SurfaceControl.Transaction finishTransaction) {
+ if (!aborted) {
+ return;
+ }
+ mTransition = null;
+ mTaskViewTransitions.onExternalDone(transition);
+ }
+ }
+
+ /**
+ * A transition to convert an expanded floating bubble to a bar bubble.
+ *
+ *
This transition class should be created after switching over to the new Bubbles window for
+ * bubble bar mode, and adding the TaskView to the new window.
+ *
+ *
The transition is only started after calling {@link #continueConvert(BubbleBarLayerView)}
+ * once the bubble bar location on the screen is known.
+ */
+ class FloatingToBarConversion implements TransitionHandler, BubbleTransition {
+ private final BubblePositioner mPositioner;
+ private final Bubble mBubble;
+ private final TransactionProvider mTransactionProvider;
+ IBinder mTransition;
+ private final Rect mBounds = new Rect();
+ private final WindowContainerTransaction mWct = new WindowContainerTransaction();
+ private SurfaceControl mTaskLeash;
+ private SurfaceControl.Transaction mFinishTransaction;
+ private boolean mIsStarted = false;
+
+ FloatingToBarConversion(Bubble bubble, BubblePositioner positioner) {
+ this(bubble, SurfaceControl.Transaction::new, positioner);
+ }
+
+ @VisibleForTesting
+ FloatingToBarConversion(Bubble bubble, TransactionProvider transactionProvider,
+ BubblePositioner positioner) {
+ mBubble = bubble;
+ mBubble.setPreparingTransition(this);
+ mTransactionProvider = transactionProvider;
+ mPositioner = positioner;
+ }
+
+ @Override
+ public WindowContainerTransaction handleRequest(@NonNull IBinder transition,
+ @Nullable TransitionRequestInfo request) {
+ return null;
+ }
+
+ @Override
+ public void mergeAnimation(@NonNull IBinder transition, @NonNull TransitionInfo info,
+ @NonNull SurfaceControl.Transaction startT,
+ @NonNull SurfaceControl.Transaction finishT,
+ @NonNull IBinder mergeTarget,
+ @NonNull Transitions.TransitionFinishCallback finishCallback) {
+ }
+
+ @Override
+ public void onTransitionConsumed(@NonNull IBinder transition, boolean aborted,
+ @Nullable SurfaceControl.Transaction finishTransaction) {
+ if (!aborted) return;
+ mTransition = null;
+ mTaskViewTransitions.onExternalDone(transition);
+ }
+
+ @Override
+ public boolean startAnimation(@NonNull IBinder transition,
+ @NonNull TransitionInfo info,
+ @NonNull SurfaceControl.Transaction startTransaction,
+ @NonNull SurfaceControl.Transaction finishTransaction,
+ @NonNull Transitions.TransitionFinishCallback finishCallback) {
+ if (mTransition != transition) {
+ return false;
+ }
+
+ final TaskViewTaskController taskViewTaskController =
+ mBubble.getTaskView().getController();
+ if (taskViewTaskController == null) {
+ mTaskViewTransitions.onExternalDone(transition);
+ finishCallback.onTransitionFinished(null);
+ return true;
+ }
+
+ TransitionInfo.Change change = findTransitionChange(info);
+ if (change == null) {
+ Slog.w(TAG, "Expected a TaskView transition to front but didn't find "
+ + "one, cleaning up the task view");
+ taskViewTaskController.setTaskNotFound();
+ mTaskViewTransitions.onExternalDone(transition);
+ finishCallback.onTransitionFinished(null);
+ return true;
+ }
+
+ mTaskLeash = change.getLeash();
+ mFinishTransaction = finishTransaction;
+ updateBubbleTask();
+ return true;
+ }
+
+ private TransitionInfo.Change findTransitionChange(TransitionInfo info) {
+ WindowContainerToken token = mBubble.getTaskView().getTaskInfo().getToken();
+ for (int i = 0; i < info.getChanges().size(); ++i) {
+ final TransitionInfo.Change change = info.getChanges().get(i);
+ if (change.getTaskInfo() == null) {
+ continue;
+ }
+ if (!token.equals(change.getTaskInfo().token)) {
+ continue;
+ }
+ return change;
+ }
+ return null;
+ }
+
+ @Override
+ public void continueConvert(BubbleBarLayerView layerView) {
+ mPositioner.getTaskViewRestBounds(mBounds);
+ mWct.setBounds(mBubble.getTaskView().getTaskInfo().token, mBounds);
+ if (!mIsStarted) {
+ startTransition();
+ }
+ }
+
+ private void startTransition() {
+ mIsStarted = true;
+ final TaskView tv = mBubble.getTaskView();
+ mTransition = mTransitions.startTransition(TRANSIT_BUBBLE_CONVERT_FLOATING_TO_BAR,
+ mWct, this);
+ mTaskViewTransitions.enqueueExternal(tv.getController(), () -> mTransition);
+ }
+
+ @Override
+ public void mergeWithUnfold(SurfaceControl taskLeash, SurfaceControl.Transaction finishT) {
+ mTaskLeash = taskLeash;
+ mFinishTransaction = finishT;
+ updateBubbleTask();
+ }
+
+ private void updateBubbleTask() {
+ final TaskViewTaskController tvc = mBubble.getTaskView().getController();
+ final SurfaceControl taskViewSurface = mBubble.getTaskView().getSurfaceControl();
+ final TaskViewRepository.TaskViewState state = mRepository.byTaskView(tvc);
+ if (state == null) return;
+ state.mVisible = true;
+ state.mBounds.set(mBounds);
+ final SurfaceControl.Transaction startT = mTransactionProvider.get();
+
+ // since the task view is switching windows, its surface needs to be moved over to the
+ // new bubble window surface
+ startT.reparent(taskViewSurface,
+ mBubble.getBubbleBarExpandedView()
+ .getViewRootImpl()
+ .updateAndGetBoundsLayer(startT));
+
+ startT.reparent(mTaskLeash, taskViewSurface);
+ startT.setPosition(mTaskLeash, 0, 0);
+ startT.setCornerRadius(mTaskLeash,
+ mBubble.getBubbleBarExpandedView().getRestingCornerRadius());
+ startT.setWindowCrop(mTaskLeash, mBounds.width(), mBounds.height());
+ startT.apply();
+ mBubble.setPreparingTransition(null);
+ mFinishTransaction.reparent(mTaskLeash, taskViewSurface);
+ mFinishTransaction.setPosition(mTaskLeash, 0, 0);
+ mFinishTransaction.setWindowCrop(mTaskLeash, mBounds.width(), mBounds.height());
+ mTaskViewTransitions.onExternalDone(mTransition);
+ mTransition = null;
+ }
+ }
+
+ interface TransactionProvider {
+ SurfaceControl.Transaction get();
+ }
+}
diff --git a/wmshell/src/com/android/wm/shell/bubbles/BubbleViewInfoTask.java b/wmshell/src/com/android/wm/shell/bubbles/BubbleViewInfoTask.java
index 69119cf433..a8d056e198 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/BubbleViewInfoTask.java
+++ b/wmshell/src/com/android/wm/shell/bubbles/BubbleViewInfoTask.java
@@ -20,44 +20,44 @@ import static com.android.wm.shell.bubbles.BadgedImageView.DEFAULT_PATH_SIZE;
import static com.android.wm.shell.bubbles.BadgedImageView.WHITE_SCRIM_ALPHA;
import static com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_BUBBLES;
import static com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_WITH_CLASS_NAME;
+import static com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_BUBBLES;
+import static com.android.wm.shell.shared.bubbles.FlyoutDrawableLoader.loadFlyoutDrawable;
-import android.annotation.NonNull;
import android.annotation.Nullable;
import android.content.Context;
-import android.content.Intent;
-import android.content.pm.ApplicationInfo;
-import android.content.pm.PackageManager;
import android.content.pm.ShortcutInfo;
import android.graphics.Bitmap;
import android.graphics.Color;
import android.graphics.Matrix;
import android.graphics.Path;
import android.graphics.drawable.Drawable;
-import android.graphics.drawable.Icon;
-import android.os.AsyncTask;
import android.util.Log;
import android.util.PathParser;
import android.view.LayoutInflater;
+import android.view.View;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.graphics.ColorUtils;
+import com.android.internal.protolog.ProtoLog;
import com.android.launcher3.icons.BitmapInfo;
import com.android.launcher3.icons.BubbleIconFactory;
import com.android.wm.shell.R;
+import com.android.wm.shell.bubbles.appinfo.BubbleAppInfo;
+import com.android.wm.shell.bubbles.appinfo.BubbleAppInfoProvider;
import com.android.wm.shell.bubbles.bar.BubbleBarExpandedView;
import com.android.wm.shell.bubbles.bar.BubbleBarLayerView;
+import com.android.wm.shell.shared.handles.RegionSamplingHelper;
import java.lang.ref.WeakReference;
-import java.util.Objects;
import java.util.concurrent.Executor;
+import java.util.concurrent.atomic.AtomicBoolean;
/**
* Simple task to inflate views & load necessary info to display a bubble.
*/
-public class BubbleViewInfoTask extends AsyncTask {
+public class BubbleViewInfoTask {
private static final String TAG = TAG_WITH_CLASS_NAME ? "BubbleViewInfoTask" : TAG_BUBBLES;
-
/**
* Callback to find out when the bubble has been inflated & necessary data loaded.
*/
@@ -68,17 +68,23 @@ public class BubbleViewInfoTask extends AsyncTask mContext;
- private WeakReference mExpandedViewManager;
- private WeakReference mTaskViewFactory;
- private WeakReference mPositioner;
- private WeakReference mStackView;
- private WeakReference mLayerView;
- private BubbleIconFactory mIconFactory;
- private boolean mSkipInflation;
- private Callback mCallback;
- private Executor mMainExecutor;
+ private final Bubble mBubble;
+ private final WeakReference mContext;
+ private final WeakReference mExpandedViewManager;
+ private final WeakReference mTaskViewFactory;
+ private final WeakReference mPositioner;
+ private final WeakReference mStackView;
+ private final WeakReference mLayerView;
+ private final BubbleIconFactory mIconFactory;
+ private final boolean mSkipInflation;
+ private final Callback mCallback;
+ private final Executor mMainExecutor;
+ private final Executor mBgExecutor;
+ private final BubbleAppInfoProvider mAppInfoProvider;
+
+ private final AtomicBoolean mStarted = new AtomicBoolean();
+ private final AtomicBoolean mCancelled = new AtomicBoolean();
+ private final AtomicBoolean mFinished = new AtomicBoolean();
/**
* Creates a task to load information for the provided {@link Bubble}. Once all info
@@ -92,9 +98,11 @@ public class BubbleViewInfoTask extends AsyncTask(context);
mExpandedViewManager = new WeakReference<>(expandedViewManager);
@@ -103,43 +111,138 @@ public class BubbleViewInfoTask extends AsyncTask(stackView);
mLayerView = new WeakReference<>(layerView);
mIconFactory = factory;
+ mAppInfoProvider = appInfoProvider;
mSkipInflation = skipInflation;
mCallback = c;
mMainExecutor = mainExecutor;
+ mBgExecutor = bgExecutor;
}
- @Override
- protected BubbleViewInfo doInBackground(Void... voids) {
+ /**
+ * Load bubble view info in background using {@code bgExecutor} specified in constructor.
+ *
+ * Use {@link #cancel()} to stop the task.
+ *
+ * @throws IllegalStateException if the task is already started
+ */
+ public void start() {
+ verifyCanStart();
+ if (mCancelled.get()) {
+ // We got cancelled even before start was called. Exit early
+ mFinished.set(true);
+ return;
+ }
+ mBgExecutor.execute(() -> {
+ if (mCancelled.get()) {
+ // We got cancelled while background executor was busy and this was waiting
+ mFinished.set(true);
+ return;
+ }
+ BubbleViewInfo viewInfo = loadViewInfo();
+ if (mCancelled.get()) {
+ // Do not schedule anything on main executor if we got cancelled.
+ // Loading view info involves inflating views and it is possible we get cancelled
+ // during it.
+ mFinished.set(true);
+ return;
+ }
+ mMainExecutor.execute(() -> {
+ // Before updating view info check that we did not get cancelled while waiting
+ // main executor to pick up the work
+ if (!mCancelled.get()) {
+ updateViewInfo(viewInfo);
+ }
+ mFinished.set(true);
+ });
+ });
+ }
+
+ private void verifyCanStart() {
+ if (mStarted.getAndSet(true)) {
+ throw new IllegalStateException("Task already started");
+ }
+ }
+
+ /**
+ * Load bubble view info synchronously.
+ *
+ * @throws IllegalStateException if the task is already started
+ */
+ public void startSync() {
+ verifyCanStart();
+ if (mCancelled.get()) {
+ mFinished.set(true);
+ return;
+ }
+ updateViewInfo(loadViewInfo());
+ mFinished.set(true);
+ }
+
+ /**
+ * Cancel the task. Stops the task from running if called before {@link #start()} or
+ * {@link #startSync()}
+ */
+ public void cancel() {
+ mCancelled.set(true);
+ }
+
+ /**
+ * Return {@code true} when the task has completed loading the view info.
+ */
+ public boolean isFinished() {
+ return mFinished.get();
+ }
+
+ @Nullable
+ private BubbleViewInfo loadViewInfo() {
if (!verifyState()) {
// If we're in an inconsistent state, then switched modes and should just bail now.
return null;
}
+ ProtoLog.v(WM_SHELL_BUBBLES, "Task loading bubble view info key=%s", mBubble.getKey());
if (mLayerView.get() != null) {
- return BubbleViewInfo.populateForBubbleBar(mContext.get(), mExpandedViewManager.get(),
- mTaskViewFactory.get(), mPositioner.get(), mLayerView.get(), mIconFactory,
- mBubble, mSkipInflation);
+ return BubbleViewInfo.populateForBubbleBar(mContext.get(), mTaskViewFactory.get(),
+ mLayerView.get(), mIconFactory, mBubble, mAppInfoProvider, mSkipInflation);
} else {
- return BubbleViewInfo.populate(mContext.get(), mExpandedViewManager.get(),
- mTaskViewFactory.get(), mPositioner.get(), mStackView.get(), mIconFactory,
- mBubble, mSkipInflation);
+ return BubbleViewInfo.populate(mContext.get(), mTaskViewFactory.get(),
+ mPositioner.get(), mStackView.get(), mIconFactory, mBubble, mAppInfoProvider,
+ mSkipInflation);
}
}
- @Override
- protected void onPostExecute(BubbleViewInfo viewInfo) {
- if (isCancelled() || viewInfo == null) {
+ private void updateViewInfo(@Nullable BubbleViewInfo viewInfo) {
+ if (viewInfo == null || !verifyState()) {
return;
}
+ ProtoLog.v(WM_SHELL_BUBBLES, "Task updating bubble view info key=%s", mBubble.getKey());
+ if (!mBubble.isInflated()) {
+ if (viewInfo.expandedView != null) {
+ ProtoLog.v(WM_SHELL_BUBBLES, "Task initializing expanded view key=%s",
+ mBubble.getKey());
+ viewInfo.expandedView.initialize(mExpandedViewManager.get(), mStackView.get(),
+ mPositioner.get(), false /* isOverflow */, viewInfo.taskView);
+ } else if (viewInfo.bubbleBarExpandedView != null) {
+ ProtoLog.v(WM_SHELL_BUBBLES, "Task initializing bubble bar expanded view key=%s",
+ mBubble.getKey());
+ viewInfo.bubbleBarExpandedView.initialize(mExpandedViewManager.get(),
+ mPositioner.get(), false /* isOverflow */, mBubble,
+ viewInfo.taskView, mMainExecutor, mBgExecutor,
+ new RegionSamplingProvider() {
+ @Override
+ public RegionSamplingHelper createHelper(View sampledView,
+ RegionSamplingHelper.SamplingCallback callback,
+ Executor backgroundExecutor, Executor mainExecutor) {
+ return RegionSamplingProvider.super.createHelper(sampledView,
+ callback, backgroundExecutor, mainExecutor);
+ }
+ });
+ }
+ }
- mMainExecutor.execute(() -> {
- if (!verifyState()) {
- return;
- }
- mBubble.setViewInfo(viewInfo);
- if (mCallback != null) {
- mCallback.onBubbleViewsReady(mBubble);
- }
- });
+ mBubble.setViewInfo(viewInfo);
+ if (mCallback != null) {
+ mCallback.onBubbleViewsReady(mBubble);
+ }
}
private boolean verifyState() {
@@ -157,6 +260,9 @@ public class BubbleViewInfoTask extends AsyncTasknot backed by a notification and remain until the user dismisses
* the bubble or bubble stack.
*
- * Some notes:
- * - Only one app bubble is supported at a time, regardless of users. Multi-users support is
- * tracked in b/273533235.
- * - Calling this method with a different intent than the existing app bubble will do nothing
+ * Some details:
+ * - Calling this method with a different intent than the existing bubble will do nothing
*
* @param intent the intent to display in the bubble expanded view.
- * @param user the {@link UserHandle} of the user to start this activity for.
- * @param icon the {@link Icon} to use for the bubble view.
+ * @param user the {@link UserHandle} of the user to start this activity for.
+ * @param icon the {@link Icon} to use for the bubble view.
*/
- void showOrHideAppBubble(Intent intent, UserHandle user, @Nullable Icon icon);
+ void showOrHideNoteBubble(Intent intent, UserHandle user, @Nullable Icon icon);
/** @return true if the specified {@code taskId} corresponds to app bubble's taskId. */
- boolean isAppBubbleTaskId(int taskId);
+ boolean isNoteBubbleTaskId(int taskId);
/**
` * @return a {@link SynchronousScreenCaptureListener} after performing a screenshot that may
@@ -205,7 +210,8 @@ public interface Bubbles {
*
* @param entry the {@link BubbleEntry} by the notification.
* @param shouldBubbleUp {@code true} if this notification should bubble up.
- * @param fromSystem {@code true} if this update is from NotificationManagerService.
+ * @param fromSystem {@code true} if this update is from NotificationManagerService or App,
+ * false means this update is from SystemUi
*/
void onEntryUpdated(BubbleEntry entry, boolean shouldBubbleUp, boolean fromSystem);
@@ -305,6 +311,33 @@ public interface Bubbles {
*/
boolean canShowBubbleNotification();
+ /**
+ * Returns the string representation of the given dismiss reason.
+ */
+ public static String dismissReasonToString(@DismissReason int dismissReason) {
+ switch (dismissReason) {
+ case DISMISS_USER_GESTURE: return "USER_GESTURE";
+ case DISMISS_AGED: return "AGED";
+ case DISMISS_TASK_FINISHED: return "TASK_FINISHED";
+ case DISMISS_BLOCKED: return "BLOCKED";
+ case DISMISS_NOTIF_CANCEL: return "NOTIF_CANCEL";
+ case DISMISS_ACCESSIBILITY_ACTION: return "ACCESSIBILITY_ACTION";
+ case DISMISS_NO_LONGER_BUBBLE: return "NO_LONGER_BUBBLE";
+ case DISMISS_USER_CHANGED: return "USER_CHANGED";
+ case DISMISS_GROUP_CANCELLED: return "GROUP_CANCELLED";
+ case DISMISS_INVALID_INTENT: return "INVALID_INTENT";
+ case DISMISS_OVERFLOW_MAX_REACHED: return "OVERFLOW_MAX_REACHED";
+ case DISMISS_SHORTCUT_REMOVED: return "SHORTCUT_REMOVED";
+ case DISMISS_PACKAGE_REMOVED: return "PACKAGE_REMOVED";
+ case DISMISS_NO_BUBBLE_UP: return "NO_BUBBLE_UP";
+ case DISMISS_RELOAD_FROM_DISK: return "RELOAD_FROM_DISK";
+ case DISMISS_USER_ACCOUNT_REMOVED: return "USER_ACCOUNT_REMOVED";
+ case DISMISS_SWITCH_TO_STACK: return "SWITCH_TO_STACK";
+ case DISMISS_USER_GESTURE_FROM_LAUNCHER: return "USER_GESTURE_FROM_LAUNCHER";
+ default: return "UNKNOWN";
+ }
+ }
+
/**
* A listener to be notified of bubble state changes, used by launcher to render bubbles in
* its process.
@@ -320,6 +353,14 @@ public interface Bubbles {
* Does not result in a state change.
*/
void animateBubbleBarLocation(BubbleBarLocation location);
+
+ /**
+ * Show the bubble bar pillow view at the provided location.
+ * If the location is null, the pillow view is should be hidden.
+ *
+ * @param location The location to show the pillow view, or null to hide it.
+ */
+ void showBubbleBarPillowAt(@Nullable BubbleBarLocation location);
}
/** Listener to find out about stack expansion / collapse events. */
diff --git a/wmshell/src/com/android/wm/shell/bubbles/BubblesNavBarGestureTracker.java b/wmshell/src/com/android/wm/shell/bubbles/BubblesNavBarGestureTracker.java
index 137568458e..9429c9e71b 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/BubblesNavBarGestureTracker.java
+++ b/wmshell/src/com/android/wm/shell/bubbles/BubblesNavBarGestureTracker.java
@@ -29,7 +29,7 @@ import android.view.InputMonitor;
import androidx.annotation.Nullable;
-import com.android.internal.protolog.common.ProtoLog;
+import com.android.internal.protolog.ProtoLog;
import com.android.wm.shell.bubbles.BubblesNavBarMotionEventHandler.MotionEventListener;
/**
diff --git a/wmshell/src/com/android/wm/shell/bubbles/BubblesNavBarMotionEventHandler.java b/wmshell/src/com/android/wm/shell/bubbles/BubblesNavBarMotionEventHandler.java
index b7107f09b1..d4f53ab353 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/BubblesNavBarMotionEventHandler.java
+++ b/wmshell/src/com/android/wm/shell/bubbles/BubblesNavBarMotionEventHandler.java
@@ -28,7 +28,7 @@ import android.view.ViewConfiguration;
import androidx.annotation.Nullable;
-import com.android.internal.protolog.common.ProtoLog;
+import com.android.internal.protolog.ProtoLog;
/**
* Handles {@link MotionEvent}s for bubbles that begin in the nav bar area
diff --git a/wmshell/src/com/android/wm/shell/bubbles/BubblesTransitionObserver.java b/wmshell/src/com/android/wm/shell/bubbles/BubblesTransitionObserver.java
index c1f704ab45..7afe789877 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/BubblesTransitionObserver.java
+++ b/wmshell/src/com/android/wm/shell/bubbles/BubblesTransitionObserver.java
@@ -13,56 +13,176 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+
package com.android.wm.shell.bubbles;
import static android.app.ActivityTaskManager.INVALID_TASK_ID;
+import static com.android.wm.shell.Flags.enableEnterSplitRemoveBubble;
+import static com.android.wm.shell.bubbles.util.BubbleUtils.getExitBubbleTransaction;
+import static com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_BUBBLES_NOISY;
+
import android.app.ActivityManager;
+import android.os.Binder;
import android.os.IBinder;
import android.view.SurfaceControl;
+import android.window.ActivityTransitionInfo;
import android.window.TransitionInfo;
+import android.window.WindowContainerTransaction;
import androidx.annotation.NonNull;
+import com.android.internal.protolog.ProtoLog;
+import com.android.wm.shell.ShellTaskOrganizer;
import com.android.wm.shell.shared.TransitionUtil;
+import com.android.wm.shell.shared.bubbles.BubbleAnythingFlagHelper;
+import com.android.wm.shell.splitscreen.SplitScreenController;
+import com.android.wm.shell.taskview.TaskViewTaskController;
+import com.android.wm.shell.taskview.TaskViewTransitions;
import com.android.wm.shell.transition.Transitions;
+import dagger.Lazy;
+
+import java.util.Optional;
+
/**
* Observer used to identify tasks that are opening or moving to front. If a bubble activity is
* currently opened when this happens, we'll collapse the bubbles.
*/
public class BubblesTransitionObserver implements Transitions.TransitionObserver {
- private BubbleController mBubbleController;
- private BubbleData mBubbleData;
+ @NonNull
+ private final BubbleController mBubbleController;
+ @NonNull
+ private final BubbleData mBubbleData;
+ @NonNull
+ private final TaskViewTransitions mTaskViewTransitions;
+ private final Lazy> mSplitScreenController;
- public BubblesTransitionObserver(BubbleController controller,
- BubbleData bubbleData) {
+ public BubblesTransitionObserver(@NonNull BubbleController controller,
+ @NonNull BubbleData bubbleData,
+ @NonNull TaskViewTransitions taskViewTransitions,
+ Lazy> splitScreenController) {
mBubbleController = controller;
mBubbleData = bubbleData;
+ mTaskViewTransitions = taskViewTransitions;
+ mSplitScreenController = splitScreenController;
}
@Override
public void onTransitionReady(@NonNull IBinder transition, @NonNull TransitionInfo info,
@NonNull SurfaceControl.Transaction startTransaction,
@NonNull SurfaceControl.Transaction finishTransaction) {
+ collapseBubbleIfNeeded(info);
+ if (enableEnterSplitRemoveBubble() && BubbleAnythingFlagHelper.enableCreateAnyBubble()) {
+ if (TransitionUtil.isOpeningType(info.getType()) && mBubbleData.hasBubbles()) {
+ removeBubbleIfLaunchingToSplit(info);
+ }
+ }
+ }
+
+ private void collapseBubbleIfNeeded(@NonNull TransitionInfo info) {
+ // --- Pre-conditions (Loop-invariant checks) ---
+ // If bubbles aren't expanded, are animating, or no bubble is selected,
+ // we don't need to process any transitions for collapsing.
+ if (mBubbleController.isStackAnimating()
+ || !mBubbleData.isExpanded()
+ || mBubbleData.getSelectedBubble() == null) {
+ return;
+ }
+
+ final int expandedTaskId = mBubbleData.getSelectedBubble().getTaskId();
+ // If expanded task id is invalid, we don't need to process any transitions for collapsing.
+ if (expandedTaskId == INVALID_TASK_ID) {
+ return;
+ }
+
+ final int bubbleViewDisplayId = mBubbleController.getCurrentViewDisplayId();
for (TransitionInfo.Change change : info.getChanges()) {
- final ActivityManager.RunningTaskInfo taskInfo = change.getTaskInfo();
- // We only care about opens / move to fronts when bubbles are expanded & not animating.
- if (taskInfo == null
- || taskInfo.taskId == INVALID_TASK_ID
- || !TransitionUtil.isOpeningType(change.getMode())
- || mBubbleController.isStackAnimating()
- || !mBubbleData.isExpanded()
- || mBubbleData.getSelectedBubble() == null) {
+ // We only care about opens / move to fronts.
+ if (!TransitionUtil.isOpeningType(change.getMode())) {
continue;
}
- int expandedId = mBubbleData.getSelectedBubble().getTaskId();
- // If the task id that's opening is the same as the expanded bubble, skip collapsing
- // because it is our bubble that is opening.
- if (expandedId != INVALID_TASK_ID && expandedId != taskInfo.taskId) {
- mBubbleData.setExpanded(false);
+ // If the opening transition is on a different display, skip collapsing because
+ // it does not visually overlap with the bubbles.
+ if (change.getEndDisplayId() != bubbleViewDisplayId) {
+ continue;
}
+
+ final ActivityManager.RunningTaskInfo taskInfo = change.getTaskInfo();
+ final ActivityTransitionInfo activityInfo = change.getActivityTransitionInfo();
+ if (taskInfo != null) { // Task transition.
+ if (shouldBypassCollapseForTask(taskInfo.taskId, expandedTaskId)) {
+ continue;
+ }
+
+ // If the opening task was launched by another bubble, skip collapsing the
+ // existing one since BubbleTransitions will start a new bubble for it.
+ if (BubbleAnythingFlagHelper.enableCreateAnyBubble()
+ && mBubbleController.shouldBeAppBubble(taskInfo)) {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY,
+ "BubblesTransitionObserver.onTransitionReady(): "
+ + "skipping app bubble for taskId=%d", taskInfo.taskId);
+ continue;
+ }
+ } else if (activityInfo != null) { // Activity transition.
+ if (shouldBypassCollapseForTask(activityInfo.getTaskId(), expandedTaskId)) {
+ continue;
+ }
+ } else { // Invalid transition.
+ continue;
+ }
+
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "BubblesTransitionObserver.onTransitionReady(): "
+ + "collapse the expanded bubble for taskId=%d", expandedTaskId);
+ mBubbleData.setExpanded(false);
+ return;
+ }
+ }
+
+ /** Checks if a task should be skipped for bubble collapse based on task ID. */
+ private boolean shouldBypassCollapseForTask(int taskId, int expandedTaskId) {
+ if (taskId == INVALID_TASK_ID) {
+ ProtoLog.w(WM_SHELL_BUBBLES_NOISY, "BubblesTransitionObserver.onTransitionReady(): "
+ + "task id is invalid so skip collapsing");
+ return true;
+ }
+ // If the opening task id is the same as the expanded bubble, skip collapsing
+ // because it is our bubble that is opening.
+ if (taskId == expandedTaskId) {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "BubblesTransitionObserver.onTransitionReady(): "
+ + "task %d is our bubble so skip collapsing", taskId);
+ return true;
+ }
+ return false;
+ }
+
+ private void removeBubbleIfLaunchingToSplit(@NonNull TransitionInfo info) {
+ if (mSplitScreenController.get().isEmpty()) return;
+ SplitScreenController splitScreenController = mSplitScreenController.get().get();
+ for (TransitionInfo.Change change : info.getChanges()) {
+ ActivityManager.RunningTaskInfo taskInfo = change.getTaskInfo();
+ if (taskInfo == null) continue;
+ Bubble bubble = mBubbleData.getBubbleInStackWithTaskId(taskInfo.taskId);
+ if (bubble == null) continue;
+ if (!splitScreenController.isTaskRootOrStageRoot(taskInfo.parentTaskId)) continue;
+ // There is a bubble task that is moving to split screen
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "BubblesTransitionObserver.onTransitionReady(): "
+ + "removing bubble for task launching into split taskId=%d", taskInfo.taskId);
+ TaskViewTaskController taskViewTaskController = bubble.getTaskView().getController();
+ ShellTaskOrganizer taskOrganizer = taskViewTaskController.getTaskOrganizer();
+ WindowContainerTransaction wct = getExitBubbleTransaction(taskInfo.token,
+ bubble.getTaskView().getCaptionInsetsOwner());
+
+ // Notify the task removal, but block all TaskViewTransitions during removal so we can
+ // clear them without triggering
+ final IBinder gate = new Binder();
+ mTaskViewTransitions.enqueueExternal(taskViewTaskController, () -> gate);
+
+ taskOrganizer.applyTransaction(wct);
+ taskViewTaskController.notifyTaskRemovalStarted(taskInfo);
+ mTaskViewTransitions.removePendingTransitions(taskViewTaskController);
+ mTaskViewTransitions.onExternalDone(gate);
}
}
diff --git a/wmshell/src/com/android/wm/shell/bubbles/DismissViewExt.kt b/wmshell/src/com/android/wm/shell/bubbles/DismissViewExt.kt
index 48692d4101..0de3d52e13 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/DismissViewExt.kt
+++ b/wmshell/src/com/android/wm/shell/bubbles/DismissViewExt.kt
@@ -17,18 +17,33 @@
package com.android.wm.shell.bubbles
+import androidx.annotation.DimenRes
import com.android.wm.shell.R
-import com.android.wm.shell.common.bubbles.DismissView
+import com.android.wm.shell.shared.bubbles.DismissView
+import com.android.wm.shell.shared.R as SharedR
+
+private val defaultConfig =
+ DismissView.Config(
+ dismissViewResId = R.id.dismiss_view,
+ targetSizeResId = SharedR.dimen.floating_dismiss_background_size,
+ iconSizeResId = SharedR.dimen.floating_dismiss_icon_size,
+ bottomMarginResId = R.dimen.floating_dismiss_bottom_margin,
+ floatingGradientHeightResId = R.dimen.floating_dismiss_gradient_height,
+ floatingGradientColorResId = android.R.color.system_neutral1_900,
+ backgroundResId = SharedR.drawable.floating_dismiss_background,
+ iconResId = SharedR.drawable.floating_dismiss_ic_close,
+ applyMarginOverNavBarInset = true,
+ )
fun DismissView.setup() {
- setup(DismissView.Config(
- dismissViewResId = R.id.dismiss_view,
- targetSizeResId = R.dimen.dismiss_circle_size,
- iconSizeResId = R.dimen.dismiss_target_x_size,
- bottomMarginResId = R.dimen.floating_dismiss_bottom_margin,
- floatingGradientHeightResId = R.dimen.floating_dismiss_gradient_height,
- floatingGradientColorResId = android.R.color.system_neutral1_900,
- backgroundResId = R.drawable.dismiss_circle_background,
- iconResId = R.drawable.pip_ic_close_white
- ))
-}
\ No newline at end of file
+ setup(defaultConfig)
+}
+
+fun DismissView.setupWithMarginIgnoringNavBarInset(@DimenRes bottomMarginResId: Int) {
+ setup(
+ defaultConfig.copy(
+ bottomMarginResId = bottomMarginResId,
+ applyMarginOverNavBarInset = false,
+ )
+ )
+}
diff --git a/wmshell/src/com/android/wm/shell/bubbles/IBubbles.aidl b/wmshell/src/com/android/wm/shell/bubbles/IBubbles.aidl
index 1db556c041..f4695281e8 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/IBubbles.aidl
+++ b/wmshell/src/com/android/wm/shell/bubbles/IBubbles.aidl
@@ -17,9 +17,12 @@
package com.android.wm.shell.bubbles;
import android.content.Intent;
+import android.content.pm.ShortcutInfo;
+import android.graphics.Point;
import android.graphics.Rect;
+import android.os.UserHandle;
import com.android.wm.shell.bubbles.IBubblesListener;
-import com.android.wm.shell.common.bubbles.BubbleBarLocation;
+import com.android.wm.shell.shared.bubbles.BubbleBarLocation;
/**
* Interface that is exposed to remote callers (launcher) to manipulate the bubbles feature when
@@ -31,9 +34,9 @@ interface IBubbles {
oneway void unregisterBubbleListener(in IBubblesListener listener) = 2;
- oneway void showBubble(in String key, in int topOnScreen) = 3;
+ oneway void showBubble(in String key, in int bubbleBarTopToScreenBottom) = 3;
- oneway void dragBubbleToDismiss(in String key) = 4;
+ oneway void dragBubbleToDismiss(in String key, in long timestamp) = 4;
oneway void removeAllBubbles() = 5;
@@ -43,9 +46,19 @@ interface IBubbles {
oneway void showUserEducation(in int positionX, in int positionY) = 8;
- oneway void setBubbleBarLocation(in BubbleBarLocation location) = 9;
+ oneway void setBubbleBarLocation(in BubbleBarLocation location, in int source) = 9;
- oneway void updateBubbleBarTopOnScreen(in int topOnScreen) = 10;
+ oneway void updateBubbleBarTopToScreenBottom(in int bubbleBarTopToScreenBottom) = 10;
- oneway void stopBubbleDrag(in BubbleBarLocation location, in int topOnScreen) = 11;
+ oneway void stopBubbleDrag(in BubbleBarLocation location, in int bubbleBarTopToScreenBottom) = 11;
+
+ oneway void showShortcutBubble(in ShortcutInfo info, in @nullable BubbleBarLocation location) = 12;
+
+ oneway void showAppBubble(in Intent intent, in UserHandle user, in @nullable BubbleBarLocation location) = 13;
+
+ oneway void showExpandedView() = 14;
+
+ oneway void moveDraggedBubbleToFullscreen(in String key, in Point dropLocation) = 15;
+
+ oneway void setHasBubbleBar(in boolean hasBubbleBar) = 16;
}
\ No newline at end of file
diff --git a/wmshell/src/com/android/wm/shell/bubbles/IBubblesListener.aidl b/wmshell/src/com/android/wm/shell/bubbles/IBubblesListener.aidl
index 14d29cd887..d51ff3d01f 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/IBubblesListener.aidl
+++ b/wmshell/src/com/android/wm/shell/bubbles/IBubblesListener.aidl
@@ -17,7 +17,7 @@
package com.android.wm.shell.bubbles;
import android.os.Bundle;
-import com.android.wm.shell.common.bubbles.BubbleBarLocation;
+import com.android.wm.shell.shared.bubbles.BubbleBarLocation;
/**
* Listener interface that Launcher attaches to SystemUI to get bubbles callbacks.
*/
@@ -33,4 +33,10 @@ oneway interface IBubblesListener {
* Does not result in a state change.
*/
void animateBubbleBarLocation(in BubbleBarLocation location);
+
+ /**
+ * Show the bubble bar pillow view at the provided location.
+ * If the location is null, the pillow view is should be hidden.
+ */
+ void showBubbleBarPillowAt(in BubbleBarLocation location);
}
\ No newline at end of file
diff --git a/wmshell/src/com/android/wm/shell/bubbles/ManageEducationView.kt b/wmshell/src/com/android/wm/shell/bubbles/ManageEducationView.kt
index da71b1c741..d2ad70886f 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/ManageEducationView.kt
+++ b/wmshell/src/com/android/wm/shell/bubbles/ManageEducationView.kt
@@ -27,7 +27,8 @@ import android.widget.Button
import android.widget.LinearLayout
import com.android.internal.R.color.system_neutral1_900
import com.android.wm.shell.R
-import com.android.wm.shell.animation.Interpolators
+import com.android.wm.shell.shared.TypefaceUtils
+import com.android.wm.shell.shared.animation.Interpolators
/**
* User education view to highlight the manage button that allows a user to configure the settings
@@ -53,6 +54,12 @@ class ManageEducationView(
init {
LayoutInflater.from(context).inflate(R.layout.bubbles_manage_button_education, this)
+ TypefaceUtils.setTypeface(findViewById(R.id.user_education_title),
+ TypefaceUtils.FontFamily.GSF_HEADLINE_SMALL_EMPHASIZED)
+ TypefaceUtils.setTypeface(findViewById(R.id.user_education_description),
+ TypefaceUtils.FontFamily.GSF_BODY_MEDIUM)
+ TypefaceUtils.setTypeface(manageButton, TypefaceUtils.FontFamily.GSF_LABEL_LARGE_EMPHASIZED)
+ TypefaceUtils.setTypeface(gotItButton, TypefaceUtils.FontFamily.GSF_LABEL_LARGE_EMPHASIZED)
visibility = View.GONE
elevation = resources.getDimensionPixelSize(R.dimen.bubble_elevation).toFloat()
diff --git a/wmshell/src/com/android/wm/shell/bubbles/OWNERS b/wmshell/src/com/android/wm/shell/bubbles/OWNERS
index 08c7031497..290151a2e5 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/OWNERS
+++ b/wmshell/src/com/android/wm/shell/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/src/com/android/wm/shell/bubbles/RegionSamplingProvider.java b/wmshell/src/com/android/wm/shell/bubbles/RegionSamplingProvider.java
new file mode 100644
index 0000000000..30f5c8fd56
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/bubbles/RegionSamplingProvider.java
@@ -0,0 +1,40 @@
+/*
+ * 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.bubbles;
+
+import android.view.View;
+
+import com.android.wm.shell.shared.handles.RegionSamplingHelper;
+
+import java.util.concurrent.Executor;
+
+/**
+ * Wrapper to provide a {@link com.android.wm.shell.shared.handles.RegionSamplingHelper} to allow
+ * testing it.
+ */
+public interface RegionSamplingProvider {
+
+ /** Creates and returns the region sampling helper */
+ default RegionSamplingHelper createHelper(View sampledView,
+ RegionSamplingHelper.SamplingCallback callback,
+ Executor backgroundExecutor,
+ Executor mainExecutor) {
+ return new RegionSamplingHelper(sampledView,
+ callback, backgroundExecutor, mainExecutor);
+ }
+
+}
diff --git a/wmshell/src/com/android/wm/shell/bubbles/ResizabilityChecker.kt b/wmshell/src/com/android/wm/shell/bubbles/ResizabilityChecker.kt
new file mode 100644
index 0000000000..6b3a72f567
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/bubbles/ResizabilityChecker.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.bubbles
+
+import android.content.Intent
+import android.content.pm.PackageManager
+
+/**
+ * Interface to check whether the activity backed by a specific intent is resizable.
+ */
+fun interface ResizabilityChecker {
+
+ /**
+ * Returns whether the provided intent represents a resizable activity.
+ *
+ * @param intent the intent to check
+ * @param packageManager the package manager to use to do the look up
+ * @param key a key representing thing being checked (used for error logging)
+ */
+ fun isResizableActivity(intent: Intent?, packageManager: PackageManager, key: String): Boolean
+}
\ No newline at end of file
diff --git a/wmshell/src/com/android/wm/shell/bubbles/StackEducationView.kt b/wmshell/src/com/android/wm/shell/bubbles/StackEducationView.kt
index c4108c4129..9ac059890d 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/StackEducationView.kt
+++ b/wmshell/src/com/android/wm/shell/bubbles/StackEducationView.kt
@@ -26,7 +26,8 @@ import android.widget.LinearLayout
import android.widget.TextView
import com.android.internal.util.ContrastColorUtil
import com.android.wm.shell.R
-import com.android.wm.shell.animation.Interpolators
+import com.android.wm.shell.shared.TypefaceUtils
+import com.android.wm.shell.shared.animation.Interpolators
/**
* User education view to highlight the collapsed stack of bubbles. Shown only the first time a user
@@ -59,6 +60,9 @@ class StackEducationView(
init {
LayoutInflater.from(context).inflate(R.layout.bubble_stack_user_education, this)
+ TypefaceUtils.setTypeface(titleTextView,
+ TypefaceUtils.FontFamily.GSF_HEADLINE_SMALL_EMPHASIZED)
+ TypefaceUtils.setTypeface(descTextView, TypefaceUtils.FontFamily.GSF_BODY_MEDIUM)
visibility = View.GONE
elevation = resources.getDimensionPixelSize(R.dimen.bubble_elevation).toFloat()
diff --git a/wmshell/src/com/android/wm/shell/bubbles/animation/AnimatableScaleMatrix.java b/wmshell/src/com/android/wm/shell/bubbles/animation/AnimatableScaleMatrix.java
index 2612b81aae..e577c3e0b1 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/animation/AnimatableScaleMatrix.java
+++ b/wmshell/src/com/android/wm/shell/bubbles/animation/AnimatableScaleMatrix.java
@@ -141,4 +141,10 @@ public class AnimatableScaleMatrix extends Matrix {
// PhysicsAnimator's animator caching).
return obj == this;
}
+
+ @Override
+ public int hashCode() {
+ // Make sure equals and hashCode work in a similar way. Rely on object identity for both.
+ return System.identityHashCode(this);
+ }
}
diff --git a/wmshell/src/com/android/wm/shell/bubbles/animation/ExpandedAnimationController.java b/wmshell/src/com/android/wm/shell/bubbles/animation/ExpandedAnimationController.java
index 44ddfe2da8..ffdd71cde9 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/animation/ExpandedAnimationController.java
+++ b/wmshell/src/com/android/wm/shell/bubbles/animation/ExpandedAnimationController.java
@@ -33,13 +33,13 @@ import androidx.dynamicanimation.animation.DynamicAnimation;
import androidx.dynamicanimation.animation.SpringForce;
import com.android.wm.shell.R;
-import com.android.wm.shell.animation.Interpolators;
import com.android.wm.shell.bubbles.BadgedImageView;
import com.android.wm.shell.bubbles.BubbleOverflow;
import com.android.wm.shell.bubbles.BubblePositioner;
import com.android.wm.shell.bubbles.BubbleStackView;
-import com.android.wm.shell.shared.magnetictarget.MagnetizedObject;
+import com.android.wm.shell.shared.animation.Interpolators;
import com.android.wm.shell.shared.animation.PhysicsAnimator;
+import com.android.wm.shell.shared.magnetictarget.MagnetizedObject;
import com.google.android.collect.Sets;
@@ -354,6 +354,7 @@ public class ExpandedAnimationController
View bubble,
MagnetizedObject.MagneticTarget target,
MagnetizedObject.MagnetListener listener) {
+ if (mLayout == null) return;
mLayout.cancelAnimationsOnView(bubble);
mMagnetizedBubbleDraggingOut = new MagnetizedObject(
diff --git a/wmshell/src/com/android/wm/shell/bubbles/animation/ExpandedViewAnimationControllerImpl.java b/wmshell/src/com/android/wm/shell/bubbles/animation/ExpandedViewAnimationControllerImpl.java
index aa4129a14d..7cb537a24c 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/animation/ExpandedViewAnimationControllerImpl.java
+++ b/wmshell/src/com/android/wm/shell/bubbles/animation/ExpandedViewAnimationControllerImpl.java
@@ -38,11 +38,11 @@ import androidx.dynamicanimation.animation.FloatPropertyCompat;
import androidx.dynamicanimation.animation.SpringAnimation;
import androidx.dynamicanimation.animation.SpringForce;
-import com.android.internal.protolog.common.ProtoLog;
+import com.android.internal.protolog.ProtoLog;
import com.android.wm.shell.animation.FlingAnimationUtils;
-import com.android.wm.shell.animation.Interpolators;
import com.android.wm.shell.bubbles.BubbleExpandedView;
import com.android.wm.shell.bubbles.BubblePositioner;
+import com.android.wm.shell.shared.animation.Interpolators;
import java.util.ArrayList;
import java.util.List;
diff --git a/wmshell/src/com/android/wm/shell/bubbles/animation/StackAnimationController.java b/wmshell/src/com/android/wm/shell/bubbles/animation/StackAnimationController.java
index 2bf8acb2e8..91585dc425 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/animation/StackAnimationController.java
+++ b/wmshell/src/com/android/wm/shell/bubbles/animation/StackAnimationController.java
@@ -42,8 +42,8 @@ import com.android.wm.shell.bubbles.BadgedImageView;
import com.android.wm.shell.bubbles.BubblePositioner;
import com.android.wm.shell.bubbles.BubbleStackView;
import com.android.wm.shell.common.FloatingContentCoordinator;
-import com.android.wm.shell.shared.magnetictarget.MagnetizedObject;
import com.android.wm.shell.shared.animation.PhysicsAnimator;
+import com.android.wm.shell.shared.magnetictarget.MagnetizedObject;
import com.google.android.collect.Sets;
diff --git a/wmshell/src/com/android/wm/shell/bubbles/appinfo/BubbleAppInfoProvider.kt b/wmshell/src/com/android/wm/shell/bubbles/appinfo/BubbleAppInfoProvider.kt
new file mode 100644
index 0000000000..c1607a8cc9
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/bubbles/appinfo/BubbleAppInfoProvider.kt
@@ -0,0 +1,30 @@
+/*
+ * 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.bubbles.appinfo
+
+import android.content.Context
+import android.graphics.drawable.Drawable
+import com.android.wm.shell.bubbles.Bubble
+
+/** Resolves app info for bubbles. */
+fun interface BubbleAppInfoProvider {
+ /** Resolves app info for the bubble. Returns `null` if the app could not be resolved. */
+ fun resolveAppInfo(context: Context, bubble: Bubble): BubbleAppInfo?
+}
+
+/** Data object for the resolved app info. */
+data class BubbleAppInfo(val appName: String?, val appIcon: Drawable, val badgedIcon: Drawable)
diff --git a/wmshell/src/com/android/wm/shell/bubbles/appinfo/PackageManagerBubbleAppInfoProvider.kt b/wmshell/src/com/android/wm/shell/bubbles/appinfo/PackageManagerBubbleAppInfoProvider.kt
new file mode 100644
index 0000000000..ea0371c956
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/bubbles/appinfo/PackageManagerBubbleAppInfoProvider.kt
@@ -0,0 +1,61 @@
+/*
+ * 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.bubbles.appinfo
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.util.Log
+import com.android.wm.shell.bubbles.Bubble
+import com.android.wm.shell.bubbles.BubbleController
+import javax.inject.Inject
+
+/**
+ * A concrete implementation of [BubbleAppInfoProvider] that uses [PackageManager] to resolve app
+ * info.
+ */
+class PackageManagerBubbleAppInfoProvider @Inject constructor() : BubbleAppInfoProvider {
+
+ private companion object {
+ const val TAG = "PackageManagerBubbleAppInfoProvider"
+ }
+
+ override fun resolveAppInfo(context: Context, bubble: Bubble): BubbleAppInfo? {
+ // App name & app icon
+ val pm = BubbleController.getPackageManagerForUser(context, bubble.user.identifier)
+ try {
+ val appInfo = pm.getApplicationInfo(
+ bubble.packageName,
+ (PackageManager.MATCH_UNINSTALLED_PACKAGES
+ or PackageManager.MATCH_DISABLED_COMPONENTS
+ or PackageManager.MATCH_DIRECT_BOOT_UNAWARE
+ or PackageManager.MATCH_DIRECT_BOOT_AWARE)
+ )
+ val appName = if (appInfo != null) pm.getApplicationLabel(appInfo)?.toString() else null
+ val appIcon = pm.getApplicationIcon(bubble.packageName)
+ val badgedIcon = pm.getUserBadgedIcon(appIcon, bubble.user)
+ return BubbleAppInfo(
+ appName = appName,
+ appIcon = appIcon,
+ badgedIcon = badgedIcon
+ )
+ } catch (exception: PackageManager.NameNotFoundException) {
+ // If we can't find package... don't think we should show the bubble.
+ Log.w(TAG, "Unable to find package: ${bubble.packageName}")
+ return null
+ }
+ }
+}
diff --git a/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleBarAnimationHelper.java b/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleBarAnimationHelper.java
index e909e754de..e9887b1a36 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleBarAnimationHelper.java
+++ b/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleBarAnimationHelper.java
@@ -16,6 +16,7 @@
package com.android.wm.shell.bubbles.bar;
import static android.view.View.ALPHA;
+import static android.view.View.INVISIBLE;
import static android.view.View.SCALE_X;
import static android.view.View.SCALE_Y;
import static android.view.View.TRANSLATION_X;
@@ -24,31 +25,38 @@ import static android.view.View.VISIBLE;
import static android.view.View.X;
import static android.view.View.Y;
-import static com.android.wm.shell.animation.Interpolators.EMPHASIZED;
-import static com.android.wm.shell.animation.Interpolators.EMPHASIZED_DECELERATE;
import static com.android.wm.shell.bubbles.bar.BubbleBarExpandedView.CORNER_RADIUS;
+import static com.android.wm.shell.bubbles.bar.BubbleBarExpandedView.TASK_VIEW_ALPHA;
+import static com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_BUBBLES_NOISY;
+import static com.android.wm.shell.shared.animation.Interpolators.EMPHASIZED;
+import static com.android.wm.shell.shared.animation.Interpolators.EMPHASIZED_DECELERATE;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
+import android.annotation.NonNull;
import android.content.Context;
-import android.graphics.Point;
import android.graphics.Rect;
import android.util.Log;
-import android.util.Size;
+import android.view.SurfaceControl;
import android.widget.FrameLayout;
import androidx.annotation.Nullable;
+import androidx.dynamicanimation.animation.FloatPropertyCompat;
-import com.android.wm.shell.animation.Interpolators;
+import com.android.app.animation.Interpolators;
+import com.android.internal.protolog.ProtoLog;
+import com.android.wm.shell.R;
+import com.android.wm.shell.animation.SizeChangeAnimation;
+import com.android.wm.shell.bubbles.Bubble;
import com.android.wm.shell.bubbles.BubbleOverflow;
import com.android.wm.shell.bubbles.BubblePositioner;
import com.android.wm.shell.bubbles.BubbleViewProvider;
import com.android.wm.shell.bubbles.animation.AnimatableScaleMatrix;
-import com.android.wm.shell.shared.magnetictarget.MagnetizedObject.MagneticTarget;
import com.android.wm.shell.shared.animation.PhysicsAnimator;
+import com.android.wm.shell.shared.magnetictarget.MagnetizedObject.MagneticTarget;
/**
* Helper class to animate a {@link BubbleBarExpandedView} on a bubble.
@@ -59,7 +67,7 @@ public class BubbleBarAnimationHelper {
private static final float EXPANDED_VIEW_ANIMATE_SCALE_AMOUNT = 0.1f;
private static final float EXPANDED_VIEW_ANIMATE_OUT_SCALE_AMOUNT = .75f;
- private static final int EXPANDED_VIEW_ALPHA_ANIMATION_DURATION = 150;
+ private static final int EXPANDED_VIEW_EXPAND_ALPHA_DURATION = 150;
private static final int EXPANDED_VIEW_SNAP_TO_DISMISS_DURATION = 400;
private static final int EXPANDED_VIEW_ANIMATE_TO_REST_DURATION = 400;
private static final int EXPANDED_VIEW_DISMISS_DURATION = 250;
@@ -72,6 +80,17 @@ public class BubbleBarAnimationHelper {
private static final float DISMISS_VIEW_SCALE = 1.25f;
private static final int HANDLE_ALPHA_ANIMATION_DURATION = 100;
+ private static final float SWITCH_OUT_SCALE = 0.97f;
+ private static final long SWITCH_OUT_SCALE_DURATION = 200L;
+ private static final long SWITCH_OUT_ALPHA_DURATION = 100L;
+ private static final long SWITCH_OUT_HANDLE_ALPHA_DURATION = 50L;
+ private static final long SWITCH_IN_ANIM_DELAY = 50L;
+ private static final long SWITCH_IN_TX_DURATION = 350L;
+ private static final long SWITCH_IN_ALPHA_DURATION = 50L;
+ // Keep this handle alpha delay at least as long as alpha animation for both expanded views.
+ private static final long SWITCH_IN_HANDLE_ALPHA_DELAY = 150L;
+ private static final long SWITCH_IN_HANDLE_ALPHA_DURATION = 100L;
+
/** Spring config for the expanded view scale-in animation. */
private final PhysicsAnimator.SpringConfig mScaleInSpringConfig =
new PhysicsAnimator.SpringConfig(300f, 0.9f);
@@ -80,128 +99,111 @@ public class BubbleBarAnimationHelper {
private final PhysicsAnimator.SpringConfig mScaleOutSpringConfig =
new PhysicsAnimator.SpringConfig(900f, 1f);
+ private final int mSwitchAnimPositionOffset;
+
/** Matrix used to scale the expanded view container with a given pivot point. */
private final AnimatableScaleMatrix mExpandedViewContainerMatrix = new AnimatableScaleMatrix();
- /** Animator for animating the expanded view's alpha (including the TaskView inside it). */
- private final ValueAnimator mExpandedViewAlphaAnimator = ValueAnimator.ofFloat(0f, 1f);
-
@Nullable
- private Animator mRunningDragAnimator;
+ private Animator mRunningAnimator;
- private final Context mContext;
- private final BubbleBarLayerView mLayerView;
private final BubblePositioner mPositioner;
private final int[] mTmpLocation = new int[2];
+ // TODO(b/381936992): remove expanded bubble state from this helper class
private BubbleViewProvider mExpandedBubble;
- private boolean mIsExpanded = false;
- public BubbleBarAnimationHelper(Context context,
- BubbleBarLayerView bubbleBarLayerView,
- BubblePositioner positioner) {
- mContext = context;
- mLayerView = bubbleBarLayerView;
+ public BubbleBarAnimationHelper(Context context, BubblePositioner positioner) {
mPositioner = positioner;
-
- mExpandedViewAlphaAnimator.setDuration(EXPANDED_VIEW_ALPHA_ANIMATION_DURATION);
- mExpandedViewAlphaAnimator.setInterpolator(Interpolators.PANEL_CLOSE_ACCELERATED);
- mExpandedViewAlphaAnimator.addListener(new AnimatorListenerAdapter() {
- @Override
- public void onAnimationStart(Animator animation) {
- BubbleBarExpandedView bbev = getExpandedView();
- if (bbev != null) {
- // We need to be Z ordered on top in order for alpha animations to work.
- bbev.setSurfaceZOrderedOnTop(true);
- bbev.setAnimating(true);
- }
- }
-
- @Override
- public void onAnimationEnd(Animator animation) {
- BubbleBarExpandedView bbev = getExpandedView();
- if (bbev != null) {
- // The surface needs to be Z ordered on top for alpha values to work on the
- // TaskView, and if we're temporarily hidden, we are still on the screen
- // with alpha = 0f until we animate back. Stay Z ordered on top so the alpha
- // = 0f remains in effect.
- if (mIsExpanded) {
- bbev.setSurfaceZOrderedOnTop(false);
- }
-
- bbev.setContentVisibility(mIsExpanded);
- bbev.setAnimating(false);
- }
- }
- });
- mExpandedViewAlphaAnimator.addUpdateListener(valueAnimator -> {
- BubbleBarExpandedView bbev = getExpandedView();
- if (bbev != null) {
- float alpha = (float) valueAnimator.getAnimatedValue();
- bbev.setTaskViewAlpha(alpha);
- bbev.setAlpha(alpha);
- }
- });
+ mSwitchAnimPositionOffset = context.getResources().getDimensionPixelSize(
+ R.dimen.bubble_bar_expanded_view_switch_offset);
}
/**
* Animates the provided bubble's expanded view to the expanded state.
+ *
+ * @param endRunnable a runnable to run at the end of the animation (even if the animation is
+ * canceled)
*/
public void animateExpansion(BubbleViewProvider expandedBubble,
- @Nullable Runnable afterAnimation) {
+ @Nullable Runnable endRunnable) {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "BBAnimationHelper.animateExpansion()");
mExpandedBubble = expandedBubble;
final BubbleBarExpandedView bbev = getExpandedView();
if (bbev == null) {
return;
}
- mIsExpanded = true;
mExpandedViewContainerMatrix.setScaleX(0f);
mExpandedViewContainerMatrix.setScaleY(0f);
- updateExpandedView();
- bbev.setAnimating(true);
- bbev.setContentVisibility(false);
- bbev.setAlpha(0f);
- bbev.setTaskViewAlpha(0f);
- bbev.setVisibility(VISIBLE);
+ prepareForAnimateIn(bbev);
setScaleFromBubbleBar(mExpandedViewContainerMatrix,
1f - EXPANDED_VIEW_ANIMATE_SCALE_AMOUNT);
bbev.setAnimationMatrix(mExpandedViewContainerMatrix);
- mExpandedViewAlphaAnimator.start();
+ bbev.animateExpansionWhenTaskViewVisible(() -> {
+ bbev.getHandleView().setAlpha(1);
+ ObjectAnimator alphaAnim = createAlphaAnimator(bbev, /* visible= */ true);
+ alphaAnim.setDuration(EXPANDED_VIEW_EXPAND_ALPHA_DURATION);
+ alphaAnim.setInterpolator(Interpolators.PANEL_CLOSE_ACCELERATED);
+ alphaAnim.addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ bbev.setAnimating(false);
+ }
+ });
+ startNewAnimator(alphaAnim);
- PhysicsAnimator.getInstance(mExpandedViewContainerMatrix).cancel();
- PhysicsAnimator.getInstance(mExpandedViewContainerMatrix)
- .spring(AnimatableScaleMatrix.SCALE_X,
- AnimatableScaleMatrix.getAnimatableValueForScaleFactor(1f),
- mScaleInSpringConfig)
- .spring(AnimatableScaleMatrix.SCALE_Y,
- AnimatableScaleMatrix.getAnimatableValueForScaleFactor(1f),
- mScaleInSpringConfig)
- .addUpdateListener((target, values) -> {
- bbev.setAnimationMatrix(mExpandedViewContainerMatrix);
- })
- .withEndActions(() -> {
- bbev.setAnimationMatrix(null);
- updateExpandedView();
- bbev.setSurfaceZOrderedOnTop(false);
- if (afterAnimation != null) {
- afterAnimation.run();
- }
- })
- .start();
+ PhysicsAnimator.getInstance(mExpandedViewContainerMatrix).cancel();
+ PhysicsAnimator.getInstance(mExpandedViewContainerMatrix)
+ .spring(AnimatableScaleMatrix.SCALE_X,
+ AnimatableScaleMatrix.getAnimatableValueForScaleFactor(1f),
+ mScaleInSpringConfig)
+ .spring(AnimatableScaleMatrix.SCALE_Y,
+ AnimatableScaleMatrix.getAnimatableValueForScaleFactor(1f),
+ mScaleInSpringConfig)
+ .addUpdateListener((target, values) -> {
+ bbev.setAnimationMatrix(mExpandedViewContainerMatrix);
+ })
+ .withEndActions(() -> {
+ bbev.setAnimationMatrix(null);
+ updateExpandedView(bbev);
+ })
+ .withEndOrCancelActions(() -> {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY,
+ "BBAnimationHelper.animateExpansion(): finished");
+ if (endRunnable != null) {
+ endRunnable.run();
+ }
+ })
+ .start();
+ });
+ }
+
+ private void prepareForAnimateIn(BubbleBarExpandedView bbev) {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "BBAnimationHelper.prepareForAnimateIn()");
+ bbev.setAnimating(true);
+ updateExpandedView(bbev);
+ // We need to be Z ordered on top in order for taskView alpha to work.
+ // It is also set when the alpha animation starts, but needs to be set here to too avoid
+ // flickers.
+ bbev.setSurfaceZOrderedOnTop(true);
+ bbev.setTaskViewAlpha(0f);
+ bbev.setContentVisibility(false);
+ bbev.setVisibility(VISIBLE);
}
/**
* Collapses the currently expanded bubble.
*
- * @param endRunnable a runnable to run at the end of the animation.
+ * @param endRunnable a runnable to run at the end of the animation (even if the animation is
+ * canceled)
*/
public void animateCollapse(Runnable endRunnable) {
- mIsExpanded = false;
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "BBAnimationHelper.animateCollapse()");
final BubbleBarExpandedView bbev = getExpandedView();
if (bbev == null) {
Log.w(TAG, "Trying to animate collapse without a bubble");
@@ -212,6 +214,19 @@ public class BubbleBarAnimationHelper {
setScaleFromBubbleBar(mExpandedViewContainerMatrix, 1f);
+ bbev.setAnimating(true);
+
+ ObjectAnimator alphaAnim = createAlphaAnimator(bbev, /* visible= */ false);
+ alphaAnim.setDuration(EXPANDED_VIEW_EXPAND_ALPHA_DURATION);
+ alphaAnim.setInterpolator(Interpolators.PANEL_CLOSE_ACCELERATED);
+ alphaAnim.addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ bbev.setAnimating(false);
+ }
+ });
+ startNewAnimator(alphaAnim);
+
PhysicsAnimator.getInstance(mExpandedViewContainerMatrix).cancel();
PhysicsAnimator.getInstance(mExpandedViewContainerMatrix)
.spring(AnimatableScaleMatrix.SCALE_X,
@@ -227,12 +242,16 @@ public class BubbleBarAnimationHelper {
})
.withEndActions(() -> {
bbev.setAnimationMatrix(null);
+ bbev.resetBottomClip();
+ })
+ .withEndOrCancelActions(() -> {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY,
+ "BBAnimationHelper.animateCollapse(): finished");
if (endRunnable != null) {
endRunnable.run();
}
})
.start();
- mExpandedViewAlphaAnimator.reverse();
}
private void setScaleFromBubbleBar(AnimatableScaleMatrix matrix, float scale) {
@@ -243,16 +262,158 @@ public class BubbleBarAnimationHelper {
matrix.setScale(scale, scale, pivotX, pivotY);
}
+ /**
+ * Animate between two bubble views using a switch animation
+ *
+ * @param fromBubble bubble to hide
+ * @param toBubble bubble to show
+ * @param endRunnable optional runnable after animation finishes (even if the animation is
+ * canceled)
+ */
+ public void animateSwitch(BubbleViewProvider fromBubble, BubbleViewProvider toBubble,
+ @Nullable Runnable endRunnable) {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "BBAnimationHelper.animateSwitch(): from=%s to=%s",
+ fromBubble.getKey(), toBubble.getKey());
+ /*
+ * Switch animation
+ *
+ * |.....................fromBubble scale to 0.97.....................|
+ * |fromBubble handle alpha 0|....fromBubble alpha to 0.....| |
+ * 0-------------------------50-----------------------100---150--------200----------250--400
+ * |..toBubble alpha to 1...| |toBubble handle alpha 1| |
+ * |................toBubble position +/-48 to 0...............|
+ */
+
+ mExpandedBubble = toBubble;
+ final BubbleBarExpandedView toBbev = toBubble.getBubbleBarExpandedView();
+ final BubbleBarExpandedView fromBbev = fromBubble.getBubbleBarExpandedView();
+ if (toBbev == null || fromBbev == null) {
+ return;
+ }
+
+ fromBbev.setAnimating(true);
+
+ prepareForAnimateIn(toBbev);
+ final float endTx = toBbev.getTranslationX();
+ final float startTx = getSwitchAnimationInitialTx(endTx);
+ toBbev.setTranslationX(startTx);
+ toBbev.getHandleView().setAlpha(0f);
+ toBbev.getHandleView().setHandleInitialColor(fromBbev.getHandleView().getHandleColor());
+
+ toBbev.animateExpansionWhenTaskViewVisible(() -> {
+ AnimatorSet switchAnim = new AnimatorSet();
+ switchAnim.playTogether(switchOutAnimator(fromBbev), switchInAnimator(toBbev, endTx));
+ switchAnim.addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY,
+ "BBAnimationHelper.animateSwitch(): finished");
+ if (endRunnable != null) {
+ endRunnable.run();
+ }
+ }
+ });
+ startNewAnimator(switchAnim);
+ });
+ }
+
+ private float getSwitchAnimationInitialTx(float endTx) {
+ if (mPositioner.isBubbleBarOnLeft()) {
+ return endTx - mSwitchAnimPositionOffset;
+ } else {
+ return endTx + mSwitchAnimPositionOffset;
+ }
+ }
+
+ private Animator switchOutAnimator(BubbleBarExpandedView bbev) {
+ setPivotToCenter(bbev);
+ AnimatorSet scaleAnim = new AnimatorSet();
+ scaleAnim.playTogether(
+ ObjectAnimator.ofFloat(bbev, SCALE_X, SWITCH_OUT_SCALE),
+ ObjectAnimator.ofFloat(bbev, SCALE_Y, SWITCH_OUT_SCALE)
+ );
+ scaleAnim.setInterpolator(Interpolators.ACCELERATE);
+ scaleAnim.setDuration(SWITCH_OUT_SCALE_DURATION);
+
+ ObjectAnimator alphaAnim = createAlphaAnimator(bbev, /* visible= */ false);
+ alphaAnim.setStartDelay(SWITCH_OUT_HANDLE_ALPHA_DURATION);
+ alphaAnim.setDuration(SWITCH_OUT_ALPHA_DURATION);
+
+ ObjectAnimator handleAlphaAnim = ObjectAnimator.ofFloat(bbev.getHandleView(), ALPHA, 0f)
+ .setDuration(SWITCH_OUT_HANDLE_ALPHA_DURATION);
+
+ AnimatorSet animator = new AnimatorSet();
+ animator.playTogether(scaleAnim, alphaAnim, handleAlphaAnim);
+
+ animator.addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ bbev.setAnimating(false);
+ }
+ });
+ return animator;
+ }
+
+ private Animator switchInAnimator(BubbleBarExpandedView bbev, float restingTx) {
+ ObjectAnimator positionAnim = ObjectAnimator.ofFloat(bbev, TRANSLATION_X, restingTx);
+ positionAnim.setInterpolator(Interpolators.EMPHASIZED_DECELERATE);
+ positionAnim.setStartDelay(SWITCH_IN_ANIM_DELAY);
+ positionAnim.setDuration(SWITCH_IN_TX_DURATION);
+
+ // Animate alpha directly to have finer control over surface z-ordering
+ ObjectAnimator alphaAnim = ObjectAnimator.ofFloat(bbev, TASK_VIEW_ALPHA, 1f);
+ alphaAnim.setStartDelay(SWITCH_IN_ANIM_DELAY);
+ alphaAnim.setDuration(SWITCH_IN_ALPHA_DURATION);
+ alphaAnim.addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationStart(Animator animation) {
+ bbev.setSurfaceZOrderedOnTop(true);
+ }
+
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ bbev.setContentVisibility(true);
+ // The outgoing expanded view alpha animation is still in progress.
+ // Do not reset the surface z-order as otherwise the outgoing expanded view is
+ // placed on top.
+ }
+ });
+
+ ObjectAnimator handleAlphaAnim = ObjectAnimator.ofFloat(bbev.getHandleView(), ALPHA, 1f);
+ handleAlphaAnim.setStartDelay(SWITCH_IN_HANDLE_ALPHA_DELAY);
+ handleAlphaAnim.setDuration(SWITCH_IN_HANDLE_ALPHA_DURATION);
+ handleAlphaAnim.addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationStart(Animator animation) {
+ bbev.setSurfaceZOrderedOnTop(false);
+ bbev.setAnimating(false);
+ }
+ });
+
+ AnimatorSet animator = new AnimatorSet();
+ animator.playTogether(positionAnim, alphaAnim, handleAlphaAnim);
+
+ animator.addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ updateExpandedView(bbev);
+ }
+ });
+ return animator;
+ }
+
/**
* Animate the expanded bubble when it is being dragged
*/
public void animateStartDrag() {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "BBAnimationHelper.animateStartDrag()");
final BubbleBarExpandedView bbev = getExpandedView();
if (bbev == null) {
Log.w(TAG, "Trying to animate start drag without a bubble");
return;
}
setDragPivot(bbev);
+ bbev.setDragging(true);
// Corner radius gets scaled, apply the reverse scale to ensure we have the desired radius
final float cornerRadius = bbev.getDraggedCornerRadius() / EXPANDED_VIEW_DRAG_SCALE;
@@ -270,16 +431,17 @@ public class BubbleBarAnimationHelper {
AnimatorSet animatorSet = new AnimatorSet();
animatorSet.playTogether(contentAnim, handleAnim);
animatorSet.addListener(new DragAnimatorListenerAdapter(bbev));
- startNewDragAnimation(animatorSet);
+ startNewAnimator(animatorSet);
}
/**
* Animates dismissal of currently expanded bubble
*
- * @param endRunnable a runnable to run at the end of the animation
+ * @param endRunnable a runnable to run at the end of the animation (even if the animation is
+ * canceled)
*/
public void animateDismiss(Runnable endRunnable) {
- mIsExpanded = false;
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "BBAnimationHelper.animateDismiss()");
final BubbleBarExpandedView bbev = getExpandedView();
if (bbev == null) {
Log.w(TAG, "Trying to animate dismiss without a bubble");
@@ -289,30 +451,40 @@ public class BubbleBarAnimationHelper {
int[] location = bbev.getLocationOnScreen();
int diffFromBottom = mPositioner.getScreenRect().bottom - location[1];
- cancelAnimations();
- bbev.animate()
- // 2x distance from bottom so the view flies out
- .translationYBy(diffFromBottom * 2)
- .setDuration(EXPANDED_VIEW_DISMISS_DURATION)
- .withEndAction(endRunnable)
- .start();
+ ObjectAnimator animator = ObjectAnimator.ofFloat(
+ bbev, TRANSLATION_Y, bbev.getTranslationY() + (diffFromBottom * 2));
+ animator.setDuration(EXPANDED_VIEW_DISMISS_DURATION);
+ animator.addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "BBAnimationHelper.animateDismiss(): finished");
+ if (endRunnable != null) {
+ endRunnable.run();
+ }
+ }
+ });
+ startNewAnimator(animator);
}
/**
* Animate current expanded bubble back to its rest position
*/
public void animateToRestPosition() {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "BBAnimationHelper.animateToRestPosition()");
BubbleBarExpandedView bbev = getExpandedView();
if (bbev == null) {
Log.w(TAG, "Trying to animate expanded view to rest position without a bubble");
return;
}
- Point restPoint = getExpandedViewRestPosition(getExpandedViewSize());
+ final boolean isOverflow = mExpandedBubble.getKey().equals(BubbleOverflow.KEY);
+ final Rect rect = new Rect();
+ mPositioner.getBubbleBarExpandedViewBounds(mPositioner.isBubbleBarOnLeft(),
+ isOverflow, rect);
AnimatorSet contentAnim = new AnimatorSet();
contentAnim.playTogether(
- ObjectAnimator.ofFloat(bbev, X, restPoint.x),
- ObjectAnimator.ofFloat(bbev, Y, restPoint.y),
+ ObjectAnimator.ofFloat(bbev, X, rect.left),
+ ObjectAnimator.ofFloat(bbev, Y, rect.top),
ObjectAnimator.ofFloat(bbev, SCALE_X, 1f),
ObjectAnimator.ofFloat(bbev, SCALE_Y, 1f),
ObjectAnimator.ofFloat(bbev, CORNER_RADIUS, bbev.getRestingCornerRadius())
@@ -329,18 +501,22 @@ public class BubbleBarAnimationHelper {
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
bbev.resetPivot();
+ bbev.setDragging(false);
+ updateExpandedView(bbev);
}
});
- startNewDragAnimation(animatorSet);
+ startNewAnimator(animatorSet);
}
/**
* Animates currently expanded bubble into the given {@link MagneticTarget}.
*
* @param target magnetic target to snap to
- * @param endRunnable a runnable to run at the end of the animation
+ * @param endRunnable a runnable to run at the end of the animation (even if the animation is
+ * canceled)
*/
public void animateIntoTarget(MagneticTarget target, @Nullable Runnable endRunnable) {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "BBAnimationHelper.animateIntoTarget()");
BubbleBarExpandedView bbev = getExpandedView();
if (bbev == null) {
Log.w(TAG, "Trying to snap the expanded view to target without a bubble");
@@ -399,19 +575,22 @@ public class BubbleBarAnimationHelper {
animatorSet.addListener(new DragAnimatorListenerAdapter(bbev) {
@Override
public void onAnimationEnd(Animator animation) {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY,
+ "BBAnimationHelper.animateIntoTarget(): finished");
super.onAnimationEnd(animation);
if (endRunnable != null) {
endRunnable.run();
}
}
});
- startNewDragAnimation(animatorSet);
+ startNewAnimator(animatorSet);
}
/**
* Animate currently expanded view when it is released from dismiss view
*/
public void animateUnstuckFromDismissView(MagneticTarget target) {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "BBAnimationHelper.animateUnstuckFromDismissView()");
BubbleBarExpandedView bbev = getExpandedView();
if (bbev == null) {
Log.w(TAG, "Trying to unsnap the expanded view from dismiss without a bubble");
@@ -431,23 +610,87 @@ public class BubbleBarAnimationHelper {
animatorSet.setDuration(EXPANDED_VIEW_SNAP_TO_DISMISS_DURATION).setInterpolator(
EMPHASIZED_DECELERATE);
animatorSet.addListener(new DragAnimatorListenerAdapter(bbev));
- startNewDragAnimation(animatorSet);
+ startNewAnimator(animatorSet);
+ }
+
+ /**
+ * Animates converting of a non-bubble task into an expanded bubble view.
+ *
+ * @param endRunnable a runnable to run at the end of the animation (even if the animation is
+ * canceled)
+ */
+ public void animateConvert(BubbleViewProvider expandedBubble,
+ @NonNull SurfaceControl.Transaction startT,
+ @NonNull Rect origBounds,
+ float origScale,
+ @NonNull SurfaceControl snapshot,
+ @NonNull SurfaceControl taskLeash,
+ @Nullable Runnable endRunnable) {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "BBAnimationHelper.animateConvert()");
+ mExpandedBubble = expandedBubble;
+ final BubbleBarExpandedView bbev = getExpandedView();
+ if (bbev == null) {
+ return;
+ }
+
+ bbev.setTaskViewAlpha(1f);
+ SurfaceControl tvSf = ((Bubble) mExpandedBubble).getTaskView().getSurfaceControl();
+
+ final boolean isOverflow = mExpandedBubble.getKey().equals(BubbleOverflow.KEY);
+ final Rect restBounds = new Rect();
+ mPositioner.getBubbleBarExpandedViewBounds(mPositioner.isBubbleBarOnLeft(),
+ isOverflow, restBounds);
+
+ Rect startBounds = new Rect(origBounds.left - restBounds.left,
+ origBounds.top - restBounds.top,
+ origBounds.right - restBounds.left,
+ origBounds.bottom - restBounds.top);
+ Rect endBounds = new Rect(0, 0, restBounds.width(), restBounds.height());
+ final SizeChangeAnimation sca = new SizeChangeAnimation(startBounds, endBounds,
+ origScale, /* scaleFactor= */ 1f);
+ sca.initialize(bbev, taskLeash, snapshot, startT);
+
+ Animator a = sca.buildViewAnimator(bbev, tvSf, snapshot, /* onFinish */ (va) -> {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "BBAnimationHelper.animateConvert(): finished");
+ updateExpandedView(bbev);
+ snapshot.release();
+ bbev.setSurfaceZOrderedOnTop(false);
+ bbev.setAnimating(false);
+ if (endRunnable != null) {
+ endRunnable.run();
+ }
+ });
+
+ bbev.setSurfaceZOrderedOnTop(true);
+ a.setDuration(EXPANDED_VIEW_ANIMATE_TO_REST_DURATION);
+ a.setInterpolator(EMPHASIZED);
+ a.start();
}
/**
* Cancel current animations
*/
public void cancelAnimations() {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "BBAnimationHelper.cancelAnimations(): "
+ + "hasRunningAnimator=%b",
+ (mRunningAnimator != null && mRunningAnimator.isRunning()));
PhysicsAnimator.getInstance(mExpandedViewContainerMatrix).cancel();
- mExpandedViewAlphaAnimator.cancel();
+ if (mRunningAnimator != null) {
+ if (mRunningAnimator.isRunning()) {
+ mRunningAnimator.cancel();
+ }
+ mRunningAnimator = null;
+ }
+ }
+
+ /** Handles IME position changes. */
+ public void onImeTopChanged(int imeTop) {
BubbleBarExpandedView bbev = getExpandedView();
- if (bbev != null) {
- bbev.animate().cancel();
- }
- if (mRunningDragAnimator != null) {
- mRunningDragAnimator.cancel();
- mRunningDragAnimator = null;
+ if (bbev == null) {
+ Log.w(TAG, "Bubble bar expanded view was null when IME top changed");
+ return;
}
+ bbev.onImeTopChanged(imeTop);
}
private @Nullable BubbleBarExpandedView getExpandedView() {
@@ -458,56 +701,75 @@ public class BubbleBarAnimationHelper {
return null;
}
- private void updateExpandedView() {
- BubbleBarExpandedView bbev = getExpandedView();
+ private void updateExpandedView(BubbleBarExpandedView bbev) {
if (bbev == null) {
Log.w(TAG, "Trying to update the expanded view without a bubble");
return;
}
-
- final Size size = getExpandedViewSize();
- Point position = getExpandedViewRestPosition(size);
+ final boolean isOverflow = mExpandedBubble.getKey().equals(BubbleOverflow.KEY);
+ final Rect rect = new Rect();
+ mPositioner.getBubbleBarExpandedViewBounds(mPositioner.isBubbleBarOnLeft(),
+ isOverflow, rect);
FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) bbev.getLayoutParams();
- lp.width = size.getWidth();
- lp.height = size.getHeight();
+ lp.width = rect.width();
+ lp.height = rect.height();
bbev.setLayoutParams(lp);
- bbev.setX(position.x);
- bbev.setY(position.y);
+ bbev.setX(rect.left);
+ bbev.setY(rect.top);
+ bbev.setScaleX(1f);
+ bbev.setScaleY(1f);
bbev.updateLocation();
bbev.maybeShowOverflow();
}
- private Point getExpandedViewRestPosition(Size size) {
- final int padding = mPositioner.getBubbleBarExpandedViewPadding();
- Point point = new Point();
- if (mPositioner.isBubbleBarOnLeft()) {
- point.x = mPositioner.getInsets().left + padding;
- } else {
- point.x = mPositioner.getAvailableRect().width() - size.getWidth() - padding;
- }
- point.y = mPositioner.getExpandedViewBottomForBubbleBar() - size.getHeight();
- return point;
- }
-
- private Size getExpandedViewSize() {
- boolean isOverflowExpanded = mExpandedBubble.getKey().equals(BubbleOverflow.KEY);
- final int width = mPositioner.getExpandedViewWidthForBubbleBar(isOverflowExpanded);
- final int height = mPositioner.getExpandedViewHeightForBubbleBar(isOverflowExpanded);
- return new Size(width, height);
- }
-
- private void startNewDragAnimation(Animator animator) {
+ private void startNewAnimator(Animator animator) {
cancelAnimations();
- mRunningDragAnimator = animator;
+ mRunningAnimator = animator;
animator.start();
}
+ /**
+ * Animate the alpha of the expanded view between visible (1) and invisible (0).
+ * {@link BubbleBarExpandedView} requires
+ * {@link com.android.wm.shell.bubbles.BubbleExpandedView#setSurfaceZOrderedOnTop(boolean)} to
+ * be called before alpha can be applied.
+ * Only supports alpha of 1 or 0. Otherwise we can't reset surface z-order at the end.
+ */
+ private ObjectAnimator createAlphaAnimator(BubbleBarExpandedView bubbleBarExpandedView,
+ boolean visible) {
+ ObjectAnimator animator = ObjectAnimator.ofFloat(bubbleBarExpandedView, TASK_VIEW_ALPHA,
+ visible ? 1f : 0f);
+ animator.addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationStart(Animator animation) {
+ // Move task view to the top of the window so alpha can be applied to it
+ bubbleBarExpandedView.setSurfaceZOrderedOnTop(true);
+ }
+
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ bubbleBarExpandedView.setContentVisibility(visible);
+ if (!visible) {
+ // Hide the expanded view before we reset the z-ordering
+ bubbleBarExpandedView.setVisibility(INVISIBLE);
+ }
+ bubbleBarExpandedView.setSurfaceZOrderedOnTop(false);
+ }
+ });
+ return animator;
+ }
+
private static void setDragPivot(BubbleBarExpandedView bbev) {
bbev.setPivotX(bbev.getWidth() / 2f);
bbev.setPivotY(0f);
}
- private class DragAnimatorListenerAdapter extends AnimatorListenerAdapter {
+ private static void setPivotToCenter(BubbleBarExpandedView bbev) {
+ bbev.setPivotX(bbev.getWidth() / 2f);
+ bbev.setPivotY(bbev.getHeight() / 2f);
+ }
+
+ private static class DragAnimatorListenerAdapter extends AnimatorListenerAdapter {
private final BubbleBarExpandedView mBubbleBarExpandedView;
@@ -519,11 +781,9 @@ public class BubbleBarAnimationHelper {
public void onAnimationStart(Animator animation) {
mBubbleBarExpandedView.setAnimating(true);
}
-
@Override
public void onAnimationEnd(Animator animation) {
mBubbleBarExpandedView.setAnimating(false);
- mRunningDragAnimator = null;
}
}
}
diff --git a/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleBarExpandedView.java b/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleBarExpandedView.java
index 972dce51e0..dd19244398 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleBarExpandedView.java
+++ b/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleBarExpandedView.java
@@ -18,36 +18,55 @@ package com.android.wm.shell.bubbles.bar;
import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
+import static com.android.wm.shell.bubbles.util.BubbleUtils.isValidToBubble;
+import static com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_BUBBLES_NOISY;
+
+import static java.lang.Math.max;
+
import android.annotation.Nullable;
import android.app.ActivityManager;
import android.content.Context;
-import android.content.res.TypedArray;
-import android.graphics.Color;
import android.graphics.Insets;
import android.graphics.Outline;
import android.graphics.Rect;
+import android.os.Bundle;
import android.util.AttributeSet;
import android.util.FloatProperty;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewOutlineProvider;
+import android.view.accessibility.AccessibilityNodeInfo;
import android.widget.FrameLayout;
+import android.widget.Toast;
+import androidx.annotation.NonNull;
+import androidx.annotation.VisibleForTesting;
+
+import com.android.internal.protolog.ProtoLog;
import com.android.wm.shell.R;
import com.android.wm.shell.bubbles.Bubble;
import com.android.wm.shell.bubbles.BubbleExpandedViewManager;
+import com.android.wm.shell.bubbles.BubbleLogger;
import com.android.wm.shell.bubbles.BubbleOverflowContainerView;
import com.android.wm.shell.bubbles.BubblePositioner;
import com.android.wm.shell.bubbles.BubbleTaskView;
-import com.android.wm.shell.bubbles.BubbleTaskViewHelper;
+import com.android.wm.shell.bubbles.BubbleTaskViewListener;
import com.android.wm.shell.bubbles.Bubbles;
+import com.android.wm.shell.bubbles.RegionSamplingProvider;
+import com.android.wm.shell.dagger.HasWMComponent;
+import com.android.wm.shell.shared.bubbles.BubbleBarLocation;
+import com.android.wm.shell.shared.handles.RegionSamplingHelper;
import com.android.wm.shell.taskview.TaskView;
+import java.io.PrintWriter;
+import java.util.concurrent.Executor;
import java.util.function.Supplier;
+import javax.inject.Inject;
+
/** Expanded view of a bubble when it's part of the bubble bar. */
-public class BubbleBarExpandedView extends FrameLayout implements BubbleTaskViewHelper.Listener {
+public class BubbleBarExpandedView extends FrameLayout implements BubbleTaskViewListener.Callback {
/**
* The expanded view listener notifying the {@link BubbleBarLayerView} about the internal
* actions and events
@@ -78,24 +97,63 @@ public class BubbleBarExpandedView extends FrameLayout implements BubbleTaskView
}
};
+ /**
+ * Property to set alpha for the task view
+ */
+ public static final FloatProperty TASK_VIEW_ALPHA = new FloatProperty<>(
+ "taskViewAlpha") {
+ @Override
+ public void setValue(BubbleBarExpandedView bbev, float alpha) {
+ bbev.setTaskViewAlpha(alpha);
+ }
+
+ @Override
+ public Float get(BubbleBarExpandedView bbev) {
+ return bbev.mTaskView != null ? bbev.mTaskView.getAlpha() : bbev.getAlpha();
+ }
+ };
+
private static final String TAG = BubbleBarExpandedView.class.getSimpleName();
private static final int INVALID_TASK_ID = -1;
+ private Bubble mBubble;
private BubbleExpandedViewManager mManager;
private BubblePositioner mPositioner;
private boolean mIsOverflow;
- private BubbleTaskViewHelper mBubbleTaskViewHelper;
+ private BubbleTaskViewListener mBubbleTaskViewListener;
private BubbleBarMenuViewController mMenuViewController;
- private @Nullable Supplier mLayerBoundsSupplier;
- private @Nullable Listener mListener;
+ @Nullable
+ private Supplier mLayerBoundsSupplier;
+ @Nullable
+ private Listener mListener;
private BubbleBarHandleView mHandleView;
- private @Nullable TaskView mTaskView;
- private @Nullable BubbleOverflowContainerView mOverflowView;
+ @Nullable
+ private BubbleTaskView mBubbleTaskView;
+ @Nullable
+ private TaskView mTaskView;
+ @Nullable
+ private BubbleOverflowContainerView mOverflowView;
+ /**
+ * The handle shown in the caption area is tinted based on the background color of the area.
+ * This can vary so we sample the caption region and update the handle color based on that.
+ * If we're showing the overflow, the helper and executors will be null.
+ */
+ @Nullable
+ private RegionSamplingHelper mRegionSamplingHelper;
+ @Nullable
+ private RegionSamplingProvider mRegionSamplingProvider;
+ @Nullable
+ private Executor mMainExecutor;
+ @Nullable
+ private Executor mBackgroundExecutor;
+ private final Rect mSampleRect = new Rect();
+ private final int[] mLoc = new int[2];
+ private final Rect mTempBounds = new Rect();
+
+ /** Height of the caption inset at the top of the TaskView */
private int mCaptionHeight;
-
- private int mBackgroundColor;
/** Corner radius used when view is resting */
private float mRestingCornerRadius = 0f;
/** Corner radius applied while dragging */
@@ -103,6 +161,10 @@ public class BubbleBarExpandedView extends FrameLayout implements BubbleTaskView
/** Current corner radius */
private float mCurrentCornerRadius = 0f;
+ /** A runnable to start the expansion animation as soon as the task view is made visible. */
+ @Nullable
+ private Runnable mAnimateExpansion = null;
+
/**
* Whether we want the {@code TaskView}'s content to be visible (alpha = 1f). If
* {@link #mIsAnimating} is true, this may not reflect the {@code TaskView}'s actual alpha
@@ -110,6 +172,17 @@ public class BubbleBarExpandedView extends FrameLayout implements BubbleTaskView
*/
private boolean mIsContentVisible = false;
private boolean mIsAnimating;
+ private boolean mIsDragging;
+
+ private boolean mIsClipping = false;
+ private int mBottomClip = 0;
+ private int mImeTop = 0;
+
+ // Ideally this would be package private, but we have to set this in a fake for test and we
+ // don't yet have dagger set up for tests, so have to set manually
+ @VisibleForTesting
+ @Inject
+ public BubbleLogger bubbleLogger;
public BubbleBarExpandedView(Context context) {
this(context, null);
@@ -132,6 +205,9 @@ public class BubbleBarExpandedView extends FrameLayout implements BubbleTaskView
protected void onFinishInflate() {
super.onFinishInflate();
Context context = getContext();
+ if (context instanceof HasWMComponent) {
+ ((HasWMComponent) context).getWMComponent().inject(this);
+ }
setElevation(getResources().getDimensionPixelSize(R.dimen.bubble_elevation));
mCaptionHeight = context.getResources().getDimensionPixelSize(
R.dimen.bubble_bar_expanded_view_caption_height);
@@ -141,57 +217,69 @@ public class BubbleBarExpandedView extends FrameLayout implements BubbleTaskView
setOutlineProvider(new ViewOutlineProvider() {
@Override
public void getOutline(View view, Outline outline) {
- outline.setRoundRect(0, 0, view.getWidth(), view.getHeight(), mCurrentCornerRadius);
+ outline.setRoundRect(0, 0, view.getWidth(), view.getHeight() - mBottomClip,
+ mCurrentCornerRadius);
}
});
// Set a touch sink to ensure that clicks on the caption area do not propagate to the parent
setOnTouchListener((v, event) -> true);
}
- @Override
- protected void onDetachedFromWindow() {
- super.onDetachedFromWindow();
- // Hide manage menu when view disappears
- mMenuViewController.hideMenu(false /* animated */);
- }
-
/** Initializes the view, must be called before doing anything else. */
public void initialize(BubbleExpandedViewManager expandedViewManager,
BubblePositioner positioner,
boolean isOverflow,
- @Nullable BubbleTaskView bubbleTaskView) {
+ @Nullable Bubble bubble,
+ @Nullable BubbleTaskView bubbleTaskView,
+ @Nullable Executor mainExecutor,
+ @Nullable Executor backgroundExecutor,
+ @Nullable RegionSamplingProvider regionSamplingProvider) {
+ mBubble = bubble;
mManager = expandedViewManager;
mPositioner = positioner;
mIsOverflow = isOverflow;
+ mMainExecutor = mainExecutor;
+ mBackgroundExecutor = backgroundExecutor;
+ mRegionSamplingProvider = regionSamplingProvider;
if (mIsOverflow) {
mOverflowView = (BubbleOverflowContainerView) LayoutInflater.from(getContext()).inflate(
R.layout.bubble_overflow_container, null /* root */);
mOverflowView.initialize(expandedViewManager, positioner);
addView(mOverflowView);
+ // Don't show handle for overflow
+ mHandleView.setVisibility(View.GONE);
} else {
+ mBubbleTaskView = bubbleTaskView;
mTaskView = bubbleTaskView.getTaskView();
- mBubbleTaskViewHelper = new BubbleTaskViewHelper(mContext, expandedViewManager,
- /* listener= */ this, bubbleTaskView,
- /* viewParent= */ this);
+ mBubbleTaskViewListener = new BubbleTaskViewListener(mContext, bubbleTaskView,
+ /* viewParent= */ this,
+ expandedViewManager,
+ /* callback= */ this);
+
+ // if the task view is already attached to a parent we need to remove it
if (mTaskView.getParent() != null) {
((ViewGroup) mTaskView.getParent()).removeView(mTaskView);
}
- FrameLayout.LayoutParams lp =
- new FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT);
- addView(mTaskView, lp);
- mTaskView.setEnableSurfaceClipping(true);
- mTaskView.setCornerRadius(mCurrentCornerRadius);
- mTaskView.setVisibility(VISIBLE);
+ setupTaskView();
// Handle view needs to draw on top of task view.
- bringChildToFront(mHandleView);
+ mHandleView.setElevation(1);
+
+ mHandleView.setAccessibilityDelegate(new HandleViewAccessibilityDelegate());
}
- mMenuViewController = new BubbleBarMenuViewController(mContext, this);
+ mMenuViewController = new BubbleBarMenuViewController(mContext, mHandleView, this);
mMenuViewController.setListener(new BubbleBarMenuViewController.Listener() {
@Override
public void onMenuVisibilityChanged(boolean visible) {
setObscured(visible);
+ if (visible) {
+ mHandleView.setFocusable(false);
+ mHandleView.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
+ } else {
+ mHandleView.setFocusable(true);
+ mHandleView.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_AUTO);
+ }
}
@Override
@@ -199,17 +287,27 @@ public class BubbleBarExpandedView extends FrameLayout implements BubbleTaskView
if (mListener != null) {
mListener.onUnBubbleConversation(bubble.getKey());
}
+ bubbleLogger.log(bubble, BubbleLogger.Event.BUBBLE_BAR_APP_MENU_OPT_OUT);
}
@Override
public void onOpenAppSettings(Bubble bubble) {
mManager.collapseStack();
mContext.startActivityAsUser(bubble.getSettingsIntent(mContext), bubble.getUser());
+ bubbleLogger.log(bubble, BubbleLogger.Event.BUBBLE_BAR_APP_MENU_GO_TO_SETTINGS);
}
@Override
public void onDismissBubble(Bubble bubble) {
mManager.dismissBubble(bubble, Bubbles.DISMISS_USER_GESTURE);
+ bubbleLogger.log(bubble, BubbleLogger.Event.BUBBLE_BAR_BUBBLE_DISMISSED_APP_MENU);
+ }
+
+ @Override
+ public void onMoveToFullscreen(Bubble bubble) {
+ if (mTaskView != null) {
+ mTaskView.moveToFullscreen();
+ }
}
});
mHandleView.setOnClickListener(view -> {
@@ -217,35 +315,58 @@ public class BubbleBarExpandedView extends FrameLayout implements BubbleTaskView
});
}
+ private void setupTaskView() {
+ // if we're converting this bubble to bar mode, set the isMovingWindows state to false for
+ // this task view before adding it as a child view.
+ if (mBubble.isConvertingToBar()) {
+ mTaskView.setIsMovingWindows(false);
+ }
+
+ FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT);
+ addView(mTaskView, lp);
+ mTaskView.setEnableSurfaceClipping(true);
+ mTaskView.setCornerRadius(mCurrentCornerRadius);
+ mTaskView.setVisibility(VISIBLE);
+ mTaskView.setCaptionInsets(Insets.of(0, mCaptionHeight, 0, 0));
+ }
+
public BubbleBarHandleView getHandleView() {
return mHandleView;
}
- // TODO (b/275087636): call this when theme/config changes
/** Updates the view based on the current theme. */
public void applyThemeAttrs() {
+ mCaptionHeight = getResources().getDimensionPixelSize(
+ R.dimen.bubble_bar_expanded_view_caption_height);
mRestingCornerRadius = getResources().getDimensionPixelSize(
- R.dimen.bubble_bar_expanded_view_corner_radius
- );
+ R.dimen.bubble_bar_expanded_view_corner_radius);
mDraggedCornerRadius = getResources().getDimensionPixelSize(
- R.dimen.bubble_bar_expanded_view_corner_radius_dragged
- );
+ R.dimen.bubble_bar_expanded_view_corner_radius_dragged);
mCurrentCornerRadius = mRestingCornerRadius;
- final TypedArray ta = mContext.obtainStyledAttributes(new int[]{
- android.R.attr.colorBackgroundFloating});
- mBackgroundColor = ta.getColor(0, Color.WHITE);
- ta.recycle();
- mCaptionHeight = getResources().getDimensionPixelSize(
- R.dimen.bubble_bar_expanded_view_caption_height);
-
if (mTaskView != null) {
mTaskView.setCornerRadius(mCurrentCornerRadius);
- updateHandleColor(true /* animated */);
+ mTaskView.setCaptionInsets(Insets.of(0, mCaptionHeight, 0, 0));
}
}
+ @Override
+ protected void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ // Hide manage menu when view disappears
+ mMenuViewController.hideMenu(false /* animated */);
+ if (mRegionSamplingHelper != null) {
+ mRegionSamplingHelper.stopAndDestroy();
+ }
+ }
+
+ @Override
+ protected void onAttachedToWindow() {
+ super.onAttachedToWindow();
+ recreateRegionSamplingHelper();
+ }
+
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
@@ -260,24 +381,43 @@ public class BubbleBarExpandedView extends FrameLayout implements BubbleTaskView
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
if (mTaskView != null) {
- mTaskView.layout(l, t, r,
- t + mTaskView.getMeasuredHeight());
- mTaskView.setCaptionInsets(Insets.of(0, mCaptionHeight, 0, 0));
+ mTaskView.layout(l, t, r, t + mTaskView.getMeasuredHeight());
}
}
@Override
public void onTaskCreated() {
- setContentVisibility(true);
- updateHandleColor(false /* animated */);
+ if (mTaskView != null && !mBubble.isConvertingToBar()) {
+ mTaskView.setAlpha(0);
+ }
if (mListener != null) {
mListener.onTaskCreated();
}
+ // when the task is created we're visible
+ onTaskViewVisible();
}
@Override
public void onContentVisibilityChanged(boolean visible) {
- setContentVisibility(visible);
+ if (visible) {
+ onTaskViewVisible();
+ }
+ }
+
+ @Override
+ public void onTaskRemovalStarted() {
+ if (mRegionSamplingHelper != null) {
+ mRegionSamplingHelper.stopAndDestroy();
+ }
+ }
+
+ @Override
+ public void onTaskInfoChanged(ActivityManager.RunningTaskInfo taskInfo) {
+ if (!isValidToBubble(taskInfo)) {
+ // TODO(b/411558731): Besides just showing a warning toast, also force the app to return
+ // to fullscreen, similar to split screen behavior when not supported.
+ Toast.makeText(mContext, R.string.bubble_not_supported_text, Toast.LENGTH_SHORT).show();
+ }
}
@Override
@@ -286,6 +426,79 @@ public class BubbleBarExpandedView extends FrameLayout implements BubbleTaskView
mListener.onBackPressed();
}
+ void animateExpansionWhenTaskViewVisible(Runnable animateExpansion) {
+ if ((mBubbleTaskView != null && mBubbleTaskView.isVisible()) || mIsOverflow) {
+ animateExpansion.run();
+ } else {
+ mAnimateExpansion = animateExpansion;
+ }
+ }
+
+ private void onTaskViewVisible() {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "BBEV.onTaskViewVisible()");
+ if (mAnimateExpansion != null) {
+ mAnimateExpansion.run();
+ mAnimateExpansion = null;
+ }
+ }
+
+ /**
+ * Set whether this view is currently being dragged.
+ *
+ * When dragging, the handle is hidden and content shouldn't be sampled. When dragging has
+ * ended we should start again.
+ */
+ public void setDragging(boolean isDragging) {
+ if (isDragging != mIsDragging) {
+ mIsDragging = isDragging;
+ updateSamplingState();
+
+ if (isDragging && mPositioner.isImeVisible()) {
+ // Hide the IME when dragging begins
+ mManager.hideCurrentInputMethod();
+ }
+ }
+ }
+
+ /** Returns whether region sampling should be enabled, i.e. if task view content is visible. */
+ private boolean shouldSampleRegion() {
+ return mTaskView != null
+ && mTaskView.getTaskInfo() != null
+ && !mIsDragging
+ && !mIsAnimating
+ && mIsContentVisible;
+ }
+
+ /**
+ * Handles starting or stopping the region sampling helper based on
+ * {@link #shouldSampleRegion()}.
+ */
+ private void updateSamplingState() {
+ if (mRegionSamplingHelper == null) return;
+ boolean shouldSample = shouldSampleRegion();
+ if (shouldSample) {
+ mRegionSamplingHelper.start(getCaptionSampleRect());
+ } else {
+ mRegionSamplingHelper.stop();
+ }
+ }
+
+ /** Returns the current area of the caption bar, in screen coordinates. */
+ Rect getCaptionSampleRect() {
+ if (mTaskView == null) return null;
+ mTaskView.getLocationOnScreen(mLoc);
+ mSampleRect.set(mLoc[0], mLoc[1],
+ mLoc[0] + mTaskView.getWidth(),
+ mLoc[1] + mCaptionHeight);
+ return mSampleRect;
+ }
+
+ @VisibleForTesting
+ @Nullable
+ public RegionSamplingHelper getRegionSamplingHelper() {
+ return mRegionSamplingHelper;
+ }
+
/** Cleans up the expanded view, should be called when the bubble is no longer active. */
public void cleanUpExpandedState() {
mMenuViewController.hideMenu(false /* animated */);
@@ -317,13 +530,16 @@ public class BubbleBarExpandedView extends FrameLayout implements BubbleTaskView
/** Updates the bubble shown in the expanded view. */
public void update(Bubble bubble) {
- mBubbleTaskViewHelper.update(bubble);
+ mBubble = bubble;
+ mBubbleTaskViewListener.setBubble(bubble);
mMenuViewController.updateMenu(bubble);
}
/** The task id of the activity shown in the task view, if it exists. */
public int getTaskId() {
- return mBubbleTaskViewHelper != null ? mBubbleTaskViewHelper.getTaskId() : INVALID_TASK_ID;
+ return mBubbleTaskViewListener != null
+ ? mBubbleTaskViewListener.getTaskId()
+ : INVALID_TASK_ID;
}
/** Sets layer bounds supplier used for obscured touchable region of task view */
@@ -369,26 +585,13 @@ public class BubbleBarExpandedView extends FrameLayout implements BubbleTaskView
if (!mIsAnimating) {
mTaskView.setAlpha(visible ? 1f : 0f);
+ if (mRegionSamplingHelper != null) {
+ mRegionSamplingHelper.setWindowVisible(visible);
+ }
+ updateSamplingState();
}
}
- /**
- * Updates the handle color based on the task view status bar or background color; if those
- * are transparent it defaults to the background color pulled from system theme attributes.
- */
- private void updateHandleColor(boolean animated) {
- if (mTaskView == null || mTaskView.getTaskInfo() == null) return;
- int color = mBackgroundColor;
- ActivityManager.TaskDescription taskDescription = mTaskView.getTaskInfo().taskDescription;
- if (taskDescription.getStatusBarColor() != Color.TRANSPARENT) {
- color = taskDescription.getStatusBarColor();
- } else if (taskDescription.getBackgroundColor() != Color.TRANSPARENT) {
- color = taskDescription.getBackgroundColor();
- }
- final boolean isRegionDark = Color.luminance(color) <= 0.5;
- mHandleView.updateHandleColor(isRegionDark, animated);
- }
-
/**
* Sets the alpha of both this view and the task view.
*/
@@ -411,12 +614,22 @@ public class BubbleBarExpandedView extends FrameLayout implements BubbleTaskView
mTaskView.setZOrderedOnTop(onTop, true /* allowDynamicChange */);
}
+ @VisibleForTesting
+ boolean isSurfaceZOrderedOnTop() {
+ return mTaskView != null && mTaskView.isZOrderedOnTop();
+ }
+
/**
* Sets whether the view is animating, in this case we won't change the content visibility
* until the animation is done.
*/
public void setAnimating(boolean animating) {
mIsAnimating = animating;
+ if (mIsAnimating) {
+ // Stop sampling while animating -- when animating is done setContentVisibility will
+ // re-trigger sampling if we're visible.
+ updateSamplingState();
+ }
// If we're done animating, apply the correct visibility.
if (!animating) {
setContentVisibility(mIsContentVisible);
@@ -455,4 +668,157 @@ public class BubbleBarExpandedView extends FrameLayout implements BubbleTaskView
invalidateOutline();
}
}
+
+ /** The y coordinate of the bottom of the expanded view. */
+ public int getContentBottomOnScreen() {
+ if (mOverflowView != null) {
+ mOverflowView.getBoundsOnScreen(mTempBounds);
+ }
+ if (mTaskView != null) {
+ mTaskView.getBoundsOnScreen(mTempBounds);
+ }
+ return mTempBounds.bottom;
+ }
+
+ /** Notifies the expanded view that the IME top changed. */
+ public void onImeTopChanged(int imeTop) {
+ mImeTop = imeTop;
+ mBottomClip = max(getContentBottomOnScreen() - mImeTop, 0);
+ onClipUpdate();
+ }
+
+ void updateBottomClip() {
+ if (mIsClipping) {
+ onImeTopChanged(mImeTop);
+ }
+ }
+
+ void resetBottomClip() {
+ mBottomClip = 0;
+ onClipUpdate();
+ }
+
+ private void onClipUpdate() {
+ if (mBottomClip == 0) {
+ if (mIsClipping) {
+ mIsClipping = false;
+ if (mTaskView != null) {
+ mTaskView.setClipBounds(null);
+ mTaskView.setEnableSurfaceClipping(false);
+ }
+ invalidateOutline();
+ }
+ } else {
+ if (!mIsClipping) {
+ mIsClipping = true;
+ if (mTaskView != null) {
+ mTaskView.setEnableSurfaceClipping(true);
+ }
+ }
+ invalidateOutline();
+ if (mTaskView != null) {
+ Rect clipBounds = new Rect(0, 0,
+ mTaskView.getWidth(),
+ mTaskView.getHeight() - mBottomClip);
+ mTaskView.setClipBounds(clipBounds);
+ }
+ }
+ }
+
+ private void recreateRegionSamplingHelper() {
+ if (mRegionSamplingHelper != null) {
+ mRegionSamplingHelper.stopAndDestroy();
+ }
+ if (mMainExecutor == null || mBackgroundExecutor == null
+ || mRegionSamplingProvider == null) {
+ // Null when it's the overflow / don't need sampling then.
+ return;
+ }
+ mRegionSamplingHelper = mRegionSamplingProvider.createHelper(this,
+ new RegionSamplingHelper.SamplingCallback() {
+ @Override
+ public void onRegionDarknessChanged(boolean isRegionDark) {
+ if (mHandleView != null) {
+ mHandleView.updateHandleColor(isRegionDark,
+ true /* animated */);
+ }
+ }
+
+ @Override
+ public Rect getSampledRegion(View sampledView) {
+ return getCaptionSampleRect();
+ }
+
+ @Override
+ public boolean isSamplingEnabled() {
+ return shouldSampleRegion();
+ }
+ }, mMainExecutor, mBackgroundExecutor);
+ }
+
+ private class HandleViewAccessibilityDelegate extends AccessibilityDelegate {
+ @Override
+ public void onInitializeAccessibilityNodeInfo(@NonNull View host,
+ @NonNull AccessibilityNodeInfo info) {
+ super.onInitializeAccessibilityNodeInfo(host, info);
+ info.addAction(new AccessibilityNodeInfo.AccessibilityAction(
+ AccessibilityNodeInfo.ACTION_CLICK, getResources().getString(
+ R.string.bubble_accessibility_action_expand_menu)));
+ info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_COLLAPSE);
+ info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_DISMISS);
+ if (mPositioner.isBubbleBarOnLeft()) {
+ info.addAction(new AccessibilityNodeInfo.AccessibilityAction(
+ R.id.action_move_bubble_bar_right, getResources().getString(
+ R.string.bubble_accessibility_action_move_bar_right)));
+ } else {
+ info.addAction(new AccessibilityNodeInfo.AccessibilityAction(
+ R.id.action_move_bubble_bar_left, getResources().getString(
+ R.string.bubble_accessibility_action_move_bar_left)));
+ }
+ }
+
+ @Override
+ public boolean performAccessibilityAction(@NonNull View host, int action,
+ @Nullable Bundle args) {
+ if (super.performAccessibilityAction(host, action, args)) {
+ return true;
+ }
+ if (action == AccessibilityNodeInfo.ACTION_COLLAPSE) {
+ mManager.collapseStack();
+ return true;
+ }
+ if (action == AccessibilityNodeInfo.ACTION_DISMISS) {
+ mManager.dismissBubble(mBubble, Bubbles.DISMISS_USER_GESTURE);
+ return true;
+ }
+ if (action == R.id.action_move_bubble_bar_left) {
+ mManager.updateBubbleBarLocation(BubbleBarLocation.LEFT,
+ BubbleBarLocation.UpdateSource.A11Y_ACTION_EXP_VIEW);
+ return true;
+ }
+ if (action == R.id.action_move_bubble_bar_right) {
+ mManager.updateBubbleBarLocation(BubbleBarLocation.RIGHT,
+ BubbleBarLocation.UpdateSource.A11Y_ACTION_EXP_VIEW);
+ return true;
+ }
+ return false;
+ }
+ }
+
+ /**
+ * Description of current expanded view state.
+ */
+ public void dump(@android.annotation.NonNull PrintWriter pw,
+ @android.annotation.NonNull String prefix) {
+ pw.print(prefix); pw.println("BubbleBarExpandedView:");
+ pw.print(prefix); pw.print(" taskId: "); pw.println(getTaskId());
+ pw.print(prefix); pw.print(" contentVisibility: "); pw.println(mIsContentVisible);
+ pw.print(prefix); pw.print(" isAnimating: "); pw.println(mIsAnimating);
+ pw.print(prefix); pw.print(" isDragging: "); pw.println(mIsDragging);
+ if (mTaskView != null) {
+ pw.print(prefix);
+ pw.print(" is task view moving windows: ");
+ pw.println(mTaskView.isMovingWindows());
+ }
+ }
}
diff --git a/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleBarExpandedViewDragController.kt b/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleBarExpandedViewDragController.kt
index dd2cee46c2..44d859dfb9 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleBarExpandedViewDragController.kt
+++ b/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleBarExpandedViewDragController.kt
@@ -17,33 +17,48 @@
package com.android.wm.shell.bubbles.bar
import android.annotation.SuppressLint
+import android.content.Context
import android.view.MotionEvent
import android.view.View
+import androidx.annotation.VisibleForTesting
+import com.android.wm.shell.R
import com.android.wm.shell.bubbles.BubblePositioner
-import com.android.wm.shell.common.bubbles.DismissView
-import com.android.wm.shell.common.bubbles.RelativeTouchListener
+import com.android.wm.shell.shared.bubbles.BubbleBarLocation
+import com.android.wm.shell.shared.bubbles.DismissView
+import com.android.wm.shell.shared.bubbles.DragZoneFactory
+import com.android.wm.shell.shared.bubbles.DraggedObject
+import com.android.wm.shell.shared.bubbles.DropTargetManager
+import com.android.wm.shell.shared.bubbles.RelativeTouchListener
import com.android.wm.shell.shared.magnetictarget.MagnetizedObject
/** Controller for handling drag interactions with [BubbleBarExpandedView] */
@SuppressLint("ClickableViewAccessibility")
class BubbleBarExpandedViewDragController(
+ private val context: Context,
private val expandedView: BubbleBarExpandedView,
private val dismissView: DismissView,
private val animationHelper: BubbleBarAnimationHelper,
private val bubblePositioner: BubblePositioner,
private val pinController: BubbleExpandedViewPinController,
- private val dragListener: DragListener
+ private val dropTargetManager: DropTargetManager?,
+ private val dragZoneFactory: DragZoneFactory?,
+ @get:VisibleForTesting val dragListener: DragListener,
) {
var isStuckToDismiss: Boolean = false
private set
+ var isDragged: Boolean = false
+ private set
+
private var expandedViewInitialTranslationX = 0f
private var expandedViewInitialTranslationY = 0f
private val magnetizedExpandedView: MagnetizedObject =
MagnetizedObject.magnetizeView(expandedView)
private val magnetizedDismissTarget: MagnetizedObject.MagneticTarget
+ private val draggedBubbleElevation: Float
+
init {
magnetizedExpandedView.magnetListener = MagnetListener()
magnetizedExpandedView.animateStuckToTarget =
@@ -60,6 +75,8 @@ class BubbleBarExpandedViewDragController(
MagnetizedObject.MagneticTarget(dismissView.circle, dismissView.circle.width)
magnetizedExpandedView.addTarget(magnetizedDismissTarget)
+ draggedBubbleElevation = context.resources.getDimension(
+ R.dimen.dragged_bubble_elevation)
val dragMotionEventHandler = HandleDragListener()
expandedView.handleView.setOnTouchListener { view, event ->
@@ -93,7 +110,23 @@ class BubbleBarExpandedViewDragController(
override fun onDown(v: View, ev: MotionEvent): Boolean {
// While animating, don't allow new touch events
if (expandedView.isAnimating) return false
- pinController.onDragStart(bubblePositioner.isBubbleBarOnLeft)
+ expandedView.z = draggedBubbleElevation
+ if (dropTargetManager != null && dragZoneFactory != null) {
+ val draggedObject = DraggedObject.ExpandedView(
+ if (bubblePositioner.isBubbleBarOnLeft) {
+ BubbleBarLocation.LEFT
+ } else {
+ BubbleBarLocation.RIGHT
+ }
+ )
+ dropTargetManager.onDragStarted(
+ draggedObject,
+ dragZoneFactory.createSortedDragZones(draggedObject)
+ )
+ } else {
+ pinController.onDragStart(bubblePositioner.isBubbleBarOnLeft)
+ }
+ isDragged = true
return true
}
@@ -103,7 +136,7 @@ class BubbleBarExpandedViewDragController(
viewInitialX: Float,
viewInitialY: Float,
dx: Float,
- dy: Float
+ dy: Float,
) {
if (!isMoving) {
isMoving = true
@@ -112,7 +145,11 @@ class BubbleBarExpandedViewDragController(
expandedView.translationX = expandedViewInitialTranslationX + dx
expandedView.translationY = expandedViewInitialTranslationY + dy
dismissView.show()
- pinController.onDragUpdate(ev.rawX, ev.rawY)
+ if (dropTargetManager != null) {
+ dropTargetManager.onDragUpdated(ev.rawX.toInt(), ev.rawY.toInt())
+ } else {
+ pinController.onDragUpdate(ev.rawX, ev.rawY)
+ }
}
override fun onUp(
@@ -123,31 +160,38 @@ class BubbleBarExpandedViewDragController(
dx: Float,
dy: Float,
velX: Float,
- velY: Float
+ velY: Float,
) {
+ v.translationZ = 0f
finishDrag()
}
override fun onCancel(v: View, ev: MotionEvent, viewInitialX: Float, viewInitialY: Float) {
isStuckToDismiss = false
+ v.translationZ = 0f
finishDrag()
}
private fun finishDrag() {
if (!isStuckToDismiss) {
- pinController.onDragEnd()
+ if (dropTargetManager != null) {
+ dropTargetManager.onDragEnded()
+ } else {
+ pinController.onDragEnd()
+ }
dragListener.onReleased(inDismiss = false)
animationHelper.animateToRestPosition()
dismissView.hide()
}
isMoving = false
+ isDragged = false
}
}
private inner class MagnetListener : MagnetizedObject.MagnetListener {
override fun onStuckToTarget(
target: MagnetizedObject.MagneticTarget,
- draggedObject: MagnetizedObject<*>
+ draggedObject: MagnetizedObject<*>,
) {
isStuckToDismiss = true
pinController.onStuckToDismissTarget()
@@ -158,7 +202,7 @@ class BubbleBarExpandedViewDragController(
draggedObject: MagnetizedObject<*>,
velX: Float,
velY: Float,
- wasFlungOut: Boolean
+ wasFlungOut: Boolean,
) {
isStuckToDismiss = false
animationHelper.animateUnstuckFromDismissView(target)
@@ -166,10 +210,14 @@ class BubbleBarExpandedViewDragController(
override fun onReleasedInTarget(
target: MagnetizedObject.MagneticTarget,
- draggedObject: MagnetizedObject<*>
+ draggedObject: MagnetizedObject<*>,
) {
dragListener.onReleased(inDismiss = true)
- pinController.onDragEnd()
+ if (dropTargetManager != null) {
+ dropTargetManager.onDragEnded()
+ } else {
+ pinController.onDragEnd()
+ }
dismissView.hide()
}
}
diff --git a/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleBarHandleView.java b/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleBarHandleView.java
index d54a6b002e..9cf0d2db71 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleBarHandleView.java
+++ b/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleBarHandleView.java
@@ -17,17 +17,18 @@ package com.android.wm.shell.bubbles.bar;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
+import android.animation.ArgbEvaluator;
import android.animation.ObjectAnimator;
import android.annotation.Nullable;
import android.content.Context;
-import android.graphics.Outline;
-import android.graphics.Path;
-import android.graphics.RectF;
+import android.graphics.Canvas;
+import android.graphics.Paint;
import android.util.AttributeSet;
import android.view.View;
-import android.view.ViewOutlineProvider;
import androidx.annotation.ColorInt;
+import androidx.annotation.VisibleForTesting;
+import androidx.core.animation.IntProperty;
import androidx.core.content.ContextCompat;
import com.android.wm.shell.R;
@@ -37,12 +38,34 @@ import com.android.wm.shell.R;
*/
public class BubbleBarHandleView extends View {
private static final long COLOR_CHANGE_DURATION = 120;
- // Path used to draw the dots
- private final Path mPath = new Path();
+ /** Custom property to set handle color. */
+ private static final IntProperty HANDLE_COLOR = new IntProperty<>(
+ "handleColor") {
+ @Override
+ public void setValue(BubbleBarHandleView bubbleBarHandleView, int color) {
+ bubbleBarHandleView.setHandleColor(color);
+ }
+
+ @Override
+ public Integer get(BubbleBarHandleView bubbleBarHandleView) {
+ return bubbleBarHandleView.getHandleColor();
+ }
+ };
+
+ @VisibleForTesting
+ final Paint mHandlePaint = new Paint();
private final @ColorInt int mHandleLightColor;
private final @ColorInt int mHandleDarkColor;
- private @Nullable ObjectAnimator mColorChangeAnim;
+ private final ArgbEvaluator mArgbEvaluator = ArgbEvaluator.getInstance();
+ private final float mHandleHeight;
+ private final float mHandleWidth;
+ private float mCurrentHandleHeight;
+ private float mCurrentHandleWidth;
+ @Nullable
+ private ObjectAnimator mColorChangeAnim;
+ private @ColorInt int mRegionSamplerColor;
+ private boolean mHasSampledColor;
public BubbleBarHandleView(Context context) {
this(context, null /* attrs */);
@@ -59,27 +82,64 @@ public class BubbleBarHandleView extends View {
public BubbleBarHandleView(Context context, AttributeSet attrs, int defStyleAttr,
int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
- final int handleHeight = getResources().getDimensionPixelSize(
+ mHandlePaint.setFlags(Paint.ANTI_ALIAS_FLAG);
+ mHandlePaint.setStyle(Paint.Style.FILL);
+ mHandlePaint.setColor(0);
+ mHandleHeight = getResources().getDimensionPixelSize(
R.dimen.bubble_bar_expanded_view_handle_height);
+ mHandleWidth = getResources().getDimensionPixelSize(
+ R.dimen.bubble_bar_expanded_view_caption_width);
mHandleLightColor = ContextCompat.getColor(getContext(),
R.color.bubble_bar_expanded_view_handle_light);
mHandleDarkColor = ContextCompat.getColor(getContext(),
R.color.bubble_bar_expanded_view_handle_dark);
+ mCurrentHandleHeight = mHandleHeight;
+ mCurrentHandleWidth = mHandleWidth;
+ setContentDescription(getResources().getString(R.string.handle_text));
+ }
- setClipToOutline(true);
- setOutlineProvider(new ViewOutlineProvider() {
- @Override
- public void getOutline(View view, Outline outline) {
- final int handleCenterY = view.getHeight() / 2;
- final int handleTop = handleCenterY - handleHeight / 2;
- final int handleBottom = handleTop + handleHeight;
- final int radius = handleHeight / 2;
- RectF handle = new RectF(/* left = */ 0, handleTop, view.getWidth(), handleBottom);
- mPath.reset();
- mPath.addRoundRect(handle, radius, radius, Path.Direction.CW);
- outline.setPath(mPath);
- }
- });
+ private void setHandleColor(int color) {
+ mHandlePaint.setColor(color);
+ invalidate();
+ }
+
+ /**
+ * Get current color value for the handle
+ */
+ @ColorInt
+ public int getHandleColor() {
+ return mHandlePaint.getColor();
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+ float handleLeft = (getWidth() - mCurrentHandleWidth) / 2;
+ float handleRight = handleLeft + mCurrentHandleWidth;
+ float handleCenterY = (float) getHeight() / 2;
+ float handleTop = (int) (handleCenterY - mCurrentHandleHeight / 2);
+ float handleBottom = handleTop + mCurrentHandleHeight;
+ float cornerRadius = mCurrentHandleHeight / 2;
+ canvas.drawRoundRect(handleLeft, handleTop, handleRight, handleBottom, cornerRadius,
+ cornerRadius, mHandlePaint);
+ }
+
+ /** Sets handle width, height and color. Does not change the layout properties */
+ private void setHandleProperties(float width, float height, int color) {
+ mCurrentHandleHeight = height;
+ mCurrentHandleWidth = width;
+ mHandlePaint.setColor(color);
+ invalidate();
+ }
+
+ /**
+ * Set initial color for the handle. Takes effect if the
+ * {@link #updateHandleColor(boolean, boolean)} has not been called.
+ */
+ public void setHandleInitialColor(@ColorInt int color) {
+ if (!mHasSampledColor) {
+ setHandleColor(color);
+ }
}
/**
@@ -87,15 +147,20 @@ public class BubbleBarHandleView extends View {
*
* @param isRegionDark Whether the background behind the handle is dark, and thus the handle
* should be light (and vice versa).
- * @param animated Whether to animate the change, or apply it immediately.
+ * @param animated Whether to animate the change, or apply it immediately.
*/
public void updateHandleColor(boolean isRegionDark, boolean animated) {
int newColor = isRegionDark ? mHandleLightColor : mHandleDarkColor;
+ if (newColor == mRegionSamplerColor) {
+ return;
+ }
+ mHasSampledColor = true;
+ mRegionSamplerColor = newColor;
if (mColorChangeAnim != null) {
mColorChangeAnim.cancel();
}
if (animated) {
- mColorChangeAnim = ObjectAnimator.ofArgb(this, "backgroundColor", newColor);
+ mColorChangeAnim = ObjectAnimator.ofArgb(this, HANDLE_COLOR, newColor);
mColorChangeAnim.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
@@ -105,7 +170,39 @@ public class BubbleBarHandleView extends View {
mColorChangeAnim.setDuration(COLOR_CHANGE_DURATION);
mColorChangeAnim.start();
} else {
- setBackgroundColor(newColor);
+ setHandleColor(newColor);
}
}
+
+ /** Returns handle padding top. */
+ public int getHandlePaddingTop() {
+ return (getHeight() - getResources().getDimensionPixelSize(
+ R.dimen.bubble_bar_expanded_view_handle_height)) / 2;
+ }
+
+ /** Animates handle for the bubble menu. */
+ public void animateHandleForMenu(float progress, float widthDelta, float heightDelta,
+ int menuColor) {
+ float currentWidth = mHandleWidth + widthDelta * progress;
+ float currentHeight = mHandleHeight + heightDelta * progress;
+ int color = (int) mArgbEvaluator.evaluate(progress, mRegionSamplerColor, menuColor);
+ setHandleProperties(currentWidth, currentHeight, color);
+ setTranslationY(heightDelta * progress / 2);
+ }
+
+ /** Restores all the properties that were animated to the default values. */
+ public void restoreAnimationDefaults() {
+ setHandleProperties(mHandleWidth, mHandleHeight, mRegionSamplerColor);
+ setTranslationY(0);
+ }
+
+ /** Returns the handle height. */
+ public int getHandleHeight() {
+ return (int) mHandleHeight;
+ }
+
+ /** Returns the handle width. */
+ public int getHandleWidth() {
+ return (int) mHandleWidth;
+ }
}
diff --git a/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleBarLayerView.java b/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleBarLayerView.java
index badc409979..e2d357530b 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleBarLayerView.java
+++ b/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleBarLayerView.java
@@ -16,17 +16,23 @@
package com.android.wm.shell.bubbles.bar;
-import static com.android.wm.shell.animation.Interpolators.ALPHA_IN;
-import static com.android.wm.shell.animation.Interpolators.ALPHA_OUT;
+import static com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_BUBBLES;
+import static com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_BUBBLES_NOISY;
+import static com.android.wm.shell.shared.animation.Interpolators.ALPHA_IN;
+import static com.android.wm.shell.shared.animation.Interpolators.ALPHA_OUT;
import static com.android.wm.shell.bubbles.Bubbles.DISMISS_USER_GESTURE;
+import static com.android.wm.shell.shared.bubbles.BubbleConstants.BUBBLE_EXPANDED_SCRIM_ALPHA;
import android.annotation.Nullable;
import android.content.Context;
+import android.graphics.Insets;
import android.graphics.Point;
import android.graphics.Rect;
import android.graphics.Region;
import android.graphics.drawable.ColorDrawable;
+import android.util.Log;
import android.view.Gravity;
+import android.view.SurfaceControl;
import android.view.TouchDelegate;
import android.view.View;
import android.view.ViewTreeObserver;
@@ -34,19 +40,29 @@ import android.view.WindowManager;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
+import androidx.annotation.VisibleForTesting;
+import com.android.internal.protolog.ProtoLog;
+import com.android.wm.shell.R;
import com.android.wm.shell.bubbles.Bubble;
import com.android.wm.shell.bubbles.BubbleController;
import com.android.wm.shell.bubbles.BubbleData;
+import com.android.wm.shell.bubbles.BubbleExpandedViewTransitionAnimator;
+import com.android.wm.shell.bubbles.BubbleLogger;
import com.android.wm.shell.bubbles.BubbleOverflow;
import com.android.wm.shell.bubbles.BubblePositioner;
import com.android.wm.shell.bubbles.BubbleViewProvider;
-import com.android.wm.shell.bubbles.DeviceConfig;
import com.android.wm.shell.bubbles.DismissViewUtils;
import com.android.wm.shell.bubbles.bar.BubbleBarExpandedViewDragController.DragListener;
-import com.android.wm.shell.common.bubbles.BaseBubblePinController;
-import com.android.wm.shell.common.bubbles.BubbleBarLocation;
-import com.android.wm.shell.common.bubbles.DismissView;
+import com.android.wm.shell.shared.bubbles.BaseBubblePinController;
+import com.android.wm.shell.shared.bubbles.BubbleAnythingFlagHelper;
+import com.android.wm.shell.shared.bubbles.BubbleBarLocation;
+import com.android.wm.shell.shared.bubbles.DeviceConfig;
+import com.android.wm.shell.shared.bubbles.DismissView;
+import com.android.wm.shell.shared.bubbles.DragZone;
+import com.android.wm.shell.shared.bubbles.DragZoneFactory;
+import com.android.wm.shell.shared.bubbles.DraggedObject;
+import com.android.wm.shell.shared.bubbles.DropTargetManager;
import kotlin.Unit;
@@ -60,19 +76,23 @@ import java.util.function.Consumer;
* on screen and instead shows & animates the expanded bubble for the bubble bar.
*/
public class BubbleBarLayerView extends FrameLayout
- implements ViewTreeObserver.OnComputeInternalInsetsListener {
+ implements ViewTreeObserver.OnComputeInternalInsetsListener,
+ BubbleExpandedViewTransitionAnimator {
private static final String TAG = BubbleBarLayerView.class.getSimpleName();
- private static final float SCRIM_ALPHA = 0.2f;
-
private final BubbleController mBubbleController;
private final BubbleData mBubbleData;
private final BubblePositioner mPositioner;
+ private final BubbleLogger mBubbleLogger;
private final BubbleBarAnimationHelper mAnimationHelper;
private final BubbleEducationViewController mEducationViewController;
private final View mScrimView;
private final BubbleExpandedViewPinController mBubbleExpandedViewPinController;
+ @Nullable
+ private DropTargetManager mDropTargetManager = null;
+ @Nullable
+ private DragZoneFactory mDragZoneFactory = null;
@Nullable
private BubbleViewProvider mExpandedBubble;
@@ -92,15 +112,17 @@ public class BubbleBarLayerView extends FrameLayout
// Used to ensure touch target size for the menu shown on a bubble expanded view
private TouchDelegate mHandleTouchDelegate;
private final Rect mHandleTouchBounds = new Rect();
+ private Insets mInsets;
- public BubbleBarLayerView(Context context, BubbleController controller, BubbleData bubbleData) {
+ public BubbleBarLayerView(Context context, BubbleController controller, BubbleData bubbleData,
+ BubbleLogger bubbleLogger) {
super(context);
mBubbleController = controller;
mBubbleData = bubbleData;
mPositioner = mBubbleController.getPositioner();
+ mBubbleLogger = bubbleLogger;
- mAnimationHelper = new BubbleBarAnimationHelper(context,
- this, mPositioner);
+ mAnimationHelper = new BubbleBarAnimationHelper(context, mPositioner);
mEducationViewController = new BubbleEducationViewController(context, (boolean visible) -> {
if (mExpandedView == null) return;
mExpandedView.setObscured(visible);
@@ -119,22 +141,111 @@ public class BubbleBarLayerView extends FrameLayout
mBubbleExpandedViewPinController = new BubbleExpandedViewPinController(
context, this, mPositioner);
- mBubbleExpandedViewPinController.setListener(
- new BaseBubblePinController.LocationChangeListener() {
- @Override
- public void onChange(@NonNull BubbleBarLocation bubbleBarLocation) {
- mBubbleController.animateBubbleBarLocation(bubbleBarLocation);
- }
+ LocationChangeListener locationChangeListener = new LocationChangeListener();
+ mBubbleExpandedViewPinController.setListener(locationChangeListener);
- @Override
- public void onRelease(@NonNull BubbleBarLocation location) {
- mBubbleController.setBubbleBarLocation(location);
- }
- });
+ if (BubbleAnythingFlagHelper.enableBubbleToFullscreen()) {
+ mDropTargetManager = new DropTargetManager(context, this,
+ new DropTargetManager.DragZoneChangedListener() {
+ private DragZone mLastBubbleLocationDragZone = null;
+ private BubbleBarLocation mInitialLocation = null;
+ @Override
+ public void onDragEnded(@Nullable DragZone zone) {
+ if (mExpandedBubble == null || !(mExpandedBubble instanceof Bubble)) {
+ Log.w(TAG, "dropped invalid bubble: " + mExpandedBubble);
+ return;
+ }
+ final boolean isBubbleLeft = zone instanceof DragZone.Bubble.Left;
+ final boolean isBubbleRight = zone instanceof DragZone.Bubble.Right;
+ if (!isBubbleLeft && !isBubbleRight) {
+ // If we didn't finish the "change" animation make sure to animate
+ // it back to the right spot
+ locationChangeListener.onChange(mInitialLocation);
+ }
+ if (zone instanceof DragZone.FullScreen) {
+ ((Bubble) mExpandedBubble).getTaskView().moveToFullscreen();
+ // Make sure location change listener is updated with the initial
+ // location -- even if we "switched sides" during the drag, since
+ // we've ended up in fullscreen, the location shouldn't change.
+ locationChangeListener.onRelease(mInitialLocation);
+ } else if (isBubbleLeft) {
+ locationChangeListener.onRelease(BubbleBarLocation.LEFT);
+ } else if (isBubbleRight) {
+ locationChangeListener.onRelease(BubbleBarLocation.RIGHT);
+ }
+ }
+
+ @Override
+ public void onInitialDragZoneSet(@Nullable DragZone dragZone) {
+ mInitialLocation = dragZone instanceof DragZone.Bubble.Left
+ ? BubbleBarLocation.LEFT
+ : BubbleBarLocation.RIGHT;
+ locationChangeListener.onStart(mInitialLocation);
+ }
+
+ @Override
+ public void onDragZoneChanged(@NonNull DraggedObject draggedObject,
+ @Nullable DragZone from, @Nullable DragZone to) {
+ final boolean isBubbleLeft = to instanceof DragZone.Bubble.Left;
+ final boolean isBubbleRight = to instanceof DragZone.Bubble.Right;
+ if ((isBubbleLeft || isBubbleRight)
+ && to != mLastBubbleLocationDragZone) {
+ mLastBubbleLocationDragZone = to;
+ locationChangeListener.onChange(isBubbleLeft
+ ? BubbleBarLocation.LEFT
+ : BubbleBarLocation.RIGHT);
+
+ }
+ }
+ });
+ // TODO - currently only fullscreen is supported, should enable for split & desktop
+ mDragZoneFactory = new DragZoneFactory(context, mPositioner.getCurrentConfig(),
+ new DragZoneFactory.SplitScreenModeChecker() {
+ @NonNull
+ @Override
+ public SplitScreenMode getSplitScreenMode() {
+ return SplitScreenMode.UNSUPPORTED;
+ }
+ },
+ new DragZoneFactory.DesktopWindowModeChecker() {
+ @Override
+ public boolean isSupported() {
+ return false;
+ }
+ },
+ new DragZoneFactory.BubbleBarPropertiesProvider() {
+ // this is only used in launcher
+ @Override
+ public int getBottomPadding() {
+ return 0;
+ }
+
+ @Override
+ public int getWidth() {
+ return 0;
+ }
+
+ @Override
+ public int getHeight() {
+ return 0;
+ }
+ });
+ }
setOnClickListener(view -> hideModalOrCollapse());
}
+ /** Hides the expanded view drop target. */
+ public void hideBubbleBarExpandedViewDropTarget() {
+ mBubbleExpandedViewPinController.hideDropTarget();
+ }
+
+ /** Shows the expanded view drop target at the requested {@link BubbleBarLocation location} */
+ public void showBubbleBarExtendedViewDropTarget(@NonNull BubbleBarLocation bubbleBarLocation) {
+ setVisibility(VISIBLE);
+ mBubbleExpandedViewPinController.showDropTarget(bubbleBarLocation);
+ }
+
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
@@ -171,18 +282,60 @@ public class BubbleBarLayerView extends FrameLayout
}
/** Whether the stack of bubbles is expanded or not. */
+ @Override
public boolean isExpanded() {
return mIsExpanded;
}
+ /** Return whether the expanded view is being dragged */
+ public boolean isExpandedViewDragged() {
+ return mDragController != null && mDragController.isDragged();
+ }
+
/** Shows the expanded view of the provided bubble. */
public void showExpandedView(BubbleViewProvider b) {
- BubbleBarExpandedView expandedView = b.getBubbleBarExpandedView();
- if (expandedView == null) {
- return;
+ if (!canExpandView(b)) return;
+ animateExpand(prepareExpandedView(b));
+ }
+
+ /**
+ * @return whether it's possible to expand {@param b} right now. This is {@code false} if
+ * the bubble has no view or if the bubble is already showing.
+ */
+ @Override
+ public boolean canExpandView(BubbleViewProvider b) {
+ if (b.getBubbleBarExpandedView() == null) return false;
+ if (mExpandedBubble != null && mIsExpanded && b.getKey().equals(mExpandedBubble.getKey())) {
+ // Already showing this bubble so can't expand it.
+ return false;
}
+ return true;
+ }
+
+ @Override
+ public void removeViewFromTransition(View view) {
+ removeView(view);
+ }
+
+ /**
+ * Prepares the expanded view of the provided bubble to be shown. This includes removing any
+ * stale content and cancelling any related animations.
+ *
+ * @return previous open bubble if there was one.
+ */
+ private BubbleViewProvider prepareExpandedView(BubbleViewProvider b) {
+ if (!canExpandView(b)) {
+ throw new IllegalStateException("Can't prepare expand. Check canExpandView(b) first.");
+ }
+ BubbleBarExpandedView expandedView = b.getBubbleBarExpandedView();
+ BubbleViewProvider previousBubble = null;
if (mExpandedBubble != null && !b.getKey().equals(mExpandedBubble.getKey())) {
- removeView(mExpandedView);
+ if (mIsExpanded && mExpandedBubble.getBubbleBarExpandedView() != null) {
+ // Previous expanded view open, keep it visible to animate the switch
+ previousBubble = mExpandedBubble;
+ } else {
+ removeView(mExpandedView);
+ }
mExpandedView = null;
}
if (mExpandedView == null) {
@@ -197,6 +350,14 @@ public class BubbleBarLayerView extends FrameLayout
boolean isOverflowExpanded = b.getKey().equals(BubbleOverflow.KEY);
final int width = mPositioner.getExpandedViewWidthForBubbleBar(isOverflowExpanded);
final int height = mPositioner.getExpandedViewHeightForBubbleBar(isOverflowExpanded);
+ if (width <= 0 || height <= 0) {
+ Log.e(TAG,
+ String.format("got expanded view with non-positive width=%d or height=%d."
+ + " this could result in the expanded view not having a"
+ + " surface!",
+ width, height));
+ }
+
mExpandedView.setVisibility(GONE);
mExpandedView.setY(mPositioner.getExpandedViewBottomForBubbleBar() - height);
mExpandedView.setLayerBoundsSupplier(() -> new Rect(0, 0, getWidth(), getHeight()));
@@ -224,14 +385,18 @@ public class BubbleBarLayerView extends FrameLayout
DragListener dragListener = inDismiss -> {
if (inDismiss && mExpandedBubble != null) {
mBubbleController.dismissBubble(mExpandedBubble.getKey(), DISMISS_USER_GESTURE);
+ logBubbleEvent(BubbleLogger.Event.BUBBLE_BAR_BUBBLE_DISMISSED_DRAG_EXP_VIEW);
}
};
mDragController = new BubbleBarExpandedViewDragController(
+ mContext,
mExpandedView,
mDismissView,
mAnimationHelper,
mPositioner,
mBubbleExpandedViewPinController,
+ mDropTargetManager,
+ mDragZoneFactory,
dragListener);
addView(mExpandedView, new LayoutParams(width, height, Gravity.LEFT));
@@ -243,7 +408,34 @@ public class BubbleBarLayerView extends FrameLayout
mIsExpanded = true;
mBubbleController.getSysuiProxy().onStackExpandChanged(true);
- mAnimationHelper.animateExpansion(mExpandedBubble, () -> {
+ showScrim(true);
+ return previousBubble;
+ }
+
+ /**
+ * Performs an animation to open a bubble with content that is not already visible.
+ *
+ * @param previousBubble If non-null, this is a bubble that is already showing before the new
+ * bubble is expanded.
+ */
+ public void animateExpand(BubbleViewProvider previousBubble) {
+ animateExpand(previousBubble, null /* finishCallback */);
+ }
+
+ /**
+ * Performs an animation to open a bubble with content that is not already visible.
+ *
+ * @param previousBubble If non-null, this is a bubble that is already showing before the new
+ * bubble is expanded.
+ * @param animFinish If non-null, the callback triggered after the expand animation completes
+ */
+ @Override
+ public void animateExpand(BubbleViewProvider previousBubble,
+ @Nullable Runnable animFinish) {
+ if (!mIsExpanded || mExpandedBubble == null) {
+ throw new IllegalStateException("Can't animateExpand without expnaded state");
+ }
+ final Runnable afterAnimation = () -> {
if (mExpandedView == null) return;
// Touch delegate for the menu
BubbleBarHandleView view = mExpandedView.getHandleView();
@@ -253,21 +445,81 @@ public class BubbleBarLayerView extends FrameLayout
mHandleTouchDelegate = new TouchDelegate(mHandleTouchBounds,
mExpandedView.getHandleView());
setTouchDelegate(mHandleTouchDelegate);
- });
- showScrim(true);
+ if (animFinish != null) {
+ animFinish.run();
+ }
+ };
+
+ if (previousBubble != null) {
+ final BubbleBarExpandedView previousExpandedView =
+ previousBubble.getBubbleBarExpandedView();
+ mAnimationHelper.animateSwitch(previousBubble, mExpandedBubble, () -> {
+ removeView(previousExpandedView);
+ afterAnimation.run();
+ });
+ } else {
+ mAnimationHelper.animateExpansion(mExpandedBubble, afterAnimation);
+ }
}
- /** Removes the given {@code bubble}. */
- public void removeBubble(Bubble bubble, Runnable endAction) {
+ /**
+ * Like {@link #prepareExpandedView} but also makes the current expanded bubble visible
+ * immediately so it gets a surface that can be animated. Since the surface may not be ready
+ * yet, this keeps the TaskView alpha=0.
+ */
+ @Override
+ public BubbleViewProvider prepareConvertedView(BubbleViewProvider b) {
+ final BubbleViewProvider prior = prepareExpandedView(b);
+
+ final BubbleBarExpandedView bbev = mExpandedBubble.getBubbleBarExpandedView();
+ if (bbev != null) {
+ updateExpandedView();
+ bbev.setAnimating(true);
+ bbev.setContentVisibility(true);
+ bbev.setSurfaceZOrderedOnTop(true);
+ bbev.setTaskViewAlpha(0.f);
+ bbev.setVisibility(VISIBLE);
+ }
+
+ return prior;
+ }
+
+ /**
+ * Starts and animates a conversion-from transition.
+ *
+ * @param startT A transaction with first-frame work. this *will* be applied here!
+ */
+ @Override
+ public void animateConvert(@NonNull SurfaceControl.Transaction startT,
+ @NonNull Rect startBounds, float startScale, @NonNull SurfaceControl snapshot,
+ SurfaceControl taskLeash, Runnable animFinish) {
+ if (!mIsExpanded || mExpandedBubble == null) {
+ throw new IllegalStateException("Can't animateExpand without expanded state");
+ }
+ mAnimationHelper.animateConvert(mExpandedBubble, startT, startBounds, startScale, snapshot,
+ taskLeash, animFinish);
+ }
+
+ public void removeBubble(@NonNull Bubble bubble, @NonNull Runnable endAction) {
+ final boolean inTransition = bubble.getPreparingTransition() != null;
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY,
+ "BBLayerView.removeBubble(): bubble=%s hasBubbles=%b inTransition=%b",
+ bubble, !mBubbleData.getBubbles().isEmpty(), inTransition);
Runnable cleanUp = () -> {
- bubble.cleanupViews();
+ // The transition is already managing the task/wm state.
+ bubble.cleanupViews(!inTransition);
endAction.run();
};
- if (mBubbleData.getBubbles().isEmpty()) {
- // we're removing the last bubble. collapse the expanded view and cleanup bubble views
- // at the end.
- collapse(cleanUp);
+ if (mBubbleData.getBubbles().isEmpty() || inTransition) {
+ if (mExpandedBubble != null && mExpandedBubble.getKey().equals(bubble.getKey())) {
+ // If we are removing the last bubble or removing the current bubble via transition,
+ // collapse the expanded view and clean up bubbles at the end.
+ collapse(cleanUp);
+ } else {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, " Skipping, does not match expanded view");
+ cleanUp.run();
+ }
} else {
cleanUp.run();
}
@@ -340,12 +592,15 @@ public class BubbleBarLayerView extends FrameLayout
removeView(mDismissView);
}
mDismissView = new DismissView(getContext());
- DismissViewUtils.setup(mDismissView);
+ DismissViewUtils.setupWithMarginIgnoringNavBarInset(
+ mDismissView, R.dimen.bubble_bar_dismiss_view_bottom_margin);
addView(mDismissView);
}
/** Hides the current modal education/menu view, IME or collapses the expanded view */
private void hideModalOrCollapse() {
+ ProtoLog.d(WM_SHELL_BUBBLES_NOISY, "hideModalOrCollapse(): expanded=%s",
+ mExpandedBubble != null ? mExpandedBubble.getKey() : "null");
if (mEducationViewController.isEducationVisible()) {
mEducationViewController.hideEducation(/* animated = */ true);
return;
@@ -365,7 +620,7 @@ public class BubbleBarLayerView extends FrameLayout
/** Updates the expanded view size and position. */
public void updateExpandedView() {
- if (mExpandedView == null || mExpandedBubble == null) return;
+ if (mExpandedView == null || mExpandedBubble == null || mExpandedView.isAnimating()) return;
boolean isOverflowExpanded = mExpandedBubble.getKey().equals(BubbleOverflow.KEY);
mPositioner.getBubbleBarExpandedViewBounds(mPositioner.isBubbleBarOnLeft(),
isOverflowExpanded, mTempRect);
@@ -376,13 +631,14 @@ public class BubbleBarLayerView extends FrameLayout
mExpandedView.setX(mTempRect.left);
mExpandedView.setY(mTempRect.top);
mExpandedView.updateLocation();
+ mExpandedView.updateBottomClip();
}
private void showScrim(boolean show) {
if (show) {
mScrimView.animate()
.setInterpolator(ALPHA_IN)
- .alpha(SCRIM_ALPHA)
+ .alpha(BUBBLE_EXPANDED_SCRIM_ALPHA)
.start();
} else {
mScrimView.animate()
@@ -404,4 +660,64 @@ public class BubbleBarLayerView extends FrameLayout
}
}
+ /** Handles IME position changes. */
+ public void onImeTopChanged(int imeTop) {
+ if (mIsExpanded) {
+ mAnimationHelper.onImeTopChanged(imeTop);
+ }
+ }
+
+ /**
+ * Log the event only if {@link #mExpandedBubble} is a {@link Bubble}.
+ *
+ * Skips logging if it is {@link BubbleOverflow}.
+ */
+ private void logBubbleEvent(BubbleLogger.Event event) {
+ if (mExpandedBubble != null && mExpandedBubble instanceof Bubble) {
+ mBubbleLogger.log((Bubble) mExpandedBubble, event);
+ }
+ }
+
+ @Nullable
+ @VisibleForTesting
+ public BubbleBarExpandedViewDragController getDragController() {
+ return mDragController;
+ }
+
+ /** Notifies view of device config update. */
+ public void update(DeviceConfig deviceConfig) {
+ Insets newInsets = deviceConfig.getInsets();
+ if (!newInsets.equals(mInsets)) {
+ mInsets = newInsets;
+ updateExpandedView();
+ }
+ }
+
+ private class LocationChangeListener implements
+ BaseBubblePinController.LocationChangeListener {
+
+ private BubbleBarLocation mInitialLocation;
+
+ @Override
+ public void onStart(@NonNull BubbleBarLocation location) {
+ mInitialLocation = location;
+ }
+
+ @Override
+ public void onChange(@NonNull BubbleBarLocation bubbleBarLocation) {
+ mBubbleController.animateBubbleBarLocation(bubbleBarLocation);
+ }
+
+ @Override
+ public void onRelease(@NonNull BubbleBarLocation location) {
+ mBubbleController.setBubbleBarLocation(location,
+ BubbleBarLocation.UpdateSource.DRAG_EXP_VIEW);
+ if (location != mInitialLocation) {
+ BubbleLogger.Event event = location.isOnLeft(isLayoutRtl())
+ ? BubbleLogger.Event.BUBBLE_BAR_MOVED_LEFT_DRAG_EXP_VIEW
+ : BubbleLogger.Event.BUBBLE_BAR_MOVED_RIGHT_DRAG_EXP_VIEW;
+ logBubbleEvent(event);
+ }
+ }
+ }
}
diff --git a/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleBarMenuItemView.java b/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleBarMenuItemView.java
index 00b977721b..bccc6dcd91 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleBarMenuItemView.java
+++ b/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleBarMenuItemView.java
@@ -17,7 +17,6 @@ package com.android.wm.shell.bubbles.bar;
import android.annotation.ColorInt;
import android.content.Context;
-import android.content.res.TypedArray;
import android.graphics.Color;
import android.graphics.drawable.Icon;
import android.util.AttributeSet;
@@ -26,6 +25,7 @@ import android.widget.LinearLayout;
import android.widget.TextView;
import com.android.wm.shell.R;
+import com.android.wm.shell.shared.TypefaceUtils;
/**
* Bubble bar expanded view menu item view to display menu action details
@@ -56,6 +56,7 @@ public class BubbleBarMenuItemView extends LinearLayout {
super.onFinishInflate();
mImageView = findViewById(R.id.bubble_bar_menu_item_icon);
mTextView = findViewById(R.id.bubble_bar_menu_item_title);
+ TypefaceUtils.setTypeface(mTextView, TypefaceUtils.FontFamily.GSF_TITLE_MEDIUM);
}
/**
@@ -63,9 +64,8 @@ public class BubbleBarMenuItemView extends LinearLayout {
*/
void update(Icon icon, String title, @ColorInt int tint) {
if (tint == Color.TRANSPARENT) {
- final TypedArray typedArray = getContext().obtainStyledAttributes(
- new int[]{android.R.attr.textColorPrimary});
- mTextView.setTextColor(typedArray.getColor(0, Color.BLACK));
+ mTextView.setTextColor(
+ getContext().getColor(com.android.internal.R.color.materialColorOnSurface));
} else {
icon.setTint(tint);
mTextView.setTextColor(tint);
diff --git a/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleBarMenuView.java b/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleBarMenuView.java
index 211fe0d48e..7c0f8e138a 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleBarMenuView.java
+++ b/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleBarMenuView.java
@@ -17,17 +17,23 @@ package com.android.wm.shell.bubbles.bar;
import android.annotation.ColorInt;
import android.content.Context;
+import android.content.res.ColorStateList;
import android.graphics.Color;
import android.graphics.drawable.Icon;
import android.util.AttributeSet;
import android.view.LayoutInflater;
+import android.view.View;
import android.view.ViewGroup;
+import android.view.accessibility.AccessibilityNodeInfo;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
+import androidx.core.widget.ImageViewCompat;
+
import com.android.wm.shell.R;
import com.android.wm.shell.bubbles.Bubble;
+import com.android.wm.shell.shared.TypefaceUtils;
import java.util.ArrayList;
@@ -35,10 +41,16 @@ import java.util.ArrayList;
* Bubble bar expanded view menu
*/
public class BubbleBarMenuView extends LinearLayout {
+
private ViewGroup mBubbleSectionView;
private ViewGroup mActionsSectionView;
private ImageView mBubbleIconView;
+ private ImageView mBubbleDismissIconView;
private TextView mBubbleTitleView;
+ // The animation has three stages. Each stage transition lasts until the animation ends. In
+ // stage 1, the title item content fades in. In stage 2, the background of the option items
+ // fades in. In stage 3, the option item content fades in.
+ private static final int SHOW_MENU_STAGES_COUNT = 3;
public BubbleBarMenuView(Context context) {
this(context, null /* attrs */);
@@ -64,6 +76,56 @@ public class BubbleBarMenuView extends LinearLayout {
mActionsSectionView = findViewById(R.id.bubble_bar_manage_menu_actions_section);
mBubbleIconView = findViewById(R.id.bubble_bar_manage_menu_bubble_icon);
mBubbleTitleView = findViewById(R.id.bubble_bar_manage_menu_bubble_title);
+ TypefaceUtils.setTypeface(mBubbleTitleView, TypefaceUtils.FontFamily.GSF_TITLE_MEDIUM);
+ mBubbleDismissIconView = findViewById(R.id.bubble_bar_manage_menu_dismiss_icon);
+ updateThemeColors();
+
+ mBubbleSectionView.setAccessibilityDelegate(new AccessibilityDelegate() {
+ @Override
+ public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) {
+ super.onInitializeAccessibilityNodeInfo(host, info);
+ info.addAction(new AccessibilityNodeInfo.AccessibilityAction(
+ AccessibilityNodeInfo.ACTION_CLICK, getResources().getString(
+ R.string.bubble_accessibility_action_collapse_menu)));
+ }
+ });
+ }
+
+ private void updateThemeColors() {
+ mActionsSectionView.getBackground().setTint(
+ mContext.getColor(com.android.internal.R.color.materialColorSurfaceBright));
+ ImageViewCompat.setImageTintList(mBubbleDismissIconView,
+ ColorStateList.valueOf(
+ mContext.getColor(com.android.internal.R.color.materialColorOnSurface)));
+ }
+
+ /** Animates the menu from the specified start scale. */
+ public void animateFromStartScale(float currentScale, float progress) {
+ int menuItemElevation = getResources().getDimensionPixelSize(
+ R.dimen.bubble_manage_menu_elevation);
+ setScaleX(currentScale);
+ setScaleY(currentScale);
+ setAlphaForTitleViews(progress);
+ mBubbleSectionView.setElevation(menuItemElevation * progress);
+ float actionsBackgroundAlpha = Math.max(0,
+ (progress - (float) 1 / SHOW_MENU_STAGES_COUNT) * (SHOW_MENU_STAGES_COUNT - 1));
+ float actionItemsAlpha = Math.max(0,
+ (progress - (float) 2 / SHOW_MENU_STAGES_COUNT) * SHOW_MENU_STAGES_COUNT);
+ mActionsSectionView.setAlpha(actionsBackgroundAlpha);
+ mActionsSectionView.setElevation(menuItemElevation * actionsBackgroundAlpha);
+ setMenuItemViewsAlpha(actionItemsAlpha);
+ }
+
+ private void setAlphaForTitleViews(float alpha) {
+ mBubbleIconView.setAlpha(alpha);
+ mBubbleTitleView.setAlpha(alpha);
+ mBubbleDismissIconView.setAlpha(alpha);
+ }
+
+ private void setMenuItemViewsAlpha(float alpha) {
+ for (int i = mActionsSectionView.getChildCount() - 1; i >= 0; i--) {
+ mActionsSectionView.getChildAt(i).setAlpha(alpha);
+ }
}
/** Update menu details with bubble info */
@@ -122,6 +184,11 @@ public class BubbleBarMenuView extends LinearLayout {
return mBubbleSectionView.getAlpha();
}
+ /** Return title menu item height. */
+ public float getTitleItemHeight() {
+ return mBubbleSectionView.getHeight();
+ }
+
/**
* Menu action details used to create menu items
*/
diff --git a/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleBarMenuViewController.java b/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleBarMenuViewController.java
index 02918db124..71e61aee2e 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleBarMenuViewController.java
+++ b/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleBarMenuViewController.java
@@ -15,6 +15,9 @@
*/
package com.android.wm.shell.bubbles.bar;
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.ValueAnimator;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.content.Context;
@@ -24,13 +27,10 @@ import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
-import androidx.core.content.ContextCompat;
-import androidx.dynamicanimation.animation.DynamicAnimation;
-import androidx.dynamicanimation.animation.SpringForce;
-
+import com.android.app.animation.Interpolators;
import com.android.wm.shell.R;
import com.android.wm.shell.bubbles.Bubble;
-import com.android.wm.shell.shared.animation.PhysicsAnimator;
+import com.android.wm.shell.shared.bubbles.BubbleAnythingFlagHelper;
import java.util.ArrayList;
@@ -38,22 +38,26 @@ import java.util.ArrayList;
* Manages bubble bar expanded view menu presentation and animations
*/
class BubbleBarMenuViewController {
- private static final float MENU_INITIAL_SCALE = 0.5f;
+
+ private static final float WIDTH_SWAP_FRACTION = 0.4F;
+ private static final long MENU_ANIMATION_DURATION = 600;
+
private final Context mContext;
private final ViewGroup mRootView;
+ private final BubbleBarHandleView mHandleView;
private @Nullable Listener mListener;
private @Nullable Bubble mBubble;
private @Nullable BubbleBarMenuView mMenuView;
/** A transparent view used to intercept touches to collapse menu when presented */
private @Nullable View mScrimView;
- private @Nullable PhysicsAnimator mMenuAnimator;
- private PhysicsAnimator.SpringConfig mMenuSpringConfig;
+ private @Nullable ValueAnimator mMenuAnimator;
- BubbleBarMenuViewController(Context context, ViewGroup rootView) {
+
+ BubbleBarMenuViewController(Context context, BubbleBarHandleView handleView,
+ ViewGroup rootView) {
mContext = context;
mRootView = rootView;
- mMenuSpringConfig = new PhysicsAnimator.SpringConfig(
- SpringForce.STIFFNESS_MEDIUM, SpringForce.DAMPING_RATIO_LOW_BOUNCY);
+ mHandleView = handleView;
}
/** Tells if the menu is visible or being animated */
@@ -79,20 +83,19 @@ class BubbleBarMenuViewController {
if (mMenuView == null || mScrimView == null) {
setupMenu();
}
- cancelAnimations();
- mMenuView.setVisibility(View.VISIBLE);
- mScrimView.setVisibility(View.VISIBLE);
- Runnable endActions = () -> {
- mMenuView.getChildAt(0).requestAccessibilityFocus();
+ runOnMenuIsMeasured(() -> {
+ mMenuView.setVisibility(View.VISIBLE);
+ mScrimView.setVisibility(View.VISIBLE);
if (mListener != null) {
mListener.onMenuVisibilityChanged(true /* isShown */);
}
- };
- if (animated) {
- animateTransition(true /* show */, endActions);
- } else {
- endActions.run();
- }
+ Runnable endActions = () -> mMenuView.getChildAt(0).requestAccessibilityFocus();
+ if (animated) {
+ animateTransition(true /* show */, endActions);
+ } else {
+ endActions.run();
+ }
+ });
}
/**
@@ -101,18 +104,30 @@ class BubbleBarMenuViewController {
*/
void hideMenu(boolean animated) {
if (mMenuView == null || mScrimView == null) return;
- cancelAnimations();
- Runnable endActions = () -> {
- mMenuView.setVisibility(View.GONE);
- mScrimView.setVisibility(View.GONE);
- if (mListener != null) {
- mListener.onMenuVisibilityChanged(false /* isShown */);
+ runOnMenuIsMeasured(() -> {
+ Runnable endActions = () -> {
+ mHandleView.restoreAnimationDefaults();
+ mMenuView.setVisibility(View.GONE);
+ mScrimView.setVisibility(View.GONE);
+ mHandleView.setVisibility(View.VISIBLE);
+ if (mListener != null) {
+ mListener.onMenuVisibilityChanged(false /* isShown */);
+ }
+ };
+ if (animated) {
+ animateTransition(false /* show */, endActions);
+ } else {
+ endActions.run();
}
- };
- if (animated) {
- animateTransition(false /* show */, endActions);
+ });
+ }
+
+ private void runOnMenuIsMeasured(Runnable action) {
+ if (mMenuView.getWidth() == 0 || mMenuView.getHeight() == 0) {
+ // the menu view is not yet measured, postpone showing the animation
+ mMenuView.post(() -> runOnMenuIsMeasured(action));
} else {
- endActions.run();
+ action.run();
}
}
@@ -123,24 +138,58 @@ class BubbleBarMenuViewController {
*/
private void animateTransition(boolean show, Runnable endActions) {
if (mMenuView == null) return;
- mMenuAnimator = PhysicsAnimator.getInstance(mMenuView);
- mMenuAnimator.setDefaultSpringConfig(mMenuSpringConfig);
- mMenuAnimator
- .spring(DynamicAnimation.ALPHA, show ? 1f : 0f)
- .spring(DynamicAnimation.SCALE_Y, show ? 1f : MENU_INITIAL_SCALE)
- .withEndActions(() -> {
- mMenuAnimator = null;
- endActions.run();
- })
- .start();
+ float startValue = show ? 0 : 1;
+ if (mMenuAnimator != null && mMenuAnimator.isRunning()) {
+ startValue = (float) mMenuAnimator.getAnimatedValue();
+ mMenuAnimator.cancel();
+ }
+ ValueAnimator showMenuAnimation = ValueAnimator.ofFloat(startValue, show ? 1 : 0);
+ showMenuAnimation.setDuration(MENU_ANIMATION_DURATION);
+ showMenuAnimation.setInterpolator(Interpolators.EMPHASIZED);
+ showMenuAnimation.addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ mMenuAnimator = null;
+ endActions.run();
+ }
+ });
+ mMenuAnimator = showMenuAnimation;
+ setupAnimatorListener(showMenuAnimation);
+ showMenuAnimation.start();
}
- /** Cancel running animations */
- private void cancelAnimations() {
- if (mMenuAnimator != null) {
- mMenuAnimator.cancel();
- mMenuAnimator = null;
- }
+ /** Setup listener that orchestrates the animation. */
+ private void setupAnimatorListener(ValueAnimator showMenuAnimation) {
+ // Getting views properties start values
+ int widthDiff = mMenuView.getWidth() - mHandleView.getHandleWidth();
+ int handleHeight = mHandleView.getHandleHeight();
+ float targetWidth = mHandleView.getHandleWidth() + widthDiff * WIDTH_SWAP_FRACTION;
+ float targetHeight = targetWidth * mMenuView.getTitleItemHeight() / mMenuView.getWidth();
+ int menuColor = mContext.getColor(com.android.internal.R.color.materialColorSurfaceBright);
+ // Calculating deltas
+ float swapScale = targetWidth / mMenuView.getWidth();
+ float handleWidthDelta = targetWidth - mHandleView.getHandleWidth();
+ float handleHeightDelta = targetHeight - handleHeight;
+ // Setting update listener that will orchestrate the animation
+ showMenuAnimation.addUpdateListener(animator -> {
+ float animationProgress = (float) animator.getAnimatedValue();
+ boolean showHandle = animationProgress <= WIDTH_SWAP_FRACTION;
+ mHandleView.setVisibility(showHandle ? View.VISIBLE : View.GONE);
+ mMenuView.setVisibility(showHandle ? View.GONE : View.VISIBLE);
+ if (showHandle) {
+ float handleAnimationProgress = animationProgress / WIDTH_SWAP_FRACTION;
+ mHandleView.animateHandleForMenu(handleAnimationProgress, handleWidthDelta,
+ handleHeightDelta, menuColor);
+ } else {
+ mMenuView.setTranslationY(mHandleView.getHandlePaddingTop());
+ mMenuView.setPivotY(0);
+ mMenuView.setPivotX((float) mMenuView.getWidth() / 2);
+ float menuAnimationProgress =
+ (animationProgress - WIDTH_SWAP_FRACTION) / (1 - WIDTH_SWAP_FRACTION);
+ float currentMenuScale = swapScale + (1 - swapScale) * menuAnimationProgress;
+ mMenuView.animateFromStartScale(currentMenuScale, menuAnimationProgress);
+ }
+ });
}
/** Sets up and inflate menu views */
@@ -148,9 +197,6 @@ class BubbleBarMenuViewController {
// Menu view setup
mMenuView = (BubbleBarMenuView) LayoutInflater.from(mContext).inflate(
R.layout.bubble_bar_menu_view, mRootView, false);
- mMenuView.setAlpha(0f);
- mMenuView.setPivotY(0f);
- mMenuView.setScaleY(MENU_INITIAL_SCALE);
mMenuView.setOnCloseListener(() -> hideMenu(true /* animated */));
if (mBubble != null) {
mMenuView.updateInfo(mBubble);
@@ -172,12 +218,14 @@ class BubbleBarMenuViewController {
private ArrayList createMenuActions(Bubble bubble) {
ArrayList menuActions = new ArrayList<>();
Resources resources = mContext.getResources();
+ int tintColor = mContext.getColor(com.android.internal.R.color.materialColorOnSurface);
- if (bubble.isConversation()) {
+ if (bubble.isChat()) {
// Don't bubble conversation action
menuActions.add(new BubbleBarMenuView.MenuAction(
Icon.createWithResource(mContext, R.drawable.bubble_ic_stop_bubble),
resources.getString(R.string.bubbles_dont_bubble_conversation),
+ tintColor,
view -> {
hideMenu(true /* animated */);
if (mListener != null) {
@@ -204,7 +252,7 @@ class BubbleBarMenuViewController {
menuActions.add(new BubbleBarMenuView.MenuAction(
Icon.createWithResource(resources, R.drawable.ic_remove_no_shadow),
resources.getString(R.string.bubble_dismiss_text),
- ContextCompat.getColor(mContext, R.color.bubble_bar_expanded_view_menu_close),
+ tintColor,
view -> {
hideMenu(true /* animated */);
if (mListener != null) {
@@ -213,6 +261,21 @@ class BubbleBarMenuViewController {
}
));
+ if (BubbleAnythingFlagHelper.enableBubbleToFullscreen()) {
+ menuActions.add(new BubbleBarMenuView.MenuAction(
+ Icon.createWithResource(resources,
+ R.drawable.desktop_mode_ic_handle_menu_fullscreen),
+ resources.getString(R.string.bubble_fullscreen_text),
+ tintColor,
+ view -> {
+ hideMenu(true /* animated */);
+ if (mListener != null) {
+ mListener.onMoveToFullscreen(bubble);
+ }
+ }
+ ));
+ }
+
return menuActions;
}
@@ -243,5 +306,10 @@ class BubbleBarMenuViewController {
* Dismiss bubble and remove it from the bubble stack
*/
void onDismissBubble(Bubble bubble);
+
+ /**
+ * Move the bubble to fullscreen.
+ */
+ void onMoveToFullscreen(Bubble bubble);
}
}
diff --git a/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleEducationViewController.kt b/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleEducationViewController.kt
index e108f7be48..0bd3a54cee 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleEducationViewController.kt
+++ b/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleEducationViewController.kt
@@ -20,6 +20,7 @@ import android.content.Context
import android.graphics.Point
import android.graphics.Rect
import android.util.Log
+import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -34,9 +35,10 @@ import com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_WITH_CLASS_NAME
import com.android.wm.shell.bubbles.BubbleEducationController
import com.android.wm.shell.bubbles.BubbleViewProvider
import com.android.wm.shell.bubbles.setup
-import com.android.wm.shell.common.bubbles.BubblePopupDrawable
-import com.android.wm.shell.common.bubbles.BubblePopupView
+import com.android.wm.shell.shared.TypefaceUtils
import com.android.wm.shell.shared.animation.PhysicsAnimator
+import com.android.wm.shell.shared.bubbles.BubblePopupDrawable
+import com.android.wm.shell.shared.bubbles.BubblePopupView
import kotlin.math.roundToInt
/** Manages bubble education presentation and animation */
@@ -102,14 +104,21 @@ class BubbleEducationViewController(private val context: Context, private val li
hideEducation(animated = false)
log { "showStackEducation at: $position" }
+ val rootBounds = Rect()
+ // Get root bounds on screen as position is in screen coordinates
+ root.getBoundsOnScreen(rootBounds)
educationView =
createEducationView(R.layout.bubble_bar_stack_education, root).apply {
+ TypefaceUtils.setTypeface(findViewById(R.id.education_title),
+ TypefaceUtils.FontFamily.GSF_HEADLINE_SMALL_EMPHASIZED)
+ TypefaceUtils.setTypeface(findViewById(R.id.education_text),
+ TypefaceUtils.FontFamily.GSF_BODY_MEDIUM)
setArrowDirection(BubblePopupDrawable.ArrowDirection.DOWN)
- setArrowPosition(BubblePopupDrawable.ArrowPosition.End)
- updateEducationPosition(view = this, position, root)
+ updateEducationPosition(view = this, position, rootBounds)
val arrowToEdgeOffset = popupDrawable?.config?.cornerRadius ?: 0f
doOnLayout {
- it.pivotX = it.width - arrowToEdgeOffset
+ it.pivotX = if (position.x < rootBounds.centerX())
+ arrowToEdgeOffset else it.width - arrowToEdgeOffset
it.pivotY = it.height.toFloat()
}
setOnClickListener { educationClickHandler() }
@@ -149,6 +158,10 @@ class BubbleEducationViewController(private val context: Context, private val li
educationView =
createEducationView(R.layout.bubble_bar_manage_education, root).apply {
+ TypefaceUtils.setTypeface(findViewById(R.id.education_manage_title),
+ TypefaceUtils.FontFamily.GSF_HEADLINE_SMALL_EMPHASIZED)
+ TypefaceUtils.setTypeface(findViewById(R.id.education_manage_text),
+ TypefaceUtils.FontFamily.GSF_BODY_MEDIUM)
pivotY = 0f
doOnLayout { it.pivotX = it.width / 2f }
setOnClickListener { hideEducation(animated = true) }
@@ -218,12 +231,9 @@ class BubbleEducationViewController(private val context: Context, private val li
*
* @param view the user education view to layout
* @param position the reference position in Screen coordinates
- * @param root the root view to use for the layout
+ * @param rootBounds bounds of the parent the education view is placed in
*/
- private fun updateEducationPosition(view: BubblePopupView, position: Point, root: ViewGroup) {
- val rootBounds = Rect()
- // Get root bounds on screen as position is in screen coordinates
- root.getBoundsOnScreen(rootBounds)
+ private fun updateEducationPosition(view: BubblePopupView, position: Point, rootBounds: Rect) {
// Get the offset to the arrow from the edge of the education view
val arrowToEdgeOffset =
view.popupDrawable?.config?.let { it.cornerRadius + it.arrowWidth / 2f }?.roundToInt()
@@ -231,7 +241,15 @@ class BubbleEducationViewController(private val context: Context, private val li
// Calculate education view margins
val params = view.layoutParams as FrameLayout.LayoutParams
params.bottomMargin = rootBounds.bottom - position.y
- params.rightMargin = rootBounds.right - position.x - arrowToEdgeOffset
+ if (position.x < rootBounds.centerX()) {
+ params.leftMargin = position.x - arrowToEdgeOffset
+ params.gravity = Gravity.LEFT or Gravity.BOTTOM
+ view.setArrowPosition(BubblePopupDrawable.ArrowPosition.Start)
+ } else {
+ params.rightMargin = rootBounds.right - position.x - arrowToEdgeOffset
+ params.gravity = Gravity.RIGHT or Gravity.BOTTOM
+ view.setArrowPosition(BubblePopupDrawable.ArrowPosition.End)
+ }
view.layoutParams = params
}
diff --git a/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleExpandedViewPinController.kt b/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleExpandedViewPinController.kt
index 651bf022e0..23ba2bff5e 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleExpandedViewPinController.kt
+++ b/wmshell/src/com/android/wm/shell/bubbles/bar/BubbleExpandedViewPinController.kt
@@ -25,8 +25,8 @@ import android.widget.FrameLayout
import androidx.core.view.updateLayoutParams
import com.android.wm.shell.R
import com.android.wm.shell.bubbles.BubblePositioner
-import com.android.wm.shell.common.bubbles.BaseBubblePinController
-import com.android.wm.shell.common.bubbles.BubbleBarLocation
+import com.android.wm.shell.shared.bubbles.BaseBubblePinController
+import com.android.wm.shell.shared.bubbles.BubbleBarLocation
/**
* Controller to manage pinning bubble bar to left or right when dragging starts from the bubble bar
diff --git a/wmshell/src/com/android/wm/shell/bubbles/bar/DragToBubbleController.kt b/wmshell/src/com/android/wm/shell/bubbles/bar/DragToBubbleController.kt
new file mode 100644
index 0000000000..f27691203e
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/bubbles/bar/DragToBubbleController.kt
@@ -0,0 +1,146 @@
+/*
+ * 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.bubbles.bar
+
+import android.app.PendingIntent
+import android.content.Context
+import android.content.pm.ShortcutInfo
+import android.os.UserHandle
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.annotation.VisibleForTesting
+import com.android.wm.shell.bubbles.BubbleController
+import com.android.wm.shell.bubbles.BubblePositioner
+import com.android.wm.shell.draganddrop.DragAndDropController.DragAndDropListener
+import com.android.wm.shell.shared.bubbles.BubbleAnythingFlagHelper
+import com.android.wm.shell.shared.bubbles.BubbleBarLocation
+import com.android.wm.shell.shared.bubbles.ContextUtils.isRtl
+import com.android.wm.shell.shared.bubbles.DragToBubblesZoneChangeListener
+import com.android.wm.shell.shared.bubbles.DragZone
+import com.android.wm.shell.shared.bubbles.DragZoneFactory
+import com.android.wm.shell.shared.bubbles.DragZoneFactory.BubbleBarPropertiesProvider
+import com.android.wm.shell.shared.bubbles.DragZoneFactory.SplitScreenModeChecker.SplitScreenMode
+import com.android.wm.shell.shared.bubbles.DraggedObject.LauncherIcon
+import com.android.wm.shell.shared.bubbles.DropTargetManager
+
+/** Handles scenarios when launcher icon is being dragged to the bubble bar drop zones. */
+class DragToBubbleController(
+ val context: Context,
+ val bubblePositioner: BubblePositioner,
+ val bubbleController: BubbleController,
+) : DragAndDropListener {
+
+ private val containerView: FrameLayout =
+ FrameLayout(context).apply {
+ layoutParams =
+ FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ )
+ }
+
+ @VisibleForTesting
+ val dropTargetManager: DropTargetManager =
+ DropTargetManager(
+ context,
+ containerView,
+ createDragZoneChangedListener()
+ )
+
+ @VisibleForTesting
+ val dragZoneFactory = createDragZoneFactory()
+ private var lastDragZone: DragZone? = null
+
+ /** Returns the container view in which drop targets are added. */
+ fun getDropTargetContainer(): ViewGroup = containerView
+
+ /** Called when the drag is tarted. */
+ override fun onDragStarted() {
+ if (!BubbleAnythingFlagHelper.enableCreateAnyBubble()) {
+ return
+ }
+ val draggedObject = LauncherIcon(bubbleBarHasBubbles = true) {}
+ val dragZones = dragZoneFactory.createSortedDragZones(draggedObject)
+ dropTargetManager.onDragStarted(draggedObject, dragZones)
+ }
+
+ /**
+ * Called when drag position is updated.
+ *
+ * @return true if drag is over any bubble bar drop zones
+ */
+ fun onDragUpdate(x: Int, y: Int): Boolean {
+ lastDragZone = dropTargetManager.onDragUpdated(x, y)
+ return lastDragZone != null
+ }
+
+ /** Called when the item with the [ShortcutInfo] is dropped over the bubble bar drop target. */
+ fun onItemDropped(shortcutInfo: ShortcutInfo) {
+ val dropLocation = lastDragZone?.getBubbleBarLocation() ?: return
+ bubbleController.expandStackAndSelectBubble(shortcutInfo, dropLocation)
+ }
+
+ /**
+ * Called when the item with the [PendingIntent] and the [UserHandle] is dropped over the
+ * bubble bar drop target.
+ */
+ fun onItemDropped(pendingIntent: PendingIntent, userHandle: UserHandle) {
+ val dropLocation = lastDragZone?.getBubbleBarLocation() ?: return
+ bubbleController.expandStackAndSelectBubble(pendingIntent, userHandle, dropLocation)
+ }
+
+ /** Called when the drag is ended. */
+ override fun onDragEnded() {
+ dropTargetManager.onDragEnded()
+ }
+
+ private fun createDragZoneFactory(): DragZoneFactory {
+ return DragZoneFactory(
+ context,
+ bubblePositioner.currentConfig,
+ { SplitScreenMode.UNSUPPORTED },
+ { false },
+ object : BubbleBarPropertiesProvider {},
+ )
+ }
+
+ private fun DragZone.getBubbleBarLocation(): BubbleBarLocation? =
+ when (this) {
+ is DragZone.Bubble.Left -> BubbleBarLocation.LEFT
+ is DragZone.Bubble.Right -> BubbleBarLocation.RIGHT
+ else -> null
+ }
+
+ private fun createDragZoneChangedListener() = DragToBubblesZoneChangeListener(
+ context.isRtl,
+ object : DragToBubblesZoneChangeListener.Callback {
+
+ override fun getStartingBubbleBarLocation(): BubbleBarLocation {
+ return bubbleController.bubbleBarLocation ?: BubbleBarLocation.DEFAULT
+ }
+
+ override fun hasBubbles(): Boolean = bubbleController.hasBubbles()
+
+ override fun animateBubbleBarLocation(bubbleBarLocation: BubbleBarLocation) {
+ bubbleController.animateBubbleBarLocation(bubbleBarLocation)
+ }
+
+ override fun bubbleBarPillowShownAtLocation(bubbleBarLocation: BubbleBarLocation?) {
+ bubbleController.showBubbleBarPinAtLocation(bubbleBarLocation)
+ }
+ })
+}
diff --git a/wmshell/src/com/android/wm/shell/bubbles/shortcut/CreateBubbleShortcutActivity.kt b/wmshell/src/com/android/wm/shell/bubbles/shortcut/CreateBubbleShortcutActivity.kt
index a124f95d74..c93c11eb2f 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/shortcut/CreateBubbleShortcutActivity.kt
+++ b/wmshell/src/com/android/wm/shell/bubbles/shortcut/CreateBubbleShortcutActivity.kt
@@ -20,10 +20,10 @@ import android.app.Activity
import android.content.pm.ShortcutManager
import android.graphics.drawable.Icon
import android.os.Bundle
+import com.android.internal.protolog.ProtoLog
import com.android.wm.shell.Flags
import com.android.wm.shell.R
import com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_BUBBLES
-import com.android.wm.shell.util.KtProtoLog
/** Activity to create a shortcut to open bubbles */
class CreateBubbleShortcutActivity : Activity() {
@@ -31,7 +31,7 @@ class CreateBubbleShortcutActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (Flags.enableRetrievableBubbles()) {
- KtProtoLog.d(WM_SHELL_BUBBLES, "Creating a shortcut for bubbles")
+ ProtoLog.d(WM_SHELL_BUBBLES, "Creating a shortcut for bubbles")
createShortcut()
}
finish()
diff --git a/wmshell/src/com/android/wm/shell/bubbles/shortcut/ShowBubblesActivity.kt b/wmshell/src/com/android/wm/shell/bubbles/shortcut/ShowBubblesActivity.kt
index ae7940ca1b..e578e9e769 100644
--- a/wmshell/src/com/android/wm/shell/bubbles/shortcut/ShowBubblesActivity.kt
+++ b/wmshell/src/com/android/wm/shell/bubbles/shortcut/ShowBubblesActivity.kt
@@ -21,9 +21,9 @@ import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.Bundle
+import com.android.internal.protolog.ProtoLog
import com.android.wm.shell.Flags
import com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_BUBBLES
-import com.android.wm.shell.util.KtProtoLog
/** Activity that sends a broadcast to open bubbles */
class ShowBubblesActivity : Activity() {
@@ -37,7 +37,7 @@ class ShowBubblesActivity : Activity() {
// Set the package as the receiver is not exported
`package` = packageName
}
- KtProtoLog.v(WM_SHELL_BUBBLES, "Sending broadcast to show bubbles")
+ ProtoLog.v(WM_SHELL_BUBBLES, "Sending broadcast to show bubbles")
sendBroadcast(intent)
}
finish()
diff --git a/wmshell/src/com/android/wm/shell/bubbles/util/BubbleUtils.kt b/wmshell/src/com/android/wm/shell/bubbles/util/BubbleUtils.kt
new file mode 100644
index 0000000000..604037683f
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/bubbles/util/BubbleUtils.kt
@@ -0,0 +1,141 @@
+/*
+ * 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.bubbles.util
+
+import android.app.ActivityManager
+import android.app.WindowConfiguration
+import android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN
+import android.graphics.Rect
+import android.os.Binder
+import android.view.WindowInsets
+import android.window.WindowContainerToken
+import android.window.WindowContainerTransaction
+import com.android.window.flags.Flags
+import com.android.wm.shell.shared.bubbles.BubbleAnythingFlagHelper
+
+object BubbleUtils {
+
+ /**
+ * Returns a [WindowContainerTransaction] that includes the necessary operations of entering or
+ * exiting Bubble.
+ */
+ private fun getBubbleTransaction(
+ token: WindowContainerToken,
+ toBubble: Boolean,
+ isAppBubble: Boolean,
+ reparentToTda: Boolean,
+ captionInsetsOwner: Binder?,
+ ): WindowContainerTransaction {
+ val wct = WindowContainerTransaction()
+ if (reparentToTda) {
+ // Reparenting must happen before setAlwaysOnTop() below since WCT operations are
+ // applied in order and always-on-top for nested tasks is not supported
+ wct.reparent(token, null, true)
+ }
+ wct.setWindowingMode(
+ token,
+ if (toBubble)
+ WindowConfiguration.WINDOWING_MODE_MULTI_WINDOW
+ else
+ WindowConfiguration.WINDOWING_MODE_UNDEFINED,
+ )
+ wct.setInterceptBackPressedOnTaskRoot(token, toBubble)
+ if (!BubbleAnythingFlagHelper.enableRootTaskForBubble()) {
+ wct.setAlwaysOnTop(token, toBubble /* alwaysOnTop */)
+ }
+ if (!toBubble || isAppBubble) {
+ // We only set launch next to Bubble for App Bubble, since new Task opened from Chat
+ // Bubble should be launched in fullscreen.
+ // Always reset everything when exit bubble.
+ wct.setLaunchNextToBubble(token, toBubble /* launchNextToBubble */)
+ }
+ if (Flags.excludeTaskFromRecents()) {
+ wct.setTaskForceExcludedFromRecents(token, toBubble /* forceExcluded */)
+ }
+ wct.setDisablePip(token, toBubble /* disablePip */)
+ if (BubbleAnythingFlagHelper.enableCreateAnyBubble()) {
+ wct.setDisableLaunchAdjacent(token, toBubble /* disableLaunchAdjacent */)
+ if (!toBubble) {
+ // Clear bounds if moving out of Bubble.
+ wct.setBounds(token, Rect())
+ }
+ }
+ if (BubbleAnythingFlagHelper.enableCreateAnyBubbleWithAppCompatFixes()) {
+ if (!toBubble && captionInsetsOwner != null) {
+ wct.removeInsetsSource(
+ token, captionInsetsOwner, 0 /* index */, WindowInsets.Type.captionBar()
+ )
+ }
+ }
+ return wct
+ }
+
+ /**
+ * Returns a [WindowContainerTransaction] that includes the necessary operations of entering
+ * Bubble.
+ *
+ * @param isAppBubble App Bubble has some different UX from Chat Bubble.
+ * @param reparentToTda Whether to reparent the task to the ancestor TaskDisplayArea (for if
+ * this task is a child of another root task)
+ */
+ @JvmOverloads
+ @JvmStatic
+ fun getEnterBubbleTransaction(
+ token: WindowContainerToken,
+ isAppBubble: Boolean,
+ reparentToTda: Boolean = false,
+ ): WindowContainerTransaction {
+ return getBubbleTransaction(
+ token,
+ toBubble = true,
+ isAppBubble,
+ reparentToTda,
+ captionInsetsOwner = null,
+ )
+ }
+
+ /**
+ * Returns a [WindowContainerTransaction] that includes the necessary operations of exiting
+ * Bubble.
+ */
+ @JvmStatic
+ fun getExitBubbleTransaction(
+ token: WindowContainerToken,
+ captionInsetsOwner: Binder?,
+ ): WindowContainerTransaction {
+ return getBubbleTransaction(
+ token,
+ toBubble = false,
+ // Everything will be reset, so doesn't matter for exit.
+ isAppBubble = true,
+ reparentToTda = false,
+ captionInsetsOwner,
+ )
+ }
+
+ /** Returns true if the task is valid for Bubble. */
+ @JvmStatic
+ fun isValidToBubble(taskInfo: ActivityManager.RunningTaskInfo?): Boolean {
+ return taskInfo != null && taskInfo.supportsMultiWindow
+ }
+
+ /** Determines if a bubble task is moving to fullscreen based on its windowing mode. */
+ fun isBubbleToFullscreen(task: ActivityManager.RunningTaskInfo?): Boolean {
+ return BubbleAnythingFlagHelper.enableCreateAnyBubbleWithForceExcludedFromRecents()
+ && task?.windowingMode == WINDOWING_MODE_FULLSCREEN
+ }
+}
diff --git a/wmshell/src/com/android/wm/shell/common/BoostExecutor.kt b/wmshell/src/com/android/wm/shell/common/BoostExecutor.kt
new file mode 100644
index 0000000000..498d0e406e
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/common/BoostExecutor.kt
@@ -0,0 +1,47 @@
+/*
+ * 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.common
+
+import android.os.Looper
+import java.util.concurrent.Executor
+
+/** Executor implementation which can be boosted temporarily to a different thread priority. */
+interface BoostExecutor : Executor {
+ /**
+ * Requests that the executor is boosted until {@link #resetBoost()} is called.
+ */
+ fun setBoost() {}
+
+ /**
+ * Requests that the executor is not boosted (only resets if there are no other boost requests
+ * in progress).
+ */
+ fun resetBoost() {}
+
+ /**
+ * Returns whether the executor is boosted.
+ */
+ fun isBoosted() : Boolean {
+ return false
+ }
+
+ /**
+ * Returns the looper for this executor.
+ */
+ fun getLooper() : Looper? {
+ return Looper.myLooper()
+ }
+}
diff --git a/wmshell/src/com/android/wm/shell/common/BoxShadowHelper.java b/wmshell/src/com/android/wm/shell/common/BoxShadowHelper.java
new file mode 100644
index 0000000000..43016f9119
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/common/BoxShadowHelper.java
@@ -0,0 +1,83 @@
+/*
+ * 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.common;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.gui.BorderSettings;
+import android.gui.BoxShadowSettings;
+
+import com.android.wm.shell.R;
+
+/**
+ * This class has helper functions for obtaining box shadow and border parameters from
+ * resource ids.
+ */
+public class BoxShadowHelper {
+ /**
+ * Gets border settings from an id.
+ *
+ * @return the border settings.
+ */
+ public static BorderSettings getBorderSettings(Context context, int borderSettingsId) {
+ final TypedArray attr = context.obtainStyledAttributes(
+ borderSettingsId, R.styleable.BorderSettings);
+ final BorderSettings result = new BorderSettings();
+ result.strokeWidth =
+ attr.getDimension(
+ R.styleable.BorderSettings_borderStrokeWidth, 0f);
+ result.color =
+ attr.getColor(
+ R.styleable.BorderSettings_borderColor, 0);
+ attr.recycle();
+ return result;
+ }
+
+ /**
+ * Gets box shadow settings from an id.
+ *
+ * @return the box shadow settings.
+ */
+ public static BoxShadowSettings getBoxShadowSettings(Context context,
+ int[] boxShadowSettingsIds) {
+ final BoxShadowSettings result = new BoxShadowSettings();
+ result.boxShadows =
+ new BoxShadowSettings.BoxShadowParams[boxShadowSettingsIds.length];
+ for (int i = 0; i < boxShadowSettingsIds.length; i++) {
+ final TypedArray attr = context.obtainStyledAttributes(
+ boxShadowSettingsIds[i], R.styleable.BoxShadowSettings);
+
+ final BoxShadowSettings.BoxShadowParams box =
+ new BoxShadowSettings.BoxShadowParams();
+ box.blurRadius = attr.getDimension(
+ R.styleable.BoxShadowSettings_boxShadowBlurRadius, 0f);
+ box.spreadRadius = attr.getDimension(
+ R.styleable.BoxShadowSettings_boxShadowSpreadRadius, 0f);
+ box.offsetX = attr.getDimension(
+ R.styleable.BoxShadowSettings_boxShadowOffsetX, 0f);
+ box.offsetY = attr.getDimension(
+ R.styleable.BoxShadowSettings_boxShadowOffsetY, 0f);
+ box.color = attr.getColor(
+ R.styleable.BoxShadowSettings_boxShadowColor, 0);
+
+ result.boxShadows[i] = box;
+
+ attr.recycle();
+ }
+ return result;
+ }
+}
diff --git a/wmshell/src/com/android/wm/shell/common/ComponentUtils.kt b/wmshell/src/com/android/wm/shell/common/ComponentUtils.kt
new file mode 100644
index 0000000000..5a69547a45
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/common/ComponentUtils.kt
@@ -0,0 +1,45 @@
+/*
+ * 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.common
+
+import android.app.PendingIntent
+import android.app.TaskInfo
+import android.content.ComponentName
+import android.content.Intent
+import com.android.wm.shell.ShellTaskOrganizer
+
+/** Utils to obtain [ComponentName]s. */
+object ComponentUtils {
+ /** Retrieves the package name from an [Intent]. */
+ @JvmStatic
+ fun getPackageName(intent: Intent?): String? =
+ intent?.component?.packageName ?: intent?.`package`
+
+ /** Retrieves the package name from a [PendingIntent]. */
+ @JvmStatic
+ fun getPackageName(pendingIntent: PendingIntent?): String? =
+ getPackageName(pendingIntent?.intent)
+
+ /** Retrieves the package name from a [taskId]. */
+ @JvmStatic
+ fun getPackageName(taskId: Int, taskOrganizer: ShellTaskOrganizer): String? {
+ return getPackageName(taskOrganizer.getRunningTaskInfo(taskId))
+ }
+
+ /** Retrieves the package name from a [TaskInfo]. */
+ @JvmStatic
+ fun getPackageName(taskInfo: TaskInfo?): String? = getPackageName(taskInfo?.baseIntent)
+}
diff --git a/wmshell/src/com/android/wm/shell/common/DisplayChangeController.java b/wmshell/src/com/android/wm/shell/common/DisplayChangeController.java
index 2873d58439..8039369ab3 100644
--- a/wmshell/src/com/android/wm/shell/common/DisplayChangeController.java
+++ b/wmshell/src/com/android/wm/shell/common/DisplayChangeController.java
@@ -17,6 +17,7 @@
package com.android.wm.shell.common;
import android.annotation.Nullable;
+import android.graphics.Rect;
import android.os.RemoteException;
import android.os.Trace;
import android.util.Slog;
@@ -28,6 +29,7 @@ import android.window.WindowContainerTransaction;
import androidx.annotation.BinderThread;
+import com.android.internal.annotations.VisibleForTesting;
import com.android.wm.shell.shared.annotations.ShellMainThread;
import com.android.wm.shell.sysui.ShellInit;
@@ -43,6 +45,7 @@ public class DisplayChangeController {
private static final String TAG = DisplayChangeController.class.getSimpleName();
private static final String HANDLE_DISPLAY_CHANGE_TRACE_TAG = "HandleRemoteDisplayChange";
+ private final DisplayController mDisplayController;
private final ShellExecutor mMainExecutor;
private final IWindowManager mWmService;
private final IDisplayChangeWindowController mControllerImpl;
@@ -50,8 +53,9 @@ public class DisplayChangeController {
private final CopyOnWriteArrayList mDisplayChangeListener =
new CopyOnWriteArrayList<>();
- public DisplayChangeController(IWindowManager wmService, ShellInit shellInit,
- ShellExecutor mainExecutor) {
+ public DisplayChangeController(DisplayController displayController, IWindowManager wmService,
+ ShellInit shellInit, ShellExecutor mainExecutor) {
+ mDisplayController = displayController;
mMainExecutor = mainExecutor;
mWmService = wmService;
mControllerImpl = new DisplayChangeWindowControllerImpl();
@@ -94,8 +98,21 @@ public class DisplayChangeController {
}
}
- private void onDisplayChange(int displayId, int fromRotation, int toRotation,
+ @VisibleForTesting
+ void onDisplayChange(int displayId, int fromRotation, int toRotation,
DisplayAreaInfo newDisplayAreaInfo, IDisplayChangeWindowCallback callback) {
+ final DisplayLayout dl = mDisplayController.getDisplayLayout(displayId);
+ if (dl != null && newDisplayAreaInfo != null) {
+ // Note: there is a chance Transitions has triggered
+ // DisplayController#onDisplayChangeRequested first, in which case layout was updated
+ // and startBounds equals endBounds; then DisplayLayout size remains the same.
+ // TODO(b/370721807): Remove DisplayChangeWindowControllerImpl and rely on transitions.
+ final Rect startBounds = new Rect(0, 0, dl.width(), dl.height());
+ final Rect endBounds = newDisplayAreaInfo.configuration.windowConfiguration.getBounds();
+ mDisplayController.updateDisplayLayout(displayId, startBounds, endBounds,
+ fromRotation, toRotation);
+ }
+
WindowContainerTransaction t = new WindowContainerTransaction();
dispatchOnDisplayChange(t, displayId, fromRotation, toRotation, newDisplayAreaInfo);
try {
diff --git a/wmshell/src/com/android/wm/shell/common/DisplayController.java b/wmshell/src/com/android/wm/shell/common/DisplayController.java
index dcbc72ab0d..6db7434f57 100644
--- a/wmshell/src/com/android/wm/shell/common/DisplayController.java
+++ b/wmshell/src/com/android/wm/shell/common/DisplayController.java
@@ -16,29 +16,39 @@
package com.android.wm.shell.common;
+import static android.app.WindowConfiguration.ROTATION_UNDEFINED;
+
+import android.annotation.NonNull;
import android.annotation.Nullable;
import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Rect;
+import android.graphics.RectF;
import android.hardware.display.DisplayManager;
+import android.hardware.display.DisplayTopology;
import android.os.RemoteException;
import android.util.ArraySet;
+import android.util.Size;
import android.util.Slog;
import android.util.SparseArray;
import android.view.Display;
import android.view.IDisplayWindowListener;
import android.view.IWindowManager;
import android.view.InsetsState;
+import android.window.DesktopExperienceFlags;
import android.window.WindowContainerTransaction;
import androidx.annotation.BinderThread;
import com.android.wm.shell.common.DisplayChangeController.OnDisplayChangingListener;
import com.android.wm.shell.shared.annotations.ShellMainThread;
+import com.android.wm.shell.shared.desktopmode.DesktopState;
import com.android.wm.shell.sysui.ShellInit;
import java.util.ArrayList;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import java.util.Set;
/**
@@ -53,19 +63,26 @@ public class DisplayController {
private final ShellExecutor mMainExecutor;
private final Context mContext;
private final IWindowManager mWmService;
+ private final DisplayManager mDisplayManager;
private final DisplayChangeController mChangeController;
private final IDisplayWindowListener mDisplayContainerListener;
+ private final DesktopState mDesktopState;
private final SparseArray mDisplays = new SparseArray<>();
private final ArrayList mDisplayChangedListeners = new ArrayList<>();
+ private final Map mUnpopulatedDisplayBounds = new HashMap<>();
+ private DisplayTopology mDisplayTopology;
public DisplayController(Context context, IWindowManager wmService, ShellInit shellInit,
- ShellExecutor mainExecutor) {
+ ShellExecutor mainExecutor, DisplayManager displayManager,
+ DesktopState desktopState) {
mMainExecutor = mainExecutor;
mContext = context;
mWmService = wmService;
- // TODO: Inject this instead
- mChangeController = new DisplayChangeController(mWmService, shellInit, mainExecutor);
+ mDisplayManager = displayManager;
+ mDesktopState = desktopState;
+ mChangeController = new DisplayChangeController(this, wmService, shellInit,
+ mainExecutor);
mDisplayContainerListener = new DisplayWindowListenerImpl();
// Note, add this after DisplaceChangeController is constructed to ensure that is
// initialized first
@@ -73,7 +90,7 @@ public class DisplayController {
}
/**
- * Initializes the window listener.
+ * Initializes the window listener and the topology listener.
*/
public void onInit() {
try {
@@ -81,6 +98,13 @@ public class DisplayController {
for (int i = 0; i < displayIds.length; i++) {
onDisplayAdded(displayIds[i]);
}
+
+ if (DesktopExperienceFlags.ENABLE_CONNECTED_DISPLAYS_WINDOW_DRAG.isTrue()
+ && mDesktopState.canEnterDesktopMode()) {
+ mDisplayManager.registerTopologyListener(mMainExecutor,
+ this::onDisplayTopologyChanged);
+ onDisplayTopologyChanged(mDisplayManager.getDisplayTopology());
+ }
} catch (RemoteException e) {
throw new RuntimeException("Unable to register display controller");
}
@@ -90,8 +114,15 @@ public class DisplayController {
* Gets a display by id from DisplayManager.
*/
public Display getDisplay(int displayId) {
- final DisplayManager displayManager = mContext.getSystemService(DisplayManager.class);
- return displayManager.getDisplay(displayId);
+ return mDisplayManager.getDisplay(displayId);
+ }
+
+ /**
+ * Returns true if the display with the given displayId is part of the topology.
+ */
+ public boolean isDisplayInTopology(int displayId) {
+ return mDisplayTopology != null
+ && mDisplayTopology.findDisplay(displayId, mDisplayTopology.getRoot()) != null;
}
/**
@@ -141,6 +172,7 @@ public class DisplayController {
for (int i = 0; i < mDisplays.size(); ++i) {
listener.onDisplayAdded(mDisplays.keyAt(i));
}
+ listener.onTopologyChanged(mDisplayTopology);
}
}
@@ -182,8 +214,17 @@ public class DisplayController {
final Context context = (displayId == Display.DEFAULT_DISPLAY)
? mContext
: mContext.createDisplayContext(display);
- final DisplayRecord record = new DisplayRecord(displayId);
- record.setDisplayLayout(context, new DisplayLayout(context, display));
+ boolean hasStatusAndNavBars = false;
+ if (DesktopExperienceFlags.ENABLE_DISPLAY_CONTENT_MODE_MANAGEMENT.isTrue()) {
+ hasStatusAndNavBars = mDesktopState.isDesktopModeSupportedOnDisplay(displayId);
+ }
+ final DisplayRecord record = new DisplayRecord(displayId, hasStatusAndNavBars);
+ DisplayLayout displayLayout = record.createLayout(context, display);
+ if (DesktopExperienceFlags.ENABLE_CONNECTED_DISPLAYS_WINDOW_DRAG.isTrue()
+ && mUnpopulatedDisplayBounds.containsKey(displayId)) {
+ displayLayout.setGlobalBoundsDp(mUnpopulatedDisplayBounds.get(displayId));
+ }
+ record.setDisplayLayout(context, displayLayout);
mDisplays.put(displayId, record);
for (int i = 0; i < mDisplayChangedListeners.size(); ++i) {
mDisplayChangedListeners.get(i).onDisplayAdded(displayId);
@@ -191,26 +232,64 @@ public class DisplayController {
}
}
-
/** Called when a display rotate requested. */
- public void onDisplayRotateRequested(WindowContainerTransaction wct, int displayId,
- int fromRotation, int toRotation) {
+ public void onDisplayChangeRequested(WindowContainerTransaction wct, int displayId,
+ Rect startAbsBounds, Rect endAbsBounds, int fromRotation, int toRotation) {
synchronized (mDisplays) {
- final DisplayRecord dr = mDisplays.get(displayId);
- if (dr == null) {
+ final DisplayLayout dl = getDisplayLayout(displayId);
+ if (dl == null) {
Slog.w(TAG, "Skipping Display rotate on non-added display.");
return;
}
-
- if (dr.mDisplayLayout != null) {
- dr.mDisplayLayout.rotateTo(dr.mContext.getResources(), toRotation);
- }
+ updateDisplayLayout(displayId, startAbsBounds, endAbsBounds, fromRotation, toRotation);
mChangeController.dispatchOnDisplayChange(
wct, displayId, fromRotation, toRotation, null /* newDisplayAreaInfo */);
}
}
+ void updateDisplayLayout(int displayId,
+ @NonNull Rect startBounds, @Nullable Rect endBounds, int fromRotation, int toRotation) {
+ final DisplayLayout dl = getDisplayLayout(displayId);
+ final Context ctx = getDisplayContext(displayId);
+ if (dl == null || ctx == null) return;
+
+ if (endBounds != null) {
+ // Note that endAbsBounds should ignore any potential rotation changes, so
+ // we still need to rotate the layout after if needed.
+ dl.resizeTo(ctx.getResources(), new Size(endBounds.width(), endBounds.height()));
+ }
+ if (fromRotation != toRotation && toRotation != ROTATION_UNDEFINED) {
+ dl.rotateTo(ctx.getResources(), toRotation);
+ }
+ }
+
+ private void onDisplayTopologyChanged(DisplayTopology topology) {
+ if (topology == null) {
+ return;
+ }
+ mDisplayTopology = topology;
+ SparseArray absoluteBounds = topology.getAbsoluteBounds();
+ mUnpopulatedDisplayBounds.clear();
+ for (int i = 0; i < absoluteBounds.size(); ++i) {
+ int displayId = absoluteBounds.keyAt(i);
+ DisplayLayout displayLayout = getDisplayLayout(displayId);
+ if (displayLayout == null) {
+ // onDisplayTopologyChanged can arrive before onDisplayAdded.
+ // Store the bounds to be applied later in onDisplayAdded.
+ Slog.d(TAG, "Storing bounds for onDisplayTopologyChanged on unknown"
+ + " display, displayId=" + displayId);
+ mUnpopulatedDisplayBounds.put(displayId, absoluteBounds.valueAt(i));
+ } else {
+ displayLayout.setGlobalBoundsDp(absoluteBounds.valueAt(i));
+ }
+ }
+
+ for (int i = 0; i < mDisplayChangedListeners.size(); ++i) {
+ mDisplayChangedListeners.get(i).onTopologyChanged(topology);
+ }
+ }
+
private void onDisplayConfigurationChanged(int displayId, Configuration newConfig) {
synchronized (mDisplays) {
final DisplayRecord dr = mDisplays.get(displayId);
@@ -229,7 +308,13 @@ public class DisplayController {
? mContext
: mContext.createDisplayContext(display);
final Context context = perDisplayContext.createConfigurationContext(newConfig);
- dr.setDisplayLayout(context, new DisplayLayout(context, display));
+ final DisplayLayout displayLayout = dr.createLayout(context, display);
+ if (mDisplayTopology != null) {
+ displayLayout.setGlobalBoundsDp(
+ mDisplayTopology.getAbsoluteBounds().get(
+ displayId, displayLayout.globalBoundsDp()));
+ }
+ dr.setDisplayLayout(context, displayLayout);
for (int i = 0; i < mDisplayChangedListeners.size(); ++i) {
mDisplayChangedListeners.get(i).onDisplayConfigurationChanged(
displayId, newConfig);
@@ -291,14 +376,56 @@ public class DisplayController {
}
}
+ private void onDesktopModeEligibleChanged(int displayId) {
+ synchronized (mDisplays) {
+ DisplayRecord r = mDisplays.get(displayId);
+ Display display = getDisplay(displayId);
+ if (r == null || display == null) {
+ Slog.w(TAG, "Skipping onDesktopModeEligibleChanged on unknown"
+ + " display, displayId=" + displayId);
+ return;
+ }
+ if (DesktopExperienceFlags.ENABLE_DISPLAY_CONTENT_MODE_MANAGEMENT.isTrue()) {
+ r.updateHasStatusAndNavBars(display,
+ mDesktopState.isDesktopModeSupportedOnDisplay(display));
+ }
+ for (int i = mDisplayChangedListeners.size() - 1; i >= 0; --i) {
+ mDisplayChangedListeners.get(i).onDesktopModeEligibleChanged(displayId);
+ }
+ }
+ }
+
private static class DisplayRecord {
- private int mDisplayId;
+ private final int mDisplayId;
private Context mContext;
private DisplayLayout mDisplayLayout;
private InsetsState mInsetsState = new InsetsState();
+ private boolean mHasStatusAndNavBars;
- private DisplayRecord(int displayId) {
+ private DisplayRecord(int displayId, boolean hasStatusAndNavBars) {
mDisplayId = displayId;
+ mHasStatusAndNavBars = hasStatusAndNavBars;
+ }
+
+ private DisplayLayout createLayout(Context context, Display display) {
+ if (mDisplayId != Display.DEFAULT_DISPLAY && mHasStatusAndNavBars) {
+ return new DisplayLayout(context, display, true /* hasNavigationBar */,
+ true /* hasTaskBar */);
+ } else {
+ return new DisplayLayout(context, display);
+ }
+ }
+
+
+ private void updateHasStatusAndNavBars(Display display, boolean hasStatusAndNavBars) {
+ if (mHasStatusAndNavBars == hasStatusAndNavBars) {
+ return;
+ }
+ mHasStatusAndNavBars = hasStatusAndNavBars;
+ // Don't change how DEFAULT_DISPLAY is handled: the default heuristic is correct.
+ if (mDisplayId != Display.DEFAULT_DISPLAY) {
+ setDisplayLayout(mContext, createLayout(mContext, display));
+ }
}
private void setDisplayLayout(Context context, DisplayLayout displayLayout) {
@@ -358,6 +485,19 @@ public class DisplayController {
new ArraySet<>(restricted), new ArraySet<>(unrestricted));
});
}
+
+ @Override
+ public void onDesktopModeEligibleChanged(int displayId) {
+ mMainExecutor.execute(() -> {
+ DisplayController.this.onDesktopModeEligibleChanged(displayId);
+ });
+ }
+
+ @Override
+ public void onDisplayAddSystemDecorations(int displayId) { }
+
+ @Override
+ public void onDisplayRemoveSystemDecorations(int displayId) { }
}
/**
@@ -398,5 +538,15 @@ public class DisplayController {
*/
default void onKeepClearAreasChanged(int displayId, Set restricted,
Set unrestricted) {}
+
+ /**
+ * Called when the display topology has changed.
+ */
+ default void onTopologyChanged(DisplayTopology topology) {}
+
+ /**
+ * Called when the eligibility of the desktop mode for a display have changed.
+ */
+ default void onDesktopModeEligibleChanged(int displayId) {}
}
}
diff --git a/wmshell/src/com/android/wm/shell/common/DisplayImeController.java b/wmshell/src/com/android/wm/shell/common/DisplayImeController.java
index f4ac5f260f..bf4c6e3173 100644
--- a/wmshell/src/com/android/wm/shell/common/DisplayImeController.java
+++ b/wmshell/src/com/android/wm/shell/common/DisplayImeController.java
@@ -21,6 +21,8 @@ import static android.view.EventLogTags.IMF_IME_REMOTE_ANIM_END;
import static android.view.EventLogTags.IMF_IME_REMOTE_ANIM_START;
import static android.view.inputmethod.ImeTracker.DEBUG_IME_VISIBILITY;
+import static com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_IME_CONTROLLER;
+
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ValueAnimator;
@@ -32,6 +34,7 @@ import android.content.res.Configuration;
import android.graphics.Point;
import android.graphics.Rect;
import android.os.RemoteException;
+import android.text.TextUtils;
import android.util.EventLog;
import android.util.Slog;
import android.util.SparseArray;
@@ -52,6 +55,8 @@ import android.view.inputmethod.InputMethodManagerGlobal;
import androidx.annotation.VisibleForTesting;
import com.android.internal.inputmethod.SoftInputShowHideReason;
+import com.android.internal.protolog.ProtoLog;
+import com.android.wm.shell.shared.TransactionPool;
import com.android.wm.shell.sysui.ShellInit;
import java.util.ArrayList;
@@ -64,8 +69,6 @@ import java.util.concurrent.Executor;
public class DisplayImeController implements DisplayController.OnDisplaysChangedListener {
private static final String TAG = "DisplayImeController";
- private static final boolean DEBUG = false;
-
// NOTE: All these constants came from InsetsController.
public static final int ANIMATION_DURATION_SHOW_MS = 275;
public static final int ANIMATION_DURATION_HIDE_MS = 340;
@@ -156,6 +159,14 @@ public class DisplayImeController implements DisplayController.OnDisplaysChanged
}
}
+ private void dispatchImeRequested(int displayId, boolean isRequested) {
+ synchronized (mPositionProcessors) {
+ for (ImePositionProcessor pp : mPositionProcessors) {
+ pp.onImeRequested(displayId, isRequested);
+ }
+ }
+ }
+
@ImePositionProcessor.ImeAnimationFlags
private int dispatchStartPositioning(int displayId, int hiddenTop, int shownTop,
boolean show, boolean isFloating, SurfaceControl.Transaction t) {
@@ -215,11 +226,22 @@ public class DisplayImeController implements DisplayController.OnDisplaysChanged
}
}
+ /** Hides the IME for Bubbles when the device is locked. */
+ public void hideImeForBubblesWhenLocked(int displayId) {
+ PerDisplay pd = mImePerDisplay.get(displayId);
+ InsetsSourceControl imeSourceControl = pd.getImeSourceControl();
+ if (imeSourceControl != null) {
+ final var statsToken = ImeTracker.forLogging().onStart(ImeTracker.TYPE_HIDE,
+ ImeTracker.ORIGIN_WM_SHELL,
+ SoftInputShowHideReason.HIDE_FOR_BUBBLES_WHEN_LOCKED, false /* fromUser */);
+ pd.setImeInputTargetRequestedVisibility(false, statsToken);
+ }
+ }
+
/** An implementation of {@link IDisplayWindowInsetsController} for a given display id. */
public class PerDisplay implements DisplayInsetsController.OnInsetsChangedListener {
final int mDisplayId;
final InsetsState mInsetsState = new InsetsState();
- @InsetsType int mRequestedVisibleTypes = WindowInsets.Type.defaultVisible();
boolean mImeRequestedVisible =
(WindowInsets.Type.defaultVisible() & WindowInsets.Type.ime()) != 0;
InsetsSourceControl mImeSourceControl = null;
@@ -248,11 +270,7 @@ public class DisplayImeController implements DisplayController.OnDisplaysChanged
if (mInsetsState.equals(insetsState)) {
return;
}
-
- if (!android.view.inputmethod.Flags.refactorInsetsController()) {
- updateImeVisibility(insetsState.isSourceOrDefaultVisible(InsetsSource.ID_IME,
- WindowInsets.Type.ime()));
- }
+ ProtoLog.d(WM_SHELL_IME_CONTROLLER, "Insets changed, state=%s", insetsState);
final InsetsSource newSource = insetsState.peekSource(InsetsSource.ID_IME);
final Rect newFrame = newSource != null ? newSource.getFrame() : null;
@@ -262,7 +280,8 @@ public class DisplayImeController implements DisplayController.OnDisplaysChanged
mInsetsState.set(insetsState, true /* copySources */);
if (mImeShowing && !Objects.equals(oldFrame, newFrame) && newSourceVisible) {
- if (DEBUG) Slog.d(TAG, "insetsChanged when IME showing, restart animation");
+ ProtoLog.d(WM_SHELL_IME_CONTROLLER,
+ "insetsChanged when IME showing, restart animation");
startAnimation(mImeShowing, true /* forceRestart */,
SoftInputShowHideReason.DISPLAY_INSETS_CHANGED);
}
@@ -272,6 +291,9 @@ public class DisplayImeController implements DisplayController.OnDisplaysChanged
@VisibleForTesting
public void insetsControlChanged(InsetsState insetsState,
InsetsSourceControl[] activeControls) {
+ ProtoLog.d(WM_SHELL_IME_CONTROLLER, "Insets control changed, state=%s controls=%s",
+ insetsState,
+ activeControls != null ? TextUtils.join(", ", activeControls) : "null");
insetsChanged(insetsState);
InsetsSourceControl imeSourceControl = null;
if (activeControls != null) {
@@ -290,63 +312,58 @@ public class DisplayImeController implements DisplayController.OnDisplaysChanged
if (hadImeSourceControl != hasImeSourceControl) {
dispatchImeControlTargetChanged(mDisplayId, hasImeSourceControl);
}
+ final boolean hasImeLeash = hasImeSourceControl && imeSourceControl.getLeash() != null;
boolean pendingImeStartAnimation = false;
- boolean canAnimate;
- if (android.view.inputmethod.Flags.refactorInsetsController()) {
- canAnimate = hasImeSourceControl && imeSourceControl.getLeash() != null;
- } else {
- canAnimate = hasImeSourceControl;
- }
-
boolean positionChanged = false;
- if (canAnimate) {
+ if (hasImeLeash) {
+ final Point lastSurfacePosition = hadImeSourceControl
+ ? mImeSourceControl.getSurfacePosition() : null;
+ positionChanged = !imeSourceControl.getSurfacePosition().equals(
+ lastSurfacePosition);
if (mAnimation != null) {
- final Point lastSurfacePosition = hadImeSourceControl
- ? mImeSourceControl.getSurfacePosition() : null;
- positionChanged = !imeSourceControl.getSurfacePosition().equals(
- lastSurfacePosition);
+ if (positionChanged) {
+ // For showing the IME, the leash has to be available first. Hiding
+ // the IME happens directly via {@link #hideInsets} (triggered by
+ // setImeInputTargetRequestedVisibility) while the leash is not gone
+ // yet.
+ pendingImeStartAnimation = true;
+ }
} else {
if (!haveSameLeash(mImeSourceControl, imeSourceControl)) {
+ pendingImeStartAnimation = true;
+ // The starting point for the IME should be it's previous state
+ // (whether it is initiallyVisible or not)
+ updateImeVisibility(imeSourceControl.isInitiallyVisible());
applyVisibilityToLeash(imeSourceControl);
-
- if (android.view.inputmethod.Flags.refactorInsetsController()) {
- pendingImeStartAnimation = true;
- }
- }
- if (!mImeShowing) {
- removeImeSurface();
}
}
- } else if (!android.view.inputmethod.Flags.refactorInsetsController()
- && mAnimation != null) {
- // we don"t want to cancel the hide animation, when the control is lost, but
- // continue the bar to slide to the end (even without visible IME)
- mAnimation.cancel();
- }
- if (positionChanged) {
- if (android.view.inputmethod.Flags.refactorInsetsController()) {
- // For showing the IME, the leash has to be available first. Hiding
- // the IME happens directly via {@link #hideInsets} (triggered by
- // setImeInputTargetRequestedVisibility) while the leash is not gone
- // yet.
- pendingImeStartAnimation = true;
- } else {
- startAnimation(mImeShowing, true /* forceRestart */,
- SoftInputShowHideReason.DISPLAY_CONTROLS_CHANGED);
- }
+ } else if (mImeShowing && mAnimation == null) {
+ // There is no leash, so the IME cannot be in a showing state
+ updateImeVisibility(false);
}
+ // Make mImeSourceControl point to the new control before starting the animation.
if (hadImeSourceControl && mImeSourceControl != imeSourceControl) {
mImeSourceControl.release(SurfaceControl::release);
+ if (!hasImeLeash && mAnimation != null) {
+ // In case of losing the leash, the animation should be cancelled.
+ mAnimation.cancel();
+ }
}
mImeSourceControl = imeSourceControl;
- if (android.view.inputmethod.Flags.refactorInsetsController()) {
- if (pendingImeStartAnimation) {
- startAnimation(true, true /* forceRestart */,
- null /* statsToken */);
- }
+ if (pendingImeStartAnimation) {
+ startAnimation(mImeRequestedVisible, true /* forceRestart */);
+ } else if (positionChanged) {
+ // If the leash is the same, but it has changed its position while no
+ // animation is ongoing, just update the position without starting a new
+ // animation.
+ SurfaceControl.Transaction t = mTransactionPool.acquire();
+ final var position = mImeSourceControl.getSurfacePosition();
+ t.setPosition(mImeSourceControl.getLeash(), position.x, position.y);
+ t.apply();
+ mTransactionPool.release(t);
}
}
@@ -365,22 +382,22 @@ public class DisplayImeController implements DisplayController.OnDisplaysChanged
}
@Override
- public void showInsets(@InsetsType int types, boolean fromIme,
- @Nullable ImeTracker.Token statsToken) {
+ public void showInsets(@InsetsType int types, @Nullable ImeTracker.Token statsToken) {
if ((types & WindowInsets.Type.ime()) == 0) {
return;
}
- if (DEBUG) Slog.d(TAG, "Got showInsets for ime");
+ ProtoLog.d(WM_SHELL_IME_CONTROLLER, "Ime shown, statsToken=%s",
+ statsToken != null ? statsToken.getBinder() : "null");
startAnimation(true /* show */, false /* forceRestart */, statsToken);
}
@Override
- public void hideInsets(@InsetsType int types, boolean fromIme,
- @Nullable ImeTracker.Token statsToken) {
+ public void hideInsets(@InsetsType int types, @Nullable ImeTracker.Token statsToken) {
if ((types & WindowInsets.Type.ime()) == 0) {
return;
}
- if (DEBUG) Slog.d(TAG, "Got hideInsets for ime");
+ ProtoLog.d(WM_SHELL_IME_CONTROLLER, "Ime hidden, statsToken=%s",
+ statsToken != null ? statsToken.getBinder() : "null");
startAnimation(false /* show */, false /* forceRestart */, statsToken);
}
@@ -391,35 +408,68 @@ public class DisplayImeController implements DisplayController.OnDisplaysChanged
@Override
// TODO(b/335404678): pass control target
- public void setImeInputTargetRequestedVisibility(boolean visible) {
- if (android.view.inputmethod.Flags.refactorInsetsController()) {
- mImeRequestedVisible = visible;
- // In the case that the IME becomes visible, but we have the control with leash
- // already (e.g., when focussing an editText in activity B, while and editText in
- // activity A is focussed), we will not get a call of #insetsControlChanged, and
- // therefore have to start the show animation from here
+ public void setImeInputTargetRequestedVisibility(boolean visible,
+ @NonNull ImeTracker.Token statsToken) {
+ ProtoLog.d(WM_SHELL_IME_CONTROLLER,
+ "Input target requested visibility, visible=%b statsToken=%s",
+ visible, statsToken != null ? statsToken.getBinder() : "null");
+ ImeTracker.forLogging().onProgress(statsToken,
+ ImeTracker.PHASE_WM_DISPLAY_IME_CONTROLLER_SET_IME_REQUESTED_VISIBLE);
+ mImeRequestedVisible = visible;
+ dispatchImeRequested(mDisplayId, mImeRequestedVisible);
+
+ // In the case that the IME becomes visible, but we have the control with leash
+ // already (e.g., when focussing an editText in activity B, while and editText in
+ // activity A is focussed), we will not get a call of #insetsControlChanged, and
+ // therefore have to start the show animation from here
+ if (visible || mImeShowing) {
+ // only start the animation if we're either already showing or becoming visible.
+ // otherwise starting another hide animation causes flickers.
startAnimation(mImeRequestedVisible /* show */, false /* forceRestart */,
- null /* TODO statsToken */);
+ statsToken);
}
+
+ boolean hideAnimOngoing;
+ boolean reportVisible;
+ if (android.view.inputmethod.Flags.reportAnimatingInsetsTypes()) {
+ hideAnimOngoing = false;
+ reportVisible = mImeRequestedVisible;
+ } else {
+ // In case of a hide, the statsToken should not been send yet (as the animation
+ // is still ongoing). It will be sent at the end of the animation.
+ hideAnimOngoing = !mImeRequestedVisible && mAnimation != null;
+ reportVisible = mImeRequestedVisible || mAnimation != null;
+ }
+ setVisibleDirectly(reportVisible, hideAnimOngoing ? null : statsToken);
}
/**
* Sends the local visibility state back to window manager. Needed for legacy adjustForIme.
*/
- private void setVisibleDirectly(boolean visible) {
+ private void setVisibleDirectly(boolean visible, @Nullable ImeTracker.Token statsToken) {
mInsetsState.setSourceVisible(InsetsSource.ID_IME, visible);
- mRequestedVisibleTypes = visible
- ? mRequestedVisibleTypes | WindowInsets.Type.ime()
- : mRequestedVisibleTypes & ~WindowInsets.Type.ime();
+ int visibleTypes = visible ? WindowInsets.Type.ime() : 0;
try {
mWmService.updateDisplayWindowRequestedVisibleTypes(mDisplayId,
- mRequestedVisibleTypes);
+ visibleTypes, WindowInsets.Type.ime(), statsToken);
} catch (RemoteException e) {
}
}
- private int imeTop(float surfaceOffset) {
- return mImeFrame.top + (int) surfaceOffset;
+ private void setAnimating(boolean imeAnimationOngoing,
+ @Nullable ImeTracker.Token statsToken) {
+ int animatingTypes = imeAnimationOngoing ? WindowInsets.Type.ime() : 0;
+ try {
+ mWmService.updateDisplayWindowAnimatingTypes(mDisplayId, animatingTypes,
+ statsToken);
+ } catch (RemoteException e) {
+ }
+ }
+
+ private int imeTop(float surfaceOffset, float surfacePositionY) {
+ // surfaceOffset is already offset by the surface's top inset, so we need to subtract
+ // the top inset so that the return value is in screen coordinates.
+ return mImeFrame.top + (int) (surfaceOffset - surfacePositionY);
}
private boolean calcIsFloating(InsetsSource imeSource) {
@@ -436,29 +486,50 @@ public class DisplayImeController implements DisplayController.OnDisplaysChanged
.navBarFrameHeight();
}
+ private void startAnimation(final boolean show, final boolean forceRestart) {
+ final var imeSource = mInsetsState.peekSource(InsetsSource.ID_IME);
+ if (imeSource == null || mImeSourceControl == null) {
+ return;
+ }
+ // TODO(b/353463205): For hide: this still has the statsToken from the previous show
+ // request
+ final var statsToken = mImeSourceControl.getImeStatsToken();
+
+ startAnimation(show, forceRestart, statsToken);
+ }
+
private void startAnimation(final boolean show, final boolean forceRestart,
@SoftInputShowHideReason int reason) {
final var imeSource = mInsetsState.peekSource(InsetsSource.ID_IME);
if (imeSource == null || mImeSourceControl == null) {
return;
}
- final var statsToken = ImeTracker.forLogging().onStart(
- show ? ImeTracker.TYPE_SHOW : ImeTracker.TYPE_HIDE, ImeTracker.ORIGIN_WM_SHELL,
- reason, false /* fromUser */);
-
+ final ImeTracker.Token statsToken;
+ if (mImeSourceControl.getImeStatsToken() != null) {
+ statsToken = mImeSourceControl.getImeStatsToken();
+ } else {
+ statsToken = ImeTracker.forLogging().onStart(
+ show ? ImeTracker.TYPE_SHOW : ImeTracker.TYPE_HIDE,
+ ImeTracker.ORIGIN_WM_SHELL, reason, false /* fromUser */);
+ }
startAnimation(show, forceRestart, statsToken);
}
private void startAnimation(final boolean show, final boolean forceRestart,
@NonNull final ImeTracker.Token statsToken) {
- if (android.view.inputmethod.Flags.refactorInsetsController()) {
- if (mImeSourceControl == null || mImeSourceControl.getLeash() == null) {
- if (DEBUG) Slog.d(TAG, "No leash available, not starting the animation.");
- return;
- }
+ if (mImeSourceControl == null || mImeSourceControl.getLeash() == null) {
+ ProtoLog.d(WM_SHELL_IME_CONTROLLER, "No Ime leash for animation");
+ return;
+ }
+ if (!mImeRequestedVisible && show) {
+ // we have a control with leash, but the IME was not requested visible before,
+ // therefore aborting the show animation.
+ Slog.e(TAG, "IME was not requested visible, not starting the show animation.");
+ // TODO(b/353463205) fail statsToken here
+ return;
}
final InsetsSource imeSource = mInsetsState.peekSource(InsetsSource.ID_IME);
- if (imeSource == null || mImeSourceControl == null) {
+ if (imeSource == null) {
ImeTracker.forLogging().onFailed(statsToken, ImeTracker.PHASE_WM_ANIMATION_CREATE);
return;
}
@@ -475,11 +546,13 @@ public class DisplayImeController implements DisplayController.OnDisplaysChanged
// Don't set a new frame if it's empty and hiding -- this maintains continuity
mImeFrame.set(newFrame);
}
- if (DEBUG) {
- Slog.d(TAG, "Run startAnim show:" + show + " was:"
- + (mAnimationDirection == DIRECTION_SHOW ? "SHOW"
- : (mAnimationDirection == DIRECTION_HIDE ? "HIDE" : "NONE")));
- }
+ final String prevVisibility = mAnimationDirection == DIRECTION_SHOW
+ ? "SHOW"
+ : mAnimationDirection == DIRECTION_HIDE
+ ? "HIDE"
+ : "NONE";
+ ProtoLog.d(WM_SHELL_IME_CONTROLLER, "Run Ime animation, show=%b was=%s",
+ show, prevVisibility);
if ((!forceRestart && (mAnimationDirection == DIRECTION_SHOW && show))
|| (mAnimationDirection == DIRECTION_HIDE && !show)) {
ImeTracker.forLogging().onCancelled(
@@ -490,42 +563,53 @@ public class DisplayImeController implements DisplayController.OnDisplaysChanged
float seekValue = 0;
if (mAnimation != null) {
if (mAnimation.isRunning()) {
- seekValue = (float) mAnimation.getAnimatedValue();
+ seekValue = mAnimationDirection == DIRECTION_SHOW && !show
+ // If we were showing previously (and now hiding), we need to use the
+ // inverse.
+ ? 1f - (float) mAnimation.getAnimatedValue()
+ : (float) mAnimation.getAnimatedValue();
seek = true;
}
mAnimation.cancel();
}
- final float defaultY = mImeSourceControl.getSurfacePosition().y;
- final float x = mImeSourceControl.getSurfacePosition().x;
+ final InsetsSourceControl animatingControl = new InsetsSourceControl(mImeSourceControl);
+ final SurfaceControl animatingLeash = animatingControl.getLeash();
+ final float defaultY = animatingControl.getSurfacePosition().y;
+ final float initialX = animatingControl.getSurfacePosition().x;
final float hiddenY = defaultY + mImeFrame.height();
final float shownY = defaultY;
final float startY = show ? hiddenY : shownY;
final float endY = show ? shownY : hiddenY;
if (mAnimationDirection == DIRECTION_NONE && mImeShowing && show) {
// IME is already showing, so set seek to end
- seekValue = shownY;
+ seekValue = 1f;
seek = true;
}
mAnimationDirection = show ? DIRECTION_SHOW : DIRECTION_HIDE;
updateImeVisibility(show);
- mAnimation = ValueAnimator.ofFloat(startY, endY);
+ mAnimation = show
+ ? ValueAnimator.ofFloat(0f, 1f)
+ : ValueAnimator.ofFloat(1f, 0f);
mAnimation.setDuration(
show ? ANIMATION_DURATION_SHOW_MS : ANIMATION_DURATION_HIDE_MS);
if (seek) {
- mAnimation.setCurrentFraction((seekValue - startY) / (endY - startY));
+ mAnimation.setCurrentFraction(seekValue);
+ } else {
+ // In some cases the value in onAnimationStart is zero, therefore setting it
+ // explicitly to startY
+ mAnimation.setCurrentFraction(0);
}
mAnimation.addUpdateListener(animation -> {
SurfaceControl.Transaction t = mTransactionPool.acquire();
- float value = (float) animation.getAnimatedValue();
- if (!android.view.inputmethod.Flags.refactorInsetsController() || (
- mImeSourceControl != null && mImeSourceControl.getLeash() != null)) {
- t.setPosition(mImeSourceControl.getLeash(), x, value);
- final float alpha = (mAnimateAlpha || isFloating)
- ? (value - hiddenY) / (shownY - hiddenY) : 1.f;
- t.setAlpha(mImeSourceControl.getLeash(), alpha);
- }
- dispatchPositionChanged(mDisplayId, imeTop(value), t);
+ final float value = (float) animation.getAnimatedValue();
+ final int x = mImeSourceControl.getSurfacePosition().x;
+ final int initialY = mImeSourceControl.getSurfacePosition().y;
+ final int y = (int) (initialY + (1f - value) * mImeFrame.height());
+ t.setPosition(animatingLeash, x, y);
+ final float alpha = (mAnimateAlpha || isFloating) ? value : 1f;
+ t.setAlpha(animatingLeash, alpha);
+ dispatchPositionChanged(mDisplayId, imeTop(y, initialY), t);
t.apply();
mTransactionPool.release(t);
});
@@ -539,33 +623,38 @@ public class DisplayImeController implements DisplayController.OnDisplaysChanged
@Override
public void onAnimationStart(Animator animation) {
ValueAnimator valueAnimator = (ValueAnimator) animation;
- float value = (float) valueAnimator.getAnimatedValue();
+ final float value = (float) valueAnimator.getAnimatedValue();
SurfaceControl.Transaction t = mTransactionPool.acquire();
- t.setPosition(mImeSourceControl.getLeash(), x, value);
- if (DEBUG) {
- Slog.d(TAG, "onAnimationStart d:" + mDisplayId + " top:"
- + imeTop(hiddenY) + "->" + imeTop(shownY)
- + " showing:" + (mAnimationDirection == DIRECTION_SHOW));
+ t.setPosition(animatingLeash, initialX, startY);
+
+ ProtoLog.d(WM_SHELL_IME_CONTROLLER,
+ "Ime animation start, d=%d top=%d->%d showing=%b",
+ mDisplayId, imeTop(hiddenY, defaultY), imeTop(shownY, defaultY),
+ (mAnimationDirection == DIRECTION_SHOW));
+
+ if (android.view.inputmethod.Flags.reportAnimatingInsetsTypes()) {
+ // Updating the animatingTypes when starting the animation is not the
+ // trigger to show the IME. Thus, not sending the statsToken here.
+ setAnimating(true /* imeAnimationOngoing */, null /* statsToken */);
}
- int flags = dispatchStartPositioning(mDisplayId, imeTop(hiddenY),
- imeTop(shownY), mAnimationDirection == DIRECTION_SHOW, isFloating, t);
+ int flags = dispatchStartPositioning(mDisplayId, imeTop(hiddenY, defaultY),
+ imeTop(shownY, defaultY), mAnimationDirection == DIRECTION_SHOW,
+ isFloating, t);
mAnimateAlpha = (flags & ImePositionProcessor.IME_ANIMATION_NO_ALPHA) == 0;
- final float alpha = (mAnimateAlpha || isFloating)
- ? (value - hiddenY) / (shownY - hiddenY)
- : 1.f;
- t.setAlpha(mImeSourceControl.getLeash(), alpha);
+ final float alpha = (mAnimateAlpha || isFloating) ? value : 1f;
+ t.setAlpha(animatingLeash, alpha);
if (mAnimationDirection == DIRECTION_SHOW) {
ImeTracker.forLogging().onProgress(mStatsToken,
ImeTracker.PHASE_WM_ANIMATION_RUNNING);
- t.show(mImeSourceControl.getLeash());
+ t.show(animatingLeash);
}
if (DEBUG_IME_VISIBILITY) {
EventLog.writeEvent(IMF_IME_REMOTE_ANIM_START,
mStatsToken != null ? mStatsToken.getTag() : ImeTracker.TOKEN_NONE,
mDisplayId, mAnimationDirection, alpha, value, endY,
- Objects.toString(mImeSourceControl.getLeash()),
- Objects.toString(mImeSourceControl.getInsetsHint()),
- Objects.toString(mImeSourceControl.getSurfacePosition()),
+ Objects.toString(animatingLeash),
+ Objects.toString(animatingControl.getInsetsHint()),
+ Objects.toString(animatingControl.getSurfacePosition()),
Objects.toString(mImeFrame));
}
t.apply();
@@ -579,50 +668,55 @@ public class DisplayImeController implements DisplayController.OnDisplaysChanged
EventLog.writeEvent(IMF_IME_REMOTE_ANIM_CANCEL,
mStatsToken != null ? mStatsToken.getTag() : ImeTracker.TOKEN_NONE,
mDisplayId,
- Objects.toString(mImeSourceControl.getInsetsHint()));
+ Objects.toString(animatingControl.getInsetsHint()));
}
}
@Override
public void onAnimationEnd(Animator animation) {
- boolean hasLeash =
- mImeSourceControl != null && mImeSourceControl.getLeash() != null;
- if (DEBUG) Slog.d(TAG, "onAnimationEnd " + mCancelled);
+ ProtoLog.d(WM_SHELL_IME_CONTROLLER, "Ime animation end, canceled=%b",
+ mCancelled);
SurfaceControl.Transaction t = mTransactionPool.acquire();
if (!mCancelled) {
- if (!android.view.inputmethod.Flags.refactorInsetsController()
- || hasLeash) {
- t.setPosition(mImeSourceControl.getLeash(), x, endY);
- t.setAlpha(mImeSourceControl.getLeash(), 1.f);
- }
+ final int x = mImeSourceControl.getSurfacePosition().x;
+ final int y = mImeSourceControl.getSurfacePosition().y
+ + (show ? 0 : mImeFrame.height());
+ t.setPosition(animatingLeash, x, y);
+ t.setAlpha(animatingLeash, 1f);
+ }
+ if (android.view.inputmethod.Flags.reportAnimatingInsetsTypes()) {
+ setAnimating(false /* imeAnimationOngoing */,
+ mAnimationDirection == DIRECTION_HIDE ? statsToken : null);
}
- dispatchEndPositioning(mDisplayId, mCancelled, t);
if (mAnimationDirection == DIRECTION_HIDE && !mCancelled) {
ImeTracker.forLogging().onProgress(mStatsToken,
ImeTracker.PHASE_WM_ANIMATION_RUNNING);
- if (!android.view.inputmethod.Flags.refactorInsetsController()
- || hasLeash) {
- t.hide(mImeSourceControl.getLeash());
- }
- removeImeSurface();
- ImeTracker.forLogging().onHidden(mStatsToken);
+ t.hide(animatingLeash);
+ // Updating the client visibility will not hide the IME, unless it is
+ // not animating anymore. Thus, not sending a statsToken here, but
+ // only later when we're updating the animatingTypes.
+ setVisibleDirectly(false /* visible */,
+ !android.view.inputmethod.Flags.reportAnimatingInsetsTypes()
+ ? statsToken : null);
} else if (mAnimationDirection == DIRECTION_SHOW && !mCancelled) {
ImeTracker.forLogging().onShown(mStatsToken);
} else if (mCancelled) {
ImeTracker.forLogging().onCancelled(mStatsToken,
ImeTracker.PHASE_WM_ANIMATION_RUNNING);
}
+ // In split screen, we also set {@link
+ // WindowContainer#mExcludeInsetsTypes} but this should only happen after
+ // the IME client visibility was set. Otherwise the insets will we
+ // dispatched too early, and we get a flicker. Thus, only dispatching it
+ // after reporting that the IME is hidden to system server.
+ dispatchEndPositioning(mDisplayId, mCancelled, t);
if (DEBUG_IME_VISIBILITY) {
EventLog.writeEvent(IMF_IME_REMOTE_ANIM_END,
mStatsToken != null ? mStatsToken.getTag() : ImeTracker.TOKEN_NONE,
mDisplayId, mAnimationDirection, endY,
- Objects.toString(
- mImeSourceControl != null ? mImeSourceControl.getLeash()
- : "null"),
- Objects.toString(mImeSourceControl != null
- ? mImeSourceControl.getInsetsHint() : "null"),
- Objects.toString(mImeSourceControl != null
- ? mImeSourceControl.getSurfacePosition() : "null"),
+ Objects.toString(animatingLeash),
+ Objects.toString(animatingControl.getInsetsHint()),
+ Objects.toString(animatingControl.getSurfacePosition()),
Objects.toString(mImeFrame));
}
t.apply();
@@ -630,19 +724,10 @@ public class DisplayImeController implements DisplayController.OnDisplaysChanged
mAnimationDirection = DIRECTION_NONE;
mAnimation = null;
+ animatingControl.release(SurfaceControl::release);
}
});
- if (!show) {
- // When going away, queue up insets change first, otherwise any bounds changes
- // can have a "flicker" of ime-provided insets.
- setVisibleDirectly(false /* visible */);
- }
mAnimation.start();
- if (show) {
- // When showing away, queue up insets change last, otherwise any bounds changes
- // can have a "flicker" of ime-provided insets.
- setVisibleDirectly(true /* visible */);
- }
}
private void updateImeVisibility(boolean isShowing) {
@@ -658,10 +743,10 @@ public class DisplayImeController implements DisplayController.OnDisplaysChanged
}
}
- void removeImeSurface() {
+ void removeImeSurface(int displayId) {
// Remove the IME surface to make the insets invisible for
// non-client controlled insets.
- InputMethodManagerGlobal.removeImeSurface(
+ InputMethodManagerGlobal.removeImeSurface(displayId,
e -> Slog.e(TAG, "Failed to remove IME surface.", e));
}
@@ -669,6 +754,10 @@ public class DisplayImeController implements DisplayController.OnDisplaysChanged
* Allows other things to synchronize with the ime position
*/
public interface ImePositionProcessor {
+
+ /** Default animation flags. */
+ int IME_ANIMATION_DEFAULT = 0;
+
/**
* Indicates that ime shouldn't animate alpha. It will always be opaque. Used when stuff
* behind the IME shouldn't be visible (for example during split-screen adjustment where
@@ -678,11 +767,20 @@ public class DisplayImeController implements DisplayController.OnDisplaysChanged
/** @hide */
@IntDef(prefix = {"IME_ANIMATION_"}, value = {
+ IME_ANIMATION_DEFAULT,
IME_ANIMATION_NO_ALPHA,
})
@interface ImeAnimationFlags {
}
+ /**
+ * Called when the IME was requested by an app
+ *
+ * @param isRequested {@code true} if the IME was requested to be visible
+ */
+ default void onImeRequested(int displayId, boolean isRequested) {
+ }
+
/**
* Called when the IME position is starting to animate.
*
@@ -696,7 +794,7 @@ public class DisplayImeController implements DisplayController.OnDisplaysChanged
@ImeAnimationFlags
default int onImeStartPositioning(int displayId, int hiddenTop, int shownTop,
boolean showing, boolean isFloating, SurfaceControl.Transaction t) {
- return 0;
+ return IME_ANIMATION_DEFAULT;
}
/**
diff --git a/wmshell/src/com/android/wm/shell/common/DisplayInsetsController.java b/wmshell/src/com/android/wm/shell/common/DisplayInsetsController.java
index 1fb0e1745e..6deb03e561 100644
--- a/wmshell/src/com/android/wm/shell/common/DisplayInsetsController.java
+++ b/wmshell/src/com/android/wm/shell/common/DisplayInsetsController.java
@@ -16,6 +16,7 @@
package com.android.wm.shell.common;
+import android.annotation.NonNull;
import android.annotation.Nullable;
import android.content.ComponentName;
import android.os.RemoteException;
@@ -47,6 +48,8 @@ public class DisplayInsetsController implements DisplayController.OnDisplaysChan
private final SparseArray mInsetsPerDisplay = new SparseArray<>();
private final SparseArray> mListeners =
new SparseArray<>();
+ private final CopyOnWriteArrayList mGlobalListeners =
+ new CopyOnWriteArrayList<>();
public DisplayInsetsController(IWindowManager wmService,
ShellInit shellInit,
@@ -80,6 +83,16 @@ public class DisplayInsetsController implements DisplayController.OnDisplaysChan
}
}
+ /**
+ * Adds a callback to listen for insets changes for any display. Note that the
+ * listener will not be updated with the existing state of the insets on any display.
+ */
+ public void addGlobalInsetsChangedListener(OnInsetsChangedListener listener) {
+ if (!mGlobalListeners.contains(listener)) {
+ mGlobalListeners.add(listener);
+ }
+ }
+
/**
* Removes a callback listening for insets changes from a particular display.
*/
@@ -91,6 +104,13 @@ public class DisplayInsetsController implements DisplayController.OnDisplaysChan
listeners.remove(listener);
}
+ /**
+ * Removes a callback listening for insets changes from any display.
+ */
+ public void removeGlobalInsetsChangedListener(OnInsetsChangedListener listener) {
+ mGlobalListeners.remove(listener);
+ }
+
@Override
public void onDisplayAdded(int displayId) {
PerDisplay pd = new PerDisplay(displayId);
@@ -138,12 +158,17 @@ public class DisplayInsetsController implements DisplayController.OnDisplaysChan
private void insetsChanged(InsetsState insetsState) {
CopyOnWriteArrayList listeners = mListeners.get(mDisplayId);
- if (listeners == null) {
+ if (listeners == null && mGlobalListeners.isEmpty()) {
return;
}
mDisplayController.updateDisplayInsets(mDisplayId, insetsState);
- for (OnInsetsChangedListener listener : listeners) {
- listener.insetsChanged(insetsState);
+ for (OnInsetsChangedListener listener : mGlobalListeners) {
+ listener.insetsChanged(mDisplayId, insetsState);
+ }
+ if (listeners != null) {
+ for (OnInsetsChangedListener listener : listeners) {
+ listener.insetsChanged(mDisplayId, insetsState);
+ }
}
}
@@ -158,8 +183,7 @@ public class DisplayInsetsController implements DisplayController.OnDisplaysChan
}
}
- private void showInsets(@InsetsType int types, boolean fromIme,
- @Nullable ImeTracker.Token statsToken) {
+ private void showInsets(@InsetsType int types, @Nullable ImeTracker.Token statsToken) {
CopyOnWriteArrayList listeners = mListeners.get(mDisplayId);
if (listeners == null) {
ImeTracker.forLogging().onFailed(
@@ -169,12 +193,11 @@ public class DisplayInsetsController implements DisplayController.OnDisplaysChan
ImeTracker.forLogging().onProgress(
statsToken, ImeTracker.PHASE_WM_REMOTE_INSETS_CONTROLLER);
for (OnInsetsChangedListener listener : listeners) {
- listener.showInsets(types, fromIme, statsToken);
+ listener.showInsets(types, statsToken);
}
}
- private void hideInsets(@InsetsType int types, boolean fromIme,
- @Nullable ImeTracker.Token statsToken) {
+ private void hideInsets(@InsetsType int types, @Nullable ImeTracker.Token statsToken) {
CopyOnWriteArrayList listeners = mListeners.get(mDisplayId);
if (listeners == null) {
ImeTracker.forLogging().onFailed(
@@ -184,7 +207,7 @@ public class DisplayInsetsController implements DisplayController.OnDisplaysChan
ImeTracker.forLogging().onProgress(
statsToken, ImeTracker.PHASE_WM_REMOTE_INSETS_CONTROLLER);
for (OnInsetsChangedListener listener : listeners) {
- listener.hideInsets(types, fromIme, statsToken);
+ listener.hideInsets(types, statsToken);
}
}
@@ -199,13 +222,14 @@ public class DisplayInsetsController implements DisplayController.OnDisplaysChan
}
}
- private void setImeInputTargetRequestedVisibility(boolean visible) {
+ private void setImeInputTargetRequestedVisibility(boolean visible,
+ @NonNull ImeTracker.Token statsToken) {
CopyOnWriteArrayList listeners = mListeners.get(mDisplayId);
if (listeners == null) {
return;
}
for (OnInsetsChangedListener listener : listeners) {
- listener.setImeInputTargetRequestedVisibility(visible);
+ listener.setImeInputTargetRequestedVisibility(visible, statsToken);
}
}
@@ -236,26 +260,27 @@ public class DisplayInsetsController implements DisplayController.OnDisplaysChan
}
@Override
- public void showInsets(@InsetsType int types, boolean fromIme,
- @Nullable ImeTracker.Token statsToken) throws RemoteException {
- mMainExecutor.execute(() -> {
- PerDisplay.this.showInsets(types, fromIme, statsToken);
- });
- }
-
- @Override
- public void hideInsets(@InsetsType int types, boolean fromIme,
- @Nullable ImeTracker.Token statsToken) throws RemoteException {
- mMainExecutor.execute(() -> {
- PerDisplay.this.hideInsets(types, fromIme, statsToken);
- });
- }
-
- @Override
- public void setImeInputTargetRequestedVisibility(boolean visible)
+ public void showInsets(@InsetsType int types, @Nullable ImeTracker.Token statsToken)
throws RemoteException {
mMainExecutor.execute(() -> {
- PerDisplay.this.setImeInputTargetRequestedVisibility(visible);
+ PerDisplay.this.showInsets(types, statsToken);
+ });
+ }
+
+ @Override
+ public void hideInsets(@InsetsType int types, @Nullable ImeTracker.Token statsToken)
+ throws RemoteException {
+ mMainExecutor.execute(() -> {
+ PerDisplay.this.hideInsets(types, statsToken);
+ });
+ }
+
+ @Override
+ public void setImeInputTargetRequestedVisibility(boolean visible,
+ @NonNull ImeTracker.Token statsToken)
+ throws RemoteException {
+ mMainExecutor.execute(() -> {
+ PerDisplay.this.setImeInputTargetRequestedVisibility(visible, statsToken);
});
}
}
@@ -284,6 +309,13 @@ public class DisplayInsetsController implements DisplayController.OnDisplaysChan
*/
default void insetsChanged(InsetsState insetsState) {}
+ /**
+ * Called when the window insets configuration has changed for the given display.
+ */
+ default void insetsChanged(int displayId, InsetsState insetsState) {
+ insetsChanged(insetsState);
+ }
+
/**
* Called when this window retrieved control over a specified set of insets sources.
*/
@@ -294,27 +326,26 @@ public class DisplayInsetsController implements DisplayController.OnDisplaysChan
* Called when a set of insets source window should be shown by policy.
*
* @param types {@link InsetsType} to show
- * @param fromIme true if this request originated from IME (InputMethodService).
* @param statsToken the token tracking the current IME request or {@code null} otherwise.
*/
- default void showInsets(@InsetsType int types, boolean fromIme,
- @Nullable ImeTracker.Token statsToken) {}
+ default void showInsets(@InsetsType int types, @Nullable ImeTracker.Token statsToken) {}
/**
* Called when a set of insets source window should be hidden by policy.
*
* @param types {@link InsetsType} to hide
- * @param fromIme true if this request originated from IME (InputMethodService).
* @param statsToken the token tracking the current IME request or {@code null} otherwise.
*/
- default void hideInsets(@InsetsType int types, boolean fromIme,
- @Nullable ImeTracker.Token statsToken) {}
+ default void hideInsets(@InsetsType int types, @Nullable ImeTracker.Token statsToken) {}
/**
* Called to set the requested visibility of the IME in DisplayImeController. Invoked by
* {@link com.android.server.wm.DisplayContent.RemoteInsetsControlTarget}.
* @param visible requested status of the IME
+ * @param statsToken the token tracking the current IME request
*/
- default void setImeInputTargetRequestedVisibility(boolean visible) {}
+ default void setImeInputTargetRequestedVisibility(boolean visible,
+ @NonNull ImeTracker.Token statsToken) {
+ }
}
}
diff --git a/wmshell/src/com/android/wm/shell/common/DisplayLayout.java b/wmshell/src/com/android/wm/shell/common/DisplayLayout.java
index 86cec02ab1..82b31ee8ef 100644
--- a/wmshell/src/com/android/wm/shell/common/DisplayLayout.java
+++ b/wmshell/src/com/android/wm/shell/common/DisplayLayout.java
@@ -31,10 +31,13 @@ import android.content.ContentResolver;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Insets;
+import android.graphics.PointF;
import android.graphics.Rect;
+import android.graphics.RectF;
import android.os.SystemProperties;
import android.provider.Settings;
import android.util.DisplayMetrics;
+import android.util.Size;
import android.view.Display;
import android.view.DisplayCutout;
import android.view.DisplayInfo;
@@ -70,9 +73,12 @@ public class DisplayLayout {
public static final int NAV_BAR_RIGHT = 1 << 1;
public static final int NAV_BAR_BOTTOM = 1 << 2;
+ private static final String TAG = "DisplayLayout";
+
private int mUiMode;
private int mWidth;
private int mHeight;
+ private RectF mGlobalBoundsDp;
private DisplayCutout mCutout;
private int mRotation;
private int mDensityDpi;
@@ -81,6 +87,7 @@ public class DisplayLayout {
private boolean mHasNavigationBar = false;
private boolean mHasStatusBar = false;
private int mNavBarFrameHeight = 0;
+ private int mTaskbarFrameHeight = 0;
private boolean mAllowSeamlessRotationDespiteNavBarMoving = false;
private boolean mNavigationBarCanMove = false;
private boolean mReverseDefaultRotation = false;
@@ -107,6 +114,7 @@ public class DisplayLayout {
return mUiMode == other.mUiMode
&& mWidth == other.mWidth
&& mHeight == other.mHeight
+ && Objects.equals(mGlobalBoundsDp, other.mGlobalBoundsDp)
&& Objects.equals(mCutout, other.mCutout)
&& mRotation == other.mRotation
&& mDensityDpi == other.mDensityDpi
@@ -119,14 +127,15 @@ public class DisplayLayout {
&& mNavigationBarCanMove == other.mNavigationBarCanMove
&& mReverseDefaultRotation == other.mReverseDefaultRotation
&& mNavBarFrameHeight == other.mNavBarFrameHeight
+ && mTaskbarFrameHeight == other.mTaskbarFrameHeight
&& Objects.equals(mInsetsState, other.mInsetsState);
}
@Override
public int hashCode() {
- return Objects.hash(mUiMode, mWidth, mHeight, mCutout, mRotation, mDensityDpi,
- mNonDecorInsets, mStableInsets, mHasNavigationBar, mHasStatusBar,
- mNavBarFrameHeight, mAllowSeamlessRotationDespiteNavBarMoving,
+ return Objects.hash(mUiMode, mWidth, mHeight, mGlobalBoundsDp, mCutout, mRotation,
+ mDensityDpi, mNonDecorInsets, mStableInsets, mHasNavigationBar, mHasStatusBar,
+ mNavBarFrameHeight, mTaskbarFrameHeight, mAllowSeamlessRotationDespiteNavBarMoving,
mNavigationBarCanMove, mReverseDefaultRotation, mInsetsState);
}
@@ -146,6 +155,20 @@ public class DisplayLayout {
init(info, res, hasNavigationBar, hasStatusBar);
}
+ /**
+ * Construct a display layout based on a live display.
+ * @param context Used for resources.
+ * @param rawDisplay Display object for the layout
+ * @param hasNavigationBar whether the navigation bar is visible on that display
+ * @param hasStatusBar whether the status bar is visible on that display
+ */
+ public DisplayLayout(@NonNull Context context, @NonNull Display rawDisplay,
+ boolean hasNavigationBar, boolean hasStatusBar) {
+ DisplayInfo info = new DisplayInfo();
+ rawDisplay.getDisplayInfo(info);
+ init(info, context.getResources(), hasNavigationBar, hasStatusBar);
+ }
+
/**
* Construct a display layout based on a live display.
* @param context Used for resources.
@@ -167,6 +190,7 @@ public class DisplayLayout {
mUiMode = dl.mUiMode;
mWidth = dl.mWidth;
mHeight = dl.mHeight;
+ mGlobalBoundsDp = dl.mGlobalBoundsDp;
mCutout = dl.mCutout;
mRotation = dl.mRotation;
mDensityDpi = dl.mDensityDpi;
@@ -176,6 +200,7 @@ public class DisplayLayout {
mNavigationBarCanMove = dl.mNavigationBarCanMove;
mReverseDefaultRotation = dl.mReverseDefaultRotation;
mNavBarFrameHeight = dl.mNavBarFrameHeight;
+ mTaskbarFrameHeight = dl.mTaskbarFrameHeight;
mNonDecorInsets.set(dl.mNonDecorInsets);
mStableInsets.set(dl.mStableInsets);
mInsetsState.set(dl.mInsetsState, true /* copySources */);
@@ -189,6 +214,7 @@ public class DisplayLayout {
mRotation = info.rotation;
mCutout = info.displayCutout;
mDensityDpi = info.logicalDensityDpi;
+ mGlobalBoundsDp = new RectF(0, 0, pxToDp(mWidth), pxToDp(mHeight));
mHasNavigationBar = hasNavigationBar;
mHasStatusBar = hasStatusBar;
mAllowSeamlessRotationDespiteNavBarMoving = res.getBoolean(
@@ -214,7 +240,8 @@ public class DisplayLayout {
if (mHasStatusBar) {
convertNonDecorInsetsToStableInsets(res, mStableInsets, mCutout, mHasStatusBar);
}
- mNavBarFrameHeight = getNavigationBarFrameHeight(res, mWidth > mHeight);
+ mNavBarFrameHeight = getNavigationBarFrameHeight(res, /* landscape */ mWidth > mHeight);
+ mTaskbarFrameHeight = SystemBarUtils.getTaskbarHeight(res);
}
/**
@@ -240,6 +267,21 @@ public class DisplayLayout {
recalcInsets(res);
}
+ /**
+ * Update the dimensions of this layout.
+ */
+ public void resizeTo(Resources res, Size displaySize) {
+ mWidth = displaySize.getWidth();
+ mHeight = displaySize.getHeight();
+
+ recalcInsets(res);
+ }
+
+ /** Update the global bounds of this layout, in DP. */
+ public void setGlobalBoundsDp(RectF bounds) {
+ mGlobalBoundsDp = bounds;
+ }
+
/** Get this layout's non-decor insets. */
public Rect nonDecorInsets() {
return mNonDecorInsets;
@@ -250,16 +292,21 @@ public class DisplayLayout {
return mStableInsets;
}
- /** Get this layout's width. */
+ /** Get this layout's width in pixels. */
public int width() {
return mWidth;
}
- /** Get this layout's height. */
+ /** Get this layout's height in pixels. */
public int height() {
return mHeight;
}
+ /** Get this layout's global bounds in the multi-display coordinate system in DP. */
+ public RectF globalBoundsDp() {
+ return mGlobalBoundsDp;
+ }
+
/** Get this layout's display rotation. */
public int rotation() {
return mRotation;
@@ -321,6 +368,17 @@ public class DisplayLayout {
outBounds.inset(mStableInsets);
}
+ /** Predicts the calculated stable bounds when in Desktop Mode. */
+ public void getStableBoundsForDesktopMode(Rect outBounds) {
+ getStableBounds(outBounds);
+
+ if (mNavBarFrameHeight != mTaskbarFrameHeight) {
+ // Currently not in pinned taskbar mode, exclude taskbar insets instead of current
+ // navigation insets from bounds.
+ outBounds.bottom = mHeight - mTaskbarFrameHeight;
+ }
+ }
+
/**
* Gets navigation bar position for this layout
* @return Navigation bar position for this layout.
@@ -364,8 +422,10 @@ public class DisplayLayout {
// Only navigation bar
if (hasNavigationBar) {
+ final Rect displayFrame = insetsState.getDisplayFrame();
final Insets insets = insetsState.calculateInsets(
- insetsState.getDisplayFrame(),
+ displayFrame,
+ displayFrame,
WindowInsets.Type.navigationBars(),
false /* ignoreVisibility */);
int position = navigationBarPosition(res, displayWidth, displayHeight, displayRotation);
@@ -460,4 +520,48 @@ public class DisplayLayout {
? R.dimen.navigation_bar_frame_height_landscape
: R.dimen.navigation_bar_frame_height);
}
+
+ /**
+ * Converts a pixel value to a density-independent pixel (dp) value.
+ *
+ * @param px The pixel value to convert.
+ * @return The equivalent value in DP units.
+ */
+ public float pxToDp(Number px) {
+ return px.floatValue() * DisplayMetrics.DENSITY_DEFAULT / mDensityDpi;
+ }
+
+ /**
+ * Converts a density-independent pixel (dp) value to a pixel value.
+ *
+ * @param dp The DP value to convert.
+ * @return The equivalent value in pixel units.
+ */
+ public float dpToPx(Number dp) {
+ return dp.floatValue() * mDensityDpi / DisplayMetrics.DENSITY_DEFAULT;
+ }
+
+ /**
+ * Converts local pixel coordinates on this layout to global DP coordinates.
+ *
+ * @param xPx The x-coordinate in pixels, relative to the layout's origin.
+ * @param yPx The y-coordinate in pixels, relative to the layout's origin.
+ * @return A PointF object representing the coordinates in global DP units.
+ */
+ public PointF localPxToGlobalDp(Number xPx, Number yPx) {
+ return new PointF(mGlobalBoundsDp.left + pxToDp(xPx),
+ mGlobalBoundsDp.top + pxToDp(yPx));
+ }
+
+ /**
+ * Converts global DP coordinates to local pixel coordinates on this layout.
+ *
+ * @param xDp The x-coordinate in global DP units.
+ * @param yDp The y-coordinate in global DP units.
+ * @return A PointF object representing the coordinates in local pixel units on this layout.
+ */
+ public PointF globalDpToLocalPx(Number xDp, Number yDp) {
+ return new PointF(dpToPx(xDp.floatValue() - mGlobalBoundsDp.left),
+ dpToPx(yDp.floatValue() - mGlobalBoundsDp.top));
+ }
}
diff --git a/wmshell/src/com/android/wm/shell/common/HandlerExecutor.java b/wmshell/src/com/android/wm/shell/common/HandlerExecutor.java
index bfee820870..803f16ce39 100644
--- a/wmshell/src/com/android/wm/shell/common/HandlerExecutor.java
+++ b/wmshell/src/com/android/wm/shell/common/HandlerExecutor.java
@@ -16,15 +16,50 @@
package com.android.wm.shell.common;
+import static android.os.Process.THREAD_PRIORITY_DEFAULT;
+import static android.os.Process.setThreadPriority;
+
import android.annotation.NonNull;
import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
+
+import androidx.annotation.VisibleForTesting;
+
+import java.util.function.BiConsumer;
/** Executor implementation which is backed by a Handler. */
public class HandlerExecutor implements ShellExecutor {
+ @NonNull
private final Handler mHandler;
+ // See android.os.Process#THREAD_PRIORITY_*
+ private final int mDefaultThreadPriority;
+ private final int mBoostedThreadPriority;
+ // Number of current requests to boost thread priority
+ private int mBoostCount;
+ private final Object mBoostLock = new Object();
+ // Default function for setting thread priority (tid, priority)
+ private BiConsumer mSetThreadPriorityFn =
+ HandlerExecutor::setThreadPriorityInternal;
public HandlerExecutor(@NonNull Handler handler) {
+ this(handler, THREAD_PRIORITY_DEFAULT, THREAD_PRIORITY_DEFAULT);
+ }
+
+ /**
+ * Used only if this executor can be boosted, if so, it can be boosted to the given
+ * {@param boostPriority}.
+ */
+ public HandlerExecutor(@NonNull Handler handler, int defaultThreadPriority,
+ int boostedThreadPriority) {
mHandler = handler;
+ mDefaultThreadPriority = defaultThreadPriority;
+ mBoostedThreadPriority = boostedThreadPriority;
+ }
+
+ @VisibleForTesting
+ void replaceSetThreadPriorityFn(BiConsumer setThreadPriorityFn) {
+ mSetThreadPriorityFn = setThreadPriorityFn;
}
@Override
@@ -54,4 +89,56 @@ public class HandlerExecutor implements ShellExecutor {
public boolean hasCallback(Runnable r) {
return mHandler.hasCallbacks(r);
}
+
+ @Override
+ public void setBoost() {
+ synchronized (mBoostLock) {
+ if (mDefaultThreadPriority == mBoostedThreadPriority) {
+ // Nothing to boost
+ return;
+ }
+ if (mBoostCount == 0) {
+ mSetThreadPriorityFn.accept(
+ ((HandlerThread) mHandler.getLooper().getThread()).getThreadId(),
+ mBoostedThreadPriority);
+ }
+ mBoostCount++;
+ }
+ }
+
+ @Override
+ public void resetBoost() {
+ synchronized (mBoostLock) {
+ mBoostCount--;
+ if (mBoostCount == 0) {
+ mSetThreadPriorityFn.accept(
+ ((HandlerThread) mHandler.getLooper().getThread()).getThreadId(),
+ mDefaultThreadPriority);
+ }
+ }
+ }
+
+ @Override
+ public boolean isBoosted() {
+ synchronized (mBoostLock) {
+ return mBoostCount > 0;
+ }
+ }
+
+ @Override
+ @NonNull
+ public Looper getLooper() {
+ return mHandler.getLooper();
+ }
+
+ @Override
+ public void assertCurrentThread() {
+ if (!mHandler.getLooper().isCurrentThread()) {
+ throw new IllegalStateException("must be called on " + mHandler);
+ }
+ }
+
+ private static void setThreadPriorityInternal(Integer tid, Integer priority) {
+ setThreadPriority(tid, priority);
+ }
}
diff --git a/wmshell/src/com/android/wm/shell/common/HomeIntentProvider.kt b/wmshell/src/com/android/wm/shell/common/HomeIntentProvider.kt
new file mode 100644
index 0000000000..ab24402433
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/common/HomeIntentProvider.kt
@@ -0,0 +1,65 @@
+/*
+ * 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.common
+
+import android.app.ActivityManager
+import android.app.ActivityOptions
+import android.app.PendingIntent
+import android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN
+import android.content.Context
+import android.content.Intent
+import android.os.UserHandle
+import android.view.Display.DEFAULT_DISPLAY
+import android.window.DesktopExperienceFlags.ENABLE_PER_DISPLAY_DESKTOP_WALLPAPER_ACTIVITY
+import android.window.WindowContainerTransaction
+
+/** Creates home intent **/
+class HomeIntentProvider(
+ private val context: Context,
+) {
+ fun addLaunchHomePendingIntent(
+ wct: WindowContainerTransaction, displayId: Int, userId: Int? = null
+ ) {
+ val userHandle =
+ if (userId != null) UserHandle.of(userId) else UserHandle.of(ActivityManager.getCurrentUser())
+
+ val launchHomeIntent = Intent(Intent.ACTION_MAIN).apply {
+ if (displayId != DEFAULT_DISPLAY) {
+ addCategory(Intent.CATEGORY_SECONDARY_HOME)
+ } else {
+ addCategory(Intent.CATEGORY_HOME)
+ }
+ }
+ val options = ActivityOptions.makeBasic().apply {
+ launchWindowingMode = WINDOWING_MODE_FULLSCREEN
+ pendingIntentBackgroundActivityStartMode =
+ ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOW_ALWAYS
+ if (ENABLE_PER_DISPLAY_DESKTOP_WALLPAPER_ACTIVITY.isTrue) {
+ launchDisplayId = displayId
+ }
+ }
+ val pendingIntent = PendingIntent.getActivityAsUser(
+ context,
+ /* requestCode= */ 0,
+ launchHomeIntent,
+ PendingIntent.FLAG_IMMUTABLE,
+ /* options= */ null,
+ userHandle,
+ )
+ wct.sendPendingIntent(pendingIntent, launchHomeIntent, options.toBundle())
+ }
+}
\ No newline at end of file
diff --git a/wmshell/src/com/android/wm/shell/common/ImeListener.kt b/wmshell/src/com/android/wm/shell/common/ImeListener.kt
new file mode 100644
index 0000000000..8851b6a210
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/common/ImeListener.kt
@@ -0,0 +1,63 @@
+/*
+ * 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.common
+
+import android.graphics.Rect
+import android.view.InsetsSource
+import android.view.InsetsState
+import com.android.wm.shell.common.DisplayInsetsController.OnInsetsChangedListener
+
+abstract class ImeListener(
+ private val displayController: DisplayController,
+ val displayId: Int
+) : OnInsetsChangedListener {
+ // The last insets state
+ private val mInsetsState = InsetsState()
+ private val mTmpBounds = Rect()
+
+ override fun insetsChanged(insetsState: InsetsState) {
+ if (mInsetsState == insetsState) {
+ return
+ }
+
+ // Get the stable bounds that account for display cutout and system bars to calculate the
+ // relative IME height
+ val layout = displayController.getDisplayLayout(displayId) ?: return
+ layout.getStableBounds(mTmpBounds)
+
+ val (wasVisible, oldHeight) = getImeVisibilityAndHeight(mInsetsState)
+ val (isVisible, newHeight) = getImeVisibilityAndHeight(insetsState)
+
+ mInsetsState.set(insetsState, true)
+ if (wasVisible != isVisible || oldHeight != newHeight) {
+ onImeVisibilityChanged(isVisible, newHeight)
+ }
+ }
+
+ private fun getImeVisibilityAndHeight(insetsState: InsetsState): Pair {
+ val source = insetsState.peekSource(InsetsSource.ID_IME)
+ val frame = if (source != null && source.isVisible) source.frame else null
+ val height = if (frame != null) mTmpBounds.bottom - frame.top else 0
+ val visible = source?.isVisible ?: false
+ return Pair(visible, height)
+ }
+
+ /**
+ * To be overridden by implementations to handle IME changes.
+ */
+ protected abstract fun onImeVisibilityChanged(imeVisible: Boolean, imeHeight: Int)
+}
\ No newline at end of file
diff --git a/wmshell/src/com/android/wm/shell/common/LaunchAdjacentController.kt b/wmshell/src/com/android/wm/shell/common/LaunchAdjacentController.kt
index 81592c35e4..e92b0b59d2 100644
--- a/wmshell/src/com/android/wm/shell/common/LaunchAdjacentController.kt
+++ b/wmshell/src/com/android/wm/shell/common/LaunchAdjacentController.kt
@@ -17,8 +17,8 @@ package com.android.wm.shell.common
import android.window.WindowContainerToken
import android.window.WindowContainerTransaction
+import com.android.internal.protolog.ProtoLog
import com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_TASK_ORG
-import com.android.wm.shell.util.KtProtoLog
/**
* Controller to manage behavior of activities launched with
@@ -30,7 +30,7 @@ class LaunchAdjacentController(private val syncQueue: SyncTransactionQueue) {
var launchAdjacentEnabled: Boolean = true
set(value) {
if (field != value) {
- KtProtoLog.d(WM_SHELL_TASK_ORG, "set launch adjacent flag root enabled=%b", value)
+ ProtoLog.d(WM_SHELL_TASK_ORG, "set launch adjacent flag root enabled=%b", value)
field = value
container?.let { c ->
if (value) {
@@ -52,7 +52,7 @@ class LaunchAdjacentController(private val syncQueue: SyncTransactionQueue) {
* @see WindowContainerTransaction.setLaunchAdjacentFlagRoot
*/
fun setLaunchAdjacentRoot(container: WindowContainerToken) {
- KtProtoLog.d(WM_SHELL_TASK_ORG, "set new launch adjacent flag root container")
+ ProtoLog.d(WM_SHELL_TASK_ORG, "set new launch adjacent flag root container")
this.container = container
if (launchAdjacentEnabled) {
enableContainer(container)
@@ -67,7 +67,7 @@ class LaunchAdjacentController(private val syncQueue: SyncTransactionQueue) {
* @see WindowContainerTransaction.clearLaunchAdjacentFlagRoot
*/
fun clearLaunchAdjacentRoot() {
- KtProtoLog.d(WM_SHELL_TASK_ORG, "clear launch adjacent flag root container")
+ ProtoLog.d(WM_SHELL_TASK_ORG, "clear launch adjacent flag root container")
container?.let {
disableContainer(it)
container = null
@@ -75,14 +75,14 @@ class LaunchAdjacentController(private val syncQueue: SyncTransactionQueue) {
}
private fun enableContainer(container: WindowContainerToken) {
- KtProtoLog.v(WM_SHELL_TASK_ORG, "enable launch adjacent flag root container")
+ ProtoLog.v(WM_SHELL_TASK_ORG, "enable launch adjacent flag root container")
val wct = WindowContainerTransaction()
wct.setLaunchAdjacentFlagRoot(container)
syncQueue.queue(wct)
}
private fun disableContainer(container: WindowContainerToken) {
- KtProtoLog.v(WM_SHELL_TASK_ORG, "disable launch adjacent flag root container")
+ ProtoLog.v(WM_SHELL_TASK_ORG, "disable launch adjacent flag root container")
val wct = WindowContainerTransaction()
wct.clearLaunchAdjacentFlagRoot(container)
syncQueue.queue(wct)
diff --git a/wmshell/src/com/android/wm/shell/common/MultiDisplayDragMoveBoundsCalculator.kt b/wmshell/src/com/android/wm/shell/common/MultiDisplayDragMoveBoundsCalculator.kt
new file mode 100644
index 0000000000..eff64e24b7
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/common/MultiDisplayDragMoveBoundsCalculator.kt
@@ -0,0 +1,88 @@
+/*
+ * 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.common
+
+import android.graphics.PointF
+import android.graphics.Rect
+import android.graphics.RectF
+
+/**
+ * Utility class for calculating bounds during multi-display drag operations.
+ *
+ * This class provides helper functions to perform bounds calculation during window drag.
+ */
+object MultiDisplayDragMoveBoundsCalculator {
+ /**
+ * Calculates the global DP bounds of a window being dragged across displays.
+ *
+ * @param startDisplayLayout The DisplayLayout object of the display where the drag started.
+ * @param repositionStartPoint The starting position of the drag (in pixels), relative to the
+ * display where the drag started.
+ * @param boundsAtDragStart The initial bounds of the window (in pixels), relative to the
+ * display where the drag started.
+ * @param currentDisplayLayout The DisplayLayout object of the display where the pointer is
+ * currently located.
+ * @param x The current x-coordinate of the drag pointer (in pixels).
+ * @param y The current y-coordinate of the drag pointer (in pixels).
+ * @return A RectF object representing the calculated global DP bounds of the window.
+ */
+ @JvmStatic
+ fun calculateGlobalDpBoundsForDrag(
+ startDisplayLayout: DisplayLayout,
+ repositionStartPoint: PointF,
+ boundsAtDragStart: Rect,
+ currentDisplayLayout: DisplayLayout,
+ x: Float,
+ y: Float,
+ ): RectF {
+ // Convert all pixel values to DP.
+ val startCursorDp =
+ startDisplayLayout.localPxToGlobalDp(repositionStartPoint.x, repositionStartPoint.y)
+ val currentCursorDp = currentDisplayLayout.localPxToGlobalDp(x, y)
+ val startLeftTopDp =
+ startDisplayLayout.localPxToGlobalDp(boundsAtDragStart.left, boundsAtDragStart.top)
+ val widthDp = startDisplayLayout.pxToDp(boundsAtDragStart.width())
+ val heightDp = startDisplayLayout.pxToDp(boundsAtDragStart.height())
+
+ // Calculate DP bounds based on pointer movement delta.
+ val currentLeftDp = startLeftTopDp.x + (currentCursorDp.x - startCursorDp.x)
+ val currentTopDp = startLeftTopDp.y + (currentCursorDp.y - startCursorDp.y)
+ val currentRightDp = currentLeftDp + widthDp
+ val currentBottomDp = currentTopDp + heightDp
+
+ return RectF(currentLeftDp, currentTopDp, currentRightDp, currentBottomDp)
+ }
+
+ /**
+ * Converts global DP bounds to local pixel bounds for a specific display.
+ *
+ * @param rectDp The global DP bounds to convert.
+ * @param displayLayout The DisplayLayout representing the display to convert the bounds to.
+ * @return A Rect object representing the local pixel bounds on the specified display.
+ */
+ @JvmStatic
+ fun convertGlobalDpToLocalPxForRect(rectDp: RectF, displayLayout: DisplayLayout): Rect {
+ val leftTopPxDisplay = displayLayout.globalDpToLocalPx(rectDp.left, rectDp.top)
+ val rightBottomPxDisplay = displayLayout.globalDpToLocalPx(rectDp.right, rectDp.bottom)
+ return Rect(
+ leftTopPxDisplay.x.toInt(),
+ leftTopPxDisplay.y.toInt(),
+ rightBottomPxDisplay.x.toInt(),
+ rightBottomPxDisplay.y.toInt(),
+ )
+ }
+}
diff --git a/wmshell/src/com/android/wm/shell/common/MultiDisplayDragMoveIndicatorController.kt b/wmshell/src/com/android/wm/shell/common/MultiDisplayDragMoveIndicatorController.kt
new file mode 100644
index 0000000000..9f42ac0fcd
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/common/MultiDisplayDragMoveIndicatorController.kt
@@ -0,0 +1,145 @@
+/*
+ * 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.common
+
+import android.app.ActivityManager.RunningTaskInfo
+import android.graphics.RectF
+import android.view.SurfaceControl
+import com.android.wm.shell.RootTaskDisplayAreaOrganizer
+import com.android.wm.shell.shared.annotations.ShellDesktopThread
+import com.android.wm.shell.shared.desktopmode.DesktopState
+
+/**
+ * Controller to manage the indicators that show users the current position of the dragged window on
+ * the new display when performing drag move across displays.
+ */
+class MultiDisplayDragMoveIndicatorController(
+ private val displayController: DisplayController,
+ private val rootTaskDisplayAreaOrganizer: RootTaskDisplayAreaOrganizer,
+ private val indicatorSurfaceFactory: MultiDisplayDragMoveIndicatorSurface.Factory,
+ @ShellDesktopThread private val desktopExecutor: ShellExecutor,
+ private val desktopState: DesktopState,
+) {
+ @ShellDesktopThread
+ private val dragIndicators =
+ mutableMapOf>()
+
+ /**
+ * Called during drag move, which started at [startDisplayId] and currently at
+ * [currentDisplayid]. Updates the position and visibility of the drag move indicators for the
+ * [taskInfo] based on [boundsDp] on the destination displays ([displayIds]) as the dragged
+ * window moves. [transactionSupplier] provides a [SurfaceControl.Transaction] for applying
+ * changes to the indicator surfaces.
+ *
+ * It is executed on the [desktopExecutor] to prevent blocking the main thread and avoid jank,
+ * as creating and manipulating surfaces can be expensive.
+ */
+ fun onDragMove(
+ boundsDp: RectF,
+ currentDisplayId: Int,
+ startDisplayId: Int,
+ taskLeash: SurfaceControl,
+ taskInfo: RunningTaskInfo,
+ displayIds: Set,
+ transactionSupplier: () -> SurfaceControl.Transaction,
+ ) {
+ desktopExecutor.execute {
+ val startDisplayDpi =
+ displayController.getDisplayLayout(startDisplayId)?.densityDpi() ?: return@execute
+ val transaction = transactionSupplier()
+ for (displayId in displayIds) {
+ if (
+ displayId == startDisplayId ||
+ !desktopState.isDesktopModeSupportedOnDisplay(displayId)
+ ) {
+ // No need to render indicators on the original display where the drag started,
+ // or on displays that do not support desktop mode.
+ continue
+ }
+ val displayLayout = displayController.getDisplayLayout(displayId) ?: continue
+ val displayContext = displayController.getDisplayContext(displayId) ?: continue
+ val visibility =
+ if (RectF.intersects(RectF(boundsDp), displayLayout.globalBoundsDp())) {
+ if (displayId == currentDisplayId) {
+ MultiDisplayDragMoveIndicatorSurface.Visibility.VISIBLE
+ } else {
+ MultiDisplayDragMoveIndicatorSurface.Visibility.TRANSLUCENT
+ }
+ } else {
+ MultiDisplayDragMoveIndicatorSurface.Visibility.INVISIBLE
+ }
+ if (
+ dragIndicators[taskInfo.taskId]?.containsKey(displayId) != true &&
+ visibility == MultiDisplayDragMoveIndicatorSurface.Visibility.INVISIBLE
+ ) {
+ // Skip this display if:
+ // - It doesn't have an existing indicator that needs to be updated, AND
+ // - The latest dragged window bounds don't intersect with this display.
+ continue
+ }
+
+ val boundsPx =
+ MultiDisplayDragMoveBoundsCalculator.convertGlobalDpToLocalPxForRect(
+ boundsDp,
+ displayLayout,
+ )
+
+ // Get or create the inner map for the current task.
+ val dragIndicatorsForTask =
+ dragIndicators.getOrPut(taskInfo.taskId) { mutableMapOf() }
+ dragIndicatorsForTask[displayId]?.also { existingIndicator ->
+ existingIndicator.relayout(boundsPx, transaction, visibility)
+ }
+ ?: run {
+ val newIndicator = indicatorSurfaceFactory.create(displayContext, taskLeash)
+ newIndicator.show(
+ transaction,
+ taskInfo,
+ rootTaskDisplayAreaOrganizer,
+ displayId,
+ boundsPx,
+ visibility,
+ displayLayout.densityDpi().toFloat() / startDisplayDpi.toFloat(),
+ )
+ dragIndicatorsForTask[displayId] = newIndicator
+ }
+ }
+ transaction.apply()
+ }
+ }
+
+ /**
+ * Called when the drag ends. Disposes of the drag move indicator surfaces associated with the
+ * given [taskId]. [transactionSupplier] provides a [SurfaceControl.Transaction] for applying
+ * changes to the indicator surfaces.
+ *
+ * It is executed on the [desktopExecutor] to ensure that any pending `onDragMove` operations
+ * have completed before disposing of the surfaces.
+ */
+ fun onDragEnd(taskId: Int, transactionSupplier: () -> SurfaceControl.Transaction) {
+ desktopExecutor.execute {
+ dragIndicators
+ .remove(taskId)
+ ?.values
+ ?.takeIf { it.isNotEmpty() }
+ ?.let { indicators ->
+ val transaction = transactionSupplier()
+ indicators.forEach { indicator -> indicator.dispose(transaction) }
+ transaction.apply()
+ }
+ }
+ }
+}
diff --git a/wmshell/src/com/android/wm/shell/common/MultiDisplayDragMoveIndicatorSurface.kt b/wmshell/src/com/android/wm/shell/common/MultiDisplayDragMoveIndicatorSurface.kt
new file mode 100644
index 0000000000..cb719a681f
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/common/MultiDisplayDragMoveIndicatorSurface.kt
@@ -0,0 +1,144 @@
+/*
+ * 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.common
+
+import android.app.ActivityManager.RunningTaskInfo
+import android.content.Context
+import android.graphics.Rect
+import android.os.Trace
+import android.view.SurfaceControl
+import android.window.TaskConstants
+import com.android.internal.protolog.ProtoLog
+import com.android.wm.shell.RootTaskDisplayAreaOrganizer
+import com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_DESKTOP_MODE
+import com.android.wm.shell.shared.R
+import com.android.wm.shell.shared.annotations.ShellDesktopThread
+
+/**
+ * Represents the indicator surface that visualizes the current position of a dragged window during
+ * a multi-display drag operation.
+ *
+ * This class manages the creation, display, and manipulation of the [SurfaceControl] that act as a
+ * visual indicator, providing feedback to the user about the dragged window's location.
+ */
+@ShellDesktopThread
+class MultiDisplayDragMoveIndicatorSurface(context: Context, taskSurface: SurfaceControl) {
+ public enum class Visibility {
+ INVISIBLE,
+ TRANSLUCENT,
+ VISIBLE,
+ }
+
+ private var visibility = Visibility.INVISIBLE
+
+ private var surface: SurfaceControl? = null
+
+ private val cornerRadius =
+ context.resources
+ .getDimensionPixelSize(R.dimen.desktop_windowing_freeform_rounded_corner_radius)
+ .toFloat()
+
+ init {
+ Trace.beginSection("DragIndicatorSurface#init")
+
+ surface = SurfaceControl.mirrorSurface(taskSurface)
+
+ Trace.endSection()
+ }
+
+ /** Disposes the indicator surface using the provided [transaction]. */
+ fun dispose(transaction: SurfaceControl.Transaction) {
+ surface?.let { sc -> transaction.remove(sc) }
+ surface = null
+ }
+
+ /**
+ * Shows the indicator surface at [bounds] on the specified display ([displayId]), with the
+ * [scale], visualizing the drag of the [taskInfo]. The indicator surface is shown using
+ * [transaction], and the [rootTaskDisplayAreaOrganizer] is used to reparent the surfaces.
+ */
+ fun show(
+ transaction: SurfaceControl.Transaction,
+ taskInfo: RunningTaskInfo,
+ rootTaskDisplayAreaOrganizer: RootTaskDisplayAreaOrganizer,
+ displayId: Int,
+ bounds: Rect,
+ visibility: Visibility,
+ scale: Float,
+ ) {
+ val sc = surface
+ if (sc == null) {
+ ProtoLog.w(
+ WM_SHELL_DESKTOP_MODE,
+ "Cannot show drag indicator for Task %d on Display %d because " +
+ "indicator surface is null.",
+ taskInfo.taskId,
+ displayId,
+ )
+ return
+ }
+
+ rootTaskDisplayAreaOrganizer.reparentToDisplayArea(displayId, sc, transaction)
+ relayout(bounds, transaction, visibility)
+ transaction.show(sc).setLayer(sc, MOVE_INDICATOR_LAYER).setScale(sc, scale, scale)
+ }
+
+ /**
+ * Repositions and resizes the indicator surface based on [bounds] using [transaction]. The
+ * [newVisibility] flag indicates whether the indicator is within the display after relayout.
+ */
+ fun relayout(bounds: Rect, transaction: SurfaceControl.Transaction, newVisibility: Visibility) {
+ if (visibility == Visibility.INVISIBLE && newVisibility == Visibility.INVISIBLE) {
+ // No need to relayout if the surface is already invisible and should not be visible.
+ return
+ }
+
+ visibility = newVisibility
+ val sc = surface ?: return
+ transaction
+ .setCornerRadius(sc, cornerRadius)
+ .setPosition(sc, bounds.left.toFloat(), bounds.top.toFloat())
+ when (visibility) {
+ Visibility.VISIBLE ->
+ transaction.setAlpha(sc, ALPHA_FOR_MOVE_INDICATOR_ON_DISPLAY_WITH_CURSOR)
+ Visibility.TRANSLUCENT ->
+ transaction.setAlpha(sc, ALPHA_FOR_MOVE_INDICATOR_ON_NON_CURSOR_DISPLAY)
+ Visibility.INVISIBLE -> {
+ // Do nothing intentionally. Falling into this means the bounds is outside
+ // of the display, so no need to hide the surface explicitly.
+ }
+ }
+ }
+
+ /** Factory for creating [MultiDisplayDragMoveIndicatorSurface] instances. */
+ class Factory() {
+ /**
+ * Creates a new [MultiDisplayDragMoveIndicatorSurface] instance to visualize the drag
+ * operation of the [taskInfo] on the given [display].
+ */
+ fun create(displayContext: Context, taskSurface: SurfaceControl) =
+ MultiDisplayDragMoveIndicatorSurface(displayContext, taskSurface)
+ }
+
+ companion object {
+ private const val TAG = "MultiDisplayDragMoveIndicatorSurface"
+
+ private const val MOVE_INDICATOR_LAYER = TaskConstants.TASK_CHILD_LAYER_RESIZE_VEIL
+
+ private const val ALPHA_FOR_MOVE_INDICATOR_ON_DISPLAY_WITH_CURSOR = 1.0f
+ private const val ALPHA_FOR_MOVE_INDICATOR_ON_NON_CURSOR_DISPLAY = 0.7f
+ }
+}
diff --git a/wmshell/src/com/android/wm/shell/common/MultiInstanceHelper.kt b/wmshell/src/com/android/wm/shell/common/MultiInstanceHelper.kt
index 9e8dfb5f0c..ff3e65a247 100644
--- a/wmshell/src/com/android/wm/shell/common/MultiInstanceHelper.kt
+++ b/wmshell/src/com/android/wm/shell/common/MultiInstanceHelper.kt
@@ -15,17 +15,21 @@
*/
package com.android.wm.shell.common
+import android.annotation.UserIdInt
import android.app.PendingIntent
import android.content.ComponentName
import android.content.Context
import android.content.pm.LauncherApps
import android.content.pm.PackageManager
+import android.content.pm.PackageManager.Property
import android.os.UserHandle
import android.view.WindowManager.PROPERTY_SUPPORTS_MULTI_INSTANCE_SYSTEM_UI
-import com.android.internal.annotations.VisibleForTesting
+import com.android.internal.protolog.ProtoLog
import com.android.wm.shell.R
import com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL
-import com.android.wm.shell.util.KtProtoLog
+import com.android.wm.shell.sysui.ShellCommandHandler
+import com.android.wm.shell.sysui.ShellInit
+import java.io.PrintWriter
import java.util.Arrays
/**
@@ -36,13 +40,23 @@ class MultiInstanceHelper @JvmOverloads constructor(
private val packageManager: PackageManager,
private val staticAppsSupportingMultiInstance: Array = context.resources
.getStringArray(R.array.config_appsSupportMultiInstancesSplit),
- private val supportsMultiInstanceProperty: Boolean) {
+ shellInit: ShellInit,
+ private val shellCommandHandler: ShellCommandHandler,
+ private val supportsMultiInstanceProperty: Boolean
+) : ShellCommandHandler.ShellCommandActionHandler {
+
+ init {
+ shellInit.addInitCallback(this::onInit, this)
+ }
+
+ private fun onInit() {
+ shellCommandHandler.addCommandCallback("multi-instance", this, this)
+ }
/**
* Returns whether a specific component desires to be launched in multiple instances.
*/
- @VisibleForTesting
- fun supportsMultiInstanceSplit(componentName: ComponentName?): Boolean {
+ fun supportsMultiInstanceSplit(componentName: ComponentName?, @UserIdInt userId: Int): Boolean {
if (componentName == null || componentName.packageName == null) {
// TODO(b/262864589): Handle empty component case
return false
@@ -52,7 +66,7 @@ class MultiInstanceHelper @JvmOverloads constructor(
val packageName = componentName.packageName
for (pkg in staticAppsSupportingMultiInstance) {
if (pkg == packageName) {
- KtProtoLog.v(WM_SHELL, "application=%s in allowlist supports multi-instance",
+ ProtoLog.v(WM_SHELL, "application=%s in allowlist supports multi-instance",
packageName)
return true
}
@@ -60,20 +74,21 @@ class MultiInstanceHelper @JvmOverloads constructor(
if (!supportsMultiInstanceProperty) {
// If not checking the multi-instance properties, then return early
- return false;
+ return false
}
// Check the activity property first
try {
- val activityProp = packageManager.getProperty(
- PROPERTY_SUPPORTS_MULTI_INSTANCE_SYSTEM_UI, componentName)
+ val activityProp = packageManager.getPropertyAsUser(
+ PROPERTY_SUPPORTS_MULTI_INSTANCE_SYSTEM_UI, componentName.packageName,
+ componentName.className, userId)
// If the above call doesn't throw a NameNotFoundException, then the activity property
// should override the application property value
if (activityProp.isBoolean) {
- KtProtoLog.v(WM_SHELL, "activity=%s supports multi-instance", componentName)
+ ProtoLog.v(WM_SHELL, "activity=%s supports multi-instance", componentName)
return activityProp.boolean
} else {
- KtProtoLog.w(WM_SHELL, "Warning: property=%s for activity=%s has non-bool type=%d",
+ ProtoLog.w(WM_SHELL, "Warning: property=%s for activity=%s has non-bool type=%d",
PROPERTY_SUPPORTS_MULTI_INSTANCE_SYSTEM_UI, packageName, activityProp.type)
}
} catch (nnfe: PackageManager.NameNotFoundException) {
@@ -82,13 +97,14 @@ class MultiInstanceHelper @JvmOverloads constructor(
// Check the application property otherwise
try {
- val appProp = packageManager.getProperty(
- PROPERTY_SUPPORTS_MULTI_INSTANCE_SYSTEM_UI, packageName)
+ val appProp = packageManager.getPropertyAsUser(
+ PROPERTY_SUPPORTS_MULTI_INSTANCE_SYSTEM_UI, packageName, null /* className */,
+ userId)
if (appProp.isBoolean) {
- KtProtoLog.v(WM_SHELL, "application=%s supports multi-instance", packageName)
+ ProtoLog.v(WM_SHELL, "application=%s supports multi-instance", packageName)
return appProp.boolean
} else {
- KtProtoLog.w(WM_SHELL,
+ ProtoLog.w(WM_SHELL,
"Warning: property=%s for application=%s has non-bool type=%d",
PROPERTY_SUPPORTS_MULTI_INSTANCE_SYSTEM_UI, packageName, appProp.type)
}
@@ -98,6 +114,66 @@ class MultiInstanceHelper @JvmOverloads constructor(
return false
}
+ override fun onShellCommand(args: Array?, pw: PrintWriter?): Boolean {
+ if (pw == null || args == null || args.isEmpty()) {
+ return false
+ }
+ when (args[0]) {
+ "list" -> return dumpSupportedApps(pw)
+ }
+ return false
+ }
+
+ override fun printShellCommandHelp(pw: PrintWriter, prefix: String) {
+ pw.println("${prefix}list")
+ pw.println("$prefix Lists all the packages that support the multiinstance property")
+ }
+
+ /**
+ * Dumps the static allowlist and list of apps that have the declared property in the manifest.
+ */
+ private fun dumpSupportedApps(pw: PrintWriter): Boolean {
+ pw.println("Static allow list (for all users):")
+ staticAppsSupportingMultiInstance.forEach { pkg ->
+ pw.println(" $pkg")
+ }
+
+ // TODO(b/391693747): Dump this per-user once PM allows us to query properties
+ // for non-calling users
+ val apps = packageManager.queryApplicationProperty(
+ PROPERTY_SUPPORTS_MULTI_INSTANCE_SYSTEM_UI)
+ val activities = packageManager.queryActivityProperty(
+ PROPERTY_SUPPORTS_MULTI_INSTANCE_SYSTEM_UI)
+ val appsWithProperty = (apps + activities)
+ .sortedWith(object : Comparator {
+ override fun compare(o1: Property?, o2: Property?): Int {
+ if (o1?.packageName != o2?.packageName) {
+ return o1?.packageName!!.compareTo(o2?.packageName!!)
+ } else {
+ if (o1?.className != null) {
+ return o1.className!!.compareTo(o2?.className!!)
+ } else if (o2?.className != null) {
+ return -o2.className!!.compareTo(o1?.className!!)
+ }
+ return 0
+ }
+ }
+ })
+ if (appsWithProperty.isNotEmpty()) {
+ pw.println("Apps (User ${context.userId}):")
+ appsWithProperty.forEach { prop ->
+ if (prop.isBoolean && prop.boolean) {
+ if (prop.className != null) {
+ pw.println(" ${prop.packageName}/${prop.className}")
+ } else {
+ pw.println(" ${prop.packageName}")
+ }
+ }
+ }
+ }
+ return true
+ }
+
companion object {
/** Returns the component from a PendingIntent */
@JvmStatic
diff --git a/wmshell/src/com/android/wm/shell/common/NavigationBarsListener.kt b/wmshell/src/com/android/wm/shell/common/NavigationBarsListener.kt
new file mode 100644
index 0000000000..20b4afed22
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/common/NavigationBarsListener.kt
@@ -0,0 +1,46 @@
+/*
+ * 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.common
+
+import android.graphics.Insets
+import android.graphics.Rect
+import android.view.InsetsState
+import android.view.WindowInsets.Type
+import com.android.wm.shell.common.DisplayInsetsController.OnInsetsChangedListener
+
+abstract class NavigationBarsListener(
+ private val displayController: DisplayController,
+ val displayId: Int
+) : OnInsetsChangedListener {
+ private var oldInsets = Insets.NONE
+
+ override fun insetsChanged(insetsState: InsetsState) {
+ getNavigationBarsInsets(insetsState).takeIf { it != oldInsets }?.let { newInsets ->
+ oldInsets = newInsets
+ onNavigationBarsVisibilityChanged(newInsets)
+ }
+ }
+
+ private fun getNavigationBarsInsets(insetsState: InsetsState): Insets {
+ val layout = displayController.getDisplayLayout(displayId) ?: return Insets.NONE
+ val displayBounds = Rect(0, 0, layout.width(), layout.height())
+ return insetsState.calculateInsets(displayBounds, displayBounds,
+ Type.navigationBars(), /* ignoreVisibility= */true)
+ }
+
+ protected abstract fun onNavigationBarsVisibilityChanged(insets: Insets)
+}
\ No newline at end of file
diff --git a/wmshell/src/com/android/wm/shell/common/ScreenshotUtils.java b/wmshell/src/com/android/wm/shell/common/ScreenshotUtils.java
index fad3dee1f9..1929729eb1 100644
--- a/wmshell/src/com/android/wm/shell/common/ScreenshotUtils.java
+++ b/wmshell/src/com/android/wm/shell/common/ScreenshotUtils.java
@@ -42,6 +42,7 @@ public class ScreenshotUtils {
.setSourceCrop(crop)
.setCaptureSecureLayers(true)
.setAllowProtected(true)
+ .setHintForSeamlessTransition(true)
.build()));
}
@@ -78,6 +79,9 @@ public class ScreenshotUtils {
mTransaction.setColorSpace(mScreenshot, buffer.getColorSpace());
mTransaction.reparent(mScreenshot, mParentSurfaceControl);
mTransaction.setLayer(mScreenshot, mLayer);
+ if (buffer.containsHdrLayers()) {
+ mTransaction.setDimmingEnabled(mScreenshot, false);
+ }
mTransaction.show(mScreenshot);
mTransaction.apply();
}
diff --git a/wmshell/src/com/android/wm/shell/common/ShellExecutor.java b/wmshell/src/com/android/wm/shell/common/ShellExecutor.java
index f729164ed3..9e5071e834 100644
--- a/wmshell/src/com/android/wm/shell/common/ShellExecutor.java
+++ b/wmshell/src/com/android/wm/shell/common/ShellExecutor.java
@@ -18,15 +18,15 @@ package com.android.wm.shell.common;
import java.lang.reflect.Array;
import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
/**
* Super basic Executor interface that adds support for delayed execution and removing callbacks.
- * Intended to wrap Handler while better-supporting testing.
+ * Intended to wrap Handler while better-supporting testing. Not every ShellExecutor implementation
+ * may support boosting.
*/
-public interface ShellExecutor extends Executor {
+public interface ShellExecutor extends BoostExecutor {
/**
* Executes the given runnable. If the caller is running on the same looper as this executor,
@@ -96,4 +96,11 @@ public interface ShellExecutor extends Executor {
* See {@link android.os.Handler#hasCallbacks(Runnable)}.
*/
boolean hasCallback(Runnable runnable);
+
+ /**
+ * May throw if the caller is not on the same thread as the executor.
+ */
+ default void assertCurrentThread() {
+ return;
+ }
}
diff --git a/wmshell/src/com/android/wm/shell/common/SurfaceUtils.java b/wmshell/src/com/android/wm/shell/common/SurfaceUtils.java
index 4b138e43bc..dd17e2980e 100644
--- a/wmshell/src/com/android/wm/shell/common/SurfaceUtils.java
+++ b/wmshell/src/com/android/wm/shell/common/SurfaceUtils.java
@@ -17,7 +17,6 @@
package com.android.wm.shell.common;
import android.view.SurfaceControl;
-import android.view.SurfaceSession;
/**
* Helpers for handling surface.
@@ -25,16 +24,15 @@ import android.view.SurfaceSession;
public class SurfaceUtils {
/** Creates a dim layer above host surface. */
public static SurfaceControl makeDimLayer(SurfaceControl.Transaction t, SurfaceControl host,
- String name, SurfaceSession surfaceSession) {
- final SurfaceControl dimLayer = makeColorLayer(host, name, surfaceSession);
+ String name) {
+ final SurfaceControl dimLayer = makeColorLayer(host, name);
t.setLayer(dimLayer, Integer.MAX_VALUE).setColor(dimLayer, new float[]{0f, 0f, 0f});
return dimLayer;
}
/** Creates a color layer for host surface. */
- public static SurfaceControl makeColorLayer(SurfaceControl host, String name,
- SurfaceSession surfaceSession) {
- return new SurfaceControl.Builder(surfaceSession)
+ public static SurfaceControl makeColorLayer(SurfaceControl host, String name) {
+ return new SurfaceControl.Builder()
.setParent(host)
.setColorLayer()
.setName(name)
diff --git a/wmshell/src/com/android/wm/shell/common/SyncTransactionQueue.java b/wmshell/src/com/android/wm/shell/common/SyncTransactionQueue.java
index e261d92bda..a8e6b593f2 100644
--- a/wmshell/src/com/android/wm/shell/common/SyncTransactionQueue.java
+++ b/wmshell/src/com/android/wm/shell/common/SyncTransactionQueue.java
@@ -20,16 +20,14 @@ import static com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL;
import android.annotation.BinderThread;
import android.annotation.NonNull;
-import android.os.RemoteException;
import android.util.Slog;
import android.view.SurfaceControl;
-import android.view.WindowManager;
import android.window.WindowContainerTransaction;
import android.window.WindowContainerTransactionCallback;
import android.window.WindowOrganizer;
-import com.android.internal.protolog.common.ProtoLog;
-import com.android.wm.shell.transition.LegacyTransitions;
+import com.android.internal.protolog.ProtoLog;
+import com.android.wm.shell.shared.TransactionPool;
import java.util.ArrayList;
@@ -85,25 +83,6 @@ public final class SyncTransactionQueue {
}
}
- /**
- * Queues a legacy transition to be sent serially to WM
- */
- public void queue(LegacyTransitions.ILegacyTransition transition,
- @WindowManager.TransitionType int type, WindowContainerTransaction wct) {
- if (wct.isEmpty()) {
- if (DEBUG) Slog.d(TAG, "Skip queue due to transaction change is empty");
- return;
- }
- SyncCallback cb = new SyncCallback(transition, type, wct);
- synchronized (mQueue) {
- if (DEBUG) Slog.d(TAG, "Queueing up legacy transition " + wct);
- mQueue.add(cb);
- if (mQueue.size() == 1) {
- cb.send();
- }
- }
- }
-
/**
* Queues a sync transaction only if there are already sync transaction(s) queued or in flight.
* Otherwise just returns without queueing.
@@ -167,17 +146,9 @@ public final class SyncTransactionQueue {
private class SyncCallback extends WindowContainerTransactionCallback {
int mId = -1;
final WindowContainerTransaction mWCT;
- final LegacyTransitions.LegacyTransition mLegacyTransition;
SyncCallback(WindowContainerTransaction wct) {
mWCT = wct;
- mLegacyTransition = null;
- }
-
- SyncCallback(LegacyTransitions.ILegacyTransition legacyTransition,
- @WindowManager.TransitionType int type, WindowContainerTransaction wct) {
- mWCT = wct;
- mLegacyTransition = new LegacyTransitions.LegacyTransition(type, legacyTransition);
}
// Must be sychronized on mQueue
@@ -191,15 +162,17 @@ public final class SyncTransactionQueue {
throw new IllegalStateException("Sync Transactions must be serialized. In Flight: "
+ mInFlight.mId + " - " + mInFlight.mWCT);
}
- mInFlight = this;
if (DEBUG) Slog.d(TAG, "Sending sync transaction: " + mWCT);
- if (mLegacyTransition != null) {
- mId = new WindowOrganizer().startLegacyTransition(mLegacyTransition.getType(),
- mLegacyTransition.getAdapter(), this, mWCT);
- } else {
+ try {
mId = new WindowOrganizer().applySyncTransaction(mWCT, this);
+ } catch (RuntimeException e) {
+ Slog.e(TAG, "Send failed", e);
+ // Finish current sync callback immediately.
+ onTransactionReady(mId, new SurfaceControl.Transaction());
+ return;
}
if (DEBUG) Slog.d(TAG, " Sent sync transaction. Got id=" + mId);
+ mInFlight = this;
mMainExecutor.executeDelayed(mOnReplyTimeout, REPLY_TIMEOUT);
}
@@ -220,18 +193,10 @@ public final class SyncTransactionQueue {
if (DEBUG) Slog.d(TAG, "onTransactionReady id=" + mId);
mQueue.remove(this);
onTransactionReceived(t);
- if (mLegacyTransition != null) {
- try {
- mLegacyTransition.getSyncCallback().onTransactionReady(mId, t);
- } catch (RemoteException e) {
- Slog.e(TAG, "Error sending callback to legacy transition: " + mId, e);
- }
- } else {
- ProtoLog.v(WM_SHELL,
- "SyncTransactionQueue.onTransactionReady(): syncId=%d apply", id);
- t.apply();
- t.close();
- }
+ ProtoLog.v(WM_SHELL,
+ "SyncTransactionQueue.onTransactionReady(): syncId=%d apply", id);
+ t.apply();
+ t.close();
if (!mQueue.isEmpty()) {
mQueue.get(0).send();
}
diff --git a/wmshell/src/com/android/wm/shell/common/SystemWindows.java b/wmshell/src/com/android/wm/shell/common/SystemWindows.java
index ef33b3830e..ef6dbd55f5 100644
--- a/wmshell/src/com/android/wm/shell/common/SystemWindows.java
+++ b/wmshell/src/com/android/wm/shell/common/SystemWindows.java
@@ -27,7 +27,6 @@ import android.os.Bundle;
import android.os.IBinder;
import android.os.ParcelFileDescriptor;
import android.os.RemoteException;
-import android.util.MergedConfiguration;
import android.util.Slog;
import android.util.SparseArray;
import android.view.Display;
@@ -42,14 +41,12 @@ import android.view.InsetsState;
import android.view.ScrollCaptureResponse;
import android.view.SurfaceControl;
import android.view.SurfaceControlViewHost;
-import android.view.SurfaceSession;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
+import android.view.WindowRelayoutResult;
import android.view.WindowlessWindowManager;
import android.view.inputmethod.ImeTracker;
-import android.window.ActivityWindowInfo;
-import android.window.ClientWindowFrames;
import android.window.InputTransferToken;
import com.android.internal.os.IResultReceiver;
@@ -311,7 +308,7 @@ public class SystemWindows {
@Override
protected SurfaceControl getParentSurface(IWindow window,
WindowManager.LayoutParams attrs) {
- SurfaceControl leash = new SurfaceControl.Builder(new SurfaceSession())
+ SurfaceControl leash = new SurfaceControl.Builder()
.setContainerLayer()
.setName("SystemWindowLeash")
.setHidden(false)
@@ -346,26 +343,24 @@ public class SystemWindows {
ContainerWindow() {}
@Override
- public void resized(ClientWindowFrames frames, boolean reportDraw,
- MergedConfiguration newMergedConfiguration, InsetsState insetsState,
- boolean forceLayout, boolean alwaysConsumeSystemBars, int displayId, int syncSeqId,
- boolean dragResizing, @Nullable ActivityWindowInfo activityWindowInfo) {}
+ public void resized(WindowRelayoutResult layout, boolean reportDraw, boolean forceLayout,
+ int displayId, boolean syncWithBuffers, boolean dragResizing) {}
@Override
public void insetsControlChanged(InsetsState insetsState,
InsetsSourceControl.Array activeControls) {}
@Override
- public void showInsets(int types, boolean fromIme, @Nullable ImeTracker.Token statsToken) {}
+ public void showInsets(int types, @Nullable ImeTracker.Token statsToken) {}
@Override
- public void hideInsets(int types, boolean fromIme, @Nullable ImeTracker.Token statsToken) {}
+ public void hideInsets(int types, @Nullable ImeTracker.Token statsToken) {}
@Override
public void moved(int newX, int newY) {}
@Override
- public void dispatchAppVisibility(boolean visible) {}
+ public void dispatchAppVisibility(boolean visible, int seqId) {}
@Override
public void dispatchGetNewSurface() {}
diff --git a/wmshell/src/com/android/wm/shell/common/TabletopModeController.java b/wmshell/src/com/android/wm/shell/common/TabletopModeController.java
index 43c92cab6a..43f9cb9843 100644
--- a/wmshell/src/com/android/wm/shell/common/TabletopModeController.java
+++ b/wmshell/src/com/android/wm/shell/common/TabletopModeController.java
@@ -32,7 +32,7 @@ import android.util.ArraySet;
import android.view.Surface;
import com.android.internal.annotations.VisibleForTesting;
-import com.android.internal.protolog.common.ProtoLog;
+import com.android.internal.protolog.ProtoLog;
import com.android.wm.shell.shared.annotations.ShellMainThread;
import com.android.wm.shell.sysui.ShellInit;
diff --git a/wmshell/src/com/android/wm/shell/common/TaskStackListenerCallback.java b/wmshell/src/com/android/wm/shell/common/TaskStackListenerCallback.java
index 9abf0f6781..de5c834f31 100644
--- a/wmshell/src/com/android/wm/shell/common/TaskStackListenerCallback.java
+++ b/wmshell/src/com/android/wm/shell/common/TaskStackListenerCallback.java
@@ -33,6 +33,9 @@ public interface TaskStackListenerCallback {
default void onRecentTaskListFrozenChanged(boolean frozen) { }
+ /** A task is removed from recents as a result of another task being added to recent tasks. */
+ default void onRecentTaskRemovedForAddTask(int taskId) { }
+
@BinderThread
default void onTaskStackChangedBackground() { }
diff --git a/wmshell/src/com/android/wm/shell/common/TaskStackListenerImpl.java b/wmshell/src/com/android/wm/shell/common/TaskStackListenerImpl.java
index d8859bac47..4e1dec60d9 100644
--- a/wmshell/src/com/android/wm/shell/common/TaskStackListenerImpl.java
+++ b/wmshell/src/com/android/wm/shell/common/TaskStackListenerImpl.java
@@ -59,6 +59,7 @@ public class TaskStackListenerImpl extends TaskStackListener implements Handler.
private static final int ON_TASK_LIST_FROZEN_UNFROZEN = 18;
private static final int ON_TASK_DESCRIPTION_CHANGED = 19;
private static final int ON_ACTIVITY_ROTATION = 20;
+ private static final int ON_RECENT_TASK_REMOVED_FOR_ADD_TASK = 21;
/**
* List of {@link TaskStackListenerCallback} registered from {@link #addListener}.
@@ -131,6 +132,11 @@ public class TaskStackListenerImpl extends TaskStackListener implements Handler.
0 /* unused */).sendToTarget();
}
+ @Override
+ public void onRecentTaskRemovedForAddTask(int taskId) {
+ mMainHandler.obtainMessage(ON_RECENT_TASK_REMOVED_FOR_ADD_TASK, taskId).sendToTarget();
+ }
+
@Override
public void onTaskStackChanged() {
// Call the task changed callback for the non-ui thread listeners first. Copy to a set
@@ -408,6 +414,13 @@ public class TaskStackListenerImpl extends TaskStackListener implements Handler.
}
break;
}
+ case ON_RECENT_TASK_REMOVED_FOR_ADD_TASK: {
+ final int taskId = (int) msg.obj;
+ for (int i = mTaskStackListeners.size() - 1; i >= 0; i--) {
+ mTaskStackListeners.get(i).onRecentTaskRemovedForAddTask(taskId);
+ }
+ break;
+ }
case ON_TASK_DESCRIPTION_CHANGED: {
final ActivityManager.RunningTaskInfo
info = (ActivityManager.RunningTaskInfo) msg.obj;
diff --git a/wmshell/src/com/android/wm/shell/common/UserProfileContexts.kt b/wmshell/src/com/android/wm/shell/common/UserProfileContexts.kt
new file mode 100644
index 0000000000..1693864700
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/common/UserProfileContexts.kt
@@ -0,0 +1,97 @@
+/*
+ * 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.common
+
+import android.app.ActivityManager
+import android.content.Context
+import android.content.pm.UserInfo
+import android.os.UserHandle
+import android.os.UserManager
+import android.util.SparseArray
+import com.android.wm.shell.sysui.ShellController
+import com.android.wm.shell.sysui.ShellInit
+import com.android.wm.shell.sysui.UserChangeListener
+import androidx.core.util.size
+
+/** Creates and manages contexts for all the profiles of the current user. */
+class UserProfileContexts(
+ private val baseContext: Context,
+ private val shellController: ShellController,
+ shellInit: ShellInit,
+) {
+ // Contexts for all the profiles of the current user.
+ private val currentProfilesContext = SparseArray()
+
+ private val shellUserId = baseContext.userId
+
+ lateinit var userContext: Context
+ private set
+
+ init {
+ shellInit.addInitCallback(this::onInit, this)
+ }
+
+ private fun onInit() {
+ shellController.addUserChangeListener(
+ object : UserChangeListener {
+ override fun onUserChanged(newUserId: Int, userContext: Context) {
+ currentProfilesContext.clear()
+ this@UserProfileContexts.userContext = userContext
+ currentProfilesContext.put(newUserId, userContext)
+ if (newUserId != shellUserId) {
+ currentProfilesContext.put(shellUserId, baseContext)
+ }
+ }
+
+ override fun onUserProfilesChanged(profiles: List) {
+ updateProfilesContexts(profiles)
+ }
+ }
+ )
+ val defaultUserId = ActivityManager.getCurrentUser()
+ val userManager = baseContext.getSystemService(UserManager::class.java)
+ userContext = baseContext.createContextAsUser(UserHandle.of(defaultUserId), /* flags= */ 0)
+ updateProfilesContexts(userManager.getProfiles(defaultUserId))
+ }
+
+ private fun updateProfilesContexts(profiles: List) {
+ for (profile in profiles) {
+ if (profile.id in currentProfilesContext) continue
+ val profileContext = baseContext.createContextAsUser(profile.userHandle, /* flags= */ 0)
+ currentProfilesContext.put(profile.id, profileContext)
+ }
+ val profilesToRemove = buildList {
+ for (i in 0.. Pair =
+ PipUtils::getTopPipActivity
private val mAppOpsChangedListener = AppOpsManager.OnOpChangedListener { _, packageName ->
try {
// Dismiss the PiP once the user disables the app ops setting for that package
- val topPipActivityInfo = PipUtils.getTopPipActivity(mContext)
+ val topPipActivityInfo = mTopPipActivityInfoSupplier.invoke(mContext)
val componentName = topPipActivityInfo.first ?: return@OnOpChangedListener
val userId = topPipActivityInfo.second
val appInfo = mContext.packageManager
@@ -41,7 +45,9 @@ class PipAppOpsListener(
packageName
) != AppOpsManager.MODE_ALLOWED
) {
- mMainExecutor.execute { mCallback.dismissPip() }
+ mCallback?.let {
+ mMainExecutor.execute { it.dismissPip() }
+ }
}
} catch (e: PackageManager.NameNotFoundException) {
// Unregister the listener if the package can't be found
@@ -49,6 +55,12 @@ class PipAppOpsListener(
}
}
+ private var mCallback: Callback? = null
+
+ fun setCallback(callback: Callback) {
+ mCallback = callback
+ }
+
fun onActivityPinned(packageName: String) {
// Register for changes to the app ops setting for this package while it is in PiP
registerAppOpsListener(packageName)
@@ -75,4 +87,9 @@ class PipAppOpsListener(
/** Dismisses the PIP window. */
fun dismissPip()
}
+
+ @VisibleForTesting
+ fun setTopPipActivityInfoSupplier(supplier: (Context) -> Pair) {
+ mTopPipActivityInfoSupplier = supplier
+ }
}
\ No newline at end of file
diff --git a/wmshell/src/com/android/wm/shell/common/pip/PipBoundsAlgorithm.java b/wmshell/src/com/android/wm/shell/common/pip/PipBoundsAlgorithm.java
index 58007b5035..337e357a7f 100644
--- a/wmshell/src/com/android/wm/shell/common/pip/PipBoundsAlgorithm.java
+++ b/wmshell/src/com/android/wm/shell/common/pip/PipBoundsAlgorithm.java
@@ -24,11 +24,13 @@ import android.content.pm.ActivityInfo;
import android.content.res.Resources;
import android.graphics.Rect;
import android.util.DisplayMetrics;
+import android.util.Rational;
import android.util.Size;
import android.view.Gravity;
-import com.android.internal.protolog.common.ProtoLog;
+import com.android.internal.protolog.ProtoLog;
import com.android.wm.shell.R;
+import com.android.wm.shell.common.DisplayLayout;
import com.android.wm.shell.protolog.ShellProtoLogGroup;
import java.io.PrintWriter;
@@ -36,14 +38,11 @@ import java.io.PrintWriter;
/**
* Calculates the default, normal, entry, inset and movement bounds of the PIP.
*/
-public class PipBoundsAlgorithm {
+public class PipBoundsAlgorithm implements PipDisplayLayoutState.DisplayIdListener {
private static final String TAG = PipBoundsAlgorithm.class.getSimpleName();
private static final float INVALID_SNAP_FRACTION = -1f;
- // The same value (with the same name) is used in Launcher.
- private static final float PIP_ASPECT_RATIO_MISMATCH_THRESHOLD = 0.01f;
-
@NonNull private final PipBoundsState mPipBoundsState;
@NonNull protected final PipDisplayLayoutState mPipDisplayLayoutState;
@NonNull protected final SizeSpecSource mSizeSpecSource;
@@ -64,6 +63,7 @@ public class PipBoundsAlgorithm {
mSnapAlgorithm = pipSnapAlgorithm;
mPipKeepClearAlgorithm = pipKeepClearAlgorithm;
mPipDisplayLayoutState = pipDisplayLayoutState;
+ mPipDisplayLayoutState.addDisplayIdListener(this);
mSizeSpecSource = sizeSpecSource;
reloadResources(context);
// Initialize the aspect ratio to the default aspect ratio. Don't do this in reload
@@ -100,6 +100,11 @@ public class PipBoundsAlgorithm {
reloadResources(context);
}
+ @Override
+ public void onDisplayIdChanged(@NonNull Context context) {
+ reloadResources(context);
+ }
+
/** Returns the normal bounds (i.e. the default entry bounds). */
public Rect getNormalBounds() {
// The normal bounds are the default bounds adjusted to the current aspect ratio.
@@ -223,9 +228,11 @@ public class PipBoundsAlgorithm {
+ " than destination(%s)", sourceRectHint, destinationBounds);
return false;
}
- final float reportedRatio = destinationBounds.width() / (float) destinationBounds.height();
- final float inferredRatio = sourceRectHint.width() / (float) sourceRectHint.height();
- if (Math.abs(reportedRatio - inferredRatio) > PIP_ASPECT_RATIO_MISMATCH_THRESHOLD) {
+ // We use the aspect ratio of source rect hint to check against destination bounds
+ // here to avoid upscaling error.
+ final Rational srcAspectRatio = new Rational(
+ sourceRectHint.width(), sourceRectHint.height());
+ if (!PictureInPictureParams.isSameAspectRatio(destinationBounds, srcAspectRatio)) {
ProtoLog.d(ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE,
"isSourceRectHintValidForEnterPip=false, hint(%s) does not match"
+ " destination(%s) aspect ratio", sourceRectHint, destinationBounds);
@@ -337,6 +344,14 @@ public class PipBoundsAlgorithm {
outRect.set(mPipDisplayLayoutState.getInsetBounds());
}
+ /**
+ * Populates the bounds on the screen that the PIP can be visible on a given
+ * {@param displayLayout}.
+ */
+ public void getInsetBounds(Rect outRect, DisplayLayout displayLayout) {
+ outRect.set(mPipDisplayLayoutState.getInsetBounds(displayLayout));
+ }
+
private int getOverrideMinEdgeSize() {
return mSizeSpecSource.getOverrideMinEdgeSize();
}
@@ -346,16 +361,18 @@ public class PipBoundsAlgorithm {
* controller.
*/
public Rect getMovementBounds(Rect stackBounds) {
- return getMovementBounds(stackBounds, true /* adjustForIme */);
+ return getMovementBounds(stackBounds, true /* adjustForIme */,
+ mPipDisplayLayoutState.getDisplayLayout() /* displayLayout */);
}
/**
- * @return the movement bounds for the given stackBounds and the current state of the
- * controller.
+ * @return the movement bounds for the given stackBounds on a given displayLayout and the
+ * current state of the controller.
*/
- public Rect getMovementBounds(Rect stackBounds, boolean adjustForIme) {
+ public Rect getMovementBounds(Rect stackBounds, boolean adjustForIme,
+ DisplayLayout displayLayout) {
final Rect movementBounds = new Rect();
- getInsetBounds(movementBounds);
+ getInsetBounds(movementBounds, displayLayout);
// Apply the movement bounds adjustments based on the current state.
getMovementBounds(stackBounds, movementBounds, movementBounds,
@@ -460,6 +477,35 @@ public class PipBoundsAlgorithm {
return adjustedNormalBounds;
}
+ /**
+ * Snaps PiP bounds to its movement bounds.
+ */
+ public void snapToMovementBoundsEdge(Rect bounds) {
+ snapToMovementBoundsEdge(bounds, mPipDisplayLayoutState.getDisplayLayout());
+ }
+
+ /**
+ * Snaps PiP bounds to its movement bounds on a given {@param displayLayout}.
+ */
+ public void snapToMovementBoundsEdge(Rect bounds, DisplayLayout displayLayout) {
+ // Get the movement bounds of the display
+ final Rect movementBounds = getMovementBounds(bounds, true /* adjustForIme */,
+ displayLayout);
+ final int leftEdge = bounds.left;
+
+ final int fromLeft = Math.abs(leftEdge - movementBounds.left);
+ final int fromRight = Math.abs(movementBounds.right - leftEdge);
+
+ // The PIP will be snapped to either the right or left edge, so calculate which one
+ // is closest to the current position.
+ final int newLeft = fromLeft < fromRight
+ ? movementBounds.left : movementBounds.right;
+ // Make sure that the PiP window vertically stays within the movement bounds
+ final int newTop = Math.max(movementBounds.top,
+ Math.min(bounds.top, movementBounds.bottom));
+
+ bounds.offsetTo(newLeft, newTop);
+ }
/**
* Dumps internal states.
*/
diff --git a/wmshell/src/com/android/wm/shell/common/pip/PipBoundsState.java b/wmshell/src/com/android/wm/shell/common/pip/PipBoundsState.java
index 7ceaaea396..0bea4da0c1 100644
--- a/wmshell/src/com/android/wm/shell/common/pip/PipBoundsState.java
+++ b/wmshell/src/com/android/wm/shell/common/pip/PipBoundsState.java
@@ -30,9 +30,10 @@ import android.graphics.Rect;
import android.os.RemoteException;
import android.util.ArraySet;
import android.util.Size;
+import android.util.SparseArray;
import com.android.internal.annotations.VisibleForTesting;
-import com.android.internal.protolog.common.ProtoLog;
+import com.android.internal.protolog.ProtoLog;
import com.android.internal.util.function.TriConsumer;
import com.android.wm.shell.R;
import com.android.wm.shell.common.DisplayLayout;
@@ -42,9 +43,7 @@ import java.io.PrintWriter;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
-import java.util.HashMap;
import java.util.List;
-import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Consumer;
@@ -52,7 +51,7 @@ import java.util.function.Consumer;
/**
* Singleton source of truth for the current state of PIP bounds.
*/
-public class PipBoundsState {
+public class PipBoundsState implements PipDisplayLayoutState.DisplayIdListener {
public static final int STASH_TYPE_NONE = 0;
public static final int STASH_TYPE_LEFT = 1;
public static final int STASH_TYPE_RIGHT = 2;
@@ -69,26 +68,37 @@ public class PipBoundsState {
@Retention(RetentionPolicy.SOURCE)
public @interface StashType {}
+ public static final int NAMED_KCA_LAUNCHER_SHELF = 0;
+ public static final int NAMED_KCA_TABLETOP_MODE = 1;
+
+ @IntDef(prefix = { "NAMED_KCA_" }, value = {
+ NAMED_KCA_LAUNCHER_SHELF,
+ NAMED_KCA_TABLETOP_MODE
+ })
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface NamedKca {}
+
private static final String TAG = PipBoundsState.class.getSimpleName();
- private final @NonNull Rect mBounds = new Rect();
- private final @NonNull Rect mMovementBounds = new Rect();
- private final @NonNull Rect mNormalBounds = new Rect();
- private final @NonNull Rect mExpandedBounds = new Rect();
- private final @NonNull Rect mNormalMovementBounds = new Rect();
- private final @NonNull Rect mExpandedMovementBounds = new Rect();
- private final @NonNull PipDisplayLayoutState mPipDisplayLayoutState;
+ @NonNull private final Rect mBounds = new Rect();
+ @NonNull private final Rect mMovementBounds = new Rect();
+ @NonNull private final Rect mNormalBounds = new Rect();
+ @NonNull private final Rect mExpandedBounds = new Rect();
+ @NonNull private final Rect mNormalMovementBounds = new Rect();
+ @NonNull private final Rect mExpandedMovementBounds = new Rect();
+ @NonNull private final Rect mRestoreBounds = new Rect();
+ @NonNull private final PipDisplayLayoutState mPipDisplayLayoutState;
private final Point mMaxSize = new Point();
private final Point mMinSize = new Point();
- private final @NonNull Context mContext;
+ @NonNull private Context mContext;
private float mAspectRatio;
private int mStashedState = STASH_TYPE_NONE;
private int mStashOffset;
- private @Nullable PipReentryState mPipReentryState;
+ @Nullable private PipReentryState mPipReentryState;
private final LauncherState mLauncherState = new LauncherState();
- private final @NonNull SizeSpecSource mSizeSpecSource;
- private @Nullable ComponentName mLastPipComponentName;
- private final @NonNull MotionBoundsState mMotionBoundsState = new MotionBoundsState();
+ @NonNull private final SizeSpecSource mSizeSpecSource;
+ @Nullable private ComponentName mLastPipComponentName;
+ @NonNull private final MotionBoundsState mMotionBoundsState = new MotionBoundsState();
private boolean mIsImeShowing;
private int mImeHeight;
private boolean mIsShelfShowing;
@@ -120,12 +130,21 @@ public class PipBoundsState {
* as unrestricted keep clear area. Values in this map would be appended to
* {@link #getUnrestrictedKeepClearAreas()} and this is meant for internal usage only.
*/
- private final Map mNamedUnrestrictedKeepClearAreas = new HashMap<>();
+ private final SparseArray mNamedUnrestrictedKeepClearAreas = new SparseArray<>();
- private @Nullable Runnable mOnMinimalSizeChangeCallback;
- private @Nullable TriConsumer mOnShelfVisibilityChangeCallback;
- private List> mOnPipExclusionBoundsChangeCallbacks = new ArrayList<>();
- private List> mOnAspectRatioChangedCallbacks = new ArrayList<>();
+ @Nullable private Runnable mOnMinimalSizeChangeCallback;
+ @Nullable private TriConsumer mOnShelfVisibilityChangeCallback;
+ private final List> mOnPipExclusionBoundsChangeCallbacks = new ArrayList<>();
+ private final List> mOnAspectRatioChangedCallbacks = new ArrayList<>();
+
+ /**
+ * This is used to set the launcher shelf height ahead of non-auto-enter-pip animation,
+ * to avoid the race condition. See also {@link #NAMED_KCA_LAUNCHER_SHELF}.
+ */
+ public final Rect mCachedLauncherShelfHeightKeepClearArea = new Rect();
+
+ private final List mOnPipComponentChangedListeners =
+ new ArrayList<>();
// the size of the current bounds relative to the max size spec
private float mBoundsScale;
@@ -136,13 +155,12 @@ public class PipBoundsState {
reloadResources();
mSizeSpecSource = sizeSpecSource;
mPipDisplayLayoutState = pipDisplayLayoutState;
+ mPipDisplayLayoutState.addDisplayIdListener(this);
// Update the relative proportion of the bounds compared to max possible size. Max size
// spec takes the aspect ratio of the bounds into account, so both width and height
// scale by the same factor.
- addPipExclusionBoundsChangeCallback((bounds) -> {
- updateBoundsScale();
- });
+ addPipExclusionBoundsChangeCallback((bounds) -> updateBoundsScale());
}
/** Reloads the resources. */
@@ -153,6 +171,12 @@ public class PipBoundsState {
mSizeSpecSource.onConfigurationChanged();
}
+ @Override
+ public void onDisplayIdChanged(@NonNull Context context) {
+ mContext = context;
+ reloadResources();
+ }
+
/** Update the bounds scale percentage value. */
public void updateBoundsScale() {
mBoundsScale = Math.min((float) mBounds.width() / mMaxSize.x, 1.0f);
@@ -325,11 +349,14 @@ public class PipBoundsState {
/** Set the last {@link ComponentName} to enter PIP mode. */
public void setLastPipComponentName(@Nullable ComponentName lastPipComponentName) {
final boolean changed = !Objects.equals(mLastPipComponentName, lastPipComponentName);
+ if (!changed) return;
+ clearReentryState();
+ setHasUserResizedPip(false);
+ setHasUserMovedPip(false);
+ final ComponentName oldComponentName = mLastPipComponentName;
mLastPipComponentName = lastPipComponentName;
- if (changed) {
- clearReentryState();
- setHasUserResizedPip(false);
- setHasUserMovedPip(false);
+ for (OnPipComponentChangedListener listener : mOnPipComponentChangedListeners) {
+ listener.onPipComponentChanged(oldComponentName, mLastPipComponentName);
}
}
@@ -361,6 +388,16 @@ public class PipBoundsState {
/** Sets the preferred size of PIP as specified by the activity in PIP mode. */
public void setOverrideMinSize(@Nullable Size overrideMinSize) {
+ if (overrideMinSize != null) {
+ final Size defaultSize = mSizeSpecSource.getDefaultSize(getAspectRatio());
+ if (overrideMinSize.getWidth() > defaultSize.getWidth()
+ || overrideMinSize.getHeight() > defaultSize.getHeight()) {
+ ProtoLog.w(ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE,
+ "Ignore override min size(%s): larger than default size (%s)",
+ overrideMinSize, defaultSize);
+ return;
+ }
+ }
final boolean changed = !Objects.equals(overrideMinSize, getOverrideMinSize());
mSizeSpecSource.setOverrideMinSize(overrideMinSize);
if (changed && mOnMinimalSizeChangeCallback != null) {
@@ -389,6 +426,10 @@ public class PipBoundsState {
public void setImeVisibility(boolean imeShowing, int imeHeight) {
mIsImeShowing = imeShowing;
mImeHeight = imeHeight;
+ // If IME is showing, save the current PiP bounds in case we need to restore it later.
+ if (mIsImeShowing) {
+ mRestoreBounds.set(getBounds());
+ }
}
/** Returns whether the IME is currently showing. */
@@ -396,6 +437,16 @@ public class PipBoundsState {
return mIsImeShowing;
}
+ /** Returns the bounds to restore PiP to (bounds before IME was expanded). */
+ public Rect getRestoreBounds() {
+ return mRestoreBounds;
+ }
+
+ /** Sets mRestoreBounds to (0,0,0,0). */
+ public void clearRestoreBounds() {
+ mRestoreBounds.setEmpty();
+ }
+
/** Returns the IME height. */
public int getImeHeight() {
return mImeHeight;
@@ -430,17 +481,32 @@ public class PipBoundsState {
mUnrestrictedKeepClearAreas.addAll(unrestrictedAreas);
}
- /** Add a named unrestricted keep clear area. */
- public void addNamedUnrestrictedKeepClearArea(@NonNull String name, Rect unrestrictedArea) {
- mNamedUnrestrictedKeepClearAreas.put(name, unrestrictedArea);
+ /** Set a named unrestricted keep clear area. */
+ public void setNamedUnrestrictedKeepClearArea(
+ @NamedKca int tag, @Nullable Rect unrestrictedArea) {
+ if (unrestrictedArea == null) {
+ mNamedUnrestrictedKeepClearAreas.remove(tag);
+ } else {
+ mNamedUnrestrictedKeepClearAreas.put(tag, unrestrictedArea);
+ if (tag == NAMED_KCA_LAUNCHER_SHELF) {
+ mCachedLauncherShelfHeightKeepClearArea.set(unrestrictedArea);
+ }
+ }
}
- /** Remove a named unrestricted keep clear area. */
- public void removeNamedUnrestrictedKeepClearArea(@NonNull String name) {
- mNamedUnrestrictedKeepClearAreas.remove(name);
+ /**
+ * Forcefully set the keep-clear-area for launcher shelf height if applicable.
+ * This is used for entering PiP in button navigation mode to make sure the destination bounds
+ * calculation includes the shelf height, to avoid race conditions that such callback is sent
+ * from Launcher after the entering animation is started.
+ */
+ public void mayUseCachedLauncherShelfHeight() {
+ if (!mCachedLauncherShelfHeightKeepClearArea.isEmpty()) {
+ setNamedUnrestrictedKeepClearArea(
+ NAMED_KCA_LAUNCHER_SHELF, mCachedLauncherShelfHeightKeepClearArea);
+ }
}
-
/**
* @return restricted keep clear areas.
*/
@@ -454,9 +520,12 @@ public class PipBoundsState {
*/
@NonNull
public Set getUnrestrictedKeepClearAreas() {
- if (mNamedUnrestrictedKeepClearAreas.isEmpty()) return mUnrestrictedKeepClearAreas;
+ if (mNamedUnrestrictedKeepClearAreas.size() == 0) return mUnrestrictedKeepClearAreas;
final Set unrestrictedAreas = new ArraySet<>(mUnrestrictedKeepClearAreas);
- unrestrictedAreas.addAll(mNamedUnrestrictedKeepClearAreas.values());
+ for (int i = 0; i < mNamedUnrestrictedKeepClearAreas.size(); i++) {
+ final int key = mNamedUnrestrictedKeepClearAreas.keyAt(i);
+ unrestrictedAreas.add(mNamedUnrestrictedKeepClearAreas.get(key));
+ }
return unrestrictedAreas;
}
@@ -488,6 +557,10 @@ public class PipBoundsState {
/** Set whether the user has resized the PIP. */
public void setHasUserResizedPip(boolean hasUserResizedPip) {
mHasUserResizedPip = hasUserResizedPip;
+ // If user resized PiP while IME is showing, clear the pre-IME restore bounds.
+ if (hasUserResizedPip && isImeShowing()) {
+ clearRestoreBounds();
+ }
}
/** Returns whether the user has moved the PIP. */
@@ -498,6 +571,10 @@ public class PipBoundsState {
/** Set whether the user has moved the PIP. */
public void setHasUserMovedPip(boolean hasUserMovedPip) {
mHasUserMovedPip = hasUserMovedPip;
+ // If user moved PiP while IME is showing, clear the pre-IME restore bounds.
+ if (hasUserMovedPip && isImeShowing()) {
+ clearRestoreBounds();
+ }
}
/**
@@ -550,6 +627,21 @@ public class PipBoundsState {
}
}
+ /** Adds callback to listen on component change. */
+ public void addOnPipComponentChangedListener(@NonNull OnPipComponentChangedListener listener) {
+ if (!mOnPipComponentChangedListeners.contains(listener)) {
+ mOnPipComponentChangedListeners.add(listener);
+ }
+ }
+
+ /** Removes callback to listen on component change. */
+ public void removeOnPipComponentChangedListener(
+ @NonNull OnPipComponentChangedListener listener) {
+ if (mOnPipComponentChangedListeners.contains(listener)) {
+ mOnPipComponentChangedListeners.remove(listener);
+ }
+ }
+
public LauncherState getLauncherState() {
return mLauncherState;
}
@@ -629,7 +721,7 @@ public class PipBoundsState {
* Represents the state of pip to potentially restore upon reentry.
*/
@VisibleForTesting
- public static final class PipReentryState {
+ static final class PipReentryState {
private static final String TAG = PipReentryState.class.getSimpleName();
private final float mSnapFraction;
@@ -656,6 +748,22 @@ public class PipBoundsState {
}
}
+ /**
+ * Listener interface for PiP component change, i.e. the app in pip mode changes
+ * TODO: Move this out of PipBoundsState once pip1 is deprecated.
+ */
+ public interface OnPipComponentChangedListener {
+ /**
+ * Callback when the component in pip mode changes.
+ * @param oldPipComponent previous component in pip mode,
+ * {@code null} if this is the very first time PiP appears.
+ * @param newPipComponent new component that enters pip mode.
+ */
+ void onPipComponentChanged(
+ @Nullable ComponentName oldPipComponent,
+ @NonNull ComponentName newPipComponent);
+ }
+
/** Dumps internal state. */
public void dump(PrintWriter pw, String prefix) {
final String innerPrefix = prefix + " ";
diff --git a/wmshell/src/com/android/wm/shell/common/pip/PipDesktopState.kt b/wmshell/src/com/android/wm/shell/common/pip/PipDesktopState.kt
new file mode 100644
index 0000000000..a39dc074d8
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/common/pip/PipDesktopState.kt
@@ -0,0 +1,168 @@
+/*
+ * 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.common.pip
+
+import android.app.WindowConfiguration.WINDOWING_MODE_FREEFORM
+import android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN
+import android.app.WindowConfiguration.WINDOWING_MODE_UNDEFINED
+import android.window.DesktopExperienceFlags
+import com.android.internal.protolog.ProtoLog
+import com.android.wm.shell.RootTaskDisplayAreaOrganizer
+import com.android.wm.shell.common.DisplayLayout
+import com.android.wm.shell.desktopmode.DesktopUserRepositories
+import com.android.wm.shell.desktopmode.DragToDesktopTransitionHandler
+import com.android.wm.shell.desktopmode.desktopfirst.isDisplayDesktopFirst
+import com.android.wm.shell.protolog.ShellProtoLogGroup
+import com.android.wm.shell.recents.RecentsTransitionHandler
+import com.android.wm.shell.recents.RecentsTransitionStateListener
+import com.android.wm.shell.recents.RecentsTransitionStateListener.RecentsTransitionState
+import com.android.wm.shell.recents.RecentsTransitionStateListener.TRANSITION_STATE_NOT_RUNNING
+import com.android.wm.shell.shared.pip.PipFlags
+import java.util.Optional
+
+/** Helper class for PiP on Desktop Mode. */
+class PipDesktopState(
+ private val pipDisplayLayoutState: PipDisplayLayoutState,
+ recentsTransitionHandler: RecentsTransitionHandler,
+ private val desktopUserRepositoriesOptional: Optional,
+ private val dragToDesktopTransitionHandlerOptional: Optional,
+ val rootTaskDisplayAreaOrganizer: RootTaskDisplayAreaOrganizer
+) {
+ @RecentsTransitionState
+ private var recentsTransitionState = TRANSITION_STATE_NOT_RUNNING
+
+ init {
+ recentsTransitionHandler.addTransitionStateListener(
+ object : RecentsTransitionStateListener {
+ override fun onTransitionStateChanged(@RecentsTransitionState state: Int) {
+ logV(
+ "Recents transition state changed: %s",
+ RecentsTransitionStateListener.stateToString(state),
+ )
+ recentsTransitionState = state
+ }
+ }
+ )
+ }
+
+ /**
+ * Returns whether PiP in Desktop Windowing is enabled by checking the following:
+ * - PiP in Desktop Windowing flag is enabled
+ * - DesktopUserRepositories is present
+ * - DragToDesktopTransitionHandler is present
+ */
+ fun isDesktopWindowingPipEnabled(): Boolean =
+ DesktopExperienceFlags.ENABLE_DESKTOP_WINDOWING_PIP.isTrue &&
+ desktopUserRepositoriesOptional.isPresent &&
+ dragToDesktopTransitionHandlerOptional.isPresent
+
+ /**
+ * Returns whether PiP in Connected Displays is enabled by checking the following:
+ * - PiP in Desktop Windowing is enabled
+ * - PiP in Connected Displays flag is enabled
+ * - PiP2 is enabled
+ */
+ fun isConnectedDisplaysPipEnabled(): Boolean =
+ isDesktopWindowingPipEnabled() &&
+ DesktopExperienceFlags.ENABLE_CONNECTED_DISPLAYS_PIP.isTrue &&
+ PipFlags.isPip2ExperimentEnabled
+
+ /**
+ * Returns whether dragging PiP in Connected Displays is enabled by checking the following:
+ * - Dragging PiP in Connected Displays flag is enabled
+ * - PiP in Connected Displays flag is enabled
+ * - PiP2 flag is enabled
+ */
+ fun isDraggingPipAcrossDisplaysEnabled(): Boolean =
+ DesktopExperienceFlags.ENABLE_DRAGGING_PIP_ACROSS_DISPLAYS.isTrue &&
+ isConnectedDisplaysPipEnabled()
+
+ /** Returns whether the display with the PiP task is in freeform windowing mode. */
+ private fun isDisplayInFreeform(): Boolean {
+ val tdaInfo = rootTaskDisplayAreaOrganizer.getDisplayAreaInfo(
+ pipDisplayLayoutState.displayId
+ )
+
+ return tdaInfo?.configuration?.windowConfiguration?.windowingMode == WINDOWING_MODE_FREEFORM
+ }
+
+ /** Returns whether PiP is active in a display that is in active Desktop Mode session. */
+ fun isPipInDesktopMode(): Boolean {
+ if (!isDesktopWindowingPipEnabled()) {
+ return false
+ }
+
+ val displayId = pipDisplayLayoutState.displayId
+ logV(
+ "isPipInDesktopMode isAnyDeskActive=%b isDisplayDesktopFirst=%b",
+ desktopUserRepositoriesOptional.get().current.isAnyDeskActive(displayId),
+ rootTaskDisplayAreaOrganizer.isDisplayDesktopFirst(displayId),
+ )
+ return desktopUserRepositoriesOptional.get().current.isAnyDeskActive(displayId) ||
+ rootTaskDisplayAreaOrganizer.isDisplayDesktopFirst(displayId)
+ }
+
+ /** Returns whether the display with the given id is a Desktop-first display. */
+ fun isDisplayDesktopFirst(displayId: Int) =
+ rootTaskDisplayAreaOrganizer.isDisplayDesktopFirst(displayId)
+
+ /** Returns whether Recents is in the middle of animating. */
+ fun isRecentsAnimating(): Boolean =
+ RecentsTransitionStateListener.isAnimating(recentsTransitionState)
+
+ /** Returns the windowing mode to restore to when resizing out of PIP direction. */
+ fun getOutPipWindowingMode(): Int {
+ val isInDesktop = isPipInDesktopMode()
+ // Temporary workaround for b/409201669: Always expand to fullscreen if we're exiting PiP
+ // in the middle of Recents animation from Desktop session.
+ if (isRecentsAnimating() && isInDesktop) {
+ return WINDOWING_MODE_FULLSCREEN
+ }
+
+ // If we are exiting PiP while the device is in Desktop mode, the task should expand to
+ // freeform windowing mode.
+ // 1) If the display windowing mode is freeform or if the ENABLE_MULTIPLE_DESKTOPS_BACKEND
+ // flag is true, set windowing mode to UNDEFINED so it will resolve the windowing mode to
+ // the display or root desk's windowing mode (which is always FREEFORM).
+ // 2) Otherwise, set windowing mode to FREEFORM.
+ if (isInDesktop) {
+ return if (isDisplayInFreeform()
+ || DesktopExperienceFlags.ENABLE_MULTIPLE_DESKTOPS_BACKEND.isTrue) {
+ WINDOWING_MODE_UNDEFINED
+ } else {
+ WINDOWING_MODE_FREEFORM
+ }
+ }
+
+ // By default, or if the task is going to fullscreen, reset the windowing mode to undefined.
+ return WINDOWING_MODE_UNDEFINED
+ }
+
+ /** Returns whether there is a drag-to-desktop transition in progress. */
+ fun isDragToDesktopInProgress(): Boolean =
+ isDesktopWindowingPipEnabled() && dragToDesktopTransitionHandlerOptional.get().inProgress
+
+ /** Returns the DisplayLayout associated with the display where PiP window is in. */
+ fun getCurrentDisplayLayout(): DisplayLayout = pipDisplayLayoutState.displayLayout
+
+ private fun logV(msg: String, vararg arguments: Any?) {
+ ProtoLog.v(ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE, "%s: $msg", TAG, *arguments)
+ }
+
+ companion object {
+ private const val TAG = "PipDesktopState"
+ }
+}
diff --git a/wmshell/src/com/android/wm/shell/common/pip/PipDisplayLayoutState.java b/wmshell/src/com/android/wm/shell/common/pip/PipDisplayLayoutState.java
index d5e47187da..45f28c70c1 100644
--- a/wmshell/src/com/android/wm/shell/common/pip/PipDisplayLayoutState.java
+++ b/wmshell/src/com/android/wm/shell/common/pip/PipDisplayLayoutState.java
@@ -18,8 +18,11 @@ package com.android.wm.shell.common.pip;
import static com.android.wm.shell.common.pip.PipUtils.dpToPx;
+import static java.lang.Math.max;
+
import android.content.Context;
import android.content.res.Resources;
+import android.graphics.Insets;
import android.graphics.Point;
import android.graphics.Rect;
import android.util.Size;
@@ -28,10 +31,14 @@ import android.view.Surface;
import androidx.annotation.NonNull;
import com.android.wm.shell.R;
+import com.android.wm.shell.common.DisplayController;
import com.android.wm.shell.common.DisplayLayout;
import com.android.wm.shell.dagger.WMSingleton;
+import com.android.wm.shell.sysui.ShellInit;
import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.List;
import javax.inject.Inject;
@@ -42,16 +49,27 @@ import javax.inject.Inject;
public class PipDisplayLayoutState {
private static final String TAG = PipDisplayLayoutState.class.getSimpleName();
- private Context mContext;
+ private final Context mContext;
+ private Context mUiContext;
private int mDisplayId;
@NonNull private DisplayLayout mDisplayLayout;
-
+ @NonNull private final DisplayController mDisplayController;
private Point mScreenEdgeInsets = null;
+ private Insets mNavigationBarsInsets = Insets.NONE;
+ private final List mDisplayIdListeners = new ArrayList<>();
@Inject
- public PipDisplayLayoutState(Context context) {
+ public PipDisplayLayoutState(Context context, @NonNull DisplayController displayController,
+ ShellInit shellInit) {
mContext = context;
+ mUiContext = context;
mDisplayLayout = new DisplayLayout();
+ mDisplayController = displayController;
+ shellInit.addInitCallback(this::onInit, this);
+ }
+
+ /** Called when Shell is done initializing. */
+ public void onInit() {
reloadResources();
}
@@ -61,7 +79,7 @@ public class PipDisplayLayoutState {
}
private void reloadResources() {
- Resources res = mContext.getResources();
+ Resources res = mUiContext.getResources();
final String screenEdgeInsetsDpString = res.getString(
R.string.config_defaultPictureInPictureScreenEdgeInsets);
@@ -81,12 +99,23 @@ public class PipDisplayLayoutState {
* Returns the inset bounds the PIP window can be visible in.
*/
public Rect getInsetBounds() {
- Rect insetBounds = new Rect();
- Rect insets = getDisplayLayout().stableInsets();
- insetBounds.set(insets.left + getScreenEdgeInsets().x,
- insets.top + getScreenEdgeInsets().y,
- getDisplayLayout().width() - insets.right - getScreenEdgeInsets().x,
- getDisplayLayout().height() - insets.bottom - getScreenEdgeInsets().y);
+ return getInsetBounds(getDisplayLayout());
+ }
+
+ /**
+ * Returns the inset bounds the PIP window can be visible on a given {@param displayLayout}
+ */
+ public Rect getInsetBounds(DisplayLayout displayLayout) {
+ final Rect insetBounds = new Rect();
+ final Rect stableInsets = displayLayout.stableInsets();
+ final Point screenEdgeInsets = getScreenEdgeInsets();
+ final int left = max(stableInsets.left, mNavigationBarsInsets.left) + screenEdgeInsets.x;
+ final int top = max(stableInsets.top, mNavigationBarsInsets.top) + screenEdgeInsets.y;
+ final int right = displayLayout.width()
+ - max(stableInsets.right, mNavigationBarsInsets.right) - screenEdgeInsets.x;
+ final int bottom = displayLayout.height()
+ - max(stableInsets.bottom, mNavigationBarsInsets.bottom) - screenEdgeInsets.y;
+ insetBounds.set(left, top, right, bottom);
return insetBounds;
}
@@ -112,7 +141,7 @@ public class PipDisplayLayoutState {
* @param targetRotation
*/
public void rotateTo(@Surface.Rotation int targetRotation) {
- mDisplayLayout.rotateTo(mContext.getResources(), targetRotation);
+ mDisplayLayout.rotateTo(mUiContext.getResources(), targetRotation);
}
/** Returns the current display rotation of this layout state. */
@@ -128,7 +157,43 @@ public class PipDisplayLayoutState {
/** Set the current display id for the associated display layout. */
public void setDisplayId(int displayId) {
+ if (mDisplayId == displayId) {
+ return;
+ }
+
mDisplayId = displayId;
+ updateUiContext();
+ }
+
+ private void updateUiContext() {
+ final Context newContext = mDisplayController.getDisplayContext(mDisplayId);
+ if (newContext == null) {
+ return;
+ }
+
+ mUiContext = newContext;
+ reloadResources();
+ for (DisplayIdListener listener : mDisplayIdListeners) {
+ listener.onDisplayIdChanged(mUiContext);
+ }
+ }
+
+ /** Returns the context associated with the current display. */
+ public Context getCurrentUiContext() {
+ return mUiContext;
+ }
+
+ /** Registers a DisplayIdListener. */
+ public void addDisplayIdListener(DisplayIdListener listener) {
+ if (mDisplayIdListeners.contains(listener)) {
+ return;
+ }
+ mDisplayIdListeners.add(listener);
+ }
+
+ /** Set the navigationBars side and widthOrHeight. */
+ public void setNavigationBarsInsets(Insets insets) {
+ mNavigationBarsInsets = insets;
}
/** Dumps internal state. */
@@ -138,5 +203,15 @@ public class PipDisplayLayoutState {
pw.println(innerPrefix + "mDisplayId=" + mDisplayId);
pw.println(innerPrefix + "getDisplayBounds=" + getDisplayBounds());
pw.println(innerPrefix + "mScreenEdgeInsets=" + mScreenEdgeInsets);
+ pw.println(innerPrefix + "mNavigationBarsInsets=" + mNavigationBarsInsets);
+ }
+
+ /** Listener interface for display id changes. */
+ public interface DisplayIdListener {
+ /**
+ * Informs listener of display id change. Default implementation does nothing.
+ * @param displayContext associated with the updated display
+ */
+ default void onDisplayIdChanged(@NonNull Context displayContext) {}
}
}
diff --git a/wmshell/src/com/android/wm/shell/common/pip/PipDoubleTapHelper.java b/wmshell/src/com/android/wm/shell/common/pip/PipDoubleTapHelper.java
index 4cbb78f2da..d36201a4ac 100644
--- a/wmshell/src/com/android/wm/shell/common/pip/PipDoubleTapHelper.java
+++ b/wmshell/src/com/android/wm/shell/common/pip/PipDoubleTapHelper.java
@@ -52,24 +52,15 @@ public class PipDoubleTapHelper {
public static final int SIZE_SPEC_MAX = 1;
public static final int SIZE_SPEC_CUSTOM = 2;
- /**
- * Returns MAX or DEFAULT {@link PipSizeSpec} to toggle to/from.
- *
- * Each double tap toggles back and forth between {@code PipSizeSpec.CUSTOM} and
- * either {@code PipSizeSpec.MAX} or {@code PipSizeSpec.DEFAULT}. The choice between
- * the latter two sizes is determined based on the current state of the pip screen.
- *
- * @param mPipBoundsState current state of the pip screen
- */
@PipSizeSpec
- private static int getMaxOrDefaultPipSizeSpec(@NonNull PipBoundsState mPipBoundsState) {
+ private static int getMaxOrDefaultPipSizeSpec(@NonNull PipBoundsState pipBoundsState) {
// determine the average pip screen width
- int averageWidth = (mPipBoundsState.getMaxSize().x
- + mPipBoundsState.getMinSize().x) / 2;
+ int averageWidth = (pipBoundsState.getMaxSize().x
+ + pipBoundsState.getMinSize().x) / 2;
// If pip screen width is above average, DEFAULT is the size spec we need to
// toggle to. Otherwise, we choose MAX.
- return (mPipBoundsState.getBounds().width() > averageWidth)
+ return (pipBoundsState.getBounds().width() > averageWidth)
? SIZE_SPEC_DEFAULT
: SIZE_SPEC_MAX;
}
@@ -77,35 +68,33 @@ public class PipDoubleTapHelper {
/**
* Determines the {@link PipSizeSpec} to toggle to on double tap.
*
- * @param mPipBoundsState current state of the pip screen
+ * @param pipBoundsState current state of the pip bounds
* @param userResizeBounds latest user resized bounds (by pinching in/out)
- * @return pip screen size to switch to
*/
@PipSizeSpec
- public static int nextSizeSpec(@NonNull PipBoundsState mPipBoundsState,
+ public static int nextSizeSpec(@NonNull PipBoundsState pipBoundsState,
@NonNull Rect userResizeBounds) {
- // is pip screen at its maximum
- boolean isScreenMax = mPipBoundsState.getBounds().width()
- == mPipBoundsState.getMaxSize().x;
-
- // is pip screen at its normal default size
- boolean isScreenDefault = (mPipBoundsState.getBounds().width()
- == mPipBoundsState.getNormalBounds().width())
- && (mPipBoundsState.getBounds().height()
- == mPipBoundsState.getNormalBounds().height());
+ boolean isScreenMax = pipBoundsState.getBounds().width() == pipBoundsState.getMaxSize().x
+ && pipBoundsState.getBounds().height() == pipBoundsState.getMaxSize().y;
+ boolean isScreenDefault = (pipBoundsState.getBounds().width()
+ == pipBoundsState.getNormalBounds().width())
+ && (pipBoundsState.getBounds().height()
+ == pipBoundsState.getNormalBounds().height());
// edge case 1
// if user hasn't resized screen yet, i.e. CUSTOM size does not exist yet
// or if user has resized exactly to DEFAULT, then we just want to maximize
if (isScreenDefault
- && userResizeBounds.width() == mPipBoundsState.getNormalBounds().width()) {
+ && userResizeBounds.width() == pipBoundsState.getNormalBounds().width()
+ && userResizeBounds.height() == pipBoundsState.getNormalBounds().height()) {
return SIZE_SPEC_MAX;
}
// edge case 2
- // if user has maximized, then we want to toggle to DEFAULT
+ // if user has resized to max, then we want to toggle to DEFAULT
if (isScreenMax
- && userResizeBounds.width() == mPipBoundsState.getMaxSize().x) {
+ && userResizeBounds.width() == pipBoundsState.getMaxSize().x
+ && userResizeBounds.height() == pipBoundsState.getMaxSize().y) {
return SIZE_SPEC_DEFAULT;
}
@@ -113,9 +102,6 @@ public class PipDoubleTapHelper {
if (isScreenDefault || isScreenMax) {
return SIZE_SPEC_CUSTOM;
}
-
- // if we are currently in user resized CUSTOM size state
- // then we toggle either to MAX or DEFAULT depending on the current pip screen state
- return getMaxOrDefaultPipSizeSpec(mPipBoundsState);
+ return getMaxOrDefaultPipSizeSpec(pipBoundsState);
}
}
diff --git a/wmshell/src/com/android/wm/shell/common/pip/PipMediaController.kt b/wmshell/src/com/android/wm/shell/common/pip/PipMediaController.kt
index 427a555eee..9098544c9e 100644
--- a/wmshell/src/com/android/wm/shell/common/pip/PipMediaController.kt
+++ b/wmshell/src/com/android/wm/shell/common/pip/PipMediaController.kt
@@ -302,6 +302,13 @@ class PipMediaController(private val mContext: Context, private val mMainHandler
setActiveMediaController(null)
}
+ /**
+ * Returns {@code true} if the pinned Activity has an active associated MediaSession.
+ */
+ fun hasActiveMediaSession(): Boolean {
+ return mMediaController != null
+ }
+
/**
* Sets the active media controller for the top PiP activity.
*/
diff --git a/wmshell/src/com/android/wm/shell/common/pip/PipMenuController.java b/wmshell/src/com/android/wm/shell/common/pip/PipMenuController.java
index 85353d3070..bad4a934ad 100644
--- a/wmshell/src/com/android/wm/shell/common/pip/PipMenuController.java
+++ b/wmshell/src/com/android/wm/shell/common/pip/PipMenuController.java
@@ -18,7 +18,6 @@ package com.android.wm.shell.common.pip;
import static android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE;
import static android.view.WindowManager.LayoutParams.FLAG_SLIPPERY;
-import static android.view.WindowManager.LayoutParams.FLAG_SPLIT_TOUCH;
import static android.view.WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH;
import static android.view.WindowManager.LayoutParams.PRIVATE_FLAG_TRUSTED_OVERLAY;
import static android.view.WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
@@ -111,7 +110,7 @@ public interface PipMenuController {
int width, int height) {
final WindowManager.LayoutParams lp = new WindowManager.LayoutParams(width, height,
TYPE_APPLICATION_OVERLAY,
- FLAG_WATCH_OUTSIDE_TOUCH | FLAG_SPLIT_TOUCH | FLAG_SLIPPERY | FLAG_NOT_TOUCHABLE,
+ FLAG_WATCH_OUTSIDE_TOUCH | FLAG_SLIPPERY | FLAG_NOT_TOUCHABLE,
PixelFormat.TRANSLUCENT);
lp.privateFlags |= PRIVATE_FLAG_TRUSTED_OVERLAY;
lp.setTitle(title);
diff --git a/wmshell/src/com/android/wm/shell/common/pip/PipPerfHintController.java b/wmshell/src/com/android/wm/shell/common/pip/PipPerfHintController.java
index c421dec025..b9c698e5d8 100644
--- a/wmshell/src/com/android/wm/shell/common/pip/PipPerfHintController.java
+++ b/wmshell/src/com/android/wm/shell/common/pip/PipPerfHintController.java
@@ -26,7 +26,7 @@ import android.window.SystemPerformanceHinter.HighPerfSession;
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.common.ShellExecutor;
import com.android.wm.shell.shared.annotations.ShellMainThread;
diff --git a/wmshell/src/com/android/wm/shell/common/pip/PipUtils.kt b/wmshell/src/com/android/wm/shell/common/pip/PipUtils.kt
index a09720dd6a..b692fbe69d 100644
--- a/wmshell/src/com/android/wm/shell/common/pip/PipUtils.kt
+++ b/wmshell/src/com/android/wm/shell/common/pip/PipUtils.kt
@@ -16,31 +16,40 @@
package com.android.wm.shell.common.pip
import android.app.ActivityTaskManager
-import android.app.AppGlobals
import android.app.RemoteAction
+import android.app.TaskInfo
import android.app.WindowConfiguration
import android.content.ComponentName
import android.content.Context
-import android.content.pm.PackageManager
+import android.content.res.Configuration
+import android.content.res.Configuration.UI_MODE_NIGHT_MASK
+import android.graphics.PointF
import android.graphics.Rect
import android.os.RemoteException
-import android.os.SystemProperties
import android.util.DisplayMetrics
import android.util.Log
import android.util.Pair
import android.util.TypedValue
+import android.window.DesktopExperienceFlags.ENABLE_DESKTOP_WINDOWING_PIP
import android.window.TaskSnapshot
-import com.android.internal.protolog.common.ProtoLog
+import android.window.TransitionInfo
+import com.android.internal.protolog.ProtoLog
+import com.android.wm.shell.shared.pip.PipFlags
import com.android.wm.shell.Flags
import com.android.wm.shell.protolog.ShellProtoLogGroup
+import java.io.PrintWriter
import kotlin.math.abs
+import kotlin.math.ceil
+import kotlin.math.floor
+import kotlin.math.roundToInt
/** A class that includes convenience methods. */
object PipUtils {
private const val TAG = "PipUtils"
// Minimum difference between two floats (e.g. aspect ratios) to consider them not equal.
- private const val EPSILON = 1e-7
+ // TODO b/377530560: Restore epsilon once a long term fix is merged for non-config-at-end issue.
+ private const val EPSILON = 0.05f
/**
* @return the ComponentName and user id of the top non-SystemUI activity in the pinned stack.
@@ -141,46 +150,190 @@ object PipUtils {
/**
- * Returns a fake source rect hint for animation purposes when app-provided one is invalid.
+ * Returns a pseudo source rect hint for animation purposes when app-provided one is invalid.
* Resulting adjusted source rect hint lets the app icon in the content overlay to stay visible.
*/
@JvmStatic
- fun getEnterPipWithOverlaySrcRectHint(appBounds: Rect, aspectRatio: Float): Rect {
+ fun getPseudoSourceRectHint(appBounds: Rect, aspectRatio: Float): Rect {
val appBoundsAspRatio = appBounds.width().toFloat() / appBounds.height()
val width: Int
val height: Int
- var left = 0
- var top = 0
+ var left = appBounds.left
+ var top = appBounds.top
if (appBoundsAspRatio < aspectRatio) {
width = appBounds.width()
- height = Math.round(width / aspectRatio)
- top = (appBounds.height() - height) / 2
+ height = (width / aspectRatio).roundToInt()
+ top = appBounds.top + (appBounds.height() - height) / 2
} else {
height = appBounds.height()
- width = Math.round(height * aspectRatio)
- left = (appBounds.width() - width) / 2
+ width = (height * aspectRatio).roundToInt()
+ left = appBounds.left + (appBounds.width() - width) / 2
}
return Rect(left, top, left + width, top + height)
}
- private var isPip2ExperimentEnabled: Boolean? = null
+ /**
+ * Temporary rounding "outward" (ie. -1.2 -> -2) used for crop since it is an int. We lean
+ * outward since, usually, child surfaces are, themselves, cropped, so we'd prefer to avoid
+ * inadvertently cutting out content that would otherwise be visible.
+ */
+ private fun roundOut(`val`: Float): Int {
+ return (if (`val` >= 0f) ceil(`val`) else floor(`val`)).toInt()
+ }
/**
- * Returns true if PiP2 implementation should be used. Besides the trunk stable flag,
- * system property can be used to override this read only flag during development.
- * It's currently limited to phone form factor, i.e., not enabled on ARC / TV.
+ * Calculates the transform to apply on a UNTRANSFORMED (config-at-end) Activity surface in
+ * order for it's hint-rect to occupy the same task-relative position/dimensions as it would
+ * have at the end of the transition (post-configuration).
+ *
+ * This is intended to be used in tandem with [calcStartTransform] below applied to the parent
+ * task. Applying both transforms simultaneously should result in the appearance of nothing
+ * having happened yet.
+ *
+ * Only the task should be animated (into it's identity state) and then WMCore will reset the
+ * activity transform in sync with its new configuration upon finish.
+ *
+ * Usage example:
+ * calcEndTransform(pipActivity, pipTask, scale, pos);
+ * t.setScale(pipActivity.getLeash(), scale.x, scale.y);
+ * t.setPosition(pipActivity.getLeash(), pos.x, pos.y);
+ *
+ * @see calcStartTransform
*/
@JvmStatic
- fun isPip2ExperimentEnabled(): Boolean {
- if (isPip2ExperimentEnabled == null) {
- val isArc = AppGlobals.getPackageManager().hasSystemFeature(
- "org.chromium.arc", 0)
- val isTv = AppGlobals.getPackageManager().hasSystemFeature(
- PackageManager.FEATURE_LEANBACK, 0)
- isPip2ExperimentEnabled = SystemProperties.getBoolean(
- "persist.wm_shell.pip2", false) ||
- (Flags.enablePip2Implementation() && !isArc && !isTv)
+ fun calcEndTransform(pipActivity: TransitionInfo.Change, pipTask: TransitionInfo.Change,
+ outScale: PointF, outPos: PointF) {
+ val actStartBounds = pipActivity.startAbsBounds
+ val actEndBounds = pipActivity.endAbsBounds
+ val taskEndBounds = pipTask.endAbsBounds
+
+ var hintRect = pipTask.taskInfo?.pictureInPictureParams?.sourceRectHint
+ if (hintRect == null) {
+ hintRect = Rect(actStartBounds)
+ hintRect.offsetTo(0, 0)
}
- return isPip2ExperimentEnabled as Boolean
+
+ // FA = final activity bounds (absolute)
+ // FT = final task bounds (absolute)
+ // SA = start activity bounds (absolute)
+ // H = source hint (relative to start activity bounds)
+ // We want to transform the activity so that when the task is at FT, H overlaps with FA
+
+ // This scales the activity such that the hint rect has the same dimensions
+ // as the final activity bounds.
+ val hintToEndScaleX = (actEndBounds.width().toFloat()) / (hintRect.width().toFloat())
+ val hintToEndScaleY = (actEndBounds.height().toFloat()) / (hintRect.height().toFloat())
+ // top-left needs to be (FA.tl - FT.tl) - H.tl * hintToEnd . H is relative to the
+ // activity; so, for example, if shrinking H to FA (hintToEnd < 1), then the tl of the
+ // shrunk SA is closer to H than expected, so we need to reduce how much we offset SA
+ // to get H.tl to match.
+ val startActPosInTaskEndX =
+ (actEndBounds.left - taskEndBounds.left) - hintRect.left * hintToEndScaleX
+ val startActPosInTaskEndY =
+ (actEndBounds.top - taskEndBounds.top) - hintRect.top * hintToEndScaleY
+ outScale.set(hintToEndScaleX, hintToEndScaleY)
+ outPos.set(startActPosInTaskEndX, startActPosInTaskEndY)
}
-}
\ No newline at end of file
+
+ @JvmStatic
+ fun isContentPip(pipTaskInfo: TaskInfo?): Boolean {
+ if (pipTaskInfo == null) return false
+ return pipTaskInfo.launchIntoPipHostTaskId != -1
+ }
+
+ /**
+ * Calculates the transform and crop to apply on a Task surface in order for the config-at-end
+ * activity inside it (original-size activity transformed to match it's hint rect to the final
+ * Task bounds) to occupy the same world-space position/dimensions as it had before the
+ * transition.
+ *
+ * Intended to be used in tandem with [calcEndTransform].
+ *
+ * Usage example:
+ * calcStartTransform(pipTask, scale, pos, crop);
+ * t.setScale(pipTask.getLeash(), scale.x, scale.y);
+ * t.setPosition(pipTask.getLeash(), pos.x, pos.y);
+ * t.setCrop(pipTask.getLeash(), crop);
+ *
+ * @see calcEndTransform
+ */
+ @JvmStatic
+ fun calcStartTransform(pipTask: TransitionInfo.Change, outScale: PointF,
+ outPos: PointF, outCrop: Rect) {
+ val startBounds = pipTask.startAbsBounds
+ val taskEndBounds = pipTask.endAbsBounds
+ // For now, pip activity bounds always matches task bounds. If this ever changes, we'll
+ // need to get the activity offset.
+ val endBounds = taskEndBounds
+ var hintRect = pipTask.taskInfo?.pictureInPictureParams?.sourceRectHint
+ if (hintRect == null) {
+ hintRect = Rect(startBounds)
+ hintRect.offsetTo(0, 0)
+ }
+
+ // FA = final activity bounds (absolute)
+ // FT = final task bounds (absolute)
+ // SA = start activity bounds (absolute)
+ // H = source hint (relative to start activity bounds)
+ // We want to transform the activity so that when the task is at FT, H overlaps with FA
+
+ // The scaling which takes the hint rect (H) in SA and matches it to FA
+ val hintToEndScaleX = (endBounds.width().toFloat()) / (hintRect.width().toFloat())
+ val hintToEndScaleY = (endBounds.height().toFloat()) / (hintRect.height().toFloat())
+
+ // We want to set the transform on the END TASK surface to put the start activity
+ // back to where it was.
+ // First do backwards scale (which takes FA back to H)
+ val endToHintScaleX = 1f / hintToEndScaleX
+ val endToHintScaleY = 1f / hintToEndScaleY
+ // Then top-left needs to place FA (relative to the FT) at H (relative to SA):
+ // so -(FA.tl - FT.tl) + SA.tl + H.tl
+ // but we have scaled up the task, so anything that was "within" the task needs to
+ // be scaled:
+ // so -(FA.tl - FT.tl)*endToHint + SA.tl + H.tl
+ val endTaskPosForStartX = (-(endBounds.left - taskEndBounds.left) * endToHintScaleX
+ + startBounds.left + hintRect.left)
+ val endTaskPosForStartY = (-(endBounds.top - taskEndBounds.top) * endToHintScaleY
+ + startBounds.top + hintRect.top)
+ outScale.set(endToHintScaleX, endToHintScaleY)
+ outPos.set(endTaskPosForStartX, endTaskPosForStartY)
+
+ // now need to set crop to reveal the non-hint stuff. Again, hintrect is relative, so
+ // we must apply outsets to reveal the *activity* content which is *inside* the task
+ // and thus is scaled (ie. if activity is scaled down, each task-level pixel exposes
+ // >1 activity-level pixels)
+ // For example, the topleft crop would be:
+ // (FA.tl - FT.tl) - H.tl * hintToEnd
+ // ^ activity within task
+ // bottomright can just use scaled activity size
+ // tl + scale(SA.size, hintToEnd)
+ outCrop.left = roundOut((endBounds.left - taskEndBounds.left)
+ - hintRect.left * hintToEndScaleX)
+ outCrop.top = roundOut((endBounds.top - taskEndBounds.top) - hintRect.top * hintToEndScaleY)
+ outCrop.right = roundOut(outCrop.left + startBounds.width() * hintToEndScaleX)
+ outCrop.bottom = roundOut(outCrop.top + startBounds.height() * hintToEndScaleY)
+ }
+
+ /**
+ * Returns true if the system theme is the dark theme.
+ */
+ @JvmStatic
+ fun Context.isDarkSystemTheme(): Boolean {
+ return (resources.configuration.uiMode and UI_MODE_NIGHT_MASK) ==
+ Configuration.UI_MODE_NIGHT_YES
+ }
+
+ /**
+ * Dumps information held by this class.
+ */
+ @JvmStatic
+ fun dump(pw: PrintWriter, prefix: String) {
+ pw.println("$prefix$TAG")
+ val innerPrefix1 = "$prefix "
+ val innerPrefix2 = "$innerPrefix1 "
+ pw.println("${innerPrefix1}isPipUmoExperienceEnabled=${PipFlags.isPipUmoExperienceEnabled}")
+ pw.println("${innerPrefix1}isPip2ExperimentEnabled=${PipFlags.isPip2ExperimentEnabled}")
+ pw.println("${innerPrefix2}enablePip2=${Flags.enablePip2()}")
+ pw.println("${innerPrefix2}enableDwPip=${ENABLE_DESKTOP_WINDOWING_PIP.isTrue}")
+ }
+}
diff --git a/wmshell/src/com/android/wm/shell/common/split/CenterParallaxSpec.java b/wmshell/src/com/android/wm/shell/common/split/CenterParallaxSpec.java
new file mode 100644
index 0000000000..966664e56e
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/common/split/CenterParallaxSpec.java
@@ -0,0 +1,39 @@
+/*
+ * 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.common.split;
+
+import android.graphics.Point;
+import android.graphics.Rect;
+
+/**
+ * Calculation class, used when
+ * {@link com.android.wm.shell.common.split.SplitLayout#PARALLAX_ALIGN_CENTER} is the desired
+ * parallax effect.
+ */
+public class CenterParallaxSpec implements ParallaxSpec {
+ @Override
+ public void getParallax(Point retreatingOut, Point advancingOut, int position,
+ DividerSnapAlgorithm snapAlgorithm, boolean isLeftRightSplit, Rect displayBounds,
+ Rect retreatingSurface, Rect retreatingContent, Rect advancingSurface,
+ Rect advancingContent, int dimmingSide, boolean topLeftShrink, SplitState splitState) {
+ if (isLeftRightSplit) {
+ retreatingOut.x = (retreatingSurface.width() - retreatingContent.width()) / 2;
+ } else {
+ retreatingOut.y = (retreatingSurface.height() - retreatingContent.height()) / 2;
+ }
+ }
+}
diff --git a/wmshell/src/com/android/wm/shell/common/split/DismissingParallaxSpec.java b/wmshell/src/com/android/wm/shell/common/split/DismissingParallaxSpec.java
new file mode 100644
index 0000000000..90ca07692b
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/common/split/DismissingParallaxSpec.java
@@ -0,0 +1,75 @@
+/*
+ * 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.common.split;
+
+import static android.view.WindowManager.DOCKED_INVALID;
+
+import static com.android.wm.shell.shared.animation.Interpolators.SLOWDOWN_INTERPOLATOR;
+
+import android.graphics.Point;
+import android.graphics.Rect;
+import android.view.WindowManager;
+
+/**
+ * Calculation class, used when
+ * {@link com.android.wm.shell.common.split.SplitLayout#PARALLAX_DISMISSING} is the desired parallax
+ * effect.
+ */
+public class DismissingParallaxSpec implements ParallaxSpec {
+ @Override
+ public void getParallax(Point retreatingOut, Point advancingOut, int position,
+ DividerSnapAlgorithm snapAlgorithm, boolean isLeftRightSplit, Rect displayBounds,
+ Rect retreatingSurface, Rect retreatingContent, Rect advancingSurface,
+ Rect advancingContent, int dimmingSide, boolean topLeftShrink, SplitState splitState) {
+ if (dimmingSide == DOCKED_INVALID) {
+ return;
+ }
+
+ float progressTowardScreenEdge =
+ Math.max(0, Math.min(snapAlgorithm.calculateDismissingFraction(position), 1f));
+ int totalDismissingDistance = 0;
+ if (position < snapAlgorithm.getFirstSplitTarget().getPosition()) {
+ totalDismissingDistance = snapAlgorithm.getDismissStartTarget().getPosition()
+ - snapAlgorithm.getFirstSplitTarget().getPosition();
+ } else if (position > snapAlgorithm.getLastSplitTarget().getPosition()) {
+ totalDismissingDistance = snapAlgorithm.getLastSplitTarget().getPosition()
+ - snapAlgorithm.getDismissEndTarget().getPosition();
+ }
+
+ float parallaxFraction =
+ calculateParallaxDismissingFraction(progressTowardScreenEdge, dimmingSide);
+ if (isLeftRightSplit) {
+ retreatingOut.x = (int) (parallaxFraction * totalDismissingDistance);
+ } else {
+ retreatingOut.y = (int) (parallaxFraction * totalDismissingDistance);
+ }
+ }
+
+ /**
+ * @return for a specified {@code fraction}, this returns an adjusted value that simulates a
+ * slowing down parallax effect
+ */
+ private float calculateParallaxDismissingFraction(float fraction, int dockSide) {
+ float result = SLOWDOWN_INTERPOLATOR.getInterpolation(fraction) / 3.5f;
+
+ // Less parallax at the top, just because.
+ if (dockSide == WindowManager.DOCKED_TOP) {
+ result /= 2f;
+ }
+ return result;
+ }
+}
diff --git a/wmshell/src/com/android/wm/shell/common/split/DividerHandleView.java b/wmshell/src/com/android/wm/shell/common/split/DividerHandleView.java
index 999da24432..35047257a5 100644
--- a/wmshell/src/com/android/wm/shell/common/split/DividerHandleView.java
+++ b/wmshell/src/com/android/wm/shell/common/split/DividerHandleView.java
@@ -32,7 +32,7 @@ import android.util.Property;
import android.view.View;
import com.android.wm.shell.R;
-import com.android.wm.shell.animation.Interpolators;
+import com.android.wm.shell.shared.animation.Interpolators;
/**
* View for the handle in the docked stack divider.
@@ -103,7 +103,24 @@ public class DividerHandleView extends View {
mHoveringHeight = mHeight > mWidth ? ((int) (mHeight * 1.5f)) : mHeight;
}
- void setIsLeftRightSplit(boolean isLeftRightSplit) {
+ /**
+ * Sets the color for the divider handle view.
+ * Optionally invalidates the view to trigger a redraw if the change should be
+ * reflected immediately.
+ *
+ * @param color The ARGB color to set for the divider handle view.
+ * @param invalidateView True if the view should be invalidated
+ * to redraw with the new color, false otherwise.
+ */
+ public void setColor(int color, boolean invalidateView) {
+ mPaint.setColor(color);
+ if (invalidateView) {
+ invalidate();
+ }
+ }
+
+ /** sets whether it's a left/right or top/bottom split */
+ public void setIsLeftRightSplit(boolean isLeftRightSplit) {
mIsLeftRightSplit = isLeftRightSplit;
updateDimens();
}
diff --git a/wmshell/src/com/android/wm/shell/common/split/DividerRoundedCorner.java b/wmshell/src/com/android/wm/shell/common/split/DividerRoundedCorner.java
index 834c15d6b8..44f4f1657a 100644
--- a/wmshell/src/com/android/wm/shell/common/split/DividerRoundedCorner.java
+++ b/wmshell/src/com/android/wm/shell/common/split/DividerRoundedCorner.java
@@ -30,6 +30,7 @@ import android.util.AttributeSet;
import android.view.RoundedCorner;
import android.view.View;
+import androidx.annotation.DimenRes;
import androidx.annotation.Nullable;
import com.android.wm.shell.R;
@@ -47,13 +48,14 @@ public class DividerRoundedCorner extends View {
private InvertedRoundedCornerDrawInfo mBottomLeftCorner;
private InvertedRoundedCornerDrawInfo mBottomRightCorner;
private boolean mIsLeftRightSplit;
+ @DimenRes private int mRadiusResourceId = 0;
public DividerRoundedCorner(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
mDividerWidth = getResources().getDimensionPixelSize(R.dimen.split_divider_bar_width);
mDividerBarBackground = new Paint();
mDividerBarBackground.setColor(
- getResources().getColor(R.color.split_divider_background, null));
+ getResources().getColor(R.color.split_divider_background, null /* theme */));
mDividerBarBackground.setFlags(Paint.ANTI_ALIAS_FLAG);
mDividerBarBackground.setStyle(Paint.Style.FILL);
}
@@ -98,7 +100,39 @@ public class DividerRoundedCorner extends View {
return false;
}
- void setIsLeftRightSplit(boolean isLeftRightSplit) {
+ /**
+ * Sets the resource ID for the radius. This resource ID can be used to retrieve
+ * dimension values for the radius from the application's resources.
+ * If {@code radiusResId} is 0, the display's default round corner will be used.
+ *
+ * @param radiusResId The resource ID of the radius dimension.
+ */
+ public void setRadiusResource(@DimenRes int radiusResId) {
+ mRadiusResourceId = radiusResId;
+ }
+
+ /**
+ * Sets the color for the rounded corners of the divider bar background.
+ * Optionally invalidates the view to trigger a redraw if the change should be
+ * reflected immediately.
+ *
+ * @param cornerColor The ARGB color to set for the rounded corners.
+ * @param invalidateView True if the view should be invalidated
+ * to redraw with the new color, false otherwise.
+ */
+ public void setRoundCornerColor(int cornerColor, boolean invalidateView) {
+ mDividerBarBackground.setColor(cornerColor);
+ if (invalidateView) {
+ invalidate();
+ }
+ }
+
+ /**
+ * Set whether the rounded corner is for a left/right split.
+ *
+ * @param isLeftRightSplit whether it's a left/right split or top/bottom split.
+ */
+ public void setIsLeftRightSplit(boolean isLeftRightSplit) {
mIsLeftRightSplit = isLeftRightSplit;
}
@@ -118,8 +152,12 @@ public class DividerRoundedCorner extends View {
InvertedRoundedCornerDrawInfo(@RoundedCorner.Position int cornerPosition) {
mCornerPosition = cornerPosition;
- final RoundedCorner roundedCorner = getDisplay().getRoundedCorner(cornerPosition);
- mRadius = roundedCorner == null ? 0 : roundedCorner.getRadius();
+ if (mRadiusResourceId == 0) {
+ final RoundedCorner roundedCorner = getDisplay().getRoundedCorner(cornerPosition);
+ mRadius = roundedCorner == null ? 0 : roundedCorner.getRadius();
+ } else {
+ mRadius = mContext.getResources().getDimensionPixelSize(mRadiusResourceId);
+ }
// Starts with a filled square, and then subtracting out a circle from the appropriate
// corner.
diff --git a/wmshell/src/com/android/wm/shell/common/split/DividerSnapAlgorithm.java b/wmshell/src/com/android/wm/shell/common/split/DividerSnapAlgorithm.java
index bc6ed1f63c..d1ff128b2f 100644
--- a/wmshell/src/com/android/wm/shell/common/split/DividerSnapAlgorithm.java
+++ b/wmshell/src/com/android/wm/shell/common/split/DividerSnapAlgorithm.java
@@ -16,30 +16,31 @@
package com.android.wm.shell.common.split;
-import static android.view.WindowManager.DOCKED_INVALID;
import static android.view.WindowManager.DOCKED_LEFT;
import static android.view.WindowManager.DOCKED_RIGHT;
-import static com.android.wm.shell.common.split.SplitScreenConstants.SNAP_TO_30_70;
-import static com.android.wm.shell.common.split.SplitScreenConstants.SNAP_TO_50_50;
-import static com.android.wm.shell.common.split.SplitScreenConstants.SNAP_TO_70_30;
-import static com.android.wm.shell.common.split.SplitScreenConstants.SNAP_TO_END_AND_DISMISS;
-import static com.android.wm.shell.common.split.SplitScreenConstants.SNAP_TO_MINIMIZE;
-import static com.android.wm.shell.common.split.SplitScreenConstants.SNAP_TO_NONE;
-import static com.android.wm.shell.common.split.SplitScreenConstants.SNAP_TO_START_AND_DISMISS;
-import static com.android.wm.shell.common.split.SplitScreenConstants.SnapPosition;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_2_10_90;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_2_33_66;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_2_50_50;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_2_66_33;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_2_90_10;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_END_AND_DISMISS;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_MINIMIZE;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_NONE;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_START_AND_DISMISS;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SnapPosition;
-import android.content.Context;
-import android.content.res.Configuration;
import android.content.res.Resources;
import android.graphics.Rect;
-import android.hardware.display.DisplayManager;
-import android.view.Display;
-import android.view.DisplayInfo;
import androidx.annotation.Nullable;
+import com.android.mechanics.spec.MotionSpec;
+import com.android.wm.shell.Flags;
+import com.android.wm.shell.shared.split.SplitScreenConstants.PersistentSnapPosition;
+
import java.util.ArrayList;
+import java.util.stream.IntStream;
/**
* Calculates the snap targets and the snap position given a position and a velocity. All positions
@@ -72,6 +73,11 @@ public class DividerSnapAlgorithm {
*/
private static final int SNAP_MODE_MINIMIZED = 3;
+ /**
+ * A mode where apps can be "flexibly offscreen" on smaller displays.
+ */
+ private static final int SNAP_FLEXIBLE_SPLIT = 4;
+
private final float mMinFlingVelocityPxPerSecond;
private final float mMinDismissVelocityPxPerSecond;
private final int mDisplayWidth;
@@ -79,14 +85,19 @@ public class DividerSnapAlgorithm {
private final int mDividerSize;
private final ArrayList mTargets = new ArrayList<>();
private final Rect mInsets = new Rect();
+ private final Rect mPinnedTaskbarInsets = new Rect();
private final int mSnapMode;
private final boolean mFreeSnapMode;
private final int mMinimalSizeResizableTask;
private final int mTaskHeightInMinimizedMode;
private final float mFixedRatio;
/** Allows split ratios to calculated dynamically instead of using {@link #mFixedRatio}. */
- private final boolean mAllowFlexibleSplitRatios;
- private boolean mIsHorizontalDivision;
+ private final boolean mCalculateRatiosBasedOnAvailableSpace;
+ /** Allows split ratios that go offscreen (a.k.a. "flexible split") */
+ private final boolean mAllowOffscreenRatios;
+ private final boolean mIsLeftRightSplit;
+ /** In SNAP_MODE_MINIMIZED, the side of the screen on which an app will "dock" when minimized */
+ private final int mDockSide;
/** The first target which is still splitting the screen */
private final SnapTarget mFirstSplitTarget;
@@ -98,37 +109,18 @@ public class DividerSnapAlgorithm {
private final SnapTarget mDismissEndTarget;
private final SnapTarget mMiddleTarget;
- public static DividerSnapAlgorithm create(Context ctx, Rect insets) {
- DisplayInfo displayInfo = new DisplayInfo();
- ctx.getSystemService(DisplayManager.class).getDisplay(
- Display.DEFAULT_DISPLAY).getDisplayInfo(displayInfo);
- int dividerWindowWidth = ctx.getResources().getDimensionPixelSize(
- com.android.internal.R.dimen.docked_stack_divider_thickness);
- int dividerInsets = ctx.getResources().getDimensionPixelSize(
- com.android.internal.R.dimen.docked_stack_divider_insets);
- return new DividerSnapAlgorithm(ctx.getResources(),
- displayInfo.logicalWidth, displayInfo.logicalHeight,
- dividerWindowWidth - 2 * dividerInsets,
- ctx.getApplicationContext().getResources().getConfiguration().orientation
- == Configuration.ORIENTATION_PORTRAIT,
- insets);
+ /** A spec used for "magnetic snap" user-controlled movement. */
+ private final MotionSpec mMotionSpec;
+
+ public DividerSnapAlgorithm(Resources res, int displayWidth, int displayHeight, int dividerSize,
+ boolean isLeftRightSplit, Rect insets, Rect pinnedTaskbarInsets, int dockSide) {
+ this(res, displayWidth, displayHeight, dividerSize, isLeftRightSplit, insets,
+ pinnedTaskbarInsets, dockSide, false /* minimized */, true /* resizable */);
}
public DividerSnapAlgorithm(Resources res, int displayWidth, int displayHeight, int dividerSize,
- boolean isHorizontalDivision, Rect insets) {
- this(res, displayWidth, displayHeight, dividerSize, isHorizontalDivision, insets,
- DOCKED_INVALID, false /* minimized */, true /* resizable */);
- }
-
- public DividerSnapAlgorithm(Resources res, int displayWidth, int displayHeight, int dividerSize,
- boolean isHorizontalDivision, Rect insets, int dockSide) {
- this(res, displayWidth, displayHeight, dividerSize, isHorizontalDivision, insets,
- dockSide, false /* minimized */, true /* resizable */);
- }
-
- public DividerSnapAlgorithm(Resources res, int displayWidth, int displayHeight, int dividerSize,
- boolean isHorizontalDivision, Rect insets, int dockSide, boolean isMinimizedMode,
- boolean isHomeResizable) {
+ boolean isLeftRightSplit, Rect insets, Rect pinnedTaskbarInsets, int dockSide,
+ boolean isMinimizedMode, boolean isHomeResizable) {
mMinFlingVelocityPxPerSecond =
MIN_FLING_VELOCITY_DP_PER_SECOND * res.getDisplayMetrics().density;
mMinDismissVelocityPxPerSecond =
@@ -136,53 +128,48 @@ public class DividerSnapAlgorithm {
mDividerSize = dividerSize;
mDisplayWidth = displayWidth;
mDisplayHeight = displayHeight;
- mIsHorizontalDivision = isHorizontalDivision;
+ mIsLeftRightSplit = isLeftRightSplit;
+ mDockSide = dockSide;
mInsets.set(insets);
- mSnapMode = isMinimizedMode ? SNAP_MODE_MINIMIZED :
- res.getInteger(com.android.internal.R.integer.config_dockedStackDividerSnapMode);
+ mPinnedTaskbarInsets.set(pinnedTaskbarInsets);
+ if (Flags.enableFlexibleTwoAppSplit()) {
+ mSnapMode = SNAP_FLEXIBLE_SPLIT;
+ } else {
+ // Set SNAP_MODE_MINIMIZED, SNAP_MODE_16_9, or SNAP_FIXED_RATIO depending on config
+ mSnapMode = isMinimizedMode
+ ? SNAP_MODE_MINIMIZED
+ : res.getInteger(
+ com.android.internal.R.integer.config_dockedStackDividerSnapMode);
+ }
mFreeSnapMode = res.getBoolean(
com.android.internal.R.bool.config_dockedStackDividerFreeSnapMode);
mFixedRatio = res.getFraction(
com.android.internal.R.fraction.docked_stack_divider_fixed_ratio, 1, 1);
mMinimalSizeResizableTask = res.getDimensionPixelSize(
com.android.internal.R.dimen.default_minimal_size_resizable_task);
- mAllowFlexibleSplitRatios = res.getBoolean(
+ mCalculateRatiosBasedOnAvailableSpace = res.getBoolean(
com.android.internal.R.bool.config_flexibleSplitRatios);
+ // If this is a small screen or a foldable, use offscreen ratios
+ mAllowOffscreenRatios = SplitScreenUtils.allowOffscreenRatios(res);
mTaskHeightInMinimizedMode = isHomeResizable ? res.getDimensionPixelSize(
com.android.internal.R.dimen.task_height_of_minimized_mode) : 0;
- calculateTargets(isHorizontalDivision, dockSide);
+ calculateTargets();
mFirstSplitTarget = mTargets.get(1);
mLastSplitTarget = mTargets.get(mTargets.size() - 2);
mDismissStartTarget = mTargets.get(0);
mDismissEndTarget = mTargets.get(mTargets.size() - 1);
mMiddleTarget = mTargets.get(mTargets.size() / 2);
mMiddleTarget.isMiddleTarget = true;
- }
-
- /**
- * @return whether it's feasible to enable split screen in the current configuration, i.e. when
- * snapping in the middle both tasks are larger than the minimal task size.
- */
- public boolean isSplitScreenFeasible() {
- int statusBarSize = mInsets.top;
- int navBarSize = mIsHorizontalDivision ? mInsets.bottom : mInsets.right;
- int size = mIsHorizontalDivision
- ? mDisplayHeight
- : mDisplayWidth;
- int availableSpace = size - navBarSize - statusBarSize - mDividerSize;
- return availableSpace / 2 >= mMinimalSizeResizableTask;
- }
-
- public SnapTarget calculateSnapTarget(int position, float velocity) {
- return calculateSnapTarget(position, velocity, true /* hardDismiss */);
+ mMotionSpec = Flags.enableMagneticSplitDivider()
+ ? MagneticDividerUtils.generateMotionSpec(mTargets, res) : null;
}
/**
* @param position the top/left position of the divider
* @param velocity current dragging velocity
- * @param hardDismiss if set, make it a bit harder to get reach the dismiss targets
+ * @param hardToDismiss if set, make it a bit harder to get reach the dismiss targets
*/
- public SnapTarget calculateSnapTarget(int position, float velocity, boolean hardDismiss) {
+ public SnapTarget calculateSnapTarget(int position, float velocity, boolean hardToDismiss) {
if (position < mFirstSplitTarget.position && velocity < -mMinDismissVelocityPxPerSecond) {
return mDismissStartTarget;
}
@@ -190,7 +177,7 @@ public class DividerSnapAlgorithm {
return mDismissEndTarget;
}
if (Math.abs(velocity) < mMinFlingVelocityPxPerSecond) {
- return snap(position, hardDismiss);
+ return snap(position, hardToDismiss);
}
if (velocity < 0) {
return mFirstSplitTarget;
@@ -236,19 +223,6 @@ public class DividerSnapAlgorithm {
return 0f;
}
- public SnapTarget getClosestDismissTarget(int position) {
- if (position < mFirstSplitTarget.position) {
- return mDismissStartTarget;
- } else if (position > mLastSplitTarget.position) {
- return mDismissEndTarget;
- } else if (position - mDismissStartTarget.position
- < mDismissEndTarget.position - position) {
- return mDismissStartTarget;
- } else {
- return mDismissEndTarget;
- }
- }
-
public SnapTarget getFirstSplitTarget() {
return mFirstSplitTarget;
}
@@ -266,18 +240,18 @@ public class DividerSnapAlgorithm {
}
private int getStartInset() {
- if (mIsHorizontalDivision) {
- return mInsets.top;
- } else {
+ if (mIsLeftRightSplit) {
return mInsets.left;
+ } else {
+ return mInsets.top;
}
}
private int getEndInset() {
- if (mIsHorizontalDivision) {
- return mInsets.bottom;
- } else {
+ if (mIsLeftRightSplit) {
return mInsets.right;
+ } else {
+ return mInsets.bottom;
}
}
@@ -291,9 +265,14 @@ public class DividerSnapAlgorithm {
return mFirstSplitTarget.position < position && position < mLastSplitTarget.position;
}
+ /** Returns if we are currently on a device/screen that supports split apps going offscreen. */
+ public boolean areOffscreenRatiosSupported() {
+ return mAllowOffscreenRatios;
+ }
+
private SnapTarget snap(int position, boolean hardDismiss) {
if (shouldApplyFreeSnapMode(position)) {
- return new SnapTarget(position, position, SNAP_TO_NONE);
+ return new SnapTarget(position, SNAP_TO_NONE);
}
int minIndex = -1;
float minDistance = Float.MAX_VALUE;
@@ -312,69 +291,106 @@ public class DividerSnapAlgorithm {
return mTargets.get(minIndex);
}
- private void calculateTargets(boolean isHorizontalDivision, int dockedSide) {
+ private void calculateTargets() {
mTargets.clear();
- int dividerMax = isHorizontalDivision
- ? mDisplayHeight
- : mDisplayWidth;
+ int dividerMax = mIsLeftRightSplit
+ ? mDisplayWidth
+ : mDisplayHeight;
int startPos = -mDividerSize;
- if (dockedSide == DOCKED_RIGHT) {
+ if (mDockSide == DOCKED_RIGHT) {
startPos += mInsets.left;
}
- mTargets.add(new SnapTarget(startPos, startPos, SNAP_TO_START_AND_DISMISS, 0.35f));
+ mTargets.add(new SnapTarget(startPos, SNAP_TO_START_AND_DISMISS, 0.35f));
switch (mSnapMode) {
case SNAP_MODE_16_9:
- addRatio16_9Targets(isHorizontalDivision, dividerMax);
+ addRatio16_9Targets(mIsLeftRightSplit, dividerMax);
break;
case SNAP_FIXED_RATIO:
- addFixedDivisionTargets(isHorizontalDivision, dividerMax);
+ addFixedDivisionTargets(mIsLeftRightSplit, dividerMax);
break;
case SNAP_ONLY_1_1:
- addMiddleTarget(isHorizontalDivision);
+ addMiddleTarget(mIsLeftRightSplit);
break;
case SNAP_MODE_MINIMIZED:
- addMinimizedTarget(isHorizontalDivision, dockedSide);
+ addMinimizedTarget(mIsLeftRightSplit, mDockSide);
+ break;
+ case SNAP_FLEXIBLE_SPLIT:
+ addFlexSplitTargets(mIsLeftRightSplit, dividerMax);
break;
}
- mTargets.add(new SnapTarget(dividerMax, dividerMax, SNAP_TO_END_AND_DISMISS, 0.35f));
+ mTargets.add(new SnapTarget(dividerMax, SNAP_TO_END_AND_DISMISS, 0.35f));
}
- private void addNonDismissingTargets(boolean isHorizontalDivision, int topPosition,
+ private void addNonDismissingTargets(boolean isLeftRightSplit, int topPosition,
int bottomPosition, int dividerMax) {
- maybeAddTarget(topPosition, topPosition - getStartInset(), SNAP_TO_30_70);
- addMiddleTarget(isHorizontalDivision);
+ @PersistentSnapPosition int firstTarget =
+ areOffscreenRatiosSupported() ? SNAP_TO_2_10_90 : SNAP_TO_2_33_66;
+ @PersistentSnapPosition int lastTarget =
+ areOffscreenRatiosSupported() ? SNAP_TO_2_90_10 : SNAP_TO_2_66_33;
+ maybeAddTarget(topPosition, topPosition - getStartInset(), firstTarget);
+ addMiddleTarget(isLeftRightSplit);
maybeAddTarget(bottomPosition,
- dividerMax - getEndInset() - (bottomPosition + mDividerSize), SNAP_TO_70_30);
+ dividerMax - getEndInset() - (bottomPosition + mDividerSize), lastTarget);
}
- private void addFixedDivisionTargets(boolean isHorizontalDivision, int dividerMax) {
- int start = isHorizontalDivision ? mInsets.top : mInsets.left;
- int end = isHorizontalDivision
- ? mDisplayHeight - mInsets.bottom
- : mDisplayWidth - mInsets.right;
- int size = (int) (mFixedRatio * (end - start)) - mDividerSize / 2;
- if (mAllowFlexibleSplitRatios) {
- size = Math.max(size, mMinimalSizeResizableTask);
- }
- int topPosition = start + size;
- int bottomPosition = end - size - mDividerSize;
- addNonDismissingTargets(isHorizontalDivision, topPosition, bottomPosition, dividerMax);
- }
-
- private void addRatio16_9Targets(boolean isHorizontalDivision, int dividerMax) {
- int start = isHorizontalDivision ? mInsets.top : mInsets.left;
- int end = isHorizontalDivision
- ? mDisplayHeight - mInsets.bottom
- : mDisplayWidth - mInsets.right;
- int startOther = isHorizontalDivision ? mInsets.left : mInsets.top;
- int endOther = isHorizontalDivision
+ private void addFixedDivisionTargets(boolean isLeftRightSplit, int dividerMax) {
+ int start = isLeftRightSplit ? mInsets.left : mInsets.top;
+ int end = isLeftRightSplit
? mDisplayWidth - mInsets.right
: mDisplayHeight - mInsets.bottom;
+
+ int size = (int) (mFixedRatio * (end - start)) - mDividerSize / 2;
+ if (mCalculateRatiosBasedOnAvailableSpace) {
+ size = Math.max(size, mMinimalSizeResizableTask);
+ }
+
+ int topPosition = start + size;
+ int bottomPosition = end - size - mDividerSize;
+ addNonDismissingTargets(isLeftRightSplit, topPosition, bottomPosition, dividerMax);
+ }
+
+ private void addFlexSplitTargets(boolean isLeftRightSplit, int dividerMax) {
+ int start = 0;
+ int end = isLeftRightSplit ? mDisplayWidth : mDisplayHeight;
+ int pinnedTaskbarShiftStart = isLeftRightSplit
+ ? mPinnedTaskbarInsets.left : mPinnedTaskbarInsets.top;
+ int pinnedTaskbarShiftEnd = isLeftRightSplit
+ ? mPinnedTaskbarInsets.right : mPinnedTaskbarInsets.bottom;
+
+ float ratio = areOffscreenRatiosSupported()
+ ? SplitSpec.OFFSCREEN_ASYMMETRIC_RATIO
+ : SplitSpec.ONSCREEN_ONLY_ASYMMETRIC_RATIO;
+
+ // The intended size of the smaller app, in pixels
+ int size = (int) (ratio * (end - start)) - mDividerSize / 2;
+
+ // If there are insets that interfere with the smaller app (visually or blocking touch
+ // targets), make the smaller app bigger by that amount to compensate. This applies to
+ // pinned taskbar, 3-button nav (both create an opaque bar at bottom) and status bar (blocks
+ // touch targets at top).
+ int extraSpace = IntStream.of(
+ getStartInset(), getEndInset(), pinnedTaskbarShiftStart, pinnedTaskbarShiftEnd
+ ).max().getAsInt();
+
+ int leftTopPosition = start + extraSpace + size;
+ int rightBottomPosition = end - extraSpace - size - mDividerSize;
+ addNonDismissingTargets(isLeftRightSplit, leftTopPosition, rightBottomPosition, dividerMax);
+ }
+
+ private void addRatio16_9Targets(boolean isLeftRightSplit, int dividerMax) {
+ int start = isLeftRightSplit ? mInsets.left : mInsets.top;
+ int end = isLeftRightSplit
+ ? mDisplayWidth - mInsets.right
+ : mDisplayHeight - mInsets.bottom;
+ int startOther = isLeftRightSplit ? mInsets.top : mInsets.left;
+ int endOther = isLeftRightSplit
+ ? mDisplayHeight - mInsets.bottom
+ : mDisplayWidth - mInsets.right;
float size = 9.0f / 16.0f * (endOther - startOther);
int sizeInt = (int) Math.floor(size);
int topPosition = start + sizeInt;
int bottomPosition = end - sizeInt - mDividerSize;
- addNonDismissingTargets(isHorizontalDivision, topPosition, bottomPosition, dividerMax);
+ addNonDismissingTargets(isLeftRightSplit, topPosition, bottomPosition, dividerMax);
}
/**
@@ -382,51 +398,35 @@ public class DividerSnapAlgorithm {
* meets the minimal size requirement.
*/
private void maybeAddTarget(int position, int smallerSize, @SnapPosition int snapPosition) {
- if (smallerSize >= mMinimalSizeResizableTask) {
- mTargets.add(new SnapTarget(position, position, snapPosition));
+ if (smallerSize >= mMinimalSizeResizableTask || areOffscreenRatiosSupported()) {
+ mTargets.add(new SnapTarget(position, snapPosition));
}
}
- private void addMiddleTarget(boolean isHorizontalDivision) {
- int position = DockedDividerUtils.calculateMiddlePosition(isHorizontalDivision,
+ private void addMiddleTarget(boolean isLeftRightSplit) {
+ int position = DockedDividerUtils.calculateMiddlePosition(isLeftRightSplit,
mInsets, mDisplayWidth, mDisplayHeight, mDividerSize);
- mTargets.add(new SnapTarget(position, position, SNAP_TO_50_50));
+ mTargets.add(new SnapTarget(position, SNAP_TO_2_50_50));
}
- private void addMinimizedTarget(boolean isHorizontalDivision, int dockedSide) {
+ private void addMinimizedTarget(boolean isLeftRightSplit, int dockedSide) {
// In portrait offset the position by the statusbar height, in landscape add the statusbar
// height as well to match portrait offset
int position = mTaskHeightInMinimizedMode + mInsets.top;
- if (!isHorizontalDivision) {
+ if (isLeftRightSplit) {
if (dockedSide == DOCKED_LEFT) {
position += mInsets.left;
} else if (dockedSide == DOCKED_RIGHT) {
position = mDisplayWidth - position - mInsets.right - mDividerSize;
}
}
- mTargets.add(new SnapTarget(position, position, SNAP_TO_MINIMIZE));
+ mTargets.add(new SnapTarget(position, SNAP_TO_MINIMIZE));
}
public SnapTarget getMiddleTarget() {
return mMiddleTarget;
}
- public SnapTarget getNextTarget(SnapTarget snapTarget) {
- int index = mTargets.indexOf(snapTarget);
- if (index != -1 && index < mTargets.size() - 1) {
- return mTargets.get(index + 1);
- }
- return snapTarget;
- }
-
- public SnapTarget getPreviousTarget(SnapTarget snapTarget) {
- int index = mTargets.indexOf(snapTarget);
- if (index != -1 && index > 0) {
- return mTargets.get(index - 1);
- }
- return snapTarget;
- }
-
/**
* @return whether or not there are more than 1 split targets that do not include the two
* dismiss targets, used in deciding to display the middle target for accessibility
@@ -450,41 +450,20 @@ public class DividerSnapAlgorithm {
return snap(currentPosition, /* hardDismiss */ true).snapPosition;
}
- /**
- * Cycles through all non-dismiss targets with a stepping of {@param increment}. It moves left
- * if {@param increment} is negative and moves right otherwise.
- */
- public SnapTarget cycleNonDismissTarget(SnapTarget snapTarget, int increment) {
- int index = mTargets.indexOf(snapTarget);
- if (index != -1) {
- SnapTarget newTarget = mTargets.get((index + mTargets.size() + increment)
- % mTargets.size());
- if (newTarget == mDismissStartTarget) {
- return mLastSplitTarget;
- } else if (newTarget == mDismissEndTarget) {
- return mFirstSplitTarget;
- } else {
- return newTarget;
- }
- }
- return snapTarget;
+ public MotionSpec getMotionSpec() {
+ return mMotionSpec;
}
/**
- * Represents a snap target for the divider.
+ * An object, calculated at boot time, representing a legal position for the split screen
+ * divider (i.e. the divider can be dragged to this spot).
*/
public static class SnapTarget {
/** Position of this snap target. The right/bottom edge of the top/left task snaps here. */
public final int position;
/**
- * Like {@link #position}, but used to calculate the task bounds which might be different
- * from the stack bounds.
- */
- public final int taskPosition;
-
- /**
- * An int describing the placement of the divider in this snap target.
+ * An int (enum) describing the placement of the divider in this snap target.
*/
public final @SnapPosition int snapPosition;
@@ -496,16 +475,19 @@ public class DividerSnapAlgorithm {
*/
private final float distanceMultiplier;
- public SnapTarget(int position, int taskPosition, @SnapPosition int snapPosition) {
- this(position, taskPosition, snapPosition, 1f);
+ public SnapTarget(int position, @SnapPosition int snapPosition) {
+ this(position, snapPosition, 1f);
}
- public SnapTarget(int position, int taskPosition, @SnapPosition int snapPosition,
+ public SnapTarget(int position, @SnapPosition int snapPosition,
float distanceMultiplier) {
this.position = position;
- this.taskPosition = taskPosition;
this.snapPosition = snapPosition;
this.distanceMultiplier = distanceMultiplier;
}
+
+ public int getPosition() {
+ return position;
+ }
}
}
diff --git a/wmshell/src/com/android/wm/shell/common/split/DividerTooltip.kt b/wmshell/src/com/android/wm/shell/common/split/DividerTooltip.kt
new file mode 100644
index 0000000000..5b3fe33a3a
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/common/split/DividerTooltip.kt
@@ -0,0 +1,105 @@
+/*
+ * 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.common.split
+
+import android.content.Context
+import android.graphics.Color
+import android.graphics.Rect
+import android.util.AttributeSet
+import android.util.Log
+import android.widget.FrameLayout
+import androidx.appcompat.widget.AppCompatTextView
+import com.android.wm.shell.R
+import com.android.wm.shell.common.pip.PipUtils.dpToPx
+import com.android.wm.shell.shared.TypefaceUtils
+import com.android.wm.shell.shared.TypefaceUtils.Companion.setTypeface
+import androidx.core.graphics.toColorInt
+
+/**
+ * A small tooltip bubble that educates the user about split screen breakpoints.
+ */
+class DividerTooltip(context: Context, attrs: AttributeSet?) :
+ AppCompatTextView(context, attrs) {
+ /** The length of the split divider handle, along its long edge, in px. */
+ private val mDividerHandleLengthPx =
+ resources.getDimensionPixelSize(R.dimen.split_divider_handle_width)
+ private var mIsLeftRightSplit = false
+
+ init {
+ alpha = 0f
+ setTextColor(TOOLTIP_TEXT_COLOR)
+ setBackgroundColor(TOOLTIP_BG_COLOR)
+ setTypeface(this, TOOLTIP_FONT)
+ }
+
+ /**
+ * Called in DividerView.setup() to determine orientation. Expected to always be called on
+ * divider initialization.
+ */
+ fun setIsLeftRightSplit(isLeftRightSplit: Boolean) {
+ mIsLeftRightSplit = isLeftRightSplit
+ }
+
+ /** Converts dp to px. */
+ private fun dpToPx(dpValue: Int): Int {
+ return dpToPx(dpValue.toFloat(), mContext.resources.displayMetrics)
+ }
+
+ /**
+ * Resizes the tooltip to fit the current text, and adjusts margins to put it in the correct
+ * place on the screen.
+ */
+ override fun onTextChanged(
+ text: CharSequence,
+ start: Int,
+ lengthBefore: Int,
+ lengthAfter: Int) {
+ if (layoutParams == null) {
+ return
+ }
+
+ // Get a Rect representing the raw size of the current text string.
+ val textBounds = Rect()
+ paint.getTextBounds(text.toString(), 0, text.length, textBounds)
+
+ val lp = layoutParams as FrameLayout.LayoutParams
+ lp.height = textBounds.height() + (dpToPx(TOOLTIP_PADDING_DP) * 2)
+ lp.width = textBounds.width() + (dpToPx(TOOLTIP_PADDING_DP) * 2)
+ if (mIsLeftRightSplit) {
+ lp.bottomMargin = ((mDividerHandleLengthPx / 2) + (lp.height / 2)
+ + dpToPx(TOOLTIP_DISTANCE_FROM_HANDLE_DP))
+ lp.rightMargin = 0
+ } else {
+ lp.bottomMargin = 0
+ lp.rightMargin = ((mDividerHandleLengthPx / 2) + (lp.width / 2)
+ + dpToPx(TOOLTIP_DISTANCE_FROM_HANDLE_DP))
+ }
+
+ layoutParams = lp
+ }
+
+ companion object {
+ private val TOOLTIP_TEXT_COLOR = "#4A3F08".toColorInt()
+ private val TOOLTIP_BG_COLOR = "#F5E29D".toColorInt()
+ private val TOOLTIP_FONT = TypefaceUtils.FontFamily.GSF_BODY_MEDIUM_EMPHASIZED
+
+ /** The padding between the tooltip's text and its outer border, on all four sides, in dp. */
+ private const val TOOLTIP_PADDING_DP = 12
+
+ /** The distance between the tooltip's border and the (full-sized) divider handle, in dp. */
+ private const val TOOLTIP_DISTANCE_FROM_HANDLE_DP = 16
+ }
+}
diff --git a/wmshell/src/com/android/wm/shell/common/split/DividerView.java b/wmshell/src/com/android/wm/shell/common/split/DividerView.java
index c2242a8b87..334dddd57a 100644
--- a/wmshell/src/com/android/wm/shell/common/split/DividerView.java
+++ b/wmshell/src/com/android/wm/shell/common/split/DividerView.java
@@ -18,9 +18,9 @@ package com.android.wm.shell.common.split;
import static android.view.PointerIcon.TYPE_HORIZONTAL_DOUBLE_ARROW;
import static android.view.PointerIcon.TYPE_VERTICAL_DOUBLE_ARROW;
-import static android.view.WindowManager.LayoutParams.FLAG_SLIPPERY;
import static com.android.internal.config.sysui.SystemUiDeviceConfigFlags.CURSOR_HOVER_STATES_ENABLED;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.snapPositionToUIString;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
@@ -45,7 +45,6 @@ import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewGroup;
import android.view.WindowInsets;
-import android.view.WindowManager;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
import android.widget.FrameLayout;
@@ -54,10 +53,20 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.android.internal.annotations.VisibleForTesting;
-import com.android.internal.protolog.common.ProtoLog;
+import com.android.internal.protolog.ProtoLog;
+import com.android.mechanics.spec.InputDirection;
+import com.android.mechanics.view.DistanceGestureContext;
+import com.android.mechanics.view.ViewMotionValue;
+import com.android.wm.shell.Flags;
import com.android.wm.shell.R;
-import com.android.wm.shell.animation.Interpolators;
+import com.android.wm.shell.common.split.DividerSnapAlgorithm.SnapTarget;
import com.android.wm.shell.protolog.ShellProtoLogGroup;
+import com.android.wm.shell.shared.animation.Interpolators;
+import com.android.wm.shell.shared.desktopmode.DesktopState;
+
+import com.google.android.msdl.data.model.MSDLToken;
+
+import java.util.Objects;
/**
* Divider for multi window splits.
@@ -65,6 +74,7 @@ import com.android.wm.shell.protolog.ShellProtoLogGroup;
public class DividerView extends FrameLayout implements View.OnTouchListener {
public static final long TOUCH_ANIMATION_DURATION = 150;
public static final long TOUCH_RELEASE_ANIMATION_DURATION = 200;
+ private static final boolean SHOW_DRAG_TOOLTIP = true;
private final Paint mPaint = new Paint();
private final Rect mBackgroundRect = new Rect();
@@ -75,6 +85,8 @@ public class DividerView extends FrameLayout implements View.OnTouchListener {
private SurfaceControlViewHost mViewHost;
private DividerHandleView mHandle;
private DividerRoundedCorner mCorners;
+ /** A tooltip view that appears to educate users about split screen breakpoints. */
+ private DividerTooltip mTooltip;
private int mTouchElevation;
private VelocityTracker mVelocityTracker;
@@ -88,9 +100,24 @@ public class DividerView extends FrameLayout implements View.OnTouchListener {
private int mHandleRegionWidth;
private int mHandleRegionHeight;
+ // Calculation classes for "magnetic snap" user-controlled movement
+ private DistanceGestureContext mDistanceGestureContext;
+ private ViewMotionValue mViewMotionValue;
/**
- * Tracks divider bar visible bounds in screen-based coordination. Used to calculate with
- * insets.
+ * The @SnapPosition where the user started dragging from. Assigned at the beginning of a drag
+ * and set back to null once the drag ends.
+ */
+ @Nullable private Integer mDragStartingSnapPosition;
+ /**
+ * When the divider is dragged out of the starting region {@link #mDragStartingSnapPosition}
+ * for the first time, this is flipped to true. Used for tooltip logic.
+ */
+ private boolean mDraggedOutOfStartingRegion = false;
+ @Nullable private Integer mLastHoveredOverSnapPosition;
+
+ /**
+ * This is not the visible bounds you see on screen, but the actual behind-the-scenes window
+ * bounds, which is larger.
*/
private final Rect mDividerBounds = new Rect();
private final Rect mTempRect = new Rect();
@@ -124,7 +151,7 @@ public class DividerView extends FrameLayout implements View.OnTouchListener {
}
};
- private final AccessibilityDelegate mHandleDelegate = new AccessibilityDelegate() {
+ final AccessibilityDelegate mHandleDelegate = new AccessibilityDelegate() {
@Override
public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) {
super.onInitializeAccessibilityNodeInfo(host, info);
@@ -147,6 +174,8 @@ public class DividerView extends FrameLayout implements View.OnTouchListener {
}
info.addAction(new AccessibilityAction(R.id.action_move_rb_full,
mContext.getString(R.string.accessibility_action_divider_right_full)));
+ info.addAction(new AccessibilityAction(R.id.action_swap_apps,
+ mContext.getString(R.string.accessibility_action_divider_swap_horizontal)));
} else {
info.addAction(new AccessibilityAction(R.id.action_move_tl_full,
mContext.getString(R.string.accessibility_action_divider_top_full)));
@@ -165,13 +194,20 @@ public class DividerView extends FrameLayout implements View.OnTouchListener {
}
info.addAction(new AccessibilityAction(R.id.action_move_rb_full,
mContext.getString(R.string.accessibility_action_divider_bottom_full)));
+ info.addAction(new AccessibilityAction(R.id.action_swap_apps,
+ mContext.getString(R.string.accessibility_action_divider_swap_vertical)));
}
}
@Override
public boolean performAccessibilityAction(@NonNull View host, int action,
@Nullable Bundle args) {
- DividerSnapAlgorithm.SnapTarget nextTarget = null;
+ if (action == R.id.action_swap_apps) {
+ mSplitLayout.onDoubleTappedDivider();
+ return true;
+ }
+
+ SnapTarget nextTarget = null;
DividerSnapAlgorithm snapAlgorithm = mSplitLayout.mDividerSnapAlgorithm;
if (action == R.id.action_move_tl_full) {
nextTarget = snapAlgorithm.getDismissEndTarget();
@@ -212,7 +248,8 @@ public class DividerView extends FrameLayout implements View.OnTouchListener {
/** Sets up essential dependencies of the divider bar. */
public void setup(SplitLayout layout, SplitWindowManager splitWindowManager,
- SurfaceControlViewHost viewHost, InsetsState insetsState) {
+ SurfaceControlViewHost viewHost, InsetsState insetsState,
+ DesktopState desktopState) {
mSplitLayout = layout;
mSplitWindowManager = splitWindowManager;
mViewHost = viewHost;
@@ -222,13 +259,16 @@ public class DividerView extends FrameLayout implements View.OnTouchListener {
final boolean isLeftRightSplit = mSplitLayout.isLeftRightSplit();
mHandle.setIsLeftRightSplit(isLeftRightSplit);
mCorners.setIsLeftRightSplit(isLeftRightSplit);
+ mTooltip.setIsLeftRightSplit(isLeftRightSplit);
mHandleRegionWidth = getResources().getDimensionPixelSize(isLeftRightSplit
? R.dimen.split_divider_handle_region_height
: R.dimen.split_divider_handle_region_width);
mHandleRegionHeight = getResources().getDimensionPixelSize(isLeftRightSplit
? R.dimen.split_divider_handle_region_width
- : R.dimen.split_divider_handle_region_height);
+ : desktopState.canEnterDesktopMode()
+ ? R.dimen.desktop_mode_portrait_split_divider_handle_region_height
+ : R.dimen.split_divider_handle_region_height);
}
void onInsetsChanged(InsetsState insetsState, boolean animate) {
@@ -243,7 +283,7 @@ public class DividerView extends FrameLayout implements View.OnTouchListener {
final InsetsSource source = insetsState.sourceAt(i);
if (source.getType() == WindowInsets.Type.navigationBars()
&& source.hasFlags(InsetsSource.FLAG_INSETS_ROUNDED_CORNER)) {
- mTempRect.inset(source.calculateVisibleInsets(mTempRect));
+ mTempRect.inset(source.calculateVisibleInsets(mTempRect, mTempRect));
}
}
}
@@ -270,6 +310,7 @@ public class DividerView extends FrameLayout implements View.OnTouchListener {
mDividerBar = findViewById(R.id.divider_bar);
mHandle = findViewById(R.id.docked_divider_handle);
mCorners = findViewById(R.id.docked_divider_rounded_corner);
+ mTooltip = findViewById(R.id.docked_divider_tooltip);
mTouchElevation = getResources().getDimensionPixelSize(
R.dimen.docked_stack_divider_lift_elevation);
mDoubleTapDetector = new GestureDetector(getContext(), new DoubleTapListener());
@@ -319,9 +360,7 @@ public class DividerView extends FrameLayout implements View.OnTouchListener {
return false;
}
- if (mDoubleTapDetector.onTouchEvent(event)) {
- return true;
- }
+ mDoubleTapDetector.onTouchEvent(event);
// Convert to use screen-based coordinates to prevent lost track of motion events while
// moving divider bar and calculating dragging velocity.
@@ -336,23 +375,77 @@ public class DividerView extends FrameLayout implements View.OnTouchListener {
setTouching();
mStartPos = touchPos;
mMoving = false;
- // This triggers initialization of things like the resize veil in preparation for
- // showing it when the user moves the divider past the slop, and has to be done
- // before onStartDragging() which starts the jank interaction tracing
- mSplitLayout.updateDividerBounds(mSplitLayout.getDividerPosition(),
- false /* shouldUseParallaxEffect */);
mSplitLayout.onStartDragging();
break;
case MotionEvent.ACTION_MOVE:
mVelocityTracker.addMovement(event);
- if (!mMoving && Math.abs(touchPos - mStartPos) > mTouchSlop) {
+ int displacement = touchPos - mStartPos;
+ if (!mMoving && Math.abs(displacement) > mTouchSlop) {
mStartPos = touchPos;
mMoving = true;
+ if (Flags.enableMagneticSplitDivider()) {
+ // Move gesture is confirmed, create framework for magnetic snap
+ InputDirection direction =
+ displacement > 0 ? InputDirection.Max : InputDirection.Min;
+ mDistanceGestureContext = DistanceGestureContext.create(mContext, mStartPos,
+ direction);
+ mViewMotionValue = new ViewMotionValue(mStartPos,
+ mDistanceGestureContext,
+ mSplitLayout.mDividerSnapAlgorithm.getMotionSpec(),
+ "dividerView::pos" /* label */);
+ mLastHoveredOverSnapPosition = mSplitLayout.calculateCurrentSnapPosition();
+ // Set a "starting region" in which we don't want to show the tooltip yet.
+ mDragStartingSnapPosition = mSplitLayout.calculateCurrentSnapPosition();
+ mViewMotionValue.addUpdateCallback(viewMotionValue -> {
+ int snappedPosition = (int) viewMotionValue.getOutput();
+ // Whenever MotionValue updates (from user moving the divider):
+ // - Place divider in its new position
+ placeDivider(snappedPosition);
+ // - Play a haptic if entering a magnetic zone
+ Integer currentlyHoveredOverSnapZone = viewMotionValue.get(
+ MagneticDividerUtils.getSNAP_POSITION_KEY());
+
+ boolean changedSnapPosition = !Objects.equals(
+ currentlyHoveredOverSnapZone, mLastHoveredOverSnapPosition);
+ if (currentlyHoveredOverSnapZone != null && changedSnapPosition) {
+ playHapticClick();
+ }
+ // - Update the last-hovered-over snap zone
+ mLastHoveredOverSnapPosition = currentlyHoveredOverSnapZone;
+ // - Update tooltip state if needed
+ if (SHOW_DRAG_TOOLTIP) {
+ // - Update internal state for closest snap position (i.e. where the
+ // user will end up if drag is released)
+ final float velocity = isLeftRightSplit
+ ? mVelocityTracker.getXVelocity()
+ : mVelocityTracker.getYVelocity();
+ int closestSnapPosition = mSplitLayout
+ .findSnapTarget(snappedPosition,
+ velocity, false /* hardDismiss */)
+ .snapPosition;
+ // If we are still in the starting zone, wait until the user drags
+ // to a point where the closest snap position is a different one.
+ if (!mDraggedOutOfStartingRegion
+ && closestSnapPosition != mDragStartingSnapPosition) {
+ mDraggedOutOfStartingRegion = true;
+ }
+ // Afterwards, always show the tooltip, updating to reflect the
+ // nearest snap point.
+ if (mDraggedOutOfStartingRegion) {
+ showTooltip(snapPositionToUIString(closestSnapPosition));
+ }
+ }
+ });
+ }
}
if (mMoving) {
final int position = mSplitLayout.getDividerPosition() + touchPos - mStartPos;
mLastDraggingPosition = position;
- mSplitLayout.updateDividerBounds(position, true /* shouldUseParallaxEffect */);
+ if (Flags.enableMagneticSplitDivider()) {
+ updateMagneticSnapCalculation(position);
+ } else {
+ placeDivider(position);
+ }
}
break;
case MotionEvent.ACTION_UP:
@@ -360,6 +453,9 @@ public class DividerView extends FrameLayout implements View.OnTouchListener {
releaseTouching();
if (!mMoving) {
mSplitLayout.onDraggingCancelled();
+ if (Flags.enableMagneticSplitDivider()) {
+ cleanUpMagneticSnapFramework();
+ }
break;
}
@@ -369,18 +465,74 @@ public class DividerView extends FrameLayout implements View.OnTouchListener {
? mVelocityTracker.getXVelocity()
: mVelocityTracker.getYVelocity();
final int position = mSplitLayout.getDividerPosition() + touchPos - mStartPos;
- final DividerSnapAlgorithm.SnapTarget snapTarget =
+ final SnapTarget snapTarget =
mSplitLayout.findSnapTarget(position, velocity, false /* hardDismiss */);
mSplitLayout.snapToTarget(position, snapTarget);
mMoving = false;
+ if (Flags.enableMagneticSplitDivider()) {
+ cleanUpMagneticSnapFramework();
+ }
break;
}
return true;
}
+ /** Plays a short haptic to indicate attaching or detaching from a divider snap point. */
+ private void playHapticClick() {
+ mSplitLayout.getHapticPlayer().playToken(MSDLToken.SWIPE_THRESHOLD_INDICATOR, null);
+ }
+
+ private void showTooltip(String tooltipText) {
+ mTooltip.setText(tooltipText);
+ if (mTooltip.getVisibility() == VISIBLE) {
+ return;
+ }
+ mTooltip.setVisibility(VISIBLE);
+ mTooltip.setAlpha(1f);
+ }
+
+ private void hideTooltip() {
+ if (mTooltip.getVisibility() == GONE) {
+ return;
+ }
+ mTooltip.setAlpha(0f);
+ mTooltip.setVisibility(GONE);
+ }
+
+ /** Updates the position of the divider. */
+ private void placeDivider(int position) {
+ mSplitLayout.updateDividerBounds(position, true /* shouldUseParallaxEffect */);
+ }
+
+ /**
+ * Sends a position update to the magnetic snap framework, allowing a calculation to occur. The
+ * position of the divider will be updated.
+ * @param position The current position of the user's finger.
+ */
+ private void updateMagneticSnapCalculation(int position) {
+ if (mDistanceGestureContext != null) {
+ mDistanceGestureContext.setDragOffset(position);
+ }
+ if (mViewMotionValue != null) {
+ mViewMotionValue.setInput(position);
+ }
+ }
+
+ /** Cleans up the magnetic snap framework after the drag gesture completes. */
+ private void cleanUpMagneticSnapFramework() {
+ if (mViewMotionValue != null) {
+ mViewMotionValue.dispose();
+ }
+ mDistanceGestureContext = null;
+ mViewMotionValue = null;
+ mLastHoveredOverSnapPosition = null;
+ mDragStartingSnapPosition = null;
+ mDraggedOutOfStartingRegion = false;
+ hideTooltip();
+ }
+
private void setTouching() {
- setSlippery(false);
mHandle.setTouching(true, true);
// Lift handle as well so it doesn't get behind the background, even though it doesn't
// cast shadow.
@@ -392,7 +544,6 @@ public class DividerView extends FrameLayout implements View.OnTouchListener {
}
private void releaseTouching() {
- setSlippery(true);
mHandle.setTouching(false, true);
mHandle.animate()
.setInterpolator(Interpolators.FAST_OUT_SLOW_IN)
@@ -401,25 +552,6 @@ public class DividerView extends FrameLayout implements View.OnTouchListener {
.start();
}
- private void setSlippery(boolean slippery) {
- if (mViewHost == null) {
- return;
- }
-
- final WindowManager.LayoutParams lp = (WindowManager.LayoutParams) getLayoutParams();
- final boolean isSlippery = (lp.flags & FLAG_SLIPPERY) != 0;
- if (isSlippery == slippery) {
- return;
- }
-
- if (slippery) {
- lp.flags |= FLAG_SLIPPERY;
- } else {
- lp.flags &= ~FLAG_SLIPPERY;
- }
- mViewHost.relayout(lp);
- }
-
@Override
public boolean onHoverEvent(MotionEvent event) {
if (!DeviceConfig.getBoolean(DeviceConfig.NAMESPACE_SYSTEMUI, CURSOR_HOVER_STATES_ENABLED,
@@ -482,6 +614,7 @@ public class DividerView extends FrameLayout implements View.OnTouchListener {
mLastDraggingPosition,
position,
mSplitLayout.FLING_RESIZE_DURATION,
+ Interpolators.FAST_OUT_SLOW_IN,
() -> mSplitLayout.setDividerPosition(position, true /* applyLayoutChange */));
mMoving = false;
}
@@ -497,18 +630,24 @@ public class DividerView extends FrameLayout implements View.OnTouchListener {
return mHideHandle;
}
+ /** Returns true if the divider is currently being physically controlled by the user. */
+ boolean isMoving() {
+ return mMoving;
+ }
+
private class DoubleTapListener extends GestureDetector.SimpleOnGestureListener {
- @Override
- public boolean onDoubleTap(MotionEvent e) {
- if (mSplitLayout != null) {
- mSplitLayout.onDoubleTappedDivider();
- }
- return true;
- }
@Override
public boolean onDoubleTapEvent(@NonNull MotionEvent e) {
- return true;
+ // User could have started double tap and then dragged before letting go. Skip the
+ // swap if so
+ if (!mMoving && e.getAction() == MotionEvent.ACTION_UP) {
+ if (mSplitLayout != null) {
+ mSplitLayout.onDoubleTappedDivider();
+ }
+ return true;
+ }
+ return false;
}
}
}
diff --git a/wmshell/src/com/android/wm/shell/common/split/DockedDividerUtils.java b/wmshell/src/com/android/wm/shell/common/split/DockedDividerUtils.java
index f25dfeafb3..25157c05d0 100644
--- a/wmshell/src/com/android/wm/shell/common/split/DockedDividerUtils.java
+++ b/wmshell/src/com/android/wm/shell/common/split/DockedDividerUtils.java
@@ -97,12 +97,12 @@ public class DockedDividerUtils {
}
}
- public static int calculateMiddlePosition(boolean isHorizontalDivision, Rect insets,
+ public static int calculateMiddlePosition(boolean isLeftRightSplit, Rect insets,
int displayWidth, int displayHeight, int dividerSize) {
- int start = isHorizontalDivision ? insets.top : insets.left;
- int end = isHorizontalDivision
- ? displayHeight - insets.bottom
- : displayWidth - insets.right;
+ int start = isLeftRightSplit ? insets.left : insets.top;
+ int end = isLeftRightSplit
+ ? displayWidth - insets.right
+ : displayHeight - insets.bottom;
return start + (end - start) / 2 - dividerSize / 2;
}
diff --git a/wmshell/src/com/android/wm/shell/common/split/FlexParallaxSpec.java b/wmshell/src/com/android/wm/shell/common/split/FlexParallaxSpec.java
new file mode 100644
index 0000000000..e02d48018d
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/common/split/FlexParallaxSpec.java
@@ -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.common.split;
+
+import static android.view.WindowManager.DOCKED_BOTTOM;
+import static android.view.WindowManager.DOCKED_INVALID;
+import static android.view.WindowManager.DOCKED_LEFT;
+import static android.view.WindowManager.DOCKED_RIGHT;
+import static android.view.WindowManager.DOCKED_TOP;
+
+import static com.android.wm.shell.common.split.ResizingEffectPolicy.DEFAULT_OFFSCREEN_DIM;
+import static com.android.wm.shell.shared.animation.Interpolators.DIM_INTERPOLATOR;
+import static com.android.wm.shell.shared.animation.Interpolators.FAST_DIM_INTERPOLATOR;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.ANIMATING_OFFSCREEN_TAP;
+
+import android.graphics.Point;
+import android.graphics.Rect;
+
+/**
+ * Calculation class, used when {@link com.android.wm.shell.common.split.SplitLayout#PARALLAX_FLEX}
+ * is the desired parallax effect.
+ */
+public class FlexParallaxSpec implements ParallaxSpec {
+ final Rect mTempRect = new Rect();
+
+ @Override
+ public int getDimmingSide(int position, DividerSnapAlgorithm snapAlgorithm,
+ boolean isLeftRightSplit) {
+ if (position < snapAlgorithm.getMiddleTarget().getPosition()) {
+ return isLeftRightSplit ? DOCKED_LEFT : DOCKED_TOP;
+ } else if (position > snapAlgorithm.getMiddleTarget().getPosition()) {
+ return isLeftRightSplit ? DOCKED_RIGHT : DOCKED_BOTTOM;
+ }
+ return DOCKED_INVALID;
+ }
+
+ /**
+ * Calculates the amount of dim to apply to a task surface moving offscreen in flexible split.
+ * In flexible split, there are two dimming "behaviors".
+ * 1) "slow dim": when moving the divider from the middle of the screen to a target at 10% or
+ * 90%, we dim the app slightly as it moves partially offscreen.
+ * 2) "fast dim": when moving the divider from a side snap target further toward the screen
+ * edge, we dim the app rapidly as it approaches the dismiss point.
+ * @return 0f = no dim applied. 1f = full black.
+ */
+ public float getDimValue(int position, DividerSnapAlgorithm snapAlgorithm) {
+ // On tablets, apps don't go offscreen, so only dim for dismissal.
+ if (!snapAlgorithm.areOffscreenRatiosSupported()) {
+ return ParallaxSpec.super.getDimValue(position, snapAlgorithm);
+ }
+
+ int startDismissPos = snapAlgorithm.getDismissStartTarget().getPosition();
+ int firstTargetPos = snapAlgorithm.getFirstSplitTarget().getPosition();
+ int middleTargetPos = snapAlgorithm.getMiddleTarget().getPosition();
+ int lastTargetPos = snapAlgorithm.getLastSplitTarget().getPosition();
+ int endDismissPos = snapAlgorithm.getDismissEndTarget().getPosition();
+ float progress;
+
+ if (startDismissPos <= position && position < firstTargetPos) {
+ // Divider is on the left/top (between 0% and 10% of screen), "fast dim" as it moves
+ // toward the screen edge
+ progress = (float) (firstTargetPos - position) / (firstTargetPos - startDismissPos);
+ return fastDim(progress);
+ } else if (firstTargetPos <= position && position < middleTargetPos) {
+ // Divider is between 10% and 50%, "slow dim" as it moves toward the left/top target
+ progress = (float) (middleTargetPos - position) / (middleTargetPos - firstTargetPos);
+ return slowDim(progress);
+ } else if (middleTargetPos <= position && position < lastTargetPos) {
+ // Divider is between 50% and 90%, "slow dim" as it moves toward the right/bottom target
+ progress = (float) (position - middleTargetPos) / (lastTargetPos - middleTargetPos);
+ return slowDim(progress);
+ } else if (lastTargetPos <= position && position <= endDismissPos) {
+ // Divider is on the right/bottom (between 90% and 100% of screen), "fast dim" as it
+ // moves toward screen edge
+ progress = (float) (position - lastTargetPos) / (endDismissPos - lastTargetPos);
+ return fastDim(progress);
+ }
+ return 0f;
+ }
+
+ /**
+ * Used by {@link #getDimValue} to determine the amount to dim an app. Starts at zero and ramps
+ * up to the default amount of dimming for an offscreen app,
+ * {@link ResizingEffectPolicy#DEFAULT_OFFSCREEN_DIM}.
+ */
+ private float slowDim(float progress) {
+ return DIM_INTERPOLATOR.getInterpolation(progress) * DEFAULT_OFFSCREEN_DIM;
+ }
+
+ /**
+ * Used by {@link #getDimValue} to determine the amount to dim an app. Starts at
+ * {@link ResizingEffectPolicy#DEFAULT_OFFSCREEN_DIM} and ramps up to 100% dim (full black).
+ */
+ private float fastDim(float progress) {
+ return DEFAULT_OFFSCREEN_DIM + (FAST_DIM_INTERPOLATOR.getInterpolation(progress)
+ * (1 - DEFAULT_OFFSCREEN_DIM));
+ }
+
+ @Override
+ public void getParallax(Point retreatingOut, Point advancingOut, int position,
+ DividerSnapAlgorithm snapAlgorithm, boolean isLeftRightSplit, Rect displayBounds,
+ Rect retreatingSurface, Rect retreatingContent, Rect advancingSurface,
+ Rect advancingContent, int dimmingSide, boolean topLeftShrink,
+ SplitState splitState) {
+ // Whether an app is getting pushed offscreen by the divider.
+ boolean isRetreatingOffscreen = !displayBounds.contains(retreatingSurface);
+ // Whether an app was getting pulled onscreen at the beginning of the drag.
+ boolean advancingSideStartedOffscreen = !displayBounds.contains(advancingContent);
+
+ // If this is during the offscreen-tap animation, we adjust the left-top app to simulate the
+ // contents sticking to the divider. (Needed because the underlying surfaces are contracting
+ // and expanding unevenly as they move on- and offscreen.)
+ if (splitState.get() == ANIMATING_OFFSCREEN_TAP) {
+ if (topLeftShrink) {
+ if (isLeftRightSplit) {
+ retreatingOut.x = retreatingSurface.width() - retreatingContent.width();
+ } else {
+ retreatingOut.y = retreatingSurface.height() - retreatingContent.height();
+ }
+ } else {
+ if (isLeftRightSplit) {
+ advancingOut.x = advancingSurface.width() - advancingContent.width();
+ } else {
+ advancingOut.y = advancingSurface.height() - advancingContent.height();
+ }
+ }
+ } else if (isRetreatingOffscreen && !advancingSideStartedOffscreen) {
+ // Simple user-controlled case when an app gets pushed offscreen (e.g. 50:50 -> 90:10).
+ // On the left/top side, we use parallax to simulate the contents sticking to the
+ // divider. (Not needed on the right/bottom side because of the natural left-top
+ // alignment of content surfaces.)
+ if (topLeftShrink) {
+ if (isLeftRightSplit) {
+ retreatingOut.x = retreatingSurface.width() - retreatingContent.width();
+ } else {
+ retreatingOut.y = retreatingSurface.height() - retreatingContent.height();
+ }
+ }
+ } else {
+ // All other user-controlled cases (10:90 -> 50:50, 10:90 -> 90:10, 10:90 -> dismiss)
+ mTempRect.set(retreatingSurface);
+ Point rootOffset = new Point();
+ // 10:90 -> 50:50, 10:90, or dismiss right
+ if (advancingSideStartedOffscreen) {
+ // We have to handle a complicated case here to keep the parallax smooth.
+ // When the divider crosses the 50% mark, the retreating-side app surface
+ // will start expanding offscreen. This is expected and unavoidable, but
+ // makes the parallax look disjointed. In order to preserve the illusion,
+ // we add another offset (rootOffset) to simulate the surface staying
+ // onscreen.
+ if (mTempRect.intersect(displayBounds)) {
+ if (retreatingSurface.left < displayBounds.left) {
+ rootOffset.x = displayBounds.left - retreatingSurface.left;
+ }
+ if (retreatingSurface.top < displayBounds.top) {
+ rootOffset.y = displayBounds.top - retreatingSurface.top;
+ }
+ }
+
+ // On the left side, we again have to simulate the contents sticking to the
+ // divider.
+ if (!topLeftShrink) {
+ if (isLeftRightSplit) {
+ advancingOut.x = advancingSurface.width() - advancingContent.width();
+ } else {
+ advancingOut.y = advancingSurface.height() - advancingContent.height();
+ }
+ }
+ }
+
+ // In all these cases, the shrinking app also receives a center parallax.
+ if (isLeftRightSplit) {
+ retreatingOut.x = rootOffset.x
+ + ((mTempRect.width() - retreatingContent.width()) / 2);
+ } else {
+ retreatingOut.y = rootOffset.y
+ + ((mTempRect.height() - retreatingContent.height()) / 2);
+ }
+ }
+ }
+}
diff --git a/wmshell/src/com/android/wm/shell/common/split/MagneticDividerUtils.kt b/wmshell/src/com/android/wm/shell/common/split/MagneticDividerUtils.kt
new file mode 100644
index 0000000000..1a5d5bbd0c
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/common/split/MagneticDividerUtils.kt
@@ -0,0 +1,156 @@
+/*
+ * 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.common.split
+
+import android.content.res.Resources
+import androidx.annotation.VisibleForTesting
+import androidx.compose.ui.unit.dp
+import com.android.mechanics.spec.Mapping
+import com.android.mechanics.spec.MotionSpec
+import com.android.mechanics.spec.SemanticKey
+import com.android.mechanics.spec.builder.MotionBuilderContext
+import com.android.mechanics.spec.builder.spatialDirectionalMotionSpec
+import com.android.mechanics.spec.with
+import com.android.mechanics.spring.SpringParameters
+import com.android.mechanics.view.standardViewMotionBuilderContext
+import com.android.wm.shell.common.split.DividerSnapAlgorithm.SnapTarget
+
+/**
+ * Utility class used to create a framework that enables the divider to snap magnetically to snap
+ * points while the user is dragging it.
+ */
+object MagneticDividerUtils {
+ /**
+ * When the user moves the divider towards or away from a snap point, a magnetic spring movement
+ * and haptic will take place at this distance.
+ */
+ @VisibleForTesting val DEFAULT_MAGNETIC_ATTACH_THRESHOLD = 56.dp
+ /** The minimum spacing between snap zones, to prevent overlap on smaller displays. */
+ private val MINIMUM_SPACE_BETWEEN_SNAP_ZONES = 4.dp
+ /** The stiffness of the magnetic snap effect. */
+ private const val ATTACH_STIFFNESS = 850f
+ /** The damping ratio of the magnetic snap effect. */
+ private const val ATTACH_DAMPING_RATIO = 0.95f
+ /** The spring used for the magnetic snap effect. */
+ private val MagneticSpring =
+ SpringParameters(stiffness = ATTACH_STIFFNESS, dampingRatio = ATTACH_DAMPING_RATIO)
+ /** When inside the magnetic snap zone, the divider's movement is reduced by this amount. */
+ private const val ATTACH_DETACH_SCALE = 0.5f
+ /**
+ * A key that can be passed into a MotionValue to retrieve the SnapPosition associated with the
+ * current drag.
+ */
+ @JvmStatic val SNAP_POSITION_KEY = SemanticKey(debugLabel = "snapPosition")
+
+ /**
+ * Create a MotionSpec that has "snap zones" for each of the SnapTargets provided.
+ *
+ * NOTE: This exists for Java/View interoperability only
+ */
+ @JvmStatic
+ fun generateMotionSpec(targets: List, resources: Resources): MotionSpec {
+ return with(standardViewMotionBuilderContext(resources.displayMetrics.density)) {
+ generateMotionSpec(targets)
+ }
+ }
+
+ /** Create a MotionSpec that has "snap zones" for each of the SnapTargets provided. */
+ fun MotionBuilderContext.generateMotionSpec(targets: List): MotionSpec {
+ // First, get the position of the left-most (or top-most) dismiss point.
+ val topLeftDismissTarget = targets.first()
+ val topLeftDismissPosition = topLeftDismissTarget.position.toFloat()
+
+ return MotionSpec(
+
+ // Create a DirectionalMotionSpec using a pre-set builder method. We choose the
+ // "spatialDirectionalMotionSpec", which is meant for "spatial" movement (as opposed to
+ // "effects" movement).
+ spatialDirectionalMotionSpec(
+ initialMapping = Mapping.Fixed(topLeftDismissPosition),
+ semantics = listOf(SNAP_POSITION_KEY with topLeftDismissTarget.snapPosition),
+ defaultSpring = MagneticSpring,
+ ) {
+ // A DirectionalMotionSpec is essentially a number line from -infinity to infinity,
+ // with instructions on how to interpret the value at each point. We create each
+ // individual segment below to fill out our number line.
+
+ // Start by finding the smallest span between two targets and setting an appropriate
+ // magnetic snap threshold.
+ val smallestSpanBetweenTargets =
+ targets
+ .zipWithNext { t1, t2 -> t2.position.toFloat() - t1.position.toFloat() }
+ .reduce { minSoFar, currentDiff -> kotlin.math.min(minSoFar, currentDiff) }
+ val availableSpaceForSnapZone =
+ (smallestSpanBetweenTargets - MINIMUM_SPACE_BETWEEN_SNAP_ZONES.toPx()) / 2f
+ val snapThreshold =
+ kotlin.math.min(
+ DEFAULT_MAGNETIC_ATTACH_THRESHOLD.toPx(),
+ availableSpaceForSnapZone,
+ )
+
+ // Our first breakpoint is located at topLeftDismissPosition. On the right side of
+ // this breakpoint, we'll use the "identity" instruction, which means values won't
+ // be converted.
+ identity(
+ breakpoint = topLeftDismissPosition,
+ semantics = listOf(SNAP_POSITION_KEY with null),
+ )
+
+ // We continue creating alternating zones of "identity" and
+ // "fractionalInputFromCurrent", which will give us the behavior we're looking for,
+ // where the divider can be dragged along normally in some areas (the identity
+ // zones) and resists the user's movement in some areas (the
+ // fractionalInputFromCurrent zones). The targets have to be created in ascending
+ // order.
+
+ // Iterating from the second target to the second-last target (EXCLUDING the first
+ // and last):
+ for (i in (1 until targets.size - 1)) {
+ val target = targets[i]
+ val targetPosition = target.position.toFloat()
+
+ // Create a fractionalInputFromCurrent zone.
+ fractionalInputFromCurrent(
+ breakpoint = targetPosition - snapThreshold,
+ // With every magnetic segment, we also pass in the associated snapPosition
+ // as a "semantic association", so we can later query the MotionValue for
+ // it.
+ semantics = listOf(SNAP_POSITION_KEY with target.snapPosition),
+ delta = snapThreshold * (1 - ATTACH_DETACH_SCALE),
+ fraction = ATTACH_DETACH_SCALE,
+ )
+
+ // Create another identity zone.
+ identity(
+ breakpoint = targetPosition + snapThreshold,
+ semantics = listOf(SNAP_POSITION_KEY with null),
+ )
+ }
+
+ // Finally, create one last fixedValue zone, from the bottom/right dismiss point to
+ // infinity.
+ val bottomRightDismissTarget = targets.last()
+ val bottomRightDismissPosition = bottomRightDismissTarget.position.toFloat()
+ fixedValue(
+ breakpoint = bottomRightDismissPosition,
+ value = bottomRightDismissPosition,
+ semantics = listOf(SNAP_POSITION_KEY with bottomRightDismissTarget.snapPosition),
+ )
+ }
+ )
+ }
+}
diff --git a/wmshell/src/com/android/wm/shell/common/split/NoParallaxSpec.java b/wmshell/src/com/android/wm/shell/common/split/NoParallaxSpec.java
new file mode 100644
index 0000000000..28fc75e8a9
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/common/split/NoParallaxSpec.java
@@ -0,0 +1,34 @@
+/*
+ * 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.common.split;
+
+import android.graphics.Point;
+import android.graphics.Rect;
+
+/**
+ * Calculation class, used when {@link com.android.wm.shell.common.split.SplitLayout#PARALLAX_NONE}
+ * is the desired parallax effect.
+ */
+public class NoParallaxSpec implements ParallaxSpec {
+ @Override
+ public void getParallax(Point retreatingOut, Point advancingOut, int position,
+ DividerSnapAlgorithm snapAlgorithm, boolean isLeftRightSplit, Rect displayBounds,
+ Rect retreatingSurface, Rect retreatingContent, Rect advancingSurface,
+ Rect advancingContent, int dimmingSide, boolean topLeftShrink, SplitState splitState) {
+ // no-op
+ }
+}
diff --git a/wmshell/src/com/android/wm/shell/common/split/OffscreenTouchZone.java b/wmshell/src/com/android/wm/shell/common/split/OffscreenTouchZone.java
new file mode 100644
index 0000000000..ee2ca320e3
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/common/split/OffscreenTouchZone.java
@@ -0,0 +1,170 @@
+/*
+ * 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.common.split;
+
+import static android.view.WindowManager.LayoutParams.PRIVATE_FLAG_NO_MOVE_ANIMATION;
+import static android.view.WindowManager.LayoutParams.PRIVATE_FLAG_TRUSTED_OVERLAY;
+
+import static com.android.wm.shell.common.split.SplitLayout.RESTING_TOUCH_LAYER;
+
+import android.content.Context;
+import android.content.res.Configuration;
+import android.graphics.PixelFormat;
+import android.os.Binder;
+import android.view.DragEvent;
+import android.view.GestureDetector;
+import android.view.MotionEvent;
+import android.view.SurfaceControl;
+import android.view.SurfaceControlViewHost;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.WindowManager;
+import android.view.WindowlessWindowManager;
+
+import com.android.wm.shell.common.SyncTransactionQueue;
+
+/**
+ * Holds and manages a single touchable surface. These are used in offscreen split layouts, where
+ * we use them as a signal that the user wants to bring an offscreen app back onscreen.
+ *
+ * Split root
+ * / | \
+ * Stage root Divider Stage root
+ * / \
+ * Task *this class*
+ *
+ */
+public class OffscreenTouchZone {
+ private static final String TAG = "OffscreenTouchZone";
+
+ /**
+ * Whether this touch zone is on the top/left or the bottom/right screen edge.
+ */
+ private final boolean mIsTopLeft;
+ /** The function that will be run when this zone is tapped. */
+ private final Runnable mOnClickRunnable;
+ private SurfaceControlViewHost mViewHost;
+ private SurfaceControl mLeash;
+ private GestureDetector mGestureDetector;
+ private final GestureDetector.SimpleOnGestureListener mTapDetector =
+ new GestureDetector.SimpleOnGestureListener() {
+ @Override
+ public boolean onSingleTapUp(MotionEvent e) {
+ mOnClickRunnable.run();
+ return true;
+ }
+ };
+ private final View.OnDragListener mDragListener = new View.OnDragListener() {
+ @Override
+ public boolean onDrag(View view, DragEvent dragEvent) {
+ if (dragEvent.getAction() == DragEvent.ACTION_DRAG_ENTERED) {
+ mOnClickRunnable.run();
+ }
+ return false;
+ }
+ };
+ /**
+ * @param isTopLeft Whether the desired touch zone will be on the top/left or the bottom/right
+ * screen edge.
+ * @param runnable The function to run when the touch zone is tapped.
+ */
+ OffscreenTouchZone(boolean isTopLeft, Runnable runnable) {
+ mIsTopLeft = isTopLeft;
+ mOnClickRunnable = runnable;
+ }
+
+ /** Sets up a touch zone. */
+ public void inflate(Context context, Configuration config, SyncTransactionQueue syncQueue,
+ SurfaceControl stageRoot) {
+ View touchableView = new View(context);
+ mGestureDetector = new GestureDetector(context, mTapDetector);
+ touchableView.setOnTouchListener(new OffscreenTouchListener());
+
+ // Set WM flags, tokens, and sizing on the touchable view. It will be the same size as its
+ // parent, the stage root.
+ // TODO (b/349828130): It's a bit wasteful to have the touch zone cover the whole app
+ // surface, even extending offscreen (keeps buffer active in memory), so can trim it down
+ // to the visible onscreen area in a future patch.
+ WindowManager.LayoutParams lp = new WindowManager.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ WindowManager.LayoutParams.TYPE_INPUT_CONSUMER,
+ WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
+ PixelFormat.TRANSLUCENT);
+ lp.token = new Binder();
+ lp.setTitle(TAG);
+ lp.privateFlags |= PRIVATE_FLAG_NO_MOVE_ANIMATION | PRIVATE_FLAG_TRUSTED_OVERLAY;
+ touchableView.setLayoutParams(lp);
+ touchableView.setOnDragListener(mDragListener);
+
+ // Create a new leash under our stage leash.
+ final SurfaceControl.Builder builder = new SurfaceControl.Builder()
+ .setContainerLayer()
+ .setName(TAG + (mIsTopLeft ? "TopLeft" : "BottomRight"))
+ .setCallsite("OffscreenTouchZone::init");
+ builder.setParent(stageRoot);
+ SurfaceControl leash = builder.build();
+ mLeash = leash;
+
+ // Create a ViewHost that will hold our view.
+ WindowlessWindowManager wwm = new WindowlessWindowManager(config, leash, null);
+ mViewHost = new SurfaceControlViewHost(context, context.getDisplay(), wwm,
+ "SplitTouchZones");
+ mViewHost.setView(touchableView, lp);
+
+ // Create a transaction so that we can activate and reposition our surface.
+ SurfaceControl.Transaction t = new SurfaceControl.Transaction();
+ // Set layer to maximum. We want this surface to be above the app layer, or else touches
+ // will be blocked.
+ t.setLayer(leash, RESTING_TOUCH_LAYER);
+ // Leash starts off hidden, show it.
+ t.show(leash);
+ syncQueue.runInSync(transaction -> {
+ transaction.merge(t);
+ t.close();
+ });
+ }
+
+ /** Releases the touch zone when it's no longer needed. */
+ void release(SurfaceControl.Transaction t) {
+ if (mViewHost != null) {
+ mViewHost.release();
+ }
+ if (mLeash != null) {
+ t.remove(mLeash);
+ mLeash = null;
+ }
+ }
+
+ /**
+ * Listens for touch events.
+ */
+ private class OffscreenTouchListener implements View.OnTouchListener {
+ @Override
+ public boolean onTouch(View view, MotionEvent motionEvent) {
+ return mGestureDetector.onTouchEvent(motionEvent);
+ }
+ }
+
+ /**
+ * Returns {@code true} if this touch zone represents an offscreen app on the top/left edge of
+ * the display, {@code false} for bottom/right.
+ */
+ public boolean isTopLeft() {
+ return mIsTopLeft;
+ }
+}
diff --git a/wmshell/src/com/android/wm/shell/common/split/ParallaxSpec.java b/wmshell/src/com/android/wm/shell/common/split/ParallaxSpec.java
new file mode 100644
index 0000000000..5b7e3ce0d3
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/common/split/ParallaxSpec.java
@@ -0,0 +1,62 @@
+/*
+ * 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.common.split;
+
+import static android.view.WindowManager.DOCKED_BOTTOM;
+import static android.view.WindowManager.DOCKED_INVALID;
+import static android.view.WindowManager.DOCKED_LEFT;
+import static android.view.WindowManager.DOCKED_RIGHT;
+import static android.view.WindowManager.DOCKED_TOP;
+
+import static com.android.wm.shell.shared.animation.Interpolators.DIM_INTERPOLATOR;
+
+import android.graphics.Point;
+import android.graphics.Rect;
+
+/**
+ * Default interface for a set of calculation classes, used for calculating various parallax and
+ * dimming effects in split screen.
+ */
+public interface ParallaxSpec {
+ /** Returns an int indicating which side of the screen is being dimmed (if any). */
+ default int getDimmingSide(int position, DividerSnapAlgorithm snapAlgorithm,
+ boolean isLeftRightSplit) {
+ if (position < snapAlgorithm.getFirstSplitTarget().getPosition()) {
+ return isLeftRightSplit ? DOCKED_LEFT : DOCKED_TOP;
+ } else if (position > snapAlgorithm.getLastSplitTarget().getPosition()) {
+ return isLeftRightSplit ? DOCKED_RIGHT : DOCKED_BOTTOM;
+ }
+ return DOCKED_INVALID;
+ }
+
+ /** Returns the dim amount that we'll apply to the app surface. 0f = no dim, 1f = full black */
+ default float getDimValue(int position, DividerSnapAlgorithm snapAlgorithm) {
+ float progressTowardScreenEdge =
+ Math.max(0, Math.min(snapAlgorithm.calculateDismissingFraction(position), 1f));
+ return DIM_INTERPOLATOR.getInterpolation(progressTowardScreenEdge);
+ }
+
+ /**
+ * Calculates the amount to offset app surfaces to create nice parallax effects. Writes to
+ * {@link ResizingEffectPolicy#mRetreatingSideParallax} and
+ * {@link ResizingEffectPolicy#mAdvancingSideParallax}.
+ */
+ void getParallax(Point retreatingOut, Point advancingOut, int position,
+ DividerSnapAlgorithm snapAlgorithm, boolean isLeftRightSplit, Rect displayBounds,
+ Rect retreatingSurface, Rect retreatingContent, Rect advancingSurface,
+ Rect advancingContent, int dimmingSide, boolean topLeftShrink, SplitState splitState);
+}
diff --git a/wmshell/src/com/android/wm/shell/common/split/ResizingEffectPolicy.java b/wmshell/src/com/android/wm/shell/common/split/ResizingEffectPolicy.java
new file mode 100644
index 0000000000..9bda225a15
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/common/split/ResizingEffectPolicy.java
@@ -0,0 +1,255 @@
+/*
+ * 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.common.split;
+
+import static android.view.WindowManager.DOCKED_BOTTOM;
+import static android.view.WindowManager.DOCKED_INVALID;
+import static android.view.WindowManager.DOCKED_LEFT;
+import static android.view.WindowManager.DOCKED_RIGHT;
+import static android.view.WindowManager.DOCKED_TOP;
+
+import static com.android.wm.shell.common.split.SplitLayout.PARALLAX_ALIGN_CENTER;
+import static com.android.wm.shell.common.split.SplitLayout.PARALLAX_DISMISSING;
+import static com.android.wm.shell.common.split.SplitLayout.PARALLAX_FLEX;
+import static com.android.wm.shell.common.split.SplitLayout.PARALLAX_NONE;
+
+import android.graphics.Point;
+import android.graphics.Rect;
+import android.view.SurfaceControl;
+
+/**
+ * This class governs how and when parallax and dimming effects are applied to task surfaces,
+ * usually when the divider is being moved around by the user (or during an animation).
+ */
+class ResizingEffectPolicy {
+ /** The default amount to dim an app that is partially offscreen. */
+ public static float DEFAULT_OFFSCREEN_DIM = 0.32f;
+
+ private final SplitLayout mSplitLayout;
+ /** The parallax algorithm we are currently using. */
+ private final int mParallaxType;
+ /**
+ * A convenience class, corresponding to {@link #mParallaxType}, that performs all the
+ * calculations for parallax and dimming values.
+ */
+ private final ParallaxSpec mParallaxSpec;
+
+ int mShrinkSide = DOCKED_INVALID;
+
+ // The current dismissing side.
+ int mDimmingSide = DOCKED_INVALID;
+
+ /**
+ * A {@link Point} that stores a single x and y value, representing the parallax translation
+ * we use on the app that the divider is moving toward. The app is either shrinking in size or
+ * getting pushed off the screen.
+ */
+ final Point mRetreatingSideParallax = new Point();
+ /**
+ * A {@link Point} that stores a single x and y value, representing the parallax translation
+ * we use on the app that the divider is moving away from. The app is either growing in size or
+ * getting pulled onto the screen.
+ */
+ final Point mAdvancingSideParallax = new Point();
+
+ // The dimming value to hint the dismissing side and progress.
+ float mDimValue = 0.0f;
+
+ /**
+ * Content bounds for the app that the divider is moving toward. This is the content that is
+ * currently drawn at the start of the divider movement. It stays unchanged throughout the
+ * divider's movement.
+ */
+ final Rect mRetreatingContent = new Rect();
+ /**
+ * Surface bounds for the app that the divider is moving toward. This is the "canvas" on
+ * which an app could potentially be drawn. It changes on every frame as the divider moves
+ * around.
+ */
+ final Rect mRetreatingSurface = new Rect();
+ /**
+ * Content bounds for the app that the divider is moving away from. This is the content that
+ * is currently drawn at the start of the divider movement. It stays unchanged throughout
+ * the divider's movement.
+ */
+ final Rect mAdvancingContent = new Rect();
+ /**
+ * Surface bounds for the app that the divider is moving away from. This is the "canvas" on
+ * which an app could potentially be drawn. It changes on every frame as the divider moves
+ * around.
+ */
+ final Rect mAdvancingSurface = new Rect();
+
+ final Rect mTempRect = new Rect();
+ final Rect mTempRect2 = new Rect();
+
+ ResizingEffectPolicy(int parallaxType, SplitLayout splitLayout) {
+ mParallaxType = parallaxType;
+ mSplitLayout = splitLayout;
+ switch (mParallaxType) {
+ case PARALLAX_DISMISSING:
+ mParallaxSpec = new DismissingParallaxSpec();
+ break;
+ case PARALLAX_ALIGN_CENTER:
+ mParallaxSpec = new CenterParallaxSpec();
+ break;
+ case PARALLAX_FLEX:
+ mParallaxSpec = new FlexParallaxSpec();
+ break;
+ case PARALLAX_NONE:
+ default:
+ mParallaxSpec = new NoParallaxSpec();
+ break;
+ }
+ }
+
+ /**
+ * Calculates the desired parallax and dimming values for a task surface and stores them in
+ * {@link #mRetreatingSideParallax}, {@link #mAdvancingSideParallax}, and
+ * {@link #mDimValue} These values will be then be applied in
+ * {@link #adjustRootSurface} and {@link #adjustDimSurface} respectively.
+ */
+ void applyDividerPosition(int position, boolean isLeftRightSplit,
+ DividerSnapAlgorithm snapAlgorithm, SplitState splitState) {
+ mDimmingSide = DOCKED_INVALID;
+ mRetreatingSideParallax.set(0, 0);
+ mAdvancingSideParallax.set(0, 0);
+ mDimValue = 0;
+ Rect displayBounds = mSplitLayout.getRootBounds();
+
+ // Figure out which side is shrinking, and assign retreating/advancing bounds
+ final boolean topLeftShrink = isLeftRightSplit
+ ? position < mSplitLayout.getTopLeftContentBounds().right
+ : position < mSplitLayout.getTopLeftContentBounds().bottom;
+ if (topLeftShrink) {
+ mShrinkSide = isLeftRightSplit ? DOCKED_LEFT : DOCKED_TOP;
+ mRetreatingContent.set(mSplitLayout.getTopLeftContentBounds());
+ mRetreatingSurface.set(mSplitLayout.getTopLeftBounds());
+ mAdvancingContent.set(mSplitLayout.getBottomRightContentBounds());
+ mAdvancingSurface.set(mSplitLayout.getBottomRightBounds());
+ } else {
+ mShrinkSide = isLeftRightSplit ? DOCKED_RIGHT : DOCKED_BOTTOM;
+ mRetreatingContent.set(mSplitLayout.getBottomRightContentBounds());
+ mRetreatingSurface.set(mSplitLayout.getBottomRightBounds());
+ mAdvancingContent.set(mSplitLayout.getTopLeftContentBounds());
+ mAdvancingSurface.set(mSplitLayout.getTopLeftBounds());
+ }
+
+ // Figure out if we should be dimming one side
+ mDimmingSide = mParallaxSpec.getDimmingSide(position, snapAlgorithm, isLeftRightSplit);
+
+ // If so, calculate dimming
+ if (mDimmingSide != DOCKED_INVALID) {
+ mDimValue = mParallaxSpec.getDimValue(position, snapAlgorithm);
+ }
+
+ // Calculate parallax and modify mRetreatingSideParallax and mAdvancingSideParallax, for use
+ // in adjustRootSurface().
+ mParallaxSpec.getParallax(mRetreatingSideParallax, mAdvancingSideParallax, position,
+ snapAlgorithm, isLeftRightSplit, displayBounds, mRetreatingSurface,
+ mRetreatingContent, mAdvancingSurface, mAdvancingContent, mDimmingSide,
+ topLeftShrink, splitState);
+ }
+
+ /** Applies the calculated parallax and dimming values to task surfaces. */
+ void adjustRootSurface(SurfaceControl.Transaction t,
+ SurfaceControl leash1, SurfaceControl leash2) {
+ SurfaceControl retreatingLeash = null;
+ SurfaceControl advancingLeash = null;
+
+ if (mParallaxType == PARALLAX_DISMISSING) {
+ switch (mDimmingSide) {
+ case DOCKED_TOP:
+ case DOCKED_LEFT:
+ retreatingLeash = leash1;
+ mTempRect.set(mSplitLayout.getTopLeftBounds());
+ advancingLeash = leash2;
+ mTempRect2.set(mSplitLayout.getBottomRightBounds());
+ break;
+ case DOCKED_BOTTOM:
+ case DOCKED_RIGHT:
+ retreatingLeash = leash2;
+ mTempRect.set(mSplitLayout.getBottomRightBounds());
+ advancingLeash = leash1;
+ mTempRect2.set(mSplitLayout.getTopLeftBounds());
+ break;
+ }
+ } else if (mParallaxType == PARALLAX_ALIGN_CENTER || mParallaxType == PARALLAX_FLEX) {
+ switch (mShrinkSide) {
+ case DOCKED_TOP:
+ case DOCKED_LEFT:
+ retreatingLeash = leash1;
+ mTempRect.set(mSplitLayout.getTopLeftBounds());
+ advancingLeash = leash2;
+ mTempRect2.set(mSplitLayout.getBottomRightBounds());
+ break;
+ case DOCKED_BOTTOM:
+ case DOCKED_RIGHT:
+ retreatingLeash = leash2;
+ mTempRect.set(mSplitLayout.getBottomRightBounds());
+ advancingLeash = leash1;
+ mTempRect2.set(mSplitLayout.getTopLeftBounds());
+ break;
+ }
+ }
+ if (mParallaxType != PARALLAX_NONE
+ && retreatingLeash != null && advancingLeash != null) {
+ t.setPosition(retreatingLeash, mTempRect.left + mRetreatingSideParallax.x,
+ mTempRect.top + mRetreatingSideParallax.y);
+ // Transform the screen-based split bounds to surface-based crop bounds.
+ mTempRect.offsetTo(-mRetreatingSideParallax.x, -mRetreatingSideParallax.y);
+ t.setWindowCrop(retreatingLeash, mTempRect);
+
+ t.setPosition(advancingLeash, mTempRect2.left + mAdvancingSideParallax.x,
+ mTempRect2.top + mAdvancingSideParallax.y);
+ // Transform the screen-based split bounds to surface-based crop bounds.
+ mTempRect2.offsetTo(-mAdvancingSideParallax.x, -mAdvancingSideParallax.y);
+ t.setWindowCrop(advancingLeash, mTempRect2);
+ }
+ }
+
+ /**
+ * Called on every frame while the user is dragging the divider to dismiss an app or move it
+ * offscreen. Sets alpha and visibility on the two provided dim layers.
+ */
+ void adjustDimSurface(SurfaceControl.Transaction t,
+ SurfaceControl dimLayer1, SurfaceControl dimLayer2) {
+ SurfaceControl targetDimLayer;
+ SurfaceControl oppositeDimLayer;
+ switch (mDimmingSide) {
+ case DOCKED_TOP:
+ case DOCKED_LEFT:
+ targetDimLayer = dimLayer1;
+ oppositeDimLayer = dimLayer2;
+ break;
+ case DOCKED_BOTTOM:
+ case DOCKED_RIGHT:
+ targetDimLayer = dimLayer2;
+ oppositeDimLayer = dimLayer1;
+ break;
+ case DOCKED_INVALID:
+ default:
+ t.setAlpha(dimLayer1, 0).hide(dimLayer1);
+ t.setAlpha(dimLayer2, 0).hide(dimLayer2);
+ return;
+ }
+ t.setAlpha(targetDimLayer, mDimValue)
+ .setVisibility(targetDimLayer, mDimValue > 0.001f);
+ t.setAlpha(oppositeDimLayer, 0f)
+ .setVisibility(oppositeDimLayer, false);
+ }
+}
diff --git a/wmshell/src/com/android/wm/shell/common/split/SplitDecorManager.java b/wmshell/src/com/android/wm/shell/common/split/SplitDecorManager.java
index 5097ed8866..e39baeb566 100644
--- a/wmshell/src/com/android/wm/shell/common/split/SplitDecorManager.java
+++ b/wmshell/src/com/android/wm/shell/common/split/SplitDecorManager.java
@@ -23,7 +23,10 @@ import static android.view.WindowManager.LayoutParams.PRIVATE_FLAG_NO_MOVE_ANIMA
import static android.view.WindowManager.LayoutParams.PRIVATE_FLAG_TRUSTED_OVERLAY;
import static android.view.WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
-import static com.android.wm.shell.common.split.SplitScreenConstants.FADE_DURATION;
+import static com.android.wm.shell.common.split.SplitLayout.ANIMATING_BACK_APP_VEIL_LAYER;
+import static com.android.wm.shell.common.split.SplitLayout.ANIMATING_FRONT_APP_VEIL_LAYER;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.FADE_DURATION;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.VEIL_DELAY_DURATION;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
@@ -31,15 +34,16 @@ import android.animation.ValueAnimator;
import android.app.ActivityManager;
import android.content.Context;
import android.content.res.Configuration;
+import android.graphics.Color;
import android.graphics.PixelFormat;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.os.Binder;
+import android.util.Log;
import android.view.IWindow;
import android.view.LayoutInflater;
import android.view.SurfaceControl;
import android.view.SurfaceControlViewHost;
-import android.view.SurfaceSession;
import android.view.View;
import android.view.WindowManager;
import android.view.WindowlessWindowManager;
@@ -47,12 +51,15 @@ import android.widget.FrameLayout;
import android.widget.ImageView;
import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
import com.android.launcher3.icons.IconProvider;
import com.android.wm.shell.R;
import com.android.wm.shell.common.ScreenshotUtils;
import com.android.wm.shell.common.SurfaceUtils;
+import java.util.HashMap;
+import java.util.Map;
import java.util.function.Consumer;
/**
@@ -63,6 +70,13 @@ import java.util.function.Consumer;
* Currently, we show a veil when:
* a) Task is resizing down from a fullscreen window.
* b) Task is being stretched past its original bounds.
+ *
+ * Split root
+ * / | \
+ * Stage root Divider Stage root
+ * / \
+ * Task *this class*
+ *
*/
public class SplitDecorManager extends WindowlessWindowManager {
private static final String TAG = SplitDecorManager.class.getSimpleName();
@@ -70,11 +84,11 @@ public class SplitDecorManager extends WindowlessWindowManager {
private static final String GAP_BACKGROUND_SURFACE_NAME = "GapBackground";
private final IconProvider mIconProvider;
- private final SurfaceSession mSurfaceSession;
private Drawable mIcon;
- private ImageView mResizingIconView;
+ private ImageView mVeilIconView;
private SurfaceControlViewHost mViewHost;
+ /** The parent surface that this is attached to. Should be the stage root. */
private SurfaceControl mHostLeash;
private SurfaceControl mIconLeash;
private SurfaceControl mBackgroundLeash;
@@ -82,13 +96,14 @@ public class SplitDecorManager extends WindowlessWindowManager {
private SurfaceControl mScreenshot;
private boolean mShown;
- private boolean mIsResizing;
+ /** True if the task is going through some kind of transition (moving or changing size). */
+ private boolean mIsCurrentlyChanging;
/** The original bounds of the main task, captured at the beginning of a resize transition. */
private final Rect mOldMainBounds = new Rect();
/** The original bounds of the side task, captured at the beginning of a resize transition. */
private final Rect mOldSideBounds = new Rect();
/** The current bounds of the main task, mid-resize. */
- private final Rect mResizingBounds = new Rect();
+ private final Rect mInstantaneousBounds = new Rect();
private final Rect mTempRect = new Rect();
private ValueAnimator mFadeAnimator;
private ValueAnimator mScreenshotAnimator;
@@ -97,18 +112,24 @@ public class SplitDecorManager extends WindowlessWindowManager {
private int mOffsetX;
private int mOffsetY;
private int mRunningAnimationCount = 0;
+ /**
+ * Keeps track of all finish callbacks meant to be executed after all animations are finished.
+ * Do not add null values.
+ *
+ * Maps a callback to the value meant to be passed in the callback. Default value to be passed
+ * to the callback is false.
+ */
+ private final Map, Boolean> mAnimFinishCallbacks = new HashMap<>();
- public SplitDecorManager(Configuration configuration, IconProvider iconProvider,
- SurfaceSession surfaceSession) {
+ public SplitDecorManager(Configuration configuration, IconProvider iconProvider) {
super(configuration, null /* rootSurface */, null /* hostInputToken */);
mIconProvider = iconProvider;
- mSurfaceSession = surfaceSession;
}
@Override
protected SurfaceControl getParentSurface(IWindow window, WindowManager.LayoutParams attrs) {
// Can't set position for the ViewRootImpl SC directly. Create a leash to manipulate later.
- final SurfaceControl.Builder builder = new SurfaceControl.Builder(new SurfaceSession())
+ final SurfaceControl.Builder builder = new SurfaceControl.Builder()
.setContainerLayer()
.setName(TAG)
.setHidden(true)
@@ -133,7 +154,7 @@ public class SplitDecorManager extends WindowlessWindowManager {
mIconSize = context.getResources().getDimensionPixelSize(R.dimen.split_icon_size);
final FrameLayout rootLayout = (FrameLayout) LayoutInflater.from(context)
.inflate(R.layout.split_decor, null);
- mResizingIconView = rootLayout.findViewById(R.id.split_resizing_icon);
+ mVeilIconView = rootLayout.findViewById(R.id.split_resizing_icon);
final WindowManager.LayoutParams lp = new WindowManager.LayoutParams(
0 /* width */, 0 /* height */, TYPE_APPLICATION_OVERLAY,
@@ -190,28 +211,55 @@ public class SplitDecorManager extends WindowlessWindowManager {
}
mHostLeash = null;
mIcon = null;
- mResizingIconView = null;
- mIsResizing = false;
+ mVeilIconView = null;
+ mIsCurrentlyChanging = false;
mShown = false;
mOldMainBounds.setEmpty();
mOldSideBounds.setEmpty();
- mResizingBounds.setEmpty();
+ mInstantaneousBounds.setEmpty();
}
- /** Showing resizing hint. */
+ /**
+ * Called on every frame when an app is getting resized, and controls the showing & hiding of
+ * the app veil. IMPORTANT: There is one SplitDecorManager for each task, so if two tasks are
+ * getting resized simultaneously, this method is called in parallel on the other
+ * SplitDecorManager too. In general, we want to hide the app behind a veil when:
+ * a) the app is stretching past its original bounds (because app content layout doesn't
+ * update mid-stretch).
+ * b) the app is resizing down from fullscreen (because there is no parallax effect that
+ * makes every app look good in this scenario).
+ * In the world of flexible split, where apps can go offscreen, there is an exception to this:
+ * - We do NOT hide the app when it is going offscreen, even though it is technically
+ * getting larger and would qualify for condition (a). Instead, we use parallax to give
+ * the illusion that the app is getting pushed offscreen by the divider.
+ *
+ * @param resizingTask The task that is getting resized.
+ * @param newBounds The bounds that that we are updating this surface to. This can be an
+ * instantaneous bounds, just for a frame, during a drag or animation.
+ * @param sideBounds The bounds of the OPPOSITE task in the split layout. This is used just for
+ * reference/calculation, the surface of the other app won't be set here.
+ * @param displayBounds The bounds of the entire display.
+ * @param t The transaction on which these changes will be bundled.
+ * @param offsetX The x-translation applied to the task surface for parallax. Will be used to
+ * position the task screenshot and/or icon veil.
+ * @param offsetY The x-translation applied to the task surface for parallax. Will be used to
+ * position the task screenshot and/or icon veil.
+ * @param immediately {@code true} if the veil should transition in/out instantly, with no
+ * animation.
+ */
public void onResizing(ActivityManager.RunningTaskInfo resizingTask, Rect newBounds,
- Rect sideBounds, SurfaceControl.Transaction t, int offsetX, int offsetY,
- boolean immediately, float[] veilColor) {
- if (mResizingIconView == null) {
+ Rect sideBounds, Rect displayBounds, SurfaceControl.Transaction t, int offsetX,
+ int offsetY, boolean immediately) {
+ if (mVeilIconView == null) {
return;
}
- if (!mIsResizing) {
- mIsResizing = true;
+ if (!mIsCurrentlyChanging) {
+ mIsCurrentlyChanging = true;
mOldMainBounds.set(newBounds);
mOldSideBounds.set(sideBounds);
}
- mResizingBounds.set(newBounds);
+ mInstantaneousBounds.set(newBounds);
mOffsetX = offsetX;
mOffsetY = offsetY;
@@ -223,7 +271,10 @@ public class SplitDecorManager extends WindowlessWindowManager {
final boolean isStretchingPastOriginalBounds =
newBounds.width() > mOldMainBounds.width()
|| newBounds.height() > mOldMainBounds.height();
- final boolean showVeil = isResizingDownFromFullscreen || isStretchingPastOriginalBounds;
+ final boolean isFullyOnscreen = displayBounds.contains(newBounds);
+ boolean showVeil = isFullyOnscreen
+ && (isResizingDownFromFullscreen || isStretchingPastOriginalBounds);
+
final boolean update = showVeil != mShown;
if (update && mFadeAnimator != null && mFadeAnimator.isRunning()) {
// If we need to animate and animator still running, cancel it before we ensure both
@@ -233,8 +284,8 @@ public class SplitDecorManager extends WindowlessWindowManager {
if (mBackgroundLeash == null) {
mBackgroundLeash = SurfaceUtils.makeColorLayer(mHostLeash,
- RESIZING_BACKGROUND_SURFACE_NAME, mSurfaceSession);
- t.setColor(mBackgroundLeash, veilColor)
+ RESIZING_BACKGROUND_SURFACE_NAME);
+ t.setColor(mBackgroundLeash, getResizingBackgroundColor(resizingTask))
.setLayer(mBackgroundLeash, Integer.MAX_VALUE - 1);
}
@@ -243,9 +294,9 @@ public class SplitDecorManager extends WindowlessWindowManager {
final int left = isLandscape ? mOldMainBounds.width() : 0;
final int top = isLandscape ? 0 : mOldMainBounds.height();
mGapBackgroundLeash = SurfaceUtils.makeColorLayer(mHostLeash,
- GAP_BACKGROUND_SURFACE_NAME, mSurfaceSession);
+ GAP_BACKGROUND_SURFACE_NAME);
// Fill up another side bounds area.
- t.setColor(mGapBackgroundLeash, veilColor)
+ t.setColor(mGapBackgroundLeash, getResizingBackgroundColor(resizingTask))
.setLayer(mGapBackgroundLeash, Integer.MAX_VALUE - 2)
.setPosition(mGapBackgroundLeash, left, top)
.setWindowCrop(mGapBackgroundLeash, sideBounds.width(), sideBounds.height());
@@ -253,8 +304,8 @@ public class SplitDecorManager extends WindowlessWindowManager {
if (mIcon == null && resizingTask.topActivityInfo != null) {
mIcon = mIconProvider.getIcon(resizingTask.topActivityInfo);
- mResizingIconView.setImageDrawable(mIcon);
- mResizingIconView.setVisibility(View.VISIBLE);
+ mVeilIconView.setImageDrawable(mIcon);
+ mVeilIconView.setVisibility(View.VISIBLE);
WindowManager.LayoutParams lp =
(WindowManager.LayoutParams) mViewHost.getView().getLayoutParams();
@@ -274,18 +325,26 @@ public class SplitDecorManager extends WindowlessWindowManager {
t.setAlpha(mIconLeash, showVeil ? 1f : 0f);
t.setVisibility(mIconLeash, showVeil);
} else {
- startFadeAnimation(showVeil, false, null);
+ startFadeAnimation(
+ showVeil,
+ false /* releaseSurface */,
+ null /* finishedCallback */,
+ false /* addDelay */
+ );
}
mShown = showVeil;
}
}
/** Stops showing resizing hint. */
- public void onResized(SurfaceControl.Transaction t, Consumer animFinishedCallback) {
+ public void onResized(SurfaceControl.Transaction t,
+ @Nullable Consumer animFinishedCallback) {
if (mScreenshotAnimator != null && mScreenshotAnimator.isRunning()) {
mScreenshotAnimator.cancel();
}
-
+ if (animFinishedCallback != null) {
+ mAnimFinishCallbacks.put(animFinishedCallback, false);
+ }
if (mScreenshot != null) {
t.setPosition(mScreenshot, mOffsetX, mOffsetY);
@@ -310,28 +369,23 @@ public class SplitDecorManager extends WindowlessWindowManager {
animT.apply();
animT.close();
mScreenshot = null;
-
- if (mRunningAnimationCount == 0 && animFinishedCallback != null) {
- animFinishedCallback.accept(true);
- }
+ updateCallbackStatus(true /*callbackStatus*/, animFinishedCallback);
}
});
mScreenshotAnimator.start();
}
- if (mResizingIconView == null) {
- if (mRunningAnimationCount == 0 && animFinishedCallback != null) {
- animFinishedCallback.accept(false);
- }
+ if (mVeilIconView == null) {
+ updateCallbackStatus(false /*callbackStatus*/, animFinishedCallback);
return;
}
- mIsResizing = false;
+ mIsCurrentlyChanging = false;
mOffsetX = 0;
mOffsetY = 0;
mOldMainBounds.setEmpty();
mOldSideBounds.setEmpty();
- mResizingBounds.setEmpty();
+ mInstantaneousBounds.setEmpty();
if (mFadeAnimator != null && mFadeAnimator.isRunning()) {
if (!mShown) {
// If fade-out animation is running, just add release callback to it.
@@ -342,32 +396,146 @@ public class SplitDecorManager extends WindowlessWindowManager {
releaseDecor(finishT);
finishT.apply();
finishT.close();
- if (mRunningAnimationCount == 0 && animFinishedCallback != null) {
- animFinishedCallback.accept(true);
- }
+ updateCallbackStatus(true /*callbackStatus*/, animFinishedCallback);
}
});
return;
}
}
if (mShown) {
- fadeOutDecor(()-> {
- if (mRunningAnimationCount == 0 && animFinishedCallback != null) {
- animFinishedCallback.accept(true);
- }
- });
+ if (animFinishedCallback != null) {
+ // Update to return true. Will be executed when fadeOutDecor anims finish
+ mAnimFinishCallbacks.put(animFinishedCallback, true);
+ }
+ fadeOutDecor(()-> {}, false /* addDelay */);
} else {
// Decor surface is hidden so release it directly.
releaseDecor(t);
- if (mRunningAnimationCount == 0 && animFinishedCallback != null) {
- animFinishedCallback.accept(false);
+ updateCallbackStatus(false /*callbackStatus*/, animFinishedCallback);
+ }
+ }
+
+ /**
+ * Updates the value for the provided {@param callback} and optionally executes the callback
+ * list if no animations are in progress.
+ *
+ * @param callbackStatus the parameter that will be passed into the {@param callback}
+ * @param callback no-op if null, must be added to {@link #mAnimFinishCallbacks} prior to
+ * updating via this method
+ */
+ private void updateCallbackStatus(boolean callbackStatus,
+ @Nullable Consumer callback) {
+ if (callback == null) {
+ return;
+ }
+ if (mAnimFinishCallbacks.get(callback) == null) {
+ Log.e(TAG, "Finish callback not found!");
+ return;
+ }
+
+ mAnimFinishCallbacks.put(callback, callbackStatus);
+ if (mRunningAnimationCount != 0) {
+ // Not all animations finished, wait
+ return;
+ }
+
+ // Run all finish callbacks
+ for (Map.Entry, Boolean> c : mAnimFinishCallbacks.entrySet()) {
+ c.getKey().accept(c.getValue());
+ }
+ mAnimFinishCallbacks.clear();
+ }
+
+ /**
+ * Called (on every frame) when two split apps are swapping, and a veil is needed.
+ */
+ public void drawNextVeilFrameForSwapAnimation(ActivityManager.RunningTaskInfo resizingTask,
+ Rect newBounds, SurfaceControl.Transaction t, boolean isGoingBehind,
+ SurfaceControl leash, float iconOffsetX, float iconOffsetY) {
+ if (mVeilIconView == null) {
+ return;
+ }
+
+ if (!mIsCurrentlyChanging) {
+ mIsCurrentlyChanging = true;
+ }
+
+ mInstantaneousBounds.set(newBounds);
+ mOffsetX = (int) iconOffsetX;
+ mOffsetY = (int) iconOffsetY;
+
+ t.setLayer(leash, isGoingBehind
+ ? ANIMATING_BACK_APP_VEIL_LAYER
+ : ANIMATING_FRONT_APP_VEIL_LAYER);
+
+ if (!mShown) {
+ if (mFadeAnimator != null && mFadeAnimator.isRunning()) {
+ // Cancel mFadeAnimator if it is running
+ mFadeAnimator.cancel();
}
}
+
+ if (mBackgroundLeash == null) {
+ // Initialize background
+ mBackgroundLeash = SurfaceUtils.makeColorLayer(mHostLeash,
+ RESIZING_BACKGROUND_SURFACE_NAME);
+ t.setColor(mBackgroundLeash, getResizingBackgroundColor(resizingTask))
+ .setLayer(mBackgroundLeash, Integer.MAX_VALUE - 1);
+ }
+
+ if (mIcon == null && resizingTask.topActivityInfo != null) {
+ // Initialize icon
+ mIcon = mIconProvider.getIcon(resizingTask.topActivityInfo);
+ mVeilIconView.setImageDrawable(mIcon);
+ mVeilIconView.setVisibility(View.VISIBLE);
+
+ WindowManager.LayoutParams lp =
+ (WindowManager.LayoutParams) mViewHost.getView().getLayoutParams();
+ lp.width = mIconSize;
+ lp.height = mIconSize;
+ mViewHost.relayout(lp);
+
+ t.setLayer(mIconLeash, Integer.MAX_VALUE);
+ }
+
+ t.setPosition(mIconLeash,
+ newBounds.width() / 2 - mIconSize / 2 - mOffsetX,
+ newBounds.height() / 2 - mIconSize / 2 - mOffsetY);
+
+ // If this is the first frame, we need to trigger the veil's fade-in animation.
+ if (!mShown) {
+ startFadeAnimation(
+ true /* show */,
+ false /* releaseSurface */,
+ null /* finishedCallball */,
+ false /* addDelay */
+ );
+ mShown = true;
+ }
+ }
+
+ /** Called at the end of the swap animation. */
+ public void fadeOutVeilAndCleanUp(SurfaceControl.Transaction t) {
+ if (mVeilIconView == null) {
+ return;
+ }
+
+ // Recenter icon
+ t.setPosition(mIconLeash,
+ mInstantaneousBounds.width() / 2f - mIconSize / 2f,
+ mInstantaneousBounds.height() / 2f - mIconSize / 2f);
+
+ mIsCurrentlyChanging = false;
+ mOffsetX = 0;
+ mOffsetY = 0;
+ mInstantaneousBounds.setEmpty();
+
+ fadeOutDecor(() -> {}, true /* addDelay */);
}
/** Screenshot host leash and attach on it if meet some conditions */
public void screenshotIfNeeded(SurfaceControl.Transaction t) {
- if (!mShown && mIsResizing && !mOldMainBounds.equals(mResizingBounds)) {
+ if (!mShown && mIsCurrentlyChanging && !mOldMainBounds.equals(mInstantaneousBounds)) {
if (mScreenshotAnimator != null && mScreenshotAnimator.isRunning()) {
mScreenshotAnimator.cancel();
} else if (mScreenshot != null) {
@@ -385,7 +553,7 @@ public class SplitDecorManager extends WindowlessWindowManager {
public void setScreenshotIfNeeded(SurfaceControl screenshot, SurfaceControl.Transaction t) {
if (screenshot == null || !screenshot.isValid()) return;
- if (!mShown && mIsResizing && !mOldMainBounds.equals(mResizingBounds)) {
+ if (!mShown && mIsCurrentlyChanging && !mOldMainBounds.equals(mInstantaneousBounds)) {
if (mScreenshotAnimator != null && mScreenshotAnimator.isRunning()) {
mScreenshotAnimator.cancel();
} else if (mScreenshot != null) {
@@ -400,24 +568,41 @@ public class SplitDecorManager extends WindowlessWindowManager {
/** Fade-out decor surface with animation end callback, if decor is hidden, run the callback
* directly. */
- public void fadeOutDecor(Runnable finishedCallback) {
+ public void fadeOutDecor(Runnable finishedCallback, boolean addDelay) {
if (mShown) {
// If previous animation is running, just cancel it.
if (mFadeAnimator != null && mFadeAnimator.isRunning()) {
mFadeAnimator.cancel();
}
- startFadeAnimation(false /* show */, true, finishedCallback);
+ startFadeAnimation(
+ false /* show */, true /* releaseSurface */, finishedCallback, addDelay);
mShown = false;
} else {
if (finishedCallback != null) finishedCallback.run();
}
}
+ /**
+ * Fades the veil in or out. Called at the first frame of a movement or resize when a veil is
+ * needed (with show = true), and called again at the end (with show = false).
+ * @param addDelay If true, adds a short delay before fading out to get the app behind the veil
+ * time to redraw.
+ */
private void startFadeAnimation(boolean show, boolean releaseSurface,
- Runnable finishedCallback) {
+ @Nullable Runnable finishedCallback, boolean addDelay) {
final SurfaceControl.Transaction animT = new SurfaceControl.Transaction();
+ final Consumer wrappedFinishCallback = aBoolean -> {
+ if (finishedCallback != null) {
+ finishedCallback.run();
+ }
+ };
+ mAnimFinishCallbacks.put(wrappedFinishCallback, false);
+
mFadeAnimator = ValueAnimator.ofFloat(0f, 1f);
+ if (addDelay) {
+ mFadeAnimator.setStartDelay(VEIL_DELAY_DURATION);
+ }
mFadeAnimator.setDuration(FADE_DURATION);
mFadeAnimator.addUpdateListener(valueAnimator-> {
final float progress = (float) valueAnimator.getAnimatedValue();
@@ -458,10 +643,7 @@ public class SplitDecorManager extends WindowlessWindowManager {
}
animT.apply();
animT.close();
-
- if (mRunningAnimationCount == 0 && finishedCallback != null) {
- finishedCallback.run();
- }
+ updateCallbackStatus(true /*callbackStatus*/, wrappedFinishCallback);
}
});
mFadeAnimator.start();
@@ -480,10 +662,15 @@ public class SplitDecorManager extends WindowlessWindowManager {
}
if (mIcon != null) {
- mResizingIconView.setVisibility(View.GONE);
- mResizingIconView.setImageDrawable(null);
+ mVeilIconView.setVisibility(View.GONE);
+ mVeilIconView.setImageDrawable(null);
t.hide(mIconLeash);
mIcon = null;
}
}
+
+ private static float[] getResizingBackgroundColor(ActivityManager.RunningTaskInfo taskInfo) {
+ final int taskBgColor = taskInfo.taskDescription.getBackgroundColor();
+ return Color.valueOf(taskBgColor == -1 ? Color.WHITE : taskBgColor).getComponents();
+ }
}
diff --git a/wmshell/src/com/android/wm/shell/common/split/SplitLayout.java b/wmshell/src/com/android/wm/shell/common/split/SplitLayout.java
index 8ced76fd23..1b1f99cfca 100644
--- a/wmshell/src/com/android/wm/shell/common/split/SplitLayout.java
+++ b/wmshell/src/com/android/wm/shell/common/split/SplitLayout.java
@@ -18,21 +18,24 @@ package com.android.wm.shell.common.split;
import static android.content.res.Configuration.SCREEN_HEIGHT_DP_UNDEFINED;
import static android.content.res.Configuration.SCREEN_WIDTH_DP_UNDEFINED;
-import static android.view.WindowManager.DOCKED_BOTTOM;
-import static android.view.WindowManager.DOCKED_INVALID;
import static android.view.WindowManager.DOCKED_LEFT;
-import static android.view.WindowManager.DOCKED_RIGHT;
import static android.view.WindowManager.DOCKED_TOP;
import static com.android.internal.jank.InteractionJankMonitor.CUJ_SPLIT_SCREEN_DOUBLE_TAP_DIVIDER;
import static com.android.internal.jank.InteractionJankMonitor.CUJ_SPLIT_SCREEN_RESIZE;
-import static com.android.wm.shell.animation.Interpolators.DIM_INTERPOLATOR;
-import static com.android.wm.shell.animation.Interpolators.SLOWDOWN_INTERPOLATOR;
-import static com.android.wm.shell.common.split.SplitScreenConstants.SNAP_TO_END_AND_DISMISS;
-import static com.android.wm.shell.common.split.SplitScreenConstants.SNAP_TO_START_AND_DISMISS;
-import static com.android.wm.shell.common.split.SplitScreenConstants.SPLIT_POSITION_BOTTOM_OR_RIGHT;
-import static com.android.wm.shell.common.split.SplitScreenConstants.SPLIT_POSITION_TOP_OR_LEFT;
-import static com.android.wm.shell.common.split.SplitScreenConstants.SPLIT_POSITION_UNDEFINED;
+import static com.android.wm.shell.shared.animation.Interpolators.EMPHASIZED;
+import static com.android.wm.shell.shared.animation.Interpolators.FAST_OUT_SLOW_IN;
+import static com.android.wm.shell.shared.animation.Interpolators.LINEAR;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.ANIMATING_OFFSCREEN_TAP;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_2_10_90;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_2_90_10;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_3_10_45_45;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_3_45_45_10;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_END_AND_DISMISS;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_START_AND_DISMISS;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SPLIT_POSITION_BOTTOM_OR_RIGHT;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SPLIT_POSITION_TOP_OR_LEFT;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SPLIT_POSITION_UNDEFINED;
import static com.android.wm.shell.splitscreen.SplitScreenController.EXIT_REASON_DRAG_DIVIDER;
import android.animation.Animator;
@@ -44,36 +47,53 @@ import android.app.ActivityManager;
import android.content.Context;
import android.content.res.Configuration;
import android.content.res.Resources;
-import android.graphics.Point;
+import android.graphics.Insets;
import android.graphics.Rect;
+import android.os.Handler;
+import android.util.Log;
import android.view.Display;
+import android.view.InsetsController;
+import android.view.InsetsSource;
import android.view.InsetsSourceControl;
import android.view.InsetsState;
import android.view.RoundedCorner;
import android.view.SurfaceControl;
import android.view.WindowInsets;
import android.view.WindowManager;
+import android.view.animation.Interpolator;
+import android.view.animation.PathInterpolator;
import android.window.WindowContainerToken;
import android.window.WindowContainerTransaction;
import androidx.annotation.Nullable;
import com.android.internal.annotations.VisibleForTesting;
-import com.android.internal.protolog.common.ProtoLog;
+import com.android.internal.jank.InteractionJankMonitor;
+import com.android.internal.protolog.ProtoLog;
+import com.android.wm.shell.Flags;
import com.android.wm.shell.R;
import com.android.wm.shell.ShellTaskOrganizer;
-import com.android.wm.shell.animation.Interpolators;
import com.android.wm.shell.common.DisplayController;
import com.android.wm.shell.common.DisplayImeController;
import com.android.wm.shell.common.DisplayInsetsController;
import com.android.wm.shell.common.DisplayLayout;
-import com.android.wm.shell.common.InteractionJankMonitorUtils;
-import com.android.wm.shell.common.split.SplitScreenConstants.PersistentSnapPosition;
-import com.android.wm.shell.common.split.SplitScreenConstants.SnapPosition;
-import com.android.wm.shell.common.split.SplitScreenConstants.SplitPosition;
+import com.android.wm.shell.common.pip.PipUtils;
+import com.android.wm.shell.common.split.DividerSnapAlgorithm.SnapTarget;
+import com.android.wm.shell.common.split.SplitWindowManager.ParentContainerCallbacks;
import com.android.wm.shell.protolog.ShellProtoLogGroup;
+import com.android.wm.shell.shared.annotations.ShellMainThread;
+import com.android.wm.shell.shared.desktopmode.DesktopState;
+import com.android.wm.shell.shared.split.SplitScreenConstants.PersistentSnapPosition;
+import com.android.wm.shell.shared.split.SplitScreenConstants.SnapPosition;
+import com.android.wm.shell.shared.split.SplitScreenConstants.SplitPosition;
+import com.android.wm.shell.splitscreen.SplitStatusBarHider;
+import com.android.wm.shell.splitscreen.StageTaskListener;
+
+import com.google.android.msdl.domain.MSDLPlayer;
import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.List;
import java.util.function.Consumer;
/**
@@ -82,39 +102,97 @@ import java.util.function.Consumer;
*/
public final class SplitLayout implements DisplayInsetsController.OnInsetsChangedListener {
private static final String TAG = "SplitLayout";
+ /** No parallax effect when the user is dragging the divider */
public static final int PARALLAX_NONE = 0;
public static final int PARALLAX_DISMISSING = 1;
+ /** Parallax effect (center-aligned) when the user is dragging the divider */
public static final int PARALLAX_ALIGN_CENTER = 2;
+ /**
+ * A custom parallax effect for flexible split. When an app is being pushed/pulled offscreen,
+ * we use a specific parallax to give the impression that it is stuck to the divider.
+ * Otherwise, we fall back to PARALLAX_ALIGN_CENTER behavior.
+ */
+ public static final int PARALLAX_FLEX = 3;
public static final int FLING_RESIZE_DURATION = 250;
- private static final int FLING_SWITCH_DURATION = 350;
private static final int FLING_ENTER_DURATION = 450;
private static final int FLING_EXIT_DURATION = 450;
+ private static final int FLING_OFFSCREEN_DURATION = 500;
+
+ // Here are some (arbitrarily decided) layer definitions used during animations to make sure the
+ // layers stay in order. (During transitions, everything is reparented onto a transition root
+ // and can be freely relayered.)
+ public static final int ANIMATING_DIVIDER_LAYER = 0;
+ public static final int ANIMATING_FRONT_APP_VEIL_LAYER = ANIMATING_DIVIDER_LAYER + 20;
+ public static final int ANIMATING_FRONT_APP_LAYER = ANIMATING_DIVIDER_LAYER + 10;
+ public static final int ANIMATING_BACK_APP_VEIL_LAYER = ANIMATING_DIVIDER_LAYER - 10;
+ public static final int ANIMATING_BACK_APP_LAYER = ANIMATING_DIVIDER_LAYER - 20;
+ // The divider is on the split root, and is sibling with the stage roots. We want to keep it
+ // above the app stages.
+ public static final int RESTING_DIVIDER_LAYER = Integer.MAX_VALUE;
+ // The touch layer is on a stage root, and is sibling with things like the app activity itself
+ // and the app veil. We want it to be above all those.
+ public static final int RESTING_TOUCH_LAYER = Integer.MAX_VALUE;
+ // The dim layer is also on the stage root, and stays under the touch layer.
+ public static final int RESTING_DIM_LAYER = RESTING_TOUCH_LAYER - 1;
+
+ // Animation specs for the swap animation
+ private static final int SWAP_ANIMATION_TOTAL_DURATION = 500;
+ private static final float SWAP_ANIMATION_SHRINK_DURATION = 83;
+ private static final float SWAP_ANIMATION_SHRINK_MARGIN_DP = 14;
+ private static final Interpolator SHRINK_INTERPOLATOR =
+ new PathInterpolator(0.2f, 0f, 0f, 1f);
+ private static final Interpolator GROW_INTERPOLATOR =
+ new PathInterpolator(0.45f, 0f, 0.5f, 1f);
+ @ShellMainThread
+ private final Handler mHandler;
+ private final SplitStatusBarHider mStatusBarHider;
+
+ /** Singleton source of truth for the current state of split screen on this device. */
+ private final SplitState mSplitState;
+
+ /** A haptics controller that plays haptic effects. */
+ private final MSDLPlayer mMSDLPlayer;
private int mDividerWindowWidth;
private int mDividerInsets;
private int mDividerSize;
private final Rect mTempRect = new Rect();
+ private final Rect mTempRect2 = new Rect();
private final Rect mRootBounds = new Rect();
private final Rect mDividerBounds = new Rect();
- // Bounds1 final position should be always at top or left
- private final Rect mBounds1 = new Rect();
- // Bounds2 final position should be always at bottom or right
- private final Rect mBounds2 = new Rect();
+ /**
+ * A list of stage bounds, kept in order from top/left to bottom/right. These are the sizes of
+ * the app surfaces, not necessarily the same as the size of the rendered content.
+ * See {@link #mContentBounds}.
+ */
+ private final List mStageBounds = List.of(new Rect(), new Rect());
+ /**
+ * A list of app content bounds, kept in order from top/left to bottom/right. These are the
+ * sizes of the rendered app contents, not necessarily the same as the size of the drawn app
+ * surfaces. See {@link #mStageBounds}.
+ */
+ private final List mContentBounds = List.of(new Rect(), new Rect());
// The temp bounds outside of display bounds for side stage when split screen inactive to avoid
// flicker next time active split screen.
private final Rect mInvisibleBounds = new Rect();
- private final Rect mWinBounds1 = new Rect();
- private final Rect mWinBounds2 = new Rect();
+ /**
+ * Areas on the screen that the user can touch to shift the layout, bringing offscreen apps
+ * onscreen. If n apps are offscreen, there should be n such areas. Empty otherwise.
+ */
+ private final List mOffscreenTouchZones = new ArrayList<>();
private final SplitLayoutHandler mSplitLayoutHandler;
private final SplitWindowManager mSplitWindowManager;
private final DisplayController mDisplayController;
private final DisplayImeController mDisplayImeController;
+ private final ParentContainerCallbacks mParentContainerCallbacks;
private final ImePositionProcessor mImePositionProcessor;
private final ResizingEffectPolicy mSurfaceEffectPolicy;
private final ShellTaskOrganizer mTaskOrganizer;
private final InsetsState mInsetsState = new InsetsState();
+ private final DesktopState mDesktopState;
+ private Insets mPinnedTaskbarInsets = Insets.NONE;
private Context mContext;
@VisibleForTesting DividerSnapAlgorithm mDividerSnapAlgorithm;
@@ -131,14 +209,20 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
private final boolean mDimNonImeSide;
private final boolean mAllowLeftRightSplitInPortrait;
+ private final InteractionJankMonitor mInteractionJankMonitor;
private boolean mIsLeftRightSplit;
private ValueAnimator mDividerFlingAnimator;
+ private AnimatorSet mSwapAnimator;
public SplitLayout(String windowName, Context context, Configuration configuration,
SplitLayoutHandler splitLayoutHandler,
- SplitWindowManager.ParentContainerCallbacks parentContainerCallbacks,
+ ParentContainerCallbacks parentContainerCallbacks,
DisplayController displayController, DisplayImeController displayImeController,
- ShellTaskOrganizer taskOrganizer, int parallaxType) {
+ ShellTaskOrganizer taskOrganizer, int parallaxType, SplitState splitState,
+ @ShellMainThread Handler handler, SplitStatusBarHider statusBarHider,
+ DesktopState desktopState, MSDLPlayer msdlPlayer) {
+ mHandler = handler;
+ mStatusBarHider = statusBarHider;
mContext = context.createConfigurationContext(configuration);
mOrientation = configuration.orientation;
mRotation = configuration.windowConfiguration.getRotation();
@@ -147,22 +231,27 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
mSplitLayoutHandler = splitLayoutHandler;
mDisplayController = displayController;
mDisplayImeController = displayImeController;
+ mParentContainerCallbacks = parentContainerCallbacks;
mSplitWindowManager = new SplitWindowManager(windowName, mContext, configuration,
parentContainerCallbacks);
mTaskOrganizer = taskOrganizer;
mImePositionProcessor = new ImePositionProcessor(mContext.getDisplayId());
- mSurfaceEffectPolicy = new ResizingEffectPolicy(parallaxType);
+ mSurfaceEffectPolicy = new ResizingEffectPolicy(parallaxType, this);
+ mSplitState = splitState;
+ mDesktopState = desktopState;
+ mMSDLPlayer = msdlPlayer;
final Resources res = mContext.getResources();
mDimNonImeSide = res.getBoolean(R.bool.config_dimNonImeAttachedSide);
mAllowLeftRightSplitInPortrait = SplitScreenUtils.allowLeftRightSplitInPortrait(res);
mIsLeftRightSplit = SplitScreenUtils.isLeftRightSplit(mAllowLeftRightSplitInPortrait,
configuration);
-
+ statusBarHider.onLeftRightSplitUpdated(mIsLeftRightSplit);
updateDividerConfig(mContext);
mRootBounds.set(configuration.windowConfiguration.getBounds());
- mDividerSnapAlgorithm = getSnapAlgorithm(mContext, mRootBounds);
+ updateLayouts();
+ mInteractionJankMonitor = InteractionJankMonitor.getInstance();
resetDividerPosition();
updateInvisibleRect();
}
@@ -187,26 +276,26 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
mDividerWindowWidth = mDividerSize + 2 * mDividerInsets;
}
- /** Gets bounds of the primary split with screen based coordinate. */
- public Rect getBounds1() {
- return new Rect(mBounds1);
+ /** Gets the bounds of the top/left app in screen-based coordinates. */
+ public Rect getTopLeftBounds() {
+ return mStageBounds.getFirst();
}
- /** Gets bounds of the primary split with parent based coordinate. */
- public Rect getRefBounds1() {
- Rect outBounds = getBounds1();
+ /** Gets the bounds of the bottom/right app in screen-based coordinates. */
+ public Rect getBottomRightBounds() {
+ return mStageBounds.getLast();
+ }
+
+ /** Gets the bounds of the top/left app in parent-based coordinates. */
+ public Rect getTopLeftRefBounds() {
+ Rect outBounds = getTopLeftBounds();
outBounds.offset(-mRootBounds.left, -mRootBounds.top);
return outBounds;
}
- /** Gets bounds of the secondary split with screen based coordinate. */
- public Rect getBounds2() {
- return new Rect(mBounds2);
- }
-
- /** Gets bounds of the secondary split with parent based coordinate. */
- public Rect getRefBounds2() {
- final Rect outBounds = getBounds2();
+ /** Gets the bounds of the bottom/right app in parent-based coordinates. */
+ public Rect getBottomRightRefBounds() {
+ Rect outBounds = getBottomRightBounds();
outBounds.offset(-mRootBounds.left, -mRootBounds.top);
return outBounds;
}
@@ -216,51 +305,74 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
return new Rect(mRootBounds);
}
- /** Gets bounds of divider window with screen based coordinate. */
+ /** Copies the top/left bounds to the provided Rect (screen-based coordinates). */
+ public void copyTopLeftBounds(Rect rect) {
+ rect.set(getTopLeftBounds());
+ }
+
+ /** Copies the top/left bounds to the provided Rect (parent-based coordinates). */
+ public void copyTopLeftRefBounds(Rect rect) {
+ copyTopLeftBounds(rect);
+ rect.offset(-mRootBounds.left, -mRootBounds.top);
+ }
+
+ /** Copies the bottom/right bounds to the provided Rect (screen-based coordinates). */
+ public void copyBottomRightBounds(Rect rect) {
+ rect.set(getBottomRightBounds());
+ }
+
+ /** Copies the bottom/right bounds to the provided Rect (parent-based coordinates). */
+ public void copyBottomRightRefBounds(Rect rect) {
+ copyBottomRightBounds(rect);
+ rect.offset(-mRootBounds.left, -mRootBounds.top);
+ }
+
+ /**
+ * Gets the content bounds of the top/left app (the bounds of where the app contents would be
+ * drawn). Might be larger than the available surface space.
+ */
+ public Rect getTopLeftContentBounds() {
+ return mContentBounds.getFirst();
+ }
+
+ /**
+ * Gets the content bounds of the bottom/right app (the bounds of where the app contents would
+ * be drawn). Might be larger than the available surface space.
+ */
+ public Rect getBottomRightContentBounds() {
+ return mContentBounds.getLast();
+ }
+
+ /**
+ * Gets the bounds of divider window, in screen-based coordinates. This is not the visible
+ * bounds you see on screen, but the actual behind-the-scenes window bounds, which is larger.
+ */
public Rect getDividerBounds() {
return new Rect(mDividerBounds);
}
- /** Gets bounds of divider window with parent based coordinate. */
+ /**
+ * Gets the bounds of divider window, in parent-based coordinates. This is not the visible
+ * bounds you see on screen, but the actual behind-the-scenes window bounds, which is larger.
+ */
public Rect getRefDividerBounds() {
final Rect outBounds = getDividerBounds();
outBounds.offset(-mRootBounds.left, -mRootBounds.top);
return outBounds;
}
- /** Gets bounds of the primary split with screen based coordinate on the param Rect. */
- public void getBounds1(Rect rect) {
- rect.set(mBounds1);
- }
-
- /** Gets bounds of the primary split with parent based coordinate on the param Rect. */
- public void getRefBounds1(Rect rect) {
- getBounds1(rect);
- rect.offset(-mRootBounds.left, -mRootBounds.top);
- }
-
- /** Gets bounds of the secondary split with screen based coordinate on the param Rect. */
- public void getBounds2(Rect rect) {
- rect.set(mBounds2);
- }
-
- /** Gets bounds of the secondary split with parent based coordinate on the param Rect. */
- public void getRefBounds2(Rect rect) {
- getBounds2(rect);
- rect.offset(-mRootBounds.left, -mRootBounds.top);
- }
-
- /** Gets root bounds of the whole split layout on the param Rect. */
- public void getRootBounds(Rect rect) {
- rect.set(mRootBounds);
- }
-
- /** Gets bounds of divider window with screen based coordinate on the param Rect. */
+ /**
+ * Gets the bounds of divider window, in screen-based coordinates. This is not the visible
+ * bounds you see on screen, but the actual behind-the-scenes window bounds, which is larger.
+ */
public void getDividerBounds(Rect rect) {
rect.set(mDividerBounds);
}
- /** Gets bounds of divider window with parent based coordinate on the param Rect. */
+ /**
+ * Gets the bounds of divider window, in parent-based coordinates. This is not the visible
+ * bounds you see on screen, but the actual behind-the-scenes window bounds, which is larger.
+ */
public void getRefDividerBounds(Rect rect) {
getDividerBounds(rect);
rect.offset(-mRootBounds.left, -mRootBounds.top);
@@ -282,6 +394,11 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
return mDividerPosition;
}
+ /** Returns the haptic player used in this class. */
+ public MSDLPlayer getHapticPlayer() {
+ return mMSDLPlayer;
+ }
+
/**
* Finds the {@link SnapPosition} nearest to the current divider position.
*/
@@ -289,13 +406,28 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
return mDividerSnapAlgorithm.calculateNearestSnapPosition(mDividerPosition);
}
+ /** Updates the {@link SplitState} using the current divider position. */
+ public void updateStateWithCurrentPosition() {
+ mSplitState.set(calculateCurrentSnapPosition());
+ }
+
/**
* Returns the divider position as a fraction from 0 to 1.
*/
public float getDividerPositionAsFraction() {
- return Math.min(1f, Math.max(0f, mIsLeftRightSplit
- ? (float) ((mBounds1.right + mBounds2.left) / 2f) / mBounds2.right
- : (float) ((mBounds1.bottom + mBounds2.top) / 2f) / mBounds2.bottom));
+ if (Flags.enableFlexibleTwoAppSplit()) {
+ return Math.min(1f, Math.max(0f, mIsLeftRightSplit
+ ? (getTopLeftBounds().right + getBottomRightBounds().left) / 2f
+ / getDisplayWidth()
+ : (getTopLeftBounds().bottom + getBottomRightBounds().top) / 2f
+ / getDisplayHeight()));
+ } else {
+ return Math.min(1f, Math.max(0f, mIsLeftRightSplit
+ ? (float) ((getTopLeftBounds().right + getBottomRightBounds().left) / 2f)
+ / getBottomRightBounds().right
+ : (float) ((getTopLeftBounds().bottom + getBottomRightBounds().top) / 2f)
+ / getBottomRightBounds().bottom));
+ }
}
private void updateInvisibleRect() {
@@ -306,6 +438,59 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
mIsLeftRightSplit ? 0 : mRootBounds.bottom);
}
+ /**
+ * (Re)calculates and activates any needed touch zones, so the user can tap them and retrieve
+ * offscreen apps.
+ */
+ public void populateTouchZones() {
+ if (!Flags.enableFlexibleTwoAppSplit()) {
+ return;
+ }
+
+ if (!mOffscreenTouchZones.isEmpty()) {
+ removeTouchZones();
+ }
+
+ int currentPosition = mSplitState.get();
+ // TODO (b/349828130): Can delete this warning after brief soak time.
+ if (currentPosition != calculateCurrentSnapPosition()) {
+ Log.wtf(TAG, "SplitState is " + mSplitState.get()
+ + ", expected " + calculateCurrentSnapPosition());
+ }
+
+ switch (currentPosition) {
+ case SNAP_TO_2_10_90:
+ case SNAP_TO_3_10_45_45:
+ mOffscreenTouchZones.add(new OffscreenTouchZone(true /* isTopLeft */,
+ () -> flingDividerToOtherSide(currentPosition)));
+ break;
+ case SNAP_TO_2_90_10:
+ case SNAP_TO_3_45_45_10:
+ mOffscreenTouchZones.add(new OffscreenTouchZone(false /* isTopLeft */,
+ () -> flingDividerToOtherSide(currentPosition)));
+ break;
+ }
+
+ mOffscreenTouchZones.forEach(mParentContainerCallbacks::inflateOnStageRoot);
+ }
+
+ /** Removes all touch zones. */
+ public void removeTouchZones() {
+ if (!Flags.enableFlexibleTwoAppSplit()) {
+ return;
+ }
+
+ // TODO (b/349828130): It would be good to reuse a Transaction from StageCoordinator's
+ // mTransactionPool here, but passing it through SplitLayout and specifically
+ // SplitLayout.release() is complicated because that function is purposely called with a
+ // null value sometimes. When that function is refactored, we should also pass the
+ // Transaction in here.
+ SurfaceControl.Transaction t = new SurfaceControl.Transaction();
+ mOffscreenTouchZones.forEach(touchZone -> touchZone.release(t));
+ t.apply();
+ mOffscreenTouchZones.clear();
+ }
+
/** Applies new configuration, returns {@code false} if there's no effect to the layout. */
public boolean updateConfiguration(Configuration configuration) {
// Update the split bounds when necessary. Besides root bounds changed, split bounds need to
@@ -339,7 +524,8 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
mIsLargeScreen = configuration.smallestScreenWidthDp >= 600;
mIsLeftRightSplit = SplitScreenUtils.isLeftRightSplit(mAllowLeftRightSplitInPortrait,
configuration);
- mDividerSnapAlgorithm = getSnapAlgorithm(mContext, mRootBounds);
+ mStatusBarHider.onLeftRightSplitUpdated(mIsLeftRightSplit);
+ updateLayouts();
updateDividerConfig(mContext);
initDividerPosition(mTempRect, wasLeftRightSplit);
updateInvisibleRect();
@@ -367,7 +553,9 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
mRootBounds.set(tmpRect);
mIsLeftRightSplit = SplitScreenUtils.isLeftRightSplit(mAllowLeftRightSplitInPortrait,
mIsLargeScreen, mRootBounds.width() >= mRootBounds.height());
- mDividerSnapAlgorithm = getSnapAlgorithm(mContext, mRootBounds);
+ mStatusBarHider.onLeftRightSplitUpdated(mIsLeftRightSplit);
+
+ updateLayouts();
initDividerPosition(mTempRect, wasLeftRightSplit);
}
@@ -389,10 +577,16 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
}
private void updateBounds(int position) {
- updateBounds(position, mBounds1, mBounds2, mDividerBounds, true /* setEffectBounds */);
+ updateBounds(position, getTopLeftBounds(), getBottomRightBounds(), mDividerBounds,
+ true /* setEffectBounds */);
}
- /** Updates recording bounds of divider window and both of the splits. */
+ /**
+ * Updates the bounds of the divider window and both split apps.
+ * @param position The left/top edge of the visual divider, where the edge of app A meets the
+ * divider. Not to be confused with the actual divider surface, which is larger
+ * and overlaps the apps a bit.
+ */
private void updateBounds(int position, Rect bounds1, Rect bounds2, Rect dividerBounds,
boolean setEffectBounds) {
dividerBounds.set(mRootBounds);
@@ -404,17 +598,39 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
dividerBounds.right = dividerBounds.left + mDividerWindowWidth;
bounds1.right = position;
bounds2.left = bounds1.right + mDividerSize;
+
+ // For flexible split, expand app offscreen as well
+ if (mDividerSnapAlgorithm.areOffscreenRatiosSupported()) {
+ int distanceToCenter = position - mDividerSnapAlgorithm.getMiddleTarget().position;
+ if (position < mDividerSnapAlgorithm.getMiddleTarget().position) {
+ bounds1.left += distanceToCenter * 2;
+ } else {
+ bounds2.right += distanceToCenter * 2;
+ }
+ }
+
} else {
position += mRootBounds.top;
dividerBounds.top = position - mDividerInsets;
dividerBounds.bottom = dividerBounds.top + mDividerWindowWidth;
bounds1.bottom = position;
bounds2.top = bounds1.bottom + mDividerSize;
+
+ // For flexible split, expand app offscreen as well
+ if (mDividerSnapAlgorithm.areOffscreenRatiosSupported()) {
+ int distanceToCenter = position - mDividerSnapAlgorithm.getMiddleTarget().position;
+ if (position < mDividerSnapAlgorithm.getMiddleTarget().position) {
+ bounds1.top += distanceToCenter * 2;
+ } else {
+ bounds2.bottom += distanceToCenter * 2;
+ }
+ }
}
DockedDividerUtils.sanitizeStackBounds(bounds1, true /** topLeft */);
DockedDividerUtils.sanitizeStackBounds(bounds2, false /** topLeft */);
if (setEffectBounds) {
- mSurfaceEffectPolicy.applyDividerPosition(position, mIsLeftRightSplit);
+ mSurfaceEffectPolicy.applyDividerPosition(
+ position, mIsLeftRightSplit, mDividerSnapAlgorithm, mSplitState);
}
}
@@ -422,7 +638,8 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
public void init() {
if (mInitialized) return;
mInitialized = true;
- mSplitWindowManager.init(this, mInsetsState, false /* isRestoring */);
+ mSplitWindowManager.init(this, mInsetsState, false /* isRestoring */, mDesktopState);
+ populateTouchZones();
mDisplayImeController.addPositionProcessor(mImePositionProcessor);
}
@@ -431,6 +648,7 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
if (!mInitialized) return;
mInitialized = false;
mSplitWindowManager.release(t);
+ removeTouchZones();
mDisplayImeController.removePositionProcessor(mImePositionProcessor);
mImePositionProcessor.reset();
if (mDividerFlingAnimator != null) {
@@ -453,7 +671,8 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
if (resetImePosition) {
mImePositionProcessor.reset();
}
- mSplitWindowManager.init(this, mInsetsState, true /* isRestoring */);
+ mSplitWindowManager.init(this, mInsetsState, true /* isRestoring */, mDesktopState);
+ populateTouchZones();
// Update the surface positions again after recreating the divider in case nothing else
// triggers it
mSplitLayoutHandler.onLayoutPositionChanging(SplitLayout.this);
@@ -462,6 +681,7 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
@Override
public void insetsChanged(InsetsState insetsState) {
mInsetsState.set(insetsState);
+
if (!mInitialized) {
return;
}
@@ -470,9 +690,45 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
// flicker.
return;
}
+
+ // Check to see if insets changed in such a way that the divider needs to be animated to
+ // a new position. (We only do this when switching to pinned taskbar mode and back).
+ Insets pinnedTaskbarInsets = calculatePinnedTaskbarInsets(insetsState);
+ if (!mPinnedTaskbarInsets.equals(pinnedTaskbarInsets)) {
+ mPinnedTaskbarInsets = pinnedTaskbarInsets;
+ // Refresh the DividerSnapAlgorithm.
+ updateLayouts();
+ // If the divider is no longer placed on a snap point, animate it to the nearest one
+ DividerSnapAlgorithm.SnapTarget snapTarget =
+ findSnapTarget(mDividerPosition, 0, false /* hardDismiss */);
+ if (snapTarget.position != mDividerPosition) {
+ snapToTarget(mDividerPosition, snapTarget,
+ InsetsController.ANIMATION_DURATION_RESIZE,
+ InsetsController.RESIZE_INTERPOLATOR);
+ }
+ }
+
mSplitWindowManager.onInsetsChanged(insetsState);
}
+ /**
+ * Calculates the insets that might trigger a divider algorithm recalculation.
+ */
+ private Insets calculatePinnedTaskbarInsets(InsetsState insetsState) {
+ for (int i = insetsState.sourceSize() - 1; i >= 0; i--) {
+ final InsetsSource source = insetsState.sourceAt(i);
+ // If Taskbar is pinned...
+ if (source.getType() == WindowInsets.Type.navigationBars()
+ && source.hasFlags(InsetsSource.FLAG_INSETS_ROUNDED_CORNER)) {
+ // Return Insets representing the pinned taskbar state.
+ return source.calculateVisibleInsets(mRootBounds, mRootBounds);
+ }
+ }
+
+ // Else, divider can calculate based on the full display.
+ return Insets.NONE;
+ }
+
@Override
public void insetsControlChanged(InsetsState insetsState,
InsetsSourceControl[] activeControls) {
@@ -498,8 +754,9 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
*/
void updateDividerBounds(int position, boolean shouldUseParallaxEffect) {
updateBounds(position);
- mSplitLayoutHandler.onLayoutSizeChanging(this, mSurfaceEffectPolicy.mParallaxOffset.x,
- mSurfaceEffectPolicy.mParallaxOffset.y, shouldUseParallaxEffect);
+ mSplitLayoutHandler.onLayoutSizeChanging(this,
+ mSurfaceEffectPolicy.mRetreatingSideParallax.x,
+ mSurfaceEffectPolicy.mRetreatingSideParallax.y, shouldUseParallaxEffect);
}
void setDividerPosition(int position, boolean applyLayoutChange) {
@@ -515,7 +772,7 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
* to middle position if the provided SnapTarget is not supported.
*/
public void setDivideRatio(@PersistentSnapPosition int snapPosition) {
- final DividerSnapAlgorithm.SnapTarget snapTarget = mDividerSnapAlgorithm.findSnapTarget(
+ final SnapTarget snapTarget = mDividerSnapAlgorithm.findSnapTarget(
snapPosition);
setDividerPosition(snapTarget != null
@@ -530,8 +787,8 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
updateBounds(mDividerPosition);
mWinToken1 = null;
mWinToken2 = null;
- mWinBounds1.setEmpty();
- mWinBounds2.setEmpty();
+ getTopLeftContentBounds().setEmpty();
+ getBottomRightContentBounds().setEmpty();
}
/**
@@ -549,49 +806,80 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
* Sets new divider position and updates bounds correspondingly. Notifies listener if the new
* target indicates dismissing split.
*/
- public void snapToTarget(int currentPosition, DividerSnapAlgorithm.SnapTarget snapTarget) {
+ public void snapToTarget(int currentPosition, SnapTarget snapTarget, int duration,
+ Interpolator interpolator) {
switch (snapTarget.snapPosition) {
case SNAP_TO_START_AND_DISMISS:
- flingDividerPosition(currentPosition, snapTarget.position, FLING_RESIZE_DURATION,
+ flingDividerPosition(currentPosition, snapTarget.position, duration, interpolator,
() -> mSplitLayoutHandler.onSnappedToDismiss(false /* bottomOrRight */,
EXIT_REASON_DRAG_DIVIDER));
break;
case SNAP_TO_END_AND_DISMISS:
- flingDividerPosition(currentPosition, snapTarget.position, FLING_RESIZE_DURATION,
+ flingDividerPosition(currentPosition, snapTarget.position, duration, interpolator,
() -> mSplitLayoutHandler.onSnappedToDismiss(true /* bottomOrRight */,
EXIT_REASON_DRAG_DIVIDER));
break;
default:
- flingDividerPosition(currentPosition, snapTarget.position, FLING_RESIZE_DURATION,
- () -> setDividerPosition(snapTarget.position, true /* applyLayoutChange */));
+ flingDividerPosition(currentPosition, snapTarget.position, duration, interpolator,
+ () -> {
+ setDividerPosition(snapTarget.position, true /* applyLayoutChange */);
+ mSplitState.set(snapTarget.snapPosition);
+ });
break;
}
}
+ /**
+ * Same as {@link #snapToTarget(int, SnapTarget, int, Interpolator)}, with default animation
+ * duration and interpolator.
+ */
+ public void snapToTarget(int currentPosition, SnapTarget snapTarget) {
+ snapToTarget(currentPosition, snapTarget, FLING_RESIZE_DURATION,
+ FAST_OUT_SLOW_IN);
+ }
+
void onStartDragging() {
- InteractionJankMonitorUtils.beginTracing(CUJ_SPLIT_SCREEN_RESIZE, mContext,
- getDividerLeash(), null /* tag */);
+ mInteractionJankMonitor.begin(getDividerLeash(), mContext, mHandler,
+ CUJ_SPLIT_SCREEN_RESIZE);
}
void onDraggingCancelled() {
- InteractionJankMonitorUtils.cancelTracing(CUJ_SPLIT_SCREEN_RESIZE);
+ mInteractionJankMonitor.cancel(CUJ_SPLIT_SCREEN_RESIZE);
}
void onDoubleTappedDivider() {
+ if (isCurrentlySwapping()) {
+ return;
+ }
+
mSplitLayoutHandler.onDoubleTappedDivider();
}
/**
- * Returns {@link DividerSnapAlgorithm.SnapTarget} which matches passing position and velocity.
+ * Returns {@link SnapTarget} which matches passing position and velocity.
* If hardDismiss is set to {@code true}, it will be harder to reach dismiss target.
*/
- public DividerSnapAlgorithm.SnapTarget findSnapTarget(int position, float velocity,
+ public SnapTarget findSnapTarget(int position, float velocity,
boolean hardDismiss) {
return mDividerSnapAlgorithm.calculateSnapTarget(position, velocity, hardDismiss);
}
- private DividerSnapAlgorithm getSnapAlgorithm(Context context, Rect rootBounds) {
- final Rect insets = getDisplayStableInsets(context);
+ /**
+ * (Re)calculates the split screen logic for this particular display/orientation. Refreshes the
+ * DividerSnapAlgorithm, which controls divider snap points, and populates a map in SplitState
+ * with bounds for all valid split layouts.
+ */
+ private void updateLayouts() {
+ // Update SplitState map
+
+ if (Flags.enableFlexibleTwoAppSplit()) {
+ mSplitState.populateLayouts(
+ mRootBounds, mDividerSize, mIsLeftRightSplit, mPinnedTaskbarInsets.toRect());
+ }
+
+ // Get new DividerSnapAlgorithm
+
+ final Rect insets = getDisplayStableInsets(mContext);
// Make split axis insets value same as the larger one to avoid bounds1 and bounds2
// have difference for avoiding size-compat mode when switching unresizable apps in
@@ -601,13 +889,14 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
insets.set(insets.left, largerInsets, insets.right, largerInsets);
}
- return new DividerSnapAlgorithm(
- context.getResources(),
- rootBounds.width(),
- rootBounds.height(),
+ mDividerSnapAlgorithm = new DividerSnapAlgorithm(
+ mContext.getResources(),
+ mRootBounds.width(),
+ mRootBounds.height(),
mDividerSize,
- !mIsLeftRightSplit,
+ mIsLeftRightSplit,
insets,
+ mPinnedTaskbarInsets.toRect(),
mIsLeftRightSplit ? DOCKED_LEFT : DOCKED_TOP /* dockSide */);
}
@@ -615,30 +904,53 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
public void flingDividerToDismiss(boolean toEnd, int reason) {
final int target = toEnd ? mDividerSnapAlgorithm.getDismissEndTarget().position
: mDividerSnapAlgorithm.getDismissStartTarget().position;
- flingDividerPosition(getDividerPosition(), target, FLING_EXIT_DURATION,
+ flingDividerPosition(getDividerPosition(), target, FLING_EXIT_DURATION, FAST_OUT_SLOW_IN,
() -> mSplitLayoutHandler.onSnappedToDismiss(toEnd, reason));
}
/** Fling divider from current position to center position. */
public void flingDividerToCenter(@Nullable Runnable finishCallback) {
- final int pos = mDividerSnapAlgorithm.getMiddleTarget().position;
- flingDividerPosition(getDividerPosition(), pos, FLING_ENTER_DURATION,
+ final SnapTarget target = mDividerSnapAlgorithm.getMiddleTarget();
+ final int pos = target.position;
+ flingDividerPosition(getDividerPosition(), pos, FLING_ENTER_DURATION, FAST_OUT_SLOW_IN,
() -> {
setDividerPosition(pos, true /* applyLayoutChange */);
+ mSplitState.set(target.snapPosition);
if (finishCallback != null) {
finishCallback.run();
}
});
}
+ /**
+ * Moves the divider to the other side of the screen. Does nothing if the divider is in the
+ * center.
+ * TODO (b/349828130): Currently only supports the two-app case. For n-apps,
+ * DividerSnapAlgorithm will need to be refactored, and this function will change as well.
+ */
+ public void flingDividerToOtherSide(@PersistentSnapPosition int currentSnapPosition) {
+ // If a fling animation is already running, just return.
+ if (mDividerFlingAnimator != null) return;
+
+ mSplitState.set(ANIMATING_OFFSCREEN_TAP);
+ switch (currentSnapPosition) {
+ case SNAP_TO_2_10_90 ->
+ snapToTarget(mDividerPosition, mDividerSnapAlgorithm.getLastSplitTarget(),
+ FLING_OFFSCREEN_DURATION, EMPHASIZED);
+ case SNAP_TO_2_90_10 ->
+ snapToTarget(mDividerPosition, mDividerSnapAlgorithm.getFirstSplitTarget(),
+ FLING_OFFSCREEN_DURATION, EMPHASIZED);
+ }
+ }
+
@VisibleForTesting
- void flingDividerPosition(int from, int to, int duration,
+ void flingDividerPosition(int from, int to, int duration, Interpolator interpolator,
@Nullable Runnable flingFinishedCallback) {
if (from == to) {
if (flingFinishedCallback != null) {
flingFinishedCallback.run();
}
- InteractionJankMonitorUtils.endTracing(
+ mInteractionJankMonitor.end(
CUJ_SPLIT_SCREEN_RESIZE);
return;
}
@@ -650,65 +962,100 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
mDividerFlingAnimator = ValueAnimator
.ofInt(from, to)
.setDuration(duration);
- mDividerFlingAnimator.setInterpolator(Interpolators.FAST_OUT_SLOW_IN);
+ mDividerFlingAnimator.setInterpolator(interpolator);
+
+ // If the divider is being physically controlled by the user, we use a cool parallax effect
+ // on the task windows. So if this "snap" animation is an extension of a user-controlled
+ // movement, we pass in true here to continue the parallax effect smoothly.
+ boolean isBeingMovedByUser = mSplitWindowManager.getDividerView() != null
+ && mSplitWindowManager.getDividerView().isMoving();
+ boolean isAnimatingOffscreenTap = mSplitState.get() == ANIMATING_OFFSCREEN_TAP;
+ boolean needsParallax = isBeingMovedByUser || isAnimatingOffscreenTap;
+
mDividerFlingAnimator.addUpdateListener(
animation -> updateDividerBounds(
- (int) animation.getAnimatedValue(), false /* shouldUseParallaxEffect */)
+ (int) animation.getAnimatedValue(),
+ needsParallax /* shouldUseParallaxEffect */
+ )
);
mDividerFlingAnimator.addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationStart(Animator animation) {
+ super.onAnimationStart(animation);
+ mParentContainerCallbacks.onSplitLayoutAnimating(true /*animating*/);
+ }
+
@Override
public void onAnimationEnd(Animator animation) {
if (flingFinishedCallback != null) {
flingFinishedCallback.run();
}
- InteractionJankMonitorUtils.endTracing(
+ mInteractionJankMonitor.end(
CUJ_SPLIT_SCREEN_RESIZE);
mDividerFlingAnimator = null;
+ mParentContainerCallbacks.onSplitLayoutAnimating(false /*animating*/);
}
@Override
public void onAnimationCancel(Animator animation) {
mDividerFlingAnimator = null;
+ mParentContainerCallbacks.onSplitLayoutAnimating(false /*animating*/);
}
});
mDividerFlingAnimator.start();
}
/** Switch both surface position with animation. */
- public void splitSwitching(SurfaceControl.Transaction t, SurfaceControl leash1,
- SurfaceControl leash2, Consumer finishCallback) {
+ public void playSwapAnimation(SurfaceControl.Transaction t, StageTaskListener topLeftStage,
+ StageTaskListener bottomRightStage, Consumer finishCallback) {
final Rect insets = getDisplayStableInsets(mContext);
+ // If we have insets in the direction of the swap, the animation won't look correct because
+ // window contents will shift and redraw again at the end. So we show a veil to hide that.
insets.set(mIsLeftRightSplit ? insets.left : 0, mIsLeftRightSplit ? 0 : insets.top,
mIsLeftRightSplit ? insets.right : 0, mIsLeftRightSplit ? 0 : insets.bottom);
+ final boolean shouldVeil =
+ insets.left != 0 || insets.top != 0 || insets.right != 0 || insets.bottom != 0;
+
+ // Find the "left/top"-most position of the app surface -- usually 0, but sometimes negative
+ // if the left/top app is offscreen.
+ int leftTop = 0;
+ if (Flags.enableFlexibleTwoAppSplit()) {
+ leftTop = mIsLeftRightSplit ? getTopLeftBounds().left : getTopLeftBounds().top;
+ }
final int dividerPos = mDividerSnapAlgorithm.calculateNonDismissingSnapTarget(
- mIsLeftRightSplit ? mBounds2.width() : mBounds2.height()).position;
- final Rect distBounds1 = new Rect();
- final Rect distBounds2 = new Rect();
- final Rect distDividerBounds = new Rect();
- // Compute dist bounds.
- updateBounds(dividerPos, distBounds2, distBounds1, distDividerBounds,
+ leftTop + (mIsLeftRightSplit
+ ? getBottomRightBounds().width() : getBottomRightBounds().height())
+ ).position;
+ final Rect endBounds1 = new Rect();
+ final Rect endBounds2 = new Rect();
+ final Rect endDividerBounds = new Rect();
+ // Compute destination bounds.
+ updateBounds(dividerPos, endBounds2, endBounds1, endDividerBounds,
false /* setEffectBounds */);
// Offset to real position under root container.
- distBounds1.offset(-mRootBounds.left, -mRootBounds.top);
- distBounds2.offset(-mRootBounds.left, -mRootBounds.top);
- distDividerBounds.offset(-mRootBounds.left, -mRootBounds.top);
+ endBounds1.offset(-mRootBounds.left, -mRootBounds.top);
+ endBounds2.offset(-mRootBounds.left, -mRootBounds.top);
+ endDividerBounds.offset(-mRootBounds.left, -mRootBounds.top);
- ValueAnimator animator1 = moveSurface(t, leash1, getRefBounds1(), distBounds1,
- -insets.left, -insets.top);
- ValueAnimator animator2 = moveSurface(t, leash2, getRefBounds2(), distBounds2,
- insets.left, insets.top);
- ValueAnimator animator3 = moveSurface(t, getDividerLeash(), getRefDividerBounds(),
- distDividerBounds, 0 /* offsetX */, 0 /* offsetY */);
+ ValueAnimator animator1 = moveSurface(t, topLeftStage, getTopLeftRefBounds(), endBounds1,
+ -insets.left, -insets.top, true /* roundCorners */, true /* isGoingBehind */,
+ shouldVeil);
+ ValueAnimator animator2 = moveSurface(t, bottomRightStage, getBottomRightRefBounds(),
+ endBounds2, insets.left, insets.top, true /* roundCorners */,
+ false /* isGoingBehind */, shouldVeil);
+ ValueAnimator animator3 = moveSurface(t, null /* stage */, getRefDividerBounds(),
+ endDividerBounds, 0 /* offsetX */, 0 /* offsetY */, false /* roundCorners */,
+ false /* isGoingBehind */, false /* addVeil */);
- AnimatorSet set = new AnimatorSet();
- set.playTogether(animator1, animator2, animator3);
- set.setDuration(FLING_SWITCH_DURATION);
- set.addListener(new AnimatorListenerAdapter() {
+ mSwapAnimator = new AnimatorSet();
+ mSwapAnimator.playTogether(animator1, animator2, animator3);
+ mSwapAnimator.setDuration(SWAP_ANIMATION_TOTAL_DURATION);
+ mSwapAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationStart(Animator animation) {
- InteractionJankMonitorUtils.beginTracing(CUJ_SPLIT_SCREEN_DOUBLE_TAP_DIVIDER,
- mContext, getDividerLeash(), null /*tag*/);
+ mInteractionJankMonitor.begin(getDividerLeash(),
+ mContext, mHandler, CUJ_SPLIT_SCREEN_DOUBLE_TAP_DIVIDER);
}
@Override
@@ -716,45 +1063,182 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
mDividerPosition = dividerPos;
updateBounds(mDividerPosition);
finishCallback.accept(insets);
- InteractionJankMonitorUtils.endTracing(CUJ_SPLIT_SCREEN_DOUBLE_TAP_DIVIDER);
+ mInteractionJankMonitor.end(CUJ_SPLIT_SCREEN_DOUBLE_TAP_DIVIDER);
}
@Override
public void onAnimationCancel(Animator animation) {
- InteractionJankMonitorUtils.cancelTracing(CUJ_SPLIT_SCREEN_DOUBLE_TAP_DIVIDER);
+ mInteractionJankMonitor.cancel(CUJ_SPLIT_SCREEN_DOUBLE_TAP_DIVIDER);
}
});
- set.start();
+ mSwapAnimator.start();
}
- private ValueAnimator moveSurface(SurfaceControl.Transaction t, SurfaceControl leash,
- Rect start, Rect end, float offsetX, float offsetY) {
+ /** Returns true if a swap animation is currently playing. */
+ public boolean isCurrentlySwapping() {
+ return mSwapAnimator != null && mSwapAnimator.isRunning();
+ }
+
+ /**
+ * Animates a task leash across the screen. Currently used only for the swap animation.
+ *
+ * @param stage The stage holding the task being animated. If null, it is the divider.
+ * @param roundCorners Whether we should round the corners of the task while animating.
+ * @param isGoingBehind Whether we should a shrink-and-grow effect to the task while it is
+ * moving. (Simulates moving behind the divider.)
+ */
+ private ValueAnimator moveSurface(SurfaceControl.Transaction t, StageTaskListener stage,
+ Rect start, Rect end, float offsetX, float offsetY, boolean roundCorners,
+ boolean isGoingBehind, boolean addVeil) {
+ final boolean isApp = stage != null; // check if this is an app or a divider
+ final SurfaceControl leash = isApp ? stage.getRootLeash() : getDividerLeash();
+ final ActivityManager.RunningTaskInfo taskInfo = isApp ? stage.getRunningTaskInfo() : null;
+ final SplitDecorManager decorManager = isApp ? stage.getDecorManager() : null;
+ final SurfaceControl dimLayer = isApp ? stage.getDimLayer() : null;
+ boolean goingOffscreen = Flags.enableFlexibleTwoAppSplit()
+ ? !mSplitState.isOffscreen(start) && mSplitState.isOffscreen(end) : false;
+ boolean comingOnscreen = Flags.enableFlexibleTwoAppSplit()
+ ? mSplitState.isOffscreen(start) && !mSplitState.isOffscreen(end) : false;
+
Rect tempStart = new Rect(start);
Rect tempEnd = new Rect(end);
final float diffX = tempEnd.left - tempStart.left;
final float diffY = tempEnd.top - tempStart.top;
final float diffWidth = tempEnd.width() - tempStart.width();
final float diffHeight = tempEnd.height() - tempStart.height();
+
+ // Get display measurements (for possible shrink animation).
+ final RoundedCorner roundedCorner = mSplitWindowManager.getDividerView().getDisplay()
+ .getRoundedCorner(0 /* position */);
+ float cornerRadius = roundedCorner == null ? 0 : roundedCorner.getRadius();
+ float shrinkMarginPx = PipUtils.dpToPx(
+ SWAP_ANIMATION_SHRINK_MARGIN_DP, mContext.getResources().getDisplayMetrics());
+ float shrinkAmountPx = shrinkMarginPx * 2;
+
+ // Timing calculations
+ float shrinkPortion = SWAP_ANIMATION_SHRINK_DURATION / SWAP_ANIMATION_TOTAL_DURATION;
+ float growPortion = 1 - shrinkPortion;
+
ValueAnimator animator = ValueAnimator.ofFloat(0, 1);
+ // Set the base animation to proceed linearly. Each component of the animation (movement,
+ // shrinking, growing) overrides it with a different interpolator later.
+ animator.setInterpolator(LINEAR);
animator.addUpdateListener(animation -> {
if (leash == null) return;
+ if (roundCorners) {
+ // Add rounded corners to the task leash while it is animating.
+ t.setCornerRadius(leash, cornerRadius);
+ }
- final float scale = (float) animation.getAnimatedValue();
- final float distX = tempStart.left + scale * diffX;
- final float distY = tempStart.top + scale * diffY;
- final int width = (int) (tempStart.width() + scale * diffWidth);
- final int height = (int) (tempStart.height() + scale * diffHeight);
- if (offsetX == 0 && offsetY == 0) {
- t.setPosition(leash, distX, distY);
- t.setWindowCrop(leash, width, height);
+ final float progress = (float) animation.getAnimatedValue();
+ final float moveProgress = EMPHASIZED.getInterpolation(progress);
+ float instantaneousX = tempStart.left + moveProgress * diffX;
+ float instantaneousY = tempStart.top + moveProgress * diffY;
+ int width = (int) (tempStart.width() + moveProgress * diffWidth);
+ int height = (int) (tempStart.height() + moveProgress * diffHeight);
+
+ if (isGoingBehind) {
+ float shrinkDiffX; // the position adjustments needed for this frame
+ float shrinkDiffY;
+ float shrinkScaleX; // the scale adjustments needed for this frame
+ float shrinkScaleY;
+
+ // Find the max amount we will be shrinking this leash, as a proportion (e.g. 0.1f).
+ float maxShrinkX = shrinkAmountPx / height;
+ float maxShrinkY = shrinkAmountPx / width;
+
+ // Find if we are in the shrinking part of the animation, or the growing part.
+ boolean shrinking = progress <= shrinkPortion;
+
+ if (shrinking) {
+ // Find how far into the shrink portion we are (e.g. 0.5f).
+ float shrinkProgress = progress / shrinkPortion;
+ // Find how much we should have progressed in shrinking the leash (e.g. 0.8f).
+ float interpolatedShrinkProgress =
+ SHRINK_INTERPOLATOR.getInterpolation(shrinkProgress);
+ // Find how much width proportion we should be taking off (e.g. 0.1f)
+ float widthProportionLost = maxShrinkX * interpolatedShrinkProgress;
+ shrinkScaleX = 1 - widthProportionLost;
+ // Find how much height proportion we should be taking off (e.g. 0.1f)
+ float heightProportionLost = maxShrinkY * interpolatedShrinkProgress;
+ shrinkScaleY = 1 - heightProportionLost;
+ // Add a small amount to the leash's position to keep the task centered.
+ shrinkDiffX = (width * widthProportionLost) / 2;
+ shrinkDiffY = (height * heightProportionLost) / 2;
+ } else {
+ // Find how far into the grow portion we are (e.g. 0.5f).
+ float growProgress = (progress - shrinkPortion) / growPortion;
+ // Find how much we should have progressed in growing the leash (e.g. 0.8f).
+ float interpolatedGrowProgress =
+ GROW_INTERPOLATOR.getInterpolation(growProgress);
+ // Find how much width proportion we should be taking off (e.g. 0.1f)
+ float widthProportionLost = maxShrinkX * (1 - interpolatedGrowProgress);
+ shrinkScaleX = 1 - widthProportionLost;
+ // Find how much height proportion we should be taking off (e.g. 0.1f)
+ float heightProportionLost = maxShrinkY * (1 - interpolatedGrowProgress);
+ shrinkScaleY = 1 - heightProportionLost;
+ // Add a small amount to the leash's position to keep the task centered.
+ shrinkDiffX = (width * widthProportionLost) / 2;
+ shrinkDiffY = (height * heightProportionLost) / 2;
+ }
+
+ instantaneousX += shrinkDiffX;
+ instantaneousY += shrinkDiffY;
+ width *= shrinkScaleX;
+ height *= shrinkScaleY;
+ // Set scale on the leash's contents.
+ t.setScale(leash, shrinkScaleX, shrinkScaleY);
+ }
+
+ // Set layers
+ if (taskInfo != null) {
+ t.setLayer(leash, isGoingBehind
+ ? ANIMATING_BACK_APP_LAYER
+ : ANIMATING_FRONT_APP_LAYER);
} else {
- final int diffOffsetX = (int) (scale * offsetX);
- final int diffOffsetY = (int) (scale * offsetY);
- t.setPosition(leash, distX + diffOffsetX, distY + diffOffsetY);
+ t.setLayer(leash, ANIMATING_DIVIDER_LAYER);
+ }
+
+ if (offsetX == 0 && offsetY == 0) {
+ t.setPosition(leash, instantaneousX, instantaneousY);
+ mTempRect.set((int) instantaneousX, (int) instantaneousY,
+ (int) (instantaneousX + width), (int) (instantaneousY + height));
+ t.setWindowCrop(leash, width, height);
+ if (addVeil) {
+ decorManager.drawNextVeilFrameForSwapAnimation(
+ taskInfo, mTempRect, t, isGoingBehind, leash, 0, 0);
+ }
+ } else {
+ final int diffOffsetX = (int) (moveProgress * offsetX);
+ final int diffOffsetY = (int) (moveProgress * offsetY);
+ t.setPosition(leash, instantaneousX + diffOffsetX, instantaneousY + diffOffsetY);
mTempRect.set(0, 0, width, height);
mTempRect.offsetTo(-diffOffsetX, -diffOffsetY);
t.setCrop(leash, mTempRect);
+ if (addVeil) {
+ decorManager.drawNextVeilFrameForSwapAnimation(
+ taskInfo, mTempRect, t, isGoingBehind, leash, diffOffsetX, diffOffsetY);
+ }
}
+
+ // App surfaces are dimmed when offscreen. So if the app is moving from onscreen to
+ // offscreen or vice versa, we set the dim layer's alpha on every frame for a smooth
+ // transition.
+ if (Flags.enableFlexibleTwoAppSplit()
+ && mSplitState.currentStateSupportsOffscreenApps()
+ && dimLayer != null) {
+ float instantaneousAlpha = 0f;
+ if (goingOffscreen) {
+ instantaneousAlpha = moveProgress * ResizingEffectPolicy.DEFAULT_OFFSCREEN_DIM;
+ }
+ if (comingOnscreen) {
+ instantaneousAlpha =
+ (1f - moveProgress) * ResizingEffectPolicy.DEFAULT_OFFSCREEN_DIM;
+ }
+ t.setAlpha(dimLayer, instantaneousAlpha);
+ t.setVisibility(dimLayer, instantaneousAlpha > 0.001f);
+ }
+
t.apply();
});
return animator;
@@ -790,12 +1274,18 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
getRefDividerBounds(mTempRect);
t.setPosition(dividerLeash, mTempRect.left, mTempRect.top);
// Resets layer of divider bar to make sure it is always on top.
- t.setLayer(dividerLeash, Integer.MAX_VALUE);
+ t.setLayer(dividerLeash, RESTING_DIVIDER_LAYER);
}
- getRefBounds1(mTempRect);
+ if (dimLayer1 != null) {
+ t.setLayer(dimLayer1, RESTING_DIM_LAYER);
+ }
+ if (dimLayer2 != null) {
+ t.setLayer(dimLayer2, RESTING_DIM_LAYER);
+ }
+ copyTopLeftRefBounds(mTempRect);
t.setPosition(leash1, mTempRect.left, mTempRect.top)
.setWindowCrop(leash1, mTempRect.width(), mTempRect.height());
- getRefBounds2(mTempRect);
+ copyBottomRightRefBounds(mTempRect);
t.setPosition(leash2, mTempRect.left, mTempRect.top)
.setWindowCrop(leash2, mTempRect.width(), mTempRect.height());
@@ -817,15 +1307,17 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
public boolean applyTaskChanges(WindowContainerTransaction wct,
ActivityManager.RunningTaskInfo task1, ActivityManager.RunningTaskInfo task2) {
boolean boundsChanged = false;
- if (!mBounds1.equals(mWinBounds1) || !task1.token.equals(mWinToken1)) {
- setTaskBounds(wct, task1, mBounds1);
- mWinBounds1.set(mBounds1);
+ if (!getTopLeftBounds().equals(getTopLeftContentBounds())
+ || !task1.token.equals(mWinToken1)) {
+ setTaskBounds(wct, task1, getTopLeftBounds());
+ getTopLeftContentBounds().set(getTopLeftBounds());
mWinToken1 = task1.token;
boundsChanged = true;
}
- if (!mBounds2.equals(mWinBounds2) || !task2.token.equals(mWinToken2)) {
- setTaskBounds(wct, task2, mBounds2);
- mWinBounds2.set(mBounds2);
+ if (!getBottomRightBounds().equals(getBottomRightContentBounds())
+ || !task2.token.equals(mWinToken2)) {
+ setTaskBounds(wct, task2, getBottomRightBounds());
+ getBottomRightContentBounds().set(getBottomRightBounds());
mWinToken2 = task2.token;
boundsChanged = true;
}
@@ -837,6 +1329,8 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
ActivityManager.RunningTaskInfo task, Rect bounds) {
wct.setBounds(task.token, bounds);
wct.setSmallestScreenWidthDp(task.token, getSmallestWidthDp(bounds));
+ wct.setScreenSizeDp(task.token, task.configuration.screenWidthDp,
+ task.configuration.screenHeightDp);
}
private int getSmallestWidthDp(Rect bounds) {
@@ -847,6 +1341,14 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
return (int) (minWidth / density);
}
+ public int getDisplayWidth() {
+ return mRootBounds.width();
+ }
+
+ public int getDisplayHeight() {
+ return mRootBounds.height();
+ }
+
/**
* Shift configuration bounds to prevent client apps get configuration changed or relaunch. And
* restore shifted configuration bounds if it's no longer shifted.
@@ -854,22 +1356,22 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
public void applyLayoutOffsetTarget(WindowContainerTransaction wct, int offsetX, int offsetY,
ActivityManager.RunningTaskInfo taskInfo1, ActivityManager.RunningTaskInfo taskInfo2) {
if (offsetX == 0 && offsetY == 0) {
- wct.setBounds(taskInfo1.token, mBounds1);
+ wct.setBounds(taskInfo1.token, getTopLeftBounds());
wct.setScreenSizeDp(taskInfo1.token,
SCREEN_WIDTH_DP_UNDEFINED, SCREEN_HEIGHT_DP_UNDEFINED);
- wct.setBounds(taskInfo2.token, mBounds2);
+ wct.setBounds(taskInfo2.token, getBottomRightBounds());
wct.setScreenSizeDp(taskInfo2.token,
SCREEN_WIDTH_DP_UNDEFINED, SCREEN_HEIGHT_DP_UNDEFINED);
} else {
- getBounds1(mTempRect);
+ copyTopLeftBounds(mTempRect);
mTempRect.offset(offsetX, offsetY);
wct.setBounds(taskInfo1.token, mTempRect);
wct.setScreenSizeDp(taskInfo1.token,
taskInfo1.configuration.screenWidthDp,
taskInfo1.configuration.screenHeightDp);
- getBounds2(mTempRect);
+ copyBottomRightBounds(mTempRect);
mTempRect.offset(offsetX, offsetY);
wct.setBounds(taskInfo2.token, mTempRect);
wct.setScreenSizeDp(taskInfo2.token,
@@ -887,9 +1389,9 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
pw.println(innerPrefix + "mFreezeDividerWindow=" + mFreezeDividerWindow);
pw.println(innerPrefix + "mDimNonImeSide=" + mDimNonImeSide);
pw.println(innerPrefix + "mDividerPosition=" + mDividerPosition);
- pw.println(innerPrefix + "bounds1=" + mBounds1.toShortString());
+ pw.println(innerPrefix + "bounds1=" + getTopLeftBounds().toShortString());
pw.println(innerPrefix + "dividerBounds=" + mDividerBounds.toShortString());
- pw.println(innerPrefix + "bounds2=" + mBounds2.toShortString());
+ pw.println(innerPrefix + "bounds2=" + getBottomRightBounds().toShortString());
}
/** Handles layout change event. */
@@ -941,173 +1443,16 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
default void onDoubleTappedDivider() {
}
+ /**
+ * Sets the excludedInsetsTypes for the IME in the root WindowContainer.
+ */
+ void setExcludeImeInsets(boolean exclude);
+
/** Returns split position of the token. */
@SplitPosition
int getSplitItemPosition(WindowContainerToken token);
}
- /**
- * Calculates and applies proper dismissing parallax offset and dimming value to hint users
- * dismissing gesture.
- */
- private class ResizingEffectPolicy {
- /** Indicates whether to offset splitting bounds to hint dismissing progress or not. */
- private final int mParallaxType;
-
- int mShrinkSide = DOCKED_INVALID;
-
- // The current dismissing side.
- int mDismissingSide = DOCKED_INVALID;
-
- // The parallax offset to hint the dismissing side and progress.
- final Point mParallaxOffset = new Point();
-
- // The dimming value to hint the dismissing side and progress.
- float mDismissingDimValue = 0.0f;
- final Rect mContentBounds = new Rect();
- final Rect mSurfaceBounds = new Rect();
-
- ResizingEffectPolicy(int parallaxType) {
- mParallaxType = parallaxType;
- }
-
- /**
- * Applies a parallax to the task to hint dismissing progress.
- *
- * @param position the split position to apply dismissing parallax effect
- * @param isLeftRightSplit indicates whether it's splitting horizontally or vertically
- */
- void applyDividerPosition(int position, boolean isLeftRightSplit) {
- mDismissingSide = DOCKED_INVALID;
- mParallaxOffset.set(0, 0);
- mDismissingDimValue = 0;
-
- int totalDismissingDistance = 0;
- if (position < mDividerSnapAlgorithm.getFirstSplitTarget().position) {
- mDismissingSide = isLeftRightSplit ? DOCKED_LEFT : DOCKED_TOP;
- totalDismissingDistance = mDividerSnapAlgorithm.getDismissStartTarget().position
- - mDividerSnapAlgorithm.getFirstSplitTarget().position;
- } else if (position > mDividerSnapAlgorithm.getLastSplitTarget().position) {
- mDismissingSide = isLeftRightSplit ? DOCKED_RIGHT : DOCKED_BOTTOM;
- totalDismissingDistance = mDividerSnapAlgorithm.getLastSplitTarget().position
- - mDividerSnapAlgorithm.getDismissEndTarget().position;
- }
-
- final boolean topLeftShrink = isLeftRightSplit
- ? position < mWinBounds1.right : position < mWinBounds1.bottom;
- if (topLeftShrink) {
- mShrinkSide = isLeftRightSplit ? DOCKED_LEFT : DOCKED_TOP;
- mContentBounds.set(mWinBounds1);
- mSurfaceBounds.set(mBounds1);
- } else {
- mShrinkSide = isLeftRightSplit ? DOCKED_RIGHT : DOCKED_BOTTOM;
- mContentBounds.set(mWinBounds2);
- mSurfaceBounds.set(mBounds2);
- }
-
- if (mDismissingSide != DOCKED_INVALID) {
- float fraction = Math.max(0,
- Math.min(mDividerSnapAlgorithm.calculateDismissingFraction(position), 1f));
- mDismissingDimValue = DIM_INTERPOLATOR.getInterpolation(fraction);
- if (mParallaxType == PARALLAX_DISMISSING) {
- fraction = calculateParallaxDismissingFraction(fraction, mDismissingSide);
- if (isLeftRightSplit) {
- mParallaxOffset.x = (int) (fraction * totalDismissingDistance);
- } else {
- mParallaxOffset.y = (int) (fraction * totalDismissingDistance);
- }
- }
- }
-
- if (mParallaxType == PARALLAX_ALIGN_CENTER) {
- if (isLeftRightSplit) {
- mParallaxOffset.x =
- (mSurfaceBounds.width() - mContentBounds.width()) / 2;
- } else {
- mParallaxOffset.y =
- (mSurfaceBounds.height() - mContentBounds.height()) / 2;
- }
- }
- }
-
- /**
- * @return for a specified {@code fraction}, this returns an adjusted value that simulates a
- * slowing down parallax effect
- */
- private float calculateParallaxDismissingFraction(float fraction, int dockSide) {
- float result = SLOWDOWN_INTERPOLATOR.getInterpolation(fraction) / 3.5f;
-
- // Less parallax at the top, just because.
- if (dockSide == WindowManager.DOCKED_TOP) {
- result /= 2f;
- }
- return result;
- }
-
- /** Applies parallax offset and dimming value to the root surface at the dismissing side. */
- void adjustRootSurface(SurfaceControl.Transaction t,
- SurfaceControl leash1, SurfaceControl leash2) {
- SurfaceControl targetLeash = null;
-
- if (mParallaxType == PARALLAX_DISMISSING) {
- switch (mDismissingSide) {
- case DOCKED_TOP:
- case DOCKED_LEFT:
- targetLeash = leash1;
- mTempRect.set(mBounds1);
- break;
- case DOCKED_BOTTOM:
- case DOCKED_RIGHT:
- targetLeash = leash2;
- mTempRect.set(mBounds2);
- break;
- }
- } else if (mParallaxType == PARALLAX_ALIGN_CENTER) {
- switch (mShrinkSide) {
- case DOCKED_TOP:
- case DOCKED_LEFT:
- targetLeash = leash1;
- mTempRect.set(mBounds1);
- break;
- case DOCKED_BOTTOM:
- case DOCKED_RIGHT:
- targetLeash = leash2;
- mTempRect.set(mBounds2);
- break;
- }
- }
- if (mParallaxType != PARALLAX_NONE && targetLeash != null) {
- t.setPosition(targetLeash,
- mTempRect.left + mParallaxOffset.x, mTempRect.top + mParallaxOffset.y);
- // Transform the screen-based split bounds to surface-based crop bounds.
- mTempRect.offsetTo(-mParallaxOffset.x, -mParallaxOffset.y);
- t.setWindowCrop(targetLeash, mTempRect);
- }
- }
-
- void adjustDimSurface(SurfaceControl.Transaction t,
- SurfaceControl dimLayer1, SurfaceControl dimLayer2) {
- SurfaceControl targetDimLayer;
- switch (mDismissingSide) {
- case DOCKED_TOP:
- case DOCKED_LEFT:
- targetDimLayer = dimLayer1;
- break;
- case DOCKED_BOTTOM:
- case DOCKED_RIGHT:
- targetDimLayer = dimLayer2;
- break;
- case DOCKED_INVALID:
- default:
- t.setAlpha(dimLayer1, 0).hide(dimLayer1);
- t.setAlpha(dimLayer2, 0).hide(dimLayer2);
- return;
- }
- t.setAlpha(targetDimLayer, mDismissingDimValue)
- .setVisibility(targetDimLayer, mDismissingDimValue > 0.001f);
- }
- }
-
/** Records IME top offset changes and updates SplitLayout correspondingly. */
private class ImePositionProcessor implements DisplayImeController.ImePositionProcessor {
/**
@@ -1139,6 +1484,14 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
mDisplayId = displayId;
}
+ @Override
+ public void onImeRequested(int displayId, boolean isRequested) {
+ if (displayId != mDisplayId) return;
+ ProtoLog.v(ShellProtoLogGroup.WM_SHELL_SPLIT_SCREEN, "IME was set to requested=%s",
+ isRequested);
+ mSplitLayoutHandler.setExcludeImeInsets(true);
+ }
+
@Override
public int onImeStartPositioning(int displayId, int hiddenTop, int shownTop,
boolean showing, boolean isFloating, SurfaceControl.Transaction t) {
@@ -1146,10 +1499,12 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
return 0;
}
- final int imeTargetPosition = getImeTargetPosition();
- mHasImeFocus = imeTargetPosition != SPLIT_POSITION_UNDEFINED;
+ final int imeLayeringTargetPosition = getImeLayeringTargetPosition();
+ mHasImeFocus = imeLayeringTargetPosition != SPLIT_POSITION_UNDEFINED;
if (!mHasImeFocus) {
- return 0;
+ if (showing) {
+ return 0;
+ }
}
mStartImeTop = showing ? hiddenTop : shownTop;
@@ -1158,15 +1513,15 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
// Update target dim values
mLastDim1 = mDimValue1;
- mTargetDim1 = imeTargetPosition == SPLIT_POSITION_BOTTOM_OR_RIGHT && mImeShown
+ mTargetDim1 = imeLayeringTargetPosition == SPLIT_POSITION_BOTTOM_OR_RIGHT && mImeShown
&& mDimNonImeSide ? ADJUSTED_NONFOCUS_DIM : 0.0f;
mLastDim2 = mDimValue2;
- mTargetDim2 = imeTargetPosition == SPLIT_POSITION_TOP_OR_LEFT && mImeShown
+ mTargetDim2 = imeLayeringTargetPosition == SPLIT_POSITION_TOP_OR_LEFT && mImeShown
&& mDimNonImeSide ? ADJUSTED_NONFOCUS_DIM : 0.0f;
// Calculate target bounds offset for IME
mLastYOffset = mYOffsetForIme;
- final boolean needOffset = imeTargetPosition == SPLIT_POSITION_BOTTOM_OR_RIGHT
+ final boolean needOffset = imeLayeringTargetPosition == SPLIT_POSITION_BOTTOM_OR_RIGHT
&& !isFloating && !mIsLeftRightSplit && mImeShown;
mTargetYOffset = needOffset ? getTargetYOffset() : 0;
@@ -1177,11 +1532,7 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
// Freeze the configuration size with offset to prevent app get a configuration
// changed or relaunch. This is required to make sure client apps will calculate
// insets properly after layout shifted.
- if (mTargetYOffset == 0) {
- mSplitLayoutHandler.setLayoutOffsetTarget(0, 0, SplitLayout.this);
- } else {
- mSplitLayoutHandler.setLayoutOffsetTarget(0, mTargetYOffset, SplitLayout.this);
- }
+ mSplitLayoutHandler.setLayoutOffsetTarget(0, mTargetYOffset, SplitLayout.this);
}
// Make {@link DividerView} non-interactive while IME showing in split mode. Listen to
@@ -1191,12 +1542,20 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
setDividerInteractive(!mImeShown || !mHasImeFocus || isFloating, true,
"onImeStartPositioning");
+ if (mImeShown) {
+ mSplitLayoutHandler.setExcludeImeInsets(false);
+ }
+
return mTargetYOffset != mLastYOffset ? IME_ANIMATION_NO_ALPHA : 0;
}
@Override
public void onImePositionChanged(int displayId, int imeTop, SurfaceControl.Transaction t) {
- if (displayId != mDisplayId || !mHasImeFocus) return;
+ if (displayId != mDisplayId || !mHasImeFocus) {
+ if (mImeShown) {
+ return;
+ }
+ }
onProgress(getProgress(imeTop));
mSplitLayoutHandler.onLayoutPositionChanging(SplitLayout.this);
}
@@ -1204,11 +1563,24 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
@Override
public void onImeEndPositioning(int displayId, boolean cancel,
SurfaceControl.Transaction t) {
- if (displayId != mDisplayId || !mHasImeFocus || cancel) return;
+ if (displayId != mDisplayId || cancel) return;
+ if (!mHasImeFocus) {
+ if (mImeShown) {
+ return;
+ }
+ }
ProtoLog.v(ShellProtoLogGroup.WM_SHELL_SPLIT_SCREEN,
"Split IME animation ending, canceled=%b", cancel);
onProgress(1.0f);
mSplitLayoutHandler.onLayoutPositionChanging(SplitLayout.this);
+ if (!mImeShown) {
+ // The IME hide animation is started immediately and at that point, the IME
+ // insets are not yet set to hidden. Therefore only resetting the
+ // excludedTypes at the end of the animation. Note: InsetsPolicy will only
+ // set the IME height to zero, when it is visible. When it becomes invisible,
+ // we dispatch the insets (the height there is zero as well)
+ mSplitLayoutHandler.setExcludeImeInsets(false);
+ }
}
@Override
@@ -1223,16 +1595,33 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
}
}
+ /**
+ * When IME is triggered on the bottom app in split screen, we want to translate the bottom
+ * app up by a certain amount so that it's not covered too much by the IME. But there's also
+ * an upper limit to the amount we want to translate (since we still need some of the top
+ * app to be visible too). So this function essentially says "try to translate the bottom
+ * app up, but stop before you make the top app too small."
+ */
private int getTargetYOffset() {
- final int desireOffset = Math.abs(mEndImeTop - mStartImeTop);
- // Make sure to keep at least 30% visible for the top split.
- final int maxOffset = (int) (mBounds1.height() * ADJUSTED_SPLIT_FRACTION_MAX);
- return -Math.min(desireOffset, maxOffset);
+ // We want to translate up the bottom app by this amount.
+ final int desiredOffset = Math.abs(mEndImeTop - mStartImeTop);
+
+ // But we also want to keep this much of the top app visible.
+ final float amountOfTopAppToKeepVisible =
+ getTopLeftBounds().height() * (1 - ADJUSTED_SPLIT_FRACTION_MAX);
+
+ // So the current onscreen size of the top app, minus the minimum size, is the max
+ // translation we will allow.
+ final float currentOnScreenSizeOfTopApp = getTopLeftBounds().bottom;
+ final int maxOffset =
+ (int) Math.max(currentOnScreenSizeOfTopApp - amountOfTopAppToKeepVisible, 0);
+
+ return -Math.min(desiredOffset, maxOffset);
}
@SplitPosition
- private int getImeTargetPosition() {
- final WindowContainerToken token = mTaskOrganizer.getImeTarget(mDisplayId);
+ private int getImeLayeringTargetPosition() {
+ final WindowContainerToken token = mTaskOrganizer.getImeLayeringTarget(mDisplayId);
return mSplitLayoutHandler.getSplitItemPosition(token);
}
@@ -1276,11 +1665,11 @@ public final class SplitLayout implements DisplayInsetsController.OnInsetsChange
t.setPosition(dividerLeash, mTempRect.left, mTempRect.top);
}
- getRefBounds1(mTempRect);
+ copyTopLeftRefBounds(mTempRect);
mTempRect.offset(0, mYOffsetForIme);
t.setPosition(leash1, mTempRect.left, mTempRect.top);
- getRefBounds2(mTempRect);
+ copyBottomRightRefBounds(mTempRect);
mTempRect.offset(0, mYOffsetForIme);
t.setPosition(leash2, mTempRect.left, mTempRect.top);
adjusted = true;
diff --git a/wmshell/src/com/android/wm/shell/common/split/SplitScreenUtils.java b/wmshell/src/com/android/wm/shell/common/split/SplitScreenUtils.java
index e8226051b6..84c30a52e8 100644
--- a/wmshell/src/com/android/wm/shell/common/split/SplitScreenUtils.java
+++ b/wmshell/src/com/android/wm/shell/common/split/SplitScreenUtils.java
@@ -16,28 +16,30 @@
package com.android.wm.shell.common.split;
-import static com.android.wm.shell.common.split.SplitScreenConstants.CONTROLLED_ACTIVITY_TYPES;
-import static com.android.wm.shell.common.split.SplitScreenConstants.CONTROLLED_WINDOWING_MODES;
-import static com.android.wm.shell.common.split.SplitScreenConstants.SPLIT_POSITION_BOTTOM_OR_RIGHT;
-import static com.android.wm.shell.common.split.SplitScreenConstants.SPLIT_POSITION_TOP_OR_LEFT;
-import static com.android.wm.shell.common.split.SplitScreenConstants.SPLIT_POSITION_UNDEFINED;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.CONTROLLED_ACTIVITY_TYPES;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.CONTROLLED_WINDOWING_MODES;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_2_10_90;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_2_90_10;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_3_10_45_45;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_3_45_45_10;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SPLIT_POSITION_BOTTOM_OR_RIGHT;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SPLIT_POSITION_TOP_OR_LEFT;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SPLIT_POSITION_UNDEFINED;
import android.app.ActivityManager;
-import android.app.PendingIntent;
-import android.content.Intent;
import android.content.res.Configuration;
import android.content.res.Resources;
-import android.graphics.Color;
import android.graphics.Rect;
-import androidx.annotation.Nullable;
-
import com.android.internal.util.ArrayUtils;
import com.android.wm.shell.Flags;
import com.android.wm.shell.ShellTaskOrganizer;
+import com.android.wm.shell.shared.split.SplitScreenConstants;
/** Helper utility class for split screen components to use. */
public class SplitScreenUtils {
+ private static final int LARGE_SCREEN_MIN_EDGE_DP = 600;
+
/** Reverse the split position. */
@SplitScreenConstants.SplitPosition
public static int reverseSplitPosition(@SplitScreenConstants.SplitPosition int position) {
@@ -59,31 +61,6 @@ public class SplitScreenUtils {
&& ArrayUtils.contains(CONTROLLED_WINDOWING_MODES, taskInfo.getWindowingMode());
}
- /** Retrieve package name from an intent */
- @Nullable
- public static String getPackageName(Intent intent) {
- if (intent == null || intent.getComponent() == null) {
- return null;
- }
- return intent.getComponent().getPackageName();
- }
-
- /** Retrieve package name from a PendingIntent */
- @Nullable
- public static String getPackageName(PendingIntent pendingIntent) {
- if (pendingIntent == null || pendingIntent.getIntent() == null) {
- return null;
- }
- return getPackageName(pendingIntent.getIntent());
- }
-
- /** Retrieve package name from a taskId */
- @Nullable
- public static String getPackageName(int taskId, ShellTaskOrganizer taskOrganizer) {
- final ActivityManager.RunningTaskInfo taskInfo = taskOrganizer.getRunningTaskInfo(taskId);
- return taskInfo != null ? getPackageName(taskInfo.baseIntent) : null;
- }
-
/** Retrieve user id from a taskId */
public static int getUserId(int taskId, ShellTaskOrganizer taskOrganizer) {
final ActivityManager.RunningTaskInfo taskInfo = taskOrganizer.getRunningTaskInfo(taskId);
@@ -99,8 +76,7 @@ public class SplitScreenUtils {
* Returns whether left/right split is allowed in portrait.
*/
public static boolean allowLeftRightSplitInPortrait(Resources res) {
- return Flags.enableLeftRightSplitInPortrait() && res.getBoolean(
- com.android.internal.R.bool.config_leftRightSplitInPortrait);
+ return res.getBoolean(com.android.internal.R.bool.config_leftRightSplitInPortrait);
}
/**
@@ -110,10 +86,9 @@ public class SplitScreenUtils {
Configuration config) {
// Compare the max bounds sizes as on near-square devices, the insets may result in a
// configuration in the other orientation
- final boolean isLargeScreen = config.smallestScreenWidthDp >= 600;
final Rect maxBounds = config.windowConfiguration.getMaxBounds();
final boolean isLandscape = maxBounds.width() >= maxBounds.height();
- return isLeftRightSplit(allowLeftRightSplitInPortrait, isLargeScreen, isLandscape);
+ return isLeftRightSplit(allowLeftRightSplitInPortrait, isLargeScreen(config), isLandscape);
}
/**
@@ -129,9 +104,52 @@ public class SplitScreenUtils {
}
}
- /** Returns the specified background color that matches a RunningTaskInfo. */
- public static Color getResizingBackgroundColor(ActivityManager.RunningTaskInfo taskInfo) {
- final int taskBgColor = taskInfo.taskDescription.getBackgroundColor();
- return Color.valueOf(taskBgColor == -1 ? Color.WHITE : taskBgColor);
+ /**
+ * Returns whether the current config is a large screen (tablet or unfolded foldable)
+ */
+ public static boolean isLargeScreen(Configuration config) {
+ return config.smallestScreenWidthDp >= LARGE_SCREEN_MIN_EDGE_DP;
+ }
+
+ /**
+ * Convenience function for {@link #isLargeScreen(Configuration)}.
+ */
+ public static boolean isLargeScreen(Resources res) {
+ return isLargeScreen(res.getConfiguration());
+ }
+
+ /**
+ * Returns whether the current device is a foldable
+ */
+ public static boolean isFoldable(Resources res) {
+ return res.getIntArray(com.android.internal.R.array.config_foldedDeviceStates).length != 0;
+ }
+
+ /**
+ * Returns whether we should allow split ratios to go offscreen or not. If the device is a phone
+ * or a foldable (either screen), we allow it.
+ */
+ public static boolean allowOffscreenRatios(Resources res) {
+ return Flags.enableFlexibleTwoAppSplit() && (!isLargeScreen(res) || isFoldable(res));
+ }
+
+ /**
+ * Within a particular split layout, we label the stages numerically: 0, 1, 2... from left to
+ * right (or top to bottom). This function takes in a stage index (0th, 1st, 2nd...) and a
+ * PersistentSnapPosition and returns if that particular stage is offscreen in that layout.
+ */
+ public static boolean isPartiallyOffscreen(int stageIndex,
+ @SplitScreenConstants.PersistentSnapPosition int snapPosition) {
+ switch(snapPosition) {
+ case SNAP_TO_2_10_90:
+ case SNAP_TO_3_10_45_45:
+ return stageIndex == 0;
+ case SNAP_TO_2_90_10:
+ return stageIndex == 1;
+ case SNAP_TO_3_45_45_10:
+ return stageIndex == 2;
+ default:
+ return false;
+ }
}
}
diff --git a/wmshell/src/com/android/wm/shell/common/split/SplitSpec.java b/wmshell/src/com/android/wm/shell/common/split/SplitSpec.java
new file mode 100644
index 0000000000..901a79b2ed
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/common/split/SplitSpec.java
@@ -0,0 +1,191 @@
+/*
+ * 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.common.split;
+
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_2_10_90;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_2_33_66;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_2_50_50;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_2_66_33;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_2_90_10;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_3_10_45_45;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_3_33_33_33;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_3_45_45_10;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.stateToString;
+
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.util.Log;
+
+import com.android.wm.shell.shared.split.SplitScreenConstants.SplitScreenState;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * A reference class that stores the split layouts available in this device/orientation. Layouts are
+ * available as lists of RectFs, where each RectF represents the bounds of an app.
+ */
+public class SplitSpec {
+ private static final String TAG = "SplitSpec";
+ private static final boolean DEBUG = false;
+
+ /** A split ratio used on larger screens, where we can fit both apps onscreen. */
+ public static final float ONSCREEN_ONLY_ASYMMETRIC_RATIO = 0.33f;
+ /** A split ratio used on smaller screens, where we place one app mostly offscreen. */
+ public static final float OFFSCREEN_ASYMMETRIC_RATIO = 0.1f;
+ /** A 50-50 split ratio. */
+ public static final float MIDDLE_RATIO = 0.5f;
+
+ private final boolean mIsLeftRightSplit;
+ /** The physical size of the display. */
+ private final Rect mDisplayBounds;
+ /** The usable display area, considering insets that affect split bounds. */
+ private final RectF mUsableArea;
+ /** Half the divider size. */
+ private final float mHalfDiv;
+
+ /** A large map that stores all valid split layouts. */
+ private final Map> mLayouts = new HashMap<>();
+
+ /** Constructor; initializes the layout map. */
+ public SplitSpec(Rect displayBounds, int dividerSize, boolean isLeftRightSplit,
+ Rect pinnedTaskbarInsets) {
+ mIsLeftRightSplit = isLeftRightSplit;
+ mDisplayBounds = new Rect(displayBounds);
+ mUsableArea = new RectF(displayBounds);
+ mUsableArea.left += pinnedTaskbarInsets.left;
+ mUsableArea.top += pinnedTaskbarInsets.top;
+ mUsableArea.right -= pinnedTaskbarInsets.right;
+ mUsableArea.bottom -= pinnedTaskbarInsets.bottom;
+ mHalfDiv = dividerSize / 2f;
+
+ // The "start" position, considering insets.
+ float s = isLeftRightSplit ? mUsableArea.left : mUsableArea.top;
+ // The "end" position, considering insets.
+ float e = isLeftRightSplit ? mUsableArea.right : mUsableArea.bottom;
+ // The "length" of the usable display (width or height). Apps are arranged along this axis.
+ float l = e - s;
+ float divPos;
+ float divPos2;
+
+ // SNAP_TO_2_10_90
+ divPos = s + (l * OFFSCREEN_ASYMMETRIC_RATIO);
+ createAppLayout(SNAP_TO_2_10_90, divPos);
+
+ // SNAP_TO_2_33_66
+ divPos = s + (l * ONSCREEN_ONLY_ASYMMETRIC_RATIO);
+ createAppLayout(SNAP_TO_2_33_66, divPos);
+
+ // SNAP_TO_2_50_50
+ divPos = s + (l * MIDDLE_RATIO);
+ createAppLayout(SNAP_TO_2_50_50, divPos);
+
+ // SNAP_TO_2_66_33
+ divPos = s + (l * (1 - ONSCREEN_ONLY_ASYMMETRIC_RATIO));
+ createAppLayout(SNAP_TO_2_66_33, divPos);
+
+ // SNAP_TO_2_90_10
+ divPos = s + (l * (1 - OFFSCREEN_ASYMMETRIC_RATIO));
+ createAppLayout(SNAP_TO_2_90_10, divPos);
+
+ // SNAP_TO_3_10_45_45
+ divPos = s + (l * OFFSCREEN_ASYMMETRIC_RATIO);
+ divPos2 = e - ((l * (1 - OFFSCREEN_ASYMMETRIC_RATIO)) / 2f);
+ createAppLayout(SNAP_TO_3_10_45_45, divPos, divPos2);
+
+ // SNAP_TO_3_33_33_33
+ divPos = s + (l * ONSCREEN_ONLY_ASYMMETRIC_RATIO);
+ divPos2 = e - (l * ONSCREEN_ONLY_ASYMMETRIC_RATIO);
+ createAppLayout(SNAP_TO_3_33_33_33, divPos, divPos2);
+
+ // SNAP_TO_3_45_45_10
+ divPos = s + ((l * (1 - OFFSCREEN_ASYMMETRIC_RATIO)) / 2f);
+ divPos2 = e - (l * OFFSCREEN_ASYMMETRIC_RATIO);
+ createAppLayout(SNAP_TO_3_45_45_10, divPos, divPos2);
+
+ if (DEBUG) {
+ dump();
+ }
+ }
+
+ /**
+ * Creates a two-app layout and enters it into the layout map.
+ * @param divPos The position of the divider.
+ */
+ private void createAppLayout(@SplitScreenState int state, float divPos) {
+ List list = new ArrayList<>();
+ RectF rect1 = new RectF(mUsableArea);
+ RectF rect2 = new RectF(mUsableArea);
+ if (mIsLeftRightSplit) {
+ rect1.right = divPos - mHalfDiv;
+ rect2.left = divPos + mHalfDiv;
+ } else {
+ rect1.top = divPos - mHalfDiv;
+ rect2.bottom = divPos + mHalfDiv;
+ }
+ list.add(rect1);
+ list.add(rect2);
+ mLayouts.put(state, list);
+ }
+
+ /**
+ * Creates a three-app layout and enters it into the layout map.
+ * @param divPos1 The position of the first divider.
+ * @param divPos2 The position of the second divider.
+ */
+ private void createAppLayout(@SplitScreenState int state, float divPos1, float divPos2) {
+ List list = new ArrayList<>();
+ RectF rect1 = new RectF(mUsableArea);
+ RectF rect2 = new RectF(mUsableArea);
+ RectF rect3 = new RectF(mUsableArea);
+ if (mIsLeftRightSplit) {
+ rect1.right = divPos1 - mHalfDiv;
+ rect2.left = divPos1 + mHalfDiv;
+ rect2.right = divPos2 - mHalfDiv;
+ rect3.left = divPos2 + mHalfDiv;
+ } else {
+ rect1.right = divPos1 - mHalfDiv;
+ rect2.left = divPos1 + mHalfDiv;
+ rect3.right = divPos2 - mHalfDiv;
+ rect3.left = divPos2 + mHalfDiv;
+ }
+ list.add(rect1);
+ list.add(rect2);
+ list.add(rect3);
+ mLayouts.put(state, list);
+ }
+
+ /** Logs all calculated layouts */
+ private void dump() {
+ mLayouts.forEach((k, v) -> {
+ Log.d(TAG, stateToString(k));
+ v.forEach(rect -> Log.d(TAG, " - " + rect.toShortString()));
+ });
+ }
+
+ /** Returns the layout associated with a given split state. */
+ List getSpec(@SplitScreenState int state) {
+ return mLayouts.get(state);
+ }
+
+ /** Returns whether a given Rect is partially offscreen on the current display. */
+ boolean isOffscreen(Rect rect) {
+ return !mDisplayBounds.contains(rect);
+ }
+}
diff --git a/wmshell/src/com/android/wm/shell/common/split/SplitState.java b/wmshell/src/com/android/wm/shell/common/split/SplitState.java
new file mode 100644
index 0000000000..102f83b044
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/common/split/SplitState.java
@@ -0,0 +1,128 @@
+/*
+ * 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.common.split;
+
+import static com.android.wm.shell.shared.split.SplitScreenConstants.NOT_IN_SPLIT;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_2_10_90;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_2_90_10;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_3_10_45_45;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_3_45_45_10;
+import static com.android.wm.shell.shared.split.SplitScreenConstants.SplitScreenState;
+
+import android.annotation.NonNull;
+import android.graphics.Rect;
+import android.graphics.RectF;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * A class that manages the "state" of split screen. See {@link SplitScreenState} for definitions.
+ */
+public class SplitState {
+ private @SplitScreenState int mState = NOT_IN_SPLIT;
+ private SplitSpec mSplitSpec;
+ private final Set mListeners = new HashSet<>();
+
+
+ /** Updates the current state of split screen on this device. */
+ public void set(@SplitScreenState int newState) {
+ mState = newState;
+ notifyListeners();
+ }
+
+ /** Reports the current state of split screen on this device. */
+ public @SplitScreenState int get() {
+ return mState;
+ }
+
+ /** Sets NOT_IN_SPLIT when user exits split. */
+ public void exit() {
+ set(NOT_IN_SPLIT);
+ }
+
+ /** Refresh the valid layouts for this display/orientation. */
+ public void populateLayouts(Rect displayBounds, int dividerSize, boolean isLeftRightSplit,
+ Rect pinnedTaskbarInsets) {
+ mSplitSpec =
+ new SplitSpec(displayBounds, dividerSize, isLeftRightSplit, pinnedTaskbarInsets);
+ }
+
+ /** Returns the layout associated with a given split state. */
+ public List getLayout(@SplitScreenState int state) {
+ return mSplitSpec.getSpec(state);
+ }
+
+ /** Returns the layout associated with the current split state. */
+ public List getCurrentLayout() {
+ return getLayout(mState);
+ }
+
+ /** Returns whether a given Rect is partially offscreen on the current display. */
+ boolean isOffscreen(Rect rect) {
+ return mSplitSpec.isOffscreen(rect);
+ }
+
+ /** @return {@code true} if at least one app is partially offscreen in the current layout. */
+ public boolean currentStateSupportsOffscreenApps() {
+ return mState == SNAP_TO_2_10_90
+ || mState == SNAP_TO_2_90_10
+ || mState == SNAP_TO_3_10_45_45
+ || mState == SNAP_TO_3_45_45_10;
+ }
+
+ /**
+ * Registers a listener to receive notifications when the split state changes.
+ * Multiple instances of the same listener will not be tolerated. Don't be weird.
+ *
+ * @param listener The listener to register.
+ */
+ public void registerSplitStateChangeListener(@NonNull SplitStateChangeListener listener) {
+ mListeners.add(listener);
+ }
+
+ /**
+ * Unregisters a listener, so it no longer receives notifications.
+ *
+ * @param listener The listener to unregister.
+ */
+ public void unregisterSplitStateChangeListener(@NonNull SplitStateChangeListener listener) {
+ mListeners.remove(listener);
+ }
+
+ /**
+ * Notifies all registered listeners of the current split state.
+ */
+ private void notifyListeners() {
+ for (SplitStateChangeListener listener : mListeners) {
+ listener.onSplitStateChanged(mState);
+ }
+ }
+
+ /**
+ * An interface for listeners that want to be notified of split state changes.
+ */
+ public interface SplitStateChangeListener {
+ /**
+ * Called when the split state of the splitter changes.
+ *
+ * @param splitState The new split state.
+ */
+ void onSplitStateChanged(@SplitScreenState int splitState);
+ }
+}
diff --git a/wmshell/src/com/android/wm/shell/common/split/SplitWindowManager.java b/wmshell/src/com/android/wm/shell/common/split/SplitWindowManager.java
index 5d121c23c6..eead2dc309 100644
--- a/wmshell/src/com/android/wm/shell/common/split/SplitWindowManager.java
+++ b/wmshell/src/com/android/wm/shell/common/split/SplitWindowManager.java
@@ -18,8 +18,6 @@ package com.android.wm.shell.common.split;
import static android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
import static android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL;
-import static android.view.WindowManager.LayoutParams.FLAG_SLIPPERY;
-import static android.view.WindowManager.LayoutParams.FLAG_SPLIT_TOUCH;
import static android.view.WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH;
import static android.view.WindowManager.LayoutParams.PRIVATE_FLAG_NO_MOVE_ANIMATION;
import static android.view.WindowManager.LayoutParams.PRIVATE_FLAG_TRUSTED_OVERLAY;
@@ -36,8 +34,6 @@ import android.view.InsetsState;
import android.view.LayoutInflater;
import android.view.SurfaceControl;
import android.view.SurfaceControlViewHost;
-import android.view.SurfaceSession;
-import android.view.View;
import android.view.WindowManager;
import android.view.WindowlessWindowManager;
@@ -45,6 +41,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.android.wm.shell.R;
+import com.android.wm.shell.shared.desktopmode.DesktopState;
/**
* Holds view hierarchy of a root surface and helps to inflate {@link DividerView} for a split.
@@ -69,6 +66,10 @@ public final class SplitWindowManager extends WindowlessWindowManager {
public interface ParentContainerCallbacks {
void attachToParentSurface(SurfaceControl.Builder b);
void onLeashReady(SurfaceControl leash);
+ /** Inflates the given touch zone on the appropriate stage root. */
+ void inflateOnStageRoot(OffscreenTouchZone touchZone);
+ /** Called when any visual animations w/ split layout are happening. */
+ void onSplitLayoutAnimating(boolean animating);
}
public SplitWindowManager(String windowName, Context context, Configuration config,
@@ -99,7 +100,7 @@ public final class SplitWindowManager extends WindowlessWindowManager {
@Override
protected SurfaceControl getParentSurface(IWindow window, WindowManager.LayoutParams attrs) {
// Can't set position for the ViewRootImpl SC directly. Create a leash to manipulate later.
- final SurfaceControl.Builder builder = new SurfaceControl.Builder(new SurfaceSession())
+ final SurfaceControl.Builder builder = new SurfaceControl.Builder()
.setContainerLayer()
.setName(TAG)
.setHidden(true)
@@ -111,7 +112,8 @@ public final class SplitWindowManager extends WindowlessWindowManager {
}
/** Inflates {@link DividerView} on to the root surface. */
- void init(SplitLayout splitLayout, InsetsState insetsState, boolean isRestoring) {
+ void init(SplitLayout splitLayout, InsetsState insetsState, boolean isRestoring,
+ DesktopState desktopState) {
if (mDividerView != null || mViewHost != null) {
throw new UnsupportedOperationException(
"Try to inflate divider view again without release first");
@@ -125,15 +127,14 @@ public final class SplitWindowManager extends WindowlessWindowManager {
final Rect dividerBounds = splitLayout.getDividerBounds();
WindowManager.LayoutParams lp = new WindowManager.LayoutParams(
dividerBounds.width(), dividerBounds.height(), TYPE_DOCK_DIVIDER,
- FLAG_NOT_FOCUSABLE | FLAG_NOT_TOUCH_MODAL | FLAG_WATCH_OUTSIDE_TOUCH
- | FLAG_SPLIT_TOUCH | FLAG_SLIPPERY,
+ FLAG_NOT_FOCUSABLE | FLAG_NOT_TOUCH_MODAL | FLAG_WATCH_OUTSIDE_TOUCH,
PixelFormat.TRANSLUCENT);
lp.token = new Binder();
lp.setTitle(mWindowName);
lp.privateFlags |= PRIVATE_FLAG_NO_MOVE_ANIMATION | PRIVATE_FLAG_TRUSTED_OVERLAY;
lp.accessibilityTitle = mContext.getResources().getString(R.string.accessibility_divider);
mViewHost.setView(mDividerView, lp);
- mDividerView.setup(splitLayout, this, mViewHost, insetsState);
+ mDividerView.setup(splitLayout, this, mViewHost, insetsState, desktopState);
if (isRestoring) {
mDividerView.setInteractive(mLastDividerInteractive, mLastDividerHandleHidden,
"restore_setup");
@@ -192,7 +193,7 @@ public final class SplitWindowManager extends WindowlessWindowManager {
mDividerView.setInteractive(interactive, hideHandle, from);
}
- View getDividerView() {
+ DividerView getDividerView() {
return mDividerView;
}
diff --git a/wmshell/src/com/android/wm/shell/common/suppliers/InputChannelSupplier.kt b/wmshell/src/com/android/wm/shell/common/suppliers/InputChannelSupplier.kt
new file mode 100644
index 0000000000..a83953d2f2
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/common/suppliers/InputChannelSupplier.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.wm.shell.common.suppliers
+
+import android.view.InputChannel
+import com.android.wm.shell.dagger.WMSingleton
+import java.util.function.Supplier
+import javax.inject.Inject
+
+/**
+ * An Injectable [Supplier]. This can be used in place of kotlin default
+ * parameters values [builder = ::InputChannel] which requires the [@JvmOverloads] annotation to
+ * make this available in Java.
+ * This can be used every time a component needs the dependency to the default [Supplier] for
+ * [InputChannel]s.
+ */
+@WMSingleton
+class InputChannelSupplier @Inject constructor() : Supplier {
+ override fun get(): InputChannel = InputChannel()
+}
diff --git a/wmshell/src/com/android/wm/shell/common/suppliers/SurfaceBuilderSupplier.kt b/wmshell/src/com/android/wm/shell/common/suppliers/SurfaceBuilderSupplier.kt
new file mode 100644
index 0000000000..7593d43370
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/common/suppliers/SurfaceBuilderSupplier.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.wm.shell.common.suppliers
+
+import android.view.SurfaceControl
+import com.android.wm.shell.dagger.WMSingleton
+import java.util.function.Supplier
+import javax.inject.Inject
+
+/**
+ * An Injectable [Supplier]. This can be used in place of kotlin default
+ * parameters values [builder = ::SurfaceControl.Builder] which requires the [@JvmOverloads]
+ * annotation to make this available in Java.
+ * This can be used every time a component needs the dependency to the default builder for
+ * [SurfaceControl]s.
+ */
+@WMSingleton
+class SurfaceBuilderSupplier @Inject constructor() : Supplier {
+ override fun get(): SurfaceControl.Builder = SurfaceControl.Builder()
+}
diff --git a/wmshell/src/com/android/wm/shell/common/suppliers/TransactionSupplier.kt b/wmshell/src/com/android/wm/shell/common/suppliers/TransactionSupplier.kt
new file mode 100644
index 0000000000..bd2fccc69f
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/common/suppliers/TransactionSupplier.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.wm.shell.common.suppliers
+
+import android.view.SurfaceControl
+import com.android.wm.shell.dagger.WMSingleton
+import java.util.function.Supplier
+import javax.inject.Inject
+
+/**
+ * An Injectable [Supplier]. This can be used in place of kotlin default
+ * parameters values [builder = ::SurfaceControl.Transaction] which requires the [@JvmOverloads]
+ * annotation to make this available in Java.
+ * This can be used every time a component needs the dependency to the default builder for
+ * [SurfaceControl.Transaction]s.
+ */
+@WMSingleton
+class TransactionSupplier @Inject constructor() : Supplier {
+ override fun get(): SurfaceControl.Transaction = SurfaceControl.Transaction()
+}
diff --git a/wmshell/src/com/android/wm/shell/common/suppliers/WindowContainerTransactionSupplier.kt b/wmshell/src/com/android/wm/shell/common/suppliers/WindowContainerTransactionSupplier.kt
new file mode 100644
index 0000000000..0b84753a7f
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/common/suppliers/WindowContainerTransactionSupplier.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.wm.shell.common.suppliers
+
+import android.window.WindowContainerTransaction
+import com.android.wm.shell.dagger.WMSingleton
+import java.util.function.Supplier
+import javax.inject.Inject
+
+/**
+ * An Injectable [Supplier]. This can be used in place of kotlin default
+ * parameters values [builder = ::WindowContainerTransaction] which requires the
+ * [@JvmOverloads] annotation to make this available in Java.
+ * This can be used every time a component needs the dependency to the default [Supplier] for
+ * [WindowContainerTransaction]s.
+ */
+@WMSingleton
+class WindowContainerTransactionSupplier @Inject constructor(
+) : Supplier {
+ override fun get(): WindowContainerTransaction = WindowContainerTransaction()
+}
diff --git a/wmshell/src/com/android/wm/shell/common/suppliers/WindowSessionSupplier.kt b/wmshell/src/com/android/wm/shell/common/suppliers/WindowSessionSupplier.kt
new file mode 100644
index 0000000000..1dc4b576d6
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/common/suppliers/WindowSessionSupplier.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.wm.shell.common.suppliers
+
+import android.view.IWindowSession
+import android.view.WindowManagerGlobal
+import com.android.wm.shell.dagger.WMSingleton
+import java.util.function.Supplier
+import javax.inject.Inject
+
+/**
+ * An Injectable [Supplier]. This can be used in place of kotlin default
+ * parameters values [builder = WindowManagerGlobal::getWindowSession] which requires the
+ * [@JvmOverloads] annotation to make this available in Java.
+ * This can be used every time a component needs the dependency to the default [Supplier] for
+ * [IWindowSession]s.
+ */
+@WMSingleton
+class WindowSessionSupplier @Inject constructor() : Supplier {
+ override fun get(): IWindowSession = WindowManagerGlobal.getWindowSession()
+}
diff --git a/wmshell/src/com/android/wm/shell/common/transition/TransitionStateHolder.kt b/wmshell/src/com/android/wm/shell/common/transition/TransitionStateHolder.kt
new file mode 100644
index 0000000000..4dda2a8942
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/common/transition/TransitionStateHolder.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright 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.common.transition
+
+import com.android.wm.shell.dagger.WMSingleton
+import com.android.wm.shell.recents.RecentsTransitionHandler
+import com.android.wm.shell.recents.RecentsTransitionStateListener
+import com.android.wm.shell.recents.RecentsTransitionStateListener.RecentsTransitionState
+import com.android.wm.shell.recents.RecentsTransitionStateListener.isRunning
+import com.android.wm.shell.sysui.ShellInit
+import javax.inject.Inject
+
+/**
+ * Holder for the state of the transitions.
+ */
+@WMSingleton
+class TransitionStateHolder @Inject constructor(
+ shellInit: ShellInit,
+ private val recentsTransitionHandler: RecentsTransitionHandler
+) {
+
+ @Volatile
+ @RecentsTransitionState
+ private var recentsTransitionState: Int =
+ RecentsTransitionStateListener.TRANSITION_STATE_NOT_RUNNING
+
+ init {
+ shellInit.addInitCallback({ onInit() }, this)
+ }
+
+ fun isRecentsTransitionRunning(): Boolean = isRunning(recentsTransitionState)
+
+ private fun onInit() {
+ recentsTransitionHandler.addTransitionStateListener(
+ object : RecentsTransitionStateListener {
+ override fun onTransitionStateChanged(@RecentsTransitionState state: Int) {
+ recentsTransitionState = state
+ }
+ }
+ )
+ }
+}
diff --git a/wmshell/src/com/android/wm/shell/compatui/CompatUIController.java b/wmshell/src/com/android/wm/shell/compatui/CompatUIController.java
index 2520c25613..a1b9d4830e 100644
--- a/wmshell/src/com/android/wm/shell/compatui/CompatUIController.java
+++ b/wmshell/src/com/android/wm/shell/compatui/CompatUIController.java
@@ -18,9 +18,10 @@ package com.android.wm.shell.compatui;
import static android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN;
+import static com.android.wm.shell.compatui.impl.CompatUIRequestsKt.DISPLAY_COMPAT_SHOW_RESTART_DIALOG;
+
import android.annotation.NonNull;
import android.annotation.Nullable;
-import android.app.CameraCompatTaskInfo.CameraCompatControlState;
import android.app.TaskInfo;
import android.content.ComponentName;
import android.content.Context;
@@ -38,6 +39,7 @@ import android.view.Display;
import android.view.InsetsSourceControl;
import android.view.InsetsState;
import android.view.accessibility.AccessibilityManager;
+import android.window.DesktopModeFlags;
import com.android.internal.annotations.VisibleForTesting;
import com.android.wm.shell.ShellTaskOrganizer;
@@ -50,6 +52,14 @@ import com.android.wm.shell.common.DisplayLayout;
import com.android.wm.shell.common.DockStateReader;
import com.android.wm.shell.common.ShellExecutor;
import com.android.wm.shell.common.SyncTransactionQueue;
+import com.android.wm.shell.compatui.api.CompatUIEvent;
+import com.android.wm.shell.compatui.api.CompatUIHandler;
+import com.android.wm.shell.compatui.api.CompatUIInfo;
+import com.android.wm.shell.compatui.api.CompatUIRequest;
+import com.android.wm.shell.compatui.impl.CompatUIEvents.SizeCompatRestartButtonClicked;
+import com.android.wm.shell.compatui.impl.CompatUIRequests;
+import com.android.wm.shell.desktopmode.DesktopUserRepositories;
+import com.android.wm.shell.shared.desktopmode.DesktopState;
import com.android.wm.shell.sysui.KeyguardChangeListener;
import com.android.wm.shell.sysui.ShellController;
import com.android.wm.shell.sysui.ShellInit;
@@ -61,6 +71,7 @@ import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
+import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
import java.util.function.Function;
@@ -71,17 +82,7 @@ import java.util.function.Predicate;
* activities are in compatibility mode.
*/
public class CompatUIController implements OnDisplaysChangedListener,
- DisplayImeController.ImePositionProcessor, KeyguardChangeListener {
-
- /** Callback for compat UI interaction. */
- public interface CompatUICallback {
- /** Called when the size compat restart button appears. */
- void onSizeCompatRestartButtonAppeared(int taskId);
- /** Called when the size compat restart button is clicked. */
- void onSizeCompatRestartButtonClicked(int taskId);
- /** Called when the camera compat control state is updated. */
- void onCameraControlStateUpdated(int taskId, @CameraCompatControlState int state);
- }
+ DisplayImeController.ImePositionProcessor, KeyguardChangeListener, CompatUIHandler {
private static final String TAG = "CompatUIController";
@@ -110,6 +111,12 @@ public class CompatUIController implements OnDisplaysChangedListener,
private final SparseArray mTaskIdToRestartDialogWindowManagerMap =
new SparseArray<>(0);
+ /**
+ * {@link SparseArray} that maps task ids to {@link CompatUIInfo}.
+ */
+ private final SparseArray mTaskIdToCompatUIInfoMap =
+ new SparseArray<>(0);
+
/**
* {@link Set} of task ids for which we need to display a restart confirmation dialog
*/
@@ -170,7 +177,7 @@ public class CompatUIController implements OnDisplaysChangedListener,
private final Function mDisappearTimeSupplier;
@Nullable
- private CompatUICallback mCompatUICallback;
+ private Consumer mCallback;
// Indicates if the keyguard is currently showing, in which case compat UIs shouldn't
// be shown.
@@ -193,6 +200,17 @@ public class CompatUIController implements OnDisplaysChangedListener,
*/
private boolean mIsFirstReachabilityEducationRunning;
+ private boolean mIsInDesktopMode;
+
+ @NonNull
+ private final CompatUIStatusManager mCompatUIStatusManager;
+
+ @NonNull
+ private final Optional mDesktopUserRepositories;
+
+ @NonNull
+ private final DesktopState mDesktopState;
+
public CompatUIController(@NonNull Context context,
@NonNull ShellInit shellInit,
@NonNull ShellController shellController,
@@ -205,7 +223,10 @@ public class CompatUIController implements OnDisplaysChangedListener,
@NonNull DockStateReader dockStateReader,
@NonNull CompatUIConfiguration compatUIConfiguration,
@NonNull CompatUIShellCommandHandler compatUIShellCommandHandler,
- @NonNull AccessibilityManager accessibilityManager) {
+ @NonNull AccessibilityManager accessibilityManager,
+ @NonNull CompatUIStatusManager compatUIStatusManager,
+ @NonNull Optional desktopUserRepositories,
+ @NonNull DesktopState desktopState) {
mContext = context;
mShellController = shellController;
mDisplayController = displayController;
@@ -220,6 +241,9 @@ public class CompatUIController implements OnDisplaysChangedListener,
mCompatUIShellCommandHandler = compatUIShellCommandHandler;
mDisappearTimeSupplier = flags -> accessibilityManager.getRecommendedTimeoutMillis(
DISAPPEAR_DELAY_MS, flags);
+ mCompatUIStatusManager = compatUIStatusManager;
+ mDesktopUserRepositories = desktopUserRepositories;
+ mDesktopState = desktopState;
shellInit.addInitCallback(this::onInit, this);
}
@@ -230,55 +254,86 @@ public class CompatUIController implements OnDisplaysChangedListener,
mCompatUIShellCommandHandler.onInit();
}
- /** Sets the callback for Compat UI interactions. */
- public void setCompatUICallback(@NonNull CompatUICallback compatUiCallback) {
- mCompatUICallback = compatUiCallback;
+ /** Sets the callback for UI interactions. */
+ @Override
+ public void setCallback(@Nullable Consumer callback) {
+ mCallback = callback;
+ }
+
+ @Override
+ public void sendCompatUIRequest(CompatUIRequest compatUIRequest) {
+ switch(compatUIRequest.getRequestId()) {
+ case DISPLAY_COMPAT_SHOW_RESTART_DIALOG:
+ handleDisplayCompatShowRestartDialog(compatUIRequest.asType());
+ break;
+ default:
+ }
+ }
+
+ private void handleDisplayCompatShowRestartDialog(
+ CompatUIRequests.DisplayCompatShowRestartDialog request) {
+ final CompatUIInfo compatUIInfo = mTaskIdToCompatUIInfoMap.get(request.getTaskId());
+ if (compatUIInfo == null) {
+ return;
+ }
+ onRestartButtonClicked(new Pair<>(compatUIInfo.getTaskInfo(), compatUIInfo.getListener()));
}
/**
* Called when the Task info changed. Creates and updates the compat UI if there is an
* activity in size compat, or removes the UI if there is no size compat activity.
*
- * @param taskInfo {@link TaskInfo} task the activity is in.
- * @param taskListener listener to handle the Task Surface placement.
+ * @param compatUIInfo {@link CompatUIInfo} encapsulates information about the task and listener
*/
- public void onCompatInfoChanged(@NonNull TaskInfo taskInfo,
- @Nullable ShellTaskOrganizer.TaskListener taskListener) {
- if (taskInfo != null && !taskInfo.appCompatTaskInfo.topActivityInSizeCompat) {
+ public void onCompatInfoChanged(@NonNull CompatUIInfo compatUIInfo) {
+ final TaskInfo taskInfo = compatUIInfo.getTaskInfo();
+ final ShellTaskOrganizer.TaskListener taskListener = compatUIInfo.getListener();
+ if (taskListener == null) {
+ mTaskIdToCompatUIInfoMap.delete(taskInfo.taskId);
+ } else {
+ mTaskIdToCompatUIInfoMap.put(taskInfo.taskId, compatUIInfo);
+ }
+ final boolean isInDisplayCompatMode =
+ taskInfo.appCompatTaskInfo.isRestartMenuEnabledForDisplayMove();
+ if (taskInfo != null && !taskInfo.appCompatTaskInfo.isTopActivityInSizeCompat()
+ && !isInDisplayCompatMode) {
mSetOfTaskIdsShowingRestartDialog.remove(taskInfo.taskId);
}
-
- if (taskInfo != null && taskListener != null) {
- updateActiveTaskInfo(taskInfo);
- }
-
- if (taskInfo.configuration == null || taskListener == null) {
+ mIsInDesktopMode = isInDesktopMode(taskInfo);
+ // We close all the Compat UI educations in case TaskInfo has no configuration or
+ // TaskListener or in desktop mode.
+ if (taskInfo.configuration == null || taskListener == null
+ || (mIsInDesktopMode && !isInDisplayCompatMode)) {
// Null token means the current foreground activity is not in compatibility mode.
removeLayouts(taskInfo.taskId);
return;
}
+ if (taskInfo != null && taskListener != null) {
+ updateActiveTaskInfo(taskInfo);
+ }
+
// We're showing the first reachability education so we ignore incoming TaskInfo
// until the education flow has completed or we double tap. The double-tap
// basically cancel all the onboarding flow. We don't have to ignore events in case
// the app is in size compat mode.
if (mIsFirstReachabilityEducationRunning) {
- if (!taskInfo.appCompatTaskInfo.isFromLetterboxDoubleTap
- && !taskInfo.appCompatTaskInfo.topActivityInSizeCompat) {
+ if (!taskInfo.appCompatTaskInfo.isFromLetterboxDoubleTap()
+ && !taskInfo.appCompatTaskInfo.isTopActivityInSizeCompat()) {
return;
}
mIsFirstReachabilityEducationRunning = false;
}
- if (taskInfo.appCompatTaskInfo.topActivityBoundsLetterboxed) {
- if (taskInfo.appCompatTaskInfo.isLetterboxEducationEnabled) {
+ if (taskInfo.appCompatTaskInfo.isTopActivityLetterboxed()) {
+ if (taskInfo.appCompatTaskInfo.isLetterboxEducationEnabled()) {
createOrUpdateLetterboxEduLayout(taskInfo, taskListener);
- } else if (!taskInfo.appCompatTaskInfo.isFromLetterboxDoubleTap) {
+ } else if (!taskInfo.appCompatTaskInfo.isFromLetterboxDoubleTap()) {
// In this case the app is letterboxed and the letterbox education
// is disabled. In this case we need to understand if it's the first
// time we show the reachability education. When this is happening
// we need to ignore all the incoming TaskInfo until the education
// completes. If we come from a double tap we follow the normal flow.
final boolean topActivityPillarboxed =
- taskInfo.appCompatTaskInfo.isTopActivityPillarboxed();
+ taskInfo.appCompatTaskInfo.isTopActivityPillarboxShaped();
final boolean isFirstTimeHorizontalReachabilityEdu = topActivityPillarboxed
&& !mCompatUIConfiguration.hasSeenHorizontalReachabilityEducation(taskInfo);
final boolean isFirstTimeVerticalReachabilityEdu = !topActivityPillarboxed
@@ -288,7 +343,7 @@ public class CompatUIController implements OnDisplaysChangedListener,
// We activate the first reachability education if the double-tap is enabled.
// If the double tap is not enabled (e.g. thin letterbox) we just set the value
// of the education being seen.
- if (taskInfo.appCompatTaskInfo.isLetterboxDoubleTapEnabled) {
+ if (taskInfo.appCompatTaskInfo.isLetterboxDoubleTapEnabled()) {
mIsFirstReachabilityEducationRunning = true;
createOrUpdateReachabilityEduLayout(taskInfo, taskListener);
return;
@@ -299,7 +354,7 @@ public class CompatUIController implements OnDisplaysChangedListener,
createOrUpdateCompatLayout(taskInfo, taskListener);
createOrUpdateRestartDialogLayout(taskInfo, taskListener);
if (mCompatUIConfiguration.getHasSeenLetterboxEducation(taskInfo.userId)) {
- if (taskInfo.appCompatTaskInfo.isLetterboxDoubleTapEnabled) {
+ if (taskInfo.appCompatTaskInfo.isLetterboxDoubleTapEnabled()) {
createOrUpdateReachabilityEduLayout(taskInfo, taskListener);
}
// The user aspect ratio button should not be handled when a new TaskInfo is
@@ -311,7 +366,7 @@ public class CompatUIController implements OnDisplaysChangedListener,
}
return;
}
- if (!taskInfo.appCompatTaskInfo.isFromLetterboxDoubleTap) {
+ if (!taskInfo.appCompatTaskInfo.isFromLetterboxDoubleTap()) {
createOrUpdateUserAspectRatioSettingsLayout(taskInfo, taskListener);
}
}
@@ -351,7 +406,6 @@ public class CompatUIController implements OnDisplaysChangedListener,
mOnInsetsChangedListeners.remove(displayId);
}
-
@Override
public void onDisplayConfigurationChanged(int displayId, Configuration newConfig) {
updateDisplayLayout(displayId);
@@ -436,7 +490,7 @@ public class CompatUIController implements OnDisplaysChangedListener,
@Nullable ShellTaskOrganizer.TaskListener taskListener) {
CompatUIWindowManager layout = mActiveCompatLayouts.get(taskInfo.taskId);
if (layout != null) {
- if (layout.needsToBeRecreated(taskInfo, taskListener)) {
+ if (layout.needsToBeRecreated(taskInfo, taskListener) || mIsInDesktopMode) {
mActiveCompatLayouts.remove(taskInfo.taskId);
layout.release();
} else {
@@ -449,7 +503,10 @@ public class CompatUIController implements OnDisplaysChangedListener,
return;
}
}
-
+ if (mIsInDesktopMode) {
+ // Return if in desktop mode.
+ return;
+ }
// Create a new UI layout.
final Context context = getOrCreateDisplayContext(taskInfo.displayId);
if (context == null) {
@@ -466,9 +523,9 @@ public class CompatUIController implements OnDisplaysChangedListener,
CompatUIWindowManager createCompatUiWindowManager(Context context, TaskInfo taskInfo,
ShellTaskOrganizer.TaskListener taskListener) {
return new CompatUIWindowManager(context,
- taskInfo, mSyncQueue, mCompatUICallback, taskListener,
+ taskInfo, mSyncQueue, mCallback, taskListener,
mDisplayController.getDisplayLayout(taskInfo.displayId), mCompatUIHintsState,
- mCompatUIConfiguration, this::onRestartButtonClicked);
+ mCompatUIConfiguration, this::onRestartButtonClicked, mDesktopState);
}
private void onRestartButtonClicked(
@@ -478,16 +535,17 @@ public class CompatUIController implements OnDisplaysChangedListener,
taskInfoState.first)) {
// We need to show the dialog
mSetOfTaskIdsShowingRestartDialog.add(taskInfoState.first.taskId);
- onCompatInfoChanged(taskInfoState.first, taskInfoState.second);
+ onCompatInfoChanged(new CompatUIInfo(taskInfoState.first, taskInfoState.second));
} else {
- mCompatUICallback.onSizeCompatRestartButtonClicked(taskInfoState.first.taskId);
+ mCallback.accept(new SizeCompatRestartButtonClicked(taskInfoState.first.taskId));
}
}
private void createOrUpdateLetterboxEduLayout(@NonNull TaskInfo taskInfo,
@Nullable ShellTaskOrganizer.TaskListener taskListener) {
if (mActiveLetterboxEduLayout != null) {
- if (mActiveLetterboxEduLayout.needsToBeRecreated(taskInfo, taskListener)) {
+ if (mActiveLetterboxEduLayout.needsToBeRecreated(taskInfo, taskListener)
+ || mIsInDesktopMode) {
mActiveLetterboxEduLayout.release();
mActiveLetterboxEduLayout = null;
} else {
@@ -500,6 +558,10 @@ public class CompatUIController implements OnDisplaysChangedListener,
return;
}
}
+ if (mIsInDesktopMode) {
+ // Return if in desktop mode.
+ return;
+ }
// Create a new UI layout.
final Context context = getOrCreateDisplayContext(taskInfo.displayId);
if (context == null) {
@@ -526,15 +588,18 @@ public class CompatUIController implements OnDisplaysChangedListener,
mSyncQueue, taskListener, mDisplayController.getDisplayLayout(taskInfo.displayId),
mTransitionsLazy.get(),
stateInfo -> createOrUpdateReachabilityEduLayout(stateInfo.first, stateInfo.second),
- mDockStateReader, mCompatUIConfiguration);
+ mDockStateReader, mCompatUIConfiguration, mCompatUIStatusManager);
}
private void createOrUpdateRestartDialogLayout(@NonNull TaskInfo taskInfo,
@Nullable ShellTaskOrganizer.TaskListener taskListener) {
RestartDialogWindowManager layout =
mTaskIdToRestartDialogWindowManagerMap.get(taskInfo.taskId);
+ final boolean isInNonDisplayCompatDesktopMode = mIsInDesktopMode
+ && !taskInfo.appCompatTaskInfo.isRestartMenuEnabledForDisplayMove();
if (layout != null) {
- if (layout.needsToBeRecreated(taskInfo, taskListener)) {
+ if (layout.needsToBeRecreated(taskInfo, taskListener)
+ || isInNonDisplayCompatDesktopMode) {
mTaskIdToRestartDialogWindowManagerMap.remove(taskInfo.taskId);
layout.release();
} else {
@@ -549,6 +614,11 @@ public class CompatUIController implements OnDisplaysChangedListener,
return;
}
}
+ if (isInNonDisplayCompatDesktopMode) {
+ // No restart dialog can be shown in desktop mode unless the task is in display compat
+ // mode.
+ return;
+ }
// Create a new UI layout.
final Context context = getOrCreateDisplayContext(taskInfo.displayId);
if (context == null) {
@@ -575,19 +645,20 @@ public class CompatUIController implements OnDisplaysChangedListener,
private void onRestartDialogCallback(
Pair stateInfo) {
mTaskIdToRestartDialogWindowManagerMap.remove(stateInfo.first.taskId);
- mCompatUICallback.onSizeCompatRestartButtonClicked(stateInfo.first.taskId);
+ mCallback.accept(new SizeCompatRestartButtonClicked(stateInfo.first.taskId));
}
private void onRestartDialogDismissCallback(
Pair stateInfo) {
mSetOfTaskIdsShowingRestartDialog.remove(stateInfo.first.taskId);
- onCompatInfoChanged(stateInfo.first, stateInfo.second);
+ onCompatInfoChanged(new CompatUIInfo(stateInfo.first, stateInfo.second));
}
private void createOrUpdateReachabilityEduLayout(@NonNull TaskInfo taskInfo,
@Nullable ShellTaskOrganizer.TaskListener taskListener) {
if (mActiveReachabilityEduLayout != null) {
- if (mActiveReachabilityEduLayout.needsToBeRecreated(taskInfo, taskListener)) {
+ if (mActiveReachabilityEduLayout.needsToBeRecreated(taskInfo, taskListener)
+ || mIsInDesktopMode) {
mActiveReachabilityEduLayout.release();
mActiveReachabilityEduLayout = null;
} else {
@@ -601,6 +672,10 @@ public class CompatUIController implements OnDisplaysChangedListener,
return;
}
}
+ if (mIsInDesktopMode) {
+ // Return if in desktop mode.
+ return;
+ }
// Create a new UI layout.
final Context context = getOrCreateDisplayContext(taskInfo.displayId);
if (context == null) {
@@ -639,8 +714,10 @@ public class CompatUIController implements OnDisplaysChangedListener,
private void createOrUpdateUserAspectRatioSettingsLayout(@NonNull TaskInfo taskInfo,
@Nullable ShellTaskOrganizer.TaskListener taskListener) {
+ boolean overridesShowAppHandle = mDesktopState.overridesShowAppHandle();
if (mUserAspectRatioSettingsLayout != null) {
- if (mUserAspectRatioSettingsLayout.needsToBeRecreated(taskInfo, taskListener)) {
+ if (mUserAspectRatioSettingsLayout.needsToBeRecreated(taskInfo, taskListener)
+ || mIsInDesktopMode || overridesShowAppHandle) {
mUserAspectRatioSettingsLayout.release();
mUserAspectRatioSettingsLayout = null;
} else {
@@ -653,7 +730,11 @@ public class CompatUIController implements OnDisplaysChangedListener,
return;
}
}
-
+ if (mIsInDesktopMode || overridesShowAppHandle) {
+ // Return if in desktop mode or app handle menu is already showing change aspect ratio
+ // option.
+ return;
+ }
// Create a new UI layout.
final Context context = getOrCreateDisplayContext(taskInfo.displayId);
if (context == null) {
@@ -681,6 +762,12 @@ public class CompatUIController implements OnDisplaysChangedListener,
private void launchUserAspectRatioSettings(
@NonNull TaskInfo taskInfo, @NonNull ShellTaskOrganizer.TaskListener taskListener) {
+ launchUserAspectRatioSettings(mContext, taskInfo);
+ }
+
+ /** Launch the user aspect ratio settings for the package of the given task. */
+ public static void launchUserAspectRatioSettings(
+ @NonNull Context context, @NonNull TaskInfo taskInfo) {
final Intent intent = new Intent(Settings.ACTION_MANAGE_USER_ASPECT_RATIO_SETTINGS);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK);
@@ -690,20 +777,18 @@ public class CompatUIController implements OnDisplaysChangedListener,
intent.setData(packageUri);
}
final UserHandle userHandle = UserHandle.of(taskInfo.userId);
- mContext.startActivityAsUser(intent, userHandle);
+ context.startActivityAsUser(intent, userHandle);
}
- private void removeLayouts(int taskId) {
+ @VisibleForTesting
+ void removeLayouts(int taskId) {
final CompatUIWindowManager compatLayout = mActiveCompatLayouts.get(taskId);
if (compatLayout != null) {
compatLayout.release();
mActiveCompatLayouts.remove(taskId);
}
- if (mActiveLetterboxEduLayout != null && mActiveLetterboxEduLayout.getTaskId() == taskId) {
- mActiveLetterboxEduLayout.release();
- mActiveLetterboxEduLayout = null;
- }
+ removeLetterboxEdu(taskId);
final RestartDialogWindowManager restartLayout =
mTaskIdToRestartDialogWindowManagerMap.get(taskId);
@@ -725,6 +810,16 @@ public class CompatUIController implements OnDisplaysChangedListener,
}
}
+ @VisibleForTesting
+ void removeLetterboxEdu(int taskId) {
+ // When in desktop windowing the dialog will be removed in any case.
+ if (mActiveLetterboxEduLayout != null && (mActiveLetterboxEduLayout.getTaskId() == taskId
+ || mIsInDesktopMode)) {
+ mActiveLetterboxEduLayout.release();
+ mActiveLetterboxEduLayout = null;
+ }
+ }
+
private Context getOrCreateDisplayContext(int displayId) {
if (displayId == Display.DEFAULT_DISPLAY) {
return mContext;
@@ -823,7 +918,16 @@ public class CompatUIController implements OnDisplaysChangedListener,
*/
static class CompatUIHintsState {
boolean mHasShownSizeCompatHint;
- boolean mHasShownCameraCompatHint;
boolean mHasShownUserAspectRatioSettingsButtonHint;
}
+
+ private boolean isInDesktopMode(@Nullable TaskInfo taskInfo) {
+ if (mDesktopUserRepositories.isEmpty() || taskInfo == null) {
+ return false;
+ }
+ boolean isDesktopModeShowing = mDesktopUserRepositories.get().getCurrent()
+ .isAnyDeskActive(taskInfo.displayId);
+ return DesktopModeFlags.ENABLE_DESKTOP_SKIP_COMPAT_UI_EDUCATION_IN_DESKTOP_MODE_BUGFIX
+ .isTrue() && isDesktopModeShowing;
+ }
}
diff --git a/wmshell/src/com/android/wm/shell/compatui/CompatUILayout.java b/wmshell/src/com/android/wm/shell/compatui/CompatUILayout.java
index 2b0bd3272e..688f8ca2dc 100644
--- a/wmshell/src/com/android/wm/shell/compatui/CompatUILayout.java
+++ b/wmshell/src/com/android/wm/shell/compatui/CompatUILayout.java
@@ -16,10 +16,7 @@
package com.android.wm.shell.compatui;
-import static android.app.CameraCompatTaskInfo.CAMERA_COMPAT_CONTROL_TREATMENT_SUGGESTED;
-
import android.annotation.IdRes;
-import android.app.CameraCompatTaskInfo.CameraCompatControlState;
import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
@@ -57,28 +54,10 @@ class CompatUILayout extends LinearLayout {
mWindowManager = windowManager;
}
- void updateCameraTreatmentButton(@CameraCompatControlState int newState) {
- int buttonBkgId = newState == CAMERA_COMPAT_CONTROL_TREATMENT_SUGGESTED
- ? R.drawable.camera_compat_treatment_suggested_ripple
- : R.drawable.camera_compat_treatment_applied_ripple;
- int hintStringId = newState == CAMERA_COMPAT_CONTROL_TREATMENT_SUGGESTED
- ? R.string.camera_compat_treatment_suggested_button_description
- : R.string.camera_compat_treatment_applied_button_description;
- final ImageButton button = findViewById(R.id.camera_compat_treatment_button);
- button.setImageResource(buttonBkgId);
- button.setContentDescription(getResources().getString(hintStringId));
- final LinearLayout hint = findViewById(R.id.camera_compat_hint);
- ((TextView) hint.findViewById(R.id.compat_mode_hint_text)).setText(hintStringId);
- }
-
void setSizeCompatHintVisibility(boolean show) {
setViewVisibility(R.id.size_compat_hint, show);
}
- void setCameraCompatHintVisibility(boolean show) {
- setViewVisibility(R.id.camera_compat_hint, show);
- }
-
void setRestartButtonVisibility(boolean show) {
setViewVisibility(R.id.size_compat_restart_button, show);
// Hint should never be visible without button.
@@ -87,14 +66,6 @@ class CompatUILayout extends LinearLayout {
}
}
- void setCameraControlVisibility(boolean show) {
- setViewVisibility(R.id.camera_compat_control, show);
- // Hint should never be visible without button.
- if (!show) {
- setCameraCompatHintVisibility(/* show= */ false);
- }
- }
-
private void setViewVisibility(@IdRes int resId, boolean show) {
final View view = findViewById(resId);
int visibility = show ? View.VISIBLE : View.GONE;
@@ -127,26 +98,5 @@ class CompatUILayout extends LinearLayout {
((TextView) sizeCompatHint.findViewById(R.id.compat_mode_hint_text))
.setText(R.string.restart_button_description);
sizeCompatHint.setOnClickListener(view -> setSizeCompatHintVisibility(/* show= */ false));
-
- final ImageButton cameraTreatmentButton =
- findViewById(R.id.camera_compat_treatment_button);
- cameraTreatmentButton.setOnClickListener(
- view -> mWindowManager.onCameraTreatmentButtonClicked());
- cameraTreatmentButton.setOnLongClickListener(view -> {
- mWindowManager.onCameraButtonLongClicked();
- return true;
- });
-
- final ImageButton cameraDismissButton = findViewById(R.id.camera_compat_dismiss_button);
- cameraDismissButton.setOnClickListener(
- view -> mWindowManager.onCameraDismissButtonClicked());
- cameraDismissButton.setOnLongClickListener(view -> {
- mWindowManager.onCameraButtonLongClicked();
- return true;
- });
-
- final LinearLayout cameraCompatHint = findViewById(R.id.camera_compat_hint);
- cameraCompatHint.setOnClickListener(
- view -> setCameraCompatHintVisibility(/* show= */ false));
}
}
diff --git a/wmshell/src/com/android/wm/shell/compatui/CompatUIShellCommandHandler.java b/wmshell/src/com/android/wm/shell/compatui/CompatUIShellCommandHandler.java
index 4fb18e27b1..6bf0c2f047 100644
--- a/wmshell/src/com/android/wm/shell/compatui/CompatUIShellCommandHandler.java
+++ b/wmshell/src/com/android/wm/shell/compatui/CompatUIShellCommandHandler.java
@@ -27,8 +27,7 @@ import javax.inject.Inject;
/**
* Handles the shell commands for the CompatUX.
*
- * Use with {@code adb shell dumpsys activity service SystemUIService WMShell compatui
- * <command>}.
+ *
Use with {@code adb shell wm shell compatui <command>}.
*/
@WMSingleton
public final class CompatUIShellCommandHandler implements
diff --git a/wmshell/src/com/android/wm/shell/compatui/CompatUIStatusManager.java b/wmshell/src/com/android/wm/shell/compatui/CompatUIStatusManager.java
new file mode 100644
index 0000000000..37369d1f30
--- /dev/null
+++ b/wmshell/src/com/android/wm/shell/compatui/CompatUIStatusManager.java
@@ -0,0 +1,72 @@
+/*
+ * 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.compatui;
+
+import android.annotation.NonNull;
+
+import java.util.function.IntConsumer;
+import java.util.function.IntSupplier;
+
+/** Handle the visibility state of the Compat UI components. */
+public class CompatUIStatusManager {
+
+ private static final int COMPAT_UI_EDUCATION_UNDEFINED = -1;
+ public static final int COMPAT_UI_EDUCATION_HIDDEN = 0;
+ public static final int COMPAT_UI_EDUCATION_VISIBLE = 1;
+
+ @NonNull
+ private final IntConsumer mWriter;
+ @NonNull
+ private final IntSupplier mReader;
+
+ private int mCurrentValue = COMPAT_UI_EDUCATION_UNDEFINED;
+
+ public CompatUIStatusManager(@NonNull IntConsumer writer, @NonNull IntSupplier reader) {
+ mWriter = writer;
+ mReader = reader;
+ }
+
+ public CompatUIStatusManager() {
+ this(i -> {
+ }, () -> COMPAT_UI_EDUCATION_HIDDEN);
+ }
+
+ void onEducationShown() {
+ if (mCurrentValue != COMPAT_UI_EDUCATION_VISIBLE) {
+ mCurrentValue = COMPAT_UI_EDUCATION_VISIBLE;
+ mWriter.accept(mCurrentValue);
+ }
+ }
+
+ void onEducationHidden() {
+ if (mCurrentValue != COMPAT_UI_EDUCATION_HIDDEN) {
+ mCurrentValue = COMPAT_UI_EDUCATION_HIDDEN;
+ mWriter.accept(mCurrentValue);
+ }
+ }
+
+ boolean isEducationVisible() {
+ return getCurrentValue() == COMPAT_UI_EDUCATION_VISIBLE;
+ }
+
+ private int getCurrentValue() {
+ if (mCurrentValue == COMPAT_UI_EDUCATION_UNDEFINED) {
+ mCurrentValue = mReader.getAsInt();
+ }
+ return mCurrentValue;
+ }
+}
\ No newline at end of file
diff --git a/wmshell/src/com/android/wm/shell/compatui/CompatUIWindowManager.java b/wmshell/src/com/android/wm/shell/compatui/CompatUIWindowManager.java
index 3ab1fad2b2..875105dd3d 100644
--- a/wmshell/src/com/android/wm/shell/compatui/CompatUIWindowManager.java
+++ b/wmshell/src/com/android/wm/shell/compatui/CompatUIWindowManager.java
@@ -16,32 +16,29 @@
package com.android.wm.shell.compatui;
-import static android.app.CameraCompatTaskInfo.CAMERA_COMPAT_CONTROL_DISMISSED;
-import static android.app.CameraCompatTaskInfo.CAMERA_COMPAT_CONTROL_HIDDEN;
-import static android.app.CameraCompatTaskInfo.CAMERA_COMPAT_CONTROL_TREATMENT_APPLIED;
-import static android.app.CameraCompatTaskInfo.CAMERA_COMPAT_CONTROL_TREATMENT_SUGGESTED;
import static android.view.WindowManager.LARGE_SCREEN_SMALLEST_SCREEN_WIDTH_DP;
import static android.window.TaskConstants.TASK_CHILD_LAYER_COMPAT_UI;
import android.annotation.NonNull;
import android.annotation.Nullable;
-import android.app.CameraCompatTaskInfo.CameraCompatControlState;
import android.app.TaskInfo;
import android.content.Context;
import android.graphics.Rect;
-import android.util.Log;
import android.util.Pair;
import android.view.LayoutInflater;
+import android.view.SurfaceControl;
import android.view.View;
+import android.window.DesktopModeFlags;
import com.android.internal.annotations.VisibleForTesting;
-import com.android.window.flags.Flags;
import com.android.wm.shell.R;
import com.android.wm.shell.ShellTaskOrganizer;
import com.android.wm.shell.common.DisplayLayout;
import com.android.wm.shell.common.SyncTransactionQueue;
-import com.android.wm.shell.compatui.CompatUIController.CompatUICallback;
import com.android.wm.shell.compatui.CompatUIController.CompatUIHintsState;
+import com.android.wm.shell.compatui.api.CompatUIEvent;
+import com.android.wm.shell.compatui.impl.CompatUIEvents.SizeCompatRestartButtonAppeared;
+import com.android.wm.shell.shared.desktopmode.DesktopState;
import java.util.function.Consumer;
@@ -50,10 +47,13 @@ import java.util.function.Consumer;
*/
class CompatUIWindowManager extends CompatUIWindowManagerAbstract {
- private final CompatUICallback mCallback;
+ @NonNull
+ private final Consumer mCallback;
+ @NonNull
private final CompatUIConfiguration mCompatUIConfiguration;
+ @NonNull
private final Consumer> mOnRestartButtonClicked;
// Remember the last reported states in case visibility changes due to keyguard or IME updates.
@@ -61,10 +61,7 @@ class CompatUIWindowManager extends CompatUIWindowManagerAbstract {
boolean mHasSizeCompat;
@VisibleForTesting
- @CameraCompatControlState
- int mCameraCompatControlState = CAMERA_COMPAT_CONTROL_HIDDEN;
-
- @VisibleForTesting
+ @NonNull
CompatUIHintsState mCompatUIHintsState;
@Nullable
@@ -73,24 +70,35 @@ class CompatUIWindowManager extends CompatUIWindowManagerAbstract {
private final float mHideScmTolerance;
- CompatUIWindowManager(Context context, TaskInfo taskInfo,
- SyncTransactionQueue syncQueue, CompatUICallback callback,
- ShellTaskOrganizer.TaskListener taskListener, DisplayLayout displayLayout,
- CompatUIHintsState compatUIHintsState, CompatUIConfiguration compatUIConfiguration,
- Consumer> onRestartButtonClicked) {
+ @NonNull
+ private final Rect mLayoutBounds = new Rect();
+
+ @NonNull
+ private final DesktopState mDesktopState;
+
+ CompatUIWindowManager(@NonNull Context context, @NonNull TaskInfo taskInfo,
+ @NonNull SyncTransactionQueue syncQueue,
+ @NonNull Consumer callback,
+ @Nullable ShellTaskOrganizer.TaskListener taskListener,
+ @Nullable DisplayLayout displayLayout,
+ @NonNull CompatUIHintsState compatUIHintsState,
+ @NonNull CompatUIConfiguration compatUIConfiguration,
+ @NonNull Consumer>
+ onRestartButtonClicked,
+ @NonNull DesktopState desktopState) {
super(context, taskInfo, syncQueue, taskListener, displayLayout);
mCallback = callback;
- mHasSizeCompat = taskInfo.appCompatTaskInfo.topActivityInSizeCompat;
- if (Flags.enableDesktopWindowingMode() && Flags.enableWindowingDynamicInitialBounds()) {
+ mHasSizeCompat = taskInfo.appCompatTaskInfo.isTopActivityInSizeCompat();
+ if (desktopState.canEnterDesktopMode()
+ && DesktopModeFlags.ENABLE_WINDOWING_DYNAMIC_INITIAL_BOUNDS.isTrue()) {
// Don't show the SCM button for freeform tasks
mHasSizeCompat &= !taskInfo.isFreeform();
}
- mCameraCompatControlState =
- taskInfo.appCompatTaskInfo.cameraCompatTaskInfo.cameraCompatControlState;
mCompatUIHintsState = compatUIHintsState;
mCompatUIConfiguration = compatUIConfiguration;
mOnRestartButtonClicked = onRestartButtonClicked;
mHideScmTolerance = mCompatUIConfiguration.getHideSizeCompatRestartButtonTolerance();
+ mDesktopState = desktopState;
}
@Override
@@ -105,13 +113,13 @@ class CompatUIWindowManager extends CompatUIWindowManagerAbstract {
@Override
protected void removeLayout() {
+ mLayoutBounds.setEmpty();
mLayout = null;
}
@Override
protected boolean eligibleToShowLayout() {
- return (mHasSizeCompat && shouldShowSizeCompatRestartButton(getLastTaskInfo()))
- || shouldShowCameraControl();
+ return mHasSizeCompat && shouldShowSizeCompatRestartButton(getLastTaskInfo());
}
@Override
@@ -122,7 +130,7 @@ class CompatUIWindowManager extends CompatUIWindowManagerAbstract {
updateVisibilityOfViews();
if (mHasSizeCompat) {
- mCallback.onSizeCompatRestartButtonAppeared(mTaskId);
+ mCallback.accept(new SizeCompatRestartButtonAppeared(mTaskId));
}
return mLayout;
@@ -138,21 +146,18 @@ class CompatUIWindowManager extends CompatUIWindowManagerAbstract {
public boolean updateCompatInfo(TaskInfo taskInfo, ShellTaskOrganizer.TaskListener taskListener,
boolean canShow) {
final boolean prevHasSizeCompat = mHasSizeCompat;
- final int prevCameraCompatControlState = mCameraCompatControlState;
- mHasSizeCompat = taskInfo.appCompatTaskInfo.topActivityInSizeCompat;
- if (Flags.enableDesktopWindowingMode() && Flags.enableWindowingDynamicInitialBounds()) {
+ mHasSizeCompat = taskInfo.appCompatTaskInfo.isTopActivityInSizeCompat();
+ if (mDesktopState.canEnterDesktopMode()
+ && DesktopModeFlags.ENABLE_WINDOWING_DYNAMIC_INITIAL_BOUNDS.isTrue()) {
// Don't show the SCM button for freeform tasks
mHasSizeCompat &= !taskInfo.isFreeform();
}
- mCameraCompatControlState =
- taskInfo.appCompatTaskInfo.cameraCompatTaskInfo.cameraCompatControlState;
if (!super.updateCompatInfo(taskInfo, taskListener, canShow)) {
return false;
}
- if (prevHasSizeCompat != mHasSizeCompat
- || prevCameraCompatControlState != mCameraCompatControlState) {
+ if (prevHasSizeCompat != mHasSizeCompat) {
updateVisibilityOfViews();
}
@@ -164,34 +169,6 @@ class CompatUIWindowManager extends CompatUIWindowManagerAbstract {
mOnRestartButtonClicked.accept(Pair.create(getLastTaskInfo(), getTaskListener()));
}
- /** Called when the camera treatment button is clicked. */
- void onCameraTreatmentButtonClicked() {
- if (!shouldShowCameraControl()) {
- Log.w(getTag(), "Camera compat shouldn't receive clicks in the hidden state.");
- return;
- }
- // When a camera control is shown, only two states are allowed: "treament applied" and
- // "treatment suggested". Clicks on the conrol's treatment button toggle between these
- // two states.
- mCameraCompatControlState =
- mCameraCompatControlState == CAMERA_COMPAT_CONTROL_TREATMENT_SUGGESTED
- ? CAMERA_COMPAT_CONTROL_TREATMENT_APPLIED
- : CAMERA_COMPAT_CONTROL_TREATMENT_SUGGESTED;
- mCallback.onCameraControlStateUpdated(mTaskId, mCameraCompatControlState);
- mLayout.updateCameraTreatmentButton(mCameraCompatControlState);
- }
-
- /** Called when the camera dismiss button is clicked. */
- void onCameraDismissButtonClicked() {
- if (!shouldShowCameraControl()) {
- Log.w(getTag(), "Camera compat shouldn't receive clicks in the hidden state.");
- return;
- }
- mCameraCompatControlState = CAMERA_COMPAT_CONTROL_DISMISSED;
- mCallback.onCameraControlStateUpdated(mTaskId, CAMERA_COMPAT_CONTROL_DISMISSED);
- mLayout.setCameraControlVisibility(/* show= */ false);
- }
-
/** Called when the restart button is long clicked. */
void onRestartButtonLongClicked() {
if (mLayout == null) {
@@ -200,36 +177,30 @@ class CompatUIWindowManager extends CompatUIWindowManagerAbstract {
mLayout.setSizeCompatHintVisibility(/* show= */ true);
}
- /** Called when either dismiss or treatment camera buttons is long clicked. */
- void onCameraButtonLongClicked() {
- if (mLayout == null) {
+ @Override
+ @VisibleForTesting
+ public void updateSurfacePosition() {
+ updateLayoutBounds();
+ if (mLayoutBounds.isEmpty()) {
return;
}
- mLayout.setCameraCompatHintVisibility(/* show= */ true);
+ updateSurfacePosition(mLayoutBounds.left, mLayoutBounds.top);
}
@Override
@VisibleForTesting
- public void updateSurfacePosition() {
- if (mLayout == null) {
+ public void updateSurfacePosition(@NonNull SurfaceControl.Transaction tx) {
+ updateLayoutBounds();
+ if (mLayoutBounds.isEmpty()) {
return;
}
- // Position of the button in the container coordinate.
- final Rect taskBounds = getTaskBounds();
- final Rect taskStableBounds = getTaskStableBounds();
- final int positionX = getLayoutDirection() == View.LAYOUT_DIRECTION_RTL
- ? taskStableBounds.left - taskBounds.left
- : taskStableBounds.right - taskBounds.left - mLayout.getMeasuredWidth();
- final int positionY = taskStableBounds.bottom - taskBounds.top
- - mLayout.getMeasuredHeight();
- updateSurfacePosition(positionX, positionY);
+ updateSurfaceBounds(tx, mLayoutBounds);
}
@VisibleForTesting
boolean shouldShowSizeCompatRestartButton(@NonNull TaskInfo taskInfo) {
// Always show button if display is phone sized.
- if (!Flags.allowHideScmButton() || taskInfo.configuration.smallestScreenWidthDp
- < LARGE_SCREEN_SMALLEST_SCREEN_WIDTH_DP) {
+ if (taskInfo.configuration.smallestScreenWidthDp < LARGE_SCREEN_SMALLEST_SCREEN_WIDTH_DP) {
return true;
}
@@ -255,9 +226,27 @@ class CompatUIWindowManager extends CompatUIWindowManagerAbstract {
return false;
}
final float percentageAreaOfLetterboxInTask = (float) letterboxArea / taskArea * 100;
+
return percentageAreaOfLetterboxInTask < mHideScmTolerance;
}
+ private void updateLayoutBounds() {
+ if (mLayout == null) {
+ mLayoutBounds.setEmpty();
+ return;
+ }
+ // Position of the button in the container coordinate.
+ final Rect taskBounds = getTaskBounds();
+ final Rect taskStableBounds = getTaskStableBounds();
+ final int layoutWidth = mLayout.getMeasuredWidth();
+ final int layoutHeight = mLayout.getMeasuredHeight();
+ final int positionX = getLayoutDirection() == View.LAYOUT_DIRECTION_RTL
+ ? taskStableBounds.left - taskBounds.left
+ : taskStableBounds.right - taskBounds.left - layoutWidth;
+ final int positionY = taskStableBounds.bottom - taskBounds.top - layoutHeight;
+ mLayoutBounds.set(positionX, positionY, positionX + layoutWidth, positionY + layoutHeight);
+ }
+
private void updateVisibilityOfViews() {
if (mLayout == null) {
return;
@@ -269,21 +258,5 @@ class CompatUIWindowManager extends CompatUIWindowManagerAbstract {
mLayout.setSizeCompatHintVisibility(/* show= */ true);
mCompatUIHintsState.mHasShownSizeCompatHint = true;
}
-
- // Camera control for stretched issues.
- mLayout.setCameraControlVisibility(shouldShowCameraControl());
- // Only show by default for the first time.
- if (shouldShowCameraControl() && !mCompatUIHintsState.mHasShownCameraCompatHint) {
- mLayout.setCameraCompatHintVisibility(/* show= */ true);
- mCompatUIHintsState.mHasShownCameraCompatHint = true;
- }
- if (shouldShowCameraControl()) {
- mLayout.updateCameraTreatmentButton(mCameraCompatControlState);
- }
- }
-
- private boolean shouldShowCameraControl() {
- return mCameraCompatControlState != CAMERA_COMPAT_CONTROL_HIDDEN
- && mCameraCompatControlState != CAMERA_COMPAT_CONTROL_DISMISSED;
}
}
diff --git a/wmshell/src/com/android/wm/shell/compatui/CompatUIWindowManagerAbstract.java b/wmshell/src/com/android/wm/shell/compatui/CompatUIWindowManagerAbstract.java
index 0564c95aef..82acfe5478 100644
--- a/wmshell/src/com/android/wm/shell/compatui/CompatUIWindowManagerAbstract.java
+++ b/wmshell/src/com/android/wm/shell/compatui/CompatUIWindowManagerAbstract.java
@@ -38,12 +38,12 @@ import android.util.Log;
import android.view.IWindow;
import android.view.SurfaceControl;
import android.view.SurfaceControlViewHost;
-import android.view.SurfaceSession;
import android.view.View;
import android.view.WindowManager;
import android.view.WindowlessWindowManager;
import com.android.internal.annotations.VisibleForTesting;
+import com.android.window.flags.Flags;
import com.android.wm.shell.ShellTaskOrganizer;
import com.android.wm.shell.common.DisplayLayout;
import com.android.wm.shell.common.SyncTransactionQueue;
@@ -173,7 +173,7 @@ public abstract class CompatUIWindowManagerAbstract extends WindowlessWindowMana
@Override
protected SurfaceControl getParentSurface(IWindow window, WindowManager.LayoutParams attrs) {
String className = getClass().getSimpleName();
- final SurfaceControl.Builder builder = new SurfaceControl.Builder(new SurfaceSession())
+ final SurfaceControl.Builder builder = new SurfaceControl.Builder()
.setContainerLayer()
.setName(className + "Leash")
.setHidden(false)
@@ -328,8 +328,15 @@ public abstract class CompatUIWindowManagerAbstract extends WindowlessWindowMana
if (mViewHost == null) {
return;
}
- mViewHost.relayout(windowLayoutParams);
- updateSurfacePosition();
+ if (Flags.appCompatAsyncRelayout()) {
+ mViewHost.relayout(windowLayoutParams, tx -> {
+ updateSurfacePosition(tx);
+ tx.apply();
+ });
+ } else {
+ mViewHost.relayout(windowLayoutParams);
+ updateSurfacePosition();
+ }
}
@NonNull
@@ -350,6 +357,10 @@ public abstract class CompatUIWindowManagerAbstract extends WindowlessWindowMana
*/
protected abstract void updateSurfacePosition();
+ protected void updateSurfacePosition(@NonNull SurfaceControl.Transaction tx) {
+
+ }
+
/**
* Updates the position of the surface with respect to the given {@code positionX} and {@code
* positionY}.
@@ -367,6 +378,15 @@ public abstract class CompatUIWindowManagerAbstract extends WindowlessWindowMana
});
}
+ protected void updateSurfaceBounds(@NonNull SurfaceControl.Transaction tx,
+ @NonNull Rect bounds) {
+ if (mLeash == null) {
+ return;
+ }
+ tx.setPosition(mLeash, bounds.left, bounds.top)
+ .setWindowCrop(mLeash, bounds.width(), bounds.height());
+ }
+
protected int getLayoutDirection() {
return mContext.getResources().getConfiguration().getLayoutDirection();
}
diff --git a/wmshell/src/com/android/wm/shell/compatui/DialogAnimationController.java b/wmshell/src/com/android/wm/shell/compatui/DialogAnimationController.java
index 7475feac5b..7d86063b89 100644
--- a/wmshell/src/com/android/wm/shell/compatui/DialogAnimationController.java
+++ b/wmshell/src/com/android/wm/shell/compatui/DialogAnimationController.java
@@ -18,7 +18,6 @@ package com.android.wm.shell.compatui;
import static com.android.internal.R.styleable.WindowAnimation_windowEnterAnimation;
import static com.android.internal.R.styleable.WindowAnimation_windowExitAnimation;
-import static com.android.wm.shell.transition.Transitions.ENABLE_SHELL_TRANSITIONS;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
@@ -48,9 +47,7 @@ public class DialogAnimationController {
// 204 is simply 255 * 0.8.
static final int BACKGROUND_DIM_ALPHA = 204;
- // If shell transitions are enabled, startEnterAnimation will be called after all transitions
- // have finished, and therefore the start delay should be shorter.
- private static final int ENTER_ANIM_START_DELAY_MILLIS = ENABLE_SHELL_TRANSITIONS ? 300 : 500;
+ private static final int ENTER_ANIM_START_DELAY_MILLIS = 300;
private final TransitionAnimation mTransitionAnimation;
private final String mPackageName;
diff --git a/wmshell/src/com/android/wm/shell/compatui/LetterboxEduWindowManager.java b/wmshell/src/com/android/wm/shell/compatui/LetterboxEduWindowManager.java
index 623feada01..3124a39716 100644
--- a/wmshell/src/com/android/wm/shell/compatui/LetterboxEduWindowManager.java
+++ b/wmshell/src/com/android/wm/shell/compatui/LetterboxEduWindowManager.java
@@ -19,6 +19,7 @@ package com.android.wm.shell.compatui;
import static android.provider.Settings.Secure.LAUNCHER_TASKBAR_EDUCATION_SHOWING;
import static android.window.TaskConstants.TASK_CHILD_LAYER_COMPAT_UI;
+import android.annotation.NonNull;
import android.annotation.Nullable;
import android.app.TaskInfo;
import android.content.Context;
@@ -76,15 +77,19 @@ class LetterboxEduWindowManager extends CompatUIWindowManagerAbstract {
private final DockStateReader mDockStateReader;
+ @NonNull
+ private final CompatUIStatusManager mCompatUIStatusManager;
+
LetterboxEduWindowManager(Context context, TaskInfo taskInfo,
SyncTransactionQueue syncQueue, ShellTaskOrganizer.TaskListener taskListener,
DisplayLayout displayLayout, Transitions transitions,
Consumer