Merge "Add a transition observer to listen to transitions." into main
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellModule.java b/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellModule.java
index fb3c35b..04f0f44 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellModule.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellModule.java
@@ -57,6 +57,8 @@
 import com.android.wm.shell.common.annotations.ShellMainThread;
 import com.android.wm.shell.dagger.back.ShellBackAnimationModule;
 import com.android.wm.shell.dagger.pip.PipModule;
+import com.android.wm.shell.desktopmode.DesktopModeEventLogger;
+import com.android.wm.shell.desktopmode.DesktopModeLoggerTransitionObserver;
 import com.android.wm.shell.desktopmode.DesktopModeStatus;
 import com.android.wm.shell.desktopmode.DesktopModeTaskRepository;
 import com.android.wm.shell.desktopmode.DesktopTasksController;
@@ -509,6 +511,7 @@
             ToggleResizeDesktopTaskTransitionHandler toggleResizeDesktopTaskTransitionHandler,
             DragToDesktopTransitionHandler dragToDesktopTransitionHandler,
             @DynamicOverride DesktopModeTaskRepository desktopModeTaskRepository,
+            DesktopModeLoggerTransitionObserver desktopModeLoggerTransitionObserver,
             LaunchAdjacentController launchAdjacentController,
             RecentsTransitionHandler recentsTransitionHandler,
             MultiInstanceHelper multiInstanceHelper,
@@ -518,7 +521,8 @@
                 displayController, shellTaskOrganizer, syncQueue, rootTaskDisplayAreaOrganizer,
                 dragAndDropController, transitions, enterDesktopTransitionHandler,
                 exitDesktopTransitionHandler, toggleResizeDesktopTaskTransitionHandler,
-                dragToDesktopTransitionHandler, desktopModeTaskRepository, launchAdjacentController,
+                dragToDesktopTransitionHandler, desktopModeTaskRepository,
+                desktopModeLoggerTransitionObserver, launchAdjacentController,
                 recentsTransitionHandler, multiInstanceHelper, mainExecutor);
     }
 
@@ -562,6 +566,22 @@
         return new DesktopModeTaskRepository();
     }
 
+    @WMSingleton
+    @Provides
+    static DesktopModeLoggerTransitionObserver provideDesktopModeLoggerTransitionObserver(
+            ShellInit shellInit,
+            Transitions transitions,
+            DesktopModeEventLogger desktopModeEventLogger) {
+        return new DesktopModeLoggerTransitionObserver(
+                shellInit, transitions, desktopModeEventLogger);
+    }
+
+    @WMSingleton
+    @Provides
+    static DesktopModeEventLogger provideDesktopModeEventLogger() {
+        return new DesktopModeEventLogger();
+    }
+
     //
     // Drag and drop
     //
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeLoggerTransitionObserver.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeLoggerTransitionObserver.kt
new file mode 100644
index 0000000..a10c7c0
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeLoggerTransitionObserver.kt
@@ -0,0 +1,349 @@
+/*
+ * 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.desktopmode
+
+import android.app.ActivityManager.RunningTaskInfo
+import android.app.ActivityTaskManager.INVALID_TASK_ID
+import android.app.TaskInfo
+import android.app.WindowConfiguration.WINDOWING_MODE_FREEFORM
+import android.os.IBinder
+import android.util.SparseArray
+import android.view.SurfaceControl
+import android.view.WindowManager
+import android.window.TransitionInfo
+import androidx.annotation.VisibleForTesting
+import androidx.core.util.containsKey
+import androidx.core.util.forEach
+import androidx.core.util.isEmpty
+import androidx.core.util.isNotEmpty
+import androidx.core.util.plus
+import androidx.core.util.putAll
+import com.android.internal.logging.InstanceId
+import com.android.internal.logging.InstanceIdSequence
+import com.android.wm.shell.desktopmode.DesktopModeEventLogger.Companion.EnterReason
+import com.android.wm.shell.desktopmode.DesktopModeEventLogger.Companion.ExitReason
+import com.android.wm.shell.desktopmode.DesktopModeEventLogger.Companion.TaskUpdate
+import com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_DESKTOP_MODE
+import com.android.wm.shell.shared.TransitionUtil
+import com.android.wm.shell.sysui.ShellInit
+import com.android.wm.shell.transition.Transitions
+import com.android.wm.shell.util.KtProtoLog
+
+/**
+ * A [Transitions.TransitionObserver] that observes transitions and the proposed changes to log
+ * appropriate desktop mode session log events. This observes transitions related to desktop mode
+ * and other transitions that originate both within and outside shell.
+ */
+class DesktopModeLoggerTransitionObserver(
+    shellInit: ShellInit,
+    private val transitions: Transitions,
+    private val desktopModeEventLogger: DesktopModeEventLogger
+) : Transitions.TransitionObserver {
+
+    private val idSequence: InstanceIdSequence by lazy { InstanceIdSequence(Int.MAX_VALUE) }
+
+    init {
+        if (Transitions.ENABLE_SHELL_TRANSITIONS && DesktopModeStatus.isEnabled()) {
+            shellInit.addInitCallback(this::onInit, this)
+        }
+    }
+
+    // A sparse array of visible freeform tasks and taskInfos
+    private val visibleFreeformTaskInfos: SparseArray<TaskInfo> = SparseArray()
+
+    // Caching the taskInfos to handle canceled recents animations, if we identify that the recents
+    // animation was cancelled, we restore these tasks to calculate the post-Transition state
+    private val tasksSavedForRecents: SparseArray<TaskInfo> = SparseArray()
+
+    // The instanceId for the current logging session
+    private var loggerInstanceId: InstanceId? = null
+
+    private val isSessionActive: Boolean
+        get() = loggerInstanceId != null
+
+    private fun setSessionInactive() {
+        loggerInstanceId = null
+    }
+
+    fun onInit() {
+        transitions.registerObserver(this)
+    }
+
+    override fun onTransitionReady(
+        transition: IBinder,
+        info: TransitionInfo,
+        startTransaction: SurfaceControl.Transaction,
+        finishTransaction: SurfaceControl.Transaction
+    ) {
+        // this was a new recents animation
+        if (info.isRecentsTransition() && tasksSavedForRecents.isEmpty()) {
+            KtProtoLog.v(
+                WM_SHELL_DESKTOP_MODE,
+                "DesktopModeLogger: Recents animation running, saving tasks for later"
+            )
+            // TODO (b/326391303) - avoid logging session exit if we can identify a cancelled
+            // recents animation
+
+            // when recents animation is running, all freeform tasks are sent TO_BACK temporarily
+            // if the user ends up at home, we need to update the visible freeform tasks
+            // if the user cancels the animation, the subsequent transition is NONE
+            // if the user opens a new task, the subsequent transition is OPEN with flag
+            tasksSavedForRecents.putAll(visibleFreeformTaskInfos)
+        }
+
+        // figure out what the new state of freeform tasks would be post transition
+        var postTransitionVisibleFreeformTasks = getPostTransitionVisibleFreeformTaskInfos(info)
+
+        // A canceled recents animation is followed by a TRANSIT_NONE transition with no flags, if
+        // that's the case, we might have accidentally logged a session exit and would need to
+        // revaluate again. Add all the tasks back.
+        // This will start a new desktop mode session.
+        if (
+            info.type == WindowManager.TRANSIT_NONE &&
+                info.flags == 0 &&
+                tasksSavedForRecents.isNotEmpty()
+        ) {
+            KtProtoLog.v(
+                WM_SHELL_DESKTOP_MODE,
+                "DesktopModeLogger: Canceled recents animation, restoring tasks"
+            )
+            // restore saved tasks in the updated set and clear for next use
+            postTransitionVisibleFreeformTasks += tasksSavedForRecents
+            tasksSavedForRecents.clear()
+        }
+
+        // identify if we need to log any changes and update the state of visible freeform tasks
+        identifyLogEventAndUpdateState(
+            transitionInfo = info,
+            preTransitionVisibleFreeformTasks = visibleFreeformTaskInfos,
+            postTransitionVisibleFreeformTasks = postTransitionVisibleFreeformTasks
+        )
+    }
+
+    override fun onTransitionStarting(transition: IBinder) {}
+
+    override fun onTransitionMerged(merged: IBinder, playing: IBinder) {}
+
+    override fun onTransitionFinished(transition: IBinder, aborted: Boolean) {}
+
+    private fun getPostTransitionVisibleFreeformTaskInfos(
+        info: TransitionInfo
+    ): SparseArray<TaskInfo> {
+        // device is sleeping, so no task will be visible anymore
+        if (info.type == WindowManager.TRANSIT_SLEEP) {
+            return SparseArray()
+        }
+
+        // filter changes involving freeform tasks or tasks that were cached in previous state
+        val changesToFreeformWindows =
+            info.changes
+                .filter { it.taskInfo != null && it.requireTaskInfo().taskId != INVALID_TASK_ID }
+                .filter {
+                    it.requireTaskInfo().isFreeformWindow() ||
+                        visibleFreeformTaskInfos.containsKey(it.requireTaskInfo().taskId)
+                }
+
+        val postTransitionFreeformTasks: SparseArray<TaskInfo> = SparseArray()
+        // start off by adding all existing tasks
+        postTransitionFreeformTasks.putAll(visibleFreeformTaskInfos)
+
+        // the combined set of taskInfos we are interested in this transition change
+        for (change in changesToFreeformWindows) {
+            val taskInfo = change.requireTaskInfo()
+
+            // check if this task existed as freeform window in previous cached state and it's now
+            // changing window modes
+            if (
+                visibleFreeformTaskInfos.containsKey(taskInfo.taskId) &&
+                    visibleFreeformTaskInfos.get(taskInfo.taskId).isFreeformWindow() &&
+                    !taskInfo.isFreeformWindow()
+            ) {
+                postTransitionFreeformTasks.remove(taskInfo.taskId)
+                // no need to evaluate new visibility of this task, since it's no longer a freeform
+                // window
+                continue
+            }
+
+            // check if the task is visible after this change, otherwise remove it
+            if (isTaskVisibleAfterChange(change)) {
+                postTransitionFreeformTasks.put(taskInfo.taskId, taskInfo)
+            } else {
+                postTransitionFreeformTasks.remove(taskInfo.taskId)
+            }
+        }
+
+        KtProtoLog.v(
+            WM_SHELL_DESKTOP_MODE,
+            "DesktopModeLogger: taskInfo map after processing changes %s",
+            postTransitionFreeformTasks.size()
+        )
+
+        return postTransitionFreeformTasks
+    }
+
+    /**
+     * Look at the [TransitionInfo.Change] and figure out if this task will be visible after this
+     * change is processed
+     */
+    private fun isTaskVisibleAfterChange(change: TransitionInfo.Change): Boolean =
+        when {
+            TransitionUtil.isOpeningType(change.mode) -> true
+            TransitionUtil.isClosingType(change.mode) -> false
+            // change mode TRANSIT_CHANGE is only for visible to visible transitions
+            change.mode == WindowManager.TRANSIT_CHANGE -> true
+            else -> false
+        }
+
+    /**
+     * Log the appropriate log event based on the new state of TasksInfos and previously cached
+     * state and update it
+     */
+    private fun identifyLogEventAndUpdateState(
+        transitionInfo: TransitionInfo,
+        preTransitionVisibleFreeformTasks: SparseArray<TaskInfo>,
+        postTransitionVisibleFreeformTasks: SparseArray<TaskInfo>
+    ) {
+        if (
+            postTransitionVisibleFreeformTasks.isEmpty() &&
+                preTransitionVisibleFreeformTasks.isNotEmpty() &&
+                isSessionActive
+        ) {
+            // Sessions is finishing, log task updates followed by an exit event
+            identifyAndLogTaskUpdates(
+                loggerInstanceId!!.id,
+                preTransitionVisibleFreeformTasks,
+                postTransitionVisibleFreeformTasks
+            )
+
+            desktopModeEventLogger.logSessionExit(
+                loggerInstanceId!!.id,
+                getExitReason(transitionInfo)
+            )
+
+            setSessionInactive()
+        } else if (
+            postTransitionVisibleFreeformTasks.isNotEmpty() &&
+                preTransitionVisibleFreeformTasks.isEmpty() &&
+                !isSessionActive
+        ) {
+            // Session is starting, log enter event followed by task updates
+            loggerInstanceId = idSequence.newInstanceId()
+            desktopModeEventLogger.logSessionEnter(
+                loggerInstanceId!!.id,
+                getEnterReason(transitionInfo)
+            )
+
+            identifyAndLogTaskUpdates(
+                loggerInstanceId!!.id,
+                preTransitionVisibleFreeformTasks,
+                postTransitionVisibleFreeformTasks
+            )
+        } else if (isSessionActive) {
+            // Session is neither starting, nor finishing, log task updates if there are any
+            identifyAndLogTaskUpdates(
+                loggerInstanceId!!.id,
+                preTransitionVisibleFreeformTasks,
+                postTransitionVisibleFreeformTasks
+            )
+        }
+
+        // update the state to the new version
+        visibleFreeformTaskInfos.clear()
+        visibleFreeformTaskInfos.putAll(postTransitionVisibleFreeformTasks)
+    }
+
+    // TODO(b/326231724) - Add logging around taskInfoChanges Updates
+    /** Compare the old and new state of taskInfos and identify and log the changes */
+    private fun identifyAndLogTaskUpdates(
+        sessionId: Int,
+        preTransitionVisibleFreeformTasks: SparseArray<TaskInfo>,
+        postTransitionVisibleFreeformTasks: SparseArray<TaskInfo>
+    ) {
+        // find new tasks that were added
+        postTransitionVisibleFreeformTasks.forEach { taskId, taskInfo ->
+            if (!preTransitionVisibleFreeformTasks.containsKey(taskId)) {
+                desktopModeEventLogger.logTaskAdded(sessionId, buildTaskUpdateForTask(taskInfo))
+            }
+        }
+
+        // find old tasks that were removed
+        preTransitionVisibleFreeformTasks.forEach { taskId, taskInfo ->
+            if (!postTransitionVisibleFreeformTasks.containsKey(taskId)) {
+                desktopModeEventLogger.logTaskRemoved(sessionId, buildTaskUpdateForTask(taskInfo))
+            }
+        }
+    }
+
+    // TODO(b/326231724: figure out how to get taskWidth and taskHeight from TaskInfo
+    private fun buildTaskUpdateForTask(taskInfo: TaskInfo): TaskUpdate {
+        val taskUpdate = TaskUpdate(taskInfo.taskId, taskInfo.userId)
+        // add task x, y if available
+        taskInfo.positionInParent?.let { taskUpdate.copy(taskX = it.x, taskY = it.y) }
+
+        return taskUpdate
+    }
+
+    /** Get [EnterReason] for this session enter */
+    private fun getEnterReason(transitionInfo: TransitionInfo): EnterReason {
+        // TODO(b/326231756) - Add support for missing enter reasons
+        return when (transitionInfo.type) {
+            WindowManager.TRANSIT_WAKE -> EnterReason.SCREEN_ON
+            Transitions.TRANSIT_DESKTOP_MODE_END_DRAG_TO_DESKTOP -> EnterReason.APP_HANDLE_DRAG
+            Transitions.TRANSIT_MOVE_TO_DESKTOP -> EnterReason.APP_HANDLE_MENU_BUTTON
+            WindowManager.TRANSIT_OPEN -> EnterReason.APP_FREEFORM_INTENT
+            else -> EnterReason.UNKNOWN_ENTER
+        }
+    }
+
+    /** Get [ExitReason] for this session exit */
+    private fun getExitReason(transitionInfo: TransitionInfo): ExitReason {
+        // TODO(b/326231756) - Add support for missing exit reasons
+        return when {
+            transitionInfo.type == WindowManager.TRANSIT_SLEEP -> ExitReason.SCREEN_OFF
+            transitionInfo.type == WindowManager.TRANSIT_CLOSE -> ExitReason.TASK_FINISHED
+            transitionInfo.type == Transitions.TRANSIT_EXIT_DESKTOP_MODE -> ExitReason.DRAG_TO_EXIT
+            transitionInfo.isRecentsTransition() -> ExitReason.RETURN_HOME_OR_OVERVIEW
+            else -> ExitReason.UNKNOWN_EXIT
+        }
+    }
+
+    /** Adds tasks to the saved copy of freeform taskId, taskInfo. Only used for testing. */
+    @VisibleForTesting
+    fun addTaskInfosToCachedMap(taskInfo: TaskInfo) {
+        visibleFreeformTaskInfos.set(taskInfo.taskId, taskInfo)
+    }
+
+    @VisibleForTesting fun getLoggerSessionId(): Int? = loggerInstanceId?.id
+
+    @VisibleForTesting
+    fun setLoggerSessionId(id: Int) {
+        loggerInstanceId = InstanceId.fakeInstanceId(id)
+    }
+
+    private fun TransitionInfo.Change.requireTaskInfo(): RunningTaskInfo {
+        return this.taskInfo ?: throw IllegalStateException("Expected TaskInfo in the Change")
+    }
+
+    private fun TaskInfo.isFreeformWindow(): Boolean {
+        return this.windowingMode == WINDOWING_MODE_FREEFORM
+    }
+
+    private fun TransitionInfo.isRecentsTransition(): Boolean {
+        return this.type == WindowManager.TRANSIT_TO_FRONT &&
+            this.flags == WindowManager.TRANSIT_FLAG_IS_RECENTS
+    }
+}
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksController.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksController.kt
index 95237c3..dd8c1a0 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksController.kt
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksController.kt
@@ -102,6 +102,7 @@
         ToggleResizeDesktopTaskTransitionHandler,
         private val dragToDesktopTransitionHandler: DragToDesktopTransitionHandler,
         private val desktopModeTaskRepository: DesktopModeTaskRepository,
+        private val desktopModeLoggerTransitionObserver: DesktopModeLoggerTransitionObserver,
         private val launchAdjacentController: LaunchAdjacentController,
         private val recentsTransitionHandler: RecentsTransitionHandler,
         private val multiInstanceHelper: MultiInstanceHelper,
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopModeLoggerTransitionObserverTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopModeLoggerTransitionObserverTest.kt
new file mode 100644
index 0000000..65117f7
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopModeLoggerTransitionObserverTest.kt
@@ -0,0 +1,358 @@
+/*
+ * 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.desktopmode
+
+import android.app.ActivityManager
+import android.app.WindowConfiguration.WINDOWING_MODE_FREEFORM
+import android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN
+import android.os.IBinder
+import android.testing.AndroidTestingRunner
+import android.view.SurfaceControl
+import android.view.WindowManager.TRANSIT_CHANGE
+import android.view.WindowManager.TRANSIT_CLOSE
+import android.view.WindowManager.TRANSIT_FLAG_IS_RECENTS
+import android.view.WindowManager.TRANSIT_NONE
+import android.view.WindowManager.TRANSIT_OPEN
+import android.view.WindowManager.TRANSIT_SLEEP
+import android.view.WindowManager.TRANSIT_TO_BACK
+import android.view.WindowManager.TRANSIT_TO_FRONT
+import android.view.WindowManager.TRANSIT_WAKE
+import android.window.IWindowContainerToken
+import android.window.TransitionInfo
+import android.window.TransitionInfo.Change
+import android.window.WindowContainerToken
+import androidx.test.filters.SmallTest
+import com.android.modules.utils.testing.ExtendedMockitoRule
+import com.android.wm.shell.common.ShellExecutor
+import com.android.wm.shell.desktopmode.DesktopModeEventLogger.Companion.EnterReason
+import com.android.wm.shell.desktopmode.DesktopModeEventLogger.Companion.ExitReason
+import com.android.wm.shell.sysui.ShellInit
+import com.android.wm.shell.transition.TransitionInfoBuilder
+import com.android.wm.shell.transition.Transitions
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentCaptor
+import org.mockito.Mock
+import org.mockito.Mockito
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.verify
+import org.mockito.kotlin.any
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.never
+import org.mockito.kotlin.same
+import org.mockito.kotlin.times
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+class DesktopModeLoggerTransitionObserverTest {
+
+    @JvmField
+    @Rule
+    val extendedMockitoRule = ExtendedMockitoRule.Builder(this)
+            .mockStatic(DesktopModeEventLogger::class.java)
+            .mockStatic(DesktopModeStatus::class.java).build()!!
+
+    @Mock
+    lateinit var testExecutor: ShellExecutor
+    @Mock
+    private lateinit var mockShellInit: ShellInit
+    @Mock
+    private lateinit var transitions: Transitions
+
+    private lateinit var transitionObserver: DesktopModeLoggerTransitionObserver
+    private lateinit var shellInit: ShellInit
+    private lateinit var desktopModeEventLogger: DesktopModeEventLogger
+
+    @Before
+    fun setup() {
+        Mockito.`when`(DesktopModeStatus.isEnabled()).thenReturn(true)
+        shellInit = Mockito.spy(ShellInit(testExecutor))
+        desktopModeEventLogger = mock(DesktopModeEventLogger::class.java)
+
+        transitionObserver = DesktopModeLoggerTransitionObserver(
+            mockShellInit, transitions, desktopModeEventLogger)
+        if (Transitions.ENABLE_SHELL_TRANSITIONS) {
+            val initRunnableCaptor = ArgumentCaptor.forClass(
+                Runnable::class.java)
+            verify(mockShellInit).addInitCallback(initRunnableCaptor.capture(),
+                same(transitionObserver))
+            initRunnableCaptor.value.run()
+        } else {
+            transitionObserver.onInit()
+        }
+    }
+
+    @Test
+    fun testRegistersObserverAtInit() {
+        verify(transitions)
+                .registerObserver(same(
+                    transitionObserver))
+    }
+
+    @Test
+    fun taskCreated_notFreeformWindow_doesNotLogSessionEnterOrTaskAdded() {
+        val change = createChange(TRANSIT_OPEN, createTaskInfo(1, WINDOWING_MODE_FULLSCREEN))
+        val transitionInfo = TransitionInfoBuilder(TRANSIT_OPEN, 0).addChange(change).build()
+
+        callOnTransitionReady(transitionInfo)
+
+        verify(desktopModeEventLogger, never()).logSessionEnter(any(), any())
+        verify(desktopModeEventLogger, never()).logTaskAdded(any(), any())
+    }
+
+    @Test
+    fun taskCreated_FreeformWindowOpen_logSessionEnterAndTaskAdded() {
+        val change = createChange(TRANSIT_OPEN, createTaskInfo(1, WINDOWING_MODE_FREEFORM))
+        val transitionInfo = TransitionInfoBuilder(TRANSIT_OPEN, 0).addChange(change).build()
+
+        callOnTransitionReady(transitionInfo)
+        val sessionId = transitionObserver.getLoggerSessionId()
+
+        assertThat(sessionId).isNotNull()
+        verify(desktopModeEventLogger, times(1)).logSessionEnter(eq(sessionId!!),
+            eq(EnterReason.APP_FREEFORM_INTENT))
+        verify(desktopModeEventLogger, times(1)).logTaskAdded(eq(sessionId), any())
+    }
+
+    @Test
+    fun taskChanged_taskMovedToDesktopByDrag_logSessionEnterAndTaskAdded() {
+        val change = createChange(TRANSIT_TO_FRONT, createTaskInfo(1, WINDOWING_MODE_FREEFORM))
+        // task change is finalised when drag ends
+        val transitionInfo = TransitionInfoBuilder(
+            Transitions.TRANSIT_DESKTOP_MODE_END_DRAG_TO_DESKTOP, 0).addChange(change).build()
+
+        callOnTransitionReady(transitionInfo)
+        val sessionId = transitionObserver.getLoggerSessionId()
+
+        assertThat(sessionId).isNotNull()
+        verify(desktopModeEventLogger, times(1)).logSessionEnter(eq(sessionId!!),
+            eq(EnterReason.APP_HANDLE_DRAG))
+        verify(desktopModeEventLogger, times(1)).logTaskAdded(eq(sessionId), any())
+    }
+
+    @Test
+    fun taskChanged_taskMovedToDesktopByButtonTap_logSessionEnterAndTaskAdded() {
+        val change = createChange(TRANSIT_TO_FRONT, createTaskInfo(1, WINDOWING_MODE_FREEFORM))
+        val transitionInfo = TransitionInfoBuilder(Transitions.TRANSIT_MOVE_TO_DESKTOP, 0)
+                .addChange(change).build()
+
+        callOnTransitionReady(transitionInfo)
+        val sessionId = transitionObserver.getLoggerSessionId()
+
+        assertThat(sessionId).isNotNull()
+        verify(desktopModeEventLogger, times(1)).logSessionEnter(eq(sessionId!!),
+            eq(EnterReason.APP_HANDLE_MENU_BUTTON))
+        verify(desktopModeEventLogger, times(1)).logTaskAdded(eq(sessionId), any())
+    }
+
+    @Test
+    fun taskChanged_existingFreeformTaskMadeVisible_logSessionEnterAndTaskAdded() {
+        val taskInfo = createTaskInfo(1, WINDOWING_MODE_FREEFORM)
+        taskInfo.isVisibleRequested = true
+        val change = createChange(TRANSIT_CHANGE, taskInfo)
+        val transitionInfo = TransitionInfoBuilder(Transitions.TRANSIT_MOVE_TO_DESKTOP, 0)
+                .addChange(change).build()
+
+        callOnTransitionReady(transitionInfo)
+        val sessionId = transitionObserver.getLoggerSessionId()
+
+        assertThat(sessionId).isNotNull()
+        verify(desktopModeEventLogger, times(1)).logSessionEnter(eq(sessionId!!),
+            eq(EnterReason.APP_HANDLE_MENU_BUTTON))
+        verify(desktopModeEventLogger, times(1)).logTaskAdded(eq(sessionId), any())
+    }
+
+    @Test
+    fun taskToFront_screenWake_logSessionStartedAndTaskAdded() {
+        val change = createChange(TRANSIT_TO_FRONT, createTaskInfo(1, WINDOWING_MODE_FREEFORM))
+        val transitionInfo = TransitionInfoBuilder(TRANSIT_WAKE, 0)
+                .addChange(change).build()
+
+        callOnTransitionReady(transitionInfo)
+        val sessionId = transitionObserver.getLoggerSessionId()
+
+        assertThat(sessionId).isNotNull()
+        verify(desktopModeEventLogger, times(1)).logSessionEnter(eq(sessionId!!),
+            eq(EnterReason.SCREEN_ON))
+        verify(desktopModeEventLogger, times(1)).logTaskAdded(eq(sessionId), any())
+    }
+
+    @Test
+    fun freeformTaskVisible_screenTurnOff_logSessionExitAndTaskRemoved_sessionIdNull() {
+        val sessionId = 1
+        // add a freeform task
+        transitionObserver.addTaskInfosToCachedMap(createTaskInfo(1, WINDOWING_MODE_FREEFORM))
+        transitionObserver.setLoggerSessionId(sessionId)
+
+        val transitionInfo = TransitionInfoBuilder(TRANSIT_SLEEP).build()
+        callOnTransitionReady(transitionInfo)
+
+        verify(desktopModeEventLogger, times(1)).logTaskRemoved(eq(sessionId), any())
+        verify(desktopModeEventLogger, times(1)).logSessionExit(eq(sessionId),
+            eq(ExitReason.SCREEN_OFF))
+        assertThat(transitionObserver.getLoggerSessionId()).isNull()
+    }
+
+    @Test
+    fun freeformTaskVisible_exitDesktopUsingDrag_logSessionExitAndTaskRemoved_sessionIdNull() {
+        val sessionId = 1
+        // add a freeform task
+        transitionObserver.addTaskInfosToCachedMap(createTaskInfo(1, WINDOWING_MODE_FREEFORM))
+        transitionObserver.setLoggerSessionId(sessionId)
+
+        // window mode changing from FREEFORM to FULLSCREEN
+        val change = createChange(TRANSIT_TO_FRONT, createTaskInfo(1, WINDOWING_MODE_FULLSCREEN))
+        val transitionInfo = TransitionInfoBuilder(Transitions.TRANSIT_EXIT_DESKTOP_MODE)
+                .addChange(change).build()
+        callOnTransitionReady(transitionInfo)
+
+        verify(desktopModeEventLogger, times(1)).logTaskRemoved(eq(sessionId), any())
+        verify(desktopModeEventLogger, times(1)).logSessionExit(eq(sessionId),
+            eq(ExitReason.DRAG_TO_EXIT))
+        assertThat(transitionObserver.getLoggerSessionId()).isNull()
+    }
+
+    @Test
+    fun freeformTaskVisible_exitDesktopBySwipeUp_logSessionExitAndTaskRemoved_sessionIdNull() {
+        val sessionId = 1
+        // add a freeform task
+        transitionObserver.addTaskInfosToCachedMap(createTaskInfo(1, WINDOWING_MODE_FREEFORM))
+        transitionObserver.setLoggerSessionId(sessionId)
+
+        // recents transition
+        val change = createChange(TRANSIT_TO_BACK, createTaskInfo(1, WINDOWING_MODE_FREEFORM))
+        val transitionInfo = TransitionInfoBuilder(TRANSIT_TO_FRONT, TRANSIT_FLAG_IS_RECENTS)
+                .addChange(change).build()
+        callOnTransitionReady(transitionInfo)
+
+        verify(desktopModeEventLogger, times(1)).logTaskRemoved(eq(sessionId), any())
+        verify(desktopModeEventLogger, times(1)).logSessionExit(eq(sessionId),
+            eq(ExitReason.RETURN_HOME_OR_OVERVIEW))
+        assertThat(transitionObserver.getLoggerSessionId()).isNull()
+    }
+
+    @Test
+    fun freeformTaskVisible_taskFinished_logSessionExitAndTaskRemoved_sessionIdNull() {
+        val sessionId = 1
+        // add a freeform task
+        transitionObserver.addTaskInfosToCachedMap(createTaskInfo(1, WINDOWING_MODE_FREEFORM))
+        transitionObserver.setLoggerSessionId(sessionId)
+
+        // task closing
+        val change = createChange(TRANSIT_CLOSE, createTaskInfo(1, WINDOWING_MODE_FULLSCREEN))
+        val transitionInfo = TransitionInfoBuilder(TRANSIT_CLOSE).addChange(change).build()
+        callOnTransitionReady(transitionInfo)
+
+        verify(desktopModeEventLogger, times(1)).logTaskRemoved(eq(sessionId), any())
+        verify(desktopModeEventLogger, times(1)).logSessionExit(eq(sessionId),
+            eq(ExitReason.TASK_FINISHED))
+        assertThat(transitionObserver.getLoggerSessionId()).isNull()
+    }
+
+    @Test
+    fun sessionExitByRecents_cancelledAnimation_sessionRestored() {
+        val sessionId = 1
+        // add a freeform task to an existing session
+        transitionObserver.addTaskInfosToCachedMap(createTaskInfo(1, WINDOWING_MODE_FREEFORM))
+        transitionObserver.setLoggerSessionId(sessionId)
+
+        // recents transition sent freeform window to back
+        val change = createChange(TRANSIT_TO_BACK, createTaskInfo(1, WINDOWING_MODE_FREEFORM))
+        val transitionInfo1 =
+            TransitionInfoBuilder(TRANSIT_TO_FRONT, TRANSIT_FLAG_IS_RECENTS).addChange(change)
+                    .build()
+        callOnTransitionReady(transitionInfo1)
+        verify(desktopModeEventLogger, times(1)).logTaskRemoved(eq(sessionId), any())
+        verify(desktopModeEventLogger, times(1)).logSessionExit(eq(sessionId),
+            eq(ExitReason.RETURN_HOME_OR_OVERVIEW))
+        assertThat(transitionObserver.getLoggerSessionId()).isNull()
+
+        val transitionInfo2 = TransitionInfoBuilder(TRANSIT_NONE).build()
+        callOnTransitionReady(transitionInfo2)
+
+        verify(desktopModeEventLogger, times(1)).logSessionEnter(any(), any())
+        verify(desktopModeEventLogger, times(1)).logTaskAdded(any(), any())
+    }
+
+    @Test
+    fun sessionAlreadyStarted_newFreeformTaskAdded_logsTaskAdded() {
+        val sessionId = 1
+        // add an existing freeform task
+        transitionObserver.addTaskInfosToCachedMap(createTaskInfo(1, WINDOWING_MODE_FREEFORM))
+        transitionObserver.setLoggerSessionId(sessionId)
+
+        // new freeform task added
+        val change = createChange(TRANSIT_OPEN, createTaskInfo(2, WINDOWING_MODE_FREEFORM))
+        val transitionInfo = TransitionInfoBuilder(TRANSIT_OPEN, 0).addChange(change).build()
+        callOnTransitionReady(transitionInfo)
+
+        verify(desktopModeEventLogger, times(1)).logTaskAdded(eq(sessionId), any())
+        verify(desktopModeEventLogger, never()).logSessionEnter(any(), any())
+    }
+
+    @Test
+    fun sessionAlreadyStarted_freeformTaskRemoved_logsTaskRemoved() {
+        val sessionId = 1
+        // add two existing freeform tasks
+        transitionObserver.addTaskInfosToCachedMap(createTaskInfo(1, WINDOWING_MODE_FREEFORM))
+        transitionObserver.addTaskInfosToCachedMap(createTaskInfo(2, WINDOWING_MODE_FREEFORM))
+        transitionObserver.setLoggerSessionId(sessionId)
+
+        // new freeform task added
+        val change = createChange(TRANSIT_CLOSE, createTaskInfo(2, WINDOWING_MODE_FREEFORM))
+        val transitionInfo = TransitionInfoBuilder(TRANSIT_CLOSE, 0).addChange(change).build()
+        callOnTransitionReady(transitionInfo)
+
+        verify(desktopModeEventLogger, times(1)).logTaskRemoved(eq(sessionId), any())
+        verify(desktopModeEventLogger, never()).logSessionExit(any(), any())
+    }
+
+    /**
+     * Simulate calling the onTransitionReady() method
+     */
+    private fun callOnTransitionReady(transitionInfo: TransitionInfo) {
+        val transition = mock(IBinder::class.java)
+        val startT = mock(
+            SurfaceControl.Transaction::class.java)
+        val finishT = mock(
+            SurfaceControl.Transaction::class.java)
+
+        transitionObserver.onTransitionReady(transition, transitionInfo, startT, finishT)
+    }
+
+    companion object {
+        fun createTaskInfo(taskId: Int, windowMode: Int): ActivityManager.RunningTaskInfo {
+            val taskInfo = ActivityManager.RunningTaskInfo()
+            taskInfo.taskId = taskId
+            taskInfo.configuration.windowConfiguration.windowingMode = windowMode
+
+            return taskInfo
+        }
+
+        fun createChange(mode: Int, taskInfo: ActivityManager.RunningTaskInfo): Change {
+            val change = Change(
+                WindowContainerToken(mock(
+                    IWindowContainerToken::class.java)),
+                mock(SurfaceControl::class.java))
+            change.mode = mode
+            change.taskInfo = taskInfo
+            return change
+        }
+    }
+}
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksControllerTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksControllerTest.kt
index 0136751..5df9dd3 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksControllerTest.kt
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksControllerTest.kt
@@ -87,6 +87,7 @@
 import org.mockito.Mockito.anyInt
 import org.mockito.Mockito.clearInvocations
 import org.mockito.Mockito.verify
+import org.mockito.kotlin.times
 import org.mockito.Mockito.`when` as whenever
 import org.mockito.quality.Strictness
 
@@ -113,6 +114,7 @@
     @Mock lateinit var recentsTransitionHandler: RecentsTransitionHandler
     @Mock lateinit var dragAndDropController: DragAndDropController
     @Mock lateinit var multiInstanceHelper: MultiInstanceHelper
+    @Mock lateinit var desktopModeLoggerTransitionObserver: DesktopModeLoggerTransitionObserver
 
     private lateinit var mockitoSession: StaticMockitoSession
     private lateinit var controller: DesktopTasksController
@@ -163,6 +165,7 @@
             mToggleResizeDesktopTaskTransitionHandler,
             dragToDesktopTransitionHandler,
             desktopModeTaskRepository,
+            desktopModeLoggerTransitionObserver,
             launchAdjacentController,
             recentsTransitionHandler,
             multiInstanceHelper,