Merge changes from topic "autoconfirm" into udc-qpr-dev

* changes:
  Show auth failure animation for PIN bouncer
  Add support for auto-confirmed PINs
This commit is contained in:
Mike Schneider
2023-06-19 12:43:51 +00:00
committed by Android (Google) Code Review
11 changed files with 651 additions and 125 deletions

View File

@@ -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<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()
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<Animatable<Float, AnimationVector1D>>
) {
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

View File

@@ -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()

View File

@@ -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
}
}
/**

View File

@@ -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)

View File

@@ -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 -> {

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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
/**

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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",
)
}
}