Merge "[flexiglass] Bouncer throttling - view models." into udc-dev

This commit is contained in:
Ale Nijamkin
2023-05-20 14:02:37 +00:00
committed by Android (Google) Code Review
9 changed files with 185 additions and 7 deletions

View File

@@ -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>
}

View File

@@ -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? {

View File

@@ -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("")

View File

@@ -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 ->

View File

@@ -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())

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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