Refactor syncing between communal STL state and KTF

This introduces a new CommunalSceneTransitionInteractor which handles
syncing between the two state machines.

Similarly, it introduces new APIs for callers to set the desired KTF
state when changing scenes programatically.

Bug: 327225415
Test: atest CommunalSceneTransitionInteractorTest
Flag: com.android.systemui.communal_scene_ktf_refactor
Change-Id: If5a6165713a2933be92730bcb8321393700e0ba1
diff --git a/packages/SystemUI/aconfig/systemui.aconfig b/packages/SystemUI/aconfig/systemui.aconfig
index 71f5511..062a19a 100644
--- a/packages/SystemUI/aconfig/systemui.aconfig
+++ b/packages/SystemUI/aconfig/systemui.aconfig
@@ -981,6 +981,16 @@
 }
 
 flag {
+  name: "communal_scene_ktf_refactor"
+  namespace: "systemui"
+  description: "refactors the syncing mechanism between communal STL and KTF state."
+  bug: "327225415"
+  metadata {
+    purpose: PURPOSE_BUGFIX
+  }
+}
+
+flag {
   name: "app_clips_backlinks"
   namespace: "systemui"
   description: "Enables Backlinks improvement feature in App Clips"
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/domain/interactor/CommunalSceneTransitionInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/domain/interactor/CommunalSceneTransitionInteractorTest.kt
new file mode 100644
index 0000000..f7f70c1
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/domain/interactor/CommunalSceneTransitionInteractorTest.kt
@@ -0,0 +1,685 @@
+/*
+ * 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.domain.interactor
+
+import android.platform.test.annotations.EnableFlags
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.compose.animation.scene.ObservableTransitionState
+import com.android.compose.animation.scene.ObservableTransitionState.Idle
+import com.android.systemui.Flags.FLAG_COMMUNAL_HUB
+import com.android.systemui.Flags.FLAG_COMMUNAL_SCENE_KTF_REFACTOR
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.communal.data.repository.communalSceneRepository
+import com.android.systemui.communal.shared.model.CommunalScenes
+import com.android.systemui.coroutines.collectLastValue
+import com.android.systemui.coroutines.collectValues
+import com.android.systemui.flags.DisableSceneContainer
+import com.android.systemui.flags.Flags
+import com.android.systemui.flags.fakeFeatureFlagsClassic
+import com.android.systemui.keyguard.data.repository.fakeKeyguardRepository
+import com.android.systemui.keyguard.data.repository.keyguardTransitionRepository
+import com.android.systemui.keyguard.data.repository.realKeyguardTransitionRepository
+import com.android.systemui.keyguard.shared.model.KeyguardState.DREAMING
+import com.android.systemui.keyguard.shared.model.KeyguardState.GLANCEABLE_HUB
+import com.android.systemui.keyguard.shared.model.KeyguardState.LOCKSCREEN
+import com.android.systemui.keyguard.shared.model.KeyguardState.OCCLUDED
+import com.android.systemui.keyguard.shared.model.TransitionInfo
+import com.android.systemui.keyguard.shared.model.TransitionModeOnCanceled
+import com.android.systemui.keyguard.shared.model.TransitionState.CANCELED
+import com.android.systemui.keyguard.shared.model.TransitionState.FINISHED
+import com.android.systemui.keyguard.shared.model.TransitionState.RUNNING
+import com.android.systemui.keyguard.shared.model.TransitionState.STARTED
+import com.android.systemui.keyguard.shared.model.TransitionStep
+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.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.launch
+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)
+@EnableFlags(FLAG_COMMUNAL_HUB, FLAG_COMMUNAL_SCENE_KTF_REFACTOR)
+@DisableSceneContainer
+class CommunalSceneTransitionInteractorTest : SysuiTestCase() {
+
+    private val kosmos =
+        testKosmos().apply { keyguardTransitionRepository = realKeyguardTransitionRepository }
+    private val testScope = kosmos.testScope
+
+    private val underTest by lazy { kosmos.communalSceneTransitionInteractor }
+    private val keyguardTransitionRepository by lazy { kosmos.realKeyguardTransitionRepository }
+
+    private val ownerName = CommunalSceneTransitionInteractor::class.java.simpleName
+    private val progress = MutableSharedFlow<Float>()
+
+    private val sceneTransitions =
+        MutableStateFlow<ObservableTransitionState>(Idle(CommunalScenes.Blank))
+
+    private val blankToHub =
+        ObservableTransitionState.Transition(
+            fromScene = CommunalScenes.Blank,
+            toScene = CommunalScenes.Communal,
+            currentScene = flowOf(CommunalScenes.Blank),
+            progress = progress,
+            isInitiatedByUserInput = false,
+            isUserInputOngoing = flowOf(false),
+        )
+
+    private val hubToBlank =
+        ObservableTransitionState.Transition(
+            fromScene = CommunalScenes.Communal,
+            toScene = CommunalScenes.Blank,
+            currentScene = flowOf(CommunalScenes.Communal),
+            progress = progress,
+            isInitiatedByUserInput = false,
+            isUserInputOngoing = flowOf(false),
+        )
+
+    @Before
+    fun setup() {
+        kosmos.fakeFeatureFlagsClassic.set(Flags.COMMUNAL_SERVICE_ENABLED, true)
+        underTest.start()
+        kosmos.communalSceneRepository.setTransitionState(sceneTransitions)
+        testScope.launch { keyguardTransitionRepository.emitInitialStepsFromOff(LOCKSCREEN) }
+    }
+
+    /** Transition from blank to glanceable hub. This is the default case. */
+    @Test
+    fun transition_from_blank_end_in_hub() =
+        testScope.runTest {
+            sceneTransitions.value = blankToHub
+
+            val currentStep by collectLastValue(keyguardTransitionRepository.transitions)
+
+            assertThat(currentStep)
+                .isEqualTo(
+                    TransitionStep(
+                        from = LOCKSCREEN,
+                        to = GLANCEABLE_HUB,
+                        transitionState = STARTED,
+                        value = 0f,
+                        ownerName = ownerName,
+                    )
+                )
+
+            progress.emit(0.4f)
+            assertThat(currentStep)
+                .isEqualTo(
+                    TransitionStep(
+                        from = LOCKSCREEN,
+                        to = GLANCEABLE_HUB,
+                        transitionState = RUNNING,
+                        value = 0.4f,
+                        ownerName = ownerName,
+                    )
+                )
+
+            progress.emit(1f)
+            assertThat(currentStep)
+                .isEqualTo(
+                    TransitionStep(
+                        from = LOCKSCREEN,
+                        to = GLANCEABLE_HUB,
+                        transitionState = RUNNING,
+                        value = 1f,
+                        ownerName = ownerName,
+                    )
+                )
+
+            sceneTransitions.value = Idle(CommunalScenes.Communal)
+            assertThat(currentStep)
+                .isEqualTo(
+                    TransitionStep(
+                        from = LOCKSCREEN,
+                        to = GLANCEABLE_HUB,
+                        transitionState = FINISHED,
+                        value = 1f,
+                        ownerName = ownerName,
+                    )
+                )
+        }
+
+    /** Transition from hub to lockscreen. */
+    @Test
+    fun transition_from_hub_end_in_lockscreen() =
+        testScope.runTest {
+            sceneTransitions.value = hubToBlank
+
+            val currentStep by collectLastValue(keyguardTransitionRepository.transitions)
+
+            assertThat(currentStep)
+                .isEqualTo(
+                    TransitionStep(
+                        from = GLANCEABLE_HUB,
+                        to = LOCKSCREEN,
+                        transitionState = STARTED,
+                        value = 0f,
+                        ownerName = ownerName,
+                    )
+                )
+
+            progress.emit(0.4f)
+            assertThat(currentStep)
+                .isEqualTo(
+                    TransitionStep(
+                        from = GLANCEABLE_HUB,
+                        to = LOCKSCREEN,
+                        transitionState = RUNNING,
+                        value = 0.4f,
+                        ownerName = ownerName,
+                    )
+                )
+
+            sceneTransitions.value = Idle(CommunalScenes.Blank)
+            assertThat(currentStep)
+                .isEqualTo(
+                    TransitionStep(
+                        from = GLANCEABLE_HUB,
+                        to = LOCKSCREEN,
+                        transitionState = FINISHED,
+                        value = 1f,
+                        ownerName = ownerName,
+                    )
+                )
+        }
+
+    /** Transition from hub to dream. */
+    @Test
+    fun transition_from_hub_end_in_dream() =
+        testScope.runTest {
+            kosmos.fakeKeyguardRepository.setDreaming(true)
+            runCurrent()
+
+            sceneTransitions.value = hubToBlank
+
+            val currentStep by collectLastValue(keyguardTransitionRepository.transitions)
+
+            assertThat(currentStep)
+                .isEqualTo(
+                    TransitionStep(
+                        from = GLANCEABLE_HUB,
+                        to = DREAMING,
+                        transitionState = STARTED,
+                        value = 0f,
+                        ownerName = ownerName,
+                    )
+                )
+
+            progress.emit(0.4f)
+            assertThat(currentStep)
+                .isEqualTo(
+                    TransitionStep(
+                        from = GLANCEABLE_HUB,
+                        to = DREAMING,
+                        transitionState = RUNNING,
+                        value = 0.4f,
+                        ownerName = ownerName,
+                    )
+                )
+
+            sceneTransitions.value = Idle(CommunalScenes.Blank)
+            assertThat(currentStep)
+                .isEqualTo(
+                    TransitionStep(
+                        from = GLANCEABLE_HUB,
+                        to = DREAMING,
+                        transitionState = FINISHED,
+                        value = 1f,
+                        ownerName = ownerName,
+                    )
+                )
+        }
+
+    /** Transition from blank to hub, then settle back in blank. */
+    @Test
+    fun transition_from_blank_end_in_blank() =
+        testScope.runTest {
+            sceneTransitions.value = blankToHub
+
+            val currentStep by collectLastValue(keyguardTransitionRepository.transitions)
+            val allSteps by collectValues(keyguardTransitionRepository.transitions)
+
+            assertThat(currentStep)
+                .isEqualTo(
+                    TransitionStep(
+                        from = LOCKSCREEN,
+                        to = GLANCEABLE_HUB,
+                        transitionState = STARTED,
+                        value = 0f,
+                        ownerName = ownerName,
+                    )
+                )
+
+            progress.emit(0.4f)
+            assertThat(currentStep)
+                .isEqualTo(
+                    TransitionStep(
+                        from = LOCKSCREEN,
+                        to = GLANCEABLE_HUB,
+                        transitionState = RUNNING,
+                        value = 0.4f,
+                        ownerName = ownerName,
+                    )
+                )
+
+            val numToDrop = allSteps.size
+            // Settle back in blank
+            sceneTransitions.value = Idle(CommunalScenes.Blank)
+
+            // Assert that KTF reversed transition back to lockscreen.
+            assertThat(allSteps.drop(numToDrop))
+                .containsExactly(
+                    TransitionStep(
+                        from = LOCKSCREEN,
+                        to = GLANCEABLE_HUB,
+                        transitionState = CANCELED,
+                        value = 0.4f,
+                        ownerName = ownerName,
+                    ),
+                    // Transition back to lockscreen
+                    TransitionStep(
+                        from = GLANCEABLE_HUB,
+                        to = LOCKSCREEN,
+                        transitionState = STARTED,
+                        value = 0.6f,
+                        ownerName = ownerName,
+                    ),
+                    TransitionStep(
+                        from = GLANCEABLE_HUB,
+                        to = LOCKSCREEN,
+                        transitionState = FINISHED,
+                        value = 1f,
+                        ownerName = ownerName,
+                    ),
+                )
+                .inOrder()
+        }
+
+    @Test
+    fun transition_to_occluded_with_changed_scene_respected_just_once() =
+        testScope.runTest {
+            underTest.onSceneAboutToChange(CommunalScenes.Blank, OCCLUDED)
+            runCurrent()
+            sceneTransitions.value = hubToBlank
+
+            val currentStep by collectLastValue(keyguardTransitionRepository.transitions)
+
+            assertThat(currentStep)
+                .isEqualTo(
+                    TransitionStep(
+                        from = GLANCEABLE_HUB,
+                        to = OCCLUDED,
+                        transitionState = STARTED,
+                        value = 0f,
+                        ownerName = ownerName,
+                    )
+                )
+
+            sceneTransitions.value = blankToHub
+            assertThat(currentStep)
+                .isEqualTo(
+                    TransitionStep(
+                        from = OCCLUDED,
+                        to = GLANCEABLE_HUB,
+                        transitionState = STARTED,
+                        value = 0f,
+                        ownerName = ownerName,
+                    )
+                )
+
+            sceneTransitions.value = hubToBlank
+            assertThat(currentStep)
+                .isEqualTo(
+                    TransitionStep(
+                        from = GLANCEABLE_HUB,
+                        to = LOCKSCREEN,
+                        transitionState = STARTED,
+                        value = 0f,
+                        ownerName = ownerName,
+                    )
+                )
+        }
+
+    @Test
+    fun transition_from_blank_interrupted() =
+        testScope.runTest {
+            sceneTransitions.value = blankToHub
+
+            val currentStep by collectLastValue(keyguardTransitionRepository.transitions)
+            val allSteps by collectValues(keyguardTransitionRepository.transitions)
+
+            assertThat(currentStep)
+                .isEqualTo(
+                    TransitionStep(
+                        from = LOCKSCREEN,
+                        to = GLANCEABLE_HUB,
+                        transitionState = STARTED,
+                        value = 0f,
+                        ownerName = ownerName,
+                    )
+                )
+
+            progress.emit(0.4f)
+            assertThat(currentStep)
+                .isEqualTo(
+                    TransitionStep(
+                        from = LOCKSCREEN,
+                        to = GLANCEABLE_HUB,
+                        transitionState = RUNNING,
+                        value = 0.4f,
+                        ownerName = ownerName,
+                    )
+                )
+
+            val numToDrop = allSteps.size
+            // Transition back from hub to blank, interrupting
+            // the current transition.
+            sceneTransitions.value = hubToBlank
+
+            assertThat(allSteps.drop(numToDrop))
+                .containsExactly(
+                    TransitionStep(
+                        from = LOCKSCREEN,
+                        to = GLANCEABLE_HUB,
+                        value = 1f,
+                        transitionState = FINISHED,
+                        ownerName = ownerName,
+                    ),
+                    TransitionStep(
+                        from = GLANCEABLE_HUB,
+                        to = LOCKSCREEN,
+                        value = 0f,
+                        transitionState = STARTED,
+                        ownerName = ownerName,
+                    ),
+                )
+                .inOrder()
+
+            progress.emit(0.1f)
+            assertThat(currentStep)
+                .isEqualTo(
+                    TransitionStep(
+                        from = GLANCEABLE_HUB,
+                        to = LOCKSCREEN,
+                        transitionState = RUNNING,
+                        value = 0.1f,
+                        ownerName = ownerName,
+                    )
+                )
+        }
+
+    /**
+     * Blank -> Hub transition interrupted by a new Blank -> Hub transition. KTF state should not be
+     * updated in this case.
+     */
+    @Test
+    fun transition_to_hub_duplicate_does_not_change_ktf() =
+        testScope.runTest {
+            sceneTransitions.value =
+                ObservableTransitionState.Transition(
+                    fromScene = CommunalScenes.Blank,
+                    toScene = CommunalScenes.Communal,
+                    currentScene = flowOf(CommunalScenes.Blank),
+                    progress = progress,
+                    isInitiatedByUserInput = false,
+                    isUserInputOngoing = flowOf(false),
+                )
+
+            val currentStep by collectLastValue(keyguardTransitionRepository.transitions)
+            val allSteps by collectValues(keyguardTransitionRepository.transitions)
+
+            assertThat(currentStep)
+                .isEqualTo(
+                    TransitionStep(
+                        from = LOCKSCREEN,
+                        to = GLANCEABLE_HUB,
+                        transitionState = STARTED,
+                        value = 0f,
+                        ownerName = ownerName,
+                    )
+                )
+
+            progress.emit(0.4f)
+            assertThat(currentStep)
+                .isEqualTo(
+                    TransitionStep(
+                        from = LOCKSCREEN,
+                        to = GLANCEABLE_HUB,
+                        transitionState = RUNNING,
+                        value = 0.4f,
+                        ownerName = ownerName,
+                    )
+                )
+
+            val sizeBefore = allSteps.size
+            val newProgress = MutableSharedFlow<Float>()
+            sceneTransitions.value =
+                ObservableTransitionState.Transition(
+                    fromScene = CommunalScenes.Blank,
+                    toScene = CommunalScenes.Communal,
+                    currentScene = flowOf(CommunalScenes.Blank),
+                    progress = newProgress,
+                    isInitiatedByUserInput = true,
+                    isUserInputOngoing = flowOf(true),
+                )
+
+            // No new KTF steps emitted as a result of the new transition.
+            assertThat(allSteps).hasSize(sizeBefore)
+
+            // Progress is now tracked by the new flow.
+            newProgress.emit(0.1f)
+            assertThat(currentStep)
+                .isEqualTo(
+                    TransitionStep(
+                        from = LOCKSCREEN,
+                        to = GLANCEABLE_HUB,
+                        transitionState = RUNNING,
+                        value = 0.1f,
+                        ownerName = ownerName,
+                    )
+                )
+        }
+
+    /**
+     * STL: Hub -> Blank, then interrupt in KTF LS -> OCCLUDED, then STL still finishes in Blank.
+     * After a KTF transition is started (GLANCEABLE_HUB -> LOCKSCREEN) KTF immediately considers
+     * the active scene to be LOCKSCREEN. This means that all listeners for LOCKSCREEN are active
+     * and may start a new transition LOCKSCREEN -> *. Here we test LOCKSCREEN -> OCCLUDED.
+     *
+     * KTF is allowed to already start and play the other transition, while the STL transition may
+     * finish later (gesture completes much later). When we eventually settle the STL transition in
+     * Blank we do not want to force KTF back to its original destination (LOCKSCREEN). Instead, for
+     * this scenario the settle can be ignored.
+     */
+    @Test
+    fun transition_to_blank_interrupted_by_ktf_transition_then_finish_in_blank() =
+        testScope.runTest {
+            sceneTransitions.value = hubToBlank
+
+            val currentStep by collectLastValue(keyguardTransitionRepository.transitions)
+
+            assertThat(currentStep)
+                .isEqualTo(
+                    TransitionStep(
+                        from = GLANCEABLE_HUB,
+                        to = LOCKSCREEN,
+                        transitionState = STARTED,
+                        value = 0f,
+                        ownerName = ownerName,
+                    )
+                )
+
+            progress.emit(0.4f)
+            assertThat(currentStep)
+                .isEqualTo(
+                    TransitionStep(
+                        from = GLANCEABLE_HUB,
+                        to = LOCKSCREEN,
+                        transitionState = RUNNING,
+                        value = 0.4f,
+                        ownerName = ownerName,
+                    )
+                )
+
+            // Start another transition externally while our scene
+            // transition is happening.
+            keyguardTransitionRepository.startTransition(
+                TransitionInfo(
+                    ownerName = "external",
+                    from = LOCKSCREEN,
+                    to = OCCLUDED,
+                    animator = null,
+                    modeOnCanceled = TransitionModeOnCanceled.RESET
+                )
+            )
+
+            assertThat(currentStep)
+                .isEqualTo(
+                    TransitionStep(
+                        from = LOCKSCREEN,
+                        to = OCCLUDED,
+                        transitionState = STARTED,
+                        value = 0f,
+                        ownerName = "external",
+                    )
+                )
+
+            // Scene progress should not affect KTF transition anymore
+            progress.emit(0.7f)
+            assertThat(currentStep)
+                .isEqualTo(
+                    TransitionStep(
+                        from = LOCKSCREEN,
+                        to = OCCLUDED,
+                        transitionState = STARTED,
+                        value = 0f,
+                        ownerName = "external",
+                    )
+                )
+
+            // Scene transition still finishes but should not impact KTF transition
+            sceneTransitions.value = Idle(CommunalScenes.Blank)
+
+            assertThat(currentStep)
+                .isEqualTo(
+                    TransitionStep(
+                        from = LOCKSCREEN,
+                        to = OCCLUDED,
+                        transitionState = STARTED,
+                        value = 0f,
+                        ownerName = "external",
+                    )
+                )
+        }
+
+    /**
+     * STL: Hub -> Blank, then interrupt in KTF LS -> OCCLUDED, then STL finishes back in Hub.
+     *
+     * This is similar to the previous scenario but the gesture may have been interrupted by any
+     * other transition. KTF needs to immediately finish in GLANCEABLE_HUB (there is a jump cut).
+     */
+    @Test
+    fun transition_to_blank_interrupted_by_ktf_transition_then_finish_in_hub() =
+        testScope.runTest {
+            sceneTransitions.value = hubToBlank
+
+            val currentStep by collectLastValue(keyguardTransitionRepository.transitions)
+
+            assertThat(currentStep)
+                .isEqualTo(
+                    TransitionStep(
+                        from = GLANCEABLE_HUB,
+                        to = LOCKSCREEN,
+                        transitionState = STARTED,
+                        value = 0f,
+                        ownerName = ownerName,
+                    )
+                )
+
+            progress.emit(0.4f)
+            assertThat(currentStep)
+                .isEqualTo(
+                    TransitionStep(
+                        from = GLANCEABLE_HUB,
+                        to = LOCKSCREEN,
+                        transitionState = RUNNING,
+                        value = 0.4f,
+                        ownerName = ownerName,
+                    )
+                )
+
+            // Start another transition externally while our scene
+            // transition is happening.
+            keyguardTransitionRepository.startTransition(
+                TransitionInfo(
+                    ownerName = "external",
+                    from = LOCKSCREEN,
+                    to = OCCLUDED,
+                    animator = null,
+                    modeOnCanceled = TransitionModeOnCanceled.RESET
+                )
+            )
+
+            assertThat(currentStep)
+                .isEqualTo(
+                    TransitionStep(
+                        from = LOCKSCREEN,
+                        to = OCCLUDED,
+                        transitionState = STARTED,
+                        value = 0f,
+                        ownerName = "external",
+                    )
+                )
+
+            // Scene progress should not affect KTF transition anymore
+            progress.emit(0.7f)
+            assertThat(currentStep)
+                .isEqualTo(
+                    TransitionStep(
+                        from = LOCKSCREEN,
+                        to = OCCLUDED,
+                        transitionState = STARTED,
+                        value = 0f,
+                        ownerName = "external",
+                    )
+                )
+
+            // We land back in communal.
+            sceneTransitions.value = Idle(CommunalScenes.Communal)
+
+            assertThat(currentStep)
+                .isEqualTo(
+                    TransitionStep(
+                        from = OCCLUDED,
+                        to = GLANCEABLE_HUB,
+                        transitionState = FINISHED,
+                        value = 1f,
+                        ownerName = ownerName,
+                    )
+                )
+        }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/communal/dagger/CommunalModule.kt b/packages/SystemUI/src/com/android/systemui/communal/dagger/CommunalModule.kt
index 3d201a3..a445335 100644
--- a/packages/SystemUI/src/com/android/systemui/communal/dagger/CommunalModule.kt
+++ b/packages/SystemUI/src/com/android/systemui/communal/dagger/CommunalModule.kt
@@ -17,6 +17,7 @@
 package com.android.systemui.communal.dagger
 
 import android.content.Context
+import com.android.systemui.CoreStartable
 import com.android.systemui.communal.data.backup.CommunalBackupUtils
 import com.android.systemui.communal.data.db.CommunalDatabaseModule
 import com.android.systemui.communal.data.repository.CommunalMediaRepositoryModule
@@ -26,6 +27,7 @@
 import com.android.systemui.communal.data.repository.CommunalSmartspaceRepositoryModule
 import com.android.systemui.communal.data.repository.CommunalTutorialRepositoryModule
 import com.android.systemui.communal.data.repository.CommunalWidgetRepositoryModule
+import com.android.systemui.communal.domain.interactor.CommunalSceneTransitionInteractor
 import com.android.systemui.communal.shared.model.CommunalScenes
 import com.android.systemui.communal.util.CommunalColors
 import com.android.systemui.communal.util.CommunalColorsImpl
@@ -40,6 +42,8 @@
 import dagger.Binds
 import dagger.Module
 import dagger.Provides
+import dagger.multibindings.ClassKey
+import dagger.multibindings.IntoMap
 import kotlinx.coroutines.CoroutineScope
 
 @Module(
@@ -69,6 +73,13 @@
 
     @Binds fun bindCommunalColors(impl: CommunalColorsImpl): CommunalColors
 
+    @Binds
+    @IntoMap
+    @ClassKey(CommunalSceneTransitionInteractor::class)
+    abstract fun bindCommunalSceneTransitionInteractor(
+        impl: CommunalSceneTransitionInteractor
+    ): CoreStartable
+
     companion object {
         @Provides
         @Communal
diff --git a/packages/SystemUI/src/com/android/systemui/communal/data/repository/CommunalSceneRepository.kt b/packages/SystemUI/src/com/android/systemui/communal/data/repository/CommunalSceneRepository.kt
index 7a4006d..260dcba 100644
--- a/packages/SystemUI/src/com/android/systemui/communal/data/repository/CommunalSceneRepository.kt
+++ b/packages/SystemUI/src/com/android/systemui/communal/data/repository/CommunalSceneRepository.kt
@@ -28,7 +28,6 @@
 import javax.inject.Inject
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.SharingStarted
@@ -52,7 +51,7 @@
     fun changeScene(toScene: SceneKey, transitionKey: TransitionKey? = null)
 
     /** Immediately snaps to the desired scene. */
-    fun snapToScene(toScene: SceneKey, delayMillis: Long = 0)
+    fun snapToScene(toScene: SceneKey)
 
     /**
      * Updates the transition state of the hub [SceneTransitionLayout].
@@ -93,11 +92,10 @@
         }
     }
 
-    override fun snapToScene(toScene: SceneKey, delayMillis: Long) {
+    override fun snapToScene(toScene: SceneKey) {
         applicationScope.launch {
             // SceneTransitionLayout state updates must be triggered on the thread the STL was
             // created on.
-            delay(delayMillis)
             sceneDataSource.snapToScene(toScene)
         }
     }
diff --git a/packages/SystemUI/src/com/android/systemui/communal/data/repository/CommunalSceneTransitionRepository.kt b/packages/SystemUI/src/com/android/systemui/communal/data/repository/CommunalSceneTransitionRepository.kt
new file mode 100644
index 0000000..7d9e1df
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/communal/data/repository/CommunalSceneTransitionRepository.kt
@@ -0,0 +1,32 @@
+/*
+ * 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.data.repository
+
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.keyguard.shared.model.KeyguardState
+import javax.inject.Inject
+import kotlinx.coroutines.flow.MutableStateFlow
+
+@SysUISingleton
+class CommunalSceneTransitionRepository @Inject constructor() {
+    /**
+     * This [KeyguardState] will indicate which sub state within KTF should be navigated to when the
+     * next transition away from communal scene is started. It will be consumed exactly once and
+     * after that the state will be set back to null.
+     */
+    val nextLockscreenTargetState: MutableStateFlow<KeyguardState?> = MutableStateFlow(null)
+}
diff --git a/packages/SystemUI/src/com/android/systemui/communal/domain/interactor/CommunalSceneInteractor.kt b/packages/SystemUI/src/com/android/systemui/communal/domain/interactor/CommunalSceneInteractor.kt
index 122f9647..aa9cbd0 100644
--- a/packages/SystemUI/src/com/android/systemui/communal/domain/interactor/CommunalSceneInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/communal/domain/interactor/CommunalSceneInteractor.kt
@@ -16,6 +16,7 @@
 
 package com.android.systemui.communal.domain.interactor
 
+import com.android.app.tracing.coroutines.launch
 import com.android.compose.animation.scene.ObservableTransitionState
 import com.android.compose.animation.scene.SceneKey
 import com.android.compose.animation.scene.TransitionKey
@@ -26,9 +27,11 @@
 import com.android.systemui.communal.shared.model.EditModeState
 import com.android.systemui.dagger.SysUISingleton
 import com.android.systemui.dagger.qualifiers.Application
+import com.android.systemui.keyguard.shared.model.KeyguardState
 import javax.inject.Inject
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.SharingStarted
@@ -39,6 +42,7 @@
 import kotlinx.coroutines.flow.flowOf
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
 
 @OptIn(ExperimentalCoroutinesApi::class)
 @SysUISingleton
@@ -57,17 +61,48 @@
         _isLaunchingWidget.value = launching
     }
 
+    fun interface OnSceneAboutToChangeListener {
+        /** Notifies that the scene is about to change to [toScene]. */
+        fun onSceneAboutToChange(toScene: SceneKey, keyguardState: KeyguardState?)
+    }
+
+    private val onSceneAboutToChangeListener = mutableSetOf<OnSceneAboutToChangeListener>()
+
+    /** Registers a listener which is called when the scene is about to change. */
+    fun registerSceneStateProcessor(processor: OnSceneAboutToChangeListener) {
+        onSceneAboutToChangeListener.add(processor)
+    }
+
     /**
      * Asks for an asynchronous scene witch to [newScene], which will use the corresponding
      * installed transition or the one specified by [transitionKey], if provided.
      */
-    fun changeScene(newScene: SceneKey, transitionKey: TransitionKey? = null) {
-        communalSceneRepository.changeScene(newScene, transitionKey)
+    fun changeScene(
+        newScene: SceneKey,
+        transitionKey: TransitionKey? = null,
+        keyguardState: KeyguardState? = null,
+    ) {
+        applicationScope.launch {
+            notifyListeners(newScene, keyguardState)
+            communalSceneRepository.changeScene(newScene, transitionKey)
+        }
     }
 
     /** Immediately snaps to the new scene. */
-    fun snapToScene(newScene: SceneKey, delayMillis: Long = 0) {
-        communalSceneRepository.snapToScene(newScene, delayMillis)
+    fun snapToScene(
+        newScene: SceneKey,
+        delayMillis: Long = 0,
+        keyguardState: KeyguardState? = null
+    ) {
+        applicationScope.launch("$TAG#snapToScene") {
+            delay(delayMillis)
+            notifyListeners(newScene, keyguardState)
+            communalSceneRepository.snapToScene(newScene)
+        }
+    }
+
+    private fun notifyListeners(newScene: SceneKey, keyguardState: KeyguardState?) {
+        onSceneAboutToChangeListener.forEach { it.onSceneAboutToChange(newScene, keyguardState) }
     }
 
     /** Changes to Blank scene when starting an activity after dismissing keyguard. */
@@ -164,4 +199,8 @@
                 started = SharingStarted.WhileSubscribed(),
                 initialValue = false,
             )
+
+    private companion object {
+        const val TAG = "CommunalSceneInteractor"
+    }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/communal/domain/interactor/CommunalSceneTransitionInteractor.kt b/packages/SystemUI/src/com/android/systemui/communal/domain/interactor/CommunalSceneTransitionInteractor.kt
new file mode 100644
index 0000000..8351566
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/communal/domain/interactor/CommunalSceneTransitionInteractor.kt
@@ -0,0 +1,293 @@
+/*
+ * 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.domain.interactor
+
+import com.android.compose.animation.scene.ObservableTransitionState
+import com.android.compose.animation.scene.SceneKey
+import com.android.compose.animation.scene.SceneTransitionLayout
+import com.android.systemui.CoreStartable
+import com.android.systemui.Flags.communalSceneKtfRefactor
+import com.android.systemui.communal.data.repository.CommunalSceneTransitionRepository
+import com.android.systemui.communal.shared.model.CommunalScenes
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.dagger.qualifiers.Application
+import com.android.systemui.keyguard.domain.interactor.InternalKeyguardTransitionInteractor
+import com.android.systemui.keyguard.domain.interactor.KeyguardInteractor
+import com.android.systemui.keyguard.domain.interactor.KeyguardTransitionInteractor
+import com.android.systemui.keyguard.shared.model.KeyguardState
+import com.android.systemui.keyguard.shared.model.TransitionInfo
+import com.android.systemui.keyguard.shared.model.TransitionModeOnCanceled
+import com.android.systemui.keyguard.shared.model.TransitionState
+import com.android.systemui.scene.shared.flag.SceneContainerFlag
+import com.android.systemui.util.kotlin.pairwise
+import java.util.UUID
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+
+/**
+ * This class listens to [SceneTransitionLayout] transitions and manages keyguard transition
+ * framework (KTF) states accordingly for communal states.
+ *
+ * There are a few rules:
+ * - There are only 2 communal scenes: [CommunalScenes.Communal] and [CommunalScenes.Blank]
+ * - When scene framework is on [CommunalScenes.Blank], KTF is allowed to change its scenes freely
+ * - When scene framework is on [CommunalScenes.Communal], KTF is locked into
+ *   [KeyguardState.GLANCEABLE_HUB]
+ */
+@SysUISingleton
+class CommunalSceneTransitionInteractor
+@Inject
+constructor(
+    val transitionInteractor: KeyguardTransitionInteractor,
+    val internalTransitionInteractor: InternalKeyguardTransitionInteractor,
+    private val settingsInteractor: CommunalSettingsInteractor,
+    @Application private val applicationScope: CoroutineScope,
+    private val sceneInteractor: CommunalSceneInteractor,
+    private val repository: CommunalSceneTransitionRepository,
+    keyguardInteractor: KeyguardInteractor,
+) : CoreStartable, CommunalSceneInteractor.OnSceneAboutToChangeListener {
+
+    private var currentTransitionId: UUID? = null
+    private var progressJob: Job? = null
+
+    private val currentToState: KeyguardState
+        get() = internalTransitionInteractor.currentTransitionInfoInternal.value.to
+
+    /**
+     * The next keyguard state to trigger when exiting [CommunalScenes.Communal]. This is only used
+     * if the state is changed by user gesture or not explicitly defined by the caller when changing
+     * scenes programmatically.
+     *
+     * This is needed because we do not always want to exit back to the KTF state we came from. For
+     * example, when going from HUB (Communal) -> OCCLUDED (Blank) -> HUB (Communal) and then
+     * closing the hub via gesture, we don't want to go back to OCCLUDED but instead either go to
+     * DREAM or LOCKSCREEN depending on if there is a dream showing.
+     */
+    private val nextKeyguardStateInternal =
+        combine(
+            keyguardInteractor.isDreaming,
+            keyguardInteractor.isKeyguardOccluded,
+            keyguardInteractor.isKeyguardGoingAway,
+        ) { dreaming, occluded, keyguardGoingAway ->
+            if (keyguardGoingAway) {
+                KeyguardState.GONE
+            } else if (dreaming) {
+                KeyguardState.DREAMING
+            } else if (occluded) {
+                KeyguardState.OCCLUDED
+            } else {
+                KeyguardState.LOCKSCREEN
+            }
+        }
+
+    private val nextKeyguardState: StateFlow<KeyguardState> =
+        combine(
+                repository.nextLockscreenTargetState,
+                nextKeyguardStateInternal,
+            ) { override, nextState ->
+                override ?: nextState
+            }
+            .stateIn(
+                scope = applicationScope,
+                started = SharingStarted.Eagerly,
+                initialValue = KeyguardState.LOCKSCREEN,
+            )
+
+    override fun start() {
+        if (
+            communalSceneKtfRefactor() &&
+                settingsInteractor.isCommunalFlagEnabled() &&
+                !SceneContainerFlag.isEnabled
+        ) {
+            sceneInteractor.registerSceneStateProcessor(this)
+            listenForSceneTransitionProgress()
+        }
+    }
+
+    /**
+     * Called when the scene is programmatically changed, allowing callers to specify which KTF
+     * state should be set when transitioning to [CommunalScenes.Blank]
+     */
+    override fun onSceneAboutToChange(toScene: SceneKey, keyguardState: KeyguardState?) {
+        if (toScene != CommunalScenes.Blank || keyguardState == null) return
+        repository.nextLockscreenTargetState.value = keyguardState
+    }
+
+    /** Monitors [SceneTransitionLayout] state and updates KTF state accordingly. */
+    private fun listenForSceneTransitionProgress() {
+        applicationScope.launch {
+            sceneInteractor.transitionState
+                .pairwise(ObservableTransitionState.Idle(CommunalScenes.Blank))
+                .collect { (prevTransition, transition) ->
+                    when (transition) {
+                        is ObservableTransitionState.Idle -> handleIdle(prevTransition, transition)
+                        is ObservableTransitionState.Transition ->
+                            handleTransition(prevTransition, transition)
+                    }
+                }
+        }
+    }
+
+    private suspend fun handleIdle(
+        prevTransition: ObservableTransitionState,
+        idle: ObservableTransitionState.Idle
+    ) {
+        if (
+            prevTransition is ObservableTransitionState.Transition &&
+                currentTransitionId != null &&
+                idle.currentScene == prevTransition.toScene
+        ) {
+            finishCurrentTransition()
+        } else {
+            // We may receive an Idle event without a corresponding Transition
+            // event, such as when snapping to a scene without an animation.
+            val targetState =
+                if (idle.currentScene == CommunalScenes.Blank) {
+                    nextKeyguardState.value
+                } else {
+                    KeyguardState.GLANCEABLE_HUB
+                }
+            transitionKtfTo(targetState)
+            repository.nextLockscreenTargetState.value = null
+        }
+    }
+
+    private fun finishCurrentTransition() {
+        internalTransitionInteractor.updateTransition(
+            currentTransitionId!!,
+            1f,
+            TransitionState.FINISHED
+        )
+        resetTransitionData()
+    }
+
+    private suspend fun finishReversedTransitionTo(state: KeyguardState) {
+        val newTransition =
+            TransitionInfo(
+                ownerName = this::class.java.simpleName,
+                from = internalTransitionInteractor.currentTransitionInfoInternal.value.to,
+                to = state,
+                animator = null,
+                modeOnCanceled = TransitionModeOnCanceled.REVERSE
+            )
+        currentTransitionId = internalTransitionInteractor.startTransition(newTransition)
+        internalTransitionInteractor.updateTransition(
+            currentTransitionId!!,
+            1f,
+            TransitionState.FINISHED
+        )
+        resetTransitionData()
+    }
+
+    private fun resetTransitionData() {
+        progressJob?.cancel()
+        progressJob = null
+        currentTransitionId = null
+    }
+
+    private suspend fun handleTransition(
+        prevTransition: ObservableTransitionState,
+        transition: ObservableTransitionState.Transition
+    ) {
+        if (prevTransition.isTransitioning(from = transition.fromScene, to = transition.toScene)) {
+            // This is a new transition, but exactly the same as the previous state. Skip resetting
+            // KTF for this case and just collect the new progress instead.
+            collectProgress(transition)
+        } else if (transition.toScene == CommunalScenes.Communal) {
+            if (currentTransitionId != null) {
+                if (currentToState == KeyguardState.GLANCEABLE_HUB) {
+                    transitionKtfTo(transitionInteractor.getStartedFromState())
+                }
+            }
+            startTransitionToGlanceableHub()
+            collectProgress(transition)
+        } else if (transition.toScene == CommunalScenes.Blank) {
+            if (currentTransitionId != null) {
+                // Another transition started before this one is completed. Transition to the
+                // GLANCEABLE_HUB state so that we can properly transition away from it.
+                transitionKtfTo(KeyguardState.GLANCEABLE_HUB)
+            }
+            startTransitionFromGlanceableHub()
+            collectProgress(transition)
+        }
+    }
+
+    private suspend fun transitionKtfTo(state: KeyguardState) {
+        val currentTransition = transitionInteractor.transitionState.value
+        if (currentTransition.isFinishedIn(state)) {
+            // This is already the state we want to be in
+            resetTransitionData()
+        } else if (currentTransition.isTransitioning(to = state)) {
+            finishCurrentTransition()
+        } else {
+            finishReversedTransitionTo(state)
+        }
+    }
+
+    private fun collectProgress(transition: ObservableTransitionState.Transition) {
+        progressJob?.cancel()
+        progressJob = applicationScope.launch { transition.progress.collect { updateProgress(it) } }
+    }
+
+    private suspend fun startTransitionFromGlanceableHub() {
+        val newTransition =
+            TransitionInfo(
+                ownerName = this::class.java.simpleName,
+                from = KeyguardState.GLANCEABLE_HUB,
+                to = nextKeyguardState.value,
+                animator = null,
+                modeOnCanceled = TransitionModeOnCanceled.RESET,
+            )
+        repository.nextLockscreenTargetState.value = null
+        startTransition(newTransition)
+    }
+
+    private suspend fun startTransitionToGlanceableHub() {
+        val currentState = internalTransitionInteractor.currentTransitionInfoInternal.value.to
+        val newTransition =
+            TransitionInfo(
+                ownerName = this::class.java.simpleName,
+                from = currentState,
+                to = KeyguardState.GLANCEABLE_HUB,
+                animator = null,
+                modeOnCanceled = TransitionModeOnCanceled.RESET,
+            )
+        startTransition(newTransition)
+    }
+
+    private suspend fun startTransition(transitionInfo: TransitionInfo) {
+        if (currentTransitionId != null) {
+            resetTransitionData()
+        }
+        currentTransitionId = internalTransitionInteractor.startTransition(transitionInfo)
+    }
+
+    private fun updateProgress(progress: Float) {
+        if (currentTransitionId == null) return
+        internalTransitionInteractor.updateTransition(
+            currentTransitionId!!,
+            progress.coerceIn(0f, 1f),
+            TransitionState.RUNNING
+        )
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/communal/ui/viewmodel/BaseCommunalViewModel.kt b/packages/SystemUI/src/com/android/systemui/communal/ui/viewmodel/BaseCommunalViewModel.kt
index 19d7ceb..01ed2b7 100644
--- a/packages/SystemUI/src/com/android/systemui/communal/ui/viewmodel/BaseCommunalViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/communal/ui/viewmodel/BaseCommunalViewModel.kt
@@ -27,6 +27,7 @@
 import com.android.systemui.communal.domain.model.CommunalContentModel
 import com.android.systemui.communal.shared.model.EditModeState
 import com.android.systemui.communal.widgets.WidgetConfigurator
+import com.android.systemui.keyguard.shared.model.KeyguardState
 import com.android.systemui.media.controls.ui.view.MediaHost
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableStateFlow
@@ -75,8 +76,16 @@
         communalInteractor.signalUserInteraction()
     }
 
-    fun changeScene(scene: SceneKey, transitionKey: TransitionKey? = null) {
-        communalSceneInteractor.changeScene(scene, transitionKey)
+    /**
+     * Asks for an asynchronous scene witch to [newScene], which will use the corresponding
+     * installed transition or the one specified by [transitionKey], if provided.
+     */
+    fun changeScene(
+        scene: SceneKey,
+        transitionKey: TransitionKey? = null,
+        keyguardState: KeyguardState? = null
+    ) {
+        communalSceneInteractor.changeScene(scene, transitionKey, keyguardState)
     }
 
     fun setEditModeState(state: EditModeState?) = communalSceneInteractor.setEditModeState(state)
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/communal/data/repository/CommunalSceneTransitionRepositoryKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/communal/data/repository/CommunalSceneTransitionRepositoryKosmos.kt
new file mode 100644
index 0000000..2050437
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/communal/data/repository/CommunalSceneTransitionRepositoryKosmos.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.communal.data.repository
+
+import com.android.systemui.kosmos.Kosmos
+
+val Kosmos.communalSceneTransitionRepository: CommunalSceneTransitionRepository by
+    Kosmos.Fixture { CommunalSceneTransitionRepository() }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/communal/data/repository/FakeCommunalSceneRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/communal/data/repository/FakeCommunalSceneRepository.kt
index d280be2..8245481 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/communal/data/repository/FakeCommunalSceneRepository.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/communal/data/repository/FakeCommunalSceneRepository.kt
@@ -6,7 +6,6 @@
 import com.android.systemui.communal.shared.model.CommunalScenes
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.SharingStarted
@@ -25,11 +24,10 @@
 ) : CommunalSceneRepository {
 
     override fun changeScene(toScene: SceneKey, transitionKey: TransitionKey?) =
-        snapToScene(toScene, 0)
+        snapToScene(toScene)
 
-    override fun snapToScene(toScene: SceneKey, delayMillis: Long) {
+    override fun snapToScene(toScene: SceneKey) {
         applicationScope.launch {
-            delay(delayMillis)
             currentScene.value = toScene
             _transitionState.value = flowOf(ObservableTransitionState.Idle(toScene))
         }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/communal/domain/interactor/CommunalSceneTransitionInteractorKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/communal/domain/interactor/CommunalSceneTransitionInteractorKosmos.kt
new file mode 100644
index 0000000..e6e59e1
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/communal/domain/interactor/CommunalSceneTransitionInteractorKosmos.kt
@@ -0,0 +1,37 @@
+/*
+ * 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.domain.interactor
+
+import com.android.systemui.communal.data.repository.communalSceneTransitionRepository
+import com.android.systemui.keyguard.domain.interactor.internalKeyguardTransitionInteractor
+import com.android.systemui.keyguard.domain.interactor.keyguardInteractor
+import com.android.systemui.keyguard.domain.interactor.keyguardTransitionInteractor
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.applicationCoroutineScope
+
+val Kosmos.communalSceneTransitionInteractor: CommunalSceneTransitionInteractor by
+    Kosmos.Fixture {
+        CommunalSceneTransitionInteractor(
+            applicationScope = applicationCoroutineScope,
+            transitionInteractor = keyguardTransitionInteractor,
+            internalTransitionInteractor = internalKeyguardTransitionInteractor,
+            settingsInteractor = communalSettingsInteractor,
+            sceneInteractor = communalSceneInteractor,
+            repository = communalSceneTransitionRepository,
+            keyguardInteractor = keyguardInteractor,
+        )
+    }