diff options
6 files changed, 925 insertions, 219 deletions
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt index bef0b3df36c2..ec6e5eda264e 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt @@ -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 @@ -109,147 +85,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() val backspaceButtonAppearance by viewModel.backspaceButtonAppearance.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 diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinInputDisplay.kt b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinInputDisplay.kt new file mode 100644 index 000000000000..77065cfdeb76 --- /dev/null +++ b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinInputDisplay.kt @@ -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) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModel.kt index 1b14acc7fabc..823836dcdcb2 100644 --- a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModel.kt @@ -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) -} diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PinInputViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PinInputViewModel.kt new file mode 100644 index 000000000000..4efc21b41e6a --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PinInputViewModel.kt @@ -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 +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModelTest.kt index 45d1af722369..8edc6cf8dd54 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModelTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModelTest.kt @@ -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)) } diff --git a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PinInputViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PinInputViewModelTest.kt new file mode 100644 index 000000000000..4c279ea08fd7 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PinInputViewModelTest.kt @@ -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) + } +} |