Add support for auto-confirmed PINs
This changes the PIN code to a List<Int>, 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
This commit is contained in:
@@ -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<EntryVisibility>) {
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,9 @@ class AuthenticationRepositoryImpl @Inject constructor() : AuthenticationReposit
|
||||
override val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()
|
||||
|
||||
private val _authenticationMethod =
|
||||
MutableStateFlow<AuthenticationMethodModel>(AuthenticationMethodModel.Pin(1234))
|
||||
MutableStateFlow<AuthenticationMethodModel>(
|
||||
AuthenticationMethodModel.Pin(listOf(1, 2, 3, 4), autoConfirm = false)
|
||||
)
|
||||
override val authenticationMethod: StateFlow<AuthenticationMethodModel> =
|
||||
_authenticationMethod.asStateFlow()
|
||||
|
||||
|
||||
@@ -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<Any>): Boolean {
|
||||
fun authenticate(input: List<Any>, 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<Any>.asCode(): Long? {
|
||||
private fun List<Any>.asCode(): List<Int>? {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<Int>, 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)
|
||||
|
||||
|
||||
@@ -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<Any>,
|
||||
): 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 -> {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Boolean>,
|
||||
) :
|
||||
@@ -34,6 +39,42 @@ class PinBouncerViewModel(
|
||||
private val mutablePinEntries = MutableStateFlow<List<EnteredKey>>(emptyList())
|
||||
val pinEntries: StateFlow<List<EnteredKey>> = mutablePinEntries
|
||||
|
||||
/** The length of the hinted PIN, or null if pin length hint should not be shown. */
|
||||
val hintedPinLength: StateFlow<Int?> =
|
||||
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<ActionButtonAppearance> =
|
||||
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<ActionButtonAppearance> =
|
||||
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<EnteredKey>
|
||||
): 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
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<EnteredKey, Int>(
|
||||
{ it?.input },
|
||||
"has a eventId of",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user