diff options
9 files changed, 185 insertions, 7 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModel.kt index ebefb78f0477..774a5593530c 100644 --- a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModel.kt @@ -16,4 +16,14 @@ package com.android.systemui.bouncer.ui.viewmodel -sealed interface AuthMethodBouncerViewModel +import kotlinx.coroutines.flow.StateFlow + +sealed interface AuthMethodBouncerViewModel { + /** + * Whether user input is enabled. + * + * If `false`, user input should be completely ignored in the UI as the user is "locked out" of + * being able to attempt to unlock the device. + */ + val isInputEnabled: StateFlow<Boolean> +} 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 index c6528d0736cd..02991bd47c6e 100644 --- a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt @@ -17,6 +17,7 @@ package com.android.systemui.bouncer.ui.viewmodel import android.content.Context +import com.android.systemui.R import com.android.systemui.authentication.shared.model.AuthenticationMethodModel import com.android.systemui.bouncer.domain.interactor.BouncerInteractor import com.android.systemui.dagger.qualifiers.Application @@ -24,10 +25,14 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch /** Holds UI state and handles user input on bouncer UIs. */ class BouncerViewModel @@ -40,16 +45,42 @@ constructor( ) { private val interactor: BouncerInteractor = interactorFactory.create(containerName) + /** + * Whether updates to the message should be cross-animated from one message to another. + * + * If `false`, no animation should be applied, the message text should just be replaced + * instantly. + */ + val isMessageUpdateAnimationsEnabled: StateFlow<Boolean> = + interactor.throttling + .map { it == null } + .stateIn( + scope = applicationScope, + started = SharingStarted.WhileSubscribed(), + initialValue = interactor.throttling.value == null, + ) + + private val isInputEnabled: StateFlow<Boolean> = + interactor.throttling + .map { it == null } + .stateIn( + scope = applicationScope, + started = SharingStarted.WhileSubscribed(), + initialValue = interactor.throttling.value == null, + ) + private val pin: PinBouncerViewModel by lazy { PinBouncerViewModel( applicationScope = applicationScope, interactor = interactor, + isInputEnabled = isInputEnabled, ) } private val password: PasswordBouncerViewModel by lazy { PasswordBouncerViewModel( interactor = interactor, + isInputEnabled = isInputEnabled, ) } @@ -58,6 +89,7 @@ constructor( applicationContext = applicationContext, applicationScope = applicationScope, interactor = interactor, + isInputEnabled = isInputEnabled, ) } @@ -81,11 +113,59 @@ constructor( initialValue = interactor.message.value ?: "", ) + private val _throttlingDialogMessage = MutableStateFlow<String?>(null) + /** + * A message for a throttling dialog to show when the user has attempted the wrong credential + * too many times and now must wait a while before attempting again. + * + * If `null`, no dialog should be shown. + * + * Once the dialog is shown, the UI should call [onThrottlingDialogDismissed] when the user + * dismisses this dialog. + */ + val throttlingDialogMessage: StateFlow<String?> = _throttlingDialogMessage.asStateFlow() + + init { + applicationScope.launch { + interactor.throttling + .map { model -> + model?.let { + when (interactor.authenticationMethod.value) { + is AuthenticationMethodModel.PIN -> + R.string.kg_too_many_failed_pin_attempts_dialog_message + is AuthenticationMethodModel.Password -> + R.string.kg_too_many_failed_password_attempts_dialog_message + is AuthenticationMethodModel.Pattern -> + R.string.kg_too_many_failed_pattern_attempts_dialog_message + else -> null + }?.let { stringResourceId -> + applicationContext.getString( + stringResourceId, + model.failedAttemptCount, + model.totalDurationSec, + ) + } + } + } + .distinctUntilChanged() + .collect { dialogMessageOrNull -> + if (dialogMessageOrNull != null) { + _throttlingDialogMessage.value = dialogMessageOrNull + } + } + } + } + /** Notifies that the emergency services button was clicked. */ fun onEmergencyServicesButtonClicked() { // TODO(b/280877228): implement this } + /** Notifies that a throttling dialog has been dismissed by the user. */ + fun onThrottlingDialogDismissed() { + _throttlingDialogMessage.value = null + } + private fun toViewModel( authMethod: AuthenticationMethodModel, ): AuthMethodBouncerViewModel? { diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModel.kt index 730d4e8ba050..c38fcaa3b657 100644 --- a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModel.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.asStateFlow /** Holds UI state and handles user input for the password bouncer UI. */ class PasswordBouncerViewModel( private val interactor: BouncerInteractor, + override val isInputEnabled: StateFlow<Boolean>, ) : AuthMethodBouncerViewModel { private val _password = MutableStateFlow("") diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModel.kt index eb1b45771ad4..1b0b38ea6e9c 100644 --- a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModel.kt @@ -37,6 +37,7 @@ class PatternBouncerViewModel( private val applicationContext: Context, applicationScope: CoroutineScope, private val interactor: BouncerInteractor, + override val isInputEnabled: StateFlow<Boolean>, ) : AuthMethodBouncerViewModel { /** The number of columns in the dot grid. */ @@ -63,6 +64,16 @@ class PatternBouncerViewModel( /** All dots on the grid. */ val dots: StateFlow<List<PatternDotViewModel>> = _dots.asStateFlow() + /** Whether the pattern itself should be rendered visibly. */ + val isPatternVisible: StateFlow<Boolean> = + interactor.authenticationMethod + .map { authMethod -> isPatternVisible(authMethod) } + .stateIn( + scope = applicationScope, + started = SharingStarted.Eagerly, + initialValue = isPatternVisible(interactor.authenticationMethod.value), + ) + /** Notifies that the UI has been shown to the user. */ fun onShown() { interactor.resetMessage() @@ -146,6 +157,10 @@ class PatternBouncerViewModel( _selectedDots.value = linkedSetOf() } + private fun isPatternVisible(authMethodModel: AuthenticationMethodModel): Boolean { + return (authMethodModel as? AuthenticationMethodModel.Pattern)?.isPatternVisible ?: false + } + private fun defaultDots(): List<PatternDotViewModel> { return buildList { (0 until columnCount).forEach { x -> diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModel.kt index f9223cb0872e..2a733d93b857 100644 --- a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModel.kt @@ -33,6 +33,7 @@ import kotlinx.coroutines.launch class PinBouncerViewModel( private val applicationScope: CoroutineScope, private val interactor: BouncerInteractor, + override val isInputEnabled: StateFlow<Boolean>, ) : AuthMethodBouncerViewModel { private val entered = MutableStateFlow<List<Int>>(emptyList()) diff --git a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelTest.kt index 954e67d77181..b942ccbb51f3 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelTest.kt @@ -19,11 +19,15 @@ package com.android.systemui.bouncer.ui.viewmodel import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase import com.android.systemui.authentication.shared.model.AuthenticationMethodModel +import com.android.systemui.bouncer.domain.interactor.BouncerInteractor import com.android.systemui.coroutines.collectLastValue import com.android.systemui.scene.SceneTestUtils import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith @@ -40,13 +44,12 @@ class BouncerViewModelTest : SysuiTestCase() { utils.authenticationInteractor( repository = utils.authenticationRepository(), ) - private val underTest = - utils.bouncerViewModel( - utils.bouncerInteractor( - authenticationInteractor = authenticationInteractor, - sceneInteractor = utils.sceneInteractor(), - ) + private val bouncerInteractor = + utils.bouncerInteractor( + authenticationInteractor = authenticationInteractor, + sceneInteractor = utils.sceneInteractor(), ) + private val underTest = utils.bouncerViewModel(bouncerInteractor) @Test fun authMethod_nonNullForSecureMethods_nullForNotSecureMethods() = @@ -89,6 +92,65 @@ class BouncerViewModelTest : SysuiTestCase() { .isEqualTo(AuthenticationMethodModel::class.sealedSubclasses.toSet()) } + @Test + fun isMessageUpdateAnimationsEnabled() = + testScope.runTest { + val isMessageUpdateAnimationsEnabled by + collectLastValue(underTest.isMessageUpdateAnimationsEnabled) + val throttling by collectLastValue(bouncerInteractor.throttling) + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + assertThat(isMessageUpdateAnimationsEnabled).isTrue() + + repeat(BouncerInteractor.THROTTLE_EVERY) { + // Wrong PIN. + bouncerInteractor.authenticate(listOf(3, 4, 5, 6)) + } + assertThat(isMessageUpdateAnimationsEnabled).isFalse() + + throttling?.totalDurationSec?.let { seconds -> advanceTimeBy(seconds * 1000L) } + assertThat(isMessageUpdateAnimationsEnabled).isTrue() + } + + @Test + fun isInputEnabled() = + testScope.runTest { + val isInputEnabled by + collectLastValue( + underTest.authMethod.flatMapLatest { authViewModel -> + authViewModel?.isInputEnabled ?: emptyFlow() + } + ) + val throttling by collectLastValue(bouncerInteractor.throttling) + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + assertThat(isInputEnabled).isTrue() + + repeat(BouncerInteractor.THROTTLE_EVERY) { + // Wrong PIN. + bouncerInteractor.authenticate(listOf(3, 4, 5, 6)) + } + assertThat(isInputEnabled).isFalse() + + throttling?.totalDurationSec?.let { seconds -> advanceTimeBy(seconds * 1000L) } + assertThat(isInputEnabled).isTrue() + } + + @Test + fun throttlingDialogMessage() = + testScope.runTest { + val throttlingDialogMessage by collectLastValue(underTest.throttlingDialogMessage) + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + + repeat(BouncerInteractor.THROTTLE_EVERY) { + // Wrong PIN. + assertThat(throttlingDialogMessage).isNull() + bouncerInteractor.authenticate(listOf(3, 4, 5, 6)) + } + assertThat(throttlingDialogMessage).isNotEmpty() + + underTest.onThrottlingDialogDismissed() + assertThat(throttlingDialogMessage).isNull() + } + private fun authMethodsToTest(): List<AuthenticationMethodModel> { return listOf( AuthenticationMethodModel.None, diff --git a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModelTest.kt index e48b6386c739..b7b90de3b54a 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModelTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModelTest.kt @@ -26,6 +26,8 @@ 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.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Before @@ -57,6 +59,7 @@ class PasswordBouncerViewModelTest : SysuiTestCase() { private val underTest = PasswordBouncerViewModel( interactor = bouncerInteractor, + isInputEnabled = MutableStateFlow(true).asStateFlow(), ) @Before diff --git a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModelTest.kt index 6ce29e67982c..b588ba2b2574 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModelTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModelTest.kt @@ -27,6 +27,8 @@ import com.android.systemui.scene.shared.model.SceneModel 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.flow.asStateFlow import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Before @@ -60,6 +62,7 @@ class PatternBouncerViewModelTest : SysuiTestCase() { applicationContext = context, applicationScope = testScope.backgroundScope, interactor = bouncerInteractor, + isInputEnabled = MutableStateFlow(true).asStateFlow(), ) @Before diff --git a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModelTest.kt index bb28520ad8a0..83f9687d7ac5 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModelTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModelTest.kt @@ -27,6 +27,8 @@ 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.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest @@ -68,6 +70,7 @@ class PinBouncerViewModelTest : SysuiTestCase() { PinBouncerViewModel( applicationScope = testScope.backgroundScope, interactor = bouncerInteractor, + isInputEnabled = MutableStateFlow(true).asStateFlow(), ) @Before |