Merge "Automatically navigate between communal scenes." 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
new file mode 100644
index 0000000..a8fe16b
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/CommunalSceneStartableTest.kt
@@ -0,0 +1,252 @@
+/*
+ * 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.systemui.communal
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.communal.domain.interactor.communalInteractor
+import com.android.systemui.communal.shared.model.CommunalSceneKey
+import com.android.systemui.coroutines.collectLastValue
+import com.android.systemui.dock.DockManager
+import com.android.systemui.dock.dockManager
+import com.android.systemui.dock.fakeDockManager
+import com.android.systemui.keyguard.data.repository.fakeKeyguardTransitionRepository
+import com.android.systemui.keyguard.domain.interactor.keyguardTransitionInteractor
+import com.android.systemui.keyguard.shared.model.KeyguardState
+import com.android.systemui.kosmos.applicationCoroutineScope
+import com.android.systemui.kosmos.testScope
+import com.android.systemui.testKosmos
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceTimeBy
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class CommunalSceneStartableTest : SysuiTestCase() {
+    private val kosmos = testKosmos()
+
+    private lateinit var underTest: CommunalSceneStartable
+
+    @Before
+    fun setUp() =
+        with(kosmos) {
+            underTest =
+                CommunalSceneStartable(
+                        dockManager = dockManager,
+                        communalInteractor = communalInteractor,
+                        keyguardTransitionInteractor = keyguardTransitionInteractor,
+                        applicationScope = applicationCoroutineScope,
+                        bgScope = applicationCoroutineScope,
+                    )
+                    .apply { start() }
+        }
+
+    @Test
+    fun keyguardGoesAway_forceBlankScene() =
+        with(kosmos) {
+            testScope.runTest {
+                val scene by collectLastValue(communalInteractor.desiredScene)
+
+                communalInteractor.onSceneChanged(CommunalSceneKey.Communal)
+                assertThat(scene).isEqualTo(CommunalSceneKey.Communal)
+
+                fakeKeyguardTransitionRepository.sendTransitionSteps(
+                    from = KeyguardState.PRIMARY_BOUNCER,
+                    to = KeyguardState.GONE,
+                    testScope = this
+                )
+
+                assertThat(scene).isEqualTo(CommunalSceneKey.Blank)
+            }
+        }
+
+    @Test
+    fun deviceDreaming_forceBlankScene() =
+        with(kosmos) {
+            testScope.runTest {
+                val scene by collectLastValue(communalInteractor.desiredScene)
+
+                communalInteractor.onSceneChanged(CommunalSceneKey.Communal)
+                assertThat(scene).isEqualTo(CommunalSceneKey.Communal)
+
+                fakeKeyguardTransitionRepository.sendTransitionSteps(
+                    from = KeyguardState.GLANCEABLE_HUB,
+                    to = KeyguardState.DREAMING,
+                    testScope = this
+                )
+
+                assertThat(scene).isEqualTo(CommunalSceneKey.Blank)
+            }
+        }
+
+    @Test
+    fun deviceDocked_forceCommunalScene() =
+        with(kosmos) {
+            testScope.runTest {
+                val scene by collectLastValue(communalInteractor.desiredScene)
+                assertThat(scene).isEqualTo(CommunalSceneKey.Blank)
+
+                updateDocked(true)
+                fakeKeyguardTransitionRepository.sendTransitionSteps(
+                    from = KeyguardState.GONE,
+                    to = KeyguardState.LOCKSCREEN,
+                    testScope = this
+                )
+                assertThat(scene).isEqualTo(CommunalSceneKey.Communal)
+
+                fakeKeyguardTransitionRepository.sendTransitionSteps(
+                    from = KeyguardState.GLANCEABLE_HUB,
+                    to = KeyguardState.DREAMING,
+                    testScope = this
+                )
+                assertThat(scene).isEqualTo(CommunalSceneKey.Blank)
+            }
+        }
+
+    @Test
+    fun deviceDocked_doesNotForceCommunalIfTransitioningFromCommunal() =
+        with(kosmos) {
+            testScope.runTest {
+                val scene by collectLastValue(communalInteractor.desiredScene)
+                assertThat(scene).isEqualTo(CommunalSceneKey.Blank)
+
+                updateDocked(true)
+                fakeKeyguardTransitionRepository.sendTransitionSteps(
+                    from = KeyguardState.GLANCEABLE_HUB,
+                    to = KeyguardState.LOCKSCREEN,
+                    testScope = this
+                )
+                assertThat(scene).isEqualTo(CommunalSceneKey.Blank)
+            }
+        }
+
+    @Test
+    fun deviceAsleep_forceBlankSceneAfterTimeout() =
+        with(kosmos) {
+            testScope.runTest {
+                val scene by collectLastValue(communalInteractor.desiredScene)
+                communalInteractor.onSceneChanged(CommunalSceneKey.Communal)
+                assertThat(scene).isEqualTo(CommunalSceneKey.Communal)
+
+                fakeKeyguardTransitionRepository.sendTransitionSteps(
+                    from = KeyguardState.GLANCEABLE_HUB,
+                    to = KeyguardState.OFF,
+                    testScope = this
+                )
+                assertThat(scene).isEqualTo(CommunalSceneKey.Communal)
+
+                advanceTimeBy(CommunalSceneStartable.AWAKE_DEBOUNCE_DELAY)
+
+                assertThat(scene).isEqualTo(CommunalSceneKey.Blank)
+            }
+        }
+
+    @Test
+    fun deviceAsleep_wakesUpBeforeTimeout_noChangeInScene() =
+        with(kosmos) {
+            testScope.runTest {
+                val scene by collectLastValue(communalInteractor.desiredScene)
+                communalInteractor.onSceneChanged(CommunalSceneKey.Communal)
+                assertThat(scene).isEqualTo(CommunalSceneKey.Communal)
+
+                fakeKeyguardTransitionRepository.sendTransitionSteps(
+                    from = KeyguardState.GLANCEABLE_HUB,
+                    to = KeyguardState.OFF,
+                    testScope = this
+                )
+                assertThat(scene).isEqualTo(CommunalSceneKey.Communal)
+                advanceTimeBy(CommunalSceneStartable.AWAKE_DEBOUNCE_DELAY / 2)
+                assertThat(scene).isEqualTo(CommunalSceneKey.Communal)
+
+                fakeKeyguardTransitionRepository.sendTransitionSteps(
+                    from = KeyguardState.OFF,
+                    to = KeyguardState.GLANCEABLE_HUB,
+                    testScope = this
+                )
+
+                advanceTimeBy(CommunalSceneStartable.AWAKE_DEBOUNCE_DELAY)
+                assertThat(scene).isEqualTo(CommunalSceneKey.Communal)
+            }
+        }
+
+    @Test
+    fun dockingOnLockscreen_forcesCommunal() =
+        with(kosmos) {
+            testScope.runTest {
+                communalInteractor.onSceneChanged(CommunalSceneKey.Blank)
+                val scene by collectLastValue(communalInteractor.desiredScene)
+
+                // device is docked while on the lockscreen
+                fakeKeyguardTransitionRepository.sendTransitionSteps(
+                    from = KeyguardState.GLANCEABLE_HUB,
+                    to = KeyguardState.LOCKSCREEN,
+                    testScope = this
+                )
+                updateDocked(true)
+
+                assertThat(scene).isEqualTo(CommunalSceneKey.Blank)
+                advanceTimeBy(CommunalSceneStartable.DOCK_DEBOUNCE_DELAY)
+                assertThat(scene).isEqualTo(CommunalSceneKey.Communal)
+            }
+        }
+
+    @Test
+    fun dockingOnLockscreen_doesNotForceCommunalIfDreamStarts() =
+        with(kosmos) {
+            testScope.runTest {
+                communalInteractor.onSceneChanged(CommunalSceneKey.Blank)
+                val scene by collectLastValue(communalInteractor.desiredScene)
+
+                // device is docked while on the lockscreen
+                fakeKeyguardTransitionRepository.sendTransitionSteps(
+                    from = KeyguardState.GLANCEABLE_HUB,
+                    to = KeyguardState.LOCKSCREEN,
+                    testScope = this
+                )
+                updateDocked(true)
+
+                assertThat(scene).isEqualTo(CommunalSceneKey.Blank)
+                advanceTimeBy(CommunalSceneStartable.DOCK_DEBOUNCE_DELAY / 2)
+                assertThat(scene).isEqualTo(CommunalSceneKey.Blank)
+
+                // dream starts shortly after docking
+                fakeKeyguardTransitionRepository.sendTransitionSteps(
+                    from = KeyguardState.LOCKSCREEN,
+                    to = KeyguardState.DREAMING,
+                    testScope = this
+                )
+                advanceTimeBy(CommunalSceneStartable.DOCK_DEBOUNCE_DELAY)
+                assertThat(scene).isEqualTo(CommunalSceneKey.Blank)
+            }
+        }
+
+    private fun TestScope.updateDocked(docked: Boolean) =
+        with(kosmos) {
+            runCurrent()
+            fakeDockManager.setIsDocked(docked)
+            fakeDockManager.setDockEvent(DockManager.STATE_DOCKED)
+            runCurrent()
+        }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/dock/DockManagerFakeKosmos.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/dock/DockManagerFakeKosmos.kt
new file mode 100644
index 0000000..06275fa
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/dock/DockManagerFakeKosmos.kt
@@ -0,0 +1,22 @@
+/*
+ * 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.systemui.dock
+
+import com.android.systemui.kosmos.Kosmos
+
+val Kosmos.dockManager: DockManager by Kosmos.Fixture { fakeDockManager }
+val Kosmos.fakeDockManager: DockManagerFake by Kosmos.Fixture { DockManagerFake() }
diff --git a/packages/SystemUI/src/com/android/systemui/communal/CommunalSceneStartable.kt b/packages/SystemUI/src/com/android/systemui/communal/CommunalSceneStartable.kt
new file mode 100644
index 0000000..f7ba5a4
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/communal/CommunalSceneStartable.kt
@@ -0,0 +1,110 @@
+/*
+ * 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.systemui.communal
+
+import com.android.systemui.CoreStartable
+import com.android.systemui.communal.domain.interactor.CommunalInteractor
+import com.android.systemui.communal.shared.model.CommunalSceneKey
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.dagger.qualifiers.Application
+import com.android.systemui.dagger.qualifiers.Background
+import com.android.systemui.dock.DockManager
+import com.android.systemui.dock.retrieveIsDocked
+import com.android.systemui.keyguard.domain.interactor.KeyguardTransitionInteractor
+import com.android.systemui.keyguard.shared.model.KeyguardState
+import com.android.systemui.keyguard.shared.model.TransitionStep
+import com.android.systemui.util.kotlin.sample
+import javax.inject.Inject
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.flow.onEach
+
+/**
+ * A [CoreStartable] responsible for automatically navigating between communal scenes when certain
+ * conditions are met.
+ */
+@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
+@SysUISingleton
+class CommunalSceneStartable
+@Inject
+constructor(
+    private val dockManager: DockManager,
+    private val communalInteractor: CommunalInteractor,
+    private val keyguardTransitionInteractor: KeyguardTransitionInteractor,
+    @Application private val applicationScope: CoroutineScope,
+    @Background private val bgScope: CoroutineScope,
+) : CoreStartable {
+    override fun start() {
+        // Handle automatically switching based on keyguard state.
+        keyguardTransitionInteractor.startedKeyguardTransitionStep
+            .mapLatest(::determineSceneAfterTransition)
+            .filterNotNull()
+            // TODO(b/322787129): Also set a custom transition animation here to avoid the regular
+            // slide-in animation when setting the scene programmatically
+            .onEach { nextScene -> communalInteractor.onSceneChanged(nextScene) }
+            .launchIn(applicationScope)
+
+        // Handle automatically switching to communal when docked.
+        dockManager
+            .retrieveIsDocked()
+            // Allow some time after docking to ensure the dream doesn't start. If the dream
+            // starts, then we don't want to automatically transition to glanceable hub.
+            .debounce(DOCK_DEBOUNCE_DELAY)
+            .sample(keyguardTransitionInteractor.startedKeyguardState, ::Pair)
+            .onEach { (docked, lastStartedState) ->
+                if (docked && lastStartedState == KeyguardState.LOCKSCREEN) {
+                    communalInteractor.onSceneChanged(CommunalSceneKey.Communal)
+                }
+            }
+            .launchIn(bgScope)
+    }
+
+    private suspend fun determineSceneAfterTransition(
+        lastStartedTransition: TransitionStep,
+    ): CommunalSceneKey? {
+        val to = lastStartedTransition.to
+        val from = lastStartedTransition.from
+        val docked = dockManager.isDocked
+
+        return when {
+            to == KeyguardState.DREAMING -> CommunalSceneKey.Blank
+            docked && to == KeyguardState.LOCKSCREEN && from != KeyguardState.GLANCEABLE_HUB -> {
+                CommunalSceneKey.Communal
+            }
+            to == KeyguardState.GONE -> CommunalSceneKey.Blank
+            !docked && !KeyguardState.deviceIsAwakeInState(to) -> {
+                // If the user taps the screen and wakes the device within this timeout, we don't
+                // want to dismiss the hub
+                delay(AWAKE_DEBOUNCE_DELAY)
+                CommunalSceneKey.Blank
+            }
+            else -> null
+        }
+    }
+
+    companion object {
+        val AWAKE_DEBOUNCE_DELAY = 5.seconds
+        val DOCK_DEBOUNCE_DELAY = 1.seconds
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/communal/widgets/EditWidgetsActivity.kt b/packages/SystemUI/src/com/android/systemui/communal/widgets/EditWidgetsActivity.kt
index ad1327e..54c709d 100644
--- a/packages/SystemUI/src/com/android/systemui/communal/widgets/EditWidgetsActivity.kt
+++ b/packages/SystemUI/src/com/android/systemui/communal/widgets/EditWidgetsActivity.kt
@@ -29,6 +29,7 @@
 import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
 import com.android.internal.logging.UiEventLogger
 import com.android.systemui.communal.shared.log.CommunalUiEvent
+import com.android.systemui.communal.shared.model.CommunalSceneKey
 import com.android.systemui.communal.ui.viewmodel.CommunalEditModeViewModel
 import com.android.systemui.compose.ComposeFacade.setCommunalEditWidgetActivityContent
 import javax.inject.Inject
@@ -126,6 +127,7 @@
             },
             onEditDone = {
                 try {
+                    communalViewModel.onSceneChanged(CommunalSceneKey.Communal)
                     checkNotNull(windowManagerService).lockNow(/* options */ null)
                     finish()
                 } catch (e: RemoteException) {
diff --git a/packages/SystemUI/src/com/android/systemui/dagger/SystemUICoreStartableModule.kt b/packages/SystemUI/src/com/android/systemui/dagger/SystemUICoreStartableModule.kt
index 95233f7..8170879 100644
--- a/packages/SystemUI/src/com/android/systemui/dagger/SystemUICoreStartableModule.kt
+++ b/packages/SystemUI/src/com/android/systemui/dagger/SystemUICoreStartableModule.kt
@@ -24,6 +24,7 @@
 import com.android.systemui.back.domain.interactor.BackActionInteractor
 import com.android.systemui.biometrics.BiometricNotificationService
 import com.android.systemui.clipboardoverlay.ClipboardListener
+import com.android.systemui.communal.CommunalSceneStartable
 import com.android.systemui.communal.log.CommunalLoggerStartable
 import com.android.systemui.communal.widgets.CommunalAppWidgetHostStartable
 import com.android.systemui.controls.dagger.StartControlsStartableModule
@@ -328,6 +329,11 @@
 
     @Binds
     @IntoMap
+    @ClassKey(CommunalSceneStartable::class)
+    abstract fun bindCommunalSceneStartable(impl: CommunalSceneStartable): CoreStartable
+
+    @Binds
+    @IntoMap
     @ClassKey(CommunalAppWidgetHostStartable::class)
     abstract fun bindCommunalAppWidgetHostStartable(
         impl: CommunalAppWidgetHostStartable