Merge changes Ic8538604,I29663488 into main

* changes:
  [flexiglass] Changes Lockscreen's Swipe.Left to Swipe.Start
  [flexiglass] Communal scene navigates to shade and bouncer.
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/CommunalScene.kt b/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/CommunalScene.kt
index e41a7df..a88ad94 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/CommunalScene.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/CommunalScene.kt
@@ -21,11 +21,10 @@
 import androidx.compose.ui.Modifier
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import com.android.compose.animation.scene.SceneScope
-import com.android.compose.animation.scene.Swipe
-import com.android.compose.animation.scene.SwipeDirection
 import com.android.compose.animation.scene.UserAction
 import com.android.compose.animation.scene.UserActionResult
 import com.android.systemui.communal.shared.model.CommunalBackgroundType
+import com.android.systemui.communal.ui.viewmodel.CommunalUserActionsViewModel
 import com.android.systemui.communal.ui.viewmodel.CommunalViewModel
 import com.android.systemui.communal.util.CommunalColors
 import com.android.systemui.dagger.SysUISingleton
@@ -33,38 +32,32 @@
 import com.android.systemui.scene.shared.model.Scenes
 import com.android.systemui.scene.ui.composable.Scene
 import javax.inject.Inject
-import kotlinx.coroutines.awaitCancellation
 import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asStateFlow
 
 /** The communal scene shows glanceable hub when the device is locked and docked. */
 @SysUISingleton
 class CommunalScene
 @Inject
 constructor(
-    private val viewModel: CommunalViewModel,
+    private val contentViewModel: CommunalViewModel,
+    actionsViewModelFactory: CommunalUserActionsViewModel.Factory,
     private val communalColors: CommunalColors,
     private val communalContent: CommunalContent,
 ) : ExclusiveActivatable(), Scene {
     override val key = Scenes.Communal
 
-    override val userActions: Flow<Map<UserAction, UserActionResult>> =
-        MutableStateFlow(
-                mapOf(
-                    Swipe(SwipeDirection.End) to Scenes.Lockscreen,
-                )
-            )
-            .asStateFlow()
+    private val actionsViewModel: CommunalUserActionsViewModel = actionsViewModelFactory.create()
+
+    override val userActions: Flow<Map<UserAction, UserActionResult>> = actionsViewModel.actions
 
     override suspend fun onActivated(): Nothing {
-        awaitCancellation()
+        actionsViewModel.activate()
     }
 
     @Composable
     override fun SceneScope.Content(modifier: Modifier) {
         val backgroundType by
-            viewModel.communalBackground.collectAsStateWithLifecycle(
+            contentViewModel.communalBackground.collectAsStateWithLifecycle(
                 initialValue = CommunalBackgroundType.ANIMATED
             )
 
@@ -72,7 +65,7 @@
             backgroundType = backgroundType,
             colors = communalColors,
             content = communalContent,
-            viewModel = viewModel,
+            viewModel = contentViewModel,
             modifier = modifier.horizontalNestedScrollToScene(),
         )
     }
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/ui/viewmodel/CommunalUserActionsViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/ui/viewmodel/CommunalUserActionsViewModelTest.kt
new file mode 100644
index 0000000..58b59ff
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/ui/viewmodel/CommunalUserActionsViewModelTest.kt
@@ -0,0 +1,223 @@
+/*
+ * 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.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package com.android.systemui.communal.ui.viewmodel
+
+import android.platform.test.annotations.DisableFlags
+import android.platform.test.annotations.EnableFlags
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.compose.animation.scene.Swipe
+import com.android.compose.animation.scene.UserActionResult
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.authentication.data.repository.fakeAuthenticationRepository
+import com.android.systemui.authentication.shared.model.AuthenticationMethodModel
+import com.android.systemui.coroutines.collectLastValue
+import com.android.systemui.deviceentry.domain.interactor.deviceUnlockedInteractor
+import com.android.systemui.flags.EnableSceneContainer
+import com.android.systemui.keyguard.data.repository.fakeDeviceEntryFingerprintAuthRepository
+import com.android.systemui.keyguard.shared.model.SuccessFingerprintAuthenticationStatus
+import com.android.systemui.kosmos.testScope
+import com.android.systemui.lifecycle.activateIn
+import com.android.systemui.power.domain.interactor.PowerInteractor.Companion.setAsleepForTest
+import com.android.systemui.power.domain.interactor.PowerInteractor.Companion.setAwakeForTest
+import com.android.systemui.power.domain.interactor.powerInteractor
+import com.android.systemui.scene.domain.interactor.sceneInteractor
+import com.android.systemui.scene.shared.model.Overlays
+import com.android.systemui.scene.shared.model.SceneFamilies
+import com.android.systemui.scene.shared.model.Scenes
+import com.android.systemui.scene.shared.model.TransitionKeys.ToSplitShade
+import com.android.systemui.shade.data.repository.fakeShadeRepository
+import com.android.systemui.shade.shared.flag.DualShade
+import com.android.systemui.shade.shared.model.ShadeMode
+import com.android.systemui.testKosmos
+import com.google.common.truth.Truth.assertThat
+import kotlin.test.Test
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+@EnableSceneContainer
+class CommunalUserActionsViewModelTest : SysuiTestCase() {
+
+    private val kosmos = testKosmos()
+    private val testScope = kosmos.testScope
+
+    private lateinit var underTest: CommunalUserActionsViewModel
+
+    @Before
+    fun setUp() {
+        underTest = kosmos.communalUserActionsViewModel
+        underTest.activateIn(testScope)
+    }
+
+    @Test
+    @DisableFlags(DualShade.FLAG_NAME)
+    fun actions_singleShade() =
+        testScope.runTest {
+            val actions by collectLastValue(underTest.actions)
+
+            setUpState(
+                isShadeTouchable = true,
+                isDeviceUnlocked = false,
+                shadeMode = ShadeMode.Single,
+            )
+            assertThat(actions).isNotEmpty()
+            assertThat(actions?.get(Swipe.End)).isEqualTo(UserActionResult(SceneFamilies.Home))
+            assertThat(actions?.get(Swipe.Up)).isEqualTo(UserActionResult(Scenes.Bouncer))
+            assertThat(actions?.get(Swipe.Down)).isEqualTo(UserActionResult(Scenes.Shade))
+
+            setUpState(
+                isShadeTouchable = false,
+                isDeviceUnlocked = false,
+                shadeMode = ShadeMode.Single,
+            )
+            assertThat(actions).isEmpty()
+
+            setUpState(
+                isShadeTouchable = true,
+                isDeviceUnlocked = true,
+                shadeMode = ShadeMode.Single,
+            )
+            assertThat(actions).isNotEmpty()
+            assertThat(actions?.get(Swipe.End)).isEqualTo(UserActionResult(SceneFamilies.Home))
+            assertThat(actions?.get(Swipe.Up)).isEqualTo(UserActionResult(Scenes.Gone))
+            assertThat(actions?.get(Swipe.Down)).isEqualTo(UserActionResult(Scenes.Shade))
+        }
+
+    @Test
+    @DisableFlags(DualShade.FLAG_NAME)
+    fun actions_splitShade() =
+        testScope.runTest {
+            val actions by collectLastValue(underTest.actions)
+
+            setUpState(
+                isShadeTouchable = true,
+                isDeviceUnlocked = false,
+                shadeMode = ShadeMode.Split,
+            )
+            assertThat(actions).isNotEmpty()
+            assertThat(actions?.get(Swipe.End)).isEqualTo(UserActionResult(SceneFamilies.Home))
+            assertThat(actions?.get(Swipe.Up)).isEqualTo(UserActionResult(Scenes.Bouncer))
+            assertThat(actions?.get(Swipe.Down))
+                .isEqualTo(UserActionResult(Scenes.Shade, ToSplitShade))
+
+            setUpState(
+                isShadeTouchable = false,
+                isDeviceUnlocked = false,
+                shadeMode = ShadeMode.Split,
+            )
+            assertThat(actions).isEmpty()
+
+            setUpState(
+                isShadeTouchable = true,
+                isDeviceUnlocked = true,
+                shadeMode = ShadeMode.Split,
+            )
+            assertThat(actions).isNotEmpty()
+            assertThat(actions?.get(Swipe.End)).isEqualTo(UserActionResult(SceneFamilies.Home))
+            assertThat(actions?.get(Swipe.Up)).isEqualTo(UserActionResult(Scenes.Gone))
+            assertThat(actions?.get(Swipe.Down))
+                .isEqualTo(UserActionResult(Scenes.Shade, ToSplitShade))
+        }
+
+    @Test
+    @EnableFlags(DualShade.FLAG_NAME)
+    fun actions_dualShade() =
+        testScope.runTest {
+            val actions by collectLastValue(underTest.actions)
+
+            setUpState(
+                isShadeTouchable = true,
+                isDeviceUnlocked = false,
+                shadeMode = ShadeMode.Dual,
+            )
+            assertThat(actions).isNotEmpty()
+            assertThat(actions?.get(Swipe.End)).isEqualTo(UserActionResult(SceneFamilies.Home))
+            assertThat(actions?.get(Swipe.Up)).isEqualTo(UserActionResult(Scenes.Bouncer))
+            assertThat(actions?.get(Swipe.Down))
+                .isEqualTo(UserActionResult(Overlays.NotificationsShade))
+
+            setUpState(
+                isShadeTouchable = false,
+                isDeviceUnlocked = false,
+                shadeMode = ShadeMode.Dual,
+            )
+            assertThat(actions).isEmpty()
+
+            setUpState(isShadeTouchable = true, isDeviceUnlocked = true, shadeMode = ShadeMode.Dual)
+            assertThat(actions).isNotEmpty()
+            assertThat(actions?.get(Swipe.End)).isEqualTo(UserActionResult(SceneFamilies.Home))
+            assertThat(actions?.get(Swipe.Up)).isEqualTo(UserActionResult(Scenes.Gone))
+            assertThat(actions?.get(Swipe.Down))
+                .isEqualTo(UserActionResult(Overlays.NotificationsShade))
+        }
+
+    private fun TestScope.setUpState(
+        isShadeTouchable: Boolean,
+        isDeviceUnlocked: Boolean,
+        shadeMode: ShadeMode,
+    ) {
+        if (isShadeTouchable) {
+            kosmos.powerInteractor.setAwakeForTest()
+        } else {
+            kosmos.powerInteractor.setAsleepForTest()
+        }
+
+        if (isDeviceUnlocked) {
+            unlockDevice()
+        } else {
+            lockDevice()
+        }
+
+        if (shadeMode == ShadeMode.Dual) {
+            assertThat(DualShade.isEnabled).isTrue()
+        } else {
+            assertThat(DualShade.isEnabled).isFalse()
+            kosmos.fakeShadeRepository.setShadeLayoutWide(shadeMode == ShadeMode.Split)
+        }
+        runCurrent()
+    }
+
+    private fun TestScope.lockDevice() {
+        val deviceUnlockStatus by
+            collectLastValue(kosmos.deviceUnlockedInteractor.deviceUnlockStatus)
+
+        kosmos.fakeAuthenticationRepository.setAuthenticationMethod(AuthenticationMethodModel.Pin)
+        assertThat(deviceUnlockStatus?.isUnlocked).isFalse()
+        kosmos.sceneInteractor.changeScene(Scenes.Lockscreen, "reason")
+        runCurrent()
+    }
+
+    private fun TestScope.unlockDevice() {
+        val deviceUnlockStatus by
+            collectLastValue(kosmos.deviceUnlockedInteractor.deviceUnlockStatus)
+
+        kosmos.fakeDeviceEntryFingerprintAuthRepository.setAuthenticationStatus(
+            SuccessFingerprintAuthenticationStatus(0, true)
+        )
+        assertThat(deviceUnlockStatus?.isUnlocked).isTrue()
+        kosmos.sceneInteractor.changeScene(Scenes.Gone, "reason")
+        runCurrent()
+    }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/ui/viewmodel/LockscreenUserActionsViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/ui/viewmodel/LockscreenUserActionsViewModelTest.kt
index a330cf0..fb1bf28 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/ui/viewmodel/LockscreenUserActionsViewModelTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/ui/viewmodel/LockscreenUserActionsViewModelTest.kt
@@ -147,7 +147,7 @@
             }
         }
 
-        private fun expectedLeftDestination(
+        private fun expectedStartDestination(
             isCommunalAvailable: Boolean,
             isShadeTouchable: Boolean,
         ): SceneKey? {
@@ -246,17 +246,17 @@
                     )
                 )
 
-            val leftScene by
+            val startScene by
                 collectLastValue(
-                    (userActions?.get(Swipe.Left) as? UserActionResult.ChangeScene)?.toScene?.let {
-                        scene ->
-                        kosmos.sceneInteractor.resolveSceneFamily(scene)
-                    } ?: flowOf(null)
+                    (userActions?.get(Swipe.Start) as? UserActionResult.ChangeScene)
+                        ?.toScene
+                        ?.let { scene -> kosmos.sceneInteractor.resolveSceneFamily(scene) }
+                        ?: flowOf(null)
                 )
 
-            assertThat(leftScene)
+            assertThat(startScene)
                 .isEqualTo(
-                    expectedLeftDestination(
+                    expectedStartDestination(
                         isCommunalAvailable = isCommunalAvailable,
                         isShadeTouchable = isShadeTouchable,
                     )
@@ -341,17 +341,17 @@
                     )
                 )
 
-            val leftScene by
+            val startScene by
                 collectLastValue(
-                    (userActions?.get(Swipe.Left) as? UserActionResult.ChangeScene)?.toScene?.let {
-                        scene ->
-                        kosmos.sceneInteractor.resolveSceneFamily(scene)
-                    } ?: flowOf(null)
+                    (userActions?.get(Swipe.Start) as? UserActionResult.ChangeScene)
+                        ?.toScene
+                        ?.let { scene -> kosmos.sceneInteractor.resolveSceneFamily(scene) }
+                        ?: flowOf(null)
                 )
 
-            assertThat(leftScene)
+            assertThat(startScene)
                 .isEqualTo(
-                    expectedLeftDestination(
+                    expectedStartDestination(
                         isCommunalAvailable = isCommunalAvailable,
                         isShadeTouchable = isShadeTouchable,
                     )
diff --git a/packages/SystemUI/src/com/android/systemui/communal/ui/viewmodel/CommunalUserActionsViewModel.kt b/packages/SystemUI/src/com/android/systemui/communal/ui/viewmodel/CommunalUserActionsViewModel.kt
new file mode 100644
index 0000000..e35fdfe
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/communal/ui/viewmodel/CommunalUserActionsViewModel.kt
@@ -0,0 +1,83 @@
+/*
+ * 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.ui.viewmodel
+
+import com.android.compose.animation.scene.Swipe
+import com.android.compose.animation.scene.UserAction
+import com.android.compose.animation.scene.UserActionResult
+import com.android.systemui.deviceentry.domain.interactor.DeviceUnlockedInteractor
+import com.android.systemui.scene.shared.model.SceneFamilies
+import com.android.systemui.scene.shared.model.Scenes
+import com.android.systemui.scene.ui.viewmodel.UserActionsViewModel
+import com.android.systemui.shade.domain.interactor.ShadeInteractor
+import com.android.systemui.shade.shared.model.ShadeMode
+import com.android.systemui.shade.ui.viewmodel.dualShadeActions
+import com.android.systemui.shade.ui.viewmodel.singleShadeActions
+import com.android.systemui.shade.ui.viewmodel.splitShadeActions
+import com.android.systemui.utils.coroutines.flow.flatMapLatestConflated
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
+
+/** Provides scene container user actions and results. */
+class CommunalUserActionsViewModel
+@AssistedInject
+constructor(
+    private val deviceUnlockedInteractor: DeviceUnlockedInteractor,
+    private val shadeInteractor: ShadeInteractor,
+) : UserActionsViewModel() {
+
+    override suspend fun hydrateActions(setActions: (Map<UserAction, UserActionResult>) -> Unit) {
+        shadeInteractor.isShadeTouchable
+            .flatMapLatestConflated { isShadeTouchable ->
+                if (!isShadeTouchable) {
+                    flowOf(emptyMap())
+                } else {
+                    combine(
+                        deviceUnlockedInteractor.deviceUnlockStatus.map { it.isUnlocked },
+                        shadeInteractor.shadeMode,
+                    ) { isDeviceUnlocked, shadeMode ->
+                        buildList {
+                                val bouncerOrGone =
+                                    if (isDeviceUnlocked) Scenes.Gone else Scenes.Bouncer
+                                add(Swipe.Up to bouncerOrGone)
+
+                                // "Home" is either Lockscreen, or Gone - if the device is entered.
+                                add(Swipe.End to SceneFamilies.Home)
+
+                                addAll(
+                                    when (shadeMode) {
+                                        ShadeMode.Single -> singleShadeActions()
+                                        ShadeMode.Split -> splitShadeActions()
+                                        ShadeMode.Dual -> dualShadeActions()
+                                    }
+                                )
+                            }
+                            .associate { it }
+                    }
+                }
+            }
+            .collect { setActions(it) }
+    }
+
+    @AssistedFactory
+    interface Factory {
+        fun create(): CommunalUserActionsViewModel
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/LockscreenUserActionsViewModel.kt b/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/LockscreenUserActionsViewModel.kt
index 3b266f9..6f29004 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/LockscreenUserActionsViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/LockscreenUserActionsViewModel.kt
@@ -18,20 +18,18 @@
 
 package com.android.systemui.keyguard.ui.viewmodel
 
-import com.android.compose.animation.scene.Edge
 import com.android.compose.animation.scene.Swipe
-import com.android.compose.animation.scene.SwipeDirection
 import com.android.compose.animation.scene.UserAction
 import com.android.compose.animation.scene.UserActionResult
 import com.android.systemui.communal.domain.interactor.CommunalInteractor
 import com.android.systemui.deviceentry.domain.interactor.DeviceEntryInteractor
-import com.android.systemui.scene.shared.model.Overlays
 import com.android.systemui.scene.shared.model.Scenes
-import com.android.systemui.scene.shared.model.TransitionKeys.ToSplitShade
-import com.android.systemui.scene.ui.viewmodel.SceneContainerEdge
 import com.android.systemui.scene.ui.viewmodel.UserActionsViewModel
 import com.android.systemui.shade.domain.interactor.ShadeInteractor
 import com.android.systemui.shade.shared.model.ShadeMode
+import com.android.systemui.shade.ui.viewmodel.dualShadeActions
+import com.android.systemui.shade.ui.viewmodel.singleShadeActions
+import com.android.systemui.shade.ui.viewmodel.splitShadeActions
 import dagger.assisted.AssistedFactory
 import dagger.assisted.AssistedInject
 import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -62,7 +60,7 @@
                 ) { isDeviceUnlocked, isCommunalAvailable, shadeMode ->
                     buildList {
                             if (isCommunalAvailable) {
-                                add(Swipe.Left to Scenes.Communal)
+                                add(Swipe.Start to Scenes.Communal)
                             }
 
                             add(Swipe.Up to if (isDeviceUnlocked) Scenes.Gone else Scenes.Bouncer)
@@ -81,45 +79,6 @@
             .collect { setActions(it) }
     }
 
-    private fun singleShadeActions(): Array<Pair<UserAction, UserActionResult>> {
-        return arrayOf(
-            // Swiping down, not from the edge, always goes to shade.
-            Swipe.Down to Scenes.Shade,
-            swipeDown(pointerCount = 2) to Scenes.Shade,
-            // Swiping down from the top edge goes to QS.
-            swipeDownFromTop(pointerCount = 1) to Scenes.QuickSettings,
-            swipeDownFromTop(pointerCount = 2) to Scenes.QuickSettings,
-        )
-    }
-
-    private fun splitShadeActions(): Array<Pair<UserAction, UserActionResult>> {
-        val splitShadeSceneKey = UserActionResult(Scenes.Shade, ToSplitShade)
-        return arrayOf(
-            // Swiping down, not from the edge, always goes to shade.
-            Swipe.Down to splitShadeSceneKey,
-            swipeDown(pointerCount = 2) to splitShadeSceneKey,
-            // Swiping down from the top edge goes to QS.
-            swipeDownFromTop(pointerCount = 1) to splitShadeSceneKey,
-            swipeDownFromTop(pointerCount = 2) to splitShadeSceneKey,
-        )
-    }
-
-    private fun dualShadeActions(): Array<Pair<UserAction, UserActionResult>> {
-        return arrayOf(
-            Swipe.Down to Overlays.NotificationsShade,
-            Swipe(direction = SwipeDirection.Down, fromSource = SceneContainerEdge.TopRight) to
-                Overlays.QuickSettingsShade,
-        )
-    }
-
-    private fun swipeDownFromTop(pointerCount: Int): Swipe {
-        return Swipe(SwipeDirection.Down, fromSource = Edge.Top, pointerCount = pointerCount)
-    }
-
-    private fun swipeDown(pointerCount: Int): Swipe {
-        return Swipe(SwipeDirection.Down, pointerCount = pointerCount)
-    }
-
     @AssistedFactory
     interface Factory {
         fun create(): LockscreenUserActionsViewModel
diff --git a/packages/SystemUI/src/com/android/systemui/scene/ui/viewmodel/GoneUserActionsViewModel.kt b/packages/SystemUI/src/com/android/systemui/scene/ui/viewmodel/GoneUserActionsViewModel.kt
index 5ff507a..fc172e8 100644
--- a/packages/SystemUI/src/com/android/systemui/scene/ui/viewmodel/GoneUserActionsViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/scene/ui/viewmodel/GoneUserActionsViewModel.kt
@@ -16,16 +16,13 @@
 
 package com.android.systemui.scene.ui.viewmodel
 
-import com.android.compose.animation.scene.Edge
-import com.android.compose.animation.scene.Swipe
-import com.android.compose.animation.scene.SwipeDirection
 import com.android.compose.animation.scene.UserAction
 import com.android.compose.animation.scene.UserActionResult
-import com.android.systemui.scene.shared.model.Overlays
-import com.android.systemui.scene.shared.model.Scenes
-import com.android.systemui.scene.shared.model.TransitionKeys.ToSplitShade
 import com.android.systemui.shade.domain.interactor.ShadeInteractor
 import com.android.systemui.shade.shared.model.ShadeMode
+import com.android.systemui.shade.ui.viewmodel.dualShadeActions
+import com.android.systemui.shade.ui.viewmodel.singleShadeActions
+import com.android.systemui.shade.ui.viewmodel.splitShadeActions
 import dagger.assisted.AssistedFactory
 import dagger.assisted.AssistedInject
 
@@ -36,41 +33,21 @@
     override suspend fun hydrateActions(setActions: (Map<UserAction, UserActionResult>) -> Unit) {
         shadeInteractor.shadeMode.collect { shadeMode ->
             setActions(
-                when (shadeMode) {
-                    ShadeMode.Single -> singleShadeActions()
-                    ShadeMode.Split -> splitShadeActions()
-                    ShadeMode.Dual -> dualShadeActions()
-                }
+                buildList {
+                        addAll(
+                            when (shadeMode) {
+                                ShadeMode.Single ->
+                                    singleShadeActions(requireTwoPointersForTopEdgeForQs = true)
+                                ShadeMode.Split -> splitShadeActions()
+                                ShadeMode.Dual -> dualShadeActions()
+                            }
+                        )
+                    }
+                    .associate { it }
             )
         }
     }
 
-    private fun singleShadeActions(): Map<UserAction, UserActionResult> {
-        return mapOf(
-            Swipe.Down to Scenes.Shade,
-            swipeDownFromTopWithTwoFingers() to Scenes.QuickSettings,
-        )
-    }
-
-    private fun splitShadeActions(): Map<UserAction, UserActionResult> {
-        return mapOf(
-            Swipe.Down to UserActionResult(Scenes.Shade, ToSplitShade),
-            swipeDownFromTopWithTwoFingers() to UserActionResult(Scenes.Shade, ToSplitShade),
-        )
-    }
-
-    private fun dualShadeActions(): Map<UserAction, UserActionResult> {
-        return mapOf(
-            Swipe.Down to Overlays.NotificationsShade,
-            Swipe(direction = SwipeDirection.Down, fromSource = SceneContainerEdge.TopRight) to
-                Overlays.QuickSettingsShade,
-        )
-    }
-
-    private fun swipeDownFromTopWithTwoFingers(): UserAction {
-        return Swipe(direction = SwipeDirection.Down, pointerCount = 2, fromSource = Edge.Top)
-    }
-
     @AssistedFactory
     interface Factory {
         fun create(): GoneUserActionsViewModel
diff --git a/packages/SystemUI/src/com/android/systemui/shade/ui/viewmodel/ShadeUserActions.kt b/packages/SystemUI/src/com/android/systemui/shade/ui/viewmodel/ShadeUserActions.kt
new file mode 100644
index 0000000..65b6231
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/shade/ui/viewmodel/ShadeUserActions.kt
@@ -0,0 +1,77 @@
+/*
+ * 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.shade.ui.viewmodel
+
+import com.android.compose.animation.scene.Edge
+import com.android.compose.animation.scene.Swipe
+import com.android.compose.animation.scene.SwipeDirection
+import com.android.compose.animation.scene.UserAction
+import com.android.compose.animation.scene.UserActionResult
+import com.android.systemui.scene.shared.model.Overlays
+import com.android.systemui.scene.shared.model.Scenes
+import com.android.systemui.scene.shared.model.TransitionKeys.ToSplitShade
+import com.android.systemui.scene.ui.viewmodel.SceneContainerEdge
+
+/** Returns collection of [UserAction] to [UserActionResult] pairs for opening the single shade. */
+fun singleShadeActions(
+    requireTwoPointersForTopEdgeForQs: Boolean = false
+): Array<Pair<UserAction, UserActionResult>> {
+    return arrayOf(
+        // Swiping down, not from the edge, always goes to shade.
+        Swipe.Down to Scenes.Shade,
+        swipeDown(pointerCount = 2) to Scenes.Shade,
+
+        // Swiping down from the top edge.
+        swipeDownFromTop(pointerCount = 1) to
+            if (requireTwoPointersForTopEdgeForQs) {
+                Scenes.Shade
+            } else {
+                Scenes.QuickSettings
+            },
+        swipeDownFromTop(pointerCount = 2) to Scenes.QuickSettings,
+    )
+}
+
+/** Returns collection of [UserAction] to [UserActionResult] pairs for opening the split shade. */
+fun splitShadeActions(): Array<Pair<UserAction, UserActionResult>> {
+    val splitShadeSceneKey = UserActionResult(Scenes.Shade, ToSplitShade)
+    return arrayOf(
+        // Swiping down, not from the edge, always goes to shade.
+        Swipe.Down to splitShadeSceneKey,
+        swipeDown(pointerCount = 2) to splitShadeSceneKey,
+        // Swiping down from the top edge goes to QS.
+        swipeDownFromTop(pointerCount = 1) to splitShadeSceneKey,
+        swipeDownFromTop(pointerCount = 2) to splitShadeSceneKey,
+    )
+}
+
+/** Returns collection of [UserAction] to [UserActionResult] pairs for opening the dual shade. */
+fun dualShadeActions(): Array<Pair<UserAction, UserActionResult>> {
+    return arrayOf(
+        Swipe.Down to Overlays.NotificationsShade,
+        Swipe(direction = SwipeDirection.Down, fromSource = SceneContainerEdge.TopRight) to
+            Overlays.QuickSettingsShade,
+    )
+}
+
+private fun swipeDownFromTop(pointerCount: Int): Swipe {
+    return Swipe(SwipeDirection.Down, fromSource = Edge.Top, pointerCount = pointerCount)
+}
+
+private fun swipeDown(pointerCount: Int): Swipe {
+    return Swipe(SwipeDirection.Down, pointerCount = pointerCount)
+}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/communal/ui/viewmodel/CommunalUserActionsViewModelKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/communal/ui/viewmodel/CommunalUserActionsViewModelKosmos.kt
new file mode 100644
index 0000000..1c84133
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/communal/ui/viewmodel/CommunalUserActionsViewModelKosmos.kt
@@ -0,0 +1,29 @@
+/*
+ * 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.ui.viewmodel
+
+import com.android.systemui.deviceentry.domain.interactor.deviceUnlockedInteractor
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.Kosmos.Fixture
+import com.android.systemui.shade.domain.interactor.shadeInteractor
+
+val Kosmos.communalUserActionsViewModel by Fixture {
+    CommunalUserActionsViewModel(
+        deviceUnlockedInteractor = deviceUnlockedInteractor,
+        shadeInteractor = shadeInteractor,
+    )
+}