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

Commit 8d655c39 authored by Alejandro Nijamkin's avatar Alejandro Nijamkin
Browse files

[flexiglass] Foundation for placeholder bouncer scene.

Foundational parts of the bouncer 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: 280877228
Test: unit tests included. Manually tested with the entire relation
chain in the Compose Gallery testdbed app.

Change-Id: I54449898006489fd02d1f318646d4e5317256077
parent 71754a15
Loading
Loading
Loading
Loading
+35 −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.bouncer.data.repo

import com.android.systemui.dagger.SysUISingleton
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

/** Provides access to bouncer-related application state. */
@SysUISingleton
class BouncerRepository @Inject constructor() {
    private val _message = MutableStateFlow<String?>(null)
    /** The user-facing message to show in the bouncer. */
    val message: StateFlow<String?> = _message.asStateFlow()

    fun setMessage(message: String?) {
        _message.value = message
    }
}
+172 −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.bouncer.domain.interactor

import android.content.Context
import com.android.systemui.R
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.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 dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch

/** Encapsulates business logic and application state accessing use-cases. */
class BouncerInteractor
@AssistedInject
constructor(
    @Application private val applicationScope: CoroutineScope,
    @Application private val applicationContext: Context,
    private val repository: BouncerRepository,
    private val authenticationInteractor: AuthenticationInteractor,
    private val sceneInteractor: SceneInteractor,
    @Assisted private val containerName: String,
) {

    /** The user-facing message to show in the bouncer. */
    val message: StateFlow<String?> = repository.message

    /**
     * The currently-configured authentication method. This determines how the authentication
     * challenge is completed in order to unlock an otherwise locked device.
     */
    val authenticationMethod: StateFlow<AuthenticationMethodModel> =
        authenticationInteractor.authenticationMethod

    init {
        applicationScope.launch {
            combine(
                    sceneInteractor.currentScene(containerName),
                    authenticationInteractor.authenticationMethod,
                    ::Pair,
                )
                .collect { (currentScene, authMethod) ->
                    if (currentScene.key == SceneKey.Bouncer) {
                        when (authMethod) {
                            is AuthenticationMethodModel.None ->
                                sceneInteractor.setCurrentScene(
                                    containerName,
                                    SceneModel(SceneKey.Gone),
                                )
                            is AuthenticationMethodModel.Swipe ->
                                sceneInteractor.setCurrentScene(
                                    containerName,
                                    SceneModel(SceneKey.LockScreen),
                                )
                            else -> Unit
                        }
                    }
                }
        }
    }

    /**
     * Either shows the bouncer or unlocks the device, if the bouncer doesn't need to be shown.
     *
     * @param containerName The name of the scene container to show the bouncer in.
     * @param message An optional message to show to the user in the bouncer.
     */
    fun showOrUnlockDevice(
        containerName: String,
        message: String? = null,
    ) {
        if (authenticationInteractor.isAuthenticationRequired()) {
            repository.setMessage(message ?: promptMessage(authenticationMethod.value))
            sceneInteractor.setCurrentScene(
                containerName = containerName,
                scene = SceneModel(SceneKey.Bouncer),
            )
        } else {
            authenticationInteractor.unlockDevice()
            sceneInteractor.setCurrentScene(
                containerName = containerName,
                scene = SceneModel(SceneKey.Gone),
            )
        }
    }

    /**
     * Resets the user-facing message back to the default according to the current authentication
     * method.
     */
    fun resetMessage() {
        repository.setMessage(promptMessage(authenticationMethod.value))
    }

    /** Removes the user-facing message. */
    fun clearMessage() {
        repository.setMessage(null)
    }

    /**
     * Attempts to authenticate based on the given user input.
     *
     * If the input is correct, the device will be unlocked and the lock screen and bouncer will be
     * dismissed and hidden.
     */
    fun authenticate(
        input: List<Any>,
    ) {
        val isAuthenticated = authenticationInteractor.authenticate(input)
        if (isAuthenticated) {
            sceneInteractor.setCurrentScene(
                containerName = containerName,
                scene = SceneModel(SceneKey.Gone),
            )
        } else {
            repository.setMessage(errorMessage(authenticationMethod.value))
        }
    }

    private fun promptMessage(authMethod: AuthenticationMethodModel): String {
        return when (authMethod) {
            is AuthenticationMethodModel.PIN ->
                applicationContext.getString(R.string.keyguard_enter_your_pin)
            is AuthenticationMethodModel.Password ->
                applicationContext.getString(R.string.keyguard_enter_your_password)
            is AuthenticationMethodModel.Pattern ->
                applicationContext.getString(R.string.keyguard_enter_your_pattern)
            else -> ""
        }
    }

    private fun errorMessage(authMethod: AuthenticationMethodModel): String {
        return when (authMethod) {
            is AuthenticationMethodModel.PIN -> applicationContext.getString(R.string.kg_wrong_pin)
            is AuthenticationMethodModel.Password ->
                applicationContext.getString(R.string.kg_wrong_password)
            is AuthenticationMethodModel.Pattern ->
                applicationContext.getString(R.string.kg_wrong_pattern)
            else -> ""
        }
    }

    @AssistedFactory
    interface Factory {
        fun create(
            containerName: String,
        ): BouncerInteractor
    }
}
+75 −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.bouncer.ui.viewmodel

import com.android.systemui.authentication.shared.model.AuthenticationMethodModel
import com.android.systemui.bouncer.domain.interactor.BouncerInteractor
import com.android.systemui.dagger.qualifiers.Application
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

/** Holds UI state and handles user input on bouncer UIs. */
class BouncerViewModel
@AssistedInject
constructor(
    @Application private val applicationScope: CoroutineScope,
    interactorFactory: BouncerInteractor.Factory,
    containerName: String,
) {
    private val interactor: BouncerInteractor = interactorFactory.create(containerName)

    /** The user-facing message to show in the bouncer. */
    val message: StateFlow<String> =
        interactor.message
            .map { it ?: "" }
            .stateIn(
                scope = applicationScope,
                started = SharingStarted.WhileSubscribed(),
                initialValue = interactor.message.value ?: "",
            )

    /** Notifies that the authenticate button was clicked. */
    fun onAuthenticateButtonClicked() {
        // TODO(b/280877228): remove this and send the real input.
        interactor.authenticate(
            when (interactor.authenticationMethod.value) {
                is AuthenticationMethodModel.PIN -> listOf(1, 2, 3, 4)
                is AuthenticationMethodModel.Password -> "password".toList()
                is AuthenticationMethodModel.Pattern ->
                    listOf(
                        AuthenticationMethodModel.Pattern.PatternCoordinate(2, 0),
                        AuthenticationMethodModel.Pattern.PatternCoordinate(2, 1),
                        AuthenticationMethodModel.Pattern.PatternCoordinate(2, 2),
                        AuthenticationMethodModel.Pattern.PatternCoordinate(1, 1),
                        AuthenticationMethodModel.Pattern.PatternCoordinate(0, 0),
                        AuthenticationMethodModel.Pattern.PatternCoordinate(0, 1),
                        AuthenticationMethodModel.Pattern.PatternCoordinate(0, 2),
                    )
                else -> emptyList()
            }
        )
    }

    /** Notifies that the emergency services button was clicked. */
    fun onEmergencyServicesButtonClicked() {
        // TODO(b/280877228): implement this.
    }
}
+223 −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.bouncer.domain.interactor

import androidx.test.filters.SmallTest
import com.android.systemui.R
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.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.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4

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

    private val testScope = TestScope()
    private val authenticationInteractor =
        AuthenticationInteractor(
            applicationScope = testScope.backgroundScope,
            repository = AuthenticationRepositoryImpl(),
        )
    private val sceneInteractor =
        SceneInteractor(
            repository = fakeSceneContainerRepository(),
        )
    private val underTest =
        BouncerInteractor(
            applicationScope = testScope.backgroundScope,
            applicationContext = context,
            repository = BouncerRepository(),
            authenticationInteractor = authenticationInteractor,
            sceneInteractor = sceneInteractor,
            containerName = "container1",
        )

    @Before
    fun setUp() {
        overrideResource(R.string.keyguard_enter_your_pin, MESSAGE_ENTER_YOUR_PIN)
        overrideResource(R.string.keyguard_enter_your_password, MESSAGE_ENTER_YOUR_PASSWORD)
        overrideResource(R.string.keyguard_enter_your_pattern, MESSAGE_ENTER_YOUR_PATTERN)
        overrideResource(R.string.kg_wrong_pin, MESSAGE_WRONG_PIN)
        overrideResource(R.string.kg_wrong_password, MESSAGE_WRONG_PASSWORD)
        overrideResource(R.string.kg_wrong_pattern, MESSAGE_WRONG_PATTERN)
    }

    @Test
    fun pinAuthMethod() =
        testScope.runTest {
            val currentScene by collectLastValue(sceneInteractor.currentScene("container1"))
            val message by collectLastValue(underTest.message)

            authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234))
            authenticationInteractor.lockDevice()
            underTest.showOrUnlockDevice("container1")
            assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
            assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PIN)

            underTest.clearMessage()
            assertThat(message).isNull()

            underTest.resetMessage()
            assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PIN)

            // Wrong input.
            underTest.authenticate(listOf(9, 8, 7))
            assertThat(message).isEqualTo(MESSAGE_WRONG_PIN)
            assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))

            underTest.resetMessage()
            assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PIN)

            // Correct input.
            underTest.authenticate(listOf(1, 2, 3, 4))
            assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone))
        }

    @Test
    fun passwordAuthMethod() =
        testScope.runTest {
            val currentScene by collectLastValue(sceneInteractor.currentScene("container1"))
            val message by collectLastValue(underTest.message)
            authenticationInteractor.setAuthenticationMethod(
                AuthenticationMethodModel.Password("password")
            )
            authenticationInteractor.lockDevice()
            underTest.showOrUnlockDevice("container1")
            assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
            assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PASSWORD)

            underTest.clearMessage()
            assertThat(message).isNull()

            underTest.resetMessage()
            assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PASSWORD)

            // Wrong input.
            underTest.authenticate("alohamora".toList())
            assertThat(message).isEqualTo(MESSAGE_WRONG_PASSWORD)
            assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))

            underTest.resetMessage()
            assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PASSWORD)

            // Correct input.
            underTest.authenticate("password".toList())
            assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone))
        }

    @Test
    fun patternAuthMethod() =
        testScope.runTest {
            val currentScene by collectLastValue(sceneInteractor.currentScene("container1"))
            val message by collectLastValue(underTest.message)
            authenticationInteractor.setAuthenticationMethod(
                AuthenticationMethodModel.Pattern(emptyList())
            )
            authenticationInteractor.lockDevice()
            underTest.showOrUnlockDevice("container1")
            assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
            assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PATTERN)

            underTest.clearMessage()
            assertThat(message).isNull()

            underTest.resetMessage()
            assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PATTERN)

            // Wrong input.
            underTest.authenticate(
                listOf(AuthenticationMethodModel.Pattern.PatternCoordinate(3, 4))
            )
            assertThat(message).isEqualTo(MESSAGE_WRONG_PATTERN)
            assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))

            underTest.resetMessage()
            assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PATTERN)

            // Correct input.
            underTest.authenticate(emptyList())
            assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone))
        }

    @Test
    fun showOrUnlockDevice_notLocked_switchesToGoneScene() =
        testScope.runTest {
            val currentScene by collectLastValue(sceneInteractor.currentScene("container1"))
            authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234))
            authenticationInteractor.unlockDevice()
            runCurrent()

            underTest.showOrUnlockDevice("container1")

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

    @Test
    fun showOrUnlockDevice_authMethodNotSecure_switchesToGoneScene() =
        testScope.runTest {
            val currentScene by collectLastValue(sceneInteractor.currentScene("container1"))
            authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.Swipe)
            authenticationInteractor.lockDevice()

            underTest.showOrUnlockDevice("container1")

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

    @Test
    fun showOrUnlockDevice_customMessageShown() =
        testScope.runTest {
            val currentScene by collectLastValue(sceneInteractor.currentScene("container1"))
            val message by collectLastValue(underTest.message)
            authenticationInteractor.setAuthenticationMethod(
                AuthenticationMethodModel.Password("password")
            )
            authenticationInteractor.lockDevice()

            val customMessage = "Hello there!"
            underTest.showOrUnlockDevice("container1", customMessage)

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

    companion object {
        private const val MESSAGE_ENTER_YOUR_PIN = "Enter your PIN"
        private const val MESSAGE_ENTER_YOUR_PASSWORD = "Enter your password"
        private const val MESSAGE_ENTER_YOUR_PATTERN = "Enter your pattern"
        private const val MESSAGE_WRONG_PIN = "Wrong PIN"
        private const val MESSAGE_WRONG_PASSWORD = "Wrong password"
        private const val MESSAGE_WRONG_PATTERN = "Wrong pattern"
    }
}