• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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