1 /* <lambda>null2 * Copyright (C) 2024 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.android.wm.shell.desktopmode 17 18 import android.animation.RectEvaluator 19 import android.animation.ValueAnimator 20 import android.app.ActivityManager.RunningTaskInfo 21 import android.graphics.Rect 22 import android.os.IBinder 23 import android.view.SurfaceControl 24 import android.view.WindowManager.TRANSIT_CHANGE 25 import android.view.animation.DecelerateInterpolator 26 import android.window.DesktopModeFlags 27 import android.window.DesktopModeFlags.ENABLE_WINDOWING_DYNAMIC_INITIAL_BOUNDS 28 import android.window.TransitionInfo 29 import android.window.TransitionRequestInfo 30 import android.window.WindowContainerTransaction 31 import androidx.core.animation.addListener 32 import com.android.internal.annotations.VisibleForTesting 33 import com.android.internal.protolog.ProtoLog 34 import com.android.window.flags.Flags 35 import com.android.wm.shell.ShellTaskOrganizer 36 import com.android.wm.shell.common.DisplayController 37 import com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_DESKTOP_MODE 38 import com.android.wm.shell.sysui.ShellCommandHandler 39 import com.android.wm.shell.sysui.ShellInit 40 import com.android.wm.shell.transition.Transitions 41 import com.android.wm.shell.transition.Transitions.TransitionHandler 42 import com.android.wm.shell.transition.Transitions.TransitionObserver 43 import com.android.wm.shell.windowdecor.OnTaskResizeAnimationListener 44 import java.io.PrintWriter 45 46 /** 47 * A controller to move tasks in/out of desktop's full immersive state where the task remains 48 * freeform while being able to take fullscreen bounds and have its App Header visibility be 49 * transient below the status bar like in fullscreen immersive mode. 50 */ 51 class DesktopImmersiveController( 52 shellInit: ShellInit, 53 private val transitions: Transitions, 54 private val desktopUserRepositories: DesktopUserRepositories, 55 private val displayController: DisplayController, 56 private val shellTaskOrganizer: ShellTaskOrganizer, 57 private val shellCommandHandler: ShellCommandHandler, 58 private val transactionSupplier: () -> SurfaceControl.Transaction, 59 ) : TransitionHandler, TransitionObserver { 60 61 constructor( 62 shellInit: ShellInit, 63 transitions: Transitions, 64 desktopUserRepositories: DesktopUserRepositories, 65 displayController: DisplayController, 66 shellTaskOrganizer: ShellTaskOrganizer, 67 shellCommandHandler: ShellCommandHandler, 68 ) : this( 69 shellInit, 70 transitions, 71 desktopUserRepositories, 72 displayController, 73 shellTaskOrganizer, 74 shellCommandHandler, 75 { SurfaceControl.Transaction() }, 76 ) 77 78 @VisibleForTesting val pendingImmersiveTransitions = mutableListOf<PendingTransition>() 79 80 /** Whether there is an immersive transition that hasn't completed yet. */ 81 private val inProgress: Boolean 82 get() = pendingImmersiveTransitions.isNotEmpty() 83 84 private val rectEvaluator = RectEvaluator() 85 86 /** A listener to invoke on animation changes during entry/exit. */ 87 var onTaskResizeAnimationListener: OnTaskResizeAnimationListener? = null 88 89 init { 90 shellInit.addInitCallback({ onInit() }, this) 91 } 92 93 fun onInit() { 94 shellCommandHandler.addDumpCallback(this::dump, this) 95 } 96 97 /** Starts a transition to enter full immersive state inside the desktop. */ 98 fun moveTaskToImmersive(taskInfo: RunningTaskInfo) { 99 check(taskInfo.isFreeform) { "Task must already be in freeform" } 100 if (inProgress) { 101 logV( 102 "Cannot start entry because transition(s) already in progress: %s", 103 pendingImmersiveTransitions, 104 ) 105 return 106 } 107 val wct = WindowContainerTransaction().apply { setBounds(taskInfo.token, Rect()) } 108 logV("Moving task ${taskInfo.taskId} into immersive mode") 109 val transition = transitions.startTransition(TRANSIT_CHANGE, wct, /* handler= */ this) 110 addPendingImmersiveTransition( 111 taskId = taskInfo.taskId, 112 displayId = taskInfo.displayId, 113 direction = Direction.ENTER, 114 transition = transition, 115 ) 116 } 117 118 /** Starts a transition to move an immersive task out of immersive. */ 119 fun moveTaskToNonImmersive(taskInfo: RunningTaskInfo, reason: ExitReason) { 120 check(taskInfo.isFreeform) { "Task must already be in freeform" } 121 if (inProgress) { 122 logV( 123 "Cannot start exit because transition(s) already in progress: %s", 124 pendingImmersiveTransitions, 125 ) 126 return 127 } 128 129 val wct = 130 WindowContainerTransaction().apply { 131 setBounds(taskInfo.token, getExitDestinationBounds(taskInfo)) 132 } 133 logV("Moving task %d out of immersive mode, reason: %s", taskInfo.taskId, reason) 134 val transition = transitions.startTransition(TRANSIT_CHANGE, wct, /* handler= */ this) 135 addPendingImmersiveTransition( 136 taskId = taskInfo.taskId, 137 displayId = taskInfo.displayId, 138 direction = Direction.EXIT, 139 transition = transition, 140 ) 141 } 142 143 /** 144 * Bring the immersive app of the given [displayId] out of immersive mode, if applicable. 145 * 146 * @param transition that will apply this transaction 147 * @param wct that will apply these changes 148 * @param displayId of the display that should exit immersive mode 149 */ 150 fun exitImmersiveIfApplicable( 151 transition: IBinder, 152 wct: WindowContainerTransaction, 153 displayId: Int, 154 reason: ExitReason, 155 ) { 156 if (!DesktopModeFlags.ENABLE_FULLY_IMMERSIVE_IN_DESKTOP.isTrue) return 157 val result = exitImmersiveIfApplicable(wct, displayId, excludeTaskId = null, reason) 158 result.asExit()?.runOnTransitionStart?.invoke(transition) 159 } 160 161 /** 162 * Bring the immersive app of the given [displayId] out of immersive mode, if applicable. 163 * 164 * @param wct that will apply these changes 165 * @param displayId of the display that should exit immersive mode 166 * @param excludeTaskId of the task to ignore (not exit) if it is the immersive one 167 * @return a function to apply once the transition that will apply these changes is started 168 */ 169 fun exitImmersiveIfApplicable( 170 wct: WindowContainerTransaction, 171 displayId: Int, 172 excludeTaskId: Int? = null, 173 reason: ExitReason, 174 ): ExitResult { 175 if (!DesktopModeFlags.ENABLE_FULLY_IMMERSIVE_IN_DESKTOP.isTrue) return ExitResult.NoExit 176 val immersiveTask = 177 desktopUserRepositories.current.getTaskInFullImmersiveState(displayId) 178 ?: return ExitResult.NoExit 179 if (immersiveTask == excludeTaskId) { 180 return ExitResult.NoExit 181 } 182 val taskInfo = 183 shellTaskOrganizer.getRunningTaskInfo(immersiveTask) ?: return ExitResult.NoExit 184 logV( 185 "Appending immersive exit for task: %d in display: %d for reason: %s", 186 immersiveTask, 187 displayId, 188 reason, 189 ) 190 wct.setBounds(taskInfo.token, getExitDestinationBounds(taskInfo)) 191 return ExitResult.Exit( 192 exitingTask = immersiveTask, 193 runOnTransitionStart = { transition -> 194 addPendingImmersiveTransition( 195 taskId = immersiveTask, 196 displayId = displayId, 197 direction = Direction.EXIT, 198 transition = transition, 199 animate = false, 200 ) 201 }, 202 ) 203 } 204 205 /** 206 * Bring the given [taskInfo] out of immersive mode, if applicable. 207 * 208 * @param wct that will apply these changes 209 * @param taskInfo of the task that should exit immersive mode 210 * @return a function to apply once the transition that will apply these changes is started 211 */ 212 fun exitImmersiveIfApplicable( 213 wct: WindowContainerTransaction, 214 taskInfo: RunningTaskInfo, 215 reason: ExitReason, 216 ): ExitResult { 217 if (!DesktopModeFlags.ENABLE_FULLY_IMMERSIVE_IN_DESKTOP.isTrue) return ExitResult.NoExit 218 if (desktopUserRepositories.current.isTaskInFullImmersiveState(taskInfo.taskId)) { 219 // A full immersive task is being minimized, make sure the immersive state is broken 220 // (i.e. resize back to max bounds). 221 wct.setBounds(taskInfo.token, getExitDestinationBounds(taskInfo)) 222 logV("Appending immersive exit for task: %d for reason: %s", taskInfo.taskId, reason) 223 return ExitResult.Exit( 224 exitingTask = taskInfo.taskId, 225 runOnTransitionStart = { transition -> 226 addPendingImmersiveTransition( 227 taskId = taskInfo.taskId, 228 displayId = taskInfo.displayId, 229 direction = Direction.EXIT, 230 transition = transition, 231 animate = false, 232 ) 233 }, 234 ) 235 } 236 return ExitResult.NoExit 237 } 238 239 /** Whether the [change] in the [transition] is a known immersive change. */ 240 fun isImmersiveChange(transition: IBinder, change: TransitionInfo.Change): Boolean { 241 return pendingImmersiveTransitions.any { 242 it.transition == transition && it.taskId == change.taskInfo?.taskId 243 } 244 } 245 246 private fun addPendingImmersiveTransition( 247 taskId: Int, 248 displayId: Int, 249 direction: Direction, 250 transition: IBinder, 251 animate: Boolean = true, 252 ) { 253 pendingImmersiveTransitions.add( 254 PendingTransition( 255 taskId = taskId, 256 displayId = displayId, 257 direction = direction, 258 transition = transition, 259 animate = animate, 260 ) 261 ) 262 } 263 264 override fun startAnimation( 265 transition: IBinder, 266 info: TransitionInfo, 267 startTransaction: SurfaceControl.Transaction, 268 finishTransaction: SurfaceControl.Transaction, 269 finishCallback: Transitions.TransitionFinishCallback, 270 ): Boolean { 271 val immersiveTransition = getImmersiveTransition(transition) ?: return false 272 if (!immersiveTransition.animate) return false 273 logD("startAnimation transition=%s", transition) 274 animateResize( 275 targetTaskId = immersiveTransition.taskId, 276 info = info, 277 startTransaction = startTransaction, 278 finishTransaction = finishTransaction, 279 finishCallback = { 280 finishCallback.onTransitionFinished(/* wct= */ null) 281 pendingImmersiveTransitions.remove(immersiveTransition) 282 }, 283 ) 284 return true 285 } 286 287 private fun animateResize( 288 targetTaskId: Int, 289 info: TransitionInfo, 290 startTransaction: SurfaceControl.Transaction, 291 finishTransaction: SurfaceControl.Transaction, 292 finishCallback: Transitions.TransitionFinishCallback, 293 ) { 294 logD("animateResize for task#%d", targetTaskId) 295 val change = 296 info.changes.firstOrNull { c -> 297 val taskInfo = c.taskInfo 298 return@firstOrNull taskInfo != null && taskInfo.taskId == targetTaskId 299 } 300 if (change == null) { 301 logD("Did not find change for task#%d to animate", targetTaskId) 302 startTransaction.apply() 303 finishCallback.onTransitionFinished(/* wct= */ null) 304 return 305 } 306 animateResizeChange(change, startTransaction, finishTransaction, finishCallback) 307 } 308 309 /** 310 * Animate an immersive change. 311 * 312 * As of now, both enter and exit transitions have the same animation, a veiled resize. 313 */ 314 fun animateResizeChange( 315 change: TransitionInfo.Change, 316 startTransaction: SurfaceControl.Transaction, 317 finishTransaction: SurfaceControl.Transaction, 318 finishCallback: Transitions.TransitionFinishCallback, 319 ) { 320 val taskId = change.taskInfo!!.taskId 321 val leash = change.leash 322 val startBounds = change.startAbsBounds 323 val endBounds = change.endAbsBounds 324 logD("Animating resize change for task#%d from %s to %s", taskId, startBounds, endBounds) 325 326 startTransaction 327 .setPosition(leash, startBounds.left.toFloat(), startBounds.top.toFloat()) 328 .setWindowCrop(leash, startBounds.width(), startBounds.height()) 329 .show(leash) 330 onTaskResizeAnimationListener?.onAnimationStart(taskId, startTransaction, startBounds) 331 ?: startTransaction.apply() 332 val updateTransaction = transactionSupplier() 333 ValueAnimator.ofObject(rectEvaluator, startBounds, endBounds).apply { 334 duration = FULL_IMMERSIVE_ANIM_DURATION_MS 335 interpolator = DecelerateInterpolator() 336 addListener( 337 onEnd = { 338 finishTransaction 339 .setPosition(leash, endBounds.left.toFloat(), endBounds.top.toFloat()) 340 .setWindowCrop(leash, endBounds.width(), endBounds.height()) 341 .apply() 342 onTaskResizeAnimationListener?.onAnimationEnd(taskId) 343 finishCallback.onTransitionFinished(/* wct= */ null) 344 } 345 ) 346 addUpdateListener { animation -> 347 val rect = animation.animatedValue as Rect 348 updateTransaction 349 .setPosition(leash, rect.left.toFloat(), rect.top.toFloat()) 350 .setWindowCrop(leash, rect.width(), rect.height()) 351 .apply() 352 onTaskResizeAnimationListener?.onBoundsChange(taskId, updateTransaction, rect) 353 ?: updateTransaction.apply() 354 } 355 start() 356 } 357 } 358 359 override fun handleRequest( 360 transition: IBinder, 361 request: TransitionRequestInfo, 362 ): WindowContainerTransaction? = null 363 364 /** 365 * Called when any transition in the system is ready to play. This is needed to update the 366 * repository state before window decorations are drawn (which happens immediately after 367 * |onTransitionReady|, before this transition actually animates) because drawing decorations 368 * depends on whether the task is in full immersive state or not. 369 */ 370 override fun onTransitionReady( 371 transition: IBinder, 372 info: TransitionInfo, 373 startTransaction: SurfaceControl.Transaction, 374 finishTransaction: SurfaceControl.Transaction, 375 ) { 376 val desktopRepository: DesktopRepository = desktopUserRepositories.current 377 val pendingTransition = getImmersiveTransition(transition) 378 379 if (pendingTransition != null) { 380 val taskId = pendingTransition.taskId 381 val immersiveChange = info.getTaskChange(taskId = taskId) 382 if (immersiveChange == null) { 383 logV( 384 "Transition for task#%d in %s direction missing immersive change.", 385 taskId, 386 pendingTransition.direction, 387 ) 388 return 389 } 390 logV( 391 "Immersive transition for task#%d in %s direction verified", 392 taskId, 393 pendingTransition.direction, 394 ) 395 desktopRepository.setTaskInFullImmersiveState( 396 displayId = pendingTransition.displayId, 397 taskId = taskId, 398 immersive = pendingTransition.direction == Direction.ENTER, 399 ) 400 if (DesktopModeFlags.ENABLE_RESTORE_TO_PREVIOUS_SIZE_FROM_DESKTOP_IMMERSIVE.isTrue) { 401 when (pendingTransition.direction) { 402 Direction.EXIT -> { 403 desktopRepository.removeBoundsBeforeFullImmersive(taskId) 404 } 405 Direction.ENTER -> { 406 desktopRepository.saveBoundsBeforeFullImmersive( 407 taskId, 408 immersiveChange.startAbsBounds, 409 ) 410 } 411 } 412 } 413 } 414 415 // Check if this is an untracked exit transition, like display rotation. 416 info.changes 417 .filter { c -> c.taskInfo != null } 418 .filter { c -> desktopRepository.isTaskInFullImmersiveState(c.taskInfo!!.taskId) } 419 .filter { c -> c.startRotation != c.endRotation } 420 .forEach { c -> 421 logV("Detected immersive exit due to rotation for task#%d", c.taskInfo!!.taskId) 422 desktopRepository.setTaskInFullImmersiveState( 423 displayId = c.taskInfo!!.displayId, 424 taskId = c.taskInfo!!.taskId, 425 immersive = false, 426 ) 427 } 428 } 429 430 override fun onTransitionMerged(merged: IBinder, playing: IBinder) { 431 val pendingTransition = 432 pendingImmersiveTransitions.firstOrNull { pendingTransition -> 433 pendingTransition.transition == merged 434 } 435 if (pendingTransition != null) { 436 logV( 437 "Pending transition %s for task#%s merged into %s", 438 merged, 439 pendingTransition.taskId, 440 playing, 441 ) 442 pendingTransition.transition = playing 443 } 444 } 445 446 override fun onTransitionFinished(transition: IBinder, aborted: Boolean) { 447 val pendingTransition = getImmersiveTransition(transition) 448 if (pendingTransition != null) { 449 logV("Pending exit transition %s for task#%s finished", transition, pendingTransition) 450 pendingImmersiveTransitions.remove(pendingTransition) 451 } 452 } 453 454 private fun getImmersiveTransition(transition: IBinder) = 455 pendingImmersiveTransitions.firstOrNull { it.transition == transition } 456 457 private fun getExitDestinationBounds(taskInfo: RunningTaskInfo): Rect { 458 val displayLayout = 459 displayController.getDisplayLayout(taskInfo.displayId) 460 ?: error("Expected non-null display layout for displayId: ${taskInfo.displayId}") 461 return if (DesktopModeFlags.ENABLE_RESTORE_TO_PREVIOUS_SIZE_FROM_DESKTOP_IMMERSIVE.isTrue) { 462 desktopUserRepositories.current.removeBoundsBeforeFullImmersive(taskInfo.taskId) 463 ?: if (ENABLE_WINDOWING_DYNAMIC_INITIAL_BOUNDS.isTrue()) { 464 calculateInitialBounds(displayLayout, taskInfo) 465 } else { 466 calculateDefaultDesktopTaskBounds(displayLayout) 467 } 468 } else { 469 return calculateMaximizeBounds(displayLayout, taskInfo) 470 } 471 } 472 473 private fun TransitionInfo.getTaskChange(taskId: Int): TransitionInfo.Change? = 474 changes.firstOrNull { c -> c.taskInfo?.taskId == taskId } 475 476 private fun dump(pw: PrintWriter, prefix: String) { 477 val innerPrefix = "$prefix " 478 pw.println("${prefix}DesktopImmersiveController") 479 pw.println(innerPrefix + "pendingImmersiveTransitions=" + pendingImmersiveTransitions) 480 } 481 482 /** The state of the currently running transition. */ 483 @VisibleForTesting 484 data class TransitionState( 485 val transition: IBinder, 486 val displayId: Int, 487 val taskId: Int, 488 val direction: Direction, 489 ) 490 491 /** 492 * Tracks state of a transition involving an immersive enter or exit. This includes both 493 * transitions that should and should not be animated by this handler. 494 * 495 * @param taskId of the task that should enter/exit immersive mode 496 * @param displayId of the display that should enter/exit immersive mode 497 * @param direction of the immersive transition 498 * @param transition that will apply this transaction 499 * @param animate whether transition should be animated by this handler 500 */ 501 data class PendingTransition( 502 val taskId: Int, 503 val displayId: Int, 504 val direction: Direction, 505 var transition: IBinder, 506 val animate: Boolean, 507 ) 508 509 /** The result of an external exit request. */ 510 sealed class ExitResult { 511 /** An immersive task exit (meaning, resize) was appended to the request. */ 512 data class Exit(val exitingTask: Int, val runOnTransitionStart: ((IBinder) -> Unit)) : 513 ExitResult() 514 515 /** There was no exit appended to the request. */ 516 data object NoExit : ExitResult() 517 518 /** Returns the result as an [Exit] or null if it isn't of that type. */ 519 fun asExit(): Exit? = if (this is Exit) this else null 520 } 521 522 @VisibleForTesting 523 enum class Direction { 524 ENTER, 525 EXIT, 526 } 527 528 /** The reason for moving the task out of desktop immersive mode. */ 529 enum class ExitReason { 530 APP_NOT_IMMERSIVE, // The app stopped requesting immersive treatment. 531 USER_INTERACTION, // Explicit user intent request, e.g. a button click. 532 TASK_LAUNCH, // A task launched/moved on top of the immersive task. 533 MINIMIZED, // The immersive task was minimized. 534 CLOSED, // The immersive task was closed. 535 } 536 537 private fun logV(msg: String, vararg arguments: Any?) { 538 ProtoLog.v(WM_SHELL_DESKTOP_MODE, "%s: $msg", TAG, *arguments) 539 } 540 541 private fun logD(msg: String, vararg arguments: Any?) { 542 ProtoLog.d(WM_SHELL_DESKTOP_MODE, "%s: $msg", TAG, *arguments) 543 } 544 545 companion object { 546 private const val TAG = "DesktopImmersive" 547 548 @VisibleForTesting const val FULL_IMMERSIVE_ANIM_DURATION_MS = 336L 549 } 550 } 551