diff options
15 files changed, 608 insertions, 15 deletions
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/SceneContainer.kt b/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/SceneContainer.kt index 3cb0d8af1ba4..df101c558dff 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/SceneContainer.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/SceneContainer.kt @@ -128,7 +128,11 @@ fun SceneContainer( } }, ) { - SceneTransitionLayout(state = state, modifier = modifier.fillMaxSize()) { + SceneTransitionLayout( + state = state, + modifier = modifier.fillMaxSize(), + swipeSourceDetector = viewModel.edgeDetector, + ) { sceneByKey.forEach { (sceneKey, scene) -> scene( key = sceneKey, diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/scene/ui/viewmodel/SceneContainerViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/scene/ui/viewmodel/SceneContainerViewModelTest.kt index 4b132c4276ea..a0bb01797f2c 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/scene/ui/viewmodel/SceneContainerViewModelTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/scene/ui/viewmodel/SceneContainerViewModelTest.kt @@ -18,9 +18,12 @@ package com.android.systemui.scene.ui.viewmodel +import android.platform.test.annotations.DisableFlags +import android.platform.test.annotations.EnableFlags import android.view.MotionEvent import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest +import com.android.compose.animation.scene.DefaultEdgeDetector import com.android.systemui.SysuiTestCase import com.android.systemui.classifier.domain.interactor.falsingInteractor import com.android.systemui.classifier.fakeFalsingManager @@ -37,6 +40,10 @@ import com.android.systemui.scene.shared.logger.sceneLogger import com.android.systemui.scene.shared.model.Overlays import com.android.systemui.scene.shared.model.Scenes import com.android.systemui.scene.shared.model.fakeSceneDataSource +import com.android.systemui.shade.data.repository.fakeShadeRepository +import com.android.systemui.shade.domain.interactor.shadeInteractor +import com.android.systemui.shade.shared.flag.DualShade +import com.android.systemui.shade.shared.model.ShadeMode import com.android.systemui.testKosmos import com.android.systemui.util.mockito.mock import com.android.systemui.util.mockito.whenever @@ -60,6 +67,7 @@ class SceneContainerViewModelTest : SysuiTestCase() { private val testScope by lazy { kosmos.testScope } private val sceneInteractor by lazy { kosmos.sceneInteractor } private val fakeSceneDataSource by lazy { kosmos.fakeSceneDataSource } + private val fakeShadeRepository by lazy { kosmos.fakeShadeRepository } private val sceneContainerConfig by lazy { kosmos.sceneContainerConfig } private val falsingManager by lazy { kosmos.fakeFalsingManager } @@ -75,6 +83,8 @@ class SceneContainerViewModelTest : SysuiTestCase() { sceneInteractor = sceneInteractor, falsingInteractor = kosmos.falsingInteractor, powerInteractor = kosmos.powerInteractor, + shadeInteractor = kosmos.shadeInteractor, + splitEdgeDetector = kosmos.splitEdgeDetector, logger = kosmos.sceneLogger, motionEventHandlerReceiver = { motionEventHandler -> this@SceneContainerViewModelTest.motionEventHandler = motionEventHandler @@ -287,4 +297,48 @@ class SceneContainerViewModelTest : SysuiTestCase() { assertThat(actionableContentKey).isEqualTo(Overlays.QuickSettingsShade) } + + @Test + @DisableFlags(DualShade.FLAG_NAME) + fun edgeDetector_singleShade_usesDefaultEdgeDetector() = + testScope.runTest { + val shadeMode by collectLastValue(kosmos.shadeInteractor.shadeMode) + fakeShadeRepository.setShadeLayoutWide(false) + assertThat(shadeMode).isEqualTo(ShadeMode.Single) + + assertThat(underTest.edgeDetector).isEqualTo(DefaultEdgeDetector) + } + + @Test + @DisableFlags(DualShade.FLAG_NAME) + fun edgeDetector_splitShade_usesDefaultEdgeDetector() = + testScope.runTest { + val shadeMode by collectLastValue(kosmos.shadeInteractor.shadeMode) + fakeShadeRepository.setShadeLayoutWide(true) + assertThat(shadeMode).isEqualTo(ShadeMode.Split) + + assertThat(underTest.edgeDetector).isEqualTo(DefaultEdgeDetector) + } + + @Test + @EnableFlags(DualShade.FLAG_NAME) + fun edgeDetector_dualShade_narrowScreen_usesSplitEdgeDetector() = + testScope.runTest { + val shadeMode by collectLastValue(kosmos.shadeInteractor.shadeMode) + fakeShadeRepository.setShadeLayoutWide(false) + + assertThat(shadeMode).isEqualTo(ShadeMode.Dual) + assertThat(underTest.edgeDetector).isEqualTo(kosmos.splitEdgeDetector) + } + + @Test + @EnableFlags(DualShade.FLAG_NAME) + fun edgeDetector_dualShade_wideScreen_usesSplitEdgeDetector() = + testScope.runTest { + val shadeMode by collectLastValue(kosmos.shadeInteractor.shadeMode) + fakeShadeRepository.setShadeLayoutWide(true) + + assertThat(shadeMode).isEqualTo(ShadeMode.Dual) + assertThat(underTest.edgeDetector).isEqualTo(kosmos.splitEdgeDetector) + } } diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/scene/ui/viewmodel/SplitEdgeDetectorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/scene/ui/viewmodel/SplitEdgeDetectorTest.kt new file mode 100644 index 000000000000..3d76d280b2cc --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/scene/ui/viewmodel/SplitEdgeDetectorTest.kt @@ -0,0 +1,274 @@ +/* + * 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.scene.ui.viewmodel + +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.scene.ui.viewmodel.SceneContainerEdge.End +import com.android.systemui.scene.ui.viewmodel.SceneContainerEdge.Resolved.Bottom +import com.android.systemui.scene.ui.viewmodel.SceneContainerEdge.Resolved.Left +import com.android.systemui.scene.ui.viewmodel.SceneContainerEdge.Resolved.Right +import com.android.systemui.scene.ui.viewmodel.SceneContainerEdge.Resolved.TopLeft +import com.android.systemui.scene.ui.viewmodel.SceneContainerEdge.Resolved.TopRight +import com.android.systemui.scene.ui.viewmodel.SceneContainerEdge.Start +import com.android.systemui.scene.ui.viewmodel.SceneContainerEdge.TopEnd +import com.android.systemui.scene.ui.viewmodel.SceneContainerEdge.TopStart +import com.google.common.truth.Truth.assertThat +import kotlin.test.assertFailsWith +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidJUnit4::class) +class SplitEdgeDetectorTest : SysuiTestCase() { + + private val edgeSize = 40 + private val screenWidth = 800 + private val screenHeight = 600 + + private var edgeSplitFraction = 0.7f + + private val underTest = + SplitEdgeDetector( + topEdgeSplitFraction = { edgeSplitFraction }, + edgeSize = edgeSize.dp, + ) + + @Test + fun source_noEdge_detectsNothing() { + val detectedEdge = + swipeVerticallyFrom( + x = screenWidth / 2, + y = screenHeight / 2, + ) + assertThat(detectedEdge).isNull() + } + + @Test + fun source_swipeVerticallyOnTopLeft_detectsTopLeft() { + val detectedEdge = + swipeVerticallyFrom( + x = 1, + y = edgeSize - 1, + ) + assertThat(detectedEdge).isEqualTo(TopLeft) + } + + @Test + fun source_swipeHorizontallyOnTopLeft_detectsLeft() { + val detectedEdge = + swipeHorizontallyFrom( + x = 1, + y = edgeSize - 1, + ) + assertThat(detectedEdge).isEqualTo(Left) + } + + @Test + fun source_swipeVerticallyOnTopRight_detectsTopRight() { + val detectedEdge = + swipeVerticallyFrom( + x = screenWidth - 1, + y = edgeSize - 1, + ) + assertThat(detectedEdge).isEqualTo(TopRight) + } + + @Test + fun source_swipeHorizontallyOnTopRight_detectsRight() { + val detectedEdge = + swipeHorizontallyFrom( + x = screenWidth - 1, + y = edgeSize - 1, + ) + assertThat(detectedEdge).isEqualTo(Right) + } + + @Test + fun source_swipeVerticallyToLeftOfSplit_detectsTopLeft() { + val detectedEdge = + swipeVerticallyFrom( + x = (screenWidth * edgeSplitFraction).toInt() - 1, + y = edgeSize - 1, + ) + assertThat(detectedEdge).isEqualTo(TopLeft) + } + + @Test + fun source_swipeVerticallyToRightOfSplit_detectsTopRight() { + val detectedEdge = + swipeVerticallyFrom( + x = (screenWidth * edgeSplitFraction).toInt() + 1, + y = edgeSize - 1, + ) + assertThat(detectedEdge).isEqualTo(TopRight) + } + + @Test + fun source_edgeSplitFractionUpdatesDynamically() { + val middleX = (screenWidth * 0.5f).toInt() + val topY = 0 + + // Split closer to the right; middle of screen is considered "left". + edgeSplitFraction = 0.6f + assertThat(swipeVerticallyFrom(x = middleX, y = topY)).isEqualTo(TopLeft) + + // Split closer to the left; middle of screen is considered "right". + edgeSplitFraction = 0.4f + assertThat(swipeVerticallyFrom(x = middleX, y = topY)).isEqualTo(TopRight) + + // Illegal fraction. + edgeSplitFraction = 1.2f + assertFailsWith<IllegalArgumentException> { swipeVerticallyFrom(x = middleX, y = topY) } + + // Illegal fraction. + edgeSplitFraction = -0.3f + assertFailsWith<IllegalArgumentException> { swipeVerticallyFrom(x = middleX, y = topY) } + } + + @Test + fun source_swipeVerticallyOnBottom_detectsBottom() { + val detectedEdge = + swipeVerticallyFrom( + x = screenWidth / 3, + y = screenHeight - (edgeSize / 2), + ) + assertThat(detectedEdge).isEqualTo(Bottom) + } + + @Test + fun source_swipeHorizontallyOnBottom_detectsNothing() { + val detectedEdge = + swipeHorizontallyFrom( + x = screenWidth / 3, + y = screenHeight - (edgeSize - 1), + ) + assertThat(detectedEdge).isNull() + } + + @Test + fun source_swipeHorizontallyOnLeft_detectsLeft() { + val detectedEdge = + swipeHorizontallyFrom( + x = edgeSize - 1, + y = screenHeight / 2, + ) + assertThat(detectedEdge).isEqualTo(Left) + } + + @Test + fun source_swipeVerticallyOnLeft_detectsNothing() { + val detectedEdge = + swipeVerticallyFrom( + x = edgeSize - 1, + y = screenHeight / 2, + ) + assertThat(detectedEdge).isNull() + } + + @Test + fun source_swipeHorizontallyOnRight_detectsRight() { + val detectedEdge = + swipeHorizontallyFrom( + x = screenWidth - edgeSize + 1, + y = screenHeight / 2, + ) + assertThat(detectedEdge).isEqualTo(Right) + } + + @Test + fun source_swipeVerticallyOnRight_detectsNothing() { + val detectedEdge = + swipeVerticallyFrom( + x = screenWidth - edgeSize + 1, + y = screenHeight / 2, + ) + assertThat(detectedEdge).isNull() + } + + @Test + fun resolve_startInLtr_resolvesLeft() { + val resolvedEdge = Start.resolve(LayoutDirection.Ltr) + assertThat(resolvedEdge).isEqualTo(Left) + } + + @Test + fun resolve_startInRtl_resolvesRight() { + val resolvedEdge = Start.resolve(LayoutDirection.Rtl) + assertThat(resolvedEdge).isEqualTo(Right) + } + + @Test + fun resolve_endInLtr_resolvesRight() { + val resolvedEdge = End.resolve(LayoutDirection.Ltr) + assertThat(resolvedEdge).isEqualTo(Right) + } + + @Test + fun resolve_endInRtl_resolvesLeft() { + val resolvedEdge = End.resolve(LayoutDirection.Rtl) + assertThat(resolvedEdge).isEqualTo(Left) + } + + @Test + fun resolve_topStartInLtr_resolvesTopLeft() { + val resolvedEdge = TopStart.resolve(LayoutDirection.Ltr) + assertThat(resolvedEdge).isEqualTo(TopLeft) + } + + @Test + fun resolve_topStartInRtl_resolvesTopRight() { + val resolvedEdge = TopStart.resolve(LayoutDirection.Rtl) + assertThat(resolvedEdge).isEqualTo(TopRight) + } + + @Test + fun resolve_topEndInLtr_resolvesTopRight() { + val resolvedEdge = TopEnd.resolve(LayoutDirection.Ltr) + assertThat(resolvedEdge).isEqualTo(TopRight) + } + + @Test + fun resolve_topEndInRtl_resolvesTopLeft() { + val resolvedEdge = TopEnd.resolve(LayoutDirection.Rtl) + assertThat(resolvedEdge).isEqualTo(TopLeft) + } + + private fun swipeVerticallyFrom(x: Int, y: Int): SceneContainerEdge.Resolved? { + return swipeFrom(x, y, Orientation.Vertical) + } + + private fun swipeHorizontallyFrom(x: Int, y: Int): SceneContainerEdge.Resolved? { + return swipeFrom(x, y, Orientation.Horizontal) + } + + private fun swipeFrom(x: Int, y: Int, orientation: Orientation): SceneContainerEdge.Resolved? { + return underTest.source( + layoutSize = IntSize(width = screenWidth, height = screenHeight), + position = IntOffset(x, y), + density = Density(1f), + orientation = orientation, + ) + } +} diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/shade/domain/interactor/ShadeInteractorImplTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/shade/domain/interactor/ShadeInteractorImplTest.kt index 3283ea154b3f..9464c75eeb71 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/shade/domain/interactor/ShadeInteractorImplTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/shade/domain/interactor/ShadeInteractorImplTest.kt @@ -24,7 +24,6 @@ import android.platform.test.annotations.EnableFlags import android.platform.test.flag.junit.FlagsParameterization import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase -import com.android.systemui.common.ui.data.repository.fakeConfigurationRepository import com.android.systemui.coroutines.collectLastValue import com.android.systemui.flags.parameterizeSceneContainerFlag import com.android.systemui.keyguard.data.repository.fakeKeyguardRepository @@ -39,6 +38,7 @@ import com.android.systemui.kosmos.testScope import com.android.systemui.power.data.repository.fakePowerRepository import com.android.systemui.power.shared.model.WakeSleepReason import com.android.systemui.power.shared.model.WakefulnessState +import com.android.systemui.shade.data.repository.fakeShadeRepository import com.android.systemui.shade.data.repository.shadeRepository import com.android.systemui.shade.shadeTestUtil import com.android.systemui.shade.shared.flag.DualShade @@ -66,18 +66,18 @@ import platform.test.runner.parameterized.Parameters @SmallTest @RunWith(ParameterizedAndroidJunit4::class) class ShadeInteractorImplTest(flags: FlagsParameterization) : SysuiTestCase() { - val kosmos = testKosmos() - val testScope = kosmos.testScope - val configurationRepository by lazy { kosmos.fakeConfigurationRepository } - val deviceProvisioningRepository by lazy { kosmos.fakeDeviceProvisioningRepository } - val disableFlagsRepository by lazy { kosmos.fakeDisableFlagsRepository } - val keyguardRepository by lazy { kosmos.fakeKeyguardRepository } - val keyguardTransitionRepository by lazy { kosmos.fakeKeyguardTransitionRepository } - val powerRepository by lazy { kosmos.fakePowerRepository } - val shadeTestUtil by lazy { kosmos.shadeTestUtil } - val userRepository by lazy { kosmos.fakeUserRepository } - val userSetupRepository by lazy { kosmos.fakeUserSetupRepository } - val dozeParameters by lazy { kosmos.dozeParameters } + private val kosmos = testKosmos() + private val testScope = kosmos.testScope + private val deviceProvisioningRepository by lazy { kosmos.fakeDeviceProvisioningRepository } + private val disableFlagsRepository by lazy { kosmos.fakeDisableFlagsRepository } + private val keyguardRepository by lazy { kosmos.fakeKeyguardRepository } + private val keyguardTransitionRepository by lazy { kosmos.fakeKeyguardTransitionRepository } + private val powerRepository by lazy { kosmos.fakePowerRepository } + private val shadeRepository by lazy { kosmos.fakeShadeRepository } + private val shadeTestUtil by lazy { kosmos.shadeTestUtil } + private val userRepository by lazy { kosmos.fakeUserRepository } + private val userSetupRepository by lazy { kosmos.fakeUserSetupRepository } + private val dozeParameters by lazy { kosmos.dozeParameters } lateinit var underTest: ShadeInteractorImpl @@ -497,4 +497,24 @@ class ShadeInteractorImplTest(flags: FlagsParameterization) : SysuiTestCase() { assertThat(shadeMode).isEqualTo(ShadeMode.Dual) } + + @Test + fun getTopEdgeSplitFraction_narrowScreen_splitInHalf() = + testScope.runTest { + // Ensure isShadeLayoutWide is collected. + val isShadeLayoutWide by collectLastValue(underTest.isShadeLayoutWide) + shadeRepository.setShadeLayoutWide(false) + + assertThat(underTest.getTopEdgeSplitFraction()).isEqualTo(0.5f) + } + + @Test + fun getTopEdgeSplitFraction_wideScreen_leftSideLarger() = + testScope.runTest { + // Ensure isShadeLayoutWide is collected. + val isShadeLayoutWide by collectLastValue(underTest.isShadeLayoutWide) + shadeRepository.setShadeLayoutWide(true) + + assertThat(underTest.getTopEdgeSplitFraction()).isGreaterThan(0.5f) + } } diff --git a/packages/SystemUI/src/com/android/systemui/notifications/ui/viewmodel/NotificationsShadeUserActionsViewModel.kt b/packages/SystemUI/src/com/android/systemui/notifications/ui/viewmodel/NotificationsShadeUserActionsViewModel.kt index a5c07bc2fdbf..11854d9317c9 100644 --- a/packages/SystemUI/src/com/android/systemui/notifications/ui/viewmodel/NotificationsShadeUserActionsViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/notifications/ui/viewmodel/NotificationsShadeUserActionsViewModel.kt @@ -18,9 +18,13 @@ package com.android.systemui.notifications.ui.viewmodel import com.android.compose.animation.scene.Back 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.compose.animation.scene.UserActionResult.ReplaceByOverlay +import com.android.systemui.scene.shared.model.Overlays import com.android.systemui.scene.shared.model.SceneFamilies +import com.android.systemui.scene.ui.viewmodel.SceneContainerEdge import com.android.systemui.scene.ui.viewmodel.UserActionsViewModel import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -34,8 +38,10 @@ class NotificationsShadeUserActionsViewModel @AssistedInject constructor() : override suspend fun hydrateActions(setActions: (Map<UserAction, UserActionResult>) -> Unit) { setActions( mapOf( - Swipe.Up to SceneFamilies.Home, Back to SceneFamilies.Home, + Swipe.Up to SceneFamilies.Home, + Swipe(direction = SwipeDirection.Down, fromSource = SceneContainerEdge.TopRight) to + ReplaceByOverlay(Overlays.QuickSettingsShade), ) ) } diff --git a/packages/SystemUI/src/com/android/systemui/qs/ui/viewmodel/QuickSettingsShadeUserActionsViewModel.kt b/packages/SystemUI/src/com/android/systemui/qs/ui/viewmodel/QuickSettingsShadeUserActionsViewModel.kt index d3dc302d44ca..bd1872d455d0 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/ui/viewmodel/QuickSettingsShadeUserActionsViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/ui/viewmodel/QuickSettingsShadeUserActionsViewModel.kt @@ -18,9 +18,13 @@ package com.android.systemui.qs.ui.viewmodel import com.android.compose.animation.scene.Back 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.compose.animation.scene.UserActionResult.ReplaceByOverlay +import com.android.systemui.scene.shared.model.Overlays import com.android.systemui.scene.shared.model.SceneFamilies +import com.android.systemui.scene.ui.viewmodel.SceneContainerEdge import com.android.systemui.scene.ui.viewmodel.UserActionsViewModel import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -43,6 +47,13 @@ constructor( .map { editing -> buildMap { put(Swipe.Up, UserActionResult(SceneFamilies.Home)) + put( + Swipe( + direction = SwipeDirection.Down, + fromSource = SceneContainerEdge.TopLeft + ), + ReplaceByOverlay(Overlays.NotificationsShade) + ) if (!editing) { put(Back, UserActionResult(SceneFamilies.Home)) } diff --git a/packages/SystemUI/src/com/android/systemui/scene/KeyguardlessSceneContainerFrameworkModule.kt b/packages/SystemUI/src/com/android/systemui/scene/KeyguardlessSceneContainerFrameworkModule.kt index 00944b8d0849..834db98263f5 100644 --- a/packages/SystemUI/src/com/android/systemui/scene/KeyguardlessSceneContainerFrameworkModule.kt +++ b/packages/SystemUI/src/com/android/systemui/scene/KeyguardlessSceneContainerFrameworkModule.kt @@ -16,6 +16,7 @@ package com.android.systemui.scene +import androidx.compose.ui.unit.dp import com.android.systemui.CoreStartable import com.android.systemui.notifications.ui.composable.NotificationsShadeSessionModule import com.android.systemui.scene.domain.SceneDomainModule @@ -30,6 +31,8 @@ import com.android.systemui.scene.domain.startable.StatusBarStartable import com.android.systemui.scene.shared.model.Overlays import com.android.systemui.scene.shared.model.SceneContainerConfig import com.android.systemui.scene.shared.model.Scenes +import com.android.systemui.scene.ui.viewmodel.SplitEdgeDetector +import com.android.systemui.shade.domain.interactor.ShadeInteractor import com.android.systemui.shade.shared.flag.DualShade import dagger.Binds import dagger.Module @@ -119,5 +122,15 @@ interface KeyguardlessSceneContainerFrameworkModule { .mapValues { checkNotNull(it.value) } ) } + + @Provides + fun splitEdgeDetector(shadeInteractor: ShadeInteractor): SplitEdgeDetector { + return SplitEdgeDetector( + topEdgeSplitFraction = shadeInteractor::getTopEdgeSplitFraction, + // TODO(b/338577208): This should be 60dp at the top in the dual-shade UI. Better to + // replace this constant with dynamic window insets. + edgeSize = 40.dp + ) + } } } diff --git a/packages/SystemUI/src/com/android/systemui/scene/SceneContainerFrameworkModule.kt b/packages/SystemUI/src/com/android/systemui/scene/SceneContainerFrameworkModule.kt index 4061ad851f57..a4c7d00d0e80 100644 --- a/packages/SystemUI/src/com/android/systemui/scene/SceneContainerFrameworkModule.kt +++ b/packages/SystemUI/src/com/android/systemui/scene/SceneContainerFrameworkModule.kt @@ -16,6 +16,7 @@ package com.android.systemui.scene +import androidx.compose.ui.unit.dp import com.android.systemui.CoreStartable import com.android.systemui.notifications.ui.composable.NotificationsShadeSessionModule import com.android.systemui.scene.domain.SceneDomainModule @@ -30,6 +31,8 @@ import com.android.systemui.scene.domain.startable.StatusBarStartable import com.android.systemui.scene.shared.model.Overlays import com.android.systemui.scene.shared.model.SceneContainerConfig import com.android.systemui.scene.shared.model.Scenes +import com.android.systemui.scene.ui.viewmodel.SplitEdgeDetector +import com.android.systemui.shade.domain.interactor.ShadeInteractor import com.android.systemui.shade.shared.flag.DualShade import dagger.Binds import dagger.Module @@ -129,5 +132,15 @@ interface SceneContainerFrameworkModule { .mapValues { checkNotNull(it.value) } ) } + + @Provides + fun splitEdgeDetector(shadeInteractor: ShadeInteractor): SplitEdgeDetector { + return SplitEdgeDetector( + topEdgeSplitFraction = shadeInteractor::getTopEdgeSplitFraction, + // TODO(b/338577208): This should be 60dp at the top in the dual-shade UI. Better to + // replace this constant with dynamic window insets. + edgeSize = 40.dp + ) + } } } diff --git a/packages/SystemUI/src/com/android/systemui/scene/ui/viewmodel/SceneContainerViewModel.kt b/packages/SystemUI/src/com/android/systemui/scene/ui/viewmodel/SceneContainerViewModel.kt index 4c6341b672ad..54823945a827 100644 --- a/packages/SystemUI/src/com/android/systemui/scene/ui/viewmodel/SceneContainerViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/scene/ui/viewmodel/SceneContainerViewModel.kt @@ -19,9 +19,11 @@ package com.android.systemui.scene.ui.viewmodel import android.view.MotionEvent import androidx.compose.runtime.getValue import com.android.compose.animation.scene.ContentKey +import com.android.compose.animation.scene.DefaultEdgeDetector import com.android.compose.animation.scene.ObservableTransitionState import com.android.compose.animation.scene.OverlayKey import com.android.compose.animation.scene.SceneKey +import com.android.compose.animation.scene.SwipeSourceDetector import com.android.compose.animation.scene.UserAction import com.android.compose.animation.scene.UserActionResult import com.android.systemui.classifier.Classifier @@ -33,12 +35,15 @@ import com.android.systemui.scene.domain.interactor.SceneInteractor import com.android.systemui.scene.shared.logger.SceneLogger import com.android.systemui.scene.shared.model.Scenes import com.android.systemui.scene.ui.composable.Overlay +import com.android.systemui.shade.domain.interactor.ShadeInteractor +import com.android.systemui.shade.shared.model.ShadeMode import com.android.systemui.statusbar.notification.stack.ui.view.SharedNotificationContainer import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map /** Models UI state for the scene container. */ class SceneContainerViewModel @@ -47,6 +52,8 @@ constructor( private val sceneInteractor: SceneInteractor, private val falsingInteractor: FalsingInteractor, private val powerInteractor: PowerInteractor, + private val shadeInteractor: ShadeInteractor, + private val splitEdgeDetector: SplitEdgeDetector, private val logger: SceneLogger, @Assisted private val motionEventHandlerReceiver: (MotionEventHandler?) -> Unit, ) : ExclusiveActivatable() { @@ -59,6 +66,20 @@ constructor( /** Whether the container is visible. */ val isVisible: Boolean by hydrator.hydratedStateOf("isVisible", sceneInteractor.isVisible) + /** + * The [SwipeSourceDetector] to use for defining which edges of the screen can be defined in the + * [UserAction]s for this container. + */ + val edgeDetector: SwipeSourceDetector by + hydrator.hydratedStateOf( + traceName = "edgeDetector", + initialValue = DefaultEdgeDetector, + source = + shadeInteractor.shadeMode.map { + if (it is ShadeMode.Dual) splitEdgeDetector else DefaultEdgeDetector + } + ) + override suspend fun onActivated(): Nothing { try { // Sends a MotionEventHandler to the owner of the view-model so they can report diff --git a/packages/SystemUI/src/com/android/systemui/scene/ui/viewmodel/SplitEdgeDetector.kt b/packages/SystemUI/src/com/android/systemui/scene/ui/viewmodel/SplitEdgeDetector.kt new file mode 100644 index 000000000000..f88bcb57a27d --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/scene/ui/viewmodel/SplitEdgeDetector.kt @@ -0,0 +1,116 @@ +/* + * 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.scene.ui.viewmodel + +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import com.android.compose.animation.scene.Edge +import com.android.compose.animation.scene.FixedSizeEdgeDetector +import com.android.compose.animation.scene.SwipeSource +import com.android.compose.animation.scene.SwipeSourceDetector + +/** + * The edge of a [SceneContainer]. It differs from a standard [Edge] by splitting the top edge into + * top-left and top-right. + */ +enum class SceneContainerEdge(private val resolveEdge: (LayoutDirection) -> Resolved) : + SwipeSource { + TopLeft(resolveEdge = { Resolved.TopLeft }), + TopRight(resolveEdge = { Resolved.TopRight }), + TopStart( + resolveEdge = { if (it == LayoutDirection.Ltr) Resolved.TopLeft else Resolved.TopRight } + ), + TopEnd( + resolveEdge = { if (it == LayoutDirection.Ltr) Resolved.TopRight else Resolved.TopLeft } + ), + Bottom(resolveEdge = { Resolved.Bottom }), + Left(resolveEdge = { Resolved.Left }), + Right(resolveEdge = { Resolved.Right }), + Start(resolveEdge = { if (it == LayoutDirection.Ltr) Resolved.Left else Resolved.Right }), + End(resolveEdge = { if (it == LayoutDirection.Ltr) Resolved.Right else Resolved.Left }); + + override fun resolve(layoutDirection: LayoutDirection): Resolved { + return resolveEdge(layoutDirection) + } + + enum class Resolved : SwipeSource.Resolved { + TopLeft, + TopRight, + Bottom, + Left, + Right, + } +} + +/** + * A [SwipeSourceDetector] that detects edges similarly to [FixedSizeEdgeDetector], except that the + * top edge is split in two: top-left and top-right. The split point between the two is dynamic and + * may change during runtime. + * + * Callers who need to detect the start and end edges based on the layout direction (LTR vs RTL) + * should subscribe to [SceneContainerEdge.TopStart] and [SceneContainerEdge.TopEnd] instead. These + * will be resolved at runtime to [SceneContainerEdge.Resolved.TopLeft] and + * [SceneContainerEdge.Resolved.TopRight] appropriately. Similarly, [SceneContainerEdge.Start] and + * [SceneContainerEdge.End] will be resolved appropriately to [SceneContainerEdge.Resolved.Left] and + * [SceneContainerEdge.Resolved.Right]. + * + * @param topEdgeSplitFraction A function which returns the fraction between [0..1] (i.e., + * percentage) of screen width to consider the split point between "top-left" and "top-right" + * edges. It is called on each source detection event. + * @param edgeSize The fixed size of each edge. + */ +class SplitEdgeDetector( + val topEdgeSplitFraction: () -> Float, + val edgeSize: Dp, +) : SwipeSourceDetector { + + private val fixedEdgeDetector = FixedSizeEdgeDetector(edgeSize) + + override fun source( + layoutSize: IntSize, + position: IntOffset, + density: Density, + orientation: Orientation, + ): SceneContainerEdge.Resolved? { + val fixedEdge = + fixedEdgeDetector.source( + layoutSize, + position, + density, + orientation, + ) + return when (fixedEdge) { + Edge.Resolved.Top -> { + val topEdgeSplitFraction = topEdgeSplitFraction() + require(topEdgeSplitFraction in 0f..1f) { + "topEdgeSplitFraction must return a value between 0.0 and 1.0" + } + val isLeftSide = position.x < layoutSize.width * topEdgeSplitFraction + if (isLeftSide) SceneContainerEdge.Resolved.TopLeft + else SceneContainerEdge.Resolved.TopRight + } + Edge.Resolved.Left -> SceneContainerEdge.Resolved.Left + Edge.Resolved.Bottom -> SceneContainerEdge.Resolved.Bottom + Edge.Resolved.Right -> SceneContainerEdge.Resolved.Right + null -> null + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/shade/domain/interactor/ShadeInteractor.kt b/packages/SystemUI/src/com/android/systemui/shade/domain/interactor/ShadeInteractor.kt index 73e86a2be4aa..3cd91be469c1 100644 --- a/packages/SystemUI/src/com/android/systemui/shade/domain/interactor/ShadeInteractor.kt +++ b/packages/SystemUI/src/com/android/systemui/shade/domain/interactor/ShadeInteractor.kt @@ -16,6 +16,7 @@ package com.android.systemui.shade.domain.interactor +import androidx.annotation.FloatRange import com.android.systemui.shade.shared.model.ShadeMode import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow @@ -69,6 +70,20 @@ interface ShadeInteractor : BaseShadeInteractor { * wide as the entire screen. */ val isShadeLayoutWide: StateFlow<Boolean> + + /** + * The fraction between [0..1] (i.e., percentage) of screen width to consider the threshold + * between "top-left" and "top-right" for the purposes of dual-shade invocation. + * + * When the dual-shade is not wide, this always returns 0.5 (the top edge is evenly split). On + * wide layouts however, a larger fraction is returned because only the area of the system + * status icons is considered top-right. + * + * Note that this fraction only determines the split between the absolute left and right + * directions. In RTL layouts, the "top-start" edge will resolve to "top-right", and "top-end" + * will resolve to "top-left". + */ + @FloatRange(from = 0.0, to = 1.0) fun getTopEdgeSplitFraction(): Float } /** ShadeInteractor methods with implementations that differ between non-empty impls. */ diff --git a/packages/SystemUI/src/com/android/systemui/shade/domain/interactor/ShadeInteractorEmptyImpl.kt b/packages/SystemUI/src/com/android/systemui/shade/domain/interactor/ShadeInteractorEmptyImpl.kt index d51fd28d5458..6c0b55a5dd57 100644 --- a/packages/SystemUI/src/com/android/systemui/shade/domain/interactor/ShadeInteractorEmptyImpl.kt +++ b/packages/SystemUI/src/com/android/systemui/shade/domain/interactor/ShadeInteractorEmptyImpl.kt @@ -47,4 +47,6 @@ class ShadeInteractorEmptyImpl @Inject constructor() : ShadeInteractor { override val isExpandToQsEnabled: Flow<Boolean> = inactiveFlowBoolean override val shadeMode: StateFlow<ShadeMode> = MutableStateFlow(ShadeMode.Single) override val isShadeLayoutWide: StateFlow<Boolean> = inactiveFlowBoolean + + override fun getTopEdgeSplitFraction(): Float = 0.5f } diff --git a/packages/SystemUI/src/com/android/systemui/shade/domain/interactor/ShadeInteractorImpl.kt b/packages/SystemUI/src/com/android/systemui/shade/domain/interactor/ShadeInteractorImpl.kt index 3552092d24e7..b8d2dd2a764f 100644 --- a/packages/SystemUI/src/com/android/systemui/shade/domain/interactor/ShadeInteractorImpl.kt +++ b/packages/SystemUI/src/com/android/systemui/shade/domain/interactor/ShadeInteractorImpl.kt @@ -16,6 +16,7 @@ package com.android.systemui.shade.domain.interactor +import androidx.annotation.FloatRange import com.android.systemui.dagger.SysUISingleton import com.android.systemui.dagger.qualifiers.Application import com.android.systemui.keyguard.data.repository.KeyguardRepository @@ -104,6 +105,16 @@ constructor( override val isShadeLayoutWide: StateFlow<Boolean> = shadeRepository.isShadeLayoutWide + @FloatRange(from = 0.0, to = 1.0) + override fun getTopEdgeSplitFraction(): Float { + // Note: this implicitly relies on isShadeLayoutWide being hot (i.e. collected). This + // assumption allows us to query its value on demand (during swipe source detection) instead + // of running another infinite coroutine. + // TODO(b/338577208): Instead of being fixed at 0.8f, this should dynamically updated based + // on the position of system-status icons in the status bar. + return if (shadeRepository.isShadeLayoutWide.value) 0.8f else 0.5f + } + override val shadeMode: StateFlow<ShadeMode> = isShadeLayoutWide .map(this::determineShadeMode) diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/scene/SceneKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/scene/SceneKosmos.kt index 55f3ed7062aa..874463819c73 100644 --- a/packages/SystemUI/tests/utils/src/com/android/systemui/scene/SceneKosmos.kt +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/scene/SceneKosmos.kt @@ -12,6 +12,8 @@ import com.android.systemui.scene.shared.model.SceneContainerConfig import com.android.systemui.scene.shared.model.Scenes import com.android.systemui.scene.ui.FakeOverlay import com.android.systemui.scene.ui.viewmodel.SceneContainerViewModel +import com.android.systemui.scene.ui.viewmodel.splitEdgeDetector +import com.android.systemui.shade.domain.interactor.shadeInteractor import kotlinx.coroutines.flow.MutableStateFlow var Kosmos.sceneKeys by Fixture { @@ -70,6 +72,8 @@ val Kosmos.sceneContainerViewModel by Fixture { sceneInteractor = sceneInteractor, falsingInteractor = falsingInteractor, powerInteractor = powerInteractor, + shadeInteractor = shadeInteractor, + splitEdgeDetector = splitEdgeDetector, motionEventHandlerReceiver = {}, logger = sceneLogger ) diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/scene/ui/viewmodel/SplitEdgeDetectorKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/scene/ui/viewmodel/SplitEdgeDetectorKosmos.kt new file mode 100644 index 000000000000..e0b529261c4d --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/scene/ui/viewmodel/SplitEdgeDetectorKosmos.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.scene.ui.viewmodel + +import androidx.compose.ui.unit.dp +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.shade.domain.interactor.shadeInteractor + +var Kosmos.splitEdgeDetector: SplitEdgeDetector by + Kosmos.Fixture { + SplitEdgeDetector( + topEdgeSplitFraction = shadeInteractor::getTopEdgeSplitFraction, + edgeSize = 40.dp, + ) + } |