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 f801434999287..323fed0c11f3a 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 @@ -21,11 +21,14 @@ package com.android.systemui.bouncer.ui.composable import android.view.HapticFeedbackConstants import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.AnimationVector1D import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.Transition import androidx.compose.animation.core.animateDp import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.keyframes import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween @@ -58,6 +61,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.LocalView @@ -67,6 +71,7 @@ import androidx.compose.ui.unit.dp import com.android.compose.animation.Easings import com.android.compose.grid.VerticalGrid import com.android.systemui.R +import com.android.systemui.bouncer.ui.viewmodel.ActionButtonAppearance import com.android.systemui.bouncer.ui.viewmodel.EnteredKey import com.android.systemui.bouncer.ui.viewmodel.PinBouncerViewModel import com.android.systemui.common.shared.model.ContentDescription @@ -76,6 +81,7 @@ import com.android.systemui.compose.modifiers.thenIf import kotlin.time.Duration.Companion.milliseconds import kotlin.time.DurationUnit import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -87,78 +93,13 @@ internal fun PinBouncer( // Report that the UI is shown to let the view-model run some logic. LaunchedEffect(Unit) { viewModel.onShown() } - 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, modifier = modifier, ) { PinInputDisplay(viewModel) - Spacer(Modifier.height(100.dp)) - - VerticalGrid( - columns = 3, - verticalSpacing = 12.dp, - horizontalSpacing = 20.dp, - ) { - repeat(9) { index -> - val digit = index + 1 - PinButton( - onClicked = { viewModel.onPinButtonClicked(digit) }, - isEnabled = isInputEnabled, - ) { contentColor -> - PinDigit(digit, contentColor) - } - } - - PinButton( - onClicked = { viewModel.onBackspaceButtonClicked() }, - onLongPressed = { viewModel.onBackspaceButtonLongPressed() }, - isEnabled = isInputEnabled, - isIconButton = true, - ) { contentColor -> - PinIcon( - Icon.Resource( - res = R.drawable.ic_backspace_24dp, - contentDescription = - ContentDescription.Resource(R.string.keyboardview_keycode_delete), - ), - contentColor, - ) - } - - PinButton( - onClicked = { viewModel.onPinButtonClicked(0) }, - isEnabled = isInputEnabled, - ) { contentColor -> - PinDigit(0, contentColor) - } - - PinButton( - onClicked = { viewModel.onAuthenticateButtonClicked() }, - isEnabled = isInputEnabled, - isIconButton = true, - ) { contentColor -> - PinIcon( - Icon.Resource( - res = R.drawable.ic_keyboard_tab_36dp, - contentDescription = - ContentDescription.Resource(R.string.keyboardview_keycode_enter), - ), - contentColor, - ) - } - } + PinPad(viewModel) } } @@ -305,38 +246,153 @@ private fun ObscuredInputEntry(transition: Transition) { } @Composable -private fun PinDigit( +private fun PinPad(viewModel: PinBouncerViewModel) { + val isInputEnabled: Boolean by viewModel.isInputEnabled.collectAsState() + val backspaceButtonAppearance by viewModel.backspaceButtonAppearance.collectAsState() + val confirmButtonAppearance by viewModel.confirmButtonAppearance.collectAsState() + val animateFailure: Boolean by viewModel.animateFailure.collectAsState() + + val buttonScaleAnimatables = remember { List(12) { Animatable(1f) } } + LaunchedEffect(animateFailure) { + // Show the failure animation if the user entered the wrong input. + if (animateFailure) { + showFailureAnimation(buttonScaleAnimatables) + viewModel.onFailureAnimationShown() + } + } + + VerticalGrid( + columns = 3, + verticalSpacing = 12.dp, + horizontalSpacing = 20.dp, + ) { + repeat(9) { index -> + DigitButton( + index + 1, + isInputEnabled, + viewModel::onPinButtonClicked, + buttonScaleAnimatables[index]::value, + ) + } + + ActionButton( + icon = + Icon.Resource( + res = R.drawable.ic_backspace_24dp, + contentDescription = + ContentDescription.Resource(R.string.keyboardview_keycode_delete), + ), + isInputEnabled = isInputEnabled, + onClicked = viewModel::onBackspaceButtonClicked, + onLongPressed = viewModel::onBackspaceButtonLongPressed, + appearance = backspaceButtonAppearance, + scaling = buttonScaleAnimatables[9]::value, + ) + + DigitButton( + 0, + isInputEnabled, + viewModel::onPinButtonClicked, + buttonScaleAnimatables[10]::value, + ) + + ActionButton( + icon = + Icon.Resource( + res = R.drawable.ic_keyboard_tab_36dp, + contentDescription = + ContentDescription.Resource(R.string.keyboardview_keycode_enter), + ), + isInputEnabled = isInputEnabled, + onClicked = viewModel::onAuthenticateButtonClicked, + appearance = confirmButtonAppearance, + scaling = buttonScaleAnimatables[11]::value, + ) + } +} + +@Composable +private fun DigitButton( digit: Int, - contentColor: Color, + isInputEnabled: Boolean, + onClicked: (Int) -> Unit, + scaling: () -> Float, ) { - // TODO(b/281878426): once "color: () -> Color" (added to BasicText in aosp/2568972) makes it - // into Text, use that here, to animate more efficiently. - Text( - text = digit.toString(), - style = MaterialTheme.typography.headlineLarge, - color = contentColor, - ) + PinPadButton( + onClicked = { onClicked(digit) }, + isEnabled = isInputEnabled, + backgroundColor = MaterialTheme.colorScheme.surfaceVariant, + foregroundColor = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = + Modifier.graphicsLayer { + val scale = scaling() + scaleX = scale + scaleY = scale + } + ) { contentColor -> + // TODO(b/281878426): once "color: () -> Color" (added to BasicText in aosp/2568972) makes + // it into Text, use that here, to animate more efficiently. + Text( + text = digit.toString(), + style = MaterialTheme.typography.headlineLarge, + color = contentColor(), + ) + } } @Composable -private fun PinIcon( +private fun ActionButton( icon: Icon, - contentColor: Color, + isInputEnabled: Boolean, + onClicked: () -> Unit, + onLongPressed: (() -> Unit)? = null, + appearance: ActionButtonAppearance, + scaling: () -> Float, ) { - Icon( - icon = icon, - tint = contentColor, - ) + val isHidden = appearance == ActionButtonAppearance.Hidden + val hiddenAlpha by animateFloatAsState(if (isHidden) 0f else 1f, label = "Action button alpha") + + val foregroundColor = + when (appearance) { + ActionButtonAppearance.Shown -> MaterialTheme.colorScheme.onSecondaryContainer + else -> MaterialTheme.colorScheme.onSurface + } + val backgroundColor = + when (appearance) { + ActionButtonAppearance.Shown -> MaterialTheme.colorScheme.secondaryContainer + else -> MaterialTheme.colorScheme.surface + } + + PinPadButton( + onClicked = onClicked, + onLongPressed = onLongPressed, + isEnabled = isInputEnabled && !isHidden, + backgroundColor = backgroundColor, + foregroundColor = foregroundColor, + modifier = + Modifier.graphicsLayer { + alpha = hiddenAlpha + val scale = scaling() + scaleX = scale + scaleY = scale + } + ) { contentColor -> + Icon( + icon = icon, + tint = contentColor(), + ) + } } @Composable -private fun PinButton( +private fun PinPadButton( onClicked: () -> Unit, isEnabled: Boolean, + backgroundColor: Color, + foregroundColor: Color, modifier: Modifier = Modifier, onLongPressed: (() -> Unit)? = null, - isIconButton: Boolean = false, - content: @Composable (contentColor: Color) -> Unit, + content: @Composable (contentColor: () -> Color) -> Unit, ) { var isPressed: Boolean by remember { mutableStateOf(false) } @@ -370,18 +426,16 @@ private fun PinButton( animateColorAsState( when { isPressed -> MaterialTheme.colorScheme.primary - isIconButton -> MaterialTheme.colorScheme.secondaryContainer - else -> MaterialTheme.colorScheme.surfaceVariant + else -> backgroundColor }, label = "Pin button container color", animationSpec = colorAnimationSpec ) - val contentColor: Color by + val contentColor = animateColorAsState( when { isPressed -> MaterialTheme.colorScheme.onPrimary - isIconButton -> MaterialTheme.colorScheme.onSecondaryContainer - else -> MaterialTheme.colorScheme.onSurfaceVariant + else -> foregroundColor }, label = "Pin button container color", animationSpec = colorAnimationSpec @@ -420,17 +474,46 @@ private fun PinButton( } }, ) { - content(contentColor) + content(contentColor::value) } } -private fun showFailureAnimation() { - // TODO(b/282730134): implement. +private suspend fun showFailureAnimation( + buttonScaleAnimatables: List> +) { + coroutineScope { + buttonScaleAnimatables.forEachIndexed { index, animatable -> + launch { + animatable.animateTo( + targetValue = pinButtonErrorShrinkFactor, + animationSpec = + tween( + durationMillis = pinButtonErrorShrinkMs, + delayMillis = index * pinButtonErrorStaggerDelayMs, + easing = Easings.Linear, + ), + ) + + animatable.animateTo( + targetValue = 1f, + animationSpec = + tween( + durationMillis = pinButtonErrorRevertMs, + easing = Easings.Legacy, + ), + ) + } + } + } } private val entryShapeSize = 16.dp private val pinButtonSize = 84.dp +private val pinButtonErrorShrinkFactor = 67.dp / pinButtonSize +private const val pinButtonErrorShrinkMs = 50 +private const val pinButtonErrorStaggerDelayMs = 33 +private const val pinButtonErrorRevertMs = 617 // Pin button motion spec: http://shortn/_9TTIG6SoEa private val pinButtonPressedDuration = 100.milliseconds diff --git a/packages/SystemUI/src/com/android/systemui/authentication/data/repository/AuthenticationRepository.kt b/packages/SystemUI/src/com/android/systemui/authentication/data/repository/AuthenticationRepository.kt index c4ebee2a9197e..0530aed912bc8 100644 --- a/packages/SystemUI/src/com/android/systemui/authentication/data/repository/AuthenticationRepository.kt +++ b/packages/SystemUI/src/com/android/systemui/authentication/data/repository/AuthenticationRepository.kt @@ -77,7 +77,9 @@ class AuthenticationRepositoryImpl @Inject constructor() : AuthenticationReposit override val isUnlocked: StateFlow = _isUnlocked.asStateFlow() private val _authenticationMethod = - MutableStateFlow(AuthenticationMethodModel.Pin(1234)) + MutableStateFlow( + AuthenticationMethodModel.Pin(listOf(1, 2, 3, 4), autoConfirm = false) + ) override val authenticationMethod: StateFlow = _authenticationMethod.asStateFlow() diff --git a/packages/SystemUI/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractor.kt b/packages/SystemUI/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractor.kt index dd9dcbedd6fc8..20e82f70d8f5d 100644 --- a/packages/SystemUI/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractor.kt +++ b/packages/SystemUI/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractor.kt @@ -122,14 +122,36 @@ constructor( /** * Attempts to authenticate the user and unlock the device. * + * If [tryAutoConfirm] is `true`, authentication is attempted if and only if the auth method + * supports auto-confirming, and the input's length is at least the code's length. Otherwise, + * `null` is returned. + * * @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. + * @param tryAutoConfirm `true` if called while the user inputs the code, without an explicit + * request to validate. + * @return `true` if the authentication succeeded and the device is now unlocked; `false` when + * authentication failed, `null` if the check was not performed. */ - fun authenticate(input: List): Boolean { + fun authenticate(input: List, tryAutoConfirm: Boolean = false): Boolean? { + val authMethod = this.authenticationMethod.value + if (tryAutoConfirm) { + if ((authMethod as? AuthenticationMethodModel.Pin)?.autoConfirm != true) { + // Do not attempt to authenticate unless the PIN lock is set to auto-confirm. + return null + } + + if (input.size < authMethod.code.size) { + // Do not attempt to authenticate if the PIN has not yet the required amount of + // digits. This intentionally only skip for shorter PINs; if the PIN is longer, the + // layer above might have throttled this check, and the PIN should be rejected via + // the auth code below. + return null + } + } + val isSuccessful = - when (val authMethod = this.authenticationMethod.value) { + when (authMethod) { is AuthenticationMethodModel.Pin -> input.asCode() == authMethod.code is AuthenticationMethodModel.Password -> input.asPassword() == authMethod.password is AuthenticationMethodModel.Pattern -> input.asPattern() == authMethod.coordinates @@ -180,21 +202,17 @@ constructor( * Returns a PIN code from the given list. It's assumed the given list elements are all * [Int] in the range [0-9]. */ - private fun List.asCode(): Long? { + private fun List.asCode(): List? { if (isEmpty() || size > DevicePolicyManager.MAX_PASSWORD_LENGTH) { return null } - var code = 0L - map { - require(it is Int && it in 0..9) { - "Pin is required to be Int in range [0..9], but got $it" - } - it + return map { + require(it is Int && it in 0..9) { + "Pin is required to be Int in range [0..9], but got $it" } - .forEach { integer -> code = code * 10 + integer } - - return code + it + } } /** diff --git a/packages/SystemUI/src/com/android/systemui/authentication/shared/model/AuthenticationMethodModel.kt b/packages/SystemUI/src/com/android/systemui/authentication/shared/model/AuthenticationMethodModel.kt index e4fbf9af35eaf..1016b6b9a99e6 100644 --- a/packages/SystemUI/src/com/android/systemui/authentication/shared/model/AuthenticationMethodModel.kt +++ b/packages/SystemUI/src/com/android/systemui/authentication/shared/model/AuthenticationMethodModel.kt @@ -16,6 +16,8 @@ package com.android.systemui.authentication.shared.model +import androidx.annotation.VisibleForTesting + /** Enumerates all known authentication methods. */ sealed class AuthenticationMethodModel( /** @@ -38,7 +40,16 @@ sealed class AuthenticationMethodModel( * In practice, a pin is restricted to 16 decimal digits , see * [android.app.admin.DevicePolicyManager.MAX_PASSWORD_LENGTH] */ - data class Pin(val code: Long) : AuthenticationMethodModel(isSecure = true) + data class Pin(val code: List, val autoConfirm: Boolean) : + AuthenticationMethodModel(isSecure = true) { + + /** Convenience constructor for tests only. */ + @VisibleForTesting + constructor( + code: Long, + autoConfirm: Boolean = false + ) : this(code.toString(10).map { it - '0' }, autoConfirm) {} + } data class Password(val password: String) : AuthenticationMethodModel(isSecure = true) 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 c833defdc444b..256c63515fc87 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 @@ -149,19 +149,28 @@ constructor( * If the input is correct, the device will be unlocked and the lock screen and bouncer will be * dismissed and hidden. * + * If [tryAutoConfirm] is `true`, authentication is attempted if and only if the auth method + * supports auto-confirming, and the input's length is at least the code's length. Otherwise, + * `null` is returned. + * * @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. + * @param tryAutoConfirm `true` if called while the user inputs the code, without an explicit + * request to validate. + * @return `true` if the authentication succeeded and the device is now unlocked; `false` when + * authentication failed, `null` if the check was not performed. */ fun authenticate( input: List, - ): Boolean { + tryAutoConfirm: Boolean = false, + ): Boolean? { if (repository.throttling.value != null) { return false } - val isAuthenticated = authenticationInteractor.authenticate(input) + val isAuthenticated = + authenticationInteractor.authenticate(input, tryAutoConfirm) ?: return null + val failedAttempts = authenticationInteractor.failedAuthenticationAttempts.value when { isAuthenticated -> { 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 55929b566cf1e..0146e406703c2 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 @@ -50,7 +50,7 @@ class PasswordBouncerViewModel( /** Notifies that the user has pressed the key for attempting to authenticate the password. */ fun onAuthenticateKeyPressed() { - if (!interactor.authenticate(password.value.toCharArray().toList())) { + if (interactor.authenticate(password.value.toCharArray().toList()) != true) { showFailureAnimation() } 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 d9ef75db6103b..700703ee35601 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 @@ -153,9 +153,8 @@ class PatternBouncerViewModel( /** Notifies that the user has ended the drag gesture across the dot grid. */ fun onDragEnd() { - val isSuccessfullyAuthenticated = - interactor.authenticate(_selectedDots.value.map { it.toCoordinate() }) - if (!isSuccessfullyAuthenticated) { + val pattern = _selectedDots.value.map { it.toCoordinate() } + if (interactor.authenticate(pattern) != true) { showFailureAnimation() } 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 94d3d193e9cc2..1944c74f1d6a6 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 @@ -16,14 +16,19 @@ package com.android.systemui.bouncer.ui.viewmodel +import com.android.systemui.authentication.shared.model.AuthenticationMethodModel import com.android.systemui.bouncer.domain.interactor.BouncerInteractor import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn /** Holds UI state and handles user input for the PIN code bouncer UI. */ class PinBouncerViewModel( - private val applicationScope: CoroutineScope, + applicationScope: CoroutineScope, private val interactor: BouncerInteractor, isInputEnabled: StateFlow, ) : @@ -34,6 +39,42 @@ class PinBouncerViewModel( private val mutablePinEntries = MutableStateFlow>(emptyList()) val pinEntries: StateFlow> = mutablePinEntries + /** The length of the hinted PIN, or null if pin length hint should not be shown. */ + val hintedPinLength: StateFlow = + interactor.authenticationMethod + .map { authMethod -> computeHintedPinLength(authMethod) } + .stateIn( + scope = applicationScope, + started = SharingStarted.Eagerly, + initialValue = computeHintedPinLength(interactor.authenticationMethod.value), + ) + + /** Appearance of the backspace button. */ + val backspaceButtonAppearance: StateFlow = + combine(interactor.authenticationMethod, mutablePinEntries) { authMethod, enteredPin -> + computeBackspaceButtonAppearance(authMethod, enteredPin) + } + .stateIn( + scope = applicationScope, + started = SharingStarted.Eagerly, + initialValue = + computeBackspaceButtonAppearance( + interactor.authenticationMethod.value, + mutablePinEntries.value + ), + ) + + /** Appearance of the confirm button. */ + val confirmButtonAppearance: StateFlow = + interactor.authenticationMethod + .map { authMethod -> computeConfirmButtonAppearance(authMethod) } + .stateIn( + scope = applicationScope, + started = SharingStarted.Eagerly, + initialValue = + computeConfirmButtonAppearance(interactor.authenticationMethod.value), + ) + /** Notifies that the UI has been shown to the user. */ fun onShown() { interactor.resetMessage() @@ -46,6 +87,8 @@ class PinBouncerViewModel( } mutablePinEntries.value += EnteredKey(input) + + tryAuthenticate(useAutoConfirm = true) } /** Notifies that the user clicked the backspace button. */ @@ -63,14 +106,72 @@ class PinBouncerViewModel( /** Notifies that the user clicked the "enter" button. */ fun onAuthenticateButtonClicked() { - if (!interactor.authenticate(mutablePinEntries.value.map { it.input })) { + tryAuthenticate(useAutoConfirm = false) + } + + private fun tryAuthenticate(useAutoConfirm: Boolean) { + val pinCode = mutablePinEntries.value.map { it.input } + val isSuccess = interactor.authenticate(pinCode, useAutoConfirm) ?: return + + if (!isSuccess) { showFailureAnimation() } mutablePinEntries.value = emptyList() } + + private fun isAutoConfirmEnabled(authMethodModel: AuthenticationMethodModel): Boolean { + return (authMethodModel as? AuthenticationMethodModel.Pin)?.autoConfirm == true + } + + private fun autoConfirmPinLength(authMethodModel: AuthenticationMethodModel): Int? { + if (!isAutoConfirmEnabled(authMethodModel)) return null + + return (authMethodModel as? AuthenticationMethodModel.Pin)?.code?.size + } + + private fun computeHintedPinLength(authMethodModel: AuthenticationMethodModel): Int? { + // Hinting is enabled for 6-digit codes only + return autoConfirmPinLength(authMethodModel).takeIf { it == HINTING_PASSCODE_LENGTH } + } + + private fun computeBackspaceButtonAppearance( + authMethodModel: AuthenticationMethodModel, + enteredPin: List + ): ActionButtonAppearance { + val isAutoConfirmEnabled = isAutoConfirmEnabled(authMethodModel) + val isEmpty = enteredPin.isEmpty() + + return when { + isAutoConfirmEnabled && isEmpty -> ActionButtonAppearance.Hidden + isAutoConfirmEnabled -> ActionButtonAppearance.Subtle + else -> ActionButtonAppearance.Shown + } + } + private fun computeConfirmButtonAppearance( + authMethodModel: AuthenticationMethodModel + ): ActionButtonAppearance { + return if (isAutoConfirmEnabled(authMethodModel)) { + ActionButtonAppearance.Hidden + } else { + ActionButtonAppearance.Shown + } + } } +/** Appearance of pin-pad action buttons. */ +enum class ActionButtonAppearance { + /** Button must not be shown. */ + Hidden, + /** Button is shown, but with no background to make it less prominent. */ + Subtle, + /** Button is shown. */ + Shown, +} + +/** Auto-confirm passcodes of exactly 6 digits show a length hint, see http://shortn/_IXlmSNbDh6 */ +private const val HINTING_PASSCODE_LENGTH = 6 + private var nextSequenceNumber = 1 /** diff --git a/packages/SystemUI/tests/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractorTest.kt index 3a93e7744d00a..ac2d492f3320b 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractorTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractorTest.kt @@ -336,6 +336,110 @@ class AuthenticationInteractorTest : SysuiTestCase() { assertThat(failedAttemptCount).isEqualTo(1) } + @Test + fun tryAutoConfirm_withAutoConfirmPinAndEmptyInput_returnsNullAndHasNoEffect() = + testScope.runTest { + val failedAttemptCount by collectLastValue(underTest.failedAuthenticationAttempts) + val isUnlocked by collectLastValue(underTest.isUnlocked) + underTest.setAuthenticationMethod( + AuthenticationMethodModel.Pin(1234, autoConfirm = true) + ) + assertThat(isUnlocked).isFalse() + + assertThat(underTest.authenticate(listOf(), tryAutoConfirm = true)).isNull() + assertThat(isUnlocked).isFalse() + assertThat(failedAttemptCount).isEqualTo(0) + } + + @Test + fun tryAutoConfirm_withAutoConfirmPinAndShorterPin_returnsNullAndHasNoEffect() = + testScope.runTest { + val failedAttemptCount by collectLastValue(underTest.failedAuthenticationAttempts) + val isUnlocked by collectLastValue(underTest.isUnlocked) + underTest.setAuthenticationMethod( + AuthenticationMethodModel.Pin(1234, autoConfirm = true) + ) + assertThat(isUnlocked).isFalse() + + assertThat(underTest.authenticate(listOf(1, 2, 3), tryAutoConfirm = true)).isNull() + assertThat(isUnlocked).isFalse() + assertThat(failedAttemptCount).isEqualTo(0) + } + + @Test + fun tryAutoConfirm_withAutoConfirmWrongPinCorrectLength_returnsFalseAndDoesNotUnlockDevice() = + testScope.runTest { + val failedAttemptCount by collectLastValue(underTest.failedAuthenticationAttempts) + val isUnlocked by collectLastValue(underTest.isUnlocked) + underTest.setAuthenticationMethod( + AuthenticationMethodModel.Pin(1234, autoConfirm = true) + ) + assertThat(isUnlocked).isFalse() + + assertThat(underTest.authenticate(listOf(1, 2, 4, 4), tryAutoConfirm = true)).isFalse() + assertThat(isUnlocked).isFalse() + assertThat(failedAttemptCount).isEqualTo(1) + } + + @Test + fun tryAutoConfirm_withAutoConfirmLongerPin_returnsFalseAndDoesNotUnlockDevice() = + testScope.runTest { + val failedAttemptCount by collectLastValue(underTest.failedAuthenticationAttempts) + val isUnlocked by collectLastValue(underTest.isUnlocked) + underTest.setAuthenticationMethod( + AuthenticationMethodModel.Pin(1234, autoConfirm = true) + ) + assertThat(isUnlocked).isFalse() + + assertThat(underTest.authenticate(listOf(1, 2, 3, 4, 5), tryAutoConfirm = true)) + .isFalse() + assertThat(isUnlocked).isFalse() + assertThat(failedAttemptCount).isEqualTo(1) + } + + @Test + fun tryAutoConfirm_withAutoConfirmCorrectPin_returnsTrueAndUnlocksDevice() = + testScope.runTest { + val failedAttemptCount by collectLastValue(underTest.failedAuthenticationAttempts) + val isUnlocked by collectLastValue(underTest.isUnlocked) + underTest.setAuthenticationMethod( + AuthenticationMethodModel.Pin(1234, autoConfirm = true) + ) + assertThat(isUnlocked).isFalse() + + assertThat(underTest.authenticate(listOf(1, 2, 4, 4), tryAutoConfirm = true)).isFalse() + assertThat(isUnlocked).isFalse() + assertThat(failedAttemptCount).isEqualTo(1) + } + + @Test + fun tryAutoConfirm_withoutAutoConfirmButCorrectPin_returnsNullAndHasNoEffects() = + testScope.runTest { + val failedAttemptCount by collectLastValue(underTest.failedAuthenticationAttempts) + val isUnlocked by collectLastValue(underTest.isUnlocked) + underTest.setAuthenticationMethod( + AuthenticationMethodModel.Pin(1234, autoConfirm = false) + ) + assertThat(isUnlocked).isFalse() + + assertThat(underTest.authenticate(listOf(1, 2, 3, 4), tryAutoConfirm = true)).isNull() + assertThat(isUnlocked).isFalse() + assertThat(failedAttemptCount).isEqualTo(0) + } + + @Test + fun tryAutoConfirm_withoutCorrectPassword_returnsNullAndHasNoEffects() = + testScope.runTest { + val failedAttemptCount by collectLastValue(underTest.failedAuthenticationAttempts) + val isUnlocked by collectLastValue(underTest.isUnlocked) + underTest.setAuthenticationMethod(AuthenticationMethodModel.Password("password")) + assertThat(isUnlocked).isFalse() + + assertThat(underTest.authenticate("password".toList(), tryAutoConfirm = true)).isNull() + assertThat(isUnlocked).isFalse() + assertThat(failedAttemptCount).isEqualTo(0) + } + @Test fun unlocksDevice_whenAuthMethodBecomesNone() = testScope.runTest { 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 6a63c32f2c404..9483667909d59 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 @@ -94,6 +94,61 @@ class BouncerInteractorTest : SysuiTestCase() { assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) } + @Test + fun pinAuthMethod_tryAutoConfirm_withAutoConfirmPin() = + testScope.runTest { + val currentScene by collectLastValue(sceneInteractor.currentScene("container1")) + val message by collectLastValue(underTest.message) + + authenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Pin(1234, autoConfirm = true) + ) + authenticationInteractor.lockDevice() + underTest.showOrUnlockDevice("container1") + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PIN) + underTest.clearMessage() + + // Incomplete input. + assertThat(underTest.authenticate(listOf(1, 2), tryAutoConfirm = true)).isNull() + assertThat(message).isEmpty() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + + // Wrong 4-digit pin + assertThat(underTest.authenticate(listOf(1, 2, 3, 5), tryAutoConfirm = true)).isFalse() + assertThat(message).isEqualTo(MESSAGE_WRONG_PIN) + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + + // Correct input. + assertThat(underTest.authenticate(listOf(1, 2, 3, 4), tryAutoConfirm = true)).isTrue() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) + } + + @Test + fun pinAuthMethod_tryAutoConfirm_withoutAutoConfirmPin() = + testScope.runTest { + val currentScene by collectLastValue(sceneInteractor.currentScene("container1")) + val message by collectLastValue(underTest.message) + + authenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Pin(1234, autoConfirm = false) + ) + authenticationInteractor.lockDevice() + underTest.showOrUnlockDevice("container1") + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + underTest.clearMessage() + + // Incomplete input. + assertThat(underTest.authenticate(listOf(1, 2), tryAutoConfirm = true)).isNull() + assertThat(message).isEmpty() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + + // Correct input. + assertThat(underTest.authenticate(listOf(1, 2, 3, 4), tryAutoConfirm = true)).isNull() + assertThat(message).isEmpty() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + } + @Test fun passwordAuthMethod() = testScope.runTest { diff --git a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModelTest.kt index 7b6bb37459a78..7e358d27046b5 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModelTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModelTest.kt @@ -25,7 +25,6 @@ import com.android.systemui.coroutines.collectLastValue import com.android.systemui.scene.SceneTestUtils import com.android.systemui.scene.shared.model.SceneKey import com.android.systemui.scene.shared.model.SceneModel -import com.google.common.truth.Correspondence import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -286,15 +285,160 @@ class PinBouncerViewModelTest : SysuiTestCase() { assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) } + @Test + fun onAutoConfirm_whenCorrect() = + testScope.runTest { + val isUnlocked by collectLastValue(authenticationInteractor.isUnlocked) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + authenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Pin(1234, autoConfirm = true) + ) + authenticationInteractor.lockDevice() + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Bouncer)) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + underTest.onShown() + underTest.onPinButtonClicked(1) + underTest.onPinButtonClicked(2) + underTest.onPinButtonClicked(3) + underTest.onPinButtonClicked(4) + + assertThat(isUnlocked).isTrue() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) + } + + @Test + fun onAutoConfirm_whenWrong() = + testScope.runTest { + val isUnlocked by collectLastValue(authenticationInteractor.isUnlocked) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + val message by collectLastValue(bouncerViewModel.message) + val entries by collectLastValue(underTest.pinEntries) + authenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Pin(1234, autoConfirm = true) + ) + authenticationInteractor.lockDevice() + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Bouncer)) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + underTest.onShown() + underTest.onPinButtonClicked(1) + underTest.onPinButtonClicked(2) + underTest.onPinButtonClicked(3) + underTest.onPinButtonClicked(5) // PIN is now wrong! + + assertThat(entries).hasSize(0) + assertThat(message?.text).isEqualTo(WRONG_PIN) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + } + + @Test + fun backspaceButtonAppearance_withoutAutoConfirm_alwaysShown() = + testScope.runTest { + val backspaceButtonAppearance by collectLastValue(underTest.backspaceButtonAppearance) + + authenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Pin(1234, autoConfirm = false) + ) + + assertThat(backspaceButtonAppearance).isEqualTo(ActionButtonAppearance.Shown) + } + + @Test + fun backspaceButtonAppearance_withAutoConfirmButNoInput_isHidden() = + testScope.runTest { + val backspaceButtonAppearance by collectLastValue(underTest.backspaceButtonAppearance) + authenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Pin(1234, autoConfirm = true) + ) + + assertThat(backspaceButtonAppearance).isEqualTo(ActionButtonAppearance.Hidden) + } + + @Test + fun backspaceButtonAppearance_withAutoConfirmAndInput_isShownQuiet() = + testScope.runTest { + val backspaceButtonAppearance by collectLastValue(underTest.backspaceButtonAppearance) + authenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Pin(1234, autoConfirm = true) + ) + + underTest.onPinButtonClicked(1) + + assertThat(backspaceButtonAppearance).isEqualTo(ActionButtonAppearance.Subtle) + } + + @Test + fun confirmButtonAppearance_withoutAutoConfirm_alwaysShown() = + testScope.runTest { + val confirmButtonAppearance by collectLastValue(underTest.confirmButtonAppearance) + + authenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Pin(1234, autoConfirm = false) + ) + + assertThat(confirmButtonAppearance).isEqualTo(ActionButtonAppearance.Shown) + } + + @Test + fun confirmButtonAppearance_withAutoConfirm_isHidden() = + testScope.runTest { + val confirmButtonAppearance by collectLastValue(underTest.confirmButtonAppearance) + authenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Pin(1234, autoConfirm = true) + ) + + assertThat(confirmButtonAppearance).isEqualTo(ActionButtonAppearance.Hidden) + } + + @Test + fun hintedPinLength_withoutAutoConfirm_isNull() = + testScope.runTest { + val hintedPinLength by collectLastValue(underTest.hintedPinLength) + authenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Pin(1234, autoConfirm = false) + ) + + assertThat(hintedPinLength).isNull() + } + + @Test + fun hintedPinLength_withAutoConfirmPinLessThanSixDigits_isNull() = + testScope.runTest { + val hintedPinLength by collectLastValue(underTest.hintedPinLength) + authenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Pin(12345, autoConfirm = true) + ) + + assertThat(hintedPinLength).isNull() + } + + @Test + fun hintedPinLength_withAutoConfirmPinExactlySixDigits_isSix() = + testScope.runTest { + val hintedPinLength by collectLastValue(underTest.hintedPinLength) + authenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Pin(123456, autoConfirm = true) + ) + + assertThat(hintedPinLength).isEqualTo(6) + } + + @Test + fun hintedPinLength_withAutoConfirmPinMoreThanSixDigits_isNull() = + testScope.runTest { + val hintedPinLength by collectLastValue(underTest.hintedPinLength) + authenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Pin(1234567, autoConfirm = true) + ) + + assertThat(hintedPinLength).isNull() + } + companion object { private const val CONTAINER_NAME = "container1" private const val ENTER_YOUR_PIN = "Enter your pin" private const val WRONG_PIN = "Wrong pin" - - val KEY_CODE = - Correspondence.transforming( - { it?.input }, - "has a eventId of", - ) } }