Merge "[flexiglass] Bouncer throttling - view models." into udc-dev
This commit is contained in:
@@ -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>
|
||||||
|
}
|
||||||
|
|||||||
@@ -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? {
|
||||||
|
|||||||
@@ -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("")
|
||||||
|
|||||||
@@ -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 ->
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user