Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 58736880 authored by burakov's avatar burakov
Browse files

[bc25] Add Dual Shade invocation zone detection.

When detecting swipe gestures in dual shade, the top edge is split in
two: left and right. On narrow screens (aka handheld form factor), this
split is right down the middle of the screen. On large screens however,
the split happens in the status bar area between the notification icons
and quick settings icons.

Since the status bar doesn't yet report to SceneContainer the coordinate
of the split in the latter case, in this CL we use an interim value of
80% of the screen width to get an approximate effect.

As an example of how split-edge actions are defined, they were added to
the actions view-model for the shade overlays. User actions in the rest
of dual shade entry points will be updated in a follow-up CL.

Bug: 338577208
Bug: 338033836
Bug: 356596436
Flag: com.android.systemui.scene_container
Flag: com.android.systemui.dual_shade
Test: Added unit tests.
Test: Existing unit tests still pass.
Change-Id: Ic209a812ef2321808d6f15467cf02ee39a85e879
parent 7f460f89
Loading
Loading
Loading
Loading
+5 −1
Original line number Diff line number Diff line
@@ -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,
+54 −0
Original line number Diff line number Diff line
@@ -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)
        }
}
+274 −0
Original line number Diff line number Diff line
/*
 * 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.Bottom
import com.android.systemui.scene.ui.viewmodel.SceneContainerEdge.End
import com.android.systemui.scene.ui.viewmodel.SceneContainerEdge.Left
import com.android.systemui.scene.ui.viewmodel.SceneContainerEdge.Right
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.TopLeft
import com.android.systemui.scene.ui.viewmodel.SceneContainerEdge.TopRight
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(SceneContainerEdge.Resolved.Left)
    }

    @Test
    fun resolve_startInRtl_resolvesRight() {
        val resolvedEdge = Start.resolve(LayoutDirection.Rtl)
        assertThat(resolvedEdge).isEqualTo(SceneContainerEdge.Resolved.Right)
    }

    @Test
    fun resolve_endInLtr_resolvesRight() {
        val resolvedEdge = End.resolve(LayoutDirection.Ltr)
        assertThat(resolvedEdge).isEqualTo(SceneContainerEdge.Resolved.Right)
    }

    @Test
    fun resolve_endInRtl_resolvesLeft() {
        val resolvedEdge = End.resolve(LayoutDirection.Rtl)
        assertThat(resolvedEdge).isEqualTo(SceneContainerEdge.Resolved.Left)
    }

    @Test
    fun resolve_topStartInLtr_resolvesTopLeft() {
        val resolvedEdge = TopStart.resolve(LayoutDirection.Ltr)
        assertThat(resolvedEdge).isEqualTo(SceneContainerEdge.Resolved.TopLeft)
    }

    @Test
    fun resolve_topStartInRtl_resolvesTopRight() {
        val resolvedEdge = TopStart.resolve(LayoutDirection.Rtl)
        assertThat(resolvedEdge).isEqualTo(SceneContainerEdge.Resolved.TopRight)
    }

    @Test
    fun resolve_topEndInLtr_resolvesTopRight() {
        val resolvedEdge = TopEnd.resolve(LayoutDirection.Ltr)
        assertThat(resolvedEdge).isEqualTo(SceneContainerEdge.Resolved.TopRight)
    }

    @Test
    fun resolve_topEndInRtl_resolvesTopLeft() {
        val resolvedEdge = TopEnd.resolve(LayoutDirection.Rtl)
        assertThat(resolvedEdge).isEqualTo(SceneContainerEdge.Resolved.TopLeft)
    }

    private fun swipeVerticallyFrom(x: Int, y: Int): SceneContainerEdge? {
        return swipeFrom(x, y, Orientation.Vertical)
    }

    private fun swipeHorizontallyFrom(x: Int, y: Int): SceneContainerEdge? {
        return swipeFrom(x, y, Orientation.Horizontal)
    }

    private fun swipeFrom(x: Int, y: Int, orientation: Orientation): SceneContainerEdge? {
        return underTest.source(
            layoutSize = IntSize(width = screenWidth, height = screenHeight),
            position = IntOffset(x, y),
            density = Density(1f),
            orientation = orientation,
        )
    }
}
+33 −13
Original line number Diff line number Diff line
@@ -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)
        }
}
+7 −1
Original line number Diff line number Diff line
@@ -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),
            )
        )
    }
Loading