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