diff options
| author | 2023-07-18 12:47:36 +0200 | |
|---|---|---|
| committer | 2023-07-21 14:39:30 +0200 | |
| commit | f7f48c6ea5090d5a32c8dfcb7232af88cfe9e45f (patch) | |
| tree | 0b0eede903f6d7de4dbaf6799f8e9fa3943ca1f4 | |
| parent | 497b75bb1d1e63b2b7f5fccd4739eee9459b64d1 (diff) | |
Implement auto-confirm hinting
(and re-implement the existing PinInputDisplay from scratch with what
I learned about compose)
- refactor the pin input state as a hybrid of digits and clearall events,
  to properly implement the staggered dismiss animation while the last
  pin input is added during a wrong auto-confirm pin entry.
- all pin input display related code is now in its own PinInputDisplay.kt file
Bug: 282730134
Bug: 284429000
Test: Unit tests
Test: manually in ComposeGallery, using auto-confirm with pin-hinting and non-auto-confirm pins  (see recordings added to b/282730134)
Change-Id: I6da01c7ad94f298509e93bde169e00e379211e11
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) +    } +} |