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 package com.android.systemui.biometrics.ui.viewmodel 17 18 import android.content.Context 19 import android.graphics.Rect 20 import android.hardware.biometrics.BiometricPrompt 21 import android.util.Log 22 import android.view.HapticFeedbackConstants 23 import android.view.MotionEvent 24 import com.android.systemui.biometrics.AuthBiometricView 25 import com.android.systemui.biometrics.Utils 26 import com.android.systemui.biometrics.domain.interactor.DisplayStateInteractor 27 import com.android.systemui.biometrics.domain.interactor.PromptSelectorInteractor 28 import com.android.systemui.biometrics.domain.model.BiometricModalities 29 import com.android.systemui.biometrics.shared.model.BiometricModality 30 import com.android.systemui.biometrics.shared.model.DisplayRotation 31 import com.android.systemui.biometrics.shared.model.PromptKind 32 import com.android.systemui.dagger.qualifiers.Application 33 import com.android.systemui.flags.FeatureFlags 34 import com.android.systemui.flags.Flags.ONE_WAY_HAPTICS_API_MIGRATION 35 import com.android.systemui.statusbar.VibratorHelper 36 import javax.inject.Inject 37 import kotlinx.coroutines.Job 38 import kotlinx.coroutines.coroutineScope 39 import kotlinx.coroutines.delay 40 import kotlinx.coroutines.flow.Flow 41 import kotlinx.coroutines.flow.MutableStateFlow 42 import kotlinx.coroutines.flow.StateFlow 43 import kotlinx.coroutines.flow.asStateFlow 44 import kotlinx.coroutines.flow.combine 45 import kotlinx.coroutines.flow.distinctUntilChanged 46 import kotlinx.coroutines.flow.first 47 import kotlinx.coroutines.flow.map 48 import kotlinx.coroutines.launch 49 50 /** ViewModel for BiometricPrompt. */ 51 class PromptViewModel 52 @Inject 53 constructor( 54 private val displayStateInteractor: DisplayStateInteractor, 55 private val promptSelectorInteractor: PromptSelectorInteractor, 56 private val vibrator: VibratorHelper, 57 @Application context: Context, 58 private val featureFlags: FeatureFlags, 59 ) { 60 /** Models UI of [BiometricPromptLayout.iconView] */ 61 val fingerprintIconViewModel: PromptFingerprintIconViewModel = 62 PromptFingerprintIconViewModel(displayStateInteractor, promptSelectorInteractor) 63 64 /** The set of modalities available for this prompt */ 65 val modalities: Flow<BiometricModalities> = 66 promptSelectorInteractor.prompt 67 .map { it?.modalities ?: BiometricModalities() } 68 .distinctUntilChanged() 69 70 // TODO(b/251476085): remove after icon controllers are migrated - do not keep this state 71 private var _legacyState = MutableStateFlow(AuthBiometricView.STATE_IDLE) 72 val legacyState: StateFlow<Int> = _legacyState.asStateFlow() 73 74 private val _isAuthenticating: MutableStateFlow<Boolean> = MutableStateFlow(false) 75 76 /** If the user is currently authenticating (i.e. at least one biometric is scanning). */ 77 val isAuthenticating: Flow<Boolean> = _isAuthenticating.asStateFlow() 78 79 private val _isAuthenticated: MutableStateFlow<PromptAuthState> = 80 MutableStateFlow(PromptAuthState(false)) 81 82 /** If the user has successfully authenticated and confirmed (when explicitly required). */ 83 val isAuthenticated: Flow<PromptAuthState> = _isAuthenticated.asStateFlow() 84 85 private val _isOverlayTouched: MutableStateFlow<Boolean> = MutableStateFlow(false) 86 87 /** The kind of credential the user has. */ 88 val credentialKind: Flow<PromptKind> = promptSelectorInteractor.credentialKind 89 90 /** The label to use for the cancel button. */ 91 val negativeButtonText: Flow<String> = 92 promptSelectorInteractor.prompt.map { it?.negativeButtonText ?: "" } 93 94 private val _message: MutableStateFlow<PromptMessage> = MutableStateFlow(PromptMessage.Empty) 95 96 /** A message to show the user, if there is an error, hint, or help to show. */ 97 val message: Flow<PromptMessage> = _message.asStateFlow() 98 99 private val isRetrySupported: Flow<Boolean> = modalities.map { it.hasFace } 100 101 private val _fingerprintStartMode = MutableStateFlow(FingerprintStartMode.Pending) 102 103 /** Fingerprint sensor state. */ 104 val fingerprintStartMode: Flow<FingerprintStartMode> = _fingerprintStartMode.asStateFlow() 105 106 private val _forceLargeSize = MutableStateFlow(false) 107 private val _forceMediumSize = MutableStateFlow(false) 108 109 private val _hapticsToPlay = MutableStateFlow(HapticFeedbackConstants.NO_HAPTICS) 110 111 /** Event fired to the view indicating a [HapticFeedbackConstants] to be played */ 112 val hapticsToPlay = _hapticsToPlay.asStateFlow() 113 114 /** The size of the prompt. */ 115 val size: Flow<PromptSize> = 116 combine( 117 _forceLargeSize, 118 _forceMediumSize, 119 modalities, 120 promptSelectorInteractor.isConfirmationRequired, 121 fingerprintStartMode, 122 ) { forceLarge, forceMedium, modalities, confirmationRequired, fpStartMode -> 123 when { 124 forceLarge -> PromptSize.LARGE 125 forceMedium -> PromptSize.MEDIUM 126 modalities.hasFaceOnly && !confirmationRequired -> PromptSize.SMALL 127 modalities.hasFaceAndFingerprint && 128 !confirmationRequired && 129 fpStartMode == FingerprintStartMode.Pending -> PromptSize.SMALL 130 else -> PromptSize.MEDIUM 131 } 132 } 133 .distinctUntilChanged() 134 135 /** 136 * If the API caller or the user's personal preferences require explicit confirmation after 137 * successful authentication. Confirmation always required when in explicit flow. 138 */ 139 val isConfirmationRequired: Flow<Boolean> = 140 combine(_isOverlayTouched, size) { isOverlayTouched, size -> 141 !isOverlayTouched && size.isNotSmall 142 } 143 144 /** Padding for prompt UI elements */ 145 val promptPadding: Flow<Rect> = 146 combine(size, displayStateInteractor.currentRotation) { size, rotation -> 147 if (size != PromptSize.LARGE) { 148 val navBarInsets = Utils.getNavbarInsets(context) 149 if (rotation == DisplayRotation.ROTATION_90) { 150 Rect(0, 0, navBarInsets.right, 0) 151 } else if (rotation == DisplayRotation.ROTATION_270) { 152 Rect(navBarInsets.left, 0, 0, 0) 153 } else { 154 Rect(0, 0, 0, navBarInsets.bottom) 155 } 156 } else { 157 Rect(0, 0, 0, 0) 158 } 159 } 160 161 /** Title for the prompt. */ 162 val title: Flow<String> = 163 promptSelectorInteractor.prompt.map { it?.title ?: "" }.distinctUntilChanged() 164 165 /** Subtitle for the prompt. */ 166 val subtitle: Flow<String> = 167 promptSelectorInteractor.prompt.map { it?.subtitle ?: "" }.distinctUntilChanged() 168 169 /** Description for the prompt. */ 170 val description: Flow<String> = 171 promptSelectorInteractor.prompt.map { it?.description ?: "" }.distinctUntilChanged() 172 173 /** If the indicator (help, error) message should be shown. */ 174 val isIndicatorMessageVisible: Flow<Boolean> = 175 combine( 176 size, 177 message, 178 ) { size, message -> 179 size.isNotSmall && message.message.isNotBlank() 180 } 181 .distinctUntilChanged() 182 183 /** If the auth is pending confirmation and the confirm button should be shown. */ 184 val isConfirmButtonVisible: Flow<Boolean> = 185 combine( 186 size, 187 isAuthenticated, 188 ) { size, authState -> 189 size.isNotSmall && authState.isAuthenticated && authState.needsUserConfirmation 190 } 191 .distinctUntilChanged() 192 193 /** If the icon can be used as a confirmation button. */ 194 val isIconConfirmButton: Flow<Boolean> = size.map { it.isNotSmall }.distinctUntilChanged() 195 196 /** If the negative button should be shown. */ 197 val isNegativeButtonVisible: Flow<Boolean> = 198 combine( 199 size, 200 isAuthenticated, 201 promptSelectorInteractor.isCredentialAllowed, 202 ) { size, authState, credentialAllowed -> 203 size.isNotSmall && authState.isNotAuthenticated && !credentialAllowed 204 } 205 .distinctUntilChanged() 206 207 /** If the cancel button should be shown (. */ 208 val isCancelButtonVisible: Flow<Boolean> = 209 combine( 210 size, 211 isAuthenticated, 212 isNegativeButtonVisible, 213 isConfirmButtonVisible, 214 ) { size, authState, showNegativeButton, showConfirmButton -> 215 size.isNotSmall && 216 authState.isAuthenticated && 217 !showNegativeButton && 218 showConfirmButton 219 } 220 .distinctUntilChanged() 221 222 private val _canTryAgainNow = MutableStateFlow(false) 223 /** 224 * If authentication can be manually restarted via the try again button or touching a 225 * fingerprint sensor. 226 */ 227 val canTryAgainNow: Flow<Boolean> = 228 combine( 229 _canTryAgainNow, 230 size, 231 isAuthenticated, 232 isRetrySupported, 233 ) { readyToTryAgain, size, authState, supportsRetry -> 234 readyToTryAgain && size.isNotSmall && supportsRetry && authState.isNotAuthenticated 235 } 236 .distinctUntilChanged() 237 238 /** If the try again button show be shown (only the button, see [canTryAgainNow]). */ 239 val isTryAgainButtonVisible: Flow<Boolean> = 240 combine( 241 canTryAgainNow, 242 modalities, 243 ) { tryAgainIsPossible, modalities -> 244 tryAgainIsPossible && modalities.hasFaceOnly 245 } 246 .distinctUntilChanged() 247 248 /** If the credential fallback button show be shown. */ 249 val isCredentialButtonVisible: Flow<Boolean> = 250 combine( 251 size, 252 isAuthenticated, 253 promptSelectorInteractor.isCredentialAllowed, 254 ) { size, authState, credentialAllowed -> 255 size.isNotSmall && authState.isNotAuthenticated && credentialAllowed 256 } 257 .distinctUntilChanged() 258 259 private val history = PromptHistoryImpl() 260 private var messageJob: Job? = null 261 262 /** 263 * Show a temporary error [message] associated with an optional [failedModality] and play 264 * [hapticFeedback]. 265 * 266 * The [messageAfterError] will be shown via [showAuthenticating] when [authenticateAfterError] 267 * is set (or via [showHelp] when not set) after the error is dismissed. 268 * 269 * The error is ignored if the user has already authenticated or if [suppressIf] is true given 270 * the currently showing [PromptMessage] and [PromptHistory]. 271 */ 272 suspend fun showTemporaryError( 273 message: String, 274 messageAfterError: String, 275 authenticateAfterError: Boolean, 276 suppressIf: (PromptMessage, PromptHistory) -> Boolean = { _, _ -> false }, 277 hapticFeedback: Boolean = true, 278 failedModality: BiometricModality = BiometricModality.None, 279 ) = coroutineScope { 280 if (_isAuthenticated.value.isAuthenticated) { 281 return@coroutineScope 282 } 283 284 _canTryAgainNow.value = supportsRetry(failedModality) 285 286 val suppress = suppressIf(_message.value, history) 287 history.failure(failedModality) 288 if (suppress) { 289 return@coroutineScope 290 } 291 292 _isAuthenticating.value = false 293 _isAuthenticated.value = PromptAuthState(false) 294 _forceMediumSize.value = true 295 _message.value = PromptMessage.Error(message) 296 _legacyState.value = AuthBiometricView.STATE_ERROR 297 298 if (hapticFeedback) { 299 vibrator.error(failedModality) 300 } 301 302 messageJob?.cancel() 303 messageJob = launch { 304 delay(BiometricPrompt.HIDE_DIALOG_DELAY.toLong()) 305 if (authenticateAfterError) { 306 showAuthenticating(messageAfterError) 307 } else { 308 showInfo(messageAfterError) 309 } 310 } 311 } 312 313 /** 314 * Call to ensure the fingerprint sensor has started. Either when the dialog is first shown 315 * (most cases) or when it should be enabled after a first error (coex implicit flow). 316 */ 317 fun ensureFingerprintHasStarted(isDelayed: Boolean) { 318 if (_fingerprintStartMode.value == FingerprintStartMode.Pending) { 319 _fingerprintStartMode.value = 320 if (isDelayed) FingerprintStartMode.Delayed else FingerprintStartMode.Normal 321 } 322 } 323 324 // enable retry only when face fails (fingerprint runs constantly) 325 private fun supportsRetry(failedModality: BiometricModality) = 326 failedModality == BiometricModality.Face 327 328 suspend fun showHelp(message: String) = showHelp(message, clearIconError = false) 329 suspend fun showInfo(message: String) = showHelp(message, clearIconError = true) 330 331 /** 332 * Show a persistent help message. 333 * 334 * Will be show even if the user has already authenticated. 335 */ 336 private suspend fun showHelp(message: String, clearIconError: Boolean) { 337 val alreadyAuthenticated = _isAuthenticated.value.isAuthenticated 338 if (!alreadyAuthenticated) { 339 _isAuthenticating.value = false 340 _isAuthenticated.value = PromptAuthState(false) 341 } 342 343 _message.value = 344 if (message.isNotBlank()) PromptMessage.Help(message) else PromptMessage.Empty 345 _forceMediumSize.value = true 346 _legacyState.value = 347 if (alreadyAuthenticated && isConfirmationRequired.first()) { 348 AuthBiometricView.STATE_PENDING_CONFIRMATION 349 } else if (alreadyAuthenticated && !isConfirmationRequired.first()) { 350 AuthBiometricView.STATE_AUTHENTICATED 351 } else if (clearIconError) { 352 AuthBiometricView.STATE_IDLE 353 } else { 354 AuthBiometricView.STATE_HELP 355 } 356 357 messageJob?.cancel() 358 messageJob = null 359 } 360 361 /** 362 * Show a temporary help message and transition back to a fixed message. 363 * 364 * Ignored if the user has already authenticated. 365 */ 366 suspend fun showTemporaryHelp( 367 message: String, 368 messageAfterHelp: String = "", 369 ) = coroutineScope { 370 if (_isAuthenticated.value.isAuthenticated) { 371 return@coroutineScope 372 } 373 374 _isAuthenticating.value = false 375 _isAuthenticated.value = PromptAuthState(false) 376 _message.value = 377 if (message.isNotBlank()) PromptMessage.Help(message) else PromptMessage.Empty 378 _forceMediumSize.value = true 379 _legacyState.value = AuthBiometricView.STATE_HELP 380 381 messageJob?.cancel() 382 messageJob = launch { 383 delay(BiometricPrompt.HIDE_DIALOG_DELAY.toLong()) 384 showAuthenticating(messageAfterHelp) 385 } 386 } 387 388 /** Show the user that biometrics are actively running and set [isAuthenticating]. */ 389 fun showAuthenticating(message: String = "", isRetry: Boolean = false) { 390 if (_isAuthenticated.value.isAuthenticated) { 391 // TODO(jbolinger): convert to go/tex-apc? 392 Log.w(TAG, "Cannot show authenticating after authenticated") 393 return 394 } 395 396 _isAuthenticating.value = true 397 _isAuthenticated.value = PromptAuthState(false) 398 _message.value = if (message.isBlank()) PromptMessage.Empty else PromptMessage.Help(message) 399 _legacyState.value = AuthBiometricView.STATE_AUTHENTICATING 400 401 // reset the try again button(s) after the user attempts a retry 402 if (isRetry) { 403 _canTryAgainNow.value = false 404 } 405 406 messageJob?.cancel() 407 messageJob = null 408 } 409 410 /** 411 * Show successfully authentication, set [isAuthenticated], and dismiss the prompt after a 412 * [dismissAfterDelay] or prompt for explicit confirmation (if required). 413 */ 414 suspend fun showAuthenticated( 415 modality: BiometricModality, 416 dismissAfterDelay: Long, 417 helpMessage: String = "", 418 ) { 419 if (_isAuthenticated.value.isAuthenticated) { 420 // TODO(jbolinger): convert to go/tex-apc? 421 Log.w(TAG, "Cannot show authenticated after authenticated") 422 return 423 } 424 425 _isAuthenticating.value = false 426 val needsUserConfirmation = needsExplicitConfirmation(modality) 427 _isAuthenticated.value = 428 PromptAuthState(true, modality, needsUserConfirmation, dismissAfterDelay) 429 _message.value = PromptMessage.Empty 430 _legacyState.value = 431 if (needsUserConfirmation) { 432 AuthBiometricView.STATE_PENDING_CONFIRMATION 433 } else { 434 AuthBiometricView.STATE_AUTHENTICATED 435 } 436 437 if (!needsUserConfirmation) { 438 vibrator.success(modality) 439 } 440 441 messageJob?.cancel() 442 messageJob = null 443 444 if (helpMessage.isNotBlank()) { 445 showHelp(helpMessage) 446 } 447 } 448 449 private suspend fun needsExplicitConfirmation(modality: BiometricModality): Boolean { 450 val confirmationRequired = isConfirmationRequired.first() 451 452 // Only worry about confirmationRequired if face was used to unlock 453 if (modality == BiometricModality.Face) { 454 return confirmationRequired 455 } 456 // fingerprint only never requires confirmation 457 return false 458 } 459 460 /** 461 * Set the prompt's auth state to authenticated and confirmed. 462 * 463 * This should only be used after [showAuthenticated] when the operation requires explicit user 464 * confirmation. 465 */ 466 fun confirmAuthenticated() { 467 val authState = _isAuthenticated.value 468 if (authState.isNotAuthenticated) { 469 Log.w(TAG, "Cannot confirm authenticated when not authenticated") 470 return 471 } 472 473 _isAuthenticated.value = authState.asExplicitlyConfirmed() 474 _message.value = PromptMessage.Empty 475 _legacyState.value = AuthBiometricView.STATE_AUTHENTICATED 476 477 vibrator.success(authState.authenticatedModality) 478 479 messageJob?.cancel() 480 messageJob = null 481 } 482 483 /** 484 * Touch event occurred on the overlay 485 * 486 * Tracks whether a finger is currently down to set [_isOverlayTouched] to be used as user 487 * confirmation 488 */ 489 fun onOverlayTouch(event: MotionEvent): Boolean { 490 if (event.actionMasked == MotionEvent.ACTION_DOWN) { 491 _isOverlayTouched.value = true 492 493 if (_isAuthenticated.value.needsUserConfirmation) { 494 confirmAuthenticated() 495 } 496 return true 497 } else if (event.actionMasked == MotionEvent.ACTION_UP) { 498 _isOverlayTouched.value = false 499 } 500 return false 501 } 502 503 /** 504 * Switch to the credential view. 505 * 506 * TODO(b/251476085): this should be decoupled from the shared panel controller 507 */ 508 fun onSwitchToCredential() { 509 _forceLargeSize.value = true 510 } 511 512 private fun VibratorHelper.success(modality: BiometricModality) { 513 if (featureFlags.isEnabled(ONE_WAY_HAPTICS_API_MIGRATION)) { 514 _hapticsToPlay.value = HapticFeedbackConstants.CONFIRM 515 } else { 516 vibrateAuthSuccess("$TAG, modality = $modality BP::success") 517 } 518 } 519 520 private fun VibratorHelper.error(modality: BiometricModality = BiometricModality.None) { 521 if (featureFlags.isEnabled(ONE_WAY_HAPTICS_API_MIGRATION)) { 522 _hapticsToPlay.value = HapticFeedbackConstants.REJECT 523 } else { 524 vibrateAuthError("$TAG, modality = $modality BP::error") 525 } 526 } 527 528 /** Clears the [hapticsToPlay] variable by setting it to the NO_HAPTICS default. */ 529 fun clearHaptics() { 530 _hapticsToPlay.value = HapticFeedbackConstants.NO_HAPTICS 531 } 532 533 companion object { 534 private const val TAG = "PromptViewModel" 535 } 536 } 537 538 /** How the fingerprint sensor was started for the prompt. */ 539 enum class FingerprintStartMode { 540 /** Fingerprint sensor has not started. */ 541 Pending, 542 543 /** Fingerprint sensor started immediately when prompt was displayed. */ 544 Normal, 545 546 /** Fingerprint sensor started after the first failure of another passive modality. */ 547 Delayed; 548 549 /** If this is [Normal] or [Delayed]. */ 550 val isStarted: Boolean 551 get() = this == Normal || this == Delayed 552 } 553