Merge "[flexiglass] Bouncer throttling - view models." into udc-dev am: 2907102b7b am: 7fa5587311 am: ecfb1b5ea6
Original change: https://googleplex-android-review.googlesource.com/c/platform/frameworks/base/+/23303129
Change-Id: I16aa300fb5bd5ce9d7163d5ef48b29ab86ae7f8a
Signed-off-by: Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>
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 ebefb78..774a559 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 c6528d0..02991bd 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.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 @@
) {
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 @@
applicationContext = applicationContext,
applicationScope = applicationScope,
interactor = interactor,
+ isInputEnabled = isInputEnabled,
)
}
@@ -81,11 +113,59 @@
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 730d4e8..c38fcaa 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 @@
/** 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 eb1b457..1b0b38e 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 @@
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 @@
/** 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 @@
_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 f9223cb..2a733d9 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 @@
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 954e67d..b942ccb 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 @@
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 @@
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 @@
.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 e48b638..b7b90de 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.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 @@
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 6ce29e6..b588ba2 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.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 @@
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 bb28520..83f9687 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.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 @@
PinBouncerViewModel(
applicationScope = testScope.backgroundScope,
interactor = bouncerInteractor,
+ isInputEnabled = MutableStateFlow(true).asStateFlow(),
)
@Before