Implement auto-confirm hinting

(and re-implement the existing PinInputDisplay from scratch with what
I learned about compose)

- refactor the pin input state as a hybrid of digits and clearall events,
  to properly implement the staggered dismiss animation while the last
  pin input is added during a wrong auto-confirm pin entry.
- all pin input display related code is now in its own PinInputDisplay.kt file

Bug: 282730134
Bug: 284429000
Test: Unit tests
Test: manually in ComposeGallery, using auto-confirm with pin-hinting and non-auto-confirm pins  (see recordings added to b/282730134)
Change-Id: I6da01c7ad94f298509e93bde169e00e379211e11
This commit is contained in:
Mike Schneider
2023-07-18 12:47:36 +02:00
parent 497b75bb1d
commit f7f48c6ea5
6 changed files with 925 additions and 219 deletions

View File

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

View File

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

View File

@@ -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,
)
}
@@ -87,26 +88,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. */
@@ -115,7 +113,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
@@ -124,15 +122,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
@@ -151,19 +151,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)
}

View File

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

View File

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

View File

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