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

Commit 63561ed2 authored by Alejandro Nijamkin's avatar Alejandro Nijamkin
Browse files

[flexiglass] Foundation for placeholder lock screen scene.

Foundational parts of the lock screen scene including application state
(which is still not hooked up to real sources of truth), business logic,
and a view-model that's only good enough for placeholder scene UI.

Bug: 280879610
Test: unit tests included. Manually tested with the entire relation
chain in the Compose Gallery testdbed app.

Change-Id: I90b025f57f993c02368e73a06fba6e4ddc02b9bc
parent 8d655c39
Loading
Loading
Loading
Loading
+186 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 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.
 */

package com.android.systemui.keyguard.domain.interactor

import com.android.systemui.authentication.domain.interactor.AuthenticationInteractor
import com.android.systemui.authentication.shared.model.AuthenticationMethodModel
import com.android.systemui.bouncer.domain.interactor.BouncerInteractor
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.scene.domain.interactor.SceneInteractor
import com.android.systemui.scene.shared.model.SceneKey
import com.android.systemui.scene.shared.model.SceneModel
import com.android.systemui.util.kotlin.pairwise
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch

/** Hosts business and application state accessing logic for the lock screen scene. */
class LockScreenSceneInteractor
@AssistedInject
constructor(
    @Application applicationScope: CoroutineScope,
    private val authenticationInteractor: AuthenticationInteractor,
    bouncerInteractorFactory: BouncerInteractor.Factory,
    private val sceneInteractor: SceneInteractor,
    @Assisted private val containerName: String,
) {
    private val bouncerInteractor: BouncerInteractor =
        bouncerInteractorFactory.create(containerName)

    /** Whether the device is currently locked. */
    val isDeviceLocked: StateFlow<Boolean> =
        authenticationInteractor.isUnlocked
            .map { !it }
            .stateIn(
                scope = applicationScope,
                started = SharingStarted.WhileSubscribed(),
                initialValue = !authenticationInteractor.isUnlocked.value,
            )

    /** Whether it's currently possible to swipe up to dismiss the lock screen. */
    val isSwipeToDismissEnabled: StateFlow<Boolean> =
        combine(
                authenticationInteractor.isUnlocked,
                authenticationInteractor.authenticationMethod,
            ) { isUnlocked, authMethod ->
                isSwipeToUnlockEnabled(
                    isUnlocked = isUnlocked,
                    authMethod = authMethod,
                )
            }
            .stateIn(
                scope = applicationScope,
                started = SharingStarted.WhileSubscribed(),
                initialValue =
                    isSwipeToUnlockEnabled(
                        isUnlocked = authenticationInteractor.isUnlocked.value,
                        authMethod = authenticationInteractor.authenticationMethod.value,
                    ),
            )

    init {
        // LOCKING SHOWS LOCK SCREEN.
        //
        // Move to the lock screen scene if the device becomes locked while in any scene.
        applicationScope.launch {
            authenticationInteractor.isUnlocked
                .map { !it }
                .distinctUntilChanged()
                .collect { isLocked ->
                    if (isLocked) {
                        sceneInteractor.setCurrentScene(
                            containerName = containerName,
                            scene = SceneModel(SceneKey.LockScreen),
                        )
                    }
                }
        }

        // BYPASS UNLOCK.
        //
        // Moves to the gone scene if bypass is enabled and the device becomes unlocked while in the
        // lock screen scene.
        applicationScope.launch {
            combine(
                    authenticationInteractor.isBypassEnabled,
                    authenticationInteractor.isUnlocked,
                    sceneInteractor.currentScene(containerName),
                    ::Triple,
                )
                .collect { (isBypassEnabled, isUnlocked, currentScene) ->
                    if (isBypassEnabled && isUnlocked && currentScene.key == SceneKey.LockScreen) {
                        sceneInteractor.setCurrentScene(
                            containerName = containerName,
                            scene = SceneModel(SceneKey.Gone),
                        )
                    }
                }
        }

        // SWIPE TO DISMISS LOCK SCREEN.
        //
        // If switched from the lock screen to the gone scene and the auth method was a swipe,
        // unlocks the device.
        applicationScope.launch {
            combine(
                    authenticationInteractor.authenticationMethod,
                    sceneInteractor.currentScene(containerName).pairwise(),
                    ::Pair,
                )
                .collect { (authMethod, scenes) ->
                    val (previousScene, currentScene) = scenes
                    if (
                        authMethod is AuthenticationMethodModel.Swipe &&
                            previousScene.key == SceneKey.LockScreen &&
                            currentScene.key == SceneKey.Gone
                    ) {
                        authenticationInteractor.unlockDevice()
                    }
                }
        }

        // DISMISS LOCK SCREEN IF AUTH METHOD IS REMOVED.
        //
        // If the auth method becomes None while on the lock screen scene, dismisses the lock
        // screen.
        applicationScope.launch {
            combine(
                    authenticationInteractor.authenticationMethod,
                    sceneInteractor.currentScene(containerName),
                    ::Pair,
                )
                .collect { (authMethod, scene) ->
                    if (
                        scene.key == SceneKey.LockScreen &&
                            authMethod == AuthenticationMethodModel.None
                    ) {
                        sceneInteractor.setCurrentScene(
                            containerName = containerName,
                            scene = SceneModel(SceneKey.Gone),
                        )
                    }
                }
        }
    }

    /** Attempts to dismiss the lock screen. This will cause the bouncer to show, if needed. */
    fun dismissLockScreen() {
        bouncerInteractor.showOrUnlockDevice(containerName = containerName)
    }

    private fun isSwipeToUnlockEnabled(
        isUnlocked: Boolean,
        authMethod: AuthenticationMethodModel,
    ): Boolean {
        return !isUnlocked && authMethod is AuthenticationMethodModel.Swipe
    }

    @AssistedFactory
    interface Factory {
        fun create(
            containerName: String,
        ): LockScreenSceneInteractor
    }
}
+108 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 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.
 */

package com.android.systemui.keyguard.ui.viewmodel

import com.android.systemui.R
import com.android.systemui.common.shared.model.ContentDescription
import com.android.systemui.common.shared.model.Icon
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.keyguard.domain.interactor.LockScreenSceneInteractor
import com.android.systemui.scene.shared.model.SceneKey
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn

/** Models UI state and handles user input for the lock screen scene. */
class LockScreenSceneViewModel
@AssistedInject
constructor(
    @Application applicationScope: CoroutineScope,
    interactorFactory: LockScreenSceneInteractor.Factory,
    @Assisted containerName: String,
) {
    private val interactor: LockScreenSceneInteractor = interactorFactory.create(containerName)

    /** The icon for the "lock" button on the lock screen. */
    val lockButtonIcon: StateFlow<Icon> =
        interactor.isDeviceLocked
            .map { isLocked -> lockIcon(isLocked = isLocked) }
            .stateIn(
                scope = applicationScope,
                started = SharingStarted.WhileSubscribed(),
                initialValue = lockIcon(isLocked = interactor.isDeviceLocked.value),
            )

    /** The key of the scene we should switch to when swiping up. */
    val upDestinationSceneKey: StateFlow<SceneKey> =
        interactor.isSwipeToDismissEnabled
            .map { isSwipeToUnlockEnabled -> upDestinationSceneKey(isSwipeToUnlockEnabled) }
            .stateIn(
                scope = applicationScope,
                started = SharingStarted.WhileSubscribed(),
                initialValue = upDestinationSceneKey(interactor.isSwipeToDismissEnabled.value),
            )

    /** Notifies that the lock button on the lock screen was clicked. */
    fun onLockButtonClicked() {
        interactor.dismissLockScreen()
    }

    /** Notifies that some content on the lock screen was clicked. */
    fun onContentClicked() {
        interactor.dismissLockScreen()
    }

    private fun upDestinationSceneKey(
        isSwipeToUnlockEnabled: Boolean,
    ): SceneKey {
        return if (isSwipeToUnlockEnabled) SceneKey.Gone else SceneKey.Bouncer
    }

    private fun lockIcon(
        isLocked: Boolean,
    ): Icon {
        return Icon.Resource(
            res =
                if (isLocked) {
                    R.drawable.ic_device_lock_on
                } else {
                    R.drawable.ic_device_lock_off
                },
            contentDescription =
                ContentDescription.Resource(
                    res =
                        if (isLocked) {
                            R.string.accessibility_lock_icon
                        } else {
                            R.string.accessibility_unlock_button
                        }
                )
        )
    }

    @AssistedFactory
    interface Factory {
        fun create(
            containerName: String,
        ): LockScreenSceneViewModel
    }
}
+270 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 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.
 */

package com.android.systemui.keyguard.domain.interactor

import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.authentication.data.repository.AuthenticationRepositoryImpl
import com.android.systemui.authentication.domain.interactor.AuthenticationInteractor
import com.android.systemui.authentication.shared.model.AuthenticationMethodModel
import com.android.systemui.bouncer.data.repo.BouncerRepository
import com.android.systemui.bouncer.domain.interactor.BouncerInteractor
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.scene.data.repository.fakeSceneContainerRepository
import com.android.systemui.scene.domain.interactor.SceneInteractor
import com.android.systemui.scene.shared.model.SceneKey
import com.android.systemui.scene.shared.model.SceneModel
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4

@OptIn(ExperimentalCoroutinesApi::class)
@SmallTest
@RunWith(JUnit4::class)
class LockScreenSceneInteractorTest : SysuiTestCase() {

    private val testScope = TestScope()
    private val sceneInteractor =
        SceneInteractor(
            repository = fakeSceneContainerRepository(),
        )
    private val mAuthenticationInteractor =
        AuthenticationInteractor(
            applicationScope = testScope.backgroundScope,
            repository = AuthenticationRepositoryImpl(),
        )
    private val underTest =
        LockScreenSceneInteractor(
            applicationScope = testScope.backgroundScope,
            authenticationInteractor = mAuthenticationInteractor,
            bouncerInteractorFactory =
                object : BouncerInteractor.Factory {
                    override fun create(containerName: String): BouncerInteractor {
                        return BouncerInteractor(
                            applicationScope = testScope.backgroundScope,
                            applicationContext = context,
                            repository = BouncerRepository(),
                            authenticationInteractor = mAuthenticationInteractor,
                            sceneInteractor = sceneInteractor,
                            containerName = containerName,
                        )
                    }
                },
            sceneInteractor = sceneInteractor,
            containerName = CONTAINER_NAME,
        )

    @Test
    fun isDeviceLocked() =
        testScope.runTest {
            val isDeviceLocked by collectLastValue(underTest.isDeviceLocked)

            mAuthenticationInteractor.lockDevice()
            assertThat(isDeviceLocked).isTrue()

            mAuthenticationInteractor.unlockDevice()
            assertThat(isDeviceLocked).isFalse()
        }

    @Test
    fun isSwipeToDismissEnabled_deviceLockedAndAuthMethodSwipe_true() =
        testScope.runTest {
            val isSwipeToDismissEnabled by collectLastValue(underTest.isSwipeToDismissEnabled)

            mAuthenticationInteractor.lockDevice()
            mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.Swipe)

            assertThat(isSwipeToDismissEnabled).isTrue()
        }

    @Test
    fun isSwipeToDismissEnabled_deviceUnlockedAndAuthMethodSwipe_false() =
        testScope.runTest {
            val isSwipeToDismissEnabled by collectLastValue(underTest.isSwipeToDismissEnabled)

            mAuthenticationInteractor.unlockDevice()
            mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.Swipe)

            assertThat(isSwipeToDismissEnabled).isFalse()
        }

    @Test
    fun dismissLockScreen_deviceLockedWithSecureAuthMethod_switchesToBouncer() =
        testScope.runTest {
            val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME))
            mAuthenticationInteractor.lockDevice()
            mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234))
            assertThat(currentScene).isEqualTo(SceneModel(SceneKey.LockScreen))

            underTest.dismissLockScreen()

            assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
        }

    @Test
    fun dismissLockScreen_deviceUnlocked_switchesToGone() =
        testScope.runTest {
            val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME))
            mAuthenticationInteractor.unlockDevice()
            mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234))
            assertThat(currentScene).isEqualTo(SceneModel(SceneKey.LockScreen))

            underTest.dismissLockScreen()

            assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone))
        }

    @Test
    fun dismissLockScreen_deviceLockedWithInsecureAuthMethod_switchesToGone() =
        testScope.runTest {
            val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME))
            mAuthenticationInteractor.lockDevice()
            mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.Swipe)
            assertThat(currentScene).isEqualTo(SceneModel(SceneKey.LockScreen))

            underTest.dismissLockScreen()

            assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone))
        }

    @Test
    fun deviceLockedInNonLockScreenScene_switchesToLockScreenScene() =
        testScope.runTest {
            val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME))
            runCurrent()
            sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Gone))
            runCurrent()
            mAuthenticationInteractor.unlockDevice()
            runCurrent()
            assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone))

            mAuthenticationInteractor.lockDevice()

            assertThat(currentScene).isEqualTo(SceneModel(SceneKey.LockScreen))
        }

    @Test
    fun deviceBiometricUnlockedInLockScreen_bypassEnabled_switchesToGone() =
        testScope.runTest {
            val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME))
            mAuthenticationInteractor.lockDevice()
            sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.LockScreen))
            if (!mAuthenticationInteractor.isBypassEnabled.value) {
                mAuthenticationInteractor.toggleBypassEnabled()
            }
            assertThat(currentScene).isEqualTo(SceneModel(SceneKey.LockScreen))

            mAuthenticationInteractor.biometricUnlock()

            assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone))
        }

    @Test
    fun deviceBiometricUnlockedInLockScreen_bypassNotEnabled_doesNotSwitch() =
        testScope.runTest {
            val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME))
            mAuthenticationInteractor.lockDevice()
            sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.LockScreen))
            if (mAuthenticationInteractor.isBypassEnabled.value) {
                mAuthenticationInteractor.toggleBypassEnabled()
            }
            assertThat(currentScene).isEqualTo(SceneModel(SceneKey.LockScreen))

            mAuthenticationInteractor.biometricUnlock()

            assertThat(currentScene).isEqualTo(SceneModel(SceneKey.LockScreen))
        }

    @Test
    fun switchFromLockScreenToGone_authMethodSwipe_unlocksDevice() =
        testScope.runTest {
            val isUnlocked by collectLastValue(mAuthenticationInteractor.isUnlocked)
            sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.LockScreen))
            mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.Swipe)
            assertThat(isUnlocked).isFalse()

            sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Gone))

            assertThat(isUnlocked).isTrue()
        }

    @Test
    fun switchFromLockScreenToGone_authMethodNotSwipe_doesNotUnlockDevice() =
        testScope.runTest {
            val isUnlocked by collectLastValue(mAuthenticationInteractor.isUnlocked)
            sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.LockScreen))
            mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234))
            assertThat(isUnlocked).isFalse()

            sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Gone))

            assertThat(isUnlocked).isFalse()
        }

    @Test
    fun switchFromNonLockScreenToGone_authMethodSwipe_doesNotUnlockDevice() =
        testScope.runTest {
            val isUnlocked by collectLastValue(mAuthenticationInteractor.isUnlocked)
            runCurrent()
            sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Shade))
            runCurrent()
            mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.Swipe)
            runCurrent()
            assertThat(isUnlocked).isFalse()

            sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Gone))

            assertThat(isUnlocked).isFalse()
        }

    @Test
    fun authMethodChangedToNone_onLockScreenScene_dismissesLockScreen() =
        testScope.runTest {
            val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME))
            sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.LockScreen))
            mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.Swipe)
            assertThat(currentScene).isEqualTo(SceneModel(SceneKey.LockScreen))

            mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.None)

            assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone))
        }

    @Test
    fun authMethodChangedToNone_notOnLockScreenScene_doesNotDismissLockScreen() =
        testScope.runTest {
            val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME))
            mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.Swipe)
            runCurrent()
            sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.QuickSettings))
            runCurrent()
            assertThat(currentScene).isEqualTo(SceneModel(SceneKey.QuickSettings))

            mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.None)

            assertThat(currentScene).isEqualTo(SceneModel(SceneKey.QuickSettings))
        }

    companion object {
        private const val CONTAINER_NAME = "container1"
    }
}
+190 −0

File added.

Preview size limit exceeded, changes collapsed.