Merge changes from topic "bouncer-scene-throttling-b280877228" into udc-dev am: 637fb7497b am: 740e35ea70
Original change: https://googleplex-android-review.googlesource.com/c/platform/frameworks/base/+/23353743 Change-Id: I000af751c2e189dfa5a0397caceab377fe8435dc Signed-off-by: Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>
This commit is contained in:
@@ -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) }
|
||||
}
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user