Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 1b00a654 authored by Alejandro Nijamkin's avatar Alejandro Nijamkin
Browse files

[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
parent 70f9b87d
Loading
Loading
Loading
Loading
+8 −0
Original line number Diff line number Diff line
@@ -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,
+67 −0
Original line number Diff line number Diff line
@@ -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
+9 −0
Original line number Diff line number Diff line
@@ -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,
+9 −2
Original line number Diff line number Diff line
@@ -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 {
+25 −2
Original line number Diff line number Diff line
@@ -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
    }
}
Loading