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

Commit 1bc1d42d authored by Alejandro Nijamkin's avatar Alejandro Nijamkin
Browse files

[flexiglass] View-model integration test.

Adds a view-model level integration test for Flexiglass. This is meant
to be a collection of all known "rules" related to how Flexiglass
automatically changes scenes based on device state like authentication
and wakefulness.

Fix: 294899205
Test: Built and ran, manually made sure that all scenarios described in
the test class work as intended.

Change-Id: I49577bc72c838b3afbb8d00931b2a388aeaf0a60
parent b31b6675
Loading
Loading
Loading
Loading
+1 −1
Original line number Diff line number Diff line
@@ -102,7 +102,7 @@ constructor(
            .stateIn(
                scope = applicationScope,
                started = SharingStarted.Eagerly,
                initialValue = true,
                initialValue = false,
            )

    /**
+478 −0
Original line number Diff line number Diff line
/*
 * Copyright 2023 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.scene

import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.authentication.data.model.AuthenticationMethodModel as DataLayerAuthenticationMethodModel
import com.android.systemui.authentication.data.repository.FakeAuthenticationRepository
import com.android.systemui.authentication.domain.model.AuthenticationMethodModel as DomainLayerAuthenticationMethodModel
import com.android.systemui.authentication.domain.model.AuthenticationMethodModel
import com.android.systemui.bouncer.ui.viewmodel.PinBouncerViewModel
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.flags.FakeFeatureFlags
import com.android.systemui.flags.Flags
import com.android.systemui.keyguard.shared.model.WakefulnessState
import com.android.systemui.keyguard.ui.viewmodel.LockscreenSceneViewModel
import com.android.systemui.model.SysUiState
import com.android.systemui.scene.domain.startable.SceneContainerStartable
import com.android.systemui.scene.shared.model.ObservableTransitionState
import com.android.systemui.scene.shared.model.SceneKey
import com.android.systemui.scene.shared.model.SceneModel
import com.android.systemui.scene.ui.viewmodel.SceneContainerViewModel
import com.android.systemui.settings.FakeDisplayTracker
import com.android.systemui.util.mockito.mock
import com.google.common.truth.Truth.assertThat
import com.google.common.truth.Truth.assertWithMessage
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4

/**
 * Integration test cases for the Scene Framework.
 *
 * **Principles**
 * * All test cases here should be done from the perspective of the view-models of the system.
 * * Focus on happy paths, let smaller unit tests focus on failure cases.
 * * These are _integration_ tests and, as such, are larger and harder to maintain than unit tests.
 *   Therefore, when adding or modifying test cases, consider whether what you're testing is better
 *   covered by a more granular unit test.
 * * Please reuse the helper methods in this class (for example, [putDeviceToSleep] or
 *   [emulateUserDrivenTransition]).
 * * All tests start with the device locked and with a PIN auth method. The class offers useful
 *   methods like [setAuthMethod], [unlockDevice], [lockDevice], etc. to help you set up a starting
 *   state that makes more sense for your test case.
 * * All helper methods in this class make assertions that are meant to make sure that they're only
 *   being used when the state is as required (e.g. cannot unlock an already unlocked device, cannot
 *   put to sleep a device that's already asleep, etc.).
 */
@SmallTest
@RunWith(JUnit4::class)
class SceneFrameworkIntegrationTest : SysuiTestCase() {

    private val utils = SceneTestUtils(this)
    private val testScope = utils.testScope

    private val sceneContainerConfig = utils.fakeSceneContainerConfig()
    private val sceneRepository =
        utils.fakeSceneContainerRepository(
            containerConfig = sceneContainerConfig,
        )
    private val sceneInteractor =
        utils.sceneInteractor(
            repository = sceneRepository,
        )

    private val authenticationRepository = utils.authenticationRepository()
    private val authenticationInteractor =
        utils.authenticationInteractor(
            repository = authenticationRepository,
            sceneInteractor = sceneInteractor,
        )

    private val transitionState =
        MutableStateFlow<ObservableTransitionState>(
            ObservableTransitionState.Idle(sceneContainerConfig.initialSceneKey)
        )
    private val sceneContainerViewModel =
        SceneContainerViewModel(
                interactor = sceneInteractor,
            )
            .apply { setTransitionState(transitionState) }

    private val bouncerInteractor =
        utils.bouncerInteractor(
            authenticationInteractor = authenticationInteractor,
            sceneInteractor = sceneInteractor,
        )
    private val bouncerViewModel =
        utils.bouncerViewModel(
            bouncerInteractor = bouncerInteractor,
            authenticationInteractor = authenticationInteractor,
        )

    private val lockscreenSceneViewModel =
        LockscreenSceneViewModel(
            applicationScope = testScope.backgroundScope,
            authenticationInteractor = authenticationInteractor,
            bouncerInteractor = bouncerInteractor,
        )

    private val keyguardRepository = utils.keyguardRepository()
    private val keyguardInteractor =
        utils.keyguardInteractor(
            repository = keyguardRepository,
        )

    @Before
    fun setUp() {
        val featureFlags = FakeFeatureFlags().apply { set(Flags.SCENE_CONTAINER, true) }

        authenticationRepository.setUnlocked(false)

        val displayTracker = FakeDisplayTracker(context)
        val sysUiState = SysUiState(displayTracker)
        val startable =
            SceneContainerStartable(
                applicationScope = testScope.backgroundScope,
                sceneInteractor = sceneInteractor,
                authenticationInteractor = authenticationInteractor,
                keyguardInteractor = keyguardInteractor,
                featureFlags = featureFlags,
                sysUiState = sysUiState,
                displayId = displayTracker.defaultDisplayId,
                sceneLogger = mock(),
            )
        startable.start()

        assertWithMessage("Initial scene key mismatch!")
            .that(sceneContainerViewModel.currentScene.value.key)
            .isEqualTo(sceneContainerConfig.initialSceneKey)
        assertWithMessage("Initial scene container visibility mismatch!")
            .that(sceneContainerViewModel.isVisible.value)
            .isTrue()
    }

    @Test
    fun clickLockButtonAndEnterCorrectPin_unlocksDevice() =
        testScope.runTest {
            lockscreenSceneViewModel.onLockButtonClicked()
            assertCurrentScene(SceneKey.Bouncer)
            emulateUiSceneTransition()

            enterPin()
            assertCurrentScene(SceneKey.Gone)
            emulateUiSceneTransition(
                expectedVisible = false,
            )
        }

    @Test
    fun swipeUpOnLockscreen_enterCorrectPin_unlocksDevice() =
        testScope.runTest {
            val upDestinationSceneKey by
                collectLastValue(lockscreenSceneViewModel.upDestinationSceneKey)
            assertThat(upDestinationSceneKey).isEqualTo(SceneKey.Bouncer)
            emulateUserDrivenTransition(
                to = upDestinationSceneKey,
            )

            enterPin()
            assertCurrentScene(SceneKey.Gone)
            emulateUiSceneTransition(
                expectedVisible = false,
            )
        }

    @Test
    fun swipeUpOnLockscreen_withAuthMethodSwipe_dismissesLockscreen() =
        testScope.runTest {
            setAuthMethod(DomainLayerAuthenticationMethodModel.Swipe)

            val upDestinationSceneKey by
                collectLastValue(lockscreenSceneViewModel.upDestinationSceneKey)
            assertThat(upDestinationSceneKey).isEqualTo(SceneKey.Gone)
            emulateUserDrivenTransition(
                to = upDestinationSceneKey,
                expectedVisible = false,
            )
        }

    @Test
    fun withAuthMethodNone_deviceWakeUp_skipsLockscreen() =
        testScope.runTest {
            setAuthMethod(AuthenticationMethodModel.None)
            putDeviceToSleep(instantlyLockDevice = false)
            assertCurrentScene(SceneKey.Lockscreen)

            wakeUpDevice()
            assertCurrentScene(SceneKey.Gone)
        }

    @Test
    fun deviceGoesToSleep_switchesToLockscreen() =
        testScope.runTest {
            unlockDevice()
            assertCurrentScene(SceneKey.Gone)

            putDeviceToSleep()
            assertCurrentScene(SceneKey.Lockscreen)
        }

    @Test
    fun deviceGoesToSleep_wakeUp_unlock() =
        testScope.runTest {
            unlockDevice()
            assertCurrentScene(SceneKey.Gone)
            putDeviceToSleep()
            assertCurrentScene(SceneKey.Lockscreen)
            wakeUpDevice()
            assertCurrentScene(SceneKey.Lockscreen)

            unlockDevice()
            assertCurrentScene(SceneKey.Gone)
        }

    @Test
    fun deviceGoesToSleep_withLockTimeout_staysOnLockscreen() =
        testScope.runTest {
            unlockDevice()
            assertCurrentScene(SceneKey.Gone)
            putDeviceToSleep(instantlyLockDevice = false)
            assertCurrentScene(SceneKey.Lockscreen)

            // Pretend like the timeout elapsed and now lock the device.
            lockDevice()
            assertCurrentScene(SceneKey.Lockscreen)
        }

    /**
     * Asserts that the current scene in the view-model matches what's expected.
     *
     * Note that this doesn't assert what the current scene is in the UI.
     */
    private fun TestScope.assertCurrentScene(expected: SceneKey) {
        runCurrent()
        assertWithMessage("Current scene mismatch!")
            .that(sceneContainerViewModel.currentScene.value.key)
            .isEqualTo(expected)
    }

    /**
     * Returns the [SceneKey] of the current scene as displayed in the UI.
     *
     * This can be different than the value in [SceneContainerViewModel.currentScene], by design, as
     * the UI must gradually transition between scenes.
     */
    private fun getCurrentSceneInUi(): SceneKey {
        return when (val state = transitionState.value) {
            is ObservableTransitionState.Idle -> state.scene
            is ObservableTransitionState.Transition -> state.fromScene
        }
    }

    /** Updates the current authentication method and related states in the data layer. */
    private fun TestScope.setAuthMethod(
        authMethod: DomainLayerAuthenticationMethodModel,
    ) {
        // Set the lockscreen enabled bit _before_ set the auth method as the code picks up on the
        // lockscreen enabled bit _after_ the auth method is changed and the lockscreen enabled bit
        // is not an observable that can trigger a new evaluation.
        authenticationRepository.setLockscreenEnabled(authMethod !is AuthenticationMethodModel.None)
        authenticationRepository.setAuthenticationMethod(authMethod.toDataLayer())
        if (!authMethod.isSecure) {
            // When the auth method is not secure, the device is never considered locked.
            authenticationRepository.setUnlocked(true)
        }
        runCurrent()
    }

    /**
     * Emulates a complete transition in the UI from whatever the current scene is in the UI to
     * whatever the current scene should be, based on the value in
     * [SceneContainerViewModel.onSceneChanged].
     *
     * This should post a series of values into [transitionState] to emulate a gradual scene
     * transition and culminate with a call to [SceneContainerViewModel.onSceneChanged].
     *
     * The method asserts that a transition is actually required. E.g. it will fail if the current
     * scene in [transitionState] is already caught up with the scene in
     * [SceneContainerViewModel.currentScene].
     *
     * @param expectedVisible Whether [SceneContainerViewModel.isVisible] should be set at the end
     *   of the UI transition.
     */
    private fun TestScope.emulateUiSceneTransition(
        expectedVisible: Boolean = true,
    ) {
        val to = sceneContainerViewModel.currentScene.value
        val from = getCurrentSceneInUi()
        assertWithMessage("Cannot transition to ${to.key} as the UI is already on that scene!")
            .that(to.key)
            .isNotEqualTo(from)

        // Begin to transition.
        val progressFlow = MutableStateFlow(0f)
        transitionState.value =
            ObservableTransitionState.Transition(
                fromScene = getCurrentSceneInUi(),
                toScene = to.key,
                progress = progressFlow,
            )
        runCurrent()

        // Report progress of transition.
        while (progressFlow.value < 1f) {
            progressFlow.value += 0.2f
            runCurrent()
        }

        // End the transition and report the change.
        transitionState.value = ObservableTransitionState.Idle(to.key)

        sceneContainerViewModel.onSceneChanged(to)
        runCurrent()

        assertWithMessage("Visibility mismatch after scene transition from $from to ${to.key}!")
            .that(sceneContainerViewModel.isVisible.value)
            .isEqualTo(expectedVisible)
    }

    /**
     * Emulates a fire-and-forget user action (a fling or back, not a pointer-tracking swipe) that
     * causes a scene change to the [to] scene.
     *
     * This also includes the emulation of the resulting UI transition that culminates with the UI
     * catching up with the requested scene change (see [emulateUiSceneTransition]).
     *
     * @param to The scene to transition to.
     * @param expectedVisible Whether [SceneContainerViewModel.isVisible] should be set at the end
     *   of the UI transition.
     */
    private fun TestScope.emulateUserDrivenTransition(
        to: SceneKey?,
        expectedVisible: Boolean = true,
    ) {
        checkNotNull(to)

        sceneInteractor.changeScene(SceneModel(to), "reason")
        assertThat(sceneContainerViewModel.currentScene.value.key).isEqualTo(to)

        emulateUiSceneTransition(
            expectedVisible = expectedVisible,
        )
    }

    /**
     * Locks the device immediately (without delay).
     *
     * Asserts the device to be lockable (e.g. that the current authentication is secure).
     *
     * Not to be confused with [putDeviceToSleep], which may also instantly lock the device.
     */
    private suspend fun TestScope.lockDevice() {
        val authMethod = authenticationInteractor.getAuthenticationMethod()
        assertWithMessage("The authentication method of $authMethod is not secure, cannot lock!")
            .that(authMethod.isSecure)
            .isTrue()

        authenticationRepository.setUnlocked(false)
        runCurrent()
    }

    /** Unlocks the device by entering the correct PIN. Ends up in the Gone scene. */
    private fun TestScope.unlockDevice() {
        assertWithMessage("Cannot unlock a device that's already unlocked!")
            .that(authenticationInteractor.isUnlocked.value)
            .isFalse()

        lockscreenSceneViewModel.onLockButtonClicked()
        runCurrent()
        emulateUiSceneTransition()

        enterPin()
        emulateUiSceneTransition(
            expectedVisible = false,
        )
    }

    /**
     * Enters the correct PIN in the bouncer UI.
     *
     * Asserts that the current scene is [SceneKey.Bouncer] and that the current bouncer UI is a PIN
     * before proceeding.
     *
     * Does not assert that the device is locked or unlocked.
     */
    private fun TestScope.enterPin() {
        assertWithMessage("Cannot enter PIN when not on the Bouncer scene!")
            .that(getCurrentSceneInUi())
            .isEqualTo(SceneKey.Bouncer)
        val authMethodViewModel by collectLastValue(bouncerViewModel.authMethod)
        assertWithMessage("Cannot enter PIN when not using a PIN authentication method!")
            .that(authMethodViewModel)
            .isInstanceOf(PinBouncerViewModel::class.java)

        val pinBouncerViewModel = authMethodViewModel as PinBouncerViewModel
        FakeAuthenticationRepository.DEFAULT_PIN.forEach { digit ->
            pinBouncerViewModel.onPinButtonClicked(digit)
        }
        pinBouncerViewModel.onAuthenticateButtonClicked()
        runCurrent()
    }

    /** Changes device wakefulness state from asleep to awake, going through intermediary states. */
    private fun TestScope.wakeUpDevice() {
        val wakefulnessModel = keyguardRepository.wakefulness.value
        assertWithMessage("Cannot wake up device as it's already awake!")
            .that(wakefulnessModel.isStartingToWakeOrAwake())
            .isFalse()

        keyguardRepository.setWakefulnessModel(
            wakefulnessModel.copy(state = WakefulnessState.STARTING_TO_WAKE)
        )
        runCurrent()
        keyguardRepository.setWakefulnessModel(
            wakefulnessModel.copy(state = WakefulnessState.AWAKE)
        )
        runCurrent()
    }

    /** Changes device wakefulness state from awake to asleep, going through intermediary states. */
    private suspend fun TestScope.putDeviceToSleep(
        instantlyLockDevice: Boolean = true,
    ) {
        val wakefulnessModel = keyguardRepository.wakefulness.value
        assertWithMessage("Cannot put device to sleep as it's already asleep!")
            .that(wakefulnessModel.isStartingToWakeOrAwake())
            .isTrue()

        keyguardRepository.setWakefulnessModel(
            wakefulnessModel.copy(state = WakefulnessState.STARTING_TO_SLEEP)
        )
        runCurrent()
        keyguardRepository.setWakefulnessModel(
            wakefulnessModel.copy(state = WakefulnessState.ASLEEP)
        )
        runCurrent()

        if (instantlyLockDevice) {
            lockDevice()
        }
    }

    private fun DomainLayerAuthenticationMethodModel.toDataLayer():
        DataLayerAuthenticationMethodModel {
        return when (this) {
            DomainLayerAuthenticationMethodModel.None -> DataLayerAuthenticationMethodModel.None
            DomainLayerAuthenticationMethodModel.Swipe -> DataLayerAuthenticationMethodModel.None
            DomainLayerAuthenticationMethodModel.Pin -> DataLayerAuthenticationMethodModel.Pin
            DomainLayerAuthenticationMethodModel.Password ->
                DataLayerAuthenticationMethodModel.Password
            DomainLayerAuthenticationMethodModel.Pattern ->
                DataLayerAuthenticationMethodModel.Pattern
        }
    }
}