• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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.app.admin.DevicePolicyManager
20 import android.app.admin.DevicePolicyResources
21 import android.content.Context
22 import android.graphics.Bitmap
23 import androidx.compose.ui.input.key.KeyEvent
24 import androidx.compose.ui.input.key.type
25 import androidx.core.graphics.drawable.toBitmap
26 import com.android.app.tracing.coroutines.launchTraced as launch
27 import com.android.app.tracing.coroutines.traceCoroutine
28 import com.android.systemui.authentication.domain.interactor.AuthenticationInteractor
29 import com.android.systemui.authentication.shared.model.AuthenticationMethodModel
30 import com.android.systemui.authentication.shared.model.AuthenticationWipeModel
31 import com.android.systemui.authentication.shared.model.BouncerInputSide
32 import com.android.systemui.bouncer.domain.interactor.BouncerActionButtonInteractor
33 import com.android.systemui.bouncer.domain.interactor.BouncerInteractor
34 import com.android.systemui.bouncer.shared.model.BouncerActionButtonModel
35 import com.android.systemui.bouncer.ui.helper.BouncerHapticPlayer
36 import com.android.systemui.common.shared.model.Icon
37 import com.android.systemui.common.shared.model.Text
38 import com.android.systemui.dagger.qualifiers.Application
39 import com.android.systemui.keyguard.domain.interactor.KeyguardDismissActionInteractor
40 import com.android.systemui.keyguard.domain.interactor.KeyguardMediaKeyInteractor
41 import com.android.systemui.lifecycle.ExclusiveActivatable
42 import com.android.systemui.user.ui.viewmodel.UserSwitcherViewModel
43 import dagger.assisted.AssistedFactory
44 import dagger.assisted.AssistedInject
45 import kotlinx.coroutines.awaitCancellation
46 import kotlinx.coroutines.coroutineScope
47 import kotlinx.coroutines.flow.MutableStateFlow
48 import kotlinx.coroutines.flow.StateFlow
49 import kotlinx.coroutines.flow.asStateFlow
50 import kotlinx.coroutines.flow.collectLatest
51 import kotlinx.coroutines.flow.combine
52 import kotlinx.coroutines.flow.map
53 
54 /** Models UI state for the content of the bouncer overlay. */
55 class BouncerOverlayContentViewModel
56 @AssistedInject
57 constructor(
58     @Application private val applicationContext: Context,
59     private val bouncerInteractor: BouncerInteractor,
60     private val authenticationInteractor: AuthenticationInteractor,
61     private val devicePolicyManager: DevicePolicyManager,
62     private val bouncerMessageViewModelFactory: BouncerMessageViewModel.Factory,
63     private val userSwitcher: UserSwitcherViewModel,
64     private val actionButtonInteractor: BouncerActionButtonInteractor,
65     private val pinViewModelFactory: PinBouncerViewModel.Factory,
66     private val patternViewModelFactory: PatternBouncerViewModel.Factory,
67     private val passwordViewModelFactory: PasswordBouncerViewModel.Factory,
68     private val bouncerHapticPlayer: BouncerHapticPlayer,
69     private val keyguardMediaKeyInteractor: KeyguardMediaKeyInteractor,
70     private val bouncerActionButtonInteractor: BouncerActionButtonInteractor,
71     private val keyguardDismissActionInteractor: KeyguardDismissActionInteractor,
72 ) : ExclusiveActivatable() {
73     private val _selectedUserImage = MutableStateFlow<Bitmap?>(null)
74     val selectedUserImage: StateFlow<Bitmap?> = _selectedUserImage.asStateFlow()
75 
76     val message: BouncerMessageViewModel by lazy { bouncerMessageViewModelFactory.create() }
77 
78     private val _userSwitcherDropdown =
79         MutableStateFlow<List<UserSwitcherDropdownItemViewModel>>(emptyList())
80     val userSwitcherDropdown: StateFlow<List<UserSwitcherDropdownItemViewModel>> =
81         _userSwitcherDropdown.asStateFlow()
82 
83     private val _isUserSwitcherVisible = MutableStateFlow(false)
84     val isUserSwitcherVisible: StateFlow<Boolean> = _isUserSwitcherVisible.asStateFlow()
85 
86     /** View-model for the current UI, based on the current authentication method. */
87     private val _authMethodViewModel = MutableStateFlow<AuthMethodBouncerViewModel?>(null)
88     val authMethodViewModel: StateFlow<AuthMethodBouncerViewModel?> =
89         _authMethodViewModel.asStateFlow()
90 
91     /**
92      * A message for a dialog to show when the user has attempted the wrong credential too many
93      * times and now must wait a while before attempting again.
94      *
95      * If `null`, the lockout dialog should not be shown.
96      */
97     private val lockoutDialogMessage = MutableStateFlow<String?>(null)
98 
99     /**
100      * A message for a dialog to show when the user has attempted the wrong credential too many
101      * times and their user/profile/device data is at risk of being wiped due to a Device Manager
102      * policy.
103      *
104      * If `null`, the wipe dialog should not be shown.
105      */
106     private val wipeDialogMessage = MutableStateFlow<String?>(null)
107 
108     private val _dialogViewModel = MutableStateFlow<DialogViewModel?>(createDialogViewModel())
109     /**
110      * Models the dialog to be shown to the user, or `null` if no dialog should be shown.
111      *
112      * Once the dialog is shown, the UI should call [DialogViewModel.onDismiss] when the user
113      * dismisses this dialog.
114      */
115     val dialogViewModel: StateFlow<DialogViewModel?> = _dialogViewModel.asStateFlow()
116 
117     private val _actionButton = MutableStateFlow<BouncerActionButtonModel?>(null)
118     /**
119      * The bouncer action button (Return to Call / Emergency Call). If `null`, the button should not
120      * be shown.
121      */
122     val actionButton: StateFlow<BouncerActionButtonModel?> = _actionButton.asStateFlow()
123 
124     private val _isOneHandedModeSupported = MutableStateFlow(false)
125     /**
126      * Whether the one-handed mode is supported.
127      *
128      * When presented on its own, without a user switcher (e.g. not on communal devices like
129      * tablets, for example), some authentication method UIs don't do well if they're shown in the
130      * side-by-side layout; these need to be shown with the standard layout so they can take up as
131      * much width as possible.
132      */
133     val isOneHandedModeSupported: StateFlow<Boolean> = _isOneHandedModeSupported.asStateFlow()
134 
135     private val _isInputPreferredOnLeftSide = MutableStateFlow(false)
136     val isInputPreferredOnLeftSide = _isInputPreferredOnLeftSide.asStateFlow()
137 
138     private val _isFoldSplitRequired =
139         MutableStateFlow(isFoldSplitRequired(authMethodViewModel.value))
140     /**
141      * Whether the splitting the UI around the fold seam (where the hinge is on a foldable device)
142      * is required.
143      */
144     val isFoldSplitRequired: StateFlow<Boolean> = _isFoldSplitRequired.asStateFlow()
145 
146     /** How much the bouncer UI should be scaled. */
147     val scale: StateFlow<Float> = bouncerInteractor.scale
148 
149     private val _isInputEnabled =
150         MutableStateFlow(authenticationInteractor.lockoutEndTimestamp == null)
151     private val isInputEnabled: StateFlow<Boolean> = _isInputEnabled.asStateFlow()
152 
153     override suspend fun onActivated(): Nothing {
154         bouncerInteractor.resetScale()
155         coroutineScope {
156             launch { message.activate() }
157             launch {
158                 authenticationInteractor.authenticationMethod
159                     .map(::getChildViewModel)
160                     .collectLatest { childViewModelOrNull ->
161                         _authMethodViewModel.value = childViewModelOrNull
162                         childViewModelOrNull?.let { traceCoroutine(it.traceName) { it.activate() } }
163                     }
164             }
165 
166             launch {
167                 authenticationInteractor.upcomingWipe.collect { wipeModel ->
168                     wipeDialogMessage.value = wipeModel?.message
169                 }
170             }
171 
172             launch {
173                 userSwitcher.selectedUser
174                     .map { it.image.toBitmap() }
175                     .collect { _selectedUserImage.value = it }
176             }
177 
178             launch {
179                 combine(userSwitcher.users, userSwitcher.menu) { users, actions ->
180                         users.map { user ->
181                             UserSwitcherDropdownItemViewModel(
182                                 icon = Icon.Loaded(user.image, contentDescription = null),
183                                 text = user.name,
184                                 onClick = user.onClicked ?: {},
185                             )
186                         } +
187                             actions.map { action ->
188                                 UserSwitcherDropdownItemViewModel(
189                                     icon =
190                                         Icon.Loaded(
191                                             applicationContext.resources.getDrawable(
192                                                 action.iconResourceId
193                                             ),
194                                             contentDescription = null,
195                                         ),
196                                     text = Text.Resource(action.textResourceId),
197                                     onClick = action.onClicked,
198                                 )
199                             }
200                     }
201                     .collect { _userSwitcherDropdown.value = it }
202             }
203 
204             launch {
205                 combine(wipeDialogMessage, lockoutDialogMessage) { _, _ -> createDialogViewModel() }
206                     .collect { _dialogViewModel.value = it }
207             }
208 
209             launch { actionButtonInteractor.actionButton.collect { _actionButton.value = it } }
210 
211             launch {
212                 combine(
213                         bouncerInteractor.isOneHandedModeSupported,
214                         bouncerInteractor.lastRecordedLockscreenTouchPosition,
215                         ::Pair,
216                     )
217                     .collect { (isOneHandedModeSupported, lastRecordedNotificationTouchPosition) ->
218                         _isOneHandedModeSupported.value = isOneHandedModeSupported
219                         if (
220                             isOneHandedModeSupported &&
221                                 lastRecordedNotificationTouchPosition != null
222                         ) {
223                             bouncerInteractor.setPreferredBouncerInputSide(
224                                 if (
225                                     lastRecordedNotificationTouchPosition <
226                                         applicationContext.resources.displayMetrics.widthPixels / 2
227                                 ) {
228                                     BouncerInputSide.LEFT
229                                 } else {
230                                     BouncerInputSide.RIGHT
231                                 }
232                             )
233                         }
234                     }
235             }
236 
237             launch {
238                 bouncerInteractor.isUserSwitcherVisible.collect {
239                     _isUserSwitcherVisible.value = it
240                 }
241             }
242 
243             launch {
244                 bouncerInteractor.preferredBouncerInputSide.collect {
245                     _isInputPreferredOnLeftSide.value = it == BouncerInputSide.LEFT
246                 }
247             }
248 
249             launch {
250                 authMethodViewModel
251                     .map { authMethod -> isFoldSplitRequired(authMethod) }
252                     .collect { _isFoldSplitRequired.value = it }
253             }
254 
255             launch {
256                 message.isLockoutMessagePresent
257                     .map { lockoutMessagePresent -> !lockoutMessagePresent }
258                     .collect { _isInputEnabled.value = it }
259             }
260 
261             awaitCancellation()
262         }
263     }
264 
265     private fun isFoldSplitRequired(authMethod: AuthMethodBouncerViewModel?): Boolean {
266         return authMethod !is PasswordBouncerViewModel
267     }
268 
269     private fun getChildViewModel(
270         authenticationMethod: AuthenticationMethodModel
271     ): AuthMethodBouncerViewModel? {
272         // If the current child view-model matches the authentication method, reuse it instead of
273         // creating a new instance.
274         val childViewModel = authMethodViewModel.value
275         if (authenticationMethod == childViewModel?.authenticationMethod) {
276             return childViewModel
277         }
278 
279         return when (authenticationMethod) {
280             is AuthenticationMethodModel.Pin ->
281                 pinViewModelFactory.create(
282                     authenticationMethod = authenticationMethod,
283                     onIntentionalUserInput = ::onIntentionalUserInput,
284                     isInputEnabled = isInputEnabled,
285                     bouncerHapticPlayer = bouncerHapticPlayer,
286                 )
287             is AuthenticationMethodModel.Sim ->
288                 pinViewModelFactory.create(
289                     authenticationMethod = authenticationMethod,
290                     onIntentionalUserInput = ::onIntentionalUserInput,
291                     isInputEnabled = isInputEnabled,
292                     bouncerHapticPlayer = bouncerHapticPlayer,
293                 )
294             is AuthenticationMethodModel.Password ->
295                 passwordViewModelFactory.create(
296                     onIntentionalUserInput = ::onIntentionalUserInput,
297                     isInputEnabled = isInputEnabled,
298                 )
299             is AuthenticationMethodModel.Pattern ->
300                 patternViewModelFactory.create(
301                     onIntentionalUserInput = ::onIntentionalUserInput,
302                     isInputEnabled = isInputEnabled,
303                     bouncerHapticPlayer = bouncerHapticPlayer,
304                 )
305             else -> null
306         }
307     }
308 
309     private fun onIntentionalUserInput() {
310         message.showDefaultMessage()
311         bouncerInteractor.onIntentionalUserInput()
312     }
313 
314     /**
315      * @return A message warning the user that the user/profile/device will be wiped upon a further
316      *   [AuthenticationWipeModel.remainingAttempts] unsuccessful authentication attempts.
317      */
318     private fun AuthenticationWipeModel.getAlmostAtWipeMessage(): String {
319         val message =
320             applicationContext.getString(
321                 wipeTarget.messageIdForAlmostWipe,
322                 failedAttempts,
323                 remainingAttempts,
324             )
325         return if (wipeTarget == AuthenticationWipeModel.WipeTarget.ManagedProfile) {
326             devicePolicyManager.resources.getString(
327                 DevicePolicyResources.Strings.SystemUi
328                     .KEYGUARD_DIALOG_FAILED_ATTEMPTS_ALMOST_ERASING_PROFILE,
329                 { message },
330                 failedAttempts,
331                 remainingAttempts,
332             ) ?: message
333         } else {
334             message
335         }
336     }
337 
338     /**
339      * @return A message informing the user that their user/profile/device will be wiped promptly.
340      */
341     private fun AuthenticationWipeModel.getWipeMessage(): String {
342         val message = applicationContext.getString(wipeTarget.messageIdForWipe, failedAttempts)
343         return if (wipeTarget == AuthenticationWipeModel.WipeTarget.ManagedProfile) {
344             devicePolicyManager.resources.getString(
345                 DevicePolicyResources.Strings.SystemUi
346                     .KEYGUARD_DIALOG_FAILED_ATTEMPTS_ERASING_PROFILE,
347                 { message },
348                 failedAttempts,
349             ) ?: message
350         } else {
351             message
352         }
353     }
354 
355     private val AuthenticationWipeModel.message: String
356         get() = if (remainingAttempts > 0) getAlmostAtWipeMessage() else getWipeMessage()
357 
358     private fun createDialogViewModel(): DialogViewModel? {
359         val wipeText = wipeDialogMessage.value
360         val lockoutText = lockoutDialogMessage.value
361         return when {
362             // The wipe dialog takes priority over the lockout dialog.
363             wipeText != null ->
364                 DialogViewModel(text = wipeText, onDismiss = { wipeDialogMessage.value = null })
365             lockoutText != null ->
366                 DialogViewModel(
367                     text = lockoutText,
368                     onDismiss = { lockoutDialogMessage.value = null },
369                 )
370             else -> null // No dialog to show.
371         }
372     }
373 
374     /**
375      * Notifies that double tap gesture was detected on the bouncer.
376      * [wasEventOnNonInputHalfOfScreen] is true when it happens on the side of the bouncer where the
377      * input UI is not present.
378      */
379     fun onDoubleTap(wasEventOnNonInputHalfOfScreen: Boolean) {
380         if (!wasEventOnNonInputHalfOfScreen) return
381         if (_isInputPreferredOnLeftSide.value) {
382             bouncerInteractor.setPreferredBouncerInputSide(BouncerInputSide.RIGHT)
383         } else {
384             bouncerInteractor.setPreferredBouncerInputSide(BouncerInputSide.LEFT)
385         }
386     }
387 
388     /**
389      * Notifies that onDown was detected on the bouncer. [wasEventOnNonInputHalfOfScreen] is true
390      * when it happens on the side of the bouncer where the input UI is not present.
391      */
392     fun onDown(wasEventOnNonInputHalfOfScreen: Boolean) {
393         if (!wasEventOnNonInputHalfOfScreen) return
394         bouncerInteractor.onDown()
395     }
396 
397     /**
398      * Notifies that a key event has occurred.
399      *
400      * @return `true` when the [KeyEvent] was consumed as user input on bouncer; `false` otherwise.
401      */
402     fun onKeyEvent(keyEvent: KeyEvent): Boolean {
403         if (keyguardMediaKeyInteractor.processMediaKeyEvent(keyEvent.nativeKeyEvent)) return true
404         return authMethodViewModel.value?.onKeyEvent(keyEvent.type, keyEvent.nativeKeyEvent.keyCode)
405             ?: false
406     }
407 
408     fun onActionButtonClicked(actionButtonModel: BouncerActionButtonModel) {
409         when (actionButtonModel) {
410             is BouncerActionButtonModel.EmergencyButtonModel -> {
411                 bouncerHapticPlayer.playEmergencyButtonClickFeedback()
412                 bouncerActionButtonInteractor.onEmergencyButtonClicked()
413             }
414             is BouncerActionButtonModel.ReturnToCallButtonModel -> {
415                 bouncerActionButtonInteractor.onReturnToCallButtonClicked()
416             }
417         }
418     }
419 
420     fun onActionButtonLongClicked(actionButtonModel: BouncerActionButtonModel) {
421         if (actionButtonModel is BouncerActionButtonModel.EmergencyButtonModel) {
422             bouncerActionButtonInteractor.onEmergencyButtonLongClicked()
423         }
424     }
425 
426     /**
427      * Notifies that the bouncer UI has been destroyed (e.g. the composable left the composition).
428      */
429     fun onUiDestroyed() {
430         keyguardDismissActionInteractor.clearDismissAction()
431     }
432 
433     data class DialogViewModel(
434         val text: String,
435 
436         /** Callback to run after the dialog has been dismissed by the user. */
437         val onDismiss: () -> Unit,
438     )
439 
440     data class UserSwitcherDropdownItemViewModel(
441         val icon: Icon,
442         val text: Text,
443         val onClick: () -> Unit,
444     )
445 
446     @AssistedFactory
447     interface Factory {
448         fun create(): BouncerOverlayContentViewModel
449     }
450 }
451