diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/data/repo/BouncerRepository.kt b/packages/SystemUI/src/com/android/systemui/bouncer/data/repo/BouncerRepository.kt new file mode 100644 index 0000000000000..4c817b2e46a87 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/bouncer/data/repo/BouncerRepository.kt @@ -0,0 +1,35 @@ +/* + * 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(null) + /** The user-facing message to show in the bouncer. */ + val message: StateFlow = _message.asStateFlow() + + fun setMessage(message: String?) { + _message.value = message + } +} diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractor.kt b/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractor.kt new file mode 100644 index 0000000000000..57ce58049f134 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractor.kt @@ -0,0 +1,172 @@ +/* + * 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 = 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 = + 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, + ) { + 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 + } +} diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt new file mode 100644 index 0000000000000..8a183ae6ca7ce --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt @@ -0,0 +1,75 @@ +/* + * 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 = + 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. + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt new file mode 100644 index 0000000000000..7dd376ede3617 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt @@ -0,0 +1,223 @@ +/* + * 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" + } +}