[flexiglass] Failure animations for bouncer UIs.

- Adds a generic failure animation system to AuthMethodBouncerViewModel
  that all subclasses can easily use
- Introduces a use-case for it in PatternBouncerViewModel and a stub in
  PinBouncerViewModel for later implementation

Bug: 281871687
Test: Added unit tests for the failure animation UI state and logic in
AuthMethodBouncerViewModel, using PinBouncerViewModel as the underTest
instance because AuthMethodBouncerViewModel is a sealed class so it
cannot be extended, even for tests.
Test: manually/visually verified the staggered failuew animation in the
pattern bouncer UI. Please see b/281871687#comment4 for a video
recording.

Change-Id: I4d4d81e4d6e32943ae2fc715188e0f62dc28111d
This commit is contained in:
Alejandro Nijamkin
2023-05-19 11:14:05 -07:00
parent 70f9b87deb
commit 1b00a65478
10 changed files with 241 additions and 24 deletions

View File

@@ -54,6 +54,7 @@ internal fun PasswordBouncer(
val focusRequester = remember { FocusRequester() }
val password: String by viewModel.password.collectAsState()
val isInputEnabled: Boolean by viewModel.isInputEnabled.collectAsState()
val animateFailure: Boolean by viewModel.animateFailure.collectAsState()
LaunchedEffect(Unit) {
// When the UI comes up, request focus on the TextField to bring up the software keyboard.
@@ -62,6 +63,13 @@ internal fun PasswordBouncer(
viewModel.onShown()
}
LaunchedEffect(animateFailure) {
if (animateFailure) {
// We don't currently have a failure animation for password, just consume it:
viewModel.onFailureAnimationShown()
}
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier,

View File

@@ -18,6 +18,7 @@ package com.android.systemui.bouncer.ui.composable
import android.view.HapticFeedbackConstants
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectDragGestures
@@ -49,6 +50,7 @@ import com.android.systemui.compose.modifiers.thenIf
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.sqrt
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
/**
@@ -86,6 +88,7 @@ internal fun PatternBouncer(
val selectedDots: List<PatternDotViewModel> by viewModel.selectedDots.collectAsState()
val isInputEnabled: Boolean by viewModel.isInputEnabled.collectAsState()
val isAnimationEnabled: Boolean by viewModel.isPatternVisible.collectAsState()
val animateFailure: Boolean by viewModel.animateFailure.collectAsState()
// Map of animatables for the scale of each dot, keyed by dot.
val dotScalingAnimatables = remember(dots) { dots.associateWith { Animatable(1f) } }
@@ -174,6 +177,17 @@ internal fun PatternBouncer(
}
}
// Show the failure animation if the user entered the wrong input.
LaunchedEffect(animateFailure) {
if (animateFailure) {
showFailureAnimation(
dots = dots,
scalingAnimatables = dotScalingAnimatables,
)
viewModel.onFailureAnimationShown()
}
}
// This is the position of the input pointer.
var inputPosition: Offset? by remember { mutableStateOf(null) }
@@ -290,8 +304,61 @@ private fun lineAlpha(gridSpacing: Float, lineLength: Float = gridSpacing): Floa
return ((lineLength / gridSpacing - 0.3f) * 4f).coerceIn(0f, 1f)
}
private suspend fun showFailureAnimation(
dots: List<PatternDotViewModel>,
scalingAnimatables: Map<PatternDotViewModel, Animatable<Float, AnimationVector1D>>,
) {
val dotsByRow =
buildList<MutableList<PatternDotViewModel>> {
dots.forEach { dot ->
val rowIndex = dot.y
while (size <= rowIndex) {
add(mutableListOf())
}
get(rowIndex).add(dot)
}
}
coroutineScope {
dotsByRow.forEachIndexed { rowIndex, rowDots ->
rowDots.forEach { dot ->
scalingAnimatables[dot]?.let { dotScaleAnimatable ->
launch {
dotScaleAnimatable.animateTo(
targetValue =
FAILURE_ANIMATION_DOT_DIAMETER_DP / DOT_DIAMETER_DP.toFloat(),
animationSpec =
tween(
durationMillis =
FAILURE_ANIMATION_DOT_SHRINK_ANIMATION_DURATION_MS,
delayMillis =
rowIndex * FAILURE_ANIMATION_DOT_SHRINK_STAGGER_DELAY_MS,
easing = Easings.LinearEasing,
),
)
dotScaleAnimatable.animateTo(
targetValue = 1f,
animationSpec =
tween(
durationMillis =
FAILURE_ANIMATION_DOT_REVERT_ANIMATION_DURATION,
easing = Easings.StandardEasing,
),
)
}
}
}
}
}
}
private const val DOT_DIAMETER_DP = 16
private const val SELECTED_DOT_DIAMETER_DP = 24
private const val SELECTED_DOT_REACTION_ANIMATION_DURATION_MS = 83
private const val SELECTED_DOT_RETRACT_ANIMATION_DURATION_MS = 750
private const val LINE_STROKE_WIDTH_DP = 16
private const val FAILURE_ANIMATION_DOT_DIAMETER_DP = 13
private const val FAILURE_ANIMATION_DOT_SHRINK_ANIMATION_DURATION_MS = 50
private const val FAILURE_ANIMATION_DOT_SHRINK_STAGGER_DELAY_MS = 33
private const val FAILURE_ANIMATION_DOT_REVERT_ANIMATION_DURATION = 617

View File

@@ -89,6 +89,15 @@ 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()
val animateFailure: Boolean by viewModel.animateFailure.collectAsState()
// Show the failure animation if the user entered the wrong input.
LaunchedEffect(animateFailure) {
if (animateFailure) {
showFailureAnimation()
viewModel.onFailureAnimationShown()
}
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,

View File

@@ -148,12 +148,17 @@ constructor(
*
* If the input is correct, the device will be unlocked and the lock screen and bouncer will be
* dismissed and hidden.
*
* @param input The input from the user to try to authenticate with. This can be a list of
* different things, based on the current authentication method.
* @return `true` if the authentication succeeded and the device is now unlocked; `false`
* otherwise.
*/
fun authenticate(
input: List<Any>,
) {
): Boolean {
if (repository.throttling.value != null) {
return
return false
}
val isAuthenticated = authenticationInteractor.authenticate(input)
@@ -186,6 +191,8 @@ constructor(
}
else -> repository.setMessage(errorMessage(authenticationMethod.value))
}
return isAuthenticated
}
private fun promptMessage(authMethod: AuthenticationMethodModel): String {

View File

@@ -16,14 +16,37 @@
package com.android.systemui.bouncer.ui.viewmodel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
sealed interface AuthMethodBouncerViewModel {
sealed class 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>
val isInputEnabled: StateFlow<Boolean>,
) {
private val _animateFailure = MutableStateFlow(false)
/**
* Whether a failure animation should be shown. Once consumed, the UI must call
* [onFailureAnimationShown] to consume this state.
*/
val animateFailure: StateFlow<Boolean> = _animateFailure.asStateFlow()
/**
* Notifies that the failure animation has been shown. This should be called to consume a `true`
* value in [animateFailure].
*/
fun onFailureAnimationShown() {
_animateFailure.value = false
}
/** Ask the UI to show the failure animation. */
protected fun showFailureAnimation() {
_animateFailure.value = true
}
}

View File

@@ -24,8 +24,11 @@ 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 {
isInputEnabled: StateFlow<Boolean>,
) :
AuthMethodBouncerViewModel(
isInputEnabled = isInputEnabled,
) {
private val _password = MutableStateFlow("")
/** The password entered so far. */
@@ -47,7 +50,10 @@ class PasswordBouncerViewModel(
/** Notifies that the user has pressed the key for attempting to authenticate the password. */
fun onAuthenticateKeyPressed() {
interactor.authenticate(password.value.toCharArray().toList())
if (!interactor.authenticate(password.value.toCharArray().toList())) {
showFailureAnimation()
}
_password.value = ""
}
}

View File

@@ -37,8 +37,11 @@ class PatternBouncerViewModel(
private val applicationContext: Context,
applicationScope: CoroutineScope,
private val interactor: BouncerInteractor,
override val isInputEnabled: StateFlow<Boolean>,
) : AuthMethodBouncerViewModel {
isInputEnabled: StateFlow<Boolean>,
) :
AuthMethodBouncerViewModel(
isInputEnabled = isInputEnabled,
) {
/** The number of columns in the dot grid. */
val columnCount = 3
@@ -150,7 +153,11 @@ class PatternBouncerViewModel(
/** Notifies that the user has ended the drag gesture across the dot grid. */
fun onDragEnd() {
interactor.authenticate(_selectedDots.value.map { it.toCoordinate() })
val isSuccessfullyAuthenticated =
interactor.authenticate(_selectedDots.value.map { it.toCoordinate() })
if (!isSuccessfullyAuthenticated) {
showFailureAnimation()
}
_dots.value = defaultDots()
_currentDot.value = null

View File

@@ -33,8 +33,11 @@ import kotlinx.coroutines.launch
class PinBouncerViewModel(
private val applicationScope: CoroutineScope,
private val interactor: BouncerInteractor,
override val isInputEnabled: StateFlow<Boolean>,
) : AuthMethodBouncerViewModel {
isInputEnabled: StateFlow<Boolean>,
) :
AuthMethodBouncerViewModel(
isInputEnabled = isInputEnabled,
) {
private val entered = MutableStateFlow<List<Int>>(emptyList())
/**
@@ -92,7 +95,10 @@ class PinBouncerViewModel(
/** Notifies that the user clicked the "enter" button. */
fun onAuthenticateButtonClicked() {
interactor.authenticate(entered.value)
if (!interactor.authenticate(entered.value)) {
showFailureAnimation()
}
entered.value = emptyList()
}

View File

@@ -82,7 +82,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PIN)
// Wrong input.
underTest.authenticate(listOf(9, 8, 7))
assertThat(underTest.authenticate(listOf(9, 8, 7))).isFalse()
assertThat(message).isEqualTo(MESSAGE_WRONG_PIN)
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
@@ -90,7 +90,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PIN)
// Correct input.
underTest.authenticate(listOf(1, 2, 3, 4))
assertThat(underTest.authenticate(listOf(1, 2, 3, 4))).isTrue()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone))
}
@@ -114,7 +114,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PASSWORD)
// Wrong input.
underTest.authenticate("alohamora".toList())
assertThat(underTest.authenticate("alohamora".toList())).isFalse()
assertThat(message).isEqualTo(MESSAGE_WRONG_PASSWORD)
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
@@ -122,7 +122,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PASSWORD)
// Correct input.
underTest.authenticate("password".toList())
assertThat(underTest.authenticate("password".toList())).isTrue()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone))
}
@@ -146,9 +146,12 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PATTERN)
// Wrong input.
underTest.authenticate(
listOf(AuthenticationMethodModel.Pattern.PatternCoordinate(3, 4))
)
assertThat(
underTest.authenticate(
listOf(AuthenticationMethodModel.Pattern.PatternCoordinate(3, 4))
)
)
.isFalse()
assertThat(message).isEqualTo(MESSAGE_WRONG_PATTERN)
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
@@ -156,7 +159,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PATTERN)
// Correct input.
underTest.authenticate(emptyList())
assertThat(underTest.authenticate(emptyList())).isTrue()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone))
}
@@ -214,7 +217,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(isUnlocked).isFalse()
repeat(BouncerInteractor.THROTTLE_EVERY) { times ->
// Wrong PIN.
underTest.authenticate(listOf(6, 7, 8, 9))
assertThat(underTest.authenticate(listOf(6, 7, 8, 9))).isFalse()
if (times < BouncerInteractor.THROTTLE_EVERY - 1) {
assertThat(message).isEqualTo(MESSAGE_WRONG_PIN)
}
@@ -223,7 +226,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertTryAgainMessage(message, BouncerInteractor.THROTTLE_DURATION_SEC)
// Correct PIN, but throttled, so doesn't unlock:
underTest.authenticate(listOf(1, 2, 3, 4))
assertThat(underTest.authenticate(listOf(1, 2, 3, 4))).isFalse()
assertThat(isUnlocked).isFalse()
assertTryAgainMessage(message, BouncerInteractor.THROTTLE_DURATION_SEC)
@@ -241,7 +244,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(isUnlocked).isFalse()
// Correct PIN and no longer throttled so unlocks:
underTest.authenticate(listOf(1, 2, 3, 4))
assertThat(underTest.authenticate(listOf(1, 2, 3, 4))).isTrue()
assertThat(isUnlocked).isTrue()
}

View File

@@ -0,0 +1,81 @@
/*
* Copyright (C) 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
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.coroutines.collectLastValue
import com.android.systemui.scene.SceneTestUtils
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
@OptIn(ExperimentalCoroutinesApi::class)
@SmallTest
@RunWith(JUnit4::class)
class AuthMethodBouncerViewModelTest : SysuiTestCase() {
private val testScope = TestScope()
private val utils = SceneTestUtils(this, testScope)
private val authenticationInteractor =
utils.authenticationInteractor(
utils.authenticationRepository(),
)
private val underTest =
PinBouncerViewModel(
applicationScope = testScope.backgroundScope,
interactor =
utils.bouncerInteractor(
authenticationInteractor = authenticationInteractor,
sceneInteractor = utils.sceneInteractor(),
),
isInputEnabled = MutableStateFlow(true),
)
@Test
fun animateFailure() =
testScope.runTest {
authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234))
val animateFailure by collectLastValue(underTest.animateFailure)
assertThat(animateFailure).isFalse()
// Wrong PIN:
underTest.onPinButtonClicked(3)
underTest.onPinButtonClicked(4)
underTest.onPinButtonClicked(5)
underTest.onPinButtonClicked(6)
underTest.onAuthenticateButtonClicked()
assertThat(animateFailure).isTrue()
underTest.onFailureAnimationShown()
assertThat(animateFailure).isFalse()
// Correct PIN:
underTest.onPinButtonClicked(1)
underTest.onPinButtonClicked(2)
underTest.onPinButtonClicked(3)
underTest.onPinButtonClicked(4)
underTest.onAuthenticateButtonClicked()
assertThat(animateFailure).isFalse()
}
}