Merge changes from topic "bouncer-scene-throttling-b280877228" into udc-dev am: 637fb7497b

Original change: https://googleplex-android-review.googlesource.com/c/platform/frameworks/base/+/23353743

Change-Id: I7d31a54b7f8038bed0baa9a2e4920d48a9afcb75
Signed-off-by: Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>
This commit is contained in:
Ale Nijamkin
2023-05-26 00:44:17 +00:00
committed by Automerger Merge Worker
19 changed files with 631 additions and 127 deletions

View File

@@ -0,0 +1,63 @@
/*
* Copyright (C) 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.compose.animation
import androidx.compose.animation.core.Easing
import androidx.core.animation.Interpolator
import com.android.app.animation.InterpolatorsAndroidX
/**
* Compose-compatible definition of Android motion eases, see
* https://carbon.googleplex.com/android-motion/pages/easing
*/
object Easings {
/** The standard interpolator that should be used on every normal animation */
val StandardEasing = fromInterpolator(InterpolatorsAndroidX.STANDARD)
/**
* The standard accelerating interpolator that should be used on every regular movement of
* content that is disappearing e.g. when moving off screen.
*/
val StandardAccelerateEasing = fromInterpolator(InterpolatorsAndroidX.STANDARD_ACCELERATE)
/**
* The standard decelerating interpolator that should be used on every regular movement of
* content that is appearing e.g. when coming from off screen.
*/
val StandardDecelerateEasing = fromInterpolator(InterpolatorsAndroidX.STANDARD_DECELERATE)
/** The default emphasized interpolator. Used for hero / emphasized movement of content. */
val EmphasizedEasing = fromInterpolator(InterpolatorsAndroidX.EMPHASIZED)
/**
* The accelerated emphasized interpolator. Used for hero / emphasized movement of content that
* is disappearing e.g. when moving off screen.
*/
val EmphasizedAccelerateEasing = fromInterpolator(InterpolatorsAndroidX.EMPHASIZED_ACCELERATE)
/**
* The decelerating emphasized interpolator. Used for hero / emphasized movement of content that
* is appearing e.g. when coming from off screen
*/
val EmphasizedDecelerateEasing = fromInterpolator(InterpolatorsAndroidX.EMPHASIZED_DECELERATE)
/** The linear interpolator. */
val LinearEasing = fromInterpolator(InterpolatorsAndroidX.LINEAR)
private fun fromInterpolator(source: Interpolator) = Easing { x -> source.getInterpolation(x) }
}

View File

@@ -16,6 +16,7 @@
package com.android.systemui.scene.ui.composable
import android.content.Context
import com.android.systemui.bouncer.ui.composable.BouncerScene
import com.android.systemui.bouncer.ui.viewmodel.BouncerViewModel
import com.android.systemui.dagger.SysUISingleton
@@ -28,6 +29,7 @@ import com.android.systemui.scene.shared.model.Scene
import com.android.systemui.scene.shared.model.SceneContainerNames
import com.android.systemui.shade.ui.composable.ShadeScene
import com.android.systemui.shade.ui.viewmodel.ShadeSceneViewModel
import com.android.systemui.statusbar.phone.SystemUIDialog
import dagger.Module
import dagger.Provides
import javax.inject.Named
@@ -57,6 +59,7 @@ object SceneModule {
@SysUISingleton
@Named(SceneContainerNames.SYSTEM_UI_DEFAULT)
fun bouncerScene(
@Application context: Context,
viewModelFactory: BouncerViewModel.Factory,
): BouncerScene {
return BouncerScene(
@@ -64,6 +67,7 @@ object SceneModule {
viewModelFactory.create(
containerName = SceneContainerNames.SYSTEM_UI_DEFAULT,
),
dialogFactory = { SystemUIDialog(context) },
)
}

View File

@@ -14,9 +14,16 @@
* limitations under the License.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package com.android.systemui.bouncer.ui.composable
import android.app.AlertDialog
import android.app.Dialog
import android.content.DialogInterface
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -26,15 +33,20 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.android.systemui.R
import com.android.systemui.bouncer.ui.viewmodel.AuthMethodBouncerViewModel
import com.android.systemui.bouncer.ui.viewmodel.BouncerViewModel
import com.android.systemui.bouncer.ui.viewmodel.PasswordBouncerViewModel
@@ -51,6 +63,7 @@ import kotlinx.coroutines.flow.asStateFlow
/** The bouncer scene displays authentication challenges like PIN, password, or pattern. */
class BouncerScene(
private val viewModel: BouncerViewModel,
private val dialogFactory: () -> AlertDialog,
) : ComposableScene {
override val key = SceneKey.Bouncer
@@ -68,16 +81,19 @@ class BouncerScene(
override fun Content(
containerName: String,
modifier: Modifier,
) = BouncerScene(viewModel, modifier)
) = BouncerScene(viewModel, dialogFactory, modifier)
}
@Composable
private fun BouncerScene(
viewModel: BouncerViewModel,
dialogFactory: () -> AlertDialog,
modifier: Modifier = Modifier,
) {
val message: String by viewModel.message.collectAsState()
val message: BouncerViewModel.MessageViewModel by viewModel.message.collectAsState()
val authMethodViewModel: AuthMethodBouncerViewModel? by viewModel.authMethod.collectAsState()
val dialogMessage: String? by viewModel.throttlingDialogMessage.collectAsState()
var dialog: Dialog? by remember { mutableStateOf(null) }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
@@ -88,9 +104,10 @@ private fun BouncerScene(
Crossfade(
targetState = message,
label = "Bouncer message",
) {
animationSpec = if (message.isUpdateAnimated) tween() else snap(),
) { message ->
Text(
text = it,
text = message.text,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyLarge,
)
@@ -132,5 +149,26 @@ private fun BouncerScene(
style = MaterialTheme.typography.bodyMedium,
)
}
if (dialogMessage != null) {
if (dialog == null) {
dialog =
dialogFactory().apply {
setMessage(dialogMessage)
setButton(
DialogInterface.BUTTON_NEUTRAL,
context.getString(R.string.ok),
) { _, _ ->
viewModel.onThrottlingDialogDismissed()
}
setCancelable(false)
setCanceledOnTouchOutside(false)
show()
}
}
} else {
dialog?.dismiss()
dialog = null
}
}
}

View File

@@ -53,6 +53,8 @@ internal fun PasswordBouncer(
) {
val focusRequester = remember { FocusRequester() }
val password: String by viewModel.password.collectAsState()
val isInputEnabled: Boolean by viewModel.isInputEnabled.collectAsState()
val animateFailure: Boolean by viewModel.animateFailure.collectAsState()
LaunchedEffect(Unit) {
// When the UI comes up, request focus on the TextField to bring up the software keyboard.
@@ -61,6 +63,13 @@ internal fun PasswordBouncer(
viewModel.onShown()
}
LaunchedEffect(animateFailure) {
if (animateFailure) {
// We don't currently have a failure animation for password, just consume it:
viewModel.onFailureAnimationShown()
}
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier,
@@ -71,6 +80,7 @@ internal fun PasswordBouncer(
TextField(
value = password,
onValueChange = viewModel::onPasswordInputChanged,
enabled = isInputEnabled,
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center),

View File

@@ -18,6 +18,7 @@ package com.android.systemui.bouncer.ui.composable
import android.view.HapticFeedbackConstants
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectDragGestures
@@ -41,12 +42,15 @@ import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.integerResource
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import com.android.compose.animation.Easings
import com.android.internal.R
import com.android.systemui.bouncer.ui.viewmodel.PatternBouncerViewModel
import com.android.systemui.bouncer.ui.viewmodel.PatternDotViewModel
import com.android.systemui.compose.modifiers.thenIf
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.sqrt
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
/**
@@ -66,9 +70,9 @@ internal fun PatternBouncer(
val rowCount = viewModel.rowCount
val dotColor = MaterialTheme.colorScheme.secondary
val dotRadius = with(LocalDensity.current) { 8.dp.toPx() }
val dotRadius = with(LocalDensity.current) { (DOT_DIAMETER_DP / 2).dp.toPx() }
val lineColor = MaterialTheme.colorScheme.primary
val lineStrokeWidth = dotRadius * 2 + with(LocalDensity.current) { 4.dp.toPx() }
val lineStrokeWidth = with(LocalDensity.current) { LINE_STROKE_WIDTH_DP.dp.toPx() }
var containerSize: IntSize by remember { mutableStateOf(IntSize(0, 0)) }
val horizontalSpacing = containerSize.width / colCount
@@ -82,6 +86,9 @@ internal fun PatternBouncer(
val currentDot: PatternDotViewModel? by viewModel.currentDot.collectAsState()
// The dots selected so far, if the user is currently dragging.
val selectedDots: List<PatternDotViewModel> by viewModel.selectedDots.collectAsState()
val isInputEnabled: Boolean by viewModel.isInputEnabled.collectAsState()
val isAnimationEnabled: Boolean by viewModel.isPatternVisible.collectAsState()
val animateFailure: Boolean by viewModel.animateFailure.collectAsState()
// Map of animatables for the scale of each dot, keyed by dot.
val dotScalingAnimatables = remember(dots) { dots.associateWith { Animatable(1f) } }
@@ -96,19 +103,46 @@ internal fun PatternBouncer(
val view = LocalView.current
// When the current dot is changed, we need to update our animations.
LaunchedEffect(currentDot) {
view.performHapticFeedback(
HapticFeedbackConstants.VIRTUAL_KEY,
HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING,
)
LaunchedEffect(currentDot, isAnimationEnabled) {
// Perform haptic feedback, but only if the current dot is not null, so we don't perform it
// when the UI first shows up or when the user lifts their pointer/finger.
if (currentDot != null) {
view.performHapticFeedback(
HapticFeedbackConstants.VIRTUAL_KEY,
HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING,
)
}
// Make sure that the current dot is scaled up while the other dots are scaled back down.
if (!isAnimationEnabled) {
return@LaunchedEffect
}
// Make sure that the current dot is scaled up while the other dots are scaled back
// down.
dotScalingAnimatables.entries.forEach { (dot, animatable) ->
val isSelected = dot == currentDot
launch {
animatable.animateTo(if (isSelected) 2f else 1f)
// Launch using the longer-lived scope because we want these animations to proceed to
// completion even if the LaunchedEffect is canceled because its key objects have
// changed.
scope.launch {
if (isSelected) {
animatable.animateTo(1f)
animatable.animateTo(
targetValue = (SELECTED_DOT_DIAMETER_DP / DOT_DIAMETER_DP.toFloat()),
animationSpec =
tween(
durationMillis = SELECTED_DOT_REACTION_ANIMATION_DURATION_MS,
easing = Easings.StandardAccelerateEasing,
),
)
} else {
animatable.animateTo(
targetValue = 1f,
animationSpec =
tween(
durationMillis = SELECTED_DOT_RETRACT_ANIMATION_DURATION_MS,
easing = Easings.StandardDecelerateEasing,
),
)
}
}
}
@@ -116,14 +150,18 @@ internal fun PatternBouncer(
selectedDots.forEach { dot ->
lineFadeOutAnimatables[dot]?.let { line ->
if (!line.isRunning) {
// Launch using the longer-lived scope because we want these animations to
// proceed to completion even if the LaunchedEffect is canceled because its key
// objects have changed.
scope.launch {
if (dot == currentDot) {
// Reset the fade-out animation for the current dot. When the current
// dot is switched, this entire code block runs again for the newly
// selected dot.
// Reset the fade-out animation for the current dot. When the
// current dot is switched, this entire code block runs again for
// the newly selected dot.
line.snapTo(1f)
} else {
// For all non-current dots, make sure that the lines are fading out.
// For all non-current dots, make sure that the lines are fading
// out.
line.animateTo(
targetValue = 0f,
animationSpec =
@@ -139,6 +177,17 @@ internal fun PatternBouncer(
}
}
// Show the failure animation if the user entered the wrong input.
LaunchedEffect(animateFailure) {
if (animateFailure) {
showFailureAnimation(
dots = dots,
scalingAnimatables = dotScalingAnimatables,
)
viewModel.onFailureAnimationShown()
}
}
// This is the position of the input pointer.
var inputPosition: Offset? by remember { mutableStateOf(null) }
@@ -148,27 +197,34 @@ internal fun PatternBouncer(
// when it leaves the bounds of the dot grid.
.clipToBounds()
.onSizeChanged { containerSize = it }
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { start ->
inputPosition = start
viewModel.onDragStart()
},
onDragEnd = {
inputPosition = null
lineFadeOutAnimatables.values.forEach { animatable ->
scope.launch { animatable.animateTo(1f) }
}
viewModel.onDragEnd()
},
) { change, _ ->
inputPosition = change.position
viewModel.onDrag(
xPx = change.position.x,
yPx = change.position.y,
containerSizePx = containerSize.width,
verticalOffsetPx = verticalOffset,
)
.thenIf(isInputEnabled) {
Modifier.pointerInput(Unit) {
detectDragGestures(
onDragStart = { start ->
inputPosition = start
viewModel.onDragStart()
},
onDragEnd = {
inputPosition = null
if (isAnimationEnabled) {
lineFadeOutAnimatables.values.forEach { animatable ->
// Launch using the longer-lived scope because we want these
// animations to proceed to completion even if the surrounding
// scope is canceled.
scope.launch { animatable.animateTo(1f) }
}
}
viewModel.onDragEnd()
},
) { change, _ ->
inputPosition = change.position
viewModel.onDrag(
xPx = change.position.x,
yPx = change.position.y,
containerSizePx = containerSize.width,
verticalOffsetPx = verticalOffset,
)
}
}
}
) {
@@ -247,3 +303,62 @@ private fun lineAlpha(gridSpacing: Float, lineLength: Float = gridSpacing): Floa
// farther the user input pointer goes from the line, the more opaque the line gets.
return ((lineLength / gridSpacing - 0.3f) * 4f).coerceIn(0f, 1f)
}
private suspend fun showFailureAnimation(
dots: List<PatternDotViewModel>,
scalingAnimatables: Map<PatternDotViewModel, Animatable<Float, AnimationVector1D>>,
) {
val dotsByRow =
buildList<MutableList<PatternDotViewModel>> {
dots.forEach { dot ->
val rowIndex = dot.y
while (size <= rowIndex) {
add(mutableListOf())
}
get(rowIndex).add(dot)
}
}
coroutineScope {
dotsByRow.forEachIndexed { rowIndex, rowDots ->
rowDots.forEach { dot ->
scalingAnimatables[dot]?.let { dotScaleAnimatable ->
launch {
dotScaleAnimatable.animateTo(
targetValue =
FAILURE_ANIMATION_DOT_DIAMETER_DP / DOT_DIAMETER_DP.toFloat(),
animationSpec =
tween(
durationMillis =
FAILURE_ANIMATION_DOT_SHRINK_ANIMATION_DURATION_MS,
delayMillis =
rowIndex * FAILURE_ANIMATION_DOT_SHRINK_STAGGER_DELAY_MS,
easing = Easings.LinearEasing,
),
)
dotScaleAnimatable.animateTo(
targetValue = 1f,
animationSpec =
tween(
durationMillis =
FAILURE_ANIMATION_DOT_REVERT_ANIMATION_DURATION,
easing = Easings.StandardEasing,
),
)
}
}
}
}
}
}
private const val DOT_DIAMETER_DP = 16
private const val SELECTED_DOT_DIAMETER_DP = 24
private const val SELECTED_DOT_REACTION_ANIMATION_DURATION_MS = 83
private const val SELECTED_DOT_RETRACT_ANIMATION_DURATION_MS = 750
private const val LINE_STROKE_WIDTH_DP = 16
private const val FAILURE_ANIMATION_DOT_DIAMETER_DP = 13
private const val FAILURE_ANIMATION_DOT_SHRINK_ANIMATION_DURATION_MS = 50
private const val FAILURE_ANIMATION_DOT_SHRINK_STAGGER_DELAY_MS = 33
private const val FAILURE_ANIMATION_DOT_REVERT_ANIMATION_DURATION = 617

View File

@@ -18,11 +18,15 @@
package com.android.systemui.bouncer.ui.composable
import android.view.HapticFeedbackConstants
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
@@ -48,6 +52,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -55,15 +60,23 @@ import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.Dp
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.PinBouncerViewModel
import com.android.systemui.common.shared.model.ContentDescription
import com.android.systemui.common.shared.model.Icon
import com.android.systemui.common.ui.compose.Icon
import com.android.systemui.compose.modifiers.thenIf
import kotlin.math.max
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.DurationUnit
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
internal fun PinBouncer(
@@ -75,6 +88,16 @@ internal fun PinBouncer(
// The length of the PIN input received so far, so we know how many dots to render.
val pinLength: Pair<Int, Int> by viewModel.pinLengths.collectAsState()
val isInputEnabled: Boolean by viewModel.isInputEnabled.collectAsState()
val animateFailure: Boolean by viewModel.animateFailure.collectAsState()
// Show the failure animation if the user entered the wrong input.
LaunchedEffect(animateFailure) {
if (animateFailure) {
showFailureAnimation()
viewModel.onFailureAnimationShown()
}
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
@@ -116,6 +139,7 @@ internal fun PinBouncer(
val digit = index + 1
PinButton(
onClicked = { viewModel.onPinButtonClicked(digit) },
isEnabled = isInputEnabled,
) { contentColor ->
PinDigit(digit, contentColor)
}
@@ -124,7 +148,8 @@ internal fun PinBouncer(
PinButton(
onClicked = { viewModel.onBackspaceButtonClicked() },
onLongPressed = { viewModel.onBackspaceButtonLongPressed() },
isHighlighted = true,
isEnabled = isInputEnabled,
isIconButton = true,
) { contentColor ->
PinIcon(
Icon.Resource(
@@ -138,13 +163,15 @@ internal fun PinBouncer(
PinButton(
onClicked = { viewModel.onPinButtonClicked(0) },
isEnabled = isInputEnabled,
) { contentColor ->
PinDigit(0, contentColor)
}
PinButton(
onClicked = { viewModel.onAuthenticateButtonClicked() },
isHighlighted = true,
isEnabled = isInputEnabled,
isIconButton = true,
) { contentColor ->
PinIcon(
Icon.Resource(
@@ -187,61 +214,107 @@ private fun PinIcon(
@Composable
private fun PinButton(
onClicked: () -> Unit,
isEnabled: Boolean,
modifier: Modifier = Modifier,
onLongPressed: (() -> Unit)? = null,
isHighlighted: Boolean = false,
isIconButton: Boolean = false,
content: @Composable (contentColor: Color) -> Unit,
) {
var isPressed: Boolean by remember { mutableStateOf(false) }
val view = LocalView.current
LaunchedEffect(isPressed) {
if (isPressed) {
view.performHapticFeedback(
HapticFeedbackConstants.VIRTUAL_KEY,
HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING,
)
}
}
// Pin button animation specification is asymmetric: fast animation to the pressed state, and a
// slow animation upon release. Note that isPressed is guaranteed to be true for at least the
// press animation duration (see below in detectTapGestures).
val animEasing = if (isPressed) pinButtonPressedEasing else pinButtonReleasedEasing
val animDurationMillis =
(if (isPressed) pinButtonPressedDuration else pinButtonReleasedDuration).toInt(
DurationUnit.MILLISECONDS
)
val cornerRadius: Dp by
animateDpAsState(
if (isPressed) 24.dp else PinButtonSize / 2,
if (isPressed) 24.dp else pinButtonSize / 2,
label = "PinButton round corners",
animationSpec = tween(animDurationMillis, easing = animEasing)
)
val colorAnimationSpec: AnimationSpec<Color> = tween(animDurationMillis, easing = animEasing)
val containerColor: Color by
animateColorAsState(
when {
isPressed -> MaterialTheme.colorScheme.primaryContainer
isHighlighted -> MaterialTheme.colorScheme.secondaryContainer
else -> MaterialTheme.colorScheme.surface
isPressed -> MaterialTheme.colorScheme.primary
isIconButton -> MaterialTheme.colorScheme.secondaryContainer
else -> MaterialTheme.colorScheme.surfaceVariant
},
label = "Pin button container color",
animationSpec = colorAnimationSpec
)
val contentColor: Color by
animateColorAsState(
when {
isPressed -> MaterialTheme.colorScheme.onPrimaryContainer
isHighlighted -> MaterialTheme.colorScheme.onSecondaryContainer
else -> MaterialTheme.colorScheme.onSurface
isPressed -> MaterialTheme.colorScheme.onPrimary
isIconButton -> MaterialTheme.colorScheme.onSecondaryContainer
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
label = "Pin button container color",
animationSpec = colorAnimationSpec
)
val scope = rememberCoroutineScope()
Box(
contentAlignment = Alignment.Center,
modifier =
modifier
.size(PinButtonSize)
.size(pinButtonSize)
.drawBehind {
drawRoundRect(
color = containerColor,
cornerRadius = CornerRadius(cornerRadius.toPx()),
)
}
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed = true
tryAwaitRelease()
isPressed = false
},
onTap = { onClicked() },
onLongPress = onLongPressed?.let { { onLongPressed() } },
)
.thenIf(isEnabled) {
Modifier.pointerInput(Unit) {
detectTapGestures(
onPress = {
scope.launch {
isPressed = true
val minDuration = async {
delay(pinButtonPressedDuration + pinButtonHoldTime)
}
tryAwaitRelease()
minDuration.await()
isPressed = false
}
},
onTap = { onClicked() },
onLongPress = onLongPressed?.let { { onLongPressed() } },
)
}
},
) {
content(contentColor)
}
}
private val PinButtonSize = 84.dp
private fun showFailureAnimation() {
// TODO(b/282730134): implement.
}
private val pinButtonSize = 84.dp
// Pin button motion spec: http://shortn/_9TTIG6SoEa
private val pinButtonPressedDuration = 100.milliseconds
private val pinButtonPressedEasing = LinearEasing
private val pinButtonHoldTime = 33.milliseconds
private val pinButtonReleasedDuration = 420.milliseconds
private val pinButtonReleasedEasing = Easings.StandardEasing

View File

@@ -0,0 +1,52 @@
/*
* Copyright (C) 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.compose.modifiers
import androidx.compose.ui.Modifier
/**
* Concatenates this modifier with another if `condition` is true.
*
* @param condition Whether or not to apply the modifiers.
* @param factory Creates the modifier to concatenate with the current one.
* @return a Modifier representing this modifier followed by other in sequence.
* @see Modifier.then
*
* This method allows inline conditional addition of modifiers to a modifier chain. Instead of
* writing
*
* ```
* val aModifier = Modifier.a()
* val bModifier = if(condition) aModifier.b() else aModifier
* Composable(modifier = bModifier)
* ```
*
* You can instead write
*
* ```
* Composable(modifier = Modifier.a().thenIf(condition){
* Modifier.b()
* }
* ```
*
* This makes the modifier chain easier to read.
*
* Note that unlike the non-factory version, the conditional modifier is recreated each time, and
* may never be created at all.
*/
inline fun Modifier.thenIf(condition: Boolean, crossinline factory: () -> Modifier): Modifier =
if (condition) this.then(factory()) else this

View File

@@ -148,12 +148,17 @@ constructor(
*
* If the input is correct, the device will be unlocked and the lock screen and bouncer will be
* dismissed and hidden.
*
* @param input The input from the user to try to authenticate with. This can be a list of
* different things, based on the current authentication method.
* @return `true` if the authentication succeeded and the device is now unlocked; `false`
* otherwise.
*/
fun authenticate(
input: List<Any>,
) {
): Boolean {
if (repository.throttling.value != null) {
return
return false
}
val isAuthenticated = authenticationInteractor.authenticate(input)
@@ -186,6 +191,8 @@ constructor(
}
else -> repository.setMessage(errorMessage(authenticationMethod.value))
}
return isAuthenticated
}
private fun promptMessage(authMethod: AuthenticationMethodModel): String {

View File

@@ -16,14 +16,37 @@
package com.android.systemui.bouncer.ui.viewmodel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
sealed interface AuthMethodBouncerViewModel {
sealed class AuthMethodBouncerViewModel(
/**
* Whether user input is enabled.
*
* If `false`, user input should be completely ignored in the UI as the user is "locked out" of
* being able to attempt to unlock the device.
*/
val isInputEnabled: StateFlow<Boolean>
val isInputEnabled: StateFlow<Boolean>,
) {
private val _animateFailure = MutableStateFlow(false)
/**
* Whether a failure animation should be shown. Once consumed, the UI must call
* [onFailureAnimationShown] to consume this state.
*/
val animateFailure: StateFlow<Boolean> = _animateFailure.asStateFlow()
/**
* Notifies that the failure animation has been shown. This should be called to consume a `true`
* value in [animateFailure].
*/
fun onFailureAnimationShown() {
_animateFailure.value = false
}
/** Ask the UI to show the failure animation. */
protected fun showFailureAnimation() {
_animateFailure.value = true
}
}

View File

@@ -20,6 +20,7 @@ import android.content.Context
import com.android.systemui.R
import com.android.systemui.authentication.shared.model.AuthenticationMethodModel
import com.android.systemui.bouncer.domain.interactor.BouncerInteractor
import com.android.systemui.bouncer.shared.model.AuthenticationThrottledModel
import com.android.systemui.dagger.qualifiers.Application
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@@ -29,6 +30,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
@@ -45,21 +47,6 @@ constructor(
) {
private val interactor: BouncerInteractor = interactorFactory.create(containerName)
/**
* Whether updates to the message should be cross-animated from one message to another.
*
* If `false`, no animation should be applied, the message text should just be replaced
* instantly.
*/
val isMessageUpdateAnimationsEnabled: StateFlow<Boolean> =
interactor.throttling
.map { it == null }
.stateIn(
scope = applicationScope,
started = SharingStarted.WhileSubscribed(),
initialValue = interactor.throttling.value == null,
)
private val isInputEnabled: StateFlow<Boolean> =
interactor.throttling
.map { it == null }
@@ -104,13 +91,21 @@ constructor(
)
/** The user-facing message to show in the bouncer. */
val message: StateFlow<String> =
interactor.message
.map { it ?: "" }
val message: StateFlow<MessageViewModel> =
combine(
interactor.message,
interactor.throttling,
) { message, throttling ->
toMessageViewModel(message, throttling)
}
.stateIn(
scope = applicationScope,
started = SharingStarted.WhileSubscribed(),
initialValue = interactor.message.value ?: "",
initialValue =
toMessageViewModel(
message = interactor.message.value,
throttling = interactor.throttling.value,
),
)
private val _throttlingDialogMessage = MutableStateFlow<String?>(null)
@@ -177,6 +172,28 @@ constructor(
}
}
private fun toMessageViewModel(
message: String?,
throttling: AuthenticationThrottledModel?,
): MessageViewModel {
return MessageViewModel(
text = message ?: "",
isUpdateAnimated = throttling == null,
)
}
data class MessageViewModel(
val text: String,
/**
* Whether updates to the message should be cross-animated from one message to another.
*
* If `false`, no animation should be applied, the message text should just be replaced
* instantly.
*/
val isUpdateAnimated: Boolean,
)
@AssistedFactory
interface Factory {
fun create(

View File

@@ -24,8 +24,11 @@ import kotlinx.coroutines.flow.asStateFlow
/** Holds UI state and handles user input for the password bouncer UI. */
class PasswordBouncerViewModel(
private val interactor: BouncerInteractor,
override val isInputEnabled: StateFlow<Boolean>,
) : AuthMethodBouncerViewModel {
isInputEnabled: StateFlow<Boolean>,
) :
AuthMethodBouncerViewModel(
isInputEnabled = isInputEnabled,
) {
private val _password = MutableStateFlow("")
/** The password entered so far. */
@@ -47,7 +50,10 @@ class PasswordBouncerViewModel(
/** Notifies that the user has pressed the key for attempting to authenticate the password. */
fun onAuthenticateKeyPressed() {
interactor.authenticate(password.value.toCharArray().toList())
if (!interactor.authenticate(password.value.toCharArray().toList())) {
showFailureAnimation()
}
_password.value = ""
}
}

View File

@@ -37,8 +37,11 @@ class PatternBouncerViewModel(
private val applicationContext: Context,
applicationScope: CoroutineScope,
private val interactor: BouncerInteractor,
override val isInputEnabled: StateFlow<Boolean>,
) : AuthMethodBouncerViewModel {
isInputEnabled: StateFlow<Boolean>,
) :
AuthMethodBouncerViewModel(
isInputEnabled = isInputEnabled,
) {
/** The number of columns in the dot grid. */
val columnCount = 3
@@ -150,7 +153,11 @@ class PatternBouncerViewModel(
/** Notifies that the user has ended the drag gesture across the dot grid. */
fun onDragEnd() {
interactor.authenticate(_selectedDots.value.map { it.toCoordinate() })
val isSuccessfullyAuthenticated =
interactor.authenticate(_selectedDots.value.map { it.toCoordinate() })
if (!isSuccessfullyAuthenticated) {
showFailureAnimation()
}
_dots.value = defaultDots()
_currentDot.value = null

View File

@@ -33,8 +33,11 @@ import kotlinx.coroutines.launch
class PinBouncerViewModel(
private val applicationScope: CoroutineScope,
private val interactor: BouncerInteractor,
override val isInputEnabled: StateFlow<Boolean>,
) : AuthMethodBouncerViewModel {
isInputEnabled: StateFlow<Boolean>,
) :
AuthMethodBouncerViewModel(
isInputEnabled = isInputEnabled,
) {
private val entered = MutableStateFlow<List<Int>>(emptyList())
/**
@@ -92,7 +95,10 @@ class PinBouncerViewModel(
/** Notifies that the user clicked the "enter" button. */
fun onAuthenticateButtonClicked() {
interactor.authenticate(entered.value)
if (!interactor.authenticate(entered.value)) {
showFailureAnimation()
}
entered.value = emptyList()
}

View File

@@ -82,7 +82,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PIN)
// Wrong input.
underTest.authenticate(listOf(9, 8, 7))
assertThat(underTest.authenticate(listOf(9, 8, 7))).isFalse()
assertThat(message).isEqualTo(MESSAGE_WRONG_PIN)
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
@@ -90,7 +90,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PIN)
// Correct input.
underTest.authenticate(listOf(1, 2, 3, 4))
assertThat(underTest.authenticate(listOf(1, 2, 3, 4))).isTrue()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone))
}
@@ -114,7 +114,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PASSWORD)
// Wrong input.
underTest.authenticate("alohamora".toList())
assertThat(underTest.authenticate("alohamora".toList())).isFalse()
assertThat(message).isEqualTo(MESSAGE_WRONG_PASSWORD)
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
@@ -122,7 +122,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PASSWORD)
// Correct input.
underTest.authenticate("password".toList())
assertThat(underTest.authenticate("password".toList())).isTrue()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone))
}
@@ -146,9 +146,12 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PATTERN)
// Wrong input.
underTest.authenticate(
listOf(AuthenticationMethodModel.Pattern.PatternCoordinate(3, 4))
)
assertThat(
underTest.authenticate(
listOf(AuthenticationMethodModel.Pattern.PatternCoordinate(3, 4))
)
)
.isFalse()
assertThat(message).isEqualTo(MESSAGE_WRONG_PATTERN)
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
@@ -156,7 +159,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PATTERN)
// Correct input.
underTest.authenticate(emptyList())
assertThat(underTest.authenticate(emptyList())).isTrue()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone))
}
@@ -214,7 +217,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(isUnlocked).isFalse()
repeat(BouncerInteractor.THROTTLE_EVERY) { times ->
// Wrong PIN.
underTest.authenticate(listOf(6, 7, 8, 9))
assertThat(underTest.authenticate(listOf(6, 7, 8, 9))).isFalse()
if (times < BouncerInteractor.THROTTLE_EVERY - 1) {
assertThat(message).isEqualTo(MESSAGE_WRONG_PIN)
}
@@ -223,7 +226,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertTryAgainMessage(message, BouncerInteractor.THROTTLE_DURATION_SEC)
// Correct PIN, but throttled, so doesn't unlock:
underTest.authenticate(listOf(1, 2, 3, 4))
assertThat(underTest.authenticate(listOf(1, 2, 3, 4))).isFalse()
assertThat(isUnlocked).isFalse()
assertTryAgainMessage(message, BouncerInteractor.THROTTLE_DURATION_SEC)
@@ -241,7 +244,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(isUnlocked).isFalse()
// Correct PIN and no longer throttled so unlocks:
underTest.authenticate(listOf(1, 2, 3, 4))
assertThat(underTest.authenticate(listOf(1, 2, 3, 4))).isTrue()
assertThat(isUnlocked).isTrue()
}

View File

@@ -0,0 +1,81 @@
/*
* Copyright (C) 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.bouncer.ui.viewmodel
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.authentication.shared.model.AuthenticationMethodModel
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.scene.SceneTestUtils
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
@OptIn(ExperimentalCoroutinesApi::class)
@SmallTest
@RunWith(JUnit4::class)
class AuthMethodBouncerViewModelTest : SysuiTestCase() {
private val testScope = TestScope()
private val utils = SceneTestUtils(this, testScope)
private val authenticationInteractor =
utils.authenticationInteractor(
utils.authenticationRepository(),
)
private val underTest =
PinBouncerViewModel(
applicationScope = testScope.backgroundScope,
interactor =
utils.bouncerInteractor(
authenticationInteractor = authenticationInteractor,
sceneInteractor = utils.sceneInteractor(),
),
isInputEnabled = MutableStateFlow(true),
)
@Test
fun animateFailure() =
testScope.runTest {
authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234))
val animateFailure by collectLastValue(underTest.animateFailure)
assertThat(animateFailure).isFalse()
// Wrong PIN:
underTest.onPinButtonClicked(3)
underTest.onPinButtonClicked(4)
underTest.onPinButtonClicked(5)
underTest.onPinButtonClicked(6)
underTest.onAuthenticateButtonClicked()
assertThat(animateFailure).isTrue()
underTest.onFailureAnimationShown()
assertThat(animateFailure).isFalse()
// Correct PIN:
underTest.onPinButtonClicked(1)
underTest.onPinButtonClicked(2)
underTest.onPinButtonClicked(3)
underTest.onPinButtonClicked(4)
underTest.onAuthenticateButtonClicked()
assertThat(animateFailure).isFalse()
}
}

View File

@@ -93,22 +93,21 @@ class BouncerViewModelTest : SysuiTestCase() {
}
@Test
fun isMessageUpdateAnimationsEnabled() =
fun message() =
testScope.runTest {
val isMessageUpdateAnimationsEnabled by
collectLastValue(underTest.isMessageUpdateAnimationsEnabled)
val message by collectLastValue(underTest.message)
val throttling by collectLastValue(bouncerInteractor.throttling)
authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234))
assertThat(isMessageUpdateAnimationsEnabled).isTrue()
assertThat(message?.isUpdateAnimated).isTrue()
repeat(BouncerInteractor.THROTTLE_EVERY) {
// Wrong PIN.
bouncerInteractor.authenticate(listOf(3, 4, 5, 6))
}
assertThat(isMessageUpdateAnimationsEnabled).isFalse()
assertThat(message?.isUpdateAnimated).isFalse()
throttling?.totalDurationSec?.let { seconds -> advanceTimeBy(seconds * 1000L) }
assertThat(isMessageUpdateAnimationsEnabled).isTrue()
assertThat(message?.isUpdateAnimated).isTrue()
}
@Test

View File

@@ -85,7 +85,7 @@ class PasswordBouncerViewModelTest : SysuiTestCase() {
underTest.onShown()
assertThat(message).isEqualTo(ENTER_YOUR_PASSWORD)
assertThat(message?.text).isEqualTo(ENTER_YOUR_PASSWORD)
assertThat(password).isEqualTo("")
assertThat(isUnlocked).isFalse()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
@@ -109,7 +109,7 @@ class PasswordBouncerViewModelTest : SysuiTestCase() {
underTest.onPasswordInputChanged("password")
assertThat(message).isEmpty()
assertThat(message?.text).isEmpty()
assertThat(password).isEqualTo("password")
assertThat(isUnlocked).isFalse()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
@@ -156,7 +156,7 @@ class PasswordBouncerViewModelTest : SysuiTestCase() {
underTest.onAuthenticateKeyPressed()
assertThat(password).isEqualTo("")
assertThat(message).isEqualTo(WRONG_PASSWORD)
assertThat(message?.text).isEqualTo(WRONG_PASSWORD)
assertThat(isUnlocked).isFalse()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
}
@@ -179,13 +179,13 @@ class PasswordBouncerViewModelTest : SysuiTestCase() {
underTest.onPasswordInputChanged("wrong")
underTest.onAuthenticateKeyPressed()
assertThat(password).isEqualTo("")
assertThat(message).isEqualTo(WRONG_PASSWORD)
assertThat(message?.text).isEqualTo(WRONG_PASSWORD)
assertThat(isUnlocked).isFalse()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
// Enter the correct password:
underTest.onPasswordInputChanged("password")
assertThat(message).isEmpty()
assertThat(message?.text).isEmpty()
underTest.onAuthenticateKeyPressed()

View File

@@ -89,7 +89,7 @@ class PatternBouncerViewModelTest : SysuiTestCase() {
underTest.onShown()
assertThat(message).isEqualTo(ENTER_YOUR_PATTERN)
assertThat(message?.text).isEqualTo(ENTER_YOUR_PATTERN)
assertThat(selectedDots).isEmpty()
assertThat(currentDot).isNull()
assertThat(isUnlocked).isFalse()
@@ -115,7 +115,7 @@ class PatternBouncerViewModelTest : SysuiTestCase() {
underTest.onDragStart()
assertThat(message).isEmpty()
assertThat(message?.text).isEmpty()
assertThat(selectedDots).isEmpty()
assertThat(currentDot).isNull()
assertThat(isUnlocked).isFalse()
@@ -202,7 +202,7 @@ class PatternBouncerViewModelTest : SysuiTestCase() {
assertThat(selectedDots).isEmpty()
assertThat(currentDot).isNull()
assertThat(message).isEqualTo(WRONG_PATTERN)
assertThat(message?.text).isEqualTo(WRONG_PATTERN)
assertThat(isUnlocked).isFalse()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
}
@@ -235,7 +235,7 @@ class PatternBouncerViewModelTest : SysuiTestCase() {
underTest.onDragEnd()
assertThat(selectedDots).isEmpty()
assertThat(currentDot).isNull()
assertThat(message).isEqualTo(WRONG_PATTERN)
assertThat(message?.text).isEqualTo(WRONG_PATTERN)
assertThat(isUnlocked).isFalse()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))

View File

@@ -94,7 +94,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
underTest.onShown()
assertThat(message).isEqualTo(ENTER_YOUR_PIN)
assertThat(message?.text).isEqualTo(ENTER_YOUR_PIN)
assertThat(pinLengths).isEqualTo(0 to 0)
assertThat(isUnlocked).isFalse()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
@@ -116,7 +116,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
underTest.onPinButtonClicked(1)
assertThat(message).isEmpty()
assertThat(message?.text).isEmpty()
assertThat(pinLengths).isEqualTo(0 to 1)
assertThat(isUnlocked).isFalse()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
@@ -140,7 +140,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
underTest.onBackspaceButtonClicked()
assertThat(message).isEmpty()
assertThat(message?.text).isEmpty()
assertThat(pinLengths).isEqualTo(1 to 0)
assertThat(isUnlocked).isFalse()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
@@ -170,7 +170,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
advanceTimeBy(PinBouncerViewModel.BACKSPACE_LONG_PRESS_DELAY_MS)
}
assertThat(message).isEmpty()
assertThat(message?.text).isEmpty()
assertThat(pinLengths).isEqualTo(1 to 0)
assertThat(isUnlocked).isFalse()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
@@ -220,7 +220,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
underTest.onAuthenticateButtonClicked()
assertThat(pinLengths).isEqualTo(0 to 0)
assertThat(message).isEqualTo(WRONG_PIN)
assertThat(message?.text).isEqualTo(WRONG_PIN)
assertThat(isUnlocked).isFalse()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
}
@@ -244,7 +244,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
underTest.onPinButtonClicked(4)
underTest.onPinButtonClicked(5) // PIN is now wrong!
underTest.onAuthenticateButtonClicked()
assertThat(message).isEqualTo(WRONG_PIN)
assertThat(message?.text).isEqualTo(WRONG_PIN)
assertThat(pinLengths).isEqualTo(0 to 0)
assertThat(isUnlocked).isFalse()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
@@ -254,7 +254,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
underTest.onPinButtonClicked(2)
underTest.onPinButtonClicked(3)
underTest.onPinButtonClicked(4)
assertThat(message).isEmpty()
assertThat(message?.text).isEmpty()
underTest.onAuthenticateButtonClicked()