From da01955aa6f1b784ceb315f98cc5e115f59399fb Mon Sep 17 00:00:00 2001 From: Mike Schneider Date: Fri, 26 May 2023 16:07:18 +0200 Subject: [PATCH] Add support for auto-confirmed PINs This changes the PIN code to a List, PIN codes with leading zeros cant be distinguished from shorter PINs. This CL does not yet introduce the hint shapes (will requires AVDs), but updates the visibility/appearance of the backspace/confirm button already. Bug: 284429000 Test: unit tests Change-Id: I3ebfd68586f03a0d280b5c56d95444895dd05ade --- .../bouncer/ui/composable/PinBouncer.kt | 183 ++++++++++-------- .../repository/AuthenticationRepository.kt | 4 +- .../interactor/AuthenticationInteractor.kt | 46 +++-- .../shared/model/AuthenticationMethodModel.kt | 13 +- .../domain/interactor/BouncerInteractor.kt | 17 +- .../ui/viewmodel/PasswordBouncerViewModel.kt | 2 +- .../ui/viewmodel/PatternBouncerViewModel.kt | 5 +- .../ui/viewmodel/PinBouncerViewModel.kt | 105 +++++++++- .../AuthenticationInteractorTest.kt | 104 ++++++++++ .../interactor/BouncerInteractorTest.kt | 55 ++++++ .../ui/viewmodel/PinBouncerViewModelTest.kt | 158 ++++++++++++++- 11 files changed, 579 insertions(+), 113 deletions(-) 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..acfd8d2177aa6 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 @@ -26,6 +26,7 @@ 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 +59,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 +69,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 @@ -87,7 +90,6 @@ 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. @@ -103,62 +105,8 @@ internal fun PinBouncer( 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 +253,115 @@ 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() + + VerticalGrid( + columns = 3, + verticalSpacing = 12.dp, + horizontalSpacing = 20.dp, + ) { + repeat(9) { index -> DigitButton(index + 1, isInputEnabled, viewModel::onPinButtonClicked) } + + 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, + ) + + DigitButton(0, isInputEnabled, viewModel::onPinButtonClicked) + + 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 + ) + } +} + +@Composable +private fun DigitButton( digit: Int, - contentColor: Color, + isInputEnabled: Boolean, + onClicked: (Int) -> Unit, ) { - // 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, + ) { 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, ) { - 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 } + ) { 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 +395,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,7 +443,7 @@ private fun PinButton( } }, ) { - content(contentColor) + content(contentColor::value) } } 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", - ) } }