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