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 internal constructor(
40 @get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
41 internal val input: List<EntryToken>
42 ) {
43 init {
<lambda>null44 require(input.firstOrNull() is ClearAll) { "input does not begin with a ClearAll token" }
<lambda>null45 require(input.zipWithNext().all { it.first < it.second }) {
46 "EntryTokens are not sorted by their sequenceNumber"
47 }
48 }
49 /**
50 * [PinInputViewModel] with [previousInput] and appended [newToken].
51 *
52 * [previousInput] is trimmed so that the new [PinBouncerViewModel] contains at most two pin
53 * inputs.
54 */
55 private constructor(
56 previousInput: List<EntryToken>,
57 newToken: EntryToken
58 ) : this(
<lambda>null59 buildList {
60 addAll(
61 previousInput.subList(previousInput.indexOfLastClearAllToKeep(), previousInput.size)
62 )
63 add(newToken)
64 }
65 )
66
appendnull67 fun append(digit: Int): PinInputViewModel {
68 return PinInputViewModel(input, Digit(digit))
69 }
70
71 /**
72 * Delete last digit.
73 *
74 * This removes the last digit from the input. Returns `this` if the last token is [ClearAll].
75 */
deleteLastnull76 fun deleteLast(): PinInputViewModel {
77 if (isEmpty()) return this
78 return PinInputViewModel(input.take(input.size - 1))
79 }
80
81 /**
82 * Appends a [ClearAll] watermark, completing the current pin.
83 *
84 * Returns `this` if the last token is [ClearAll].
85 */
clearAllnull86 fun clearAll(): PinInputViewModel {
87 if (isEmpty()) return this
88 return PinInputViewModel(input, ClearAll())
89 }
90
91 /** Whether the current pin is empty. */
isEmptynull92 fun isEmpty(): Boolean {
93 return input.last() is ClearAll
94 }
95
96 /** The current pin, or an empty list if [isEmpty]. */
getPinnull97 fun getPin(): List<Int> {
98 return getDigits(mostRecentClearAll()).map { it.input }
99 }
100
101 /**
102 * The digits following the specified [ClearAll] marker, up to the next marker or the end of the
103 * input.
104 *
105 * Returns an empty list if the [ClearAll] is not in the input.
106 */
getDigitsnull107 fun getDigits(clearAllMarker: ClearAll): List<Digit> {
108 val startIndex = input.indexOf(clearAllMarker) + 1
109 if (startIndex == 0 || startIndex == input.size) return emptyList()
110
111 return input.subList(startIndex, input.size).takeWhile { it is Digit }.map { it as Digit }
112 }
113
114 /** The most recent [ClearAll] marker. */
mostRecentClearAllnull115 fun mostRecentClearAll(): ClearAll {
116 return input.last { it is ClearAll } as ClearAll
117 }
118
119 companion object {
emptynull120 fun empty() = PinInputViewModel(listOf(ClearAll()))
121 }
122 }
123
124 /**
125 * Pin bouncer entry token with a [sequenceNumber] to indicate input event ordering.
126 *
127 * Since the model only allows appending/removing [Digit]s from the end, the [sequenceNumber] is
128 * strictly increasing in input order of the pin, but not guaranteed to be monotonic or start at a
129 * specific number.
130 */
131 sealed interface EntryToken : Comparable<EntryToken> {
132 val sequenceNumber: Int
133
134 /** The pin bouncer [input] as digits 0-9. */
135 data class Digit
136 internal constructor(val input: Int, override val sequenceNumber: Int = nextSequenceNumber++) :
137 EntryToken {
138 init {
139 check(input in 0..9)
140 }
141 }
142
143 /**
144 * Marker to indicate the input is completely cleared, and subsequent [EntryToken]s mark a new
145 * pin entry.
146 */
147 data class ClearAll
148 internal constructor(override val sequenceNumber: Int = nextSequenceNumber++) : EntryToken
149
150 override fun compareTo(other: EntryToken): Int =
151 compareValuesBy(this, other, EntryToken::sequenceNumber)
152
153 companion object {
154 private var nextSequenceNumber = 1
155 }
156 }
157
158 /**
159 * Index of the last [ClearAll] token to keep for a new [PinInputViewModel], so that after appending
160 * another [EntryToken], there are at most two pin inputs in the [PinInputViewModel].
161 */
Listnull162 private fun List<EntryToken>.indexOfLastClearAllToKeep(): Int {
163 require(isNotEmpty() && first() is ClearAll)
164
165 var seenClearAll = 0
166 for (i in size - 1 downTo 0) {
167 if (get(i) is ClearAll) {
168 seenClearAll++
169 if (seenClearAll == 2) {
170 return i
171 }
172 }
173 }
174
175 // The first element is guaranteed to be a ClearAll marker.
176 return 0
177 }
178