• 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.compose.animation.scene.Back
27 import com.android.compose.animation.scene.SceneKey
28 import com.android.compose.animation.scene.Swipe
29 import com.android.compose.animation.scene.SwipeDirection
30 import com.android.compose.animation.scene.UserAction
31 import com.android.compose.animation.scene.UserActionResult
32 import com.android.systemui.authentication.domain.interactor.AuthenticationInteractor
33 import com.android.systemui.authentication.shared.model.AuthenticationMethodModel
34 import com.android.systemui.authentication.shared.model.AuthenticationWipeModel
35 import com.android.systemui.bouncer.domain.interactor.BouncerActionButtonInteractor
36 import com.android.systemui.bouncer.domain.interactor.BouncerInteractor
37 import com.android.systemui.bouncer.domain.interactor.SimBouncerInteractor
38 import com.android.systemui.bouncer.shared.flag.ComposeBouncerFlags
39 import com.android.systemui.bouncer.shared.model.BouncerActionButtonModel
40 import com.android.systemui.common.shared.model.Icon
41 import com.android.systemui.common.shared.model.Text
42 import com.android.systemui.dagger.SysUISingleton
43 import com.android.systemui.dagger.qualifiers.Application
44 import com.android.systemui.dagger.qualifiers.Main
45 import com.android.systemui.inputmethod.domain.interactor.InputMethodInteractor
46 import com.android.systemui.scene.shared.model.Scenes
47 import com.android.systemui.user.domain.interactor.SelectedUserInteractor
48 import com.android.systemui.user.ui.viewmodel.UserActionViewModel
49 import com.android.systemui.user.ui.viewmodel.UserSwitcherViewModel
50 import com.android.systemui.user.ui.viewmodel.UserViewModel
51 import dagger.Module
52 import dagger.Provides
53 import kotlinx.coroutines.CoroutineDispatcher
54 import kotlinx.coroutines.CoroutineScope
55 import kotlinx.coroutines.SupervisorJob
56 import kotlinx.coroutines.cancel
57 import kotlinx.coroutines.flow.Flow
58 import kotlinx.coroutines.flow.MutableStateFlow
59 import kotlinx.coroutines.flow.SharingStarted
60 import kotlinx.coroutines.flow.StateFlow
61 import kotlinx.coroutines.flow.combine
62 import kotlinx.coroutines.flow.map
63 import kotlinx.coroutines.flow.stateIn
64 import kotlinx.coroutines.job
65 import kotlinx.coroutines.launch
66 
67 /** Holds UI state and handles user input on bouncer UIs. */
68 class BouncerViewModel(
69     @Application private val applicationContext: Context,
70     @Application private val applicationScope: CoroutineScope,
71     @Main private val mainDispatcher: CoroutineDispatcher,
72     private val bouncerInteractor: BouncerInteractor,
73     private val inputMethodInteractor: InputMethodInteractor,
74     private val simBouncerInteractor: SimBouncerInteractor,
75     private val authenticationInteractor: AuthenticationInteractor,
76     private val selectedUserInteractor: SelectedUserInteractor,
77     private val devicePolicyManager: DevicePolicyManager,
78     bouncerMessageViewModel: BouncerMessageViewModel,
79     flags: ComposeBouncerFlags,
80     selectedUser: Flow<UserViewModel>,
81     users: Flow<List<UserViewModel>>,
82     userSwitcherMenu: Flow<List<UserActionViewModel>>,
83     actionButton: Flow<BouncerActionButtonModel?>,
84 ) {
85     val selectedUserImage: StateFlow<Bitmap?> =
86         selectedUser
87             .map { it.image.toBitmap() }
88             .stateIn(
89                 scope = applicationScope,
90                 started = SharingStarted.WhileSubscribed(),
91                 initialValue = null,
92             )
93 
94     val destinationScenes: StateFlow<Map<UserAction, UserActionResult>> =
95         bouncerInteractor.dismissDestination
96             .map(::destinationSceneMap)
97             .stateIn(
98                 applicationScope,
99                 SharingStarted.WhileSubscribed(),
100                 initialValue = destinationSceneMap(Scenes.Lockscreen),
101             )
102 
103     val message: BouncerMessageViewModel = bouncerMessageViewModel
104 
105     val userSwitcherDropdown: StateFlow<List<UserSwitcherDropdownItemViewModel>> =
106         combine(
107                 users,
108                 userSwitcherMenu,
109             ) { users, actions ->
110                 users.map { user ->
111                     UserSwitcherDropdownItemViewModel(
112                         icon = Icon.Loaded(user.image, contentDescription = null),
113                         text = user.name,
114                         onClick = user.onClicked ?: {},
115                     )
116                 } +
117                     actions.map { action ->
118                         UserSwitcherDropdownItemViewModel(
119                             icon = Icon.Resource(action.iconResourceId, contentDescription = null),
120                             text = Text.Resource(action.textResourceId),
121                             onClick = action.onClicked,
122                         )
123                     }
124             }
125             .stateIn(
126                 scope = applicationScope,
127                 started = SharingStarted.WhileSubscribed(),
128                 initialValue = emptyList(),
129             )
130 
131     val isUserSwitcherVisible: Boolean
132         get() = bouncerInteractor.isUserSwitcherVisible
133 
134     // Handle to the scope of the child ViewModel (stored in [authMethod]).
135     private var childViewModelScope: CoroutineScope? = null
136 
137     /** View-model for the current UI, based on the current authentication method. */
138     val authMethodViewModel: StateFlow<AuthMethodBouncerViewModel?> =
139         authenticationInteractor.authenticationMethod
140             .map(::getChildViewModel)
141             .stateIn(
142                 scope = applicationScope,
143                 started = SharingStarted.WhileSubscribed(),
144                 initialValue = null,
145             )
146 
147     /**
148      * A message for a dialog to show when the user has attempted the wrong credential too many
149      * times and now must wait a while before attempting again.
150      *
151      * If `null`, the lockout dialog should not be shown.
152      */
153     private val lockoutDialogMessage = MutableStateFlow<String?>(null)
154 
155     /**
156      * A message for a dialog to show when the user has attempted the wrong credential too many
157      * times and their user/profile/device data is at risk of being wiped due to a Device Manager
158      * policy.
159      *
160      * If `null`, the wipe dialog should not be shown.
161      */
162     private val wipeDialogMessage = MutableStateFlow<String?>(null)
163 
164     /**
165      * Models the dialog to be shown to the user, or `null` if no dialog should be shown.
166      *
167      * Once the dialog is shown, the UI should call [DialogViewModel.onDismiss] when the user
168      * dismisses this dialog.
169      */
170     val dialogViewModel: StateFlow<DialogViewModel?> =
171         combine(wipeDialogMessage, lockoutDialogMessage) { _, _ -> createDialogViewModel() }
172             .stateIn(
173                 scope = applicationScope,
174                 started = SharingStarted.WhileSubscribed(),
175                 initialValue = createDialogViewModel(),
176             )
177 
178     /**
179      * The bouncer action button (Return to Call / Emergency Call). If `null`, the button should not
180      * be shown.
181      */
182     val actionButton: StateFlow<BouncerActionButtonModel?> =
183         actionButton.stateIn(
184             scope = applicationScope,
185             started = SharingStarted.WhileSubscribed(),
186             initialValue = null
187         )
188 
189     /**
190      * Whether the "side-by-side" layout is supported.
191      *
192      * When presented on its own, without a user switcher (e.g. not on communal devices like
193      * tablets, for example), some authentication method UIs don't do well if they're shown in the
194      * side-by-side layout; these need to be shown with the standard layout so they can take up as
195      * much width as possible.
196      */
197     val isSideBySideSupported: StateFlow<Boolean> =
198         authMethodViewModel
199             .map { authMethod -> isSideBySideSupported(authMethod) }
200             .stateIn(
201                 scope = applicationScope,
202                 started = SharingStarted.WhileSubscribed(),
203                 initialValue = isSideBySideSupported(authMethodViewModel.value),
204             )
205 
206     /**
207      * Whether the splitting the UI around the fold seam (where the hinge is on a foldable device)
208      * is required.
209      */
210     val isFoldSplitRequired: StateFlow<Boolean> =
211         authMethodViewModel
212             .map { authMethod -> isFoldSplitRequired(authMethod) }
213             .stateIn(
214                 scope = applicationScope,
215                 started = SharingStarted.WhileSubscribed(),
216                 initialValue = isFoldSplitRequired(authMethodViewModel.value),
217             )
218 
219     private val isInputEnabled: StateFlow<Boolean> =
220         bouncerMessageViewModel.isLockoutMessagePresent
221             .map { lockoutMessagePresent -> !lockoutMessagePresent }
222             .stateIn(
223                 scope = applicationScope,
224                 started = SharingStarted.WhileSubscribed(),
225                 initialValue = authenticationInteractor.lockoutEndTimestamp == null,
226             )
227 
228     init {
229         if (flags.isComposeBouncerOrSceneContainerEnabled()) {
230             // Keeps the upcoming wipe dialog up-to-date.
231             applicationScope.launch {
232                 authenticationInteractor.upcomingWipe.collect { wipeModel ->
233                     wipeDialogMessage.value = wipeModel?.message
234                 }
235             }
236         }
237     }
238 
239     private fun isSideBySideSupported(authMethod: AuthMethodBouncerViewModel?): Boolean {
240         return isUserSwitcherVisible || authMethod !is PasswordBouncerViewModel
241     }
242 
243     private fun isFoldSplitRequired(authMethod: AuthMethodBouncerViewModel?): Boolean {
244         return authMethod !is PasswordBouncerViewModel
245     }
246 
247     private fun getChildViewModel(
248         authenticationMethod: AuthenticationMethodModel,
249     ): AuthMethodBouncerViewModel? {
250         // If the current child view-model matches the authentication method, reuse it instead of
251         // creating a new instance.
252         val childViewModel = authMethodViewModel.value
253         if (authenticationMethod == childViewModel?.authenticationMethod) {
254             return childViewModel
255         }
256 
257         childViewModelScope?.cancel()
258         val newViewModelScope = createChildCoroutineScope(applicationScope)
259         childViewModelScope = newViewModelScope
260         return when (authenticationMethod) {
261             is AuthenticationMethodModel.Pin ->
262                 PinBouncerViewModel(
263                     applicationContext = applicationContext,
264                     viewModelScope = newViewModelScope,
265                     interactor = bouncerInteractor,
266                     isInputEnabled = isInputEnabled,
267                     simBouncerInteractor = simBouncerInteractor,
268                     authenticationMethod = authenticationMethod,
269                     onIntentionalUserInput = ::onIntentionalUserInput
270                 )
271             is AuthenticationMethodModel.Sim ->
272                 PinBouncerViewModel(
273                     applicationContext = applicationContext,
274                     viewModelScope = newViewModelScope,
275                     interactor = bouncerInteractor,
276                     isInputEnabled = isInputEnabled,
277                     simBouncerInteractor = simBouncerInteractor,
278                     authenticationMethod = authenticationMethod,
279                     onIntentionalUserInput = ::onIntentionalUserInput
280                 )
281             is AuthenticationMethodModel.Password ->
282                 PasswordBouncerViewModel(
283                     viewModelScope = newViewModelScope,
284                     isInputEnabled = isInputEnabled,
285                     interactor = bouncerInteractor,
286                     inputMethodInteractor = inputMethodInteractor,
287                     selectedUserInteractor = selectedUserInteractor,
288                     onIntentionalUserInput = ::onIntentionalUserInput
289                 )
290             is AuthenticationMethodModel.Pattern ->
291                 PatternBouncerViewModel(
292                     applicationContext = applicationContext,
293                     viewModelScope = newViewModelScope,
294                     interactor = bouncerInteractor,
295                     isInputEnabled = isInputEnabled,
296                     onIntentionalUserInput = ::onIntentionalUserInput
297                 )
298             else -> null
299         }
300     }
301 
302     private fun onIntentionalUserInput() {
303         message.showDefaultMessage()
304         bouncerInteractor.onIntentionalUserInput()
305     }
306 
307     private fun createChildCoroutineScope(parentScope: CoroutineScope): CoroutineScope {
308         return CoroutineScope(
309             SupervisorJob(parent = parentScope.coroutineContext.job) + mainDispatcher
310         )
311     }
312 
313     /**
314      * @return A message warning the user that the user/profile/device will be wiped upon a further
315      *   [AuthenticationWipeModel.remainingAttempts] unsuccessful authentication attempts.
316      */
317     private fun AuthenticationWipeModel.getAlmostAtWipeMessage(): String {
318         val message =
319             applicationContext.getString(
320                 wipeTarget.messageIdForAlmostWipe,
321                 failedAttempts,
322                 remainingAttempts,
323             )
324         return if (wipeTarget == AuthenticationWipeModel.WipeTarget.ManagedProfile) {
325             devicePolicyManager.resources.getString(
326                 DevicePolicyResources.Strings.SystemUi
327                     .KEYGUARD_DIALOG_FAILED_ATTEMPTS_ALMOST_ERASING_PROFILE,
328                 { message },
329                 failedAttempts,
330                 remainingAttempts,
331             )
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             )
350                 ?: message
351         } else {
352             message
353         }
354     }
355 
356     private val AuthenticationWipeModel.message: String
357         get() = if (remainingAttempts > 0) getAlmostAtWipeMessage() else getWipeMessage()
358 
359     private fun createDialogViewModel(): DialogViewModel? {
360         val wipeText = wipeDialogMessage.value
361         val lockoutText = lockoutDialogMessage.value
362         return when {
363             // The wipe dialog takes priority over the lockout dialog.
364             wipeText != null ->
365                 DialogViewModel(
366                     text = wipeText,
367                     onDismiss = { wipeDialogMessage.value = null },
368                 )
369             lockoutText != null ->
370                 DialogViewModel(
371                     text = lockoutText,
372                     onDismiss = { lockoutDialogMessage.value = null },
373                 )
374             else -> null // No dialog to show.
375         }
376     }
377 
378     private fun destinationSceneMap(prevScene: SceneKey) =
379         mapOf(
380             Back to UserActionResult(prevScene),
381             Swipe(SwipeDirection.Down) to UserActionResult(prevScene),
382         )
383 
384     /**
385      * Notifies that a key event has occurred.
386      *
387      * @return `true` when the [KeyEvent] was consumed as user input on bouncer; `false` otherwise.
388      */
389     fun onKeyEvent(keyEvent: KeyEvent): Boolean {
390         return (authMethodViewModel.value as? PinBouncerViewModel)?.onKeyEvent(
391             keyEvent.type,
392             keyEvent.nativeKeyEvent.keyCode
393         )
394             ?: false
395     }
396 
397     data class DialogViewModel(
398         val text: String,
399 
400         /** Callback to run after the dialog has been dismissed by the user. */
401         val onDismiss: () -> Unit,
402     )
403 
404     data class UserSwitcherDropdownItemViewModel(
405         val icon: Icon,
406         val text: Text,
407         val onClick: () -> Unit,
408     )
409 }
410 
411 @Module
412 object BouncerViewModelModule {
413 
414     @Provides
415     @SysUISingleton
viewModelnull416     fun viewModel(
417         @Application applicationContext: Context,
418         @Application applicationScope: CoroutineScope,
419         @Main mainDispatcher: CoroutineDispatcher,
420         bouncerInteractor: BouncerInteractor,
421         imeInteractor: InputMethodInteractor,
422         simBouncerInteractor: SimBouncerInteractor,
423         actionButtonInteractor: BouncerActionButtonInteractor,
424         authenticationInteractor: AuthenticationInteractor,
425         selectedUserInteractor: SelectedUserInteractor,
426         flags: ComposeBouncerFlags,
427         userSwitcherViewModel: UserSwitcherViewModel,
428         devicePolicyManager: DevicePolicyManager,
429         bouncerMessageViewModel: BouncerMessageViewModel,
430     ): BouncerViewModel {
431         return BouncerViewModel(
432             applicationContext = applicationContext,
433             applicationScope = applicationScope,
434             mainDispatcher = mainDispatcher,
435             bouncerInteractor = bouncerInteractor,
436             inputMethodInteractor = imeInteractor,
437             simBouncerInteractor = simBouncerInteractor,
438             authenticationInteractor = authenticationInteractor,
439             selectedUserInteractor = selectedUserInteractor,
440             devicePolicyManager = devicePolicyManager,
441             bouncerMessageViewModel = bouncerMessageViewModel,
442             flags = flags,
443             selectedUser = userSwitcherViewModel.selectedUser,
444             users = userSwitcherViewModel.users,
445             userSwitcherMenu = userSwitcherViewModel.menu,
446             actionButton = actionButtonInteractor.actionButton,
447         )
448     }
449 }
450