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