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

Commit 1b0f1a3c authored by Michal Brzezinski's avatar Michal Brzezinski
Browse files

Refactoring BackGestureTutorialScreen

Extracting logic from BackGestureTutorialScreen (and part of GestureTutorialScreen) into BackGestureScreenViewModel which can now be easily tested, yay!

GestureTutorialScreen.kt now has two similar Composables: GestureTutorialScreenNew (refactored) and GestureTutorialScreen. The plan is to move all gestures to using GestureTutorialScreenNew
and then delete GestureTutorialScreen which is doing too much.
BackGestureScreenViewModel is injected into activity straight away even before screen is needed but it doesn't do anything on its own and lifecycle of its flows is still managed by Composable lifecycle.

Next step: doing the same refactoring for other gesture screens.

Bug: 384509663
Test: BackGestureScreenViewModelTest
Flag: com.android.systemui.shared.new_touchpad_gestures_tutorial
Change-Id: I3e86f103dce5d8853687bf00d4c6546290931ee5
parent 3b4c42ff
Loading
Loading
Loading
Loading
+144 −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.touchpad.tutorial.ui.viewmodel

import android.view.MotionEvent
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.common.ui.data.repository.fakeConfigurationRepository
import com.android.systemui.common.ui.domain.interactor.configurationInteractor
import com.android.systemui.kosmos.Kosmos
import com.android.systemui.kosmos.collectLastValue
import com.android.systemui.kosmos.runTest
import com.android.systemui.kosmos.useUnconfinedTestDispatcher
import com.android.systemui.res.R
import com.android.systemui.testKosmos
import com.android.systemui.touchpad.tutorial.ui.composable.GestureUiState
import com.android.systemui.touchpad.tutorial.ui.composable.GestureUiState.Error
import com.android.systemui.touchpad.tutorial.ui.composable.GestureUiState.Finished
import com.android.systemui.touchpad.tutorial.ui.composable.GestureUiState.InProgress
import com.android.systemui.touchpad.tutorial.ui.gesture.MultiFingerGesture.Companion.SWIPE_DISTANCE
import com.android.systemui.touchpad.tutorial.ui.gesture.ThreeFingerGesture
import com.google.common.truth.Truth.assertThat
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

@SmallTest
@RunWith(AndroidJUnit4::class)
class BackGestureScreenViewModelTest : SysuiTestCase() {

    private val kosmos = testKosmos()
    private val fakeConfigRepository = kosmos.fakeConfigurationRepository
    private val viewModel = BackGestureScreenViewModel(kosmos.configurationInteractor)

    @Before
    fun before() {
        setThresholdResource(threshold = SWIPE_DISTANCE - 1)
        kosmos.useUnconfinedTestDispatcher()
    }

    @Test
    fun easterEggNotTriggeredAtStart() =
        kosmos.runTest {
            val easterEggTriggered by collectLastValue(viewModel.easterEggTriggered)
            assertThat(easterEggTriggered).isFalse()
        }

    @Test
    fun emitsProgressStateWithLeftProgressAnimation() =
        kosmos.runTest {
            assertProgressWhileMovingFingers(
                deltaX = -SWIPE_DISTANCE,
                expected =
                    InProgress(
                        progress = 1f,
                        progressStartMarker = "gesture to L",
                        progressEndMarker = "end progress L",
                    ),
            )
        }

    @Test
    fun emitsProgressStateWithRightProgressAnimation() =
        kosmos.runTest {
            assertProgressWhileMovingFingers(
                deltaX = SWIPE_DISTANCE,
                expected =
                    InProgress(
                        progress = 1f,
                        progressStartMarker = "gesture to R",
                        progressEndMarker = "end progress R",
                    ),
            )
        }

    @Test
    fun emitsFinishedStateWithLeftSuccessAnimation() =
        kosmos.runTest {
            assertStateAfterEvents(
                events = ThreeFingerGesture.swipeLeft(),
                expected = Finished(successAnimation = R.raw.trackpad_back_success_left),
            )
        }

    @Test
    fun emitsFinishedStateWithRightSuccessAnimation() =
        kosmos.runTest {
            assertStateAfterEvents(
                events = ThreeFingerGesture.swipeRight(),
                expected = Finished(successAnimation = R.raw.trackpad_back_success_right),
            )
        }

    @Test
    fun gestureRecognitionTakesLatestDistanceThresholdIntoAccount() =
        kosmos.runTest {
            fun performBackGesture() =
                ThreeFingerGesture.swipeLeft().forEach { viewModel.handleEvent(it) }
            val state by collectLastValue(viewModel.gestureUiState)
            performBackGesture()
            assertThat(state).isInstanceOf(Finished::class.java)

            setThresholdResource(SWIPE_DISTANCE + 1)
            performBackGesture() // now swipe distance is not enough to trigger success

            assertThat(state).isInstanceOf(Error::class.java)
        }

    private fun setThresholdResource(threshold: Float) {
        fakeConfigRepository.setDimensionPixelSize(
            R.dimen.touchpad_tutorial_gestures_distance_threshold,
            (threshold).toInt(),
        )
        fakeConfigRepository.onAnyConfigurationChange()
    }

    private fun Kosmos.assertProgressWhileMovingFingers(deltaX: Float, expected: GestureUiState) {
        assertStateAfterEvents(
            events = ThreeFingerGesture.eventsForGestureInProgress { move(deltaX = deltaX) },
            expected = expected,
        )
    }

    private fun Kosmos.assertStateAfterEvents(events: List<MotionEvent>, expected: GestureUiState) {
        val state by collectLastValue(viewModel.gestureUiState)
        events.forEach { viewModel.handleEvent(it) }
        assertThat(state).isEqualTo(expected)
    }
}
+8 −4
Original line number Diff line number Diff line
@@ -28,6 +28,7 @@ import com.android.systemui.touchpad.tutorial.domain.interactor.TouchpadGestures
import com.android.systemui.touchpad.tutorial.ui.composable.BackGestureTutorialScreen
import com.android.systemui.touchpad.tutorial.ui.composable.HomeGestureTutorialScreen
import com.android.systemui.touchpad.tutorial.ui.view.TouchpadTutorialActivity
import com.android.systemui.touchpad.tutorial.ui.viewmodel.BackGestureScreenViewModel
import dagger.Binds
import dagger.Module
import dagger.Provides
@@ -45,8 +46,10 @@ interface TouchpadTutorialModule {

    companion object {
        @Provides
        fun touchpadScreensProvider(): TouchpadTutorialScreensProvider {
            return ScreensProvider
        fun touchpadScreensProvider(
            backGestureScreenViewModel: BackGestureScreenViewModel
        ): TouchpadTutorialScreensProvider {
            return ScreensProvider(backGestureScreenViewModel)
        }

        @SysUISingleton
@@ -62,10 +65,11 @@ interface TouchpadTutorialModule {
    }
}

private object ScreensProvider : TouchpadTutorialScreensProvider {
private class ScreensProvider(val backGestureScreenViewModel: BackGestureScreenViewModel) :
    TouchpadTutorialScreensProvider {
    @Composable
    override fun BackGesture(onDoneButtonClicked: () -> Unit, onBack: () -> Unit) {
        BackGestureTutorialScreen(onDoneButtonClicked, onBack)
        BackGestureTutorialScreen(backGestureScreenViewModel, onDoneButtonClicked, onBack)
    }

    @Composable
+15 −44
Original line number Diff line number Diff line
@@ -16,26 +16,22 @@

package com.android.systemui.touchpad.tutorial.ui.composable

import android.content.res.Resources
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import com.airbnb.lottie.compose.rememberLottieDynamicProperties
import com.android.compose.theme.LocalAndroidColorScheme
import com.android.systemui.inputdevice.tutorial.ui.composable.TutorialScreenConfig
import com.android.systemui.inputdevice.tutorial.ui.composable.rememberColorFilterProperty
import com.android.systemui.res.R
import com.android.systemui.touchpad.tutorial.ui.gesture.BackGestureRecognizer
import com.android.systemui.touchpad.tutorial.ui.gesture.GestureDirection
import com.android.systemui.touchpad.tutorial.ui.gesture.GestureFlowAdapter
import com.android.systemui.touchpad.tutorial.ui.gesture.GestureRecognizer
import com.android.systemui.touchpad.tutorial.ui.gesture.GestureState
import com.android.systemui.util.kotlin.pairwiseBy
import kotlinx.coroutines.flow.Flow
import com.android.systemui.touchpad.tutorial.ui.viewmodel.BackGestureScreenViewModel

@Composable
fun BackGestureTutorialScreen(onDoneButtonClicked: () -> Unit, onBack: () -> Unit) {
fun BackGestureTutorialScreen(
    viewModel: BackGestureScreenViewModel,
    onDoneButtonClicked: () -> Unit,
    onBack: () -> Unit,
) {
    val screenConfig =
        TutorialScreenConfig(
            colors = rememberScreenColors(),
@@ -50,41 +46,16 @@ fun BackGestureTutorialScreen(onDoneButtonClicked: () -> Unit, onBack: () -> Uni
                ),
            animations = TutorialScreenConfig.Animations(educationResId = R.raw.trackpad_back_edu),
        )
    val recognizer = rememberBackGestureRecognizer(LocalContext.current.resources)
    val gestureUiState: Flow<GestureUiState> =
        remember(recognizer) {
            GestureFlowAdapter(recognizer).gestureStateAsFlow.pairwiseBy(GestureState.NotStarted) {
                previous,
                current ->
                val (startMarker, endMarker) = getMarkers(current)
                current.toGestureUiState(
                    progressStartMarker = startMarker,
                    progressEndMarker = endMarker,
                    successAnimation = successAnimation(previous),
    GestureTutorialScreenNew(
        screenConfig = screenConfig,
        gestureUiStateFlow = viewModel.gestureUiState,
        motionEventConsumer = viewModel::handleEvent,
        easterEggTriggeredFlow = viewModel.easterEggTriggered,
        onEasterEggFinished = viewModel::onEasterEggFinished,
        onDoneButtonClicked = onDoneButtonClicked,
        onBack = onBack,
    )
}
        }
    GestureTutorialScreen(screenConfig, recognizer, gestureUiState, onDoneButtonClicked, onBack)
}

@Composable
private fun rememberBackGestureRecognizer(resources: Resources): GestureRecognizer {
    val distance =
        resources.getDimensionPixelSize(R.dimen.touchpad_tutorial_gestures_distance_threshold)
    return remember(distance) { BackGestureRecognizer(distance) }
}

private fun getMarkers(it: GestureState): Pair<String, String> {
    return if (it is GestureState.InProgress && it.direction == GestureDirection.LEFT) {
        "gesture to L" to "end progress L"
    } else "gesture to R" to "end progress R"
}

private fun successAnimation(previous: GestureState): Int {
    return if (previous is GestureState.InProgress && previous.direction == GestureDirection.LEFT) {
        R.raw.trackpad_back_success_left
    } else R.raw.trackpad_back_success_right
}

@Composable
private fun rememberScreenColors(): TutorialScreenConfig.Colors {
+34 −6
Original line number Diff line number Diff line
@@ -16,6 +16,7 @@

package com.android.systemui.touchpad.tutorial.ui.composable

import android.view.MotionEvent
import androidx.activity.compose.BackHandler
import androidx.annotation.RawRes
import androidx.compose.animation.core.Animatable
@@ -111,10 +112,37 @@ fun GestureTutorialScreen(
    val gestureHandler =
        remember(gestureRecognizer) { TouchpadGestureHandler(gestureRecognizer, easterEggMonitor) }
    TouchpadGesturesHandlingBox(
        gestureHandler,
        { gestureHandler.onMotionEvent(it) },
        gestureState,
        easterEggTriggered,
        resetEasterEggFlag = { easterEggTriggered = false },
        onEasterEggFinished = { easterEggTriggered = false },
    ) {
        var lastState: TutorialActionState by remember {
            mutableStateOf(TutorialActionState.NotStarted)
        }
        lastState = gestureState.toTutorialActionState(lastState)
        ActionTutorialContent(lastState, onDoneButtonClicked, screenConfig)
    }
}

@Composable
fun GestureTutorialScreenNew(
    screenConfig: TutorialScreenConfig,
    gestureUiStateFlow: Flow<GestureUiState>,
    motionEventConsumer: (MotionEvent) -> Boolean,
    easterEggTriggeredFlow: Flow<Boolean>,
    onEasterEggFinished: () -> Unit,
    onDoneButtonClicked: () -> Unit,
    onBack: () -> Unit,
) {
    BackHandler(onBack = onBack)
    val easterEggTriggered by easterEggTriggeredFlow.collectAsStateWithLifecycle(false)
    val gestureState by gestureUiStateFlow.collectAsStateWithLifecycle(NotStarted)
    TouchpadGesturesHandlingBox(
        motionEventConsumer,
        gestureState,
        easterEggTriggered,
        onEasterEggFinished,
    ) {
        var lastState: TutorialActionState by remember {
            mutableStateOf(TutorialActionState.NotStarted)
@@ -126,10 +154,10 @@ fun GestureTutorialScreen(

@Composable
private fun TouchpadGesturesHandlingBox(
    gestureHandler: TouchpadGestureHandler,
    motionEventConsumer: (MotionEvent) -> Boolean,
    gestureState: GestureUiState,
    easterEggTriggered: Boolean,
    resetEasterEggFlag: () -> Unit,
    onEasterEggFinished: () -> Unit,
    modifier: Modifier = Modifier,
    content: @Composable BoxScope.() -> Unit,
) {
@@ -141,7 +169,7 @@ private fun TouchpadGesturesHandlingBox(
                targetValue = 360f,
                animationSpec = tween(durationMillis = 2000),
            )
            resetEasterEggFlag()
            onEasterEggFinished()
        }
    }
    Box(
@@ -156,7 +184,7 @@ private fun TouchpadGesturesHandlingBox(
                        if (gestureState is Finished) {
                            false
                        } else {
                            gestureHandler.onMotionEvent(event)
                            motionEventConsumer(event)
                        }
                    }
                )
+11 −2
Original line number Diff line number Diff line
@@ -38,6 +38,7 @@ import com.android.systemui.touchpad.tutorial.ui.composable.BackGestureTutorialS
import com.android.systemui.touchpad.tutorial.ui.composable.HomeGestureTutorialScreen
import com.android.systemui.touchpad.tutorial.ui.composable.RecentAppsGestureTutorialScreen
import com.android.systemui.touchpad.tutorial.ui.composable.TutorialSelectionScreen
import com.android.systemui.touchpad.tutorial.ui.viewmodel.BackGestureScreenViewModel
import com.android.systemui.touchpad.tutorial.ui.viewmodel.Screen.BACK_GESTURE
import com.android.systemui.touchpad.tutorial.ui.viewmodel.Screen.HOME_GESTURE
import com.android.systemui.touchpad.tutorial.ui.viewmodel.Screen.RECENT_APPS_GESTURE
@@ -51,6 +52,7 @@ constructor(
    private val viewModelFactory: TouchpadTutorialViewModel.Factory,
    private val logger: InputDeviceTutorialLogger,
    private val metricsLogger: KeyboardTouchpadTutorialMetricsLogger,
    private val backGestureViewModel: BackGestureScreenViewModel,
) : ComponentActivity() {

    private val vm by viewModels<TouchpadTutorialViewModel>(factoryProducer = { viewModelFactory })
@@ -60,7 +62,9 @@ constructor(
        enableEdgeToEdge()
        setTitle(getString(R.string.launch_touchpad_tutorial_notification_content))
        setContent {
            PlatformTheme { TouchpadTutorialScreen(vm, closeTutorial = ::finishTutorial) }
            PlatformTheme {
                TouchpadTutorialScreen(vm, backGestureViewModel, closeTutorial = ::finishTutorial)
            }
        }
        // required to handle 3+ fingers on touchpad
        window.addPrivateFlags(WindowManager.LayoutParams.PRIVATE_FLAG_TRUSTED_OVERLAY)
@@ -85,7 +89,11 @@ constructor(
}

@Composable
fun TouchpadTutorialScreen(vm: TouchpadTutorialViewModel, closeTutorial: () -> Unit) {
fun TouchpadTutorialScreen(
    vm: TouchpadTutorialViewModel,
    backGestureViewModel: BackGestureScreenViewModel,
    closeTutorial: () -> Unit,
) {
    val activeScreen by vm.screen.collectAsStateWithLifecycle(STARTED)
    var lastSelectedScreen by remember { mutableStateOf(TUTORIAL_SELECTION) }
    when (activeScreen) {
@@ -108,6 +116,7 @@ fun TouchpadTutorialScreen(vm: TouchpadTutorialViewModel, closeTutorial: () -> U
            )
        BACK_GESTURE ->
            BackGestureTutorialScreen(
                backGestureViewModel,
                onDoneButtonClicked = { vm.goTo(TUTORIAL_SELECTION) },
                onBack = { vm.goTo(TUTORIAL_SELECTION) },
            )
Loading