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 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 package com.android.systemui.bouncer.ui.viewmodel
import android.content.Context import android.content.Context
import com.android.systemui.R
import com.android.systemui.authentication.shared.model.AuthenticationMethodModel import com.android.systemui.authentication.shared.model.AuthenticationMethodModel
import com.android.systemui.bouncer.domain.interactor.BouncerInteractor import com.android.systemui.bouncer.domain.interactor.BouncerInteractor
import com.android.systemui.dagger.qualifiers.Application import com.android.systemui.dagger.qualifiers.Application
@@ -24,10 +25,14 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
/** Holds UI state and handles user input on bouncer UIs. */ /** Holds UI state and handles user input on bouncer UIs. */
class BouncerViewModel class BouncerViewModel
@@ -40,16 +45,42 @@ constructor(
) { ) {
private val interactor: BouncerInteractor = interactorFactory.create(containerName) 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 { private val pin: PinBouncerViewModel by lazy {
PinBouncerViewModel( PinBouncerViewModel(
applicationScope = applicationScope, applicationScope = applicationScope,
interactor = interactor, interactor = interactor,
isInputEnabled = isInputEnabled,
) )
} }
private val password: PasswordBouncerViewModel by lazy { private val password: PasswordBouncerViewModel by lazy {
PasswordBouncerViewModel( PasswordBouncerViewModel(
interactor = interactor, interactor = interactor,
isInputEnabled = isInputEnabled,
) )
} }
@@ -58,6 +89,7 @@ constructor(
applicationContext = applicationContext, applicationContext = applicationContext,
applicationScope = applicationScope, applicationScope = applicationScope,
interactor = interactor, interactor = interactor,
isInputEnabled = isInputEnabled,
) )
} }
@@ -81,11 +113,59 @@ constructor(
initialValue = interactor.message.value ?: "", 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. */ /** Notifies that the emergency services button was clicked. */
fun onEmergencyServicesButtonClicked() { fun onEmergencyServicesButtonClicked() {
// TODO(b/280877228): implement this // TODO(b/280877228): implement this
} }
/** Notifies that a throttling dialog has been dismissed by the user. */
fun onThrottlingDialogDismissed() {
_throttlingDialogMessage.value = null
}
private fun toViewModel( private fun toViewModel(
authMethod: AuthenticationMethodModel, authMethod: AuthenticationMethodModel,
): AuthMethodBouncerViewModel? { ): AuthMethodBouncerViewModel? {

View File

@@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.asStateFlow
/** Holds UI state and handles user input for the password bouncer UI. */ /** Holds UI state and handles user input for the password bouncer UI. */
class PasswordBouncerViewModel( class PasswordBouncerViewModel(
private val interactor: BouncerInteractor, private val interactor: BouncerInteractor,
override val isInputEnabled: StateFlow<Boolean>,
) : AuthMethodBouncerViewModel { ) : AuthMethodBouncerViewModel {
private val _password = MutableStateFlow("") private val _password = MutableStateFlow("")

View File

@@ -37,6 +37,7 @@ class PatternBouncerViewModel(
private val applicationContext: Context, private val applicationContext: Context,
applicationScope: CoroutineScope, applicationScope: CoroutineScope,
private val interactor: BouncerInteractor, private val interactor: BouncerInteractor,
override val isInputEnabled: StateFlow<Boolean>,
) : AuthMethodBouncerViewModel { ) : AuthMethodBouncerViewModel {
/** The number of columns in the dot grid. */ /** The number of columns in the dot grid. */
@@ -63,6 +64,16 @@ class PatternBouncerViewModel(
/** All dots on the grid. */ /** All dots on the grid. */
val dots: StateFlow<List<PatternDotViewModel>> = _dots.asStateFlow() 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. */ /** Notifies that the UI has been shown to the user. */
fun onShown() { fun onShown() {
interactor.resetMessage() interactor.resetMessage()
@@ -146,6 +157,10 @@ class PatternBouncerViewModel(
_selectedDots.value = linkedSetOf() _selectedDots.value = linkedSetOf()
} }
private fun isPatternVisible(authMethodModel: AuthenticationMethodModel): Boolean {
return (authMethodModel as? AuthenticationMethodModel.Pattern)?.isPatternVisible ?: false
}
private fun defaultDots(): List<PatternDotViewModel> { private fun defaultDots(): List<PatternDotViewModel> {
return buildList { return buildList {
(0 until columnCount).forEach { x -> (0 until columnCount).forEach { x ->

View File

@@ -33,6 +33,7 @@ import kotlinx.coroutines.launch
class PinBouncerViewModel( class PinBouncerViewModel(
private val applicationScope: CoroutineScope, private val applicationScope: CoroutineScope,
private val interactor: BouncerInteractor, private val interactor: BouncerInteractor,
override val isInputEnabled: StateFlow<Boolean>,
) : AuthMethodBouncerViewModel { ) : AuthMethodBouncerViewModel {
private val entered = MutableStateFlow<List<Int>>(emptyList()) 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 androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase import com.android.systemui.SysuiTestCase
import com.android.systemui.authentication.shared.model.AuthenticationMethodModel 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.coroutines.collectLastValue
import com.android.systemui.scene.SceneTestUtils import com.android.systemui.scene.SceneTestUtils
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@@ -40,13 +44,12 @@ class BouncerViewModelTest : SysuiTestCase() {
utils.authenticationInteractor( utils.authenticationInteractor(
repository = utils.authenticationRepository(), repository = utils.authenticationRepository(),
) )
private val underTest = private val bouncerInteractor =
utils.bouncerViewModel( utils.bouncerInteractor(
utils.bouncerInteractor( authenticationInteractor = authenticationInteractor,
authenticationInteractor = authenticationInteractor, sceneInteractor = utils.sceneInteractor(),
sceneInteractor = utils.sceneInteractor(),
)
) )
private val underTest = utils.bouncerViewModel(bouncerInteractor)
@Test @Test
fun authMethod_nonNullForSecureMethods_nullForNotSecureMethods() = fun authMethod_nonNullForSecureMethods_nullForNotSecureMethods() =
@@ -89,6 +92,65 @@ class BouncerViewModelTest : SysuiTestCase() {
.isEqualTo(AuthenticationMethodModel::class.sealedSubclasses.toSet()) .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> { private fun authMethodsToTest(): List<AuthenticationMethodModel> {
return listOf( return listOf(
AuthenticationMethodModel.None, 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.android.systemui.scene.shared.model.SceneModel
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
@@ -57,6 +59,7 @@ class PasswordBouncerViewModelTest : SysuiTestCase() {
private val underTest = private val underTest =
PasswordBouncerViewModel( PasswordBouncerViewModel(
interactor = bouncerInteractor, interactor = bouncerInteractor,
isInputEnabled = MutableStateFlow(true).asStateFlow(),
) )
@Before @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.assertThat
import com.google.common.truth.Truth.assertWithMessage import com.google.common.truth.Truth.assertWithMessage
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
@@ -60,6 +62,7 @@ class PatternBouncerViewModelTest : SysuiTestCase() {
applicationContext = context, applicationContext = context,
applicationScope = testScope.backgroundScope, applicationScope = testScope.backgroundScope,
interactor = bouncerInteractor, interactor = bouncerInteractor,
isInputEnabled = MutableStateFlow(true).asStateFlow(),
) )
@Before @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.android.systemui.scene.shared.model.SceneModel
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -68,6 +70,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
PinBouncerViewModel( PinBouncerViewModel(
applicationScope = testScope.backgroundScope, applicationScope = testScope.backgroundScope,
interactor = bouncerInteractor, interactor = bouncerInteractor,
isInputEnabled = MutableStateFlow(true).asStateFlow(),
) )
@Before @Before