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 
17 package com.android.wm.shell.desktopmode
18 
19 import android.app.ActivityManager.RunningTaskInfo
20 import android.app.ActivityTaskManager.INVALID_TASK_ID
21 import android.app.TaskInfo
22 import android.app.WindowConfiguration.WINDOWING_MODE_FREEFORM
23 import android.content.Context
24 import android.os.IBinder
25 import android.util.SparseArray
26 import android.view.SurfaceControl
27 import android.view.WindowManager
28 import android.window.TransitionInfo
29 import androidx.annotation.VisibleForTesting
30 import androidx.core.util.containsKey
31 import androidx.core.util.forEach
32 import androidx.core.util.isEmpty
33 import androidx.core.util.isNotEmpty
34 import androidx.core.util.plus
35 import androidx.core.util.putAll
36 import com.android.internal.logging.InstanceId
37 import com.android.internal.logging.InstanceIdSequence
38 import com.android.internal.protolog.common.ProtoLog
39 import com.android.wm.shell.desktopmode.DesktopModeEventLogger.Companion.EnterReason
40 import com.android.wm.shell.desktopmode.DesktopModeEventLogger.Companion.ExitReason
41 import com.android.wm.shell.desktopmode.DesktopModeEventLogger.Companion.TaskUpdate
42 import com.android.wm.shell.desktopmode.DesktopModeTransitionTypes.TRANSIT_ENTER_DESKTOP_FROM_APP_FROM_OVERVIEW
43 import com.android.wm.shell.desktopmode.DesktopModeTransitionTypes.TRANSIT_ENTER_DESKTOP_FROM_APP_HANDLE_MENU_BUTTON
44 import com.android.wm.shell.desktopmode.DesktopModeTransitionTypes.TRANSIT_ENTER_DESKTOP_FROM_KEYBOARD_SHORTCUT
45 import com.android.wm.shell.desktopmode.DesktopModeTransitionTypes.TRANSIT_EXIT_DESKTOP_MODE_HANDLE_MENU_BUTTON
46 import com.android.wm.shell.desktopmode.DesktopModeTransitionTypes.TRANSIT_EXIT_DESKTOP_MODE_KEYBOARD_SHORTCUT
47 import com.android.wm.shell.desktopmode.DesktopModeTransitionTypes.TRANSIT_EXIT_DESKTOP_MODE_TASK_DRAG
48 import com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_DESKTOP_MODE
49 import com.android.wm.shell.shared.DesktopModeStatus
50 import com.android.wm.shell.shared.TransitionUtil
51 import com.android.wm.shell.sysui.ShellInit
52 import com.android.wm.shell.transition.Transitions
53 import com.android.wm.shell.util.KtProtoLog
54 
55 /**
56  * A [Transitions.TransitionObserver] that observes transitions and the proposed changes to log
57  * appropriate desktop mode session log events. This observes transitions related to desktop mode
58  * and other transitions that originate both within and outside shell.
59  */
60 class DesktopModeLoggerTransitionObserver(
61     context: Context,
62     shellInit: ShellInit,
63     private val transitions: Transitions,
64     private val desktopModeEventLogger: DesktopModeEventLogger
65 ) : Transitions.TransitionObserver {
66 
67     private val idSequence: InstanceIdSequence by lazy { InstanceIdSequence(Int.MAX_VALUE) }
68 
69     init {
70         if (
71             Transitions.ENABLE_SHELL_TRANSITIONS && DesktopModeStatus.canEnterDesktopMode(context)
72         ) {
73             shellInit.addInitCallback(this::onInit, this)
74         }
75     }
76 
77     // A sparse array of visible freeform tasks and taskInfos
78     private val visibleFreeformTaskInfos: SparseArray<TaskInfo> = SparseArray()
79 
80     // Caching the taskInfos to handle canceled recents animations, if we identify that the recents
81     // animation was cancelled, we restore these tasks to calculate the post-Transition state
82     private val tasksSavedForRecents: SparseArray<TaskInfo> = SparseArray()
83 
84     // Caching whether the previous transition was exit to overview.
85     private var wasPreviousTransitionExitToOverview: Boolean = false
86 
87     // The instanceId for the current logging session
88     private var loggerInstanceId: InstanceId? = null
89 
90     private val isSessionActive: Boolean
91         get() = loggerInstanceId != null
92 
93     private fun setSessionInactive() {
94         loggerInstanceId = null
95     }
96 
97     fun onInit() {
98         transitions.registerObserver(this)
99     }
100 
101     override fun onTransitionReady(
102         transition: IBinder,
103         info: TransitionInfo,
104         startTransaction: SurfaceControl.Transaction,
105         finishTransaction: SurfaceControl.Transaction
106     ) {
107         // this was a new recents animation
108         if (info.isExitToRecentsTransition() && tasksSavedForRecents.isEmpty()) {
109             KtProtoLog.v(
110                 WM_SHELL_DESKTOP_MODE,
111                 "DesktopModeLogger: Recents animation running, saving tasks for later"
112             )
113             // TODO (b/326391303) - avoid logging session exit if we can identify a cancelled
114             // recents animation
115 
116             // when recents animation is running, all freeform tasks are sent TO_BACK temporarily
117             // if the user ends up at home, we need to update the visible freeform tasks
118             // if the user cancels the animation, the subsequent transition is NONE
119             // if the user opens a new task, the subsequent transition is OPEN with flag
120             tasksSavedForRecents.putAll(visibleFreeformTaskInfos)
121         }
122 
123         // figure out what the new state of freeform tasks would be post transition
124         var postTransitionVisibleFreeformTasks = getPostTransitionVisibleFreeformTaskInfos(info)
125 
126         // A canceled recents animation is followed by a TRANSIT_NONE transition with no flags, if
127         // that's the case, we might have accidentally logged a session exit and would need to
128         // revaluate again. Add all the tasks back.
129         // This will start a new desktop mode session.
130         if (
131             info.type == WindowManager.TRANSIT_NONE &&
132                 info.flags == 0 &&
133                 tasksSavedForRecents.isNotEmpty()
134         ) {
135             KtProtoLog.v(
136                 WM_SHELL_DESKTOP_MODE,
137                 "DesktopModeLogger: Canceled recents animation, restoring tasks"
138             )
139             // restore saved tasks in the updated set and clear for next use
140             postTransitionVisibleFreeformTasks += tasksSavedForRecents
141             tasksSavedForRecents.clear()
142         }
143 
144         // identify if we need to log any changes and update the state of visible freeform tasks
145         identifyLogEventAndUpdateState(
146             transitionInfo = info,
147             preTransitionVisibleFreeformTasks = visibleFreeformTaskInfos,
148             postTransitionVisibleFreeformTasks = postTransitionVisibleFreeformTasks
149         )
150         wasPreviousTransitionExitToOverview = info.isExitToRecentsTransition()
151     }
152 
153     override fun onTransitionStarting(transition: IBinder) {}
154 
155     override fun onTransitionMerged(merged: IBinder, playing: IBinder) {}
156 
157     override fun onTransitionFinished(transition: IBinder, aborted: Boolean) {}
158 
159     private fun getPostTransitionVisibleFreeformTaskInfos(
160         info: TransitionInfo
161     ): SparseArray<TaskInfo> {
162         // device is sleeping, so no task will be visible anymore
163         if (info.type == WindowManager.TRANSIT_SLEEP) {
164             return SparseArray()
165         }
166 
167         // filter changes involving freeform tasks or tasks that were cached in previous state
168         val changesToFreeformWindows =
169             info.changes
170                 .filter { it.taskInfo != null && it.requireTaskInfo().taskId != INVALID_TASK_ID }
171                 .filter {
172                     it.requireTaskInfo().isFreeformWindow() ||
173                         visibleFreeformTaskInfos.containsKey(it.requireTaskInfo().taskId)
174                 }
175 
176         val postTransitionFreeformTasks: SparseArray<TaskInfo> = SparseArray()
177         // start off by adding all existing tasks
178         postTransitionFreeformTasks.putAll(visibleFreeformTaskInfos)
179 
180         // the combined set of taskInfos we are interested in this transition change
181         for (change in changesToFreeformWindows) {
182             val taskInfo = change.requireTaskInfo()
183 
184             // check if this task existed as freeform window in previous cached state and it's now
185             // changing window modes
186             if (
187                 visibleFreeformTaskInfos.containsKey(taskInfo.taskId) &&
188                     visibleFreeformTaskInfos.get(taskInfo.taskId).isFreeformWindow() &&
189                     !taskInfo.isFreeformWindow()
190             ) {
191                 postTransitionFreeformTasks.remove(taskInfo.taskId)
192                 // no need to evaluate new visibility of this task, since it's no longer a freeform
193                 // window
194                 continue
195             }
196 
197             // check if the task is visible after this change, otherwise remove it
198             if (isTaskVisibleAfterChange(change)) {
199                 postTransitionFreeformTasks.put(taskInfo.taskId, taskInfo)
200             } else {
201                 postTransitionFreeformTasks.remove(taskInfo.taskId)
202             }
203         }
204 
205         KtProtoLog.v(
206             WM_SHELL_DESKTOP_MODE,
207             "DesktopModeLogger: taskInfo map after processing changes %s",
208             postTransitionFreeformTasks.size()
209         )
210 
211         return postTransitionFreeformTasks
212     }
213 
214     /**
215      * Look at the [TransitionInfo.Change] and figure out if this task will be visible after this
216      * change is processed
217      */
218     private fun isTaskVisibleAfterChange(change: TransitionInfo.Change): Boolean =
219         when {
220             TransitionUtil.isOpeningType(change.mode) -> true
221             TransitionUtil.isClosingType(change.mode) -> false
222             // change mode TRANSIT_CHANGE is only for visible to visible transitions
223             change.mode == WindowManager.TRANSIT_CHANGE -> true
224             else -> false
225         }
226 
227     /**
228      * Log the appropriate log event based on the new state of TasksInfos and previously cached
229      * state and update it
230      */
231     private fun identifyLogEventAndUpdateState(
232         transitionInfo: TransitionInfo,
233         preTransitionVisibleFreeformTasks: SparseArray<TaskInfo>,
234         postTransitionVisibleFreeformTasks: SparseArray<TaskInfo>
235     ) {
236         if (
237             postTransitionVisibleFreeformTasks.isEmpty() &&
238                 preTransitionVisibleFreeformTasks.isNotEmpty() &&
239                 isSessionActive
240         ) {
241             // Sessions is finishing, log task updates followed by an exit event
242             identifyAndLogTaskUpdates(
243                 loggerInstanceId!!.id,
244                 preTransitionVisibleFreeformTasks,
245                 postTransitionVisibleFreeformTasks
246             )
247 
248             desktopModeEventLogger.logSessionExit(
249                 loggerInstanceId!!.id,
250                 getExitReason(transitionInfo)
251             )
252 
253             setSessionInactive()
254         } else if (
255             postTransitionVisibleFreeformTasks.isNotEmpty() &&
256                 preTransitionVisibleFreeformTasks.isEmpty() &&
257                 !isSessionActive
258         ) {
259             // Session is starting, log enter event followed by task updates
260             loggerInstanceId = idSequence.newInstanceId()
261             desktopModeEventLogger.logSessionEnter(
262                 loggerInstanceId!!.id,
263                 getEnterReason(transitionInfo)
264             )
265 
266             identifyAndLogTaskUpdates(
267                 loggerInstanceId!!.id,
268                 preTransitionVisibleFreeformTasks,
269                 postTransitionVisibleFreeformTasks
270             )
271         } else if (isSessionActive) {
272             // Session is neither starting, nor finishing, log task updates if there are any
273             identifyAndLogTaskUpdates(
274                 loggerInstanceId!!.id,
275                 preTransitionVisibleFreeformTasks,
276                 postTransitionVisibleFreeformTasks
277             )
278         }
279 
280         // update the state to the new version
281         visibleFreeformTaskInfos.clear()
282         visibleFreeformTaskInfos.putAll(postTransitionVisibleFreeformTasks)
283     }
284 
285     // TODO(b/326231724) - Add logging around taskInfoChanges Updates
286     /** Compare the old and new state of taskInfos and identify and log the changes */
287     private fun identifyAndLogTaskUpdates(
288         sessionId: Int,
289         preTransitionVisibleFreeformTasks: SparseArray<TaskInfo>,
290         postTransitionVisibleFreeformTasks: SparseArray<TaskInfo>
291     ) {
292         // find new tasks that were added
293         postTransitionVisibleFreeformTasks.forEach { taskId, taskInfo ->
294             if (!preTransitionVisibleFreeformTasks.containsKey(taskId)) {
295                 desktopModeEventLogger.logTaskAdded(sessionId, buildTaskUpdateForTask(taskInfo))
296             }
297         }
298 
299         // find old tasks that were removed
300         preTransitionVisibleFreeformTasks.forEach { taskId, taskInfo ->
301             if (!postTransitionVisibleFreeformTasks.containsKey(taskId)) {
302                 desktopModeEventLogger.logTaskRemoved(sessionId, buildTaskUpdateForTask(taskInfo))
303             }
304         }
305     }
306 
307     // TODO(b/326231724: figure out how to get taskWidth and taskHeight from TaskInfo
308     private fun buildTaskUpdateForTask(taskInfo: TaskInfo): TaskUpdate {
309         val taskUpdate = TaskUpdate(taskInfo.taskId, taskInfo.userId)
310         // add task x, y if available
311         taskInfo.positionInParent?.let { taskUpdate.copy(taskX = it.x, taskY = it.y) }
312 
313         return taskUpdate
314     }
315 
316     /** Get [EnterReason] for this session enter */
317     private fun getEnterReason(transitionInfo: TransitionInfo): EnterReason =
318         when {
319             transitionInfo.type == WindowManager.TRANSIT_WAKE -> EnterReason.SCREEN_ON
320             transitionInfo.type == Transitions.TRANSIT_DESKTOP_MODE_END_DRAG_TO_DESKTOP ->
321                 EnterReason.APP_HANDLE_DRAG
322             transitionInfo.type == TRANSIT_ENTER_DESKTOP_FROM_APP_HANDLE_MENU_BUTTON ->
323                 EnterReason.APP_HANDLE_MENU_BUTTON
324             transitionInfo.type == TRANSIT_ENTER_DESKTOP_FROM_APP_FROM_OVERVIEW ->
325                 EnterReason.APP_FROM_OVERVIEW
326             transitionInfo.type == TRANSIT_ENTER_DESKTOP_FROM_KEYBOARD_SHORTCUT ->
327                 EnterReason.KEYBOARD_SHORTCUT_ENTER
328             // NOTE: the below condition also applies for EnterReason quickswitch
329             transitionInfo.type == WindowManager.TRANSIT_TO_FRONT -> EnterReason.OVERVIEW
330             // Enter desktop mode from cancelled recents has no transition. Enter is detected on the
331             // next transition involving freeform windows.
332             // TODO(b/346564416): Modify logging for cancelled recents once it transition is
333             //  changed. Also see how to account to time difference between actual enter time and
334             //  time of this log. Also account for the missed session when exit happens just after
335             //  a cancelled recents.
336             wasPreviousTransitionExitToOverview -> EnterReason.OVERVIEW
337             transitionInfo.type == WindowManager.TRANSIT_OPEN -> EnterReason.APP_FREEFORM_INTENT
338             else -> {
339                 ProtoLog.w(
340                     WM_SHELL_DESKTOP_MODE,
341                     "Unknown enter reason for transition type ${transitionInfo.type}",
342                     transitionInfo.type
343                 )
344                 EnterReason.UNKNOWN_ENTER
345             }
346         }
347 
348     /** Get [ExitReason] for this session exit */
349     private fun getExitReason(transitionInfo: TransitionInfo): ExitReason =
350          when {
351             transitionInfo.type == WindowManager.TRANSIT_SLEEP -> ExitReason.SCREEN_OFF
352             transitionInfo.type == WindowManager.TRANSIT_CLOSE -> ExitReason.TASK_FINISHED
353             transitionInfo.type == TRANSIT_EXIT_DESKTOP_MODE_TASK_DRAG -> ExitReason.DRAG_TO_EXIT
354             transitionInfo.type == TRANSIT_EXIT_DESKTOP_MODE_HANDLE_MENU_BUTTON ->
355                 ExitReason.APP_HANDLE_MENU_BUTTON_EXIT
356             transitionInfo.type == TRANSIT_EXIT_DESKTOP_MODE_KEYBOARD_SHORTCUT ->
357                 ExitReason.KEYBOARD_SHORTCUT_EXIT
358             transitionInfo.isExitToRecentsTransition() -> ExitReason.RETURN_HOME_OR_OVERVIEW
359             else -> {
360                 ProtoLog.w(
361                     WM_SHELL_DESKTOP_MODE,
362                     "Unknown exit reason for transition type ${transitionInfo.type}",
363                     transitionInfo.type
364                 )
365                 ExitReason.UNKNOWN_EXIT
366             }
367         }
368 
369     /** Adds tasks to the saved copy of freeform taskId, taskInfo. Only used for testing. */
370     @VisibleForTesting
371     fun addTaskInfosToCachedMap(taskInfo: TaskInfo) {
372         visibleFreeformTaskInfos.set(taskInfo.taskId, taskInfo)
373     }
374 
375     @VisibleForTesting fun getLoggerSessionId(): Int? = loggerInstanceId?.id
376 
377     @VisibleForTesting
378     fun setLoggerSessionId(id: Int) {
379         loggerInstanceId = InstanceId.fakeInstanceId(id)
380     }
381 
382     private fun TransitionInfo.Change.requireTaskInfo(): RunningTaskInfo {
383         return this.taskInfo ?: throw IllegalStateException("Expected TaskInfo in the Change")
384     }
385 
386     private fun TaskInfo.isFreeformWindow(): Boolean {
387         return this.windowingMode == WINDOWING_MODE_FREEFORM
388     }
389 
390     private fun TransitionInfo.isExitToRecentsTransition(): Boolean {
391         return this.type == WindowManager.TRANSIT_TO_FRONT &&
392             this.flags == WindowManager.TRANSIT_FLAG_IS_RECENTS
393     }
394 }
395