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