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