summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt167
-rw-r--r--packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinInputDisplay.kt437
-rw-r--r--packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModel.kt48
-rw-r--r--packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PinInputViewModel.kt177
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModelTest.kt38
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PinInputViewModelTest.kt277
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)
+ }
+}