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.biometrics.ui.viewmodel
18
19 import android.app.ActivityTaskManager
20 import android.content.ComponentName
21 import android.content.Context
22 import android.content.pm.ActivityInfo
23 import android.content.pm.ApplicationInfo
24 import android.content.pm.PackageManager
25 import android.graphics.Rect
26 import android.graphics.drawable.BitmapDrawable
27 import android.graphics.drawable.Drawable
28 import android.hardware.biometrics.BiometricFingerprintConstants
29 import android.hardware.biometrics.BiometricPrompt
30 import android.hardware.biometrics.Flags.customBiometricPrompt
31 import android.hardware.biometrics.PromptContentView
32 import android.os.UserHandle
33 import android.text.TextPaint
34 import android.util.Log
35 import android.util.RotationUtils
36 import android.view.HapticFeedbackConstants
37 import android.view.MotionEvent
38 import com.android.launcher3.icons.IconProvider
39 import com.android.systemui.Flags.bpTalkback
40 import com.android.systemui.Flags.constraintBp
41 import com.android.systemui.biometrics.UdfpsUtils
42 import com.android.systemui.biometrics.Utils
43 import com.android.systemui.biometrics.Utils.isSystem
44 import com.android.systemui.biometrics.domain.interactor.BiometricStatusInteractor
45 import com.android.systemui.biometrics.domain.interactor.DisplayStateInteractor
46 import com.android.systemui.biometrics.domain.interactor.PromptSelectorInteractor
47 import com.android.systemui.biometrics.domain.interactor.UdfpsOverlayInteractor
48 import com.android.systemui.biometrics.domain.model.BiometricPromptRequest
49 import com.android.systemui.biometrics.shared.model.BiometricModalities
50 import com.android.systemui.biometrics.shared.model.BiometricModality
51 import com.android.systemui.biometrics.shared.model.DisplayRotation
52 import com.android.systemui.biometrics.shared.model.PromptKind
53 import com.android.systemui.dagger.qualifiers.Application
54 import com.android.systemui.keyguard.shared.model.AcquiredFingerprintAuthenticationStatus
55 import com.android.systemui.res.R
56 import com.android.systemui.util.kotlin.combine
57 import javax.inject.Inject
58 import kotlinx.coroutines.Job
59 import kotlinx.coroutines.coroutineScope
60 import kotlinx.coroutines.delay
61 import kotlinx.coroutines.flow.Flow
62 import kotlinx.coroutines.flow.MutableSharedFlow
63 import kotlinx.coroutines.flow.MutableStateFlow
64 import kotlinx.coroutines.flow.StateFlow
65 import kotlinx.coroutines.flow.asSharedFlow
66 import kotlinx.coroutines.flow.asStateFlow
67 import kotlinx.coroutines.flow.combine
68 import kotlinx.coroutines.flow.distinctUntilChanged
69 import kotlinx.coroutines.flow.first
70 import kotlinx.coroutines.flow.map
71 import kotlinx.coroutines.flow.update
72 import kotlinx.coroutines.launch
73
74 /** ViewModel for BiometricPrompt. */
75 class PromptViewModel
76 @Inject
77 constructor(
78 displayStateInteractor: DisplayStateInteractor,
79 private val promptSelectorInteractor: PromptSelectorInteractor,
80 @Application private val context: Context,
81 private val udfpsOverlayInteractor: UdfpsOverlayInteractor,
82 private val biometricStatusInteractor: BiometricStatusInteractor,
83 private val udfpsUtils: UdfpsUtils,
84 private val iconProvider: IconProvider,
85 private val activityTaskManager: ActivityTaskManager,
86 ) {
87 /** The set of modalities available for this prompt */
88 val modalities: Flow<BiometricModalities> =
89 promptSelectorInteractor.prompt
90 .map { it?.modalities ?: BiometricModalities() }
91 .distinctUntilChanged()
92
93 /** Layout params for fingerprint iconView */
94 val fingerprintIconWidth: Int =
95 context.resources.getDimensionPixelSize(R.dimen.biometric_dialog_fingerprint_icon_width)
96 val fingerprintIconHeight: Int =
97 context.resources.getDimensionPixelSize(R.dimen.biometric_dialog_fingerprint_icon_height)
98
99 /** Layout params for face iconView */
100 val faceIconWidth: Int =
101 context.resources.getDimensionPixelSize(R.dimen.biometric_dialog_face_icon_size)
102 val faceIconHeight: Int =
103 context.resources.getDimensionPixelSize(R.dimen.biometric_dialog_face_icon_size)
104
105 /** Padding for placing icons */
106 val portraitSmallBottomPadding =
107 context.resources.getDimensionPixelSize(
108 R.dimen.biometric_prompt_portrait_small_bottom_padding
109 )
110 val portraitMediumBottomPadding =
111 context.resources.getDimensionPixelSize(
112 R.dimen.biometric_prompt_portrait_medium_bottom_padding
113 )
114 val portraitLargeScreenBottomPadding =
115 context.resources.getDimensionPixelSize(
116 R.dimen.biometric_prompt_portrait_large_screen_bottom_padding
117 )
118 val landscapeSmallBottomPadding =
119 context.resources.getDimensionPixelSize(
120 R.dimen.biometric_prompt_landscape_small_bottom_padding
121 )
122 val landscapeSmallHorizontalPadding =
123 context.resources.getDimensionPixelSize(
124 R.dimen.biometric_prompt_landscape_small_horizontal_padding
125 )
126 val landscapeMediumBottomPadding =
127 context.resources.getDimensionPixelSize(
128 R.dimen.biometric_prompt_landscape_medium_bottom_padding
129 )
130 val landscapeMediumHorizontalPadding =
131 context.resources.getDimensionPixelSize(
132 R.dimen.biometric_prompt_landscape_medium_horizontal_padding
133 )
134
135 private val udfpsSensorBounds: Flow<Rect> =
136 combine(
137 udfpsOverlayInteractor.udfpsOverlayParams,
138 displayStateInteractor.currentRotation
139 ) { params, rotation ->
140 val rotatedBounds = Rect(params.sensorBounds)
141 RotationUtils.rotateBounds(
142 rotatedBounds,
143 params.naturalDisplayWidth,
144 params.naturalDisplayHeight,
145 rotation.ordinal
146 )
147 Rect(
148 rotatedBounds.left,
149 rotatedBounds.top,
150 params.logicalDisplayWidth - rotatedBounds.right,
151 params.logicalDisplayHeight - rotatedBounds.bottom
152 )
153 }
154 .distinctUntilChanged()
155
156 val legacyFingerprintSensorWidth: Flow<Int> =
157 combine(modalities, udfpsOverlayInteractor.udfpsOverlayParams) { modalities, overlayParams
158 ->
159 if (modalities.hasUdfps) {
160 overlayParams.sensorBounds.width()
161 } else {
162 fingerprintIconWidth
163 }
164 }
165
166 val legacyFingerprintSensorHeight: Flow<Int> =
167 combine(modalities, udfpsOverlayInteractor.udfpsOverlayParams) { modalities, overlayParams
168 ->
169 if (modalities.hasUdfps) {
170 overlayParams.sensorBounds.height()
171 } else {
172 fingerprintIconHeight
173 }
174 }
175
176 val fingerprintSensorWidth: Int =
177 udfpsOverlayInteractor.udfpsOverlayParams.value.sensorBounds.width()
178
179 val fingerprintSensorHeight: Int =
180 udfpsOverlayInteractor.udfpsOverlayParams.value.sensorBounds.height()
181
182 private val _accessibilityHint = MutableSharedFlow<String>()
183
184 /** Hint for talkback directional guidance */
185 val accessibilityHint: Flow<String> = _accessibilityHint.asSharedFlow()
186
187 private val _isAuthenticating: MutableStateFlow<Boolean> = MutableStateFlow(false)
188
189 /** If the user is currently authenticating (i.e. at least one biometric is scanning). */
190 val isAuthenticating: Flow<Boolean> = _isAuthenticating.asStateFlow()
191
192 private val _isAuthenticated: MutableStateFlow<PromptAuthState> =
193 MutableStateFlow(PromptAuthState(false))
194
195 /** If the user has successfully authenticated and confirmed (when explicitly required). */
196 val isAuthenticated: Flow<PromptAuthState> = _isAuthenticated.asStateFlow()
197
198 /** If the auth is pending confirmation. */
199 val isPendingConfirmation: Flow<Boolean> =
200 isAuthenticated.map { authState ->
201 authState.isAuthenticated && authState.needsUserConfirmation
202 }
203
204 private val _isOverlayTouched: MutableStateFlow<Boolean> = MutableStateFlow(false)
205
206 /** The kind of credential the user has. */
207 val credentialKind: Flow<PromptKind> = promptSelectorInteractor.credentialKind
208
209 /** The kind of prompt to use (biometric, pin, pattern, etc.). */
210 val promptKind: StateFlow<PromptKind> = promptSelectorInteractor.promptKind
211
212 /** Whether the sensor icon on biometric prompt ui should be hidden. */
213 val hideSensorIcon: Flow<Boolean> = modalities.map { it.isEmpty }.distinctUntilChanged()
214
215 /** The label to use for the cancel button. */
216 val negativeButtonText: Flow<String> =
217 promptSelectorInteractor.prompt.map { it?.negativeButtonText ?: "" }
218
219 private val _message: MutableStateFlow<PromptMessage> = MutableStateFlow(PromptMessage.Empty)
220
221 /** A message to show the user, if there is an error, hint, or help to show. */
222 val message: Flow<PromptMessage> = _message.asStateFlow()
223
224 /** Whether an error message is currently being shown. */
225 val showingError: Flow<Boolean> = message.map { it.isError }.distinctUntilChanged()
226
227 private val isRetrySupported: Flow<Boolean> = modalities.map { it.hasFace }
228
229 private val _fingerprintStartMode = MutableStateFlow(FingerprintStartMode.Pending)
230
231 /** Fingerprint sensor state. */
232 val fingerprintStartMode: Flow<FingerprintStartMode> = _fingerprintStartMode.asStateFlow()
233
234 /** Whether a finger has been acquired by the sensor */
235 val hasFingerBeenAcquired: Flow<Boolean> =
236 combine(biometricStatusInteractor.fingerprintAcquiredStatus, modalities) {
237 status,
238 modalities ->
239 modalities.hasSfps &&
240 status is AcquiredFingerprintAuthenticationStatus &&
241 status.acquiredInfo == BiometricFingerprintConstants.FINGERPRINT_ACQUIRED_START
242 }
243 .distinctUntilChanged()
244
245 /** Whether there is currently a finger on the sensor */
246 val hasFingerOnSensor: Flow<Boolean> =
247 combine(hasFingerBeenAcquired, _isOverlayTouched) { hasFingerBeenAcquired, overlayTouched ->
248 hasFingerBeenAcquired || overlayTouched
249 }
250
251 private val _forceLargeSize = MutableStateFlow(false)
252 private val _forceMediumSize = MutableStateFlow(false)
253
254 private val _hapticsToPlay =
255 MutableStateFlow(HapticsToPlay(HapticFeedbackConstants.NO_HAPTICS, /* flag= */ null))
256
257 /** Event fired to the view indicating a [HapticsToPlay] */
258 val hapticsToPlay = _hapticsToPlay.asStateFlow()
259
260 /** The current position of the prompt */
261 val position: Flow<PromptPosition> =
262 combine(
263 _forceLargeSize,
264 promptKind,
265 displayStateInteractor.isLargeScreen,
266 displayStateInteractor.currentRotation,
267 modalities
268 ) { forceLarge, promptKind, isLargeScreen, rotation, modalities ->
269 when {
270 forceLarge ||
271 isLargeScreen ||
272 promptKind.isOnePaneNoSensorLandscapeBiometric() -> PromptPosition.Bottom
273 rotation == DisplayRotation.ROTATION_90 -> PromptPosition.Right
274 rotation == DisplayRotation.ROTATION_270 -> PromptPosition.Left
275 rotation == DisplayRotation.ROTATION_180 && modalities.hasUdfps ->
276 PromptPosition.Top
277 else -> PromptPosition.Bottom
278 }
279 }
280 .distinctUntilChanged()
281
282 /** The size of the prompt. */
283 val size: Flow<PromptSize> =
284 combine(
285 _forceLargeSize,
286 _forceMediumSize,
287 modalities,
288 promptSelectorInteractor.isConfirmationRequired,
289 fingerprintStartMode,
290 ) { forceLarge, forceMedium, modalities, confirmationRequired, fpStartMode ->
291 when {
292 forceLarge -> PromptSize.LARGE
293 forceMedium -> PromptSize.MEDIUM
294 modalities.hasFaceOnly && !confirmationRequired -> PromptSize.SMALL
295 modalities.hasFaceAndFingerprint &&
296 !confirmationRequired &&
297 fpStartMode == FingerprintStartMode.Pending -> PromptSize.SMALL
298 else -> PromptSize.MEDIUM
299 }
300 }
301 .distinctUntilChanged()
302
303 /** Prompt panel size padding */
304 private val smallHorizontalGuidelinePadding =
305 context.resources.getDimensionPixelSize(
306 R.dimen.biometric_prompt_land_small_horizontal_guideline_padding
307 )
308 private val udfpsHorizontalGuidelinePadding =
309 context.resources.getDimensionPixelSize(
310 R.dimen.biometric_prompt_two_pane_udfps_horizontal_guideline_padding
311 )
312 private val udfpsHorizontalShorterGuidelinePadding =
313 context.resources.getDimensionPixelSize(
314 R.dimen.biometric_prompt_two_pane_udfps_shorter_horizontal_guideline_padding
315 )
316 private val mediumTopGuidelinePadding =
317 context.resources.getDimensionPixelSize(
318 R.dimen.biometric_prompt_one_pane_medium_top_guideline_padding
319 )
320 private val mediumHorizontalGuidelinePadding =
321 context.resources.getDimensionPixelSize(
322 R.dimen.biometric_prompt_two_pane_medium_horizontal_guideline_padding
323 )
324
325 /** Rect for positioning biometric icon */
326 val iconPosition: Flow<Rect> =
327 combine(udfpsSensorBounds, size, position, modalities) {
328 sensorBounds,
329 size,
330 position,
331 modalities ->
332 when (position) {
333 PromptPosition.Bottom ->
334 if (size.isSmall) {
335 Rect(0, 0, 0, portraitSmallBottomPadding)
336 } else if (size.isMedium && modalities.hasUdfps) {
337 Rect(0, 0, 0, sensorBounds.bottom)
338 } else if (size.isMedium) {
339 Rect(0, 0, 0, portraitMediumBottomPadding)
340 } else {
341 // Large screen
342 Rect(0, 0, 0, portraitLargeScreenBottomPadding)
343 }
344 PromptPosition.Right ->
345 if (size.isSmall || modalities.hasFaceOnly) {
346 Rect(0, 0, landscapeSmallHorizontalPadding, landscapeSmallBottomPadding)
347 } else if (size.isMedium && modalities.hasUdfps) {
348 Rect(0, 0, sensorBounds.right, sensorBounds.bottom)
349 } else {
350 // SFPS
351 Rect(
352 0,
353 0,
354 landscapeMediumHorizontalPadding,
355 landscapeMediumBottomPadding
356 )
357 }
358 PromptPosition.Left ->
359 if (size.isSmall || modalities.hasFaceOnly) {
360 Rect(landscapeSmallHorizontalPadding, 0, 0, landscapeSmallBottomPadding)
361 } else if (size.isMedium && modalities.hasUdfps) {
362 Rect(sensorBounds.left, 0, 0, sensorBounds.bottom)
363 } else {
364 // SFPS
365 Rect(
366 landscapeMediumHorizontalPadding,
367 0,
368 0,
369 landscapeMediumBottomPadding
370 )
371 }
372 PromptPosition.Top ->
373 if (size.isSmall) {
374 Rect(0, 0, 0, portraitSmallBottomPadding)
375 } else if (size.isMedium && modalities.hasUdfps) {
376 Rect(0, 0, 0, sensorBounds.bottom)
377 } else {
378 Rect(0, 0, 0, portraitMediumBottomPadding)
379 }
380 }
381 }
382 .distinctUntilChanged()
383
384 /**
385 * If the API caller or the user's personal preferences require explicit confirmation after
386 * successful authentication. Confirmation always required when in explicit flow.
387 */
388 val isConfirmationRequired: Flow<Boolean> =
389 combine(_isOverlayTouched, size) { isOverlayTouched, size ->
390 !isOverlayTouched && size.isNotSmall
391 }
392
393 /**
394 * When fingerprint and face modalities are enrolled, indicates whether only face auth has
395 * started.
396 *
397 * True when fingerprint and face modalities are enrolled and implicit flow is active. This
398 * occurs in co-ex auth when confirmation is not required and only face auth is started, then
399 * becomes false when device transitions to explicit flow after a first error, when the
400 * fingerprint sensor is started.
401 *
402 * False when the dialog opens in explicit flow (fingerprint and face modalities enrolled but
403 * confirmation is required), or if user has only fingerprint enrolled, or only face enrolled.
404 */
405 val faceMode: Flow<Boolean> =
406 combine(modalities, isConfirmationRequired, fingerprintStartMode) {
407 modalities,
408 isConfirmationRequired,
409 fingerprintStartMode ->
410 modalities.hasFaceAndFingerprint &&
411 !isConfirmationRequired &&
412 fingerprintStartMode == FingerprintStartMode.Pending
413 }
414 .distinctUntilChanged()
415
416 val iconViewModel: PromptIconViewModel =
417 PromptIconViewModel(
418 this,
419 displayStateInteractor,
420 promptSelectorInteractor,
421 udfpsOverlayInteractor
422 )
423
424 private val _isIconViewLoaded = MutableStateFlow(false)
425
426 /**
427 * For prompts with an iconView, false until the prompt's iconView animation has been loaded in
428 * the view, otherwise true by default. Used for BiometricViewSizeBinder to wait for the icon
429 * asset to be loaded before determining the prompt size.
430 */
431 val isIconViewLoaded: Flow<Boolean> =
432 combine(hideSensorIcon, _isIconViewLoaded.asStateFlow()) { hideSensorIcon, isIconViewLoaded
433 ->
434 hideSensorIcon || isIconViewLoaded
435 }
436 .distinctUntilChanged()
437
438 // Sets whether the prompt's iconView animation has been loaded in the view yet.
439 fun setIsIconViewLoaded(iconViewLoaded: Boolean) {
440 _isIconViewLoaded.value = iconViewLoaded
441 }
442
443 /** The size of the biometric icon */
444 val iconSize: Flow<Pair<Int, Int>> =
445 combine(iconViewModel.activeAuthType, modalities) { activeAuthType, modalities ->
446 if (activeAuthType == PromptIconViewModel.AuthType.Face) {
447 Pair(faceIconWidth, faceIconHeight)
448 } else {
449 if (modalities.hasUdfps) {
450 Pair(fingerprintSensorWidth, fingerprintSensorHeight)
451 } else {
452 Pair(fingerprintIconWidth, fingerprintIconHeight)
453 }
454 }
455 }
456
457 /** Padding for prompt UI elements */
458 val promptPadding: Flow<Rect> =
459 combine(size, displayStateInteractor.currentRotation) { size, rotation ->
460 if (size != PromptSize.LARGE) {
461 val navBarInsets = Utils.getNavbarInsets(context)
462 if (rotation == DisplayRotation.ROTATION_90) {
463 Rect(0, 0, navBarInsets.right, 0)
464 } else if (rotation == DisplayRotation.ROTATION_270) {
465 Rect(navBarInsets.left, 0, 0, 0)
466 } else {
467 Rect(0, 0, 0, navBarInsets.bottom)
468 }
469 } else {
470 Rect(0, 0, 0, 0)
471 }
472 }
473
474 /** Logo for the prompt. */
475 val logo: Flow<Drawable?> =
476 promptSelectorInteractor.prompt
477 .map {
478 when {
479 !(customBiometricPrompt() && constraintBp()) || it == null -> null
480 it.logoBitmap != null -> BitmapDrawable(context.resources, it.logoBitmap)
481 else -> context.getUserBadgedIcon(it, iconProvider, activityTaskManager)
482 }
483 }
484 .distinctUntilChanged()
485
486 /** Logo description for the prompt. */
487 val logoDescription: Flow<String> =
488 promptSelectorInteractor.prompt
489 .map {
490 when {
491 !(customBiometricPrompt() && constraintBp()) || it == null -> ""
492 !it.logoDescription.isNullOrEmpty() -> it.logoDescription
493 else -> context.getUserBadgedLabel(it, activityTaskManager)
494 }
495 }
496 .distinctUntilChanged()
497
498 /** Title for the prompt. */
499 val title: Flow<String> =
500 promptSelectorInteractor.prompt.map { it?.title ?: "" }.distinctUntilChanged()
501
502 /** Subtitle for the prompt. */
503 val subtitle: Flow<String> =
504 promptSelectorInteractor.prompt.map { it?.subtitle ?: "" }.distinctUntilChanged()
505
506 /** Custom content view for the prompt. */
507 val contentView: Flow<PromptContentView?> =
508 promptSelectorInteractor.prompt
509 .map { if (customBiometricPrompt() && constraintBp()) it?.contentView else null }
510 .distinctUntilChanged()
511
512 private val originalDescription =
513 promptSelectorInteractor.prompt.map { it?.description ?: "" }.distinctUntilChanged()
514 /**
515 * Description for the prompt. Description view and contentView is mutually exclusive. Pass
516 * description down only when contentView is null.
517 */
518 val description: Flow<String> =
519 combine(contentView, originalDescription) { contentView, description ->
520 if (contentView == null) description else ""
521 }
522
523 private val hasOnlyOneLineTitle: Flow<Boolean> =
524 combine(title, subtitle, contentView, description) {
525 title,
526 subtitle,
527 contentView,
528 description ->
529 if (subtitle.isNotEmpty() || contentView != null || description.isNotEmpty()) {
530 false
531 } else {
532 val maxWidth =
533 context.resources.getDimensionPixelSize(
534 R.dimen.biometric_prompt_two_pane_udfps_shorter_content_width
535 )
536 val attributes =
537 context.obtainStyledAttributes(
538 R.style.TextAppearance_AuthCredential_Title,
539 intArrayOf(android.R.attr.textSize)
540 )
541 val paint = TextPaint()
542 paint.textSize = attributes.getDimensionPixelSize(0, 0).toFloat()
543 val textWidth = paint.measureText(title)
544 attributes.recycle()
545 textWidth / maxWidth <= 1
546 }
547 }
548
549 /**
550 * Rect for positioning prompt guidelines (left, top, right, unused)
551 *
552 * Negative values are used to signify that guideline measuring should be flipped, measuring
553 * from opposite side of the screen
554 */
555 val guidelineBounds: Flow<Rect> =
556 combine(iconPosition, promptKind, size, position, modalities, hasOnlyOneLineTitle) {
557 _,
558 promptKind,
559 size,
560 position,
561 modalities,
562 hasOnlyOneLineTitle ->
563 var left = 0
564 var top = 0
565 var right = 0
566 when (position) {
567 PromptPosition.Bottom -> {
568 val noSensorLandscape = promptKind.isOnePaneNoSensorLandscapeBiometric()
569 top = if (noSensorLandscape) 0 else mediumTopGuidelinePadding
570 }
571 PromptPosition.Right ->
572 left = getHorizontalPadding(size, modalities, hasOnlyOneLineTitle)
573 PromptPosition.Left ->
574 right = getHorizontalPadding(size, modalities, hasOnlyOneLineTitle)
575 PromptPosition.Top -> {}
576 }
577 Rect(left, top, right, 0)
578 }
579 .distinctUntilChanged()
580
581 private fun getHorizontalPadding(
582 size: PromptSize,
583 modalities: BiometricModalities,
584 hasOnlyOneLineTitle: Boolean
585 ) =
586 if (size.isSmall) {
587 -smallHorizontalGuidelinePadding
588 } else if (modalities.hasUdfps) {
589 if (hasOnlyOneLineTitle) {
590 -udfpsHorizontalShorterGuidelinePadding
591 } else {
592 udfpsHorizontalGuidelinePadding
593 }
594 } else {
595 -mediumHorizontalGuidelinePadding
596 }
597
598 /** If the indicator (help, error) message should be shown. */
599 val isIndicatorMessageVisible: Flow<Boolean> =
600 combine(
601 size,
602 position,
603 message,
604 ) { size, _, message ->
605 size.isMedium && message.message.isNotBlank()
606 }
607
608 /** If the auth is pending confirmation and the confirm button should be shown. */
609 val isConfirmButtonVisible: Flow<Boolean> =
610 combine(
611 size,
612 position,
613 isPendingConfirmation,
614 ) { size, _, isPendingConfirmation ->
615 size.isNotSmall && isPendingConfirmation
616 }
617
618 /** If the icon can be used as a confirmation button. */
619 val isIconConfirmButton: Flow<Boolean> =
620 combine(modalities, size) { modalities, size -> modalities.hasUdfps && size.isNotSmall }
621
622 /** If the negative button should be shown. */
623 val isNegativeButtonVisible: Flow<Boolean> =
624 combine(
625 size,
626 position,
627 isAuthenticated,
628 promptSelectorInteractor.isCredentialAllowed,
629 ) { size, _, authState, credentialAllowed ->
630 size.isNotSmall && authState.isNotAuthenticated && !credentialAllowed
631 }
632
633 /** If the cancel button should be shown (. */
634 val isCancelButtonVisible: Flow<Boolean> =
635 combine(
636 size,
637 position,
638 isAuthenticated,
639 isNegativeButtonVisible,
640 isConfirmButtonVisible,
641 ) { size, _, authState, showNegativeButton, showConfirmButton ->
642 size.isNotSmall && authState.isAuthenticated && !showNegativeButton && showConfirmButton
643 }
644
645 private val _canTryAgainNow = MutableStateFlow(false)
646 /**
647 * If authentication can be manually restarted via the try again button or touching a
648 * fingerprint sensor.
649 */
650 val canTryAgainNow: Flow<Boolean> =
651 combine(
652 _canTryAgainNow,
653 size,
654 position,
655 isAuthenticated,
656 isRetrySupported,
657 ) { readyToTryAgain, size, _, authState, supportsRetry ->
658 readyToTryAgain && size.isNotSmall && supportsRetry && authState.isNotAuthenticated
659 }
660
661 /** If the try again button show be shown (only the button, see [canTryAgainNow]). */
662 val isTryAgainButtonVisible: Flow<Boolean> =
663 combine(
664 canTryAgainNow,
665 modalities,
666 ) { tryAgainIsPossible, modalities ->
667 tryAgainIsPossible && modalities.hasFaceOnly
668 }
669
670 /** If the credential fallback button show be shown. */
671 val isCredentialButtonVisible: Flow<Boolean> =
672 combine(
673 size,
674 position,
675 isAuthenticated,
676 promptSelectorInteractor.isCredentialAllowed,
677 ) { size, _, authState, credentialAllowed ->
678 size.isMedium && authState.isNotAuthenticated && credentialAllowed
679 }
680
681 private val history = PromptHistoryImpl()
682 private var messageJob: Job? = null
683
684 /**
685 * Show a temporary error [message] associated with an optional [failedModality] and play
686 * [hapticFeedback].
687 *
688 * The [messageAfterError] will be shown via [showAuthenticating] when [authenticateAfterError]
689 * is set (or via [showHelp] when not set) after the error is dismissed.
690 *
691 * The error is ignored if the user has already authenticated or if [suppressIf] is true given
692 * the currently showing [PromptMessage] and [PromptHistory].
693 */
694 suspend fun showTemporaryError(
695 message: String,
696 messageAfterError: String,
697 authenticateAfterError: Boolean,
698 suppressIf: (PromptMessage, PromptHistory) -> Boolean = { _, _ -> false },
699 hapticFeedback: Boolean = true,
700 failedModality: BiometricModality = BiometricModality.None,
701 ) = coroutineScope {
702 if (_isAuthenticated.value.isAuthenticated) {
703 if (_isAuthenticated.value.needsUserConfirmation && hapticFeedback) {
704 vibrateOnError()
705 }
706 return@coroutineScope
707 }
708
709 _canTryAgainNow.value = supportsRetry(failedModality)
710
711 val suppress = suppressIf(_message.value, history)
712 history.failure(failedModality)
713 if (suppress) {
714 return@coroutineScope
715 }
716
717 _isAuthenticating.value = false
718 _isAuthenticated.value = PromptAuthState(false)
719 _forceMediumSize.value = true
720 _message.value = PromptMessage.Error(message)
721
722 if (hapticFeedback) {
723 vibrateOnError()
724 }
725
726 messageJob?.cancel()
727 messageJob = launch {
728 delay(BiometricPrompt.HIDE_DIALOG_DELAY.toLong())
729 if (authenticateAfterError) {
730 showAuthenticating(messageAfterError)
731 } else {
732 showHelp(messageAfterError)
733 }
734 }
735 }
736
737 /**
738 * Call to ensure the fingerprint sensor has started. Either when the dialog is first shown
739 * (most cases) or when it should be enabled after a first error (coex implicit flow).
740 */
741 fun ensureFingerprintHasStarted(isDelayed: Boolean) {
742 if (_fingerprintStartMode.value == FingerprintStartMode.Pending) {
743 _fingerprintStartMode.value =
744 if (isDelayed) FingerprintStartMode.Delayed else FingerprintStartMode.Normal
745 }
746 }
747
748 // enable retry only when face fails (fingerprint runs constantly)
749 private fun supportsRetry(failedModality: BiometricModality) =
750 failedModality == BiometricModality.Face
751
752 /**
753 * Show a persistent help message.
754 *
755 * Will be show even if the user has already authenticated.
756 */
757 suspend fun showHelp(message: String) {
758 val alreadyAuthenticated = _isAuthenticated.value.isAuthenticated
759 if (!alreadyAuthenticated) {
760 _isAuthenticating.value = false
761 _isAuthenticated.value = PromptAuthState(false)
762 }
763
764 _message.value =
765 if (message.isNotBlank()) PromptMessage.Help(message) else PromptMessage.Empty
766 _forceMediumSize.value = true
767
768 messageJob?.cancel()
769 messageJob = null
770 }
771
772 /**
773 * Show a temporary help message and transition back to a fixed message.
774 *
775 * Ignored if the user has already authenticated.
776 */
777 suspend fun showTemporaryHelp(
778 message: String,
779 messageAfterHelp: String = "",
780 ) = coroutineScope {
781 if (_isAuthenticated.value.isAuthenticated) {
782 return@coroutineScope
783 }
784
785 _isAuthenticating.value = false
786 _isAuthenticated.value = PromptAuthState(false)
787 _message.value =
788 if (message.isNotBlank()) PromptMessage.Help(message) else PromptMessage.Empty
789 _forceMediumSize.value = true
790
791 messageJob?.cancel()
792 messageJob = launch {
793 delay(BiometricPrompt.HIDE_DIALOG_DELAY.toLong())
794 showAuthenticating(messageAfterHelp)
795 }
796 }
797
798 /** Show the user that biometrics are actively running and set [isAuthenticating]. */
799 fun showAuthenticating(message: String = "", isRetry: Boolean = false) {
800 if (_isAuthenticated.value.isAuthenticated) {
801 // TODO(jbolinger): convert to go/tex-apc?
802 Log.w(TAG, "Cannot show authenticating after authenticated")
803 return
804 }
805
806 _isAuthenticating.value = true
807 _isAuthenticated.value = PromptAuthState(false)
808 _message.value = if (message.isBlank()) PromptMessage.Empty else PromptMessage.Help(message)
809
810 // reset the try again button(s) after the user attempts a retry
811 if (isRetry) {
812 _canTryAgainNow.value = false
813 }
814
815 messageJob?.cancel()
816 messageJob = null
817 }
818
819 /**
820 * Show successfully authentication, set [isAuthenticated], and dismiss the prompt after a
821 * [dismissAfterDelay] or prompt for explicit confirmation (if required).
822 */
823 suspend fun showAuthenticated(
824 modality: BiometricModality,
825 dismissAfterDelay: Long,
826 helpMessage: String = "",
827 ) {
828 if (_isAuthenticated.value.isAuthenticated) {
829 // Treat second authentication with a different modality as confirmation for the first
830 if (
831 _isAuthenticated.value.needsUserConfirmation &&
832 modality != _isAuthenticated.value.authenticatedModality
833 ) {
834 confirmAuthenticated()
835 return
836 }
837 // TODO(jbolinger): convert to go/tex-apc?
838 Log.w(TAG, "Cannot show authenticated after authenticated")
839 return
840 }
841
842 _isAuthenticating.value = false
843 val needsUserConfirmation = needsExplicitConfirmation(modality)
844 _isAuthenticated.value =
845 PromptAuthState(true, modality, needsUserConfirmation, dismissAfterDelay)
846 _message.value = PromptMessage.Empty
847
848 if (!needsUserConfirmation) {
849 vibrateOnSuccess()
850 }
851
852 messageJob?.cancel()
853 messageJob = null
854
855 if (helpMessage.isNotBlank()) {
856 showHelp(helpMessage)
857 }
858 }
859
860 private suspend fun needsExplicitConfirmation(modality: BiometricModality): Boolean {
861 val confirmationRequired = isConfirmationRequired.first()
862
863 // Only worry about confirmationRequired if face was used to unlock
864 if (modality == BiometricModality.Face) {
865 return confirmationRequired
866 }
867 // fingerprint only never requires confirmation
868 return false
869 }
870
871 /**
872 * Set the prompt's auth state to authenticated and confirmed.
873 *
874 * This should only be used after [showAuthenticated] when the operation requires explicit user
875 * confirmation.
876 */
877 fun confirmAuthenticated() {
878 val authState = _isAuthenticated.value
879 if (authState.isNotAuthenticated) {
880 Log.w(TAG, "Cannot confirm authenticated when not authenticated")
881 return
882 }
883
884 _isAuthenticated.value = authState.asExplicitlyConfirmed()
885 _message.value = PromptMessage.Empty
886
887 vibrateOnSuccess()
888
889 messageJob?.cancel()
890 messageJob = null
891 }
892
893 /**
894 * Touch event occurred on the overlay
895 *
896 * Tracks whether a finger is currently down to set [_isOverlayTouched] to be used as user
897 * confirmation
898 */
899 fun onOverlayTouch(event: MotionEvent): Boolean {
900 if (event.actionMasked == MotionEvent.ACTION_DOWN) {
901 _isOverlayTouched.value = true
902
903 if (_isAuthenticated.value.needsUserConfirmation) {
904 confirmAuthenticated()
905 }
906 return true
907 } else if (event.actionMasked == MotionEvent.ACTION_UP) {
908 _isOverlayTouched.value = false
909 }
910 return false
911 }
912
913 /** Sets the message used for UDFPS directional guidance */
914 suspend fun onAnnounceAccessibilityHint(
915 event: MotionEvent,
916 touchExplorationEnabled: Boolean,
917 ): Boolean {
918 if (bpTalkback() && modalities.first().hasUdfps && touchExplorationEnabled) {
919 // TODO(b/315184924): Remove uses of UdfpsUtils
920 val scaledTouch =
921 udfpsUtils.getTouchInNativeCoordinates(
922 event.getPointerId(0),
923 event,
924 udfpsOverlayInteractor.udfpsOverlayParams.value
925 )
926 if (
927 !udfpsUtils.isWithinSensorArea(
928 event.getPointerId(0),
929 event,
930 udfpsOverlayInteractor.udfpsOverlayParams.value
931 )
932 ) {
933 _accessibilityHint.emit(
934 udfpsUtils.onTouchOutsideOfSensorArea(
935 touchExplorationEnabled,
936 context,
937 scaledTouch.x,
938 scaledTouch.y,
939 udfpsOverlayInteractor.udfpsOverlayParams.value
940 )
941 )
942 }
943 }
944 return false
945 }
946
947 /**
948 * Switch to the credential view.
949 *
950 * TODO(b/251476085): this should be decoupled from the shared panel controller
951 */
952 fun onSwitchToCredential() {
953 _forceLargeSize.value = true
954 promptSelectorInteractor.onSwitchToCredential()
955 }
956
957 private fun vibrateOnSuccess() {
958 _hapticsToPlay.value =
959 HapticsToPlay(
960 HapticFeedbackConstants.CONFIRM,
961 HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING,
962 )
963 }
964
965 private fun vibrateOnError() {
966 _hapticsToPlay.value =
967 HapticsToPlay(
968 HapticFeedbackConstants.REJECT,
969 HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING,
970 )
971 }
972
973 /** Clears the [hapticsToPlay] variable by setting its constant to the NO_HAPTICS default. */
974 fun clearHaptics() {
975 _hapticsToPlay.update { previous ->
976 HapticsToPlay(HapticFeedbackConstants.NO_HAPTICS, previous.flag)
977 }
978 }
979
980 companion object {
981 const val TAG = "PromptViewModel"
982 }
983 }
984
Contextnull985 private fun Context.getUserBadgedIcon(
986 prompt: BiometricPromptRequest.Biometric,
987 iconProvider: IconProvider,
988 activityTaskManager: ActivityTaskManager
989 ): Drawable? {
990 var icon: Drawable? = null
991 val componentName = prompt.getComponentNameForLogo(activityTaskManager)
992 if (componentName != null && shouldShowLogoWithOverrides(componentName)) {
993 val activityInfo = getActivityInfo(componentName)
994 icon = if (activityInfo == null) null else iconProvider.getIcon(activityInfo)
995 }
996 if (icon == null) {
997 val appInfo = prompt.getApplicationInfoForLogo(this, componentName)
998 if (appInfo == null) {
999 Log.w(PromptViewModel.TAG, "Cannot find app logo for package $opPackageName")
1000 return null
1001 } else {
1002 icon = packageManager.getApplicationIcon(appInfo)
1003 }
1004 }
1005 return packageManager.getUserBadgedIcon(icon, UserHandle.of(prompt.userInfo.userId))
1006 }
1007
Contextnull1008 private fun Context.getUserBadgedLabel(
1009 prompt: BiometricPromptRequest.Biometric,
1010 activityTaskManager: ActivityTaskManager
1011 ): String {
1012 val componentName = prompt.getComponentNameForLogo(activityTaskManager)
1013 val appInfo = prompt.getApplicationInfoForLogo(this, componentName)
1014 return if (appInfo == null || packageManager.getApplicationLabel(appInfo).isNullOrEmpty()) {
1015 Log.w(PromptViewModel.TAG, "Cannot find app logo for package $opPackageName")
1016 ""
1017 } else {
1018 packageManager
1019 .getUserBadgedLabel(packageManager.getApplicationLabel(appInfo), UserHandle.of(userId))
1020 .toString()
1021 }
1022 }
1023
BiometricPromptRequestnull1024 private fun BiometricPromptRequest.Biometric.getComponentNameForLogo(
1025 activityTaskManager: ActivityTaskManager
1026 ): ComponentName? {
1027 val topActivity: ComponentName? = activityTaskManager.getTasks(1).firstOrNull()?.topActivity
1028 return when {
1029 componentNameForConfirmDeviceCredentialActivity != null ->
1030 componentNameForConfirmDeviceCredentialActivity
1031 topActivity?.packageName.contentEquals(opPackageName) -> topActivity
1032 else -> {
1033 Log.w(PromptViewModel.TAG, "Top activity $topActivity is not the client $opPackageName")
1034 null
1035 }
1036 }
1037 }
1038
getApplicationInfoForLogonull1039 private fun BiometricPromptRequest.Biometric.getApplicationInfoForLogo(
1040 context: Context,
1041 componentNameForLogo: ComponentName?
1042 ): ApplicationInfo? {
1043 val packageName =
1044 when {
1045 componentNameForLogo != null -> componentNameForLogo.packageName
1046 // TODO(b/339532378): We should check whether |allowBackgroundAuthentication| should be
1047 // removed.
1048 // This is being consistent with the check in [AuthController.showDialog()].
1049 allowBackgroundAuthentication || isSystem(context, opPackageName) -> opPackageName
1050 else -> null
1051 }
1052 return if (packageName == null) {
1053 Log.w(PromptViewModel.TAG, "Cannot find application info for $opPackageName")
1054 null
1055 } else {
1056 context.getApplicationInfo(packageName)
1057 }
1058 }
1059
Contextnull1060 private fun Context.shouldShowLogoWithOverrides(componentName: ComponentName): Boolean {
1061 return resources
1062 .getStringArray(R.array.biometric_dialog_package_names_for_logo_with_overrides)
1063 .find { componentName.packageName.contentEquals(it) } != null
1064 }
1065
Contextnull1066 private fun Context.getActivityInfo(componentName: ComponentName): ActivityInfo? =
1067 try {
1068 packageManager.getActivityInfo(componentName, 0)
1069 } catch (e: PackageManager.NameNotFoundException) {
1070 Log.w(PromptViewModel.TAG, "Cannot find activity info for $opPackageName", e)
1071 null
1072 }
1073
Contextnull1074 private fun Context.getApplicationInfo(packageName: String): ApplicationInfo? =
1075 try {
1076 packageManager.getApplicationInfo(
1077 packageName,
1078 PackageManager.MATCH_DISABLED_COMPONENTS or PackageManager.MATCH_ANY_USER
1079 )
1080 } catch (e: PackageManager.NameNotFoundException) {
1081 Log.w(PromptViewModel.TAG, "Cannot find application info for $opPackageName", e)
1082 null
1083 }
1084
1085 /** How the fingerprint sensor was started for the prompt. */
1086 enum class FingerprintStartMode {
1087 /** Fingerprint sensor has not started. */
1088 Pending,
1089
1090 /** Fingerprint sensor started immediately when prompt was displayed. */
1091 Normal,
1092
1093 /** Fingerprint sensor started after the first failure of another passive modality. */
1094 Delayed;
1095
1096 /** If this is [Normal] or [Delayed]. */
1097 val isStarted: Boolean
1098 get() = this == Normal || this == Delayed
1099 }
1100
1101 /**
1102 * The state of haptic feedback to play. It is composed by a [HapticFeedbackConstants] and a
1103 * [HapticFeedbackConstants] flag.
1104 */
1105 data class HapticsToPlay(val hapticFeedbackConstant: Int, val flag: Int?)
1106