1 /*
<lambda>null2 * 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 android.content.Context
20 import android.util.TypedValue
21 import com.android.systemui.authentication.shared.model.AuthenticationMethodModel
22 import com.android.systemui.authentication.shared.model.AuthenticationPatternCoordinate
23 import com.android.systemui.bouncer.domain.interactor.BouncerInteractor
24 import com.android.systemui.res.R
25 import kotlin.math.max
26 import kotlin.math.min
27 import kotlin.math.pow
28 import kotlin.math.sqrt
29 import kotlinx.coroutines.CoroutineScope
30 import kotlinx.coroutines.flow.MutableStateFlow
31 import kotlinx.coroutines.flow.SharingStarted
32 import kotlinx.coroutines.flow.StateFlow
33 import kotlinx.coroutines.flow.asStateFlow
34 import kotlinx.coroutines.flow.map
35 import kotlinx.coroutines.flow.stateIn
36
37 /** Holds UI state and handles user input for the pattern bouncer UI. */
38 class PatternBouncerViewModel(
39 private val applicationContext: Context,
40 viewModelScope: CoroutineScope,
41 interactor: BouncerInteractor,
42 isInputEnabled: StateFlow<Boolean>,
43 private val onIntentionalUserInput: () -> Unit,
44 ) :
45 AuthMethodBouncerViewModel(
46 viewModelScope = viewModelScope,
47 interactor = interactor,
48 isInputEnabled = isInputEnabled,
49 ) {
50
51 /** The number of columns in the dot grid. */
52 val columnCount = 3
53
54 /** The number of rows in the dot grid. */
55 val rowCount = 3
56
57 private val _selectedDots = MutableStateFlow<LinkedHashSet<PatternDotViewModel>>(linkedSetOf())
58
59 /** The dots that were selected by the user, in the order of selection. */
60 val selectedDots: StateFlow<List<PatternDotViewModel>> =
61 _selectedDots
62 .map { it.toList() }
63 .stateIn(
64 scope = viewModelScope,
65 started = SharingStarted.WhileSubscribed(),
66 initialValue = emptyList(),
67 )
68
69 private val _currentDot = MutableStateFlow<PatternDotViewModel?>(null)
70
71 /** The most-recently selected dot that the user selected. */
72 val currentDot: StateFlow<PatternDotViewModel?> = _currentDot.asStateFlow()
73
74 private val _dots = MutableStateFlow(defaultDots())
75
76 /** All dots on the grid. */
77 val dots: StateFlow<List<PatternDotViewModel>> = _dots.asStateFlow()
78
79 /** Whether the pattern itself should be rendered visibly. */
80 val isPatternVisible: StateFlow<Boolean> = interactor.isPatternVisible
81
82 override val authenticationMethod = AuthenticationMethodModel.Pattern
83
84 override val lockoutMessageId = R.string.kg_too_many_failed_pattern_attempts_dialog_message
85
86 /** Notifies that the user has started a drag gesture across the dot grid. */
87 fun onDragStart() {
88 onIntentionalUserInput()
89 }
90
91 /**
92 * Notifies that the user is dragging across the dot grid.
93 *
94 * @param xPx The horizontal coordinate of the position of the user's pointer, in pixels.
95 * @param yPx The vertical coordinate of the position of the user's pointer, in pixels.
96 * @param containerSizePx The size of the container of the dot grid, in pixels. It's assumed
97 * that the dot grid is perfectly square such that width and height are equal.
98 */
99 fun onDrag(xPx: Float, yPx: Float, containerSizePx: Int) {
100 val cellWidthPx = containerSizePx / columnCount
101 val cellHeightPx = containerSizePx / rowCount
102
103 if (xPx < 0 || yPx < 0) {
104 return
105 }
106
107 val dotColumn = (xPx / cellWidthPx).toInt()
108 val dotRow = (yPx / cellHeightPx).toInt()
109 if (dotColumn > columnCount - 1 || dotRow > rowCount - 1) {
110 return
111 }
112
113 val dotPixelX = dotColumn * cellWidthPx + cellWidthPx / 2
114 val dotPixelY = dotRow * cellHeightPx + cellHeightPx / 2
115
116 val distance = sqrt((xPx - dotPixelX).pow(2) + (yPx - dotPixelY).pow(2))
117 val hitRadius = hitFactor * min(cellWidthPx, cellHeightPx) / 2
118 if (distance > hitRadius) {
119 return
120 }
121
122 val hitDot = dots.value.firstOrNull { dot -> dot.x == dotColumn && dot.y == dotRow }
123 if (hitDot != null && !_selectedDots.value.contains(hitDot)) {
124 val skippedOverDots =
125 currentDot.value?.let { previousDot ->
126 buildList {
127 var dot = previousDot
128 while (dot != hitDot) {
129 // Move along the direction of the line connecting the previously
130 // selected dot and current hit dot, and see if they were skipped over
131 // but fall on that line.
132 if (dot.isOnLineSegment(previousDot, hitDot)) {
133 add(dot)
134 }
135 dot =
136 PatternDotViewModel(
137 x =
138 if (hitDot.x > dot.x) {
139 dot.x + 1
140 } else if (hitDot.x < dot.x) dot.x - 1 else dot.x,
141 y =
142 if (hitDot.y > dot.y) {
143 dot.y + 1
144 } else if (hitDot.y < dot.y) dot.y - 1 else dot.y,
145 )
146 }
147 }
148 }
149 ?: emptyList()
150
151 _selectedDots.value =
152 linkedSetOf<PatternDotViewModel>().apply {
153 addAll(_selectedDots.value)
154 addAll(skippedOverDots)
155 add(hitDot)
156 }
157 _currentDot.value = hitDot
158 }
159 }
160
161 /** Notifies that the user has ended the drag gesture across the dot grid. */
162 fun onDragEnd() {
163 val pattern = getInput()
164 if (pattern.size == 1) {
165 // Single dot patterns are treated as erroneous/false taps:
166 interactor.onFalseUserInput()
167 }
168
169 clearInput()
170 tryAuthenticate(input = pattern)
171 }
172
173 override fun clearInput() {
174 _dots.value = defaultDots()
175 _currentDot.value = null
176 _selectedDots.value = linkedSetOf()
177 }
178
179 override fun getInput(): List<Any> {
180 return _selectedDots.value.map(PatternDotViewModel::toCoordinate)
181 }
182
183 private fun defaultDots(): List<PatternDotViewModel> {
184 return buildList {
185 (0 until columnCount).forEach { x ->
186 (0 until rowCount).forEach { y ->
187 add(
188 PatternDotViewModel(
189 x = x,
190 y = y,
191 )
192 )
193 }
194 }
195 }
196 }
197
198 private val hitFactor: Float by lazy {
199 val outValue = TypedValue()
200 applicationContext.resources.getValue(
201 com.android.internal.R.dimen.lock_pattern_dot_hit_factor,
202 outValue,
203 true
204 )
205 max(min(outValue.float, 1f), MIN_DOT_HIT_FACTOR)
206 }
207
208 companion object {
209 private const val MIN_DOT_HIT_FACTOR = 0.2f
210 }
211 }
212
213 /**
214 * Determines whether [this] dot is present on the line segment connecting [first] and [second]
215 * dots.
216 */
isOnLineSegmentnull217 private fun PatternDotViewModel.isOnLineSegment(
218 first: PatternDotViewModel,
219 second: PatternDotViewModel
220 ): Boolean {
221 val anotherPoint = this
222 // No need to consider any points outside the bounds of two end points
223 val isWithinBounds =
224 anotherPoint.x.isBetween(first.x, second.x) && anotherPoint.y.isBetween(first.y, second.y)
225 if (!isWithinBounds) {
226 return false
227 }
228
229 // Uses the 2 point line equation: (y-y1)/(x-x1) = (y2-y1)/(x2-x1)
230 // which can be rewritten as: (y-y1)*(x2-x1) = (x-x1)*(y2-y1)
231 // This is true for any point on the line passing through these two points
232 return (anotherPoint.y - first.y) * (second.x - first.x) ==
233 (anotherPoint.x - first.x) * (second.y - first.y)
234 }
235
236 /** Is [this] Int between [a] and [b] */
isBetweennull237 private fun Int.isBetween(a: Int, b: Int): Boolean {
238 return (this in a..b) || (this in b..a)
239 }
240
241 data class PatternDotViewModel(
242 val x: Int,
243 val y: Int,
244 ) {
toCoordinatenull245 fun toCoordinate(): AuthenticationPatternCoordinate {
246 return AuthenticationPatternCoordinate(
247 x = x,
248 y = y,
249 )
250 }
251 }
252