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

View File

@@ -14,9 +14,16 @@
* limitations under the License. * limitations under the License.
*/ */
@file:OptIn(ExperimentalMaterial3Api::class)
package com.android.systemui.bouncer.ui.composable 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.Crossfade
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.foundation.layout.padding
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp 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.AuthMethodBouncerViewModel
import com.android.systemui.bouncer.ui.viewmodel.BouncerViewModel import com.android.systemui.bouncer.ui.viewmodel.BouncerViewModel
import com.android.systemui.bouncer.ui.viewmodel.PasswordBouncerViewModel 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. */ /** The bouncer scene displays authentication challenges like PIN, password, or pattern. */
class BouncerScene( class BouncerScene(
private val viewModel: BouncerViewModel, private val viewModel: BouncerViewModel,
private val dialogFactory: () -> AlertDialog,
) : ComposableScene { ) : ComposableScene {
override val key = SceneKey.Bouncer override val key = SceneKey.Bouncer
@@ -68,16 +81,19 @@ class BouncerScene(
override fun Content( override fun Content(
containerName: String, containerName: String,
modifier: Modifier, modifier: Modifier,
) = BouncerScene(viewModel, modifier) ) = BouncerScene(viewModel, dialogFactory, modifier)
} }
@Composable @Composable
private fun BouncerScene( private fun BouncerScene(
viewModel: BouncerViewModel, viewModel: BouncerViewModel,
dialogFactory: () -> AlertDialog,
modifier: Modifier = Modifier, 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 authMethodViewModel: AuthMethodBouncerViewModel? by viewModel.authMethod.collectAsState()
val dialogMessage: String? by viewModel.throttlingDialogMessage.collectAsState()
var dialog: Dialog? by remember { mutableStateOf(null) }
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@@ -88,9 +104,10 @@ private fun BouncerScene(
Crossfade( Crossfade(
targetState = message, targetState = message,
label = "Bouncer message", label = "Bouncer message",
) { animationSpec = if (message.isUpdateAnimated) tween() else snap(),
) { message ->
Text( Text(
text = it, text = message.text,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
) )
@@ -132,5 +149,26 @@ private fun BouncerScene(
style = MaterialTheme.typography.bodyMedium, 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 focusRequester = remember { FocusRequester() }
val password: String by viewModel.password.collectAsState() val password: String by viewModel.password.collectAsState()
val isInputEnabled: Boolean by viewModel.isInputEnabled.collectAsState()
val animateFailure: Boolean by viewModel.animateFailure.collectAsState()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// When the UI comes up, request focus on the TextField to bring up the software keyboard. // 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() viewModel.onShown()
} }
LaunchedEffect(animateFailure) {
if (animateFailure) {
// We don't currently have a failure animation for password, just consume it:
viewModel.onFailureAnimationShown()
}
}
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier, modifier = modifier,
@@ -71,6 +80,7 @@ internal fun PasswordBouncer(
TextField( TextField(
value = password, value = password,
onValueChange = viewModel::onPasswordInputChanged, onValueChange = viewModel::onPasswordInputChanged,
enabled = isInputEnabled,
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
singleLine = true, singleLine = true,
textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), 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 android.view.HapticFeedbackConstants
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectDragGestures 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.res.integerResource
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.android.compose.animation.Easings
import com.android.internal.R import com.android.internal.R
import com.android.systemui.bouncer.ui.viewmodel.PatternBouncerViewModel import com.android.systemui.bouncer.ui.viewmodel.PatternBouncerViewModel
import com.android.systemui.bouncer.ui.viewmodel.PatternDotViewModel import com.android.systemui.bouncer.ui.viewmodel.PatternDotViewModel
import com.android.systemui.compose.modifiers.thenIf
import kotlin.math.min import kotlin.math.min
import kotlin.math.pow import kotlin.math.pow
import kotlin.math.sqrt import kotlin.math.sqrt
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
@@ -66,9 +70,9 @@ internal fun PatternBouncer(
val rowCount = viewModel.rowCount val rowCount = viewModel.rowCount
val dotColor = MaterialTheme.colorScheme.secondary 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 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)) } var containerSize: IntSize by remember { mutableStateOf(IntSize(0, 0)) }
val horizontalSpacing = containerSize.width / colCount val horizontalSpacing = containerSize.width / colCount
@@ -82,6 +86,9 @@ internal fun PatternBouncer(
val currentDot: PatternDotViewModel? by viewModel.currentDot.collectAsState() val currentDot: PatternDotViewModel? by viewModel.currentDot.collectAsState()
// The dots selected so far, if the user is currently dragging. // The dots selected so far, if the user is currently dragging.
val selectedDots: List<PatternDotViewModel> by viewModel.selectedDots.collectAsState() 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. // Map of animatables for the scale of each dot, keyed by dot.
val dotScalingAnimatables = remember(dots) { dots.associateWith { Animatable(1f) } } val dotScalingAnimatables = remember(dots) { dots.associateWith { Animatable(1f) } }
@@ -96,19 +103,46 @@ internal fun PatternBouncer(
val view = LocalView.current val view = LocalView.current
// When the current dot is changed, we need to update our animations. // When the current dot is changed, we need to update our animations.
LaunchedEffect(currentDot) { LaunchedEffect(currentDot, isAnimationEnabled) {
view.performHapticFeedback( // Perform haptic feedback, but only if the current dot is not null, so we don't perform it
HapticFeedbackConstants.VIRTUAL_KEY, // when the UI first shows up or when the user lifts their pointer/finger.
HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING, 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) -> dotScalingAnimatables.entries.forEach { (dot, animatable) ->
val isSelected = dot == currentDot val isSelected = dot == currentDot
launch { // Launch using the longer-lived scope because we want these animations to proceed to
animatable.animateTo(if (isSelected) 2f else 1f) // completion even if the LaunchedEffect is canceled because its key objects have
// changed.
scope.launch {
if (isSelected) { 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 -> selectedDots.forEach { dot ->
lineFadeOutAnimatables[dot]?.let { line -> lineFadeOutAnimatables[dot]?.let { line ->
if (!line.isRunning) { 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 { scope.launch {
if (dot == currentDot) { if (dot == currentDot) {
// Reset the fade-out animation for the current dot. When the current // Reset the fade-out animation for the current dot. When the
// dot is switched, this entire code block runs again for the newly // current dot is switched, this entire code block runs again for
// selected dot. // the newly selected dot.
line.snapTo(1f) line.snapTo(1f)
} else { } 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( line.animateTo(
targetValue = 0f, targetValue = 0f,
animationSpec = 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. // This is the position of the input pointer.
var inputPosition: Offset? by remember { mutableStateOf(null) } var inputPosition: Offset? by remember { mutableStateOf(null) }
@@ -148,27 +197,34 @@ internal fun PatternBouncer(
// when it leaves the bounds of the dot grid. // when it leaves the bounds of the dot grid.
.clipToBounds() .clipToBounds()
.onSizeChanged { containerSize = it } .onSizeChanged { containerSize = it }
.pointerInput(Unit) { .thenIf(isInputEnabled) {
detectDragGestures( Modifier.pointerInput(Unit) {
onDragStart = { start -> detectDragGestures(
inputPosition = start onDragStart = { start ->
viewModel.onDragStart() inputPosition = start
}, viewModel.onDragStart()
onDragEnd = { },
inputPosition = null onDragEnd = {
lineFadeOutAnimatables.values.forEach { animatable -> inputPosition = null
scope.launch { animatable.animateTo(1f) } if (isAnimationEnabled) {
} lineFadeOutAnimatables.values.forEach { animatable ->
viewModel.onDragEnd() // Launch using the longer-lived scope because we want these
}, // animations to proceed to completion even if the surrounding
) { change, _ -> // scope is canceled.
inputPosition = change.position scope.launch { animatable.animateTo(1f) }
viewModel.onDrag( }
xPx = change.position.x, }
yPx = change.position.y, viewModel.onDragEnd()
containerSizePx = containerSize.width, },
verticalOffsetPx = verticalOffset, ) { 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. // farther the user input pointer goes from the line, the more opaque the line gets.
return ((lineLength / gridSpacing - 0.3f) * 4f).coerceIn(0f, 1f) 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 package com.android.systemui.bouncer.ui.composable
import android.view.HapticFeedbackConstants
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.animateContentSize 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.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
@@ -48,6 +52,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.geometry.CornerRadius
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput 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 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.compose.grid.VerticalGrid
import com.android.systemui.R import com.android.systemui.R
import com.android.systemui.bouncer.ui.viewmodel.PinBouncerViewModel import com.android.systemui.bouncer.ui.viewmodel.PinBouncerViewModel
import com.android.systemui.common.shared.model.ContentDescription import com.android.systemui.common.shared.model.ContentDescription
import com.android.systemui.common.shared.model.Icon import com.android.systemui.common.shared.model.Icon
import com.android.systemui.common.ui.compose.Icon import com.android.systemui.common.ui.compose.Icon
import com.android.systemui.compose.modifiers.thenIf
import kotlin.math.max 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 @Composable
internal fun PinBouncer( 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. // 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 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( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@@ -116,6 +139,7 @@ internal fun PinBouncer(
val digit = index + 1 val digit = index + 1
PinButton( PinButton(
onClicked = { viewModel.onPinButtonClicked(digit) }, onClicked = { viewModel.onPinButtonClicked(digit) },
isEnabled = isInputEnabled,
) { contentColor -> ) { contentColor ->
PinDigit(digit, contentColor) PinDigit(digit, contentColor)
} }
@@ -124,7 +148,8 @@ internal fun PinBouncer(
PinButton( PinButton(
onClicked = { viewModel.onBackspaceButtonClicked() }, onClicked = { viewModel.onBackspaceButtonClicked() },
onLongPressed = { viewModel.onBackspaceButtonLongPressed() }, onLongPressed = { viewModel.onBackspaceButtonLongPressed() },
isHighlighted = true, isEnabled = isInputEnabled,
isIconButton = true,
) { contentColor -> ) { contentColor ->
PinIcon( PinIcon(
Icon.Resource( Icon.Resource(
@@ -138,13 +163,15 @@ internal fun PinBouncer(
PinButton( PinButton(
onClicked = { viewModel.onPinButtonClicked(0) }, onClicked = { viewModel.onPinButtonClicked(0) },
isEnabled = isInputEnabled,
) { contentColor -> ) { contentColor ->
PinDigit(0, contentColor) PinDigit(0, contentColor)
} }
PinButton( PinButton(
onClicked = { viewModel.onAuthenticateButtonClicked() }, onClicked = { viewModel.onAuthenticateButtonClicked() },
isHighlighted = true, isEnabled = isInputEnabled,
isIconButton = true,
) { contentColor -> ) { contentColor ->
PinIcon( PinIcon(
Icon.Resource( Icon.Resource(
@@ -187,61 +214,107 @@ private fun PinIcon(
@Composable @Composable
private fun PinButton( private fun PinButton(
onClicked: () -> Unit, onClicked: () -> Unit,
isEnabled: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onLongPressed: (() -> Unit)? = null, onLongPressed: (() -> Unit)? = null,
isHighlighted: Boolean = false, isIconButton: Boolean = false,
content: @Composable (contentColor: Color) -> Unit, content: @Composable (contentColor: Color) -> Unit,
) { ) {
var isPressed: Boolean by remember { mutableStateOf(false) } 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 val cornerRadius: Dp by
animateDpAsState( animateDpAsState(
if (isPressed) 24.dp else PinButtonSize / 2, if (isPressed) 24.dp else pinButtonSize / 2,
label = "PinButton round corners", label = "PinButton round corners",
animationSpec = tween(animDurationMillis, easing = animEasing)
) )
val colorAnimationSpec: AnimationSpec<Color> = tween(animDurationMillis, easing = animEasing)
val containerColor: Color by val containerColor: Color by
animateColorAsState( animateColorAsState(
when { when {
isPressed -> MaterialTheme.colorScheme.primaryContainer isPressed -> MaterialTheme.colorScheme.primary
isHighlighted -> MaterialTheme.colorScheme.secondaryContainer isIconButton -> MaterialTheme.colorScheme.secondaryContainer
else -> MaterialTheme.colorScheme.surface else -> MaterialTheme.colorScheme.surfaceVariant
}, },
label = "Pin button container color", label = "Pin button container color",
animationSpec = colorAnimationSpec
) )
val contentColor: Color by val contentColor: Color by
animateColorAsState( animateColorAsState(
when { when {
isPressed -> MaterialTheme.colorScheme.onPrimaryContainer isPressed -> MaterialTheme.colorScheme.onPrimary
isHighlighted -> MaterialTheme.colorScheme.onSecondaryContainer isIconButton -> MaterialTheme.colorScheme.onSecondaryContainer
else -> MaterialTheme.colorScheme.onSurface else -> MaterialTheme.colorScheme.onSurfaceVariant
}, },
label = "Pin button container color", label = "Pin button container color",
animationSpec = colorAnimationSpec
) )
val scope = rememberCoroutineScope()
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
modifier = modifier =
modifier modifier
.size(PinButtonSize) .size(pinButtonSize)
.drawBehind { .drawBehind {
drawRoundRect( drawRoundRect(
color = containerColor, color = containerColor,
cornerRadius = CornerRadius(cornerRadius.toPx()), cornerRadius = CornerRadius(cornerRadius.toPx()),
) )
} }
.pointerInput(Unit) { .thenIf(isEnabled) {
detectTapGestures( Modifier.pointerInput(Unit) {
onPress = { detectTapGestures(
isPressed = true onPress = {
tryAwaitRelease() scope.launch {
isPressed = false isPressed = true
}, val minDuration = async {
onTap = { onClicked() }, delay(pinButtonPressedDuration + pinButtonHoldTime)
onLongPress = onLongPressed?.let { { onLongPressed() } }, }
) tryAwaitRelease()
minDuration.await()
isPressed = false
}
},
onTap = { onClicked() },
onLongPress = onLongPressed?.let { { onLongPressed() } },
)
}
}, },
) { ) {
content(contentColor) 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 * If the input is correct, the device will be unlocked and the lock screen and bouncer will be
* dismissed and hidden. * 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( fun authenticate(
input: List<Any>, input: List<Any>,
) { ): Boolean {
if (repository.throttling.value != null) { if (repository.throttling.value != null) {
return return false
} }
val isAuthenticated = authenticationInteractor.authenticate(input) val isAuthenticated = authenticationInteractor.authenticate(input)
@@ -186,6 +191,8 @@ constructor(
} }
else -> repository.setMessage(errorMessage(authenticationMethod.value)) else -> repository.setMessage(errorMessage(authenticationMethod.value))
} }
return isAuthenticated
} }
private fun promptMessage(authMethod: AuthenticationMethodModel): String { private fun promptMessage(authMethod: AuthenticationMethodModel): String {

View File

@@ -16,14 +16,37 @@
package com.android.systemui.bouncer.ui.viewmodel package com.android.systemui.bouncer.ui.viewmodel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
sealed interface AuthMethodBouncerViewModel { sealed class AuthMethodBouncerViewModel(
/** /**
* Whether user input is enabled. * Whether user input is enabled.
* *
* If `false`, user input should be completely ignored in the UI as the user is "locked out" of * 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. * 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.R
import com.android.systemui.authentication.shared.model.AuthenticationMethodModel import com.android.systemui.authentication.shared.model.AuthenticationMethodModel
import com.android.systemui.bouncer.domain.interactor.BouncerInteractor import com.android.systemui.bouncer.domain.interactor.BouncerInteractor
import com.android.systemui.bouncer.shared.model.AuthenticationThrottledModel
import com.android.systemui.dagger.qualifiers.Application import com.android.systemui.dagger.qualifiers.Application
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
@@ -29,6 +30,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -45,21 +47,6 @@ constructor(
) { ) {
private val interactor: BouncerInteractor = interactorFactory.create(containerName) 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> = private val isInputEnabled: StateFlow<Boolean> =
interactor.throttling interactor.throttling
.map { it == null } .map { it == null }
@@ -104,13 +91,21 @@ constructor(
) )
/** The user-facing message to show in the bouncer. */ /** The user-facing message to show in the bouncer. */
val message: StateFlow<String> = val message: StateFlow<MessageViewModel> =
interactor.message combine(
.map { it ?: "" } interactor.message,
interactor.throttling,
) { message, throttling ->
toMessageViewModel(message, throttling)
}
.stateIn( .stateIn(
scope = applicationScope, scope = applicationScope,
started = SharingStarted.WhileSubscribed(), started = SharingStarted.WhileSubscribed(),
initialValue = interactor.message.value ?: "", initialValue =
toMessageViewModel(
message = interactor.message.value,
throttling = interactor.throttling.value,
),
) )
private val _throttlingDialogMessage = MutableStateFlow<String?>(null) 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 @AssistedFactory
interface Factory { interface Factory {
fun create( 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. */ /** Holds UI state and handles user input for the password bouncer UI. */
class PasswordBouncerViewModel( class PasswordBouncerViewModel(
private val interactor: BouncerInteractor, private val interactor: BouncerInteractor,
override val isInputEnabled: StateFlow<Boolean>, isInputEnabled: StateFlow<Boolean>,
) : AuthMethodBouncerViewModel { ) :
AuthMethodBouncerViewModel(
isInputEnabled = isInputEnabled,
) {
private val _password = MutableStateFlow("") private val _password = MutableStateFlow("")
/** The password entered so far. */ /** 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. */ /** Notifies that the user has pressed the key for attempting to authenticate the password. */
fun onAuthenticateKeyPressed() { fun onAuthenticateKeyPressed() {
interactor.authenticate(password.value.toCharArray().toList()) if (!interactor.authenticate(password.value.toCharArray().toList())) {
showFailureAnimation()
}
_password.value = "" _password.value = ""
} }
} }

View File

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

View File

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

View File

@@ -82,7 +82,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PIN) assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PIN)
// Wrong input. // Wrong input.
underTest.authenticate(listOf(9, 8, 7)) assertThat(underTest.authenticate(listOf(9, 8, 7))).isFalse()
assertThat(message).isEqualTo(MESSAGE_WRONG_PIN) assertThat(message).isEqualTo(MESSAGE_WRONG_PIN)
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
@@ -90,7 +90,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PIN) assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PIN)
// Correct input. // Correct input.
underTest.authenticate(listOf(1, 2, 3, 4)) assertThat(underTest.authenticate(listOf(1, 2, 3, 4))).isTrue()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone))
} }
@@ -114,7 +114,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PASSWORD) assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PASSWORD)
// Wrong input. // Wrong input.
underTest.authenticate("alohamora".toList()) assertThat(underTest.authenticate("alohamora".toList())).isFalse()
assertThat(message).isEqualTo(MESSAGE_WRONG_PASSWORD) assertThat(message).isEqualTo(MESSAGE_WRONG_PASSWORD)
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
@@ -122,7 +122,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PASSWORD) assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PASSWORD)
// Correct input. // Correct input.
underTest.authenticate("password".toList()) assertThat(underTest.authenticate("password".toList())).isTrue()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone))
} }
@@ -146,9 +146,12 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PATTERN) assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PATTERN)
// Wrong input. // Wrong input.
underTest.authenticate( assertThat(
listOf(AuthenticationMethodModel.Pattern.PatternCoordinate(3, 4)) underTest.authenticate(
) listOf(AuthenticationMethodModel.Pattern.PatternCoordinate(3, 4))
)
)
.isFalse()
assertThat(message).isEqualTo(MESSAGE_WRONG_PATTERN) assertThat(message).isEqualTo(MESSAGE_WRONG_PATTERN)
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
@@ -156,7 +159,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PATTERN) assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PATTERN)
// Correct input. // Correct input.
underTest.authenticate(emptyList()) assertThat(underTest.authenticate(emptyList())).isTrue()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone))
} }
@@ -214,7 +217,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(isUnlocked).isFalse() assertThat(isUnlocked).isFalse()
repeat(BouncerInteractor.THROTTLE_EVERY) { times -> repeat(BouncerInteractor.THROTTLE_EVERY) { times ->
// Wrong PIN. // Wrong PIN.
underTest.authenticate(listOf(6, 7, 8, 9)) assertThat(underTest.authenticate(listOf(6, 7, 8, 9))).isFalse()
if (times < BouncerInteractor.THROTTLE_EVERY - 1) { if (times < BouncerInteractor.THROTTLE_EVERY - 1) {
assertThat(message).isEqualTo(MESSAGE_WRONG_PIN) assertThat(message).isEqualTo(MESSAGE_WRONG_PIN)
} }
@@ -223,7 +226,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertTryAgainMessage(message, BouncerInteractor.THROTTLE_DURATION_SEC) assertTryAgainMessage(message, BouncerInteractor.THROTTLE_DURATION_SEC)
// Correct PIN, but throttled, so doesn't unlock: // 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() assertThat(isUnlocked).isFalse()
assertTryAgainMessage(message, BouncerInteractor.THROTTLE_DURATION_SEC) assertTryAgainMessage(message, BouncerInteractor.THROTTLE_DURATION_SEC)
@@ -241,7 +244,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(isUnlocked).isFalse() assertThat(isUnlocked).isFalse()
// Correct PIN and no longer throttled so unlocks: // 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() 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 @Test
fun isMessageUpdateAnimationsEnabled() = fun message() =
testScope.runTest { testScope.runTest {
val isMessageUpdateAnimationsEnabled by val message by collectLastValue(underTest.message)
collectLastValue(underTest.isMessageUpdateAnimationsEnabled)
val throttling by collectLastValue(bouncerInteractor.throttling) val throttling by collectLastValue(bouncerInteractor.throttling)
authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234))
assertThat(isMessageUpdateAnimationsEnabled).isTrue() assertThat(message?.isUpdateAnimated).isTrue()
repeat(BouncerInteractor.THROTTLE_EVERY) { repeat(BouncerInteractor.THROTTLE_EVERY) {
// Wrong PIN. // Wrong PIN.
bouncerInteractor.authenticate(listOf(3, 4, 5, 6)) bouncerInteractor.authenticate(listOf(3, 4, 5, 6))
} }
assertThat(isMessageUpdateAnimationsEnabled).isFalse() assertThat(message?.isUpdateAnimated).isFalse()
throttling?.totalDurationSec?.let { seconds -> advanceTimeBy(seconds * 1000L) } throttling?.totalDurationSec?.let { seconds -> advanceTimeBy(seconds * 1000L) }
assertThat(isMessageUpdateAnimationsEnabled).isTrue() assertThat(message?.isUpdateAnimated).isTrue()
} }
@Test @Test

View File

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

View File

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

View File

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