[flexiglass] Bouncer throttling - composables.

Compose rendering logic for throttling input entry on the
bouncer when the user enters wrong input too many times.

Bug: 280877228
Test: unit tests
Test: manually verified in PIN, pattern, and password that entering the
wrong input 5, 10, 15, or any number above 15, times shows the
throttling dialog.
Test: manually verified that the throttling dialog cannot be dismissed
without touching its "Ok" button (tapping outside or hitting back don't
dismiss it)
Test: manually verified that input on the bouncer is disabled as the
message is showing the countdown for 30 seconds.
Test: manually verified that after the 30 second countdown, the input
is enabled again and entering the correct input unlocks Flexiglass.

Change-Id: I7f5b8b7e572c4fe00f3315ccb957f036ed9e8d29
This commit is contained in:
Alejandro Nijamkin
2023-05-17 16:02:01 -07:00
parent 4b5abe26d4
commit 33ddd9ab17
10 changed files with 174 additions and 83 deletions

View File

@@ -16,6 +16,7 @@
package com.android.systemui.scene.ui.composable
import android.content.Context
import com.android.systemui.bouncer.ui.composable.BouncerScene
import com.android.systemui.bouncer.ui.viewmodel.BouncerViewModel
import com.android.systemui.dagger.SysUISingleton
@@ -28,6 +29,7 @@ import com.android.systemui.scene.shared.model.Scene
import com.android.systemui.scene.shared.model.SceneContainerNames
import com.android.systemui.shade.ui.composable.ShadeScene
import com.android.systemui.shade.ui.viewmodel.ShadeSceneViewModel
import com.android.systemui.statusbar.phone.SystemUIDialog
import dagger.Module
import dagger.Provides
import javax.inject.Named
@@ -57,6 +59,7 @@ object SceneModule {
@SysUISingleton
@Named(SceneContainerNames.SYSTEM_UI_DEFAULT)
fun bouncerScene(
@Application context: Context,
viewModelFactory: BouncerViewModel.Factory,
): BouncerScene {
return BouncerScene(
@@ -64,6 +67,7 @@ object SceneModule {
viewModelFactory.create(
containerName = SceneContainerNames.SYSTEM_UI_DEFAULT,
),
dialogFactory = { SystemUIDialog(context) },
)
}

View File

@@ -14,9 +14,16 @@
* limitations under the License.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package com.android.systemui.bouncer.ui.composable
import android.app.AlertDialog
import android.app.Dialog
import android.content.DialogInterface
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -26,15 +33,20 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.android.systemui.R
import com.android.systemui.bouncer.ui.viewmodel.AuthMethodBouncerViewModel
import com.android.systemui.bouncer.ui.viewmodel.BouncerViewModel
import com.android.systemui.bouncer.ui.viewmodel.PasswordBouncerViewModel
@@ -51,6 +63,7 @@ import kotlinx.coroutines.flow.asStateFlow
/** The bouncer scene displays authentication challenges like PIN, password, or pattern. */
class BouncerScene(
private val viewModel: BouncerViewModel,
private val dialogFactory: () -> AlertDialog,
) : ComposableScene {
override val key = SceneKey.Bouncer
@@ -68,16 +81,19 @@ class BouncerScene(
override fun Content(
containerName: String,
modifier: Modifier,
) = BouncerScene(viewModel, modifier)
) = BouncerScene(viewModel, dialogFactory, modifier)
}
@Composable
private fun BouncerScene(
viewModel: BouncerViewModel,
dialogFactory: () -> AlertDialog,
modifier: Modifier = Modifier,
) {
val message: String by viewModel.message.collectAsState()
val message: BouncerViewModel.MessageViewModel by viewModel.message.collectAsState()
val authMethodViewModel: AuthMethodBouncerViewModel? by viewModel.authMethod.collectAsState()
val dialogMessage: String? by viewModel.throttlingDialogMessage.collectAsState()
var dialog: Dialog? by remember { mutableStateOf(null) }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
@@ -88,9 +104,10 @@ private fun BouncerScene(
Crossfade(
targetState = message,
label = "Bouncer message",
) {
animationSpec = if (message.isUpdateAnimated) tween() else snap(),
) { message ->
Text(
text = it,
text = message.text,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyLarge,
)
@@ -132,5 +149,26 @@ private fun BouncerScene(
style = MaterialTheme.typography.bodyMedium,
)
}
if (dialogMessage != null) {
if (dialog == null) {
dialog =
dialogFactory().apply {
setMessage(dialogMessage)
setButton(
DialogInterface.BUTTON_NEUTRAL,
context.getString(R.string.ok),
) { _, _ ->
viewModel.onThrottlingDialogDismissed()
}
setCancelable(false)
setCanceledOnTouchOutside(false)
show()
}
}
} else {
dialog?.dismiss()
dialog = null
}
}
}

View File

@@ -53,6 +53,7 @@ internal fun PasswordBouncer(
) {
val focusRequester = remember { FocusRequester() }
val password: String by viewModel.password.collectAsState()
val isInputEnabled: Boolean by viewModel.isInputEnabled.collectAsState()
LaunchedEffect(Unit) {
// When the UI comes up, request focus on the TextField to bring up the software keyboard.
@@ -71,6 +72,7 @@ internal fun PasswordBouncer(
TextField(
value = password,
onValueChange = viewModel::onPasswordInputChanged,
enabled = isInputEnabled,
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center),

View File

@@ -44,6 +44,7 @@ import androidx.compose.ui.unit.dp
import com.android.internal.R
import com.android.systemui.bouncer.ui.viewmodel.PatternBouncerViewModel
import com.android.systemui.bouncer.ui.viewmodel.PatternDotViewModel
import com.android.systemui.compose.modifiers.thenIf
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.sqrt
@@ -82,6 +83,8 @@ internal fun PatternBouncer(
val currentDot: PatternDotViewModel? by viewModel.currentDot.collectAsState()
// The dots selected so far, if the user is currently dragging.
val selectedDots: List<PatternDotViewModel> by viewModel.selectedDots.collectAsState()
val isInputEnabled: Boolean by viewModel.isInputEnabled.collectAsState()
val isAnimationEnabled: Boolean by viewModel.isPatternVisible.collectAsState()
// Map of animatables for the scale of each dot, keyed by dot.
val dotScalingAnimatables = remember(dots) { dots.associateWith { Animatable(1f) } }
@@ -96,16 +99,24 @@ internal fun PatternBouncer(
val view = LocalView.current
// When the current dot is changed, we need to update our animations.
LaunchedEffect(currentDot) {
LaunchedEffect(currentDot, isAnimationEnabled) {
view.performHapticFeedback(
HapticFeedbackConstants.VIRTUAL_KEY,
HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING,
)
// Make sure that the current dot is scaled up while the other dots are scaled back down.
if (!isAnimationEnabled) {
return@LaunchedEffect
}
// Make sure that the current dot is scaled up while the other dots are scaled back
// down.
dotScalingAnimatables.entries.forEach { (dot, animatable) ->
val isSelected = dot == currentDot
launch {
// Launch using the longer-lived scope because we want these animations to proceed to
// completion even if the LaunchedEffect is canceled because its key objects have
// changed.
scope.launch {
animatable.animateTo(if (isSelected) 2f else 1f)
if (isSelected) {
animatable.animateTo(1f)
@@ -116,14 +127,18 @@ internal fun PatternBouncer(
selectedDots.forEach { dot ->
lineFadeOutAnimatables[dot]?.let { line ->
if (!line.isRunning) {
// Launch using the longer-lived scope because we want these animations to
// proceed to completion even if the LaunchedEffect is canceled because its key
// objects have changed.
scope.launch {
if (dot == currentDot) {
// Reset the fade-out animation for the current dot. When the current
// dot is switched, this entire code block runs again for the newly
// selected dot.
// Reset the fade-out animation for the current dot. When the
// current dot is switched, this entire code block runs again for
// the newly selected dot.
line.snapTo(1f)
} else {
// For all non-current dots, make sure that the lines are fading out.
// For all non-current dots, make sure that the lines are fading
// out.
line.animateTo(
targetValue = 0f,
animationSpec =
@@ -148,27 +163,34 @@ internal fun PatternBouncer(
// when it leaves the bounds of the dot grid.
.clipToBounds()
.onSizeChanged { containerSize = it }
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { start ->
inputPosition = start
viewModel.onDragStart()
},
onDragEnd = {
inputPosition = null
lineFadeOutAnimatables.values.forEach { animatable ->
scope.launch { animatable.animateTo(1f) }
}
viewModel.onDragEnd()
},
) { change, _ ->
inputPosition = change.position
viewModel.onDrag(
xPx = change.position.x,
yPx = change.position.y,
containerSizePx = containerSize.width,
verticalOffsetPx = verticalOffset,
)
.thenIf(isInputEnabled) {
Modifier.pointerInput(Unit) {
detectDragGestures(
onDragStart = { start ->
inputPosition = start
viewModel.onDragStart()
},
onDragEnd = {
inputPosition = null
if (isAnimationEnabled) {
lineFadeOutAnimatables.values.forEach { animatable ->
// Launch using the longer-lived scope because we want these
// animations to proceed to completion even if the surrounding
// scope is canceled.
scope.launch { animatable.animateTo(1f) }
}
}
viewModel.onDragEnd()
},
) { change, _ ->
inputPosition = change.position
viewModel.onDrag(
xPx = change.position.x,
yPx = change.position.y,
containerSizePx = containerSize.width,
verticalOffsetPx = verticalOffset,
)
}
}
}
) {

View File

@@ -63,6 +63,7 @@ import com.android.systemui.bouncer.ui.viewmodel.PinBouncerViewModel
import com.android.systemui.common.shared.model.ContentDescription
import com.android.systemui.common.shared.model.Icon
import com.android.systemui.common.ui.compose.Icon
import com.android.systemui.compose.modifiers.thenIf
import kotlin.math.max
@Composable
@@ -75,6 +76,7 @@ internal fun PinBouncer(
// The length of the PIN input received so far, so we know how many dots to render.
val pinLength: Pair<Int, Int> by viewModel.pinLengths.collectAsState()
val isInputEnabled: Boolean by viewModel.isInputEnabled.collectAsState()
Column(
horizontalAlignment = Alignment.CenterHorizontally,
@@ -116,6 +118,7 @@ internal fun PinBouncer(
val digit = index + 1
PinButton(
onClicked = { viewModel.onPinButtonClicked(digit) },
isEnabled = isInputEnabled,
) { contentColor ->
PinDigit(digit, contentColor)
}
@@ -124,6 +127,7 @@ internal fun PinBouncer(
PinButton(
onClicked = { viewModel.onBackspaceButtonClicked() },
onLongPressed = { viewModel.onBackspaceButtonLongPressed() },
isEnabled = isInputEnabled,
isHighlighted = true,
) { contentColor ->
PinIcon(
@@ -138,6 +142,7 @@ internal fun PinBouncer(
PinButton(
onClicked = { viewModel.onPinButtonClicked(0) },
isEnabled = isInputEnabled,
) { contentColor ->
PinDigit(0, contentColor)
}
@@ -145,6 +150,7 @@ internal fun PinBouncer(
PinButton(
onClicked = { viewModel.onAuthenticateButtonClicked() },
isHighlighted = true,
isEnabled = isInputEnabled,
) { contentColor ->
PinIcon(
Icon.Resource(
@@ -187,6 +193,7 @@ private fun PinIcon(
@Composable
private fun PinButton(
onClicked: () -> Unit,
isEnabled: Boolean,
modifier: Modifier = Modifier,
onLongPressed: (() -> Unit)? = null,
isHighlighted: Boolean = false,
@@ -228,16 +235,18 @@ private fun PinButton(
cornerRadius = CornerRadius(cornerRadius.toPx()),
)
}
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed = true
tryAwaitRelease()
isPressed = false
},
onTap = { onClicked() },
onLongPress = onLongPressed?.let { { onLongPressed() } },
)
.thenIf(isEnabled) {
Modifier.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed = true
tryAwaitRelease()
isPressed = false
},
onTap = { onClicked() },
onLongPress = onLongPressed?.let { { onLongPressed() } },
)
}
},
) {
content(contentColor)

View File

@@ -20,6 +20,7 @@ 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.bouncer.shared.model.AuthenticationThrottledModel
import com.android.systemui.dagger.qualifiers.Application
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@@ -29,6 +30,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
@@ -45,21 +47,6 @@ 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 }
@@ -104,13 +91,21 @@ constructor(
)
/** The user-facing message to show in the bouncer. */
val message: StateFlow<String> =
interactor.message
.map { it ?: "" }
val message: StateFlow<MessageViewModel> =
combine(
interactor.message,
interactor.throttling,
) { message, throttling ->
toMessageViewModel(message, throttling)
}
.stateIn(
scope = applicationScope,
started = SharingStarted.WhileSubscribed(),
initialValue = interactor.message.value ?: "",
initialValue =
toMessageViewModel(
message = interactor.message.value,
throttling = interactor.throttling.value,
),
)
private val _throttlingDialogMessage = MutableStateFlow<String?>(null)
@@ -177,6 +172,28 @@ constructor(
}
}
private fun toMessageViewModel(
message: String?,
throttling: AuthenticationThrottledModel?,
): MessageViewModel {
return MessageViewModel(
text = message ?: "",
isUpdateAnimated = throttling == null,
)
}
data class MessageViewModel(
val text: String,
/**
* 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 isUpdateAnimated: Boolean,
)
@AssistedFactory
interface Factory {
fun create(

View File

@@ -93,22 +93,21 @@ class BouncerViewModelTest : SysuiTestCase() {
}
@Test
fun isMessageUpdateAnimationsEnabled() =
fun message() =
testScope.runTest {
val isMessageUpdateAnimationsEnabled by
collectLastValue(underTest.isMessageUpdateAnimationsEnabled)
val message by collectLastValue(underTest.message)
val throttling by collectLastValue(bouncerInteractor.throttling)
authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234))
assertThat(isMessageUpdateAnimationsEnabled).isTrue()
assertThat(message?.isUpdateAnimated).isTrue()
repeat(BouncerInteractor.THROTTLE_EVERY) {
// Wrong PIN.
bouncerInteractor.authenticate(listOf(3, 4, 5, 6))
}
assertThat(isMessageUpdateAnimationsEnabled).isFalse()
assertThat(message?.isUpdateAnimated).isFalse()
throttling?.totalDurationSec?.let { seconds -> advanceTimeBy(seconds * 1000L) }
assertThat(isMessageUpdateAnimationsEnabled).isTrue()
assertThat(message?.isUpdateAnimated).isTrue()
}
@Test

View File

@@ -85,7 +85,7 @@ class PasswordBouncerViewModelTest : SysuiTestCase() {
underTest.onShown()
assertThat(message).isEqualTo(ENTER_YOUR_PASSWORD)
assertThat(message?.text).isEqualTo(ENTER_YOUR_PASSWORD)
assertThat(password).isEqualTo("")
assertThat(isUnlocked).isFalse()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
@@ -109,7 +109,7 @@ class PasswordBouncerViewModelTest : SysuiTestCase() {
underTest.onPasswordInputChanged("password")
assertThat(message).isEmpty()
assertThat(message?.text).isEmpty()
assertThat(password).isEqualTo("password")
assertThat(isUnlocked).isFalse()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
@@ -156,7 +156,7 @@ class PasswordBouncerViewModelTest : SysuiTestCase() {
underTest.onAuthenticateKeyPressed()
assertThat(password).isEqualTo("")
assertThat(message).isEqualTo(WRONG_PASSWORD)
assertThat(message?.text).isEqualTo(WRONG_PASSWORD)
assertThat(isUnlocked).isFalse()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
}
@@ -179,13 +179,13 @@ class PasswordBouncerViewModelTest : SysuiTestCase() {
underTest.onPasswordInputChanged("wrong")
underTest.onAuthenticateKeyPressed()
assertThat(password).isEqualTo("")
assertThat(message).isEqualTo(WRONG_PASSWORD)
assertThat(message?.text).isEqualTo(WRONG_PASSWORD)
assertThat(isUnlocked).isFalse()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
// Enter the correct password:
underTest.onPasswordInputChanged("password")
assertThat(message).isEmpty()
assertThat(message?.text).isEmpty()
underTest.onAuthenticateKeyPressed()

View File

@@ -89,7 +89,7 @@ class PatternBouncerViewModelTest : SysuiTestCase() {
underTest.onShown()
assertThat(message).isEqualTo(ENTER_YOUR_PATTERN)
assertThat(message?.text).isEqualTo(ENTER_YOUR_PATTERN)
assertThat(selectedDots).isEmpty()
assertThat(currentDot).isNull()
assertThat(isUnlocked).isFalse()
@@ -115,7 +115,7 @@ class PatternBouncerViewModelTest : SysuiTestCase() {
underTest.onDragStart()
assertThat(message).isEmpty()
assertThat(message?.text).isEmpty()
assertThat(selectedDots).isEmpty()
assertThat(currentDot).isNull()
assertThat(isUnlocked).isFalse()
@@ -202,7 +202,7 @@ class PatternBouncerViewModelTest : SysuiTestCase() {
assertThat(selectedDots).isEmpty()
assertThat(currentDot).isNull()
assertThat(message).isEqualTo(WRONG_PATTERN)
assertThat(message?.text).isEqualTo(WRONG_PATTERN)
assertThat(isUnlocked).isFalse()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
}
@@ -235,7 +235,7 @@ class PatternBouncerViewModelTest : SysuiTestCase() {
underTest.onDragEnd()
assertThat(selectedDots).isEmpty()
assertThat(currentDot).isNull()
assertThat(message).isEqualTo(WRONG_PATTERN)
assertThat(message?.text).isEqualTo(WRONG_PATTERN)
assertThat(isUnlocked).isFalse()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))

View File

@@ -94,7 +94,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
underTest.onShown()
assertThat(message).isEqualTo(ENTER_YOUR_PIN)
assertThat(message?.text).isEqualTo(ENTER_YOUR_PIN)
assertThat(pinLengths).isEqualTo(0 to 0)
assertThat(isUnlocked).isFalse()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
@@ -116,7 +116,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
underTest.onPinButtonClicked(1)
assertThat(message).isEmpty()
assertThat(message?.text).isEmpty()
assertThat(pinLengths).isEqualTo(0 to 1)
assertThat(isUnlocked).isFalse()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
@@ -140,7 +140,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
underTest.onBackspaceButtonClicked()
assertThat(message).isEmpty()
assertThat(message?.text).isEmpty()
assertThat(pinLengths).isEqualTo(1 to 0)
assertThat(isUnlocked).isFalse()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
@@ -170,7 +170,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
advanceTimeBy(PinBouncerViewModel.BACKSPACE_LONG_PRESS_DELAY_MS)
}
assertThat(message).isEmpty()
assertThat(message?.text).isEmpty()
assertThat(pinLengths).isEqualTo(1 to 0)
assertThat(isUnlocked).isFalse()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
@@ -220,7 +220,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
underTest.onAuthenticateButtonClicked()
assertThat(pinLengths).isEqualTo(0 to 0)
assertThat(message).isEqualTo(WRONG_PIN)
assertThat(message?.text).isEqualTo(WRONG_PIN)
assertThat(isUnlocked).isFalse()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
}
@@ -244,7 +244,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
underTest.onPinButtonClicked(4)
underTest.onPinButtonClicked(5) // PIN is now wrong!
underTest.onAuthenticateButtonClicked()
assertThat(message).isEqualTo(WRONG_PIN)
assertThat(message?.text).isEqualTo(WRONG_PIN)
assertThat(pinLengths).isEqualTo(0 to 0)
assertThat(isUnlocked).isFalse()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
@@ -254,7 +254,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
underTest.onPinButtonClicked(2)
underTest.onPinButtonClicked(3)
underTest.onPinButtonClicked(4)
assertThat(message).isEmpty()
assertThat(message?.text).isEmpty()
underTest.onAuthenticateButtonClicked()