1 /*
2 * Copyright (C) 2023 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package com.android.systemui.bouncer.ui.viewmodel
18
19 import androidx.annotation.VisibleForTesting
20 import com.android.systemui.bouncer.ui.viewmodel.EntryToken.ClearAll
21 import com.android.systemui.bouncer.ui.viewmodel.EntryToken.Digit
22
23 /**
24 * Immutable pin input state.
25 *
26 * The input is a hybrid of state ([Digit]) and event ([ClearAll]) tokens. The [ClearAll] token can
27 * be interpreted as a watermark, indicating that the current input up to that point is deleted
28 * (after a auth failure or when long-pressing the delete button). Therefore, [Digit]s following a
29 * [ClearAll] make up the next pin input entry. Up to two complete pin inputs are memoized.
30 *
31 * This is required when auto-confirm rejects the input, and the last digit will be animated-in at
32 * the end of the input, concurrently with the staggered clear-all animation starting to play at the
33 * beginning of the input.
34 *
35 * The input is guaranteed to always contain a initial [ClearAll] token as a sentinel, thus clients
36 * can always assume there is a 'ClearAll' watermark available.
37 */
38 data class PinInputViewModel(
39 @get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) val input: List<EntryToken>
40 ) {
41 init {
<lambda>null42 require(input.firstOrNull() is ClearAll) { "input does not begin with a ClearAll token" }
<lambda>null43 require(input.zipWithNext().all { it.first < it.second }) {
44 "EntryTokens are not sorted by their sequenceNumber"
45 }
46 }
47 /**
48 * [PinInputViewModel] with [previousInput] and appended [newToken].
49 *
50 * [previousInput] is trimmed so that the new [PinBouncerViewModel] contains at most two pin
51 * inputs.
52 */
53 private constructor(
54 previousInput: List<EntryToken>,
55 newToken: EntryToken
56 ) : this(
<lambda>null57 buildList {
58 addAll(
59 previousInput.subList(previousInput.indexOfLastClearAllToKeep(), previousInput.size)
60 )
61 add(newToken)
62 }
63 )
64
appendnull65 fun append(digit: Int): PinInputViewModel {
66 return PinInputViewModel(input, Digit(digit))
67 }
68
69 /**
70 * Delete last digit.
71 *
72 * This removes the last digit from the input. Returns `this` if the last token is [ClearAll].
73 */
deleteLastnull74 fun deleteLast(): PinInputViewModel {
75 if (isEmpty()) return this
76 return PinInputViewModel(input.take(input.size - 1))
77 }
78
79 /**
80 * Appends a [ClearAll] watermark, completing the current pin.
81 *
82 * Returns `this` if the last token is [ClearAll].
83 */
clearAllnull84 fun clearAll(): PinInputViewModel {
85 if (isEmpty()) return this
86 return PinInputViewModel(input, ClearAll())
87 }
88
89 /** Whether the current pin is empty. */
isEmptynull90 fun isEmpty(): Boolean {
91 return input.last() is ClearAll
92 }
93
94 /** The current pin, or an empty list if [isEmpty]. */
getPinnull95 fun getPin(): List<Int> {
96 return getDigits(mostRecentClearAll()).map { it.input }
97 }
98
99 /**
100 * The digits following the specified [ClearAll] marker, up to the next marker or the end of the
101 * input.
102 *
103 * Returns an empty list if the [ClearAll] is not in the input.
104 */
getDigitsnull105 fun getDigits(clearAllMarker: ClearAll): List<Digit> {
106 val startIndex = input.indexOf(clearAllMarker) + 1
107 if (startIndex == 0 || startIndex == input.size) return emptyList()
108
109 return input.subList(startIndex, input.size).takeWhile { it is Digit }.map { it as Digit }
110 }
111
112 /** The most recent [ClearAll] marker. */
mostRecentClearAllnull113 fun mostRecentClearAll(): ClearAll {
114 return input.last { it is ClearAll } as ClearAll
115 }
116
117 companion object {
emptynull118 fun empty() = PinInputViewModel(listOf(ClearAll()))
119 }
120 }
121
122 /**
123 * Pin bouncer entry token with a [sequenceNumber] to indicate input event ordering.
124 *
125 * Since the model only allows appending/removing [Digit]s from the end, the [sequenceNumber] is
126 * strictly increasing in input order of the pin, but not guaranteed to be monotonic or start at a
127 * specific number.
128 */
129 sealed interface EntryToken : Comparable<EntryToken> {
130 val sequenceNumber: Int
131
132 /** The pin bouncer [input] as digits 0-9. */
133 data class Digit(val input: Int, override val sequenceNumber: Int = nextSequenceNumber++) :
134 EntryToken {
135 init {
136 check(input in 0..9)
137 }
138 }
139
140 /**
141 * Marker to indicate the input is completely cleared, and subsequent [EntryToken]s mark a new
142 * pin entry.
143 */
144 data class ClearAll(override val sequenceNumber: Int = nextSequenceNumber++) : EntryToken
145
146 override fun compareTo(other: EntryToken): Int =
147 compareValuesBy(this, other, EntryToken::sequenceNumber)
148
149 companion object {
150 private var nextSequenceNumber = 1
151 }
152 }
153
154 /**
155 * Index of the last [ClearAll] token to keep for a new [PinInputViewModel], so that after appending
156 * another [EntryToken], there are at most two pin inputs in the [PinInputViewModel].
157 */
Listnull158 private fun List<EntryToken>.indexOfLastClearAllToKeep(): Int {
159 require(isNotEmpty() && first() is ClearAll)
160
161 var seenClearAll = 0
162 for (i in size - 1 downTo 0) {
163 if (get(i) is ClearAll) {
164 seenClearAll++
165 if (seenClearAll == 2) {
166 return i
167 }
168 }
169 }
170
171 // The first element is guaranteed to be a ClearAll marker.
172 return 0
173 }
174