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 android.content.Context
20 import android.util.TypedValue
21 import com.android.systemui.authentication.shared.model.AuthenticationPatternCoordinate
22 import com.android.systemui.bouncer.domain.interactor.BouncerInteractor
23 import kotlin.math.max
24 import kotlin.math.min
25 import kotlin.math.pow
26 import kotlin.math.sqrt
27 import kotlinx.coroutines.CoroutineScope
28 import kotlinx.coroutines.flow.MutableStateFlow
29 import kotlinx.coroutines.flow.SharingStarted
30 import kotlinx.coroutines.flow.StateFlow
31 import kotlinx.coroutines.flow.asStateFlow
32 import kotlinx.coroutines.flow.map
33 import kotlinx.coroutines.flow.stateIn
34 import kotlinx.coroutines.launch
35 
36 /** Holds UI state and handles user input for the pattern bouncer UI. */
37 class PatternBouncerViewModel(
38     private val applicationContext: Context,
39     private val applicationScope: CoroutineScope,
40     private val interactor: BouncerInteractor,
41     isInputEnabled: StateFlow<Boolean>,
42 ) :
43     AuthMethodBouncerViewModel(
44         isInputEnabled = isInputEnabled,
45     ) {
46 
47     /** The number of columns in the dot grid. */
48     val columnCount = 3
49     /** The number of rows in the dot grid. */
50     val rowCount = 3
51 
52     private val _selectedDots = MutableStateFlow<LinkedHashSet<PatternDotViewModel>>(linkedSetOf())
53     /** The dots that were selected by the user, in the order of selection. */
54     val selectedDots: StateFlow<List<PatternDotViewModel>> =
55         _selectedDots
56             .map { it.toList() }
57             .stateIn(
58                 scope = applicationScope,
59                 started = SharingStarted.WhileSubscribed(),
60                 initialValue = emptyList(),
61             )
62 
63     private val _currentDot = MutableStateFlow<PatternDotViewModel?>(null)
64     /** The most-recently selected dot that the user selected. */
65     val currentDot: StateFlow<PatternDotViewModel?> = _currentDot.asStateFlow()
66 
67     private val _dots = MutableStateFlow(defaultDots())
68     /** All dots on the grid. */
69     val dots: StateFlow<List<PatternDotViewModel>> = _dots.asStateFlow()
70 
71     /** Whether the pattern itself should be rendered visibly. */
72     val isPatternVisible: StateFlow<Boolean> = interactor.isPatternVisible
73 
74     /** Notifies that the UI has been shown to the user. */
75     fun onShown() {
76         interactor.resetMessage()
77     }
78 
79     /** Notifies that the user has started a drag gesture across the dot grid. */
80     fun onDragStart() {
81         interactor.clearMessage()
82     }
83 
84     /**
85      * Notifies that the user is dragging across the dot grid.
86      *
87      * @param xPx The horizontal coordinate of the position of the user's pointer, in pixels.
88      * @param yPx The vertical coordinate of the position of the user's pointer, in pixels.
89      * @param containerSizePx The size of the container of the dot grid, in pixels. It's assumed
90      *   that the dot grid is perfectly square such that width and height are equal.
91      * @param verticalOffsetPx How far down from `0` does the dot grid start on the display.
92      */
93     fun onDrag(xPx: Float, yPx: Float, containerSizePx: Int, verticalOffsetPx: Float) {
94         val cellWidthPx = containerSizePx / columnCount
95         val cellHeightPx = containerSizePx / rowCount
96 
97         if (xPx < 0 || yPx < verticalOffsetPx) {
98             return
99         }
100 
101         val dotColumn = (xPx / cellWidthPx).toInt()
102         val dotRow = ((yPx - verticalOffsetPx) / cellHeightPx).toInt()
103         if (dotColumn > columnCount - 1 || dotRow > rowCount - 1) {
104             return
105         }
106 
107         val dotPixelX = dotColumn * cellWidthPx + cellWidthPx / 2
108         val dotPixelY = dotRow * cellHeightPx + cellHeightPx / 2 + verticalOffsetPx
109 
110         val distance = sqrt((xPx - dotPixelX).pow(2) + (yPx - dotPixelY).pow(2))
111         val hitRadius = hitFactor * min(cellWidthPx, cellHeightPx) / 2
112         if (distance > hitRadius) {
113             return
114         }
115 
116         val hitDot = dots.value.firstOrNull { dot -> dot.x == dotColumn && dot.y == dotRow }
117         if (hitDot != null && !_selectedDots.value.contains(hitDot)) {
118             val skippedOverDots =
119                 currentDot.value?.let { previousDot ->
120                     buildList {
121                         var dot = previousDot
122                         while (dot != hitDot) {
123                             add(dot)
124                             dot =
125                                 PatternDotViewModel(
126                                     x =
127                                         if (hitDot.x > dot.x) dot.x + 1
128                                         else if (hitDot.x < dot.x) dot.x - 1 else dot.x,
129                                     y =
130                                         if (hitDot.y > dot.y) dot.y + 1
131                                         else if (hitDot.y < dot.y) dot.y - 1 else dot.y,
132                                 )
133                         }
134                     }
135                 }
136                     ?: emptyList()
137 
138             _selectedDots.value =
139                 linkedSetOf<PatternDotViewModel>().apply {
140                     addAll(_selectedDots.value)
141                     addAll(skippedOverDots)
142                     add(hitDot)
143                 }
144             _currentDot.value = hitDot
145         }
146     }
147 
148     /** Notifies that the user has ended the drag gesture across the dot grid. */
149     fun onDragEnd() {
150         val pattern = _selectedDots.value.map { it.toCoordinate() }
151         _dots.value = defaultDots()
152         _currentDot.value = null
153         _selectedDots.value = linkedSetOf()
154 
155         applicationScope.launch {
156             if (interactor.authenticate(pattern) != true) {
157                 showFailureAnimation()
158             }
159         }
160     }
161 
162     private fun defaultDots(): List<PatternDotViewModel> {
163         return buildList {
164             (0 until columnCount).forEach { x ->
165                 (0 until rowCount).forEach { y ->
166                     add(
167                         PatternDotViewModel(
168                             x = x,
169                             y = y,
170                         )
171                     )
172                 }
173             }
174         }
175     }
176 
177     private val hitFactor: Float by lazy {
178         val outValue = TypedValue()
179         applicationContext.resources.getValue(
180             com.android.internal.R.dimen.lock_pattern_dot_hit_factor,
181             outValue,
182             true
183         )
184         max(min(outValue.float, 1f), MIN_DOT_HIT_FACTOR)
185     }
186 
187     companion object {
188         private const val MIN_DOT_HIT_FACTOR = 0.2f
189     }
190 }
191 
192 data class PatternDotViewModel(
193     val x: Int,
194     val y: Int,
195 ) {
196     fun toCoordinate(): AuthenticationPatternCoordinate {
197         return AuthenticationPatternCoordinate(
198             x = x,
199             y = y,
200         )
201     }
202 }
203