Merge "Fix hub timeout not working if dream restarts underneath hub after timeout" into main
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/CommunalSceneStartableTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/CommunalSceneStartableTest.kt
index b4b812d..0ab0959 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/CommunalSceneStartableTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/CommunalSceneStartableTest.kt
@@ -265,7 +265,7 @@
         with(kosmos) {
             testScope.runTest {
                 // Device is dreaming and on communal.
-                fakeKeyguardRepository.setDreaming(true)
+                updateDreaming(true)
                 communalInteractor.changeScene(CommunalScenes.Communal)
 
                 val scene by collectLastValue(communalInteractor.desiredScene)
@@ -282,7 +282,7 @@
         with(kosmos) {
             testScope.runTest {
                 // Device is not dreaming and on communal.
-                fakeKeyguardRepository.setDreaming(false)
+                updateDreaming(false)
                 communalInteractor.changeScene(CommunalScenes.Communal)
 
                 // Scene stays as Communal
@@ -297,7 +297,7 @@
         with(kosmos) {
             testScope.runTest {
                 // Device is dreaming and on communal.
-                fakeKeyguardRepository.setDreaming(true)
+                updateDreaming(true)
                 communalInteractor.changeScene(CommunalScenes.Communal)
 
                 val scene by collectLastValue(communalInteractor.desiredScene)
@@ -309,7 +309,7 @@
 
                 // Dream stops, timeout is cancelled and device stays on hub, because the regular
                 // screen timeout will take effect at this point.
-                fakeKeyguardRepository.setDreaming(false)
+                updateDreaming(false)
                 advanceTimeBy((SCREEN_TIMEOUT / 2).milliseconds)
                 assertThat(scene).isEqualTo(CommunalScenes.Communal)
             }
@@ -320,7 +320,7 @@
         with(kosmos) {
             testScope.runTest {
                 // Device is on communal, but not dreaming.
-                fakeKeyguardRepository.setDreaming(false)
+                updateDreaming(false)
                 communalInteractor.changeScene(CommunalScenes.Communal)
 
                 val scene by collectLastValue(communalInteractor.desiredScene)
@@ -328,7 +328,7 @@
 
                 // Wait a bit, but not long enough to timeout, then start dreaming.
                 advanceTimeBy((SCREEN_TIMEOUT / 2).milliseconds)
-                fakeKeyguardRepository.setDreaming(true)
+                updateDreaming(true)
                 assertThat(scene).isEqualTo(CommunalScenes.Communal)
 
                 // Device times out after one screen timeout interval, dream doesn't reset timeout.
@@ -338,11 +338,31 @@
         }
 
     @Test
+    fun hubTimeout_dreamAfterInitialTimeout_goesToBlank() =
+        with(kosmos) {
+            testScope.runTest {
+                // Device is on communal.
+                communalInteractor.changeScene(CommunalScenes.Communal)
+
+                // Device stays on the hub after the timeout since we're not dreaming.
+                advanceTimeBy(SCREEN_TIMEOUT.milliseconds * 2)
+                val scene by collectLastValue(communalInteractor.desiredScene)
+                assertThat(scene).isEqualTo(CommunalScenes.Communal)
+
+                // Start dreaming.
+                updateDreaming(true)
+
+                // Hub times out immediately.
+                assertThat(scene).isEqualTo(CommunalScenes.Blank)
+            }
+        }
+
+    @Test
     fun hubTimeout_userActivityTriggered_resetsTimeout() =
         with(kosmos) {
             testScope.runTest {
                 // Device is dreaming and on communal.
-                fakeKeyguardRepository.setDreaming(true)
+                updateDreaming(true)
                 communalInteractor.changeScene(CommunalScenes.Communal)
 
                 val scene by collectLastValue(communalInteractor.desiredScene)
@@ -371,7 +391,7 @@
                 fakeSettings.putInt(Settings.System.SCREEN_OFF_TIMEOUT, SCREEN_TIMEOUT * 2)
 
                 // Device is dreaming and on communal.
-                fakeKeyguardRepository.setDreaming(true)
+                updateDreaming(true)
                 communalInteractor.changeScene(CommunalScenes.Communal)
 
                 val scene by collectLastValue(communalInteractor.desiredScene)
@@ -395,6 +415,12 @@
             runCurrent()
         }
 
+    private fun TestScope.updateDreaming(dreaming: Boolean) =
+        with(kosmos) {
+            fakeKeyguardRepository.setDreaming(dreaming)
+            runCurrent()
+        }
+
     private suspend fun TestScope.enableCommunal() =
         with(kosmos) {
             setCommunalAvailable(true)
diff --git a/packages/SystemUI/src/com/android/systemui/communal/CommunalSceneStartable.kt b/packages/SystemUI/src/com/android/systemui/communal/CommunalSceneStartable.kt
index 971ab11..6f20a8d 100644
--- a/packages/SystemUI/src/com/android/systemui/communal/CommunalSceneStartable.kt
+++ b/packages/SystemUI/src/com/android/systemui/communal/CommunalSceneStartable.kt
@@ -42,6 +42,7 @@
 import kotlinx.coroutines.CoroutineDispatcher
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.Job
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.flow.combine
@@ -73,6 +74,10 @@
 ) : CoreStartable {
     private var screenTimeout: Int = DEFAULT_SCREEN_TIMEOUT
 
+    private var timeoutJob: Job? = null
+
+    private var isDreaming: Boolean = false
+
     override fun start() {
         // Handle automatically switching based on keyguard state.
         keyguardTransitionInteractor.startedKeyguardTransitionStep
@@ -112,31 +117,35 @@
             }
             .launchIn(bgScope)
 
-        // Handle timing out back to the dream.
+        // The hub mode timeout should start as soon as the user enters hub mode. At the end of the
+        // timer, if the device is dreaming, hub mode should closed and reveal the dream. If the
+        // dream is not running, nothing will happen. However if the dream starts again underneath
+        // hub mode after the initial timeout expires, such as if the device is docked or the dream
+        // app is updated by the Play store, a new timeout should be started.
         bgScope.launch {
             combine(
                     communalInteractor.desiredScene,
                     // Emit a value on start so the combine starts.
                     communalInteractor.userActivity.emitOnStart()
                 ) { scene, _ ->
-                    // Time out should run whenever we're dreaming and the hub is open, even if not
-                    // docked.
+                    // Only timeout if we're on the hub is open.
                     scene == CommunalScenes.Communal
                 }
-                // mapLatest cancels the previous action block when new values arrive, so any
-                // already running timeout gets cancelled when conditions change or user interaction
-                // is detected.
-                .mapLatest { shouldTimeout ->
-                    if (!shouldTimeout) {
-                        return@mapLatest false
+                .collectLatest { shouldTimeout ->
+                    cancelHubTimeout()
+                    if (shouldTimeout) {
+                        startHubTimeout()
                     }
-
-                    delay(screenTimeout.milliseconds)
-                    true
                 }
-                .sample(keyguardInteractor.isDreaming, ::Pair)
-                .collect { (shouldTimeout, isDreaming) ->
-                    if (isDreaming && shouldTimeout) {
+        }
+        bgScope.launch {
+            keyguardInteractor.isDreaming
+                .sample(communalInteractor.desiredScene, ::Pair)
+                .collectLatest { (isDreaming, scene) ->
+                    this@CommunalSceneStartable.isDreaming = isDreaming
+                    if (scene == CommunalScenes.Communal && isDreaming && timeoutJob == null) {
+                        // If dreaming starts after timeout has expired, ex. if dream restarts under
+                        // the hub, just close the hub immediately.
                         communalInteractor.changeScene(CommunalScenes.Blank)
                     }
                 }
@@ -151,6 +160,24 @@
         }
     }
 
+    private fun cancelHubTimeout() {
+        timeoutJob?.cancel()
+        timeoutJob = null
+    }
+
+    private fun startHubTimeout() {
+        if (timeoutJob == null) {
+            timeoutJob =
+                bgScope.launch {
+                    delay(screenTimeout.milliseconds)
+                    if (isDreaming) {
+                        communalInteractor.changeScene(CommunalScenes.Blank)
+                    }
+                    timeoutJob = null
+                }
+        }
+    }
+
     private suspend fun determineSceneAfterTransition(
         lastStartedTransition: TransitionStep,
     ): SceneKey? {