[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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user