From 1b00a65478721950ca82dcf9ccfdab0d302791d7 Mon Sep 17 00:00:00 2001 From: Alejandro Nijamkin Date: Fri, 19 May 2023 11:14:05 -0700 Subject: [PATCH] [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 --- .../bouncer/ui/composable/PasswordBouncer.kt | 8 ++ .../bouncer/ui/composable/PatternBouncer.kt | 67 +++++++++++++++ .../bouncer/ui/composable/PinBouncer.kt | 9 +++ .../domain/interactor/BouncerInteractor.kt | 11 ++- .../viewmodel/AuthMethodBouncerViewModel.kt | 27 ++++++- .../ui/viewmodel/PasswordBouncerViewModel.kt | 12 ++- .../ui/viewmodel/PatternBouncerViewModel.kt | 13 ++- .../ui/viewmodel/PinBouncerViewModel.kt | 12 ++- .../interactor/BouncerInteractorTest.kt | 25 +++--- .../AuthMethodBouncerViewModelTest.kt | 81 +++++++++++++++++++ 10 files changed, 241 insertions(+), 24 deletions(-) create mode 100644 packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModelTest.kt diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PasswordBouncer.kt b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PasswordBouncer.kt index 01346c7170dae..7545ff464bab0 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PasswordBouncer.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PasswordBouncer.kt @@ -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, diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PatternBouncer.kt b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PatternBouncer.kt index e8051bcb331be..88441146ad23e 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PatternBouncer.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PatternBouncer.kt @@ -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 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, + scalingAnimatables: Map>, +) { + val dotsByRow = + buildList> { + 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 diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt index 2ca78b1ad1956..968e5ab8ad8c4 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt @@ -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 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, diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractor.kt b/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractor.kt index e462e2f5b7d8c..1d2fce7d8b05f 100644 --- a/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractor.kt +++ b/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractor.kt @@ -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, - ) { + ): 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 { diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModel.kt index 774a5593530ca..d95b70c85fe0f 100644 --- a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModel.kt @@ -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 + val isInputEnabled: StateFlow, +) { + + 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 = _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 + } } diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModel.kt index c38fcaa3b657e..55929b566cf1e 100644 --- a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModel.kt @@ -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, -) : AuthMethodBouncerViewModel { + isInputEnabled: StateFlow, +) : + 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 = "" } } diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModel.kt index 1b0b38ea6e9cf..d9ef75db6103b 100644 --- a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModel.kt @@ -37,8 +37,11 @@ class PatternBouncerViewModel( private val applicationContext: Context, applicationScope: CoroutineScope, private val interactor: BouncerInteractor, - override val isInputEnabled: StateFlow, -) : AuthMethodBouncerViewModel { + isInputEnabled: StateFlow, +) : + 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 diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModel.kt index 2a733d93b857f..5c0fd92e72997 100644 --- a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModel.kt @@ -33,8 +33,11 @@ import kotlinx.coroutines.launch class PinBouncerViewModel( private val applicationScope: CoroutineScope, private val interactor: BouncerInteractor, - override val isInputEnabled: StateFlow, -) : AuthMethodBouncerViewModel { + isInputEnabled: StateFlow, +) : + AuthMethodBouncerViewModel( + isInputEnabled = isInputEnabled, + ) { private val entered = MutableStateFlow>(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() } diff --git a/packages/SystemUI/tests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt index 9f5c181c31293..374c28d6dce88 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt @@ -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() } diff --git a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModelTest.kt new file mode 100644 index 0000000000000..1642410e5a3fb --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModelTest.kt @@ -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() + } +}