Merge "Implement auto-confirm hinting" into udc-qpr-dev
This commit is contained in:
committed by
Android (Google) Code Review
commit
500513819b
@@ -14,63 +14,40 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalAnimationApi::class, ExperimentalAnimationGraphicsApi::class)
|
||||
|
||||
package com.android.systemui.bouncer.ui.composable
|
||||
|
||||
import android.view.HapticFeedbackConstants
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.AnimationVector1D
|
||||
import androidx.compose.animation.core.MutableTransitionState
|
||||
import androidx.compose.animation.core.Transition
|
||||
import androidx.compose.animation.core.animateDp
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.updateTransition
|
||||
import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi
|
||||
import androidx.compose.animation.graphics.res.animatedVectorResource
|
||||
import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
|
||||
import androidx.compose.animation.graphics.vector.AnimatedImageVector
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.android.compose.animation.Easings
|
||||
@@ -78,7 +55,6 @@ import com.android.compose.grid.VerticalGrid
|
||||
import com.android.compose.modifiers.thenIf
|
||||
import com.android.systemui.R
|
||||
import com.android.systemui.bouncer.ui.viewmodel.ActionButtonAppearance
|
||||
import com.android.systemui.bouncer.ui.viewmodel.EnteredKey
|
||||
import com.android.systemui.bouncer.ui.viewmodel.PinBouncerViewModel
|
||||
import com.android.systemui.common.shared.model.ContentDescription
|
||||
import com.android.systemui.common.shared.model.Icon
|
||||
@@ -108,147 +84,6 @@ internal fun PinBouncer(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinInputDisplay(viewModel: PinBouncerViewModel) {
|
||||
val currentPinEntries: List<EnteredKey> by viewModel.pinEntries.collectAsState()
|
||||
|
||||
// visiblePinEntries keeps pins removed from currentPinEntries in the composition until their
|
||||
// disappear-animation completed. The list is sorted by the natural ordering of EnteredKey,
|
||||
// which is guaranteed to produce the original edit order, since the model only modifies entries
|
||||
// at the end.
|
||||
val visiblePinEntries = remember { SnapshotStateList<EnteredKey>() }
|
||||
currentPinEntries.forEach {
|
||||
val index = visiblePinEntries.binarySearch(it)
|
||||
if (index < 0) {
|
||||
val insertionPoint = -(index + 1)
|
||||
visiblePinEntries.add(insertionPoint, it)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.heightIn(min = entryShapeSize)
|
||||
// Pins overflowing horizontally should still be shown as scrolling.
|
||||
.wrapContentSize(unbounded = true),
|
||||
) {
|
||||
visiblePinEntries.forEachIndexed { index, entry ->
|
||||
key(entry) {
|
||||
val visibility = remember {
|
||||
MutableTransitionState<EntryVisibility>(EntryVisibility.Hidden)
|
||||
}
|
||||
visibility.targetState =
|
||||
when {
|
||||
currentPinEntries.isEmpty() && visiblePinEntries.size > 1 ->
|
||||
EntryVisibility.BulkHidden(index, visiblePinEntries.size)
|
||||
currentPinEntries.contains(entry) -> EntryVisibility.Shown
|
||||
else -> EntryVisibility.Hidden
|
||||
}
|
||||
|
||||
val shape = viewModel.pinShapes.getShape(entry.sequenceNumber)
|
||||
PinInputEntry(shape, updateTransition(visibility, label = "Pin Entry $entry"))
|
||||
|
||||
LaunchedEffect(entry) {
|
||||
// Remove entry from visiblePinEntries once the hide transition completed.
|
||||
snapshotFlow {
|
||||
visibility.currentState == visibility.targetState &&
|
||||
visibility.targetState != EntryVisibility.Shown
|
||||
}
|
||||
.collect { isRemoved ->
|
||||
if (isRemoved) {
|
||||
visiblePinEntries.remove(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class EntryVisibility {
|
||||
object Shown : EntryVisibility()
|
||||
|
||||
object Hidden : EntryVisibility()
|
||||
|
||||
/**
|
||||
* Same as [Hidden], but applies when multiple entries are hidden simultaneously, without
|
||||
* collapsing during the hide.
|
||||
*/
|
||||
data class BulkHidden(val staggerIndex: Int, val totalEntryCount: Int) : EntryVisibility()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinInputEntry(shapeResourceId: Int, transition: Transition<EntryVisibility>) {
|
||||
// spec: http://shortn/_DEhE3Xl2bi
|
||||
val dismissStaggerDelayMs = 33
|
||||
val dismissDurationMs = 450
|
||||
val expansionDurationMs = 250
|
||||
val shapeCollapseDurationMs = 200
|
||||
|
||||
val animatedEntryWidth by
|
||||
transition.animateDp(
|
||||
transitionSpec = {
|
||||
when (val target = targetState) {
|
||||
is EntryVisibility.BulkHidden ->
|
||||
// only collapse horizontal space once all entries are removed
|
||||
snap(dismissDurationMs + dismissStaggerDelayMs * target.totalEntryCount)
|
||||
else -> tween(expansionDurationMs, easing = Easings.Standard)
|
||||
}
|
||||
},
|
||||
label = "entry space"
|
||||
) { state ->
|
||||
if (state == EntryVisibility.Shown) entryShapeSize else 0.dp
|
||||
}
|
||||
|
||||
val animatedShapeSize by
|
||||
transition.animateDp(
|
||||
transitionSpec = {
|
||||
when {
|
||||
EntryVisibility.Hidden isTransitioningTo EntryVisibility.Shown -> {
|
||||
// The AVD contains the entry transition.
|
||||
snap()
|
||||
}
|
||||
targetState is EntryVisibility.BulkHidden -> {
|
||||
val target = targetState as EntryVisibility.BulkHidden
|
||||
tween(
|
||||
dismissDurationMs,
|
||||
delayMillis = target.staggerIndex * dismissStaggerDelayMs,
|
||||
easing = Easings.Legacy,
|
||||
)
|
||||
}
|
||||
else -> tween(shapeCollapseDurationMs, easing = Easings.StandardDecelerate)
|
||||
}
|
||||
},
|
||||
label = "shape size"
|
||||
) { state ->
|
||||
if (state == EntryVisibility.Shown) entryShapeSize else 0.dp
|
||||
}
|
||||
|
||||
val dotColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Layout(
|
||||
content = {
|
||||
val image = AnimatedImageVector.animatedVectorResource(shapeResourceId)
|
||||
var atEnd by remember { mutableStateOf(false) }
|
||||
Image(
|
||||
painter = rememberAnimatedVectorPainter(image, atEnd),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
colorFilter = ColorFilter.tint(dotColor),
|
||||
)
|
||||
LaunchedEffect(Unit) { atEnd = true }
|
||||
}
|
||||
) { measurables, _ ->
|
||||
val shapeSizePx = animatedShapeSize.roundToPx()
|
||||
val placeable = measurables.single().measure(Constraints.fixed(shapeSizePx, shapeSizePx))
|
||||
|
||||
layout(animatedEntryWidth.roundToPx(), entryShapeSize.roundToPx()) {
|
||||
placeable.place(
|
||||
((animatedEntryWidth - animatedShapeSize) / 2f).roundToPx(),
|
||||
((entryShapeSize - animatedShapeSize) / 2f).roundToPx()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinPad(viewModel: PinBouncerViewModel) {
|
||||
val isInputEnabled: Boolean by viewModel.isInputEnabled.collectAsState()
|
||||
@@ -511,8 +346,6 @@ private suspend fun showFailureAnimation(
|
||||
}
|
||||
}
|
||||
|
||||
private val entryShapeSize = 30.dp
|
||||
|
||||
private val pinButtonSize = 84.dp
|
||||
private val pinButtonErrorShrinkFactor = 67.dp / pinButtonSize
|
||||
private const val pinButtonErrorShrinkMs = 50
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalAnimationGraphicsApi::class)
|
||||
|
||||
package com.android.systemui.bouncer.ui.composable
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi
|
||||
import androidx.compose.animation.graphics.res.animatedVectorResource
|
||||
import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
|
||||
import androidx.compose.animation.graphics.vector.AnimatedImageVector
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.android.compose.animation.Easings
|
||||
import com.android.keyguard.PinShapeAdapter
|
||||
import com.android.systemui.R
|
||||
import com.android.systemui.bouncer.ui.viewmodel.EntryToken.Digit
|
||||
import com.android.systemui.bouncer.ui.viewmodel.PinBouncerViewModel
|
||||
import com.android.systemui.bouncer.ui.viewmodel.PinInputViewModel
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun PinInputDisplay(viewModel: PinBouncerViewModel) {
|
||||
val hintedPinLength: Int? by viewModel.hintedPinLength.collectAsState()
|
||||
val shapeAnimations = rememberShapeAnimations(viewModel.pinShapes)
|
||||
|
||||
// The display comes in two different flavors:
|
||||
// 1) hinting: shows a circle (◦) per expected pin input, and dot (●) per entered digit.
|
||||
// This has a fixed width, and uses two distinct types of AVDs to animate the addition and
|
||||
// removal of digits.
|
||||
// 2) regular, shows a dot (●) per entered digit.
|
||||
// This grows/shrinks as digits are added deleted. Uses the same type of AVDs to animate the
|
||||
// addition of digits, but simply center-shrinks the dot (●) shape to zero to animate the
|
||||
// removal.
|
||||
// Because of all these differences, there are two separate implementations, rather than
|
||||
// unifying into a single, more complex implementation.
|
||||
|
||||
when (val length = hintedPinLength) {
|
||||
null -> RegularPinInputDisplay(viewModel, shapeAnimations)
|
||||
else -> HintingPinInputDisplay(viewModel, shapeAnimations, length)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A pin input display that shows a placeholder circle (◦) for every digit in the pin not yet
|
||||
* entered.
|
||||
*
|
||||
* Used for auto-confirmed pins of a specific length, see design: http://shortn/_jS8kPzQ7QV
|
||||
*/
|
||||
@Composable
|
||||
private fun HintingPinInputDisplay(
|
||||
viewModel: PinBouncerViewModel,
|
||||
shapeAnimations: ShapeAnimations,
|
||||
hintedPinLength: Int,
|
||||
) {
|
||||
val pinInput: PinInputViewModel by viewModel.pinInput.collectAsState()
|
||||
// [ClearAll] marker pointing at the beginning of the current pin input.
|
||||
// When a new [ClearAll] token is added to the [pinInput], the clear-all animation is played
|
||||
// and the marker is advanced manually to the most recent marker. See LaunchedEffect below.
|
||||
var currentClearAll by remember { mutableStateOf(pinInput.mostRecentClearAll()) }
|
||||
// The length of the pin currently entered by the user.
|
||||
val currentPinLength = pinInput.getDigits(currentClearAll).size
|
||||
|
||||
// The animated vector drawables for each of the [hintedPinLength] slots.
|
||||
// The first [currentPinLength] drawables end in a dot (●) shape, the remaining drawables up to
|
||||
// [hintedPinLength] end in the circle (◦) shape.
|
||||
// This list is re-generated upon each pin entry, it is modelled as a [MutableStateList] to
|
||||
// allow the clear-all animation to replace the shapes asynchronously, see LaunchedEffect below.
|
||||
// Note that when a [ClearAll] token is added to the input (and the clear-all animation plays)
|
||||
// the [currentPinLength] does not change; the [pinEntryDrawable] is remembered until the
|
||||
// clear-all animation finishes and the [currentClearAll] state is manually advanced.
|
||||
val pinEntryDrawable =
|
||||
remember(currentPinLength) {
|
||||
buildList {
|
||||
repeat(currentPinLength) { add(shapeAnimations.getShapeToDot(it)) }
|
||||
repeat(hintedPinLength - currentPinLength) { add(shapeAnimations.dotToCircle) }
|
||||
}
|
||||
.toMutableStateList()
|
||||
}
|
||||
|
||||
val mostRecentClearAll = pinInput.mostRecentClearAll()
|
||||
// Whenever a new [ClearAll] marker is added to the input, the clear-all animation needs to
|
||||
// be played.
|
||||
LaunchedEffect(mostRecentClearAll) {
|
||||
if (currentClearAll == mostRecentClearAll) {
|
||||
// Except during the initial composition.
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
// Staggered replace of all dot (●) shapes with an animation from dot (●) to circle (◦).
|
||||
for (index in 0 until hintedPinLength) {
|
||||
if (!shapeAnimations.isDotShape(pinEntryDrawable[index])) break
|
||||
|
||||
pinEntryDrawable[index] = shapeAnimations.dotToCircle
|
||||
delay(shapeAnimations.dismissStaggerDelay)
|
||||
}
|
||||
|
||||
// Once the animation is done, start processing the next pin input again.
|
||||
currentClearAll = mostRecentClearAll
|
||||
}
|
||||
|
||||
// During the initial composition, do not play the [pinEntryDrawable] animations. This prevents
|
||||
// the dot (●) to circle (◦) animation when the empty display becomes first visible, and a
|
||||
// superfluous shape to dot (●) animation after for example device rotation.
|
||||
var playAnimation by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) { playAnimation = true }
|
||||
|
||||
val dotColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Row(modifier = Modifier.heightIn(min = shapeAnimations.shapeSize)) {
|
||||
pinEntryDrawable.forEachIndexed { index, drawable ->
|
||||
// Key the loop by [index] and [drawable], so that updating a shape drawable at the same
|
||||
// index will play the new animation (by remembering a new [atEnd]).
|
||||
key(index, drawable) {
|
||||
// [rememberAnimatedVectorPainter] requires a `atEnd` boolean to switch from `false`
|
||||
// to `true` for the animation to play. This animation is suppressed when
|
||||
// playAnimation is false, always rendering the end-state of the animation.
|
||||
var atEnd by remember { mutableStateOf(!playAnimation) }
|
||||
LaunchedEffect(Unit) { atEnd = true }
|
||||
|
||||
Image(
|
||||
painter = rememberAnimatedVectorPainter(drawable, atEnd),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
colorFilter = ColorFilter.tint(dotColor),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A pin input that shows a dot (●) for each entered pin, horizontally centered and growing /
|
||||
* shrinking as more digits are entered and deleted.
|
||||
*
|
||||
* Used for pin input when the pin length is not hinted, see design http://shortn/_wNP7SrBD78
|
||||
*/
|
||||
@Composable
|
||||
private fun RegularPinInputDisplay(
|
||||
viewModel: PinBouncerViewModel,
|
||||
shapeAnimations: ShapeAnimations,
|
||||
) {
|
||||
// Holds all currently [VisiblePinEntry] composables. This cannot be simply derived from
|
||||
// `viewModel.pinInput` at composition, since deleting a pin entry needs to play a remove
|
||||
// animation, thus the composable to be removed has to remain in the composition until fully
|
||||
// disappeared (see `prune` launched effect below)
|
||||
val pinInputRow = remember(shapeAnimations) { PinInputRow(shapeAnimations) }
|
||||
|
||||
// Processed `viewModel.pinInput` updates and applies them to [pinDigitShapes]
|
||||
LaunchedEffect(viewModel.pinInput, pinInputRow) {
|
||||
// Initial setup: capture the most recent [ClearAll] marker and create the visuals for the
|
||||
// existing digits (if any) without animation..
|
||||
var currentClearAll =
|
||||
with(viewModel.pinInput.value) {
|
||||
val initialClearAll = mostRecentClearAll()
|
||||
pinInputRow.setDigits(getDigits(initialClearAll))
|
||||
initialClearAll
|
||||
}
|
||||
|
||||
viewModel.pinInput.collect { input ->
|
||||
// Process additions and removals of pins within the current input block.
|
||||
pinInputRow.updateDigits(input.getDigits(currentClearAll), scope = this@LaunchedEffect)
|
||||
|
||||
val mostRecentClearAll = input.mostRecentClearAll()
|
||||
if (currentClearAll != mostRecentClearAll) {
|
||||
// A new [ClearAll] token is added to the [input], play the clear-all animation
|
||||
pinInputRow.playClearAllAnimation()
|
||||
|
||||
// Animation finished, advance manually to the next marker.
|
||||
currentClearAll = mostRecentClearAll
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(pinInputRow) {
|
||||
// Prunes unused VisiblePinEntries once they are no longer visible.
|
||||
snapshotFlow { pinInputRow.hasUnusedEntries() }
|
||||
.collect { hasUnusedEntries ->
|
||||
if (hasUnusedEntries) {
|
||||
pinInputRow.prune()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pinInputRow.Content()
|
||||
}
|
||||
|
||||
private class PinInputRow(
|
||||
val shapeAnimations: ShapeAnimations,
|
||||
) {
|
||||
private val entries = mutableStateListOf<PinInputEntry>()
|
||||
|
||||
@Composable
|
||||
fun Content() {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.heightIn(min = shapeAnimations.shapeSize)
|
||||
// Pins overflowing horizontally should still be shown as scrolling.
|
||||
.wrapContentSize(unbounded = true),
|
||||
) {
|
||||
entries.forEach { entry -> key(entry.digit) { entry.Content() } }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces all current [PinInputEntry] composables with new instances for each digit.
|
||||
*
|
||||
* Does not play the entry expansion animation.
|
||||
*/
|
||||
fun setDigits(digits: List<Digit>) {
|
||||
entries.clear()
|
||||
entries.addAll(digits.map { PinInputEntry(it, shapeAnimations) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds [PinInputEntry] composables for new digits and plays an entry animation, and starts the
|
||||
* exit animation for digits not in [updated] anymore.
|
||||
*
|
||||
* The function return immediately, playing the animations in the background.
|
||||
*
|
||||
* Removed entries have to be [prune]d once the exit animation completes, [hasUnusedEntries] can
|
||||
* be used in a [SnapshotFlow] to discover when its time to do so.
|
||||
*/
|
||||
fun updateDigits(updated: List<Digit>, scope: CoroutineScope) {
|
||||
val incoming = updated.minus(entries.map { it.digit }.toSet()).toList()
|
||||
val outgoing = entries.filterNot { entry -> updated.any { entry.digit == it } }.toList()
|
||||
|
||||
entries.addAll(
|
||||
incoming.map {
|
||||
PinInputEntry(it, shapeAnimations).apply { scope.launch { animateAppearance() } }
|
||||
}
|
||||
)
|
||||
|
||||
outgoing.forEach { entry -> scope.launch { entry.animateRemoval() } }
|
||||
|
||||
entries.sortWith(compareBy { it.digit })
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays a staggered remove animation, and upon completion removes the [PinInputEntry]
|
||||
* composables.
|
||||
*
|
||||
* This function returns once the animation finished playing and the entries are removed.
|
||||
*/
|
||||
suspend fun playClearAllAnimation() = coroutineScope {
|
||||
val entriesToRemove = entries.toList()
|
||||
entriesToRemove
|
||||
.mapIndexed { index, entry ->
|
||||
launch {
|
||||
delay(shapeAnimations.dismissStaggerDelay * index)
|
||||
entry.animateClearAllCollapse()
|
||||
}
|
||||
}
|
||||
.joinAll()
|
||||
|
||||
// Remove all [PinInputEntry] composables for which the staggered remove animation was
|
||||
// played. Note that up to now, each PinInputEntry still occupied the full width.
|
||||
entries.removeAll(entriesToRemove)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether there are [PinInputEntry] that can be removed from the composition since they were
|
||||
* fully animated out.
|
||||
*/
|
||||
fun hasUnusedEntries(): Boolean {
|
||||
return entries.any { it.isUnused }
|
||||
}
|
||||
|
||||
/** Remove all no longer visible [PinInputEntry]s from the composition. */
|
||||
fun prune() {
|
||||
entries.removeAll { it.isUnused }
|
||||
}
|
||||
}
|
||||
|
||||
private class PinInputEntry(
|
||||
val digit: Digit,
|
||||
val shapeAnimations: ShapeAnimations,
|
||||
) {
|
||||
private val shape = shapeAnimations.getShapeToDot(digit.sequenceNumber)
|
||||
// horizontal space occupied, used to shift contents as individual digits are animated in/out
|
||||
private val entryWidth =
|
||||
Animatable(shapeAnimations.shapeSize, Dp.VectorConverter, label = "Width of pin ($digit)")
|
||||
// intrinsic width and height of the shape, used to collapse the shape during exit animations.
|
||||
private val shapeSize =
|
||||
Animatable(shapeAnimations.shapeSize, Dp.VectorConverter, label = "Size of pin ($digit)")
|
||||
|
||||
/**
|
||||
* Whether the is fully animated out. When `true`, removing this from the composable won't have
|
||||
* visual effects.
|
||||
*/
|
||||
val isUnused: Boolean
|
||||
get() {
|
||||
return entryWidth.targetValue == 0.dp && !entryWidth.isRunning
|
||||
}
|
||||
|
||||
/** Animate the shape appearance by growing the entry width from 0.dp to the intrinsic width. */
|
||||
suspend fun animateAppearance() = coroutineScope {
|
||||
entryWidth.snapTo(0.dp)
|
||||
entryWidth.animateTo(shapeAnimations.shapeSize, shapeAnimations.inputShiftAnimationSpec)
|
||||
}
|
||||
|
||||
/**
|
||||
* Animates shape disappearance by collapsing the shape and occupied horizontal space.
|
||||
*
|
||||
* Once complete, [isUnused] will return `true`.
|
||||
*/
|
||||
suspend fun animateRemoval() = coroutineScope {
|
||||
awaitAll(
|
||||
async { entryWidth.animateTo(0.dp, shapeAnimations.inputShiftAnimationSpec) },
|
||||
async { shapeSize.animateTo(0.dp, shapeAnimations.deleteShapeSizeAnimationSpec) }
|
||||
)
|
||||
}
|
||||
|
||||
/** Collapses the shape in place, while still holding on to the horizontal space. */
|
||||
suspend fun animateClearAllCollapse() = coroutineScope {
|
||||
shapeSize.animateTo(0.dp, shapeAnimations.clearAllShapeSizeAnimationSpec)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Content() {
|
||||
val animatedShapeSize by shapeSize.asState()
|
||||
val animatedEntryWidth by entryWidth.asState()
|
||||
|
||||
val dotColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val shapeHeight = shapeAnimations.shapeSize
|
||||
var atEnd by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) { atEnd = true }
|
||||
Image(
|
||||
painter = rememberAnimatedVectorPainter(shape, atEnd),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
colorFilter = ColorFilter.tint(dotColor),
|
||||
modifier =
|
||||
Modifier.layout { measurable, _ ->
|
||||
val shapeSizePx = animatedShapeSize.roundToPx()
|
||||
val placeable = measurable.measure(Constraints.fixed(shapeSizePx, shapeSizePx))
|
||||
|
||||
layout(animatedEntryWidth.roundToPx(), shapeHeight.roundToPx()) {
|
||||
placeable.place(
|
||||
((animatedEntryWidth - animatedShapeSize) / 2f).roundToPx(),
|
||||
((shapeHeight - animatedShapeSize) / 2f).roundToPx()
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Animated Vector Drawables used to render the pin input. */
|
||||
private class ShapeAnimations(
|
||||
/** Width and height for all the animation images listed here. */
|
||||
val shapeSize: Dp,
|
||||
/** Transitions from the dot (●) to the circle (◦). Used for the hinting pin input only. */
|
||||
val dotToCircle: AnimatedImageVector,
|
||||
/** Each of the animations transition from nothing via a shape to the dot (●). */
|
||||
private val shapesToDot: List<AnimatedImageVector>,
|
||||
) {
|
||||
/**
|
||||
* Returns a transition from nothing via shape to the dot (●)., specific to the input position.
|
||||
*/
|
||||
fun getShapeToDot(position: Int): AnimatedImageVector {
|
||||
return shapesToDot[position.mod(shapesToDot.size)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the [shapeAnimation] is a image returned by [getShapeToDot], and thus is ending in
|
||||
* the dot (●) shape.
|
||||
*
|
||||
* `false` if the shape's end state is the circle (◦).
|
||||
*/
|
||||
fun isDotShape(shapeAnimation: AnimatedImageVector): Boolean {
|
||||
return shapeAnimation != dotToCircle
|
||||
}
|
||||
|
||||
// spec: http://shortn/_DEhE3Xl2bi
|
||||
val dismissStaggerDelay = 33.milliseconds
|
||||
val inputShiftAnimationSpec = tween<Dp>(durationMillis = 250, easing = Easings.Standard)
|
||||
val deleteShapeSizeAnimationSpec =
|
||||
tween<Dp>(durationMillis = 200, easing = Easings.StandardDecelerate)
|
||||
val clearAllShapeSizeAnimationSpec = tween<Dp>(durationMillis = 450, easing = Easings.Legacy)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberShapeAnimations(pinShapes: PinShapeAdapter): ShapeAnimations {
|
||||
// NOTE: `animatedVectorResource` does remember the returned AnimatedImageVector.
|
||||
val dotToCircle = AnimatedImageVector.animatedVectorResource(R.drawable.pin_dot_delete_avd)
|
||||
val shapesToDot = pinShapes.shapes.map { AnimatedImageVector.animatedVectorResource(it) }
|
||||
val shapeSize = dimensionResource(R.dimen.password_shape_size)
|
||||
|
||||
return remember(dotToCircle, shapesToDot, shapeSize) {
|
||||
ShapeAnimations(shapeSize, dotToCircle, shapesToDot)
|
||||
}
|
||||
}
|
||||
@@ -40,9 +40,10 @@ class PinBouncerViewModel(
|
||||
) {
|
||||
|
||||
val pinShapes = PinShapeAdapter(applicationContext)
|
||||
private val mutablePinInput = MutableStateFlow(PinInputViewModel.empty())
|
||||
|
||||
private val mutablePinEntries = MutableStateFlow<List<EnteredKey>>(emptyList())
|
||||
val pinEntries: StateFlow<List<EnteredKey>> = mutablePinEntries
|
||||
/** Currently entered pin keys. */
|
||||
val pinInput: StateFlow<PinInputViewModel> = mutablePinInput
|
||||
|
||||
/** The length of the PIN for which we should show a hint. */
|
||||
val hintedPinLength: StateFlow<Int?> = interactor.hintedPinLength
|
||||
@@ -50,11 +51,11 @@ class PinBouncerViewModel(
|
||||
/** Appearance of the backspace button. */
|
||||
val backspaceButtonAppearance: StateFlow<ActionButtonAppearance> =
|
||||
combine(
|
||||
mutablePinEntries,
|
||||
mutablePinInput,
|
||||
interactor.isAutoConfirmEnabled,
|
||||
) { mutablePinEntries, isAutoConfirmEnabled ->
|
||||
computeBackspaceButtonAppearance(
|
||||
enteredPin = mutablePinEntries,
|
||||
pinInput = mutablePinEntries,
|
||||
isAutoConfirmEnabled = isAutoConfirmEnabled,
|
||||
)
|
||||
}
|
||||
@@ -89,26 +90,23 @@ class PinBouncerViewModel(
|
||||
|
||||
/** Notifies that the user clicked on a PIN button with the given digit value. */
|
||||
fun onPinButtonClicked(input: Int) {
|
||||
if (mutablePinEntries.value.isEmpty()) {
|
||||
val pinInput = mutablePinInput.value
|
||||
if (pinInput.isEmpty()) {
|
||||
interactor.clearMessage()
|
||||
}
|
||||
|
||||
mutablePinEntries.value += EnteredKey(input)
|
||||
|
||||
mutablePinInput.value = pinInput.append(input)
|
||||
tryAuthenticate(useAutoConfirm = true)
|
||||
}
|
||||
|
||||
/** Notifies that the user clicked the backspace button. */
|
||||
fun onBackspaceButtonClicked() {
|
||||
if (mutablePinEntries.value.isEmpty()) {
|
||||
return
|
||||
}
|
||||
mutablePinEntries.value = mutablePinEntries.value.toMutableList().apply { removeLast() }
|
||||
mutablePinInput.value = mutablePinInput.value.deleteLast()
|
||||
}
|
||||
|
||||
/** Notifies that the user long-pressed the backspace button. */
|
||||
fun onBackspaceButtonLongPressed() {
|
||||
mutablePinEntries.value = emptyList()
|
||||
mutablePinInput.value = mutablePinInput.value.clearAll()
|
||||
}
|
||||
|
||||
/** Notifies that the user clicked the "enter" button. */
|
||||
@@ -117,7 +115,7 @@ class PinBouncerViewModel(
|
||||
}
|
||||
|
||||
private fun tryAuthenticate(useAutoConfirm: Boolean) {
|
||||
val pinCode = mutablePinEntries.value.map { it.input }
|
||||
val pinCode = mutablePinInput.value.getPin()
|
||||
|
||||
applicationScope.launch {
|
||||
val isSuccess = interactor.authenticate(pinCode, useAutoConfirm) ?: return@launch
|
||||
@@ -126,15 +124,17 @@ class PinBouncerViewModel(
|
||||
showFailureAnimation()
|
||||
}
|
||||
|
||||
mutablePinEntries.value = emptyList()
|
||||
// TODO(b/291528545): this should not be cleared on success (at least until the view
|
||||
// is animated away).
|
||||
mutablePinInput.value = mutablePinInput.value.clearAll()
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeBackspaceButtonAppearance(
|
||||
enteredPin: List<EnteredKey>,
|
||||
pinInput: PinInputViewModel,
|
||||
isAutoConfirmEnabled: Boolean,
|
||||
): ActionButtonAppearance {
|
||||
val isEmpty = enteredPin.isEmpty()
|
||||
val isEmpty = pinInput.isEmpty()
|
||||
|
||||
return when {
|
||||
isAutoConfirmEnabled && isEmpty -> ActionButtonAppearance.Hidden
|
||||
@@ -153,19 +153,3 @@ enum class ActionButtonAppearance {
|
||||
/** Button is shown. */
|
||||
Shown,
|
||||
}
|
||||
|
||||
private var nextSequenceNumber = 1
|
||||
|
||||
/**
|
||||
* The pin bouncer [input] as digits 0-9, together with a [sequenceNumber] to indicate the ordering.
|
||||
*
|
||||
* Since the model only allows appending/removing [EnteredKey]s from the end, the [sequenceNumber]
|
||||
* is strictly increasing in input order of the pin, but not guaranteed to be monotonic or start at
|
||||
* a specific number.
|
||||
*/
|
||||
data class EnteredKey
|
||||
internal constructor(val input: Int, val sequenceNumber: Int = nextSequenceNumber++) :
|
||||
Comparable<EnteredKey> {
|
||||
override fun compareTo(other: EnteredKey): Int =
|
||||
compareValuesBy(this, other, EnteredKey::sequenceNumber)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* 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.annotation.VisibleForTesting
|
||||
import com.android.systemui.bouncer.ui.viewmodel.EntryToken.ClearAll
|
||||
import com.android.systemui.bouncer.ui.viewmodel.EntryToken.Digit
|
||||
|
||||
/**
|
||||
* Immutable pin input state.
|
||||
*
|
||||
* The input is a hybrid of state ([Digit]) and event ([ClearAll]) tokens. The [ClearAll] token can
|
||||
* be interpreted as a watermark, indicating that the current input up to that point is deleted
|
||||
* (after a auth failure or when long-pressing the delete button). Therefore, [Digit]s following a
|
||||
* [ClearAll] make up the next pin input entry. Up to two complete pin inputs are memoized.
|
||||
*
|
||||
* This is required when auto-confirm rejects the input, and the last digit will be animated-in at
|
||||
* the end of the input, concurrently with the staggered clear-all animation starting to play at the
|
||||
* beginning of the input.
|
||||
*
|
||||
* The input is guaranteed to always contain a initial [ClearAll] token as a sentinel, thus clients
|
||||
* can always assume there is a 'ClearAll' watermark available.
|
||||
*/
|
||||
data class PinInputViewModel
|
||||
internal constructor(
|
||||
@get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
internal val input: List<EntryToken>
|
||||
) {
|
||||
init {
|
||||
require(input.firstOrNull() is ClearAll) { "input does not begin with a ClearAll token" }
|
||||
require(input.zipWithNext().all { it.first < it.second }) {
|
||||
"EntryTokens are not sorted by their sequenceNumber"
|
||||
}
|
||||
}
|
||||
/**
|
||||
* [PinInputViewModel] with [previousInput] and appended [newToken].
|
||||
*
|
||||
* [previousInput] is trimmed so that the new [PinBouncerViewModel] contains at most two pin
|
||||
* inputs.
|
||||
*/
|
||||
private constructor(
|
||||
previousInput: List<EntryToken>,
|
||||
newToken: EntryToken
|
||||
) : this(
|
||||
buildList {
|
||||
addAll(
|
||||
previousInput.subList(previousInput.indexOfLastClearAllToKeep(), previousInput.size)
|
||||
)
|
||||
add(newToken)
|
||||
}
|
||||
)
|
||||
|
||||
fun append(digit: Int): PinInputViewModel {
|
||||
return PinInputViewModel(input, Digit(digit))
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete last digit.
|
||||
*
|
||||
* This removes the last digit from the input. Returns `this` if the last token is [ClearAll].
|
||||
*/
|
||||
fun deleteLast(): PinInputViewModel {
|
||||
if (isEmpty()) return this
|
||||
return PinInputViewModel(input.take(input.size - 1))
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a [ClearAll] watermark, completing the current pin.
|
||||
*
|
||||
* Returns `this` if the last token is [ClearAll].
|
||||
*/
|
||||
fun clearAll(): PinInputViewModel {
|
||||
if (isEmpty()) return this
|
||||
return PinInputViewModel(input, ClearAll())
|
||||
}
|
||||
|
||||
/** Whether the current pin is empty. */
|
||||
fun isEmpty(): Boolean {
|
||||
return input.last() is ClearAll
|
||||
}
|
||||
|
||||
/** The current pin, or an empty list if [isEmpty]. */
|
||||
fun getPin(): List<Int> {
|
||||
return getDigits(mostRecentClearAll()).map { it.input }
|
||||
}
|
||||
|
||||
/**
|
||||
* The digits following the specified [ClearAll] marker, up to the next marker or the end of the
|
||||
* input.
|
||||
*
|
||||
* Returns an empty list if the [ClearAll] is not in the input.
|
||||
*/
|
||||
fun getDigits(clearAllMarker: ClearAll): List<Digit> {
|
||||
val startIndex = input.indexOf(clearAllMarker) + 1
|
||||
if (startIndex == 0 || startIndex == input.size) return emptyList()
|
||||
|
||||
return input.subList(startIndex, input.size).takeWhile { it is Digit }.map { it as Digit }
|
||||
}
|
||||
|
||||
/** The most recent [ClearAll] marker. */
|
||||
fun mostRecentClearAll(): ClearAll {
|
||||
return input.last { it is ClearAll } as ClearAll
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun empty() = PinInputViewModel(listOf(ClearAll()))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin bouncer entry token with a [sequenceNumber] to indicate input event ordering.
|
||||
*
|
||||
* Since the model only allows appending/removing [Digit]s from the end, the [sequenceNumber] is
|
||||
* strictly increasing in input order of the pin, but not guaranteed to be monotonic or start at a
|
||||
* specific number.
|
||||
*/
|
||||
sealed interface EntryToken : Comparable<EntryToken> {
|
||||
val sequenceNumber: Int
|
||||
|
||||
/** The pin bouncer [input] as digits 0-9. */
|
||||
data class Digit
|
||||
internal constructor(val input: Int, override val sequenceNumber: Int = nextSequenceNumber++) :
|
||||
EntryToken {
|
||||
init {
|
||||
check(input in 0..9)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marker to indicate the input is completely cleared, and subsequent [EntryToken]s mark a new
|
||||
* pin entry.
|
||||
*/
|
||||
data class ClearAll
|
||||
internal constructor(override val sequenceNumber: Int = nextSequenceNumber++) : EntryToken
|
||||
|
||||
override fun compareTo(other: EntryToken): Int =
|
||||
compareValuesBy(this, other, EntryToken::sequenceNumber)
|
||||
|
||||
companion object {
|
||||
private var nextSequenceNumber = 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of the last [ClearAll] token to keep for a new [PinInputViewModel], so that after appending
|
||||
* another [EntryToken], there are at most two pin inputs in the [PinInputViewModel].
|
||||
*/
|
||||
private fun List<EntryToken>.indexOfLastClearAllToKeep(): Int {
|
||||
require(isNotEmpty() && first() is ClearAll)
|
||||
|
||||
var seenClearAll = 0
|
||||
for (i in size - 1 downTo 0) {
|
||||
if (get(i) is ClearAll) {
|
||||
seenClearAll++
|
||||
if (seenClearAll == 2) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The first element is guaranteed to be a ClearAll marker.
|
||||
return 0
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Before
|
||||
@@ -77,7 +78,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
|
||||
val currentScene by
|
||||
collectLastValue(sceneInteractor.currentScene(SceneTestUtils.CONTAINER_1))
|
||||
val message by collectLastValue(bouncerViewModel.message)
|
||||
val entries by collectLastValue(underTest.pinEntries)
|
||||
val pin by collectLastValue(underTest.pinInput.map { it.getPin() })
|
||||
utils.authenticationRepository.setUnlocked(false)
|
||||
sceneInteractor.setCurrentScene(
|
||||
SceneTestUtils.CONTAINER_1,
|
||||
@@ -88,7 +89,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
|
||||
underTest.onShown()
|
||||
|
||||
assertThat(message?.text).isEqualTo(ENTER_YOUR_PIN)
|
||||
assertThat(entries).hasSize(0)
|
||||
assertThat(pin).isEmpty()
|
||||
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
|
||||
}
|
||||
|
||||
@@ -98,7 +99,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
|
||||
val currentScene by
|
||||
collectLastValue(sceneInteractor.currentScene(SceneTestUtils.CONTAINER_1))
|
||||
val message by collectLastValue(bouncerViewModel.message)
|
||||
val entries by collectLastValue(underTest.pinEntries)
|
||||
val pin by collectLastValue(underTest.pinInput.map { it.getPin() })
|
||||
utils.authenticationRepository.setAuthenticationMethod(AuthenticationMethodModel.Pin)
|
||||
utils.authenticationRepository.setUnlocked(false)
|
||||
sceneInteractor.setCurrentScene(
|
||||
@@ -112,8 +113,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
|
||||
underTest.onPinButtonClicked(1)
|
||||
|
||||
assertThat(message?.text).isEmpty()
|
||||
assertThat(entries).hasSize(1)
|
||||
assertThat(entries?.map { it.input }).containsExactly(1)
|
||||
assertThat(pin).containsExactly(1)
|
||||
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
|
||||
val currentScene by
|
||||
collectLastValue(sceneInteractor.currentScene(SceneTestUtils.CONTAINER_1))
|
||||
val message by collectLastValue(bouncerViewModel.message)
|
||||
val entries by collectLastValue(underTest.pinEntries)
|
||||
val pin by collectLastValue(underTest.pinInput.map { it.getPin() })
|
||||
utils.authenticationRepository.setAuthenticationMethod(AuthenticationMethodModel.Pin)
|
||||
utils.authenticationRepository.setUnlocked(false)
|
||||
sceneInteractor.setCurrentScene(
|
||||
@@ -134,12 +134,12 @@ class PinBouncerViewModelTest : SysuiTestCase() {
|
||||
underTest.onShown()
|
||||
runCurrent()
|
||||
underTest.onPinButtonClicked(1)
|
||||
assertThat(entries).hasSize(1)
|
||||
assertThat(pin).hasSize(1)
|
||||
|
||||
underTest.onBackspaceButtonClicked()
|
||||
|
||||
assertThat(message?.text).isEmpty()
|
||||
assertThat(entries).hasSize(0)
|
||||
assertThat(pin).isEmpty()
|
||||
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
|
||||
testScope.runTest {
|
||||
val currentScene by
|
||||
collectLastValue(sceneInteractor.currentScene(SceneTestUtils.CONTAINER_1))
|
||||
val entries by collectLastValue(underTest.pinEntries)
|
||||
val pin by collectLastValue(underTest.pinInput.map { it.getPin() })
|
||||
utils.authenticationRepository.setAuthenticationMethod(AuthenticationMethodModel.Pin)
|
||||
utils.authenticationRepository.setUnlocked(false)
|
||||
sceneInteractor.setCurrentScene(
|
||||
@@ -166,9 +166,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
|
||||
underTest.onPinButtonClicked(4)
|
||||
underTest.onPinButtonClicked(5)
|
||||
|
||||
assertThat(entries).hasSize(3)
|
||||
assertThat(entries?.map { it.input }).containsExactly(1, 4, 5).inOrder()
|
||||
assertThat(entries?.map { it.sequenceNumber }).isInStrictOrder()
|
||||
assertThat(pin).containsExactly(1, 4, 5).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -177,7 +175,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
|
||||
val currentScene by
|
||||
collectLastValue(sceneInteractor.currentScene(SceneTestUtils.CONTAINER_1))
|
||||
val message by collectLastValue(bouncerViewModel.message)
|
||||
val entries by collectLastValue(underTest.pinEntries)
|
||||
val pin by collectLastValue(underTest.pinInput.map { it.getPin() })
|
||||
utils.authenticationRepository.setAuthenticationMethod(AuthenticationMethodModel.Pin)
|
||||
utils.authenticationRepository.setUnlocked(false)
|
||||
sceneInteractor.setCurrentScene(
|
||||
@@ -195,7 +193,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
|
||||
underTest.onBackspaceButtonLongPressed()
|
||||
|
||||
assertThat(message?.text).isEmpty()
|
||||
assertThat(entries).hasSize(0)
|
||||
assertThat(pin).isEmpty()
|
||||
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
|
||||
}
|
||||
|
||||
@@ -227,7 +225,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
|
||||
val currentScene by
|
||||
collectLastValue(sceneInteractor.currentScene(SceneTestUtils.CONTAINER_1))
|
||||
val message by collectLastValue(bouncerViewModel.message)
|
||||
val entries by collectLastValue(underTest.pinEntries)
|
||||
val pin by collectLastValue(underTest.pinInput.map { it.getPin() })
|
||||
utils.authenticationRepository.setAuthenticationMethod(AuthenticationMethodModel.Pin)
|
||||
utils.authenticationRepository.setUnlocked(false)
|
||||
sceneInteractor.setCurrentScene(
|
||||
@@ -244,7 +242,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
|
||||
|
||||
underTest.onAuthenticateButtonClicked()
|
||||
|
||||
assertThat(entries).hasSize(0)
|
||||
assertThat(pin).isEmpty()
|
||||
assertThat(message?.text).isEqualTo(WRONG_PIN)
|
||||
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
|
||||
}
|
||||
@@ -255,7 +253,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
|
||||
val currentScene by
|
||||
collectLastValue(sceneInteractor.currentScene(SceneTestUtils.CONTAINER_1))
|
||||
val message by collectLastValue(bouncerViewModel.message)
|
||||
val entries by collectLastValue(underTest.pinEntries)
|
||||
val pin by collectLastValue(underTest.pinInput.map { it.getPin() })
|
||||
utils.authenticationRepository.setAuthenticationMethod(AuthenticationMethodModel.Pin)
|
||||
utils.authenticationRepository.setUnlocked(false)
|
||||
sceneInteractor.setCurrentScene(
|
||||
@@ -271,7 +269,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
|
||||
underTest.onPinButtonClicked(5) // PIN is now wrong!
|
||||
underTest.onAuthenticateButtonClicked()
|
||||
assertThat(message?.text).isEqualTo(WRONG_PIN)
|
||||
assertThat(entries).hasSize(0)
|
||||
assertThat(pin).isEmpty()
|
||||
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
|
||||
|
||||
// Enter the correct PIN:
|
||||
@@ -312,7 +310,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
|
||||
val currentScene by
|
||||
collectLastValue(sceneInteractor.currentScene(SceneTestUtils.CONTAINER_1))
|
||||
val message by collectLastValue(bouncerViewModel.message)
|
||||
val entries by collectLastValue(underTest.pinEntries)
|
||||
val pin by collectLastValue(underTest.pinInput.map { it.getPin() })
|
||||
utils.authenticationRepository.setAuthenticationMethod(AuthenticationMethodModel.Pin)
|
||||
utils.authenticationRepository.setUnlocked(false)
|
||||
utils.authenticationRepository.setAutoConfirmEnabled(true)
|
||||
@@ -329,7 +327,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
|
||||
FakeAuthenticationRepository.DEFAULT_PIN.last() + 1
|
||||
) // PIN is now wrong!
|
||||
|
||||
assertThat(entries).hasSize(0)
|
||||
assertThat(pin).isEmpty()
|
||||
assertThat(message?.text).isEqualTo(WRONG_PIN)
|
||||
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
package com.android.systemui.bouncer.ui.viewmodel
|
||||
|
||||
import androidx.test.filters.SmallTest
|
||||
import com.android.systemui.SysuiTestCase
|
||||
import com.android.systemui.bouncer.ui.viewmodel.EntryToken.ClearAll
|
||||
import com.android.systemui.bouncer.ui.viewmodel.EntryToken.Digit
|
||||
import com.android.systemui.bouncer.ui.viewmodel.PinInputSubject.Companion.assertThat
|
||||
import com.android.systemui.bouncer.ui.viewmodel.PinInputViewModel.Companion.empty
|
||||
import com.google.common.truth.Fact
|
||||
import com.google.common.truth.FailureMetadata
|
||||
import com.google.common.truth.Subject
|
||||
import com.google.common.truth.Subject.Factory
|
||||
import com.google.common.truth.Truth.assertAbout
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import java.lang.Character.isDigit
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.JUnit4
|
||||
|
||||
/**
|
||||
* This test uses a mnemonic code to create and verify PinInput instances: strings of digits [0-9]
|
||||
* for [Digit] tokens, as well as a `C` for the [ClearAll] token.
|
||||
*/
|
||||
@SmallTest
|
||||
@RunWith(JUnit4::class)
|
||||
class PinInputViewModelTest : SysuiTestCase() {
|
||||
|
||||
@Test
|
||||
fun create_emptyList_throws() {
|
||||
assertThrows(IllegalArgumentException::class.java) { PinInputViewModel(emptyList()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun create_inputWithoutLeadingClearAll_throws() {
|
||||
val exception =
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
PinInputViewModel(listOf(Digit(0)))
|
||||
}
|
||||
assertThat(exception).hasMessageThat().contains("does not begin with a ClearAll token")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun create_inputNotInAscendingOrder_throws() {
|
||||
val sentinel = ClearAll()
|
||||
val first = Digit(0)
|
||||
val second = Digit(1)
|
||||
// [first] is created before [second] is created, thus their sequence numbers are ordered.
|
||||
check(first.sequenceNumber < second.sequenceNumber)
|
||||
|
||||
val exception =
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
// Passing the [Digit] tokens in reverse order throws.
|
||||
PinInputViewModel(listOf(sentinel, second, first))
|
||||
}
|
||||
assertThat(exception).hasMessageThat().contains("EntryTokens are not sorted")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun append_digitToEmptyInput() {
|
||||
val result = empty().append(0)
|
||||
assertThat(result).matches("C0")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun append_digitToExistingPin() {
|
||||
val subject = pinInput("C1")
|
||||
assertThat(subject.append(2)).matches("C12")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun append_withTwoCompletePinsEntered_dropsFirst() {
|
||||
val subject = pinInput("C12C34C")
|
||||
assertThat(subject.append(5)).matches("C34C5")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteLast_removesLastDigit() {
|
||||
val subject = pinInput("C12")
|
||||
assertThat(subject.deleteLast()).matches("C1")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteLast_onEmptyInput_returnsSameInstance() {
|
||||
val subject = empty()
|
||||
assertThat(subject.deleteLast()).isSameInstanceAs(subject)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteLast_onInputEndingInClearAll_returnsSameInstance() {
|
||||
val subject = pinInput("C12C")
|
||||
assertThat(subject.deleteLast()).isSameInstanceAs(subject)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearAll_appendsClearAllEntryToExistingInput() {
|
||||
val subject = pinInput("C12")
|
||||
assertThat(subject.clearAll()).matches("C12C")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearAll_onInputEndingInClearAll_returnsSameInstance() {
|
||||
val subject = pinInput("C12C")
|
||||
assertThat(subject.clearAll()).isSameInstanceAs(subject)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearAll_retainsUpToTwoPinEntries() {
|
||||
val subject = pinInput("C12C34")
|
||||
assertThat(subject.clearAll()).matches("C12C34C")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isEmpty_onEmptyInput_returnsTrue() {
|
||||
val subject = empty()
|
||||
assertThat(subject.isEmpty()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isEmpty_whenLastEntryIsDigit_returnsFalse() {
|
||||
val subject = pinInput("C1234")
|
||||
assertThat(subject.isEmpty()).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isEmpty_whenLastEntryIsClearAll_returnsTrue() {
|
||||
val subject = pinInput("C1234C")
|
||||
assertThat(subject.isEmpty()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getPin_onEmptyInput_returnsEmptyList() {
|
||||
val subject = empty()
|
||||
assertThat(subject.getPin()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getPin_whenLastEntryIsDigit_returnsPin() {
|
||||
val subject = pinInput("C1234")
|
||||
assertThat(subject.getPin()).containsExactly(1, 2, 3, 4)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getPin_withMultiplePins_returnsLastPin() {
|
||||
val subject = pinInput("C1234C5678")
|
||||
assertThat(subject.getPin()).containsExactly(5, 6, 7, 8)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getPin_whenLastEntryIsClearAll_returnsEmptyList() {
|
||||
val subject = pinInput("C1234C")
|
||||
assertThat(subject.getPin()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mostRecentClearAllMarker_onEmptyInput_returnsSentinel() {
|
||||
val subject = empty()
|
||||
val sentinel = subject.input[0] as ClearAll
|
||||
|
||||
assertThat(subject.mostRecentClearAll()).isSameInstanceAs(sentinel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mostRecentClearAllMarker_whenLastEntryIsDigit_returnsSentinel() {
|
||||
val subject = pinInput("C1234")
|
||||
val sentinel = subject.input[0] as ClearAll
|
||||
|
||||
assertThat(subject.mostRecentClearAll()).isSameInstanceAs(sentinel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mostRecentClearAllMarker_withMultiplePins_returnsLastMarker() {
|
||||
val subject = pinInput("C1234C5678")
|
||||
val lastMarker = subject.input[5] as ClearAll
|
||||
|
||||
assertThat(subject.mostRecentClearAll()).isSameInstanceAs(lastMarker)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mostRecentClearAllMarker_whenLastEntryIsClearAll_returnsLastEntry() {
|
||||
val subject = pinInput("C1234C")
|
||||
val lastEntry = subject.input[5] as ClearAll
|
||||
|
||||
assertThat(subject.mostRecentClearAll()).isSameInstanceAs(lastEntry)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getDigits_invalidClearAllMarker_onEmptyInput_returnsEmptyList() {
|
||||
val subject = empty()
|
||||
assertThat(subject.getDigits(ClearAll())).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getDigits_invalidClearAllMarker_whenLastEntryIsDigit_returnsEmptyList() {
|
||||
val subject = pinInput("C1234")
|
||||
assertThat(subject.getDigits(ClearAll())).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getDigits_clearAllMarkerPointsToFirstPin_returnsFirstPinDigits() {
|
||||
val subject = pinInput("C1234C5678")
|
||||
val marker = subject.input[0] as ClearAll
|
||||
|
||||
assertThat(subject.getDigits(marker).map { it.input }).containsExactly(1, 2, 3, 4)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getDigits_clearAllMarkerPointsToLastPin_returnsLastPinDigits() {
|
||||
val subject = pinInput("C1234C5678")
|
||||
val marker = subject.input[5] as ClearAll
|
||||
|
||||
assertThat(subject.getDigits(marker).map { it.input }).containsExactly(5, 6, 7, 8)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun entryToken_equality() {
|
||||
val clearAll = ClearAll()
|
||||
val zero = Digit(0)
|
||||
val one = Digit(1)
|
||||
|
||||
// Guava's EqualsTester is not available in this codebase.
|
||||
assertThat(zero.equals(zero.copy())).isTrue()
|
||||
|
||||
assertThat(zero.equals(one)).isFalse()
|
||||
assertThat(zero.equals(clearAll)).isFalse()
|
||||
|
||||
assertThat(clearAll.equals(clearAll.copy())).isTrue()
|
||||
assertThat(clearAll.equals(zero)).isFalse()
|
||||
|
||||
// Not equal when the sequence number does not match
|
||||
assertThat(zero.equals(Digit(0))).isFalse()
|
||||
assertThat(clearAll.equals(ClearAll())).isFalse()
|
||||
}
|
||||
|
||||
private fun pinInput(mnemonics: String): PinInputViewModel {
|
||||
return PinInputViewModel(
|
||||
mnemonics.map {
|
||||
when {
|
||||
it == 'C' -> ClearAll()
|
||||
isDigit(it) -> Digit(it.digitToInt())
|
||||
else -> throw AssertionError()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class PinInputSubject
|
||||
private constructor(metadata: FailureMetadata, private val actual: PinInputViewModel) :
|
||||
Subject(metadata, actual) {
|
||||
|
||||
fun matches(mnemonics: String) {
|
||||
val actualMnemonics =
|
||||
actual.input
|
||||
.map { entry ->
|
||||
when (entry) {
|
||||
is Digit -> entry.input.digitToChar()
|
||||
is ClearAll -> 'C'
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
}
|
||||
.joinToString(separator = "")
|
||||
|
||||
if (mnemonics != actualMnemonics) {
|
||||
failWithActual(
|
||||
Fact.simpleFact(
|
||||
"expected pin input to be '$mnemonics' but is '$actualMnemonics' instead"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun assertThat(input: PinInputViewModel): PinInputSubject =
|
||||
assertAbout(Factory(::PinInputSubject)).that(input)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user