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.binder
18 
19 import android.animation.Animator
20 import android.annotation.SuppressLint
21 import android.content.Context
22 import android.hardware.biometrics.BiometricAuthenticator
23 import android.hardware.biometrics.BiometricConstants
24 import android.hardware.biometrics.BiometricPrompt
25 import android.hardware.biometrics.Flags
26 import android.hardware.face.FaceManager
27 import android.text.method.ScrollingMovementMethod
28 import android.util.Log
29 import android.view.HapticFeedbackConstants
30 import android.view.MotionEvent
31 import android.view.View
32 import android.view.View.IMPORTANT_FOR_ACCESSIBILITY_NO
33 import android.view.accessibility.AccessibilityManager
34 import android.widget.Button
35 import android.widget.ImageView
36 import android.widget.LinearLayout
37 import android.widget.TextView
38 import androidx.lifecycle.DefaultLifecycleObserver
39 import androidx.lifecycle.Lifecycle
40 import androidx.lifecycle.LifecycleOwner
41 import androidx.lifecycle.lifecycleScope
42 import androidx.lifecycle.repeatOnLifecycle
43 import com.airbnb.lottie.LottieAnimationView
44 import com.airbnb.lottie.LottieCompositionFactory
45 import com.android.systemui.Flags.constraintBp
46 import com.android.systemui.biometrics.AuthPanelController
47 import com.android.systemui.biometrics.Utils.ellipsize
48 import com.android.systemui.biometrics.shared.model.BiometricModalities
49 import com.android.systemui.biometrics.shared.model.BiometricModality
50 import com.android.systemui.biometrics.shared.model.PromptKind
51 import com.android.systemui.biometrics.shared.model.asBiometricModality
52 import com.android.systemui.biometrics.ui.BiometricPromptLayout
53 import com.android.systemui.biometrics.ui.viewmodel.FingerprintStartMode
54 import com.android.systemui.biometrics.ui.viewmodel.PromptMessage
55 import com.android.systemui.biometrics.ui.viewmodel.PromptSize
56 import com.android.systemui.biometrics.ui.viewmodel.PromptViewModel
57 import com.android.systemui.lifecycle.repeatWhenAttached
58 import com.android.systemui.res.R
59 import com.android.systemui.statusbar.VibratorHelper
60 import kotlinx.coroutines.CoroutineScope
61 import kotlinx.coroutines.delay
62 import kotlinx.coroutines.flow.combine
63 import kotlinx.coroutines.flow.first
64 import kotlinx.coroutines.flow.map
65 import kotlinx.coroutines.launch
66 
67 private const val TAG = "BiometricViewBinder"
68 
69 /** Top-most view binder for BiometricPrompt views. */
70 object BiometricViewBinder {
71     const val MAX_LOGO_DESCRIPTION_CHARACTER_NUMBER = 30
72 
73     /** Binds a [BiometricPromptLayout] to a [PromptViewModel]. */
74     @SuppressLint("ClickableViewAccessibility")
75     @JvmStatic
76     fun bind(
77         view: View,
78         viewModel: PromptViewModel,
79         panelViewController: AuthPanelController?,
80         jankListener: BiometricJankListener,
81         backgroundView: View,
82         legacyCallback: Spaghetti.Callback,
83         applicationScope: CoroutineScope,
84         vibratorHelper: VibratorHelper,
85     ): Spaghetti {
86         /**
87          * View is only set visible in BiometricViewSizeBinder once PromptSize is determined that
88          * accounts for iconView size, to prevent prompt resizing being visible to the user.
89          *
90          * TODO(b/288175072): May be able to remove this once constraint layout is implemented
91          */
92         if (!constraintBp()) {
93             view.visibility = View.INVISIBLE
94         }
95         val accessibilityManager = view.context.getSystemService(AccessibilityManager::class.java)!!
96 
97         val textColorError =
98             view.resources.getColor(R.color.biometric_dialog_error, view.context.theme)
99         val textColorHint =
100             view.resources.getColor(R.color.biometric_dialog_gray, view.context.theme)
101 
102         val logoView = view.requireViewById<ImageView>(R.id.logo)
103         val logoDescriptionView = view.requireViewById<TextView>(R.id.logo_description)
104         val titleView = view.requireViewById<TextView>(R.id.title)
105         val subtitleView = view.requireViewById<TextView>(R.id.subtitle)
106         val descriptionView = view.requireViewById<TextView>(R.id.description)
107         val customizedViewContainer =
108             view.requireViewById<LinearLayout>(R.id.customized_view_container)
109         val udfpsGuidanceView =
110             if (constraintBp()) {
111                 view.requireViewById<View>(R.id.panel)
112             } else {
113                 backgroundView
114             }
115 
116         // set selected to enable marquee unless a screen reader is enabled
117         titleView.isSelected =
118             !accessibilityManager.isEnabled || !accessibilityManager.isTouchExplorationEnabled
119         subtitleView.isSelected =
120             !accessibilityManager.isEnabled || !accessibilityManager.isTouchExplorationEnabled
121         descriptionView.movementMethod = ScrollingMovementMethod()
122 
123         val iconOverlayView = view.requireViewById<LottieAnimationView>(R.id.biometric_icon_overlay)
124         val iconView = view.requireViewById<LottieAnimationView>(R.id.biometric_icon)
125 
126         val iconSizeOverride =
127             if (constraintBp()) {
128                 null
129             } else {
130                 (view as BiometricPromptLayout).updatedFingerprintAffordanceSize
131             }
132 
133         val indicatorMessageView = view.requireViewById<TextView>(R.id.indicator)
134 
135         // Negative-side (left) buttons
136         val negativeButton = view.requireViewById<Button>(R.id.button_negative)
137         val cancelButton = view.requireViewById<Button>(R.id.button_cancel)
138         val credentialFallbackButton = view.requireViewById<Button>(R.id.button_use_credential)
139 
140         // Positive-side (right) buttons
141         val confirmationButton = view.requireViewById<Button>(R.id.button_confirm)
142         val retryButton = view.requireViewById<Button>(R.id.button_try_again)
143 
144         // TODO(b/330788871): temporary workaround for the unsafe callbacks & legacy controllers
145         val adapter =
146             Spaghetti(
147                 view = view,
148                 viewModel = viewModel,
149                 applicationContext = view.context.applicationContext,
150                 applicationScope = applicationScope,
151             )
152 
153         // bind to prompt
154         var boundSize = false
155 
156         view.repeatWhenAttached {
157             // these do not change and need to be set before any size transitions
158             val modalities = viewModel.modalities.first()
159 
160             if (modalities.hasFingerprint) {
161                 /**
162                  * Load the given [rawResources] immediately so they are cached for use in the
163                  * [context].
164                  */
165                 val rawResources = viewModel.iconViewModel.getRawAssets(modalities.hasSfps)
166                 for (res in rawResources) {
167                     LottieCompositionFactory.fromRawRes(view.context, res)
168                 }
169             }
170 
171             logoView.setImageDrawable(viewModel.logo.first())
172             // The ellipsize effect on xml happens only when the TextView does not have any free
173             // space on the screen to show the text. So we need to manually truncate.
174             logoDescriptionView.text =
175                 viewModel.logoDescription.first().ellipsize(MAX_LOGO_DESCRIPTION_CHARACTER_NUMBER)
176             titleView.text = viewModel.title.first()
177             subtitleView.text = viewModel.subtitle.first()
178             descriptionView.text = viewModel.description.first()
179 
180             if (Flags.customBiometricPrompt() && constraintBp()) {
181                 BiometricCustomizedViewBinder.bind(
182                     customizedViewContainer,
183                     viewModel.contentView.first(),
184                     legacyCallback
185                 )
186             }
187 
188             // set button listeners
189             negativeButton.setOnClickListener { legacyCallback.onButtonNegative() }
190             cancelButton.setOnClickListener { legacyCallback.onUserCanceled() }
191             credentialFallbackButton.setOnClickListener {
192                 viewModel.onSwitchToCredential()
193                 legacyCallback.onUseDeviceCredential()
194             }
195             confirmationButton.setOnClickListener { viewModel.confirmAuthenticated() }
196             retryButton.setOnClickListener {
197                 viewModel.showAuthenticating(isRetry = true)
198                 legacyCallback.onButtonTryAgain()
199             }
200 
201             adapter.attach(this, modalities, legacyCallback)
202 
203             if (!boundSize) {
204                 boundSize = true
205                 BiometricViewSizeBinder.bind(
206                     view = view,
207                     viewModel = viewModel,
208                     viewsToHideWhenSmall =
209                         listOf(
210                             logoView,
211                             logoDescriptionView,
212                             titleView,
213                             subtitleView,
214                             descriptionView,
215                             customizedViewContainer,
216                         ),
217                     viewsToFadeInOnSizeChange =
218                         listOf(
219                             logoView,
220                             logoDescriptionView,
221                             titleView,
222                             subtitleView,
223                             descriptionView,
224                             customizedViewContainer,
225                             indicatorMessageView,
226                             negativeButton,
227                             cancelButton,
228                             retryButton,
229                             confirmationButton,
230                             credentialFallbackButton,
231                         ),
232                     panelViewController = panelViewController,
233                     jankListener = jankListener,
234                 )
235             }
236 
237             lifecycleScope.launch {
238                 viewModel.hideSensorIcon.collect { showWithoutIcon ->
239                     if (!showWithoutIcon) {
240                         PromptIconViewBinder.bind(
241                             iconView,
242                             iconOverlayView,
243                             iconSizeOverride,
244                             viewModel,
245                         )
246                     }
247                 }
248             }
249 
250             // TODO(b/251476085): migrate legacy icon controllers and remove
251             // The fingerprint sensor is started by the legacy
252             // AuthContainerView#onDialogAnimatedIn in all cases but the implicit coex flow
253             // (delayed mode). In that case, start it on the first transition to delayed
254             // which will be triggered by any auth failure.
255             lifecycleScope.launch {
256                 val oldMode = viewModel.fingerprintStartMode.first()
257                 viewModel.fingerprintStartMode.collect { newMode ->
258                     // trigger sensor to start
259                     if (
260                         oldMode == FingerprintStartMode.Pending &&
261                             newMode == FingerprintStartMode.Delayed
262                     ) {
263                         legacyCallback.onStartDelayedFingerprintSensor()
264                     }
265                 }
266             }
267 
268             repeatOnLifecycle(Lifecycle.State.STARTED) {
269                 // handle background clicks
270                 launch {
271                     combine(viewModel.isAuthenticated, viewModel.size) { (authenticated, _), size ->
272                             when {
273                                 authenticated -> false
274                                 size == PromptSize.SMALL -> false
275                                 size == PromptSize.LARGE -> false
276                                 else -> true
277                             }
278                         }
279                         .collect { dismissOnClick ->
280                             backgroundView.setOnClickListener {
281                                 if (dismissOnClick) {
282                                     legacyCallback.onUserCanceled()
283                                 } else {
284                                     Log.w(TAG, "Ignoring background click")
285                                 }
286                             }
287                         }
288                 }
289 
290                 // set messages
291                 launch {
292                     viewModel.isIndicatorMessageVisible.collect { show ->
293                         indicatorMessageView.visibility = show.asVisibleOrHidden()
294                     }
295                 }
296 
297                 // set padding
298                 launch {
299                     viewModel.promptPadding.collect { promptPadding ->
300                         if (!constraintBp()) {
301                             view.setPadding(
302                                 promptPadding.left,
303                                 promptPadding.top,
304                                 promptPadding.right,
305                                 promptPadding.bottom
306                             )
307                         }
308                     }
309                 }
310 
311                 // configure & hide/disable buttons
312                 launch {
313                     viewModel.credentialKind
314                         .map { kind ->
315                             when (kind) {
316                                 PromptKind.Pin ->
317                                     view.resources.getString(R.string.biometric_dialog_use_pin)
318                                 PromptKind.Password ->
319                                     view.resources.getString(R.string.biometric_dialog_use_password)
320                                 PromptKind.Pattern ->
321                                     view.resources.getString(R.string.biometric_dialog_use_pattern)
322                                 else -> ""
323                             }
324                         }
325                         .collect { credentialFallbackButton.text = it }
326                 }
327                 launch { viewModel.negativeButtonText.collect { negativeButton.text = it } }
328                 launch {
329                     viewModel.isConfirmButtonVisible.collect { show ->
330                         confirmationButton.visibility = show.asVisibleOrGone()
331                     }
332                 }
333                 launch {
334                     viewModel.isCancelButtonVisible.collect { show ->
335                         cancelButton.visibility = show.asVisibleOrGone()
336                     }
337                 }
338                 launch {
339                     viewModel.isNegativeButtonVisible.collect { show ->
340                         negativeButton.visibility = show.asVisibleOrGone()
341                     }
342                 }
343                 launch {
344                     viewModel.isTryAgainButtonVisible.collect { show ->
345                         retryButton.visibility = show.asVisibleOrGone()
346                     }
347                 }
348                 launch {
349                     viewModel.isCredentialButtonVisible.collect { show ->
350                         credentialFallbackButton.visibility = show.asVisibleOrGone()
351                     }
352                 }
353 
354                 // reuse the icon as a confirm button
355                 launch {
356                     viewModel.isIconConfirmButton
357                         .map { isPending ->
358                             when {
359                                 isPending && modalities.hasFaceAndFingerprint ->
360                                     View.OnTouchListener { _: View, event: MotionEvent ->
361                                         viewModel.onOverlayTouch(event)
362                                     }
363                                 else -> null
364                             }
365                         }
366                         .collect { onTouch ->
367                             iconOverlayView.setOnTouchListener(onTouch)
368                             iconView.setOnTouchListener(onTouch)
369                         }
370                 }
371 
372                 // dismiss prompt when authenticated and confirmed
373                 launch {
374                     viewModel.isAuthenticated.collect { authState ->
375                         // Disable background view for cancelling authentication once authenticated,
376                         // and remove from talkback
377                         if (authState.isAuthenticated) {
378                             // Prevents Talkback from speaking subtitle after already authenticated
379                             subtitleView.importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO
380                             backgroundView.setOnClickListener(null)
381                             backgroundView.importantForAccessibility =
382                                 IMPORTANT_FOR_ACCESSIBILITY_NO
383 
384                             // Allow icon to be used as confirmation button with a11y enabled
385                             if (accessibilityManager.isTouchExplorationEnabled) {
386                                 iconOverlayView.setOnClickListener {
387                                     viewModel.confirmAuthenticated()
388                                 }
389                                 iconView.setOnClickListener { viewModel.confirmAuthenticated() }
390                             }
391                         }
392                         if (authState.isAuthenticatedAndConfirmed) {
393                             view.announceForAccessibility(
394                                 view.resources.getString(R.string.biometric_dialog_authenticated)
395                             )
396 
397                             launch {
398                                 delay(authState.delay)
399                                 if (authState.isAuthenticatedAndExplicitlyConfirmed) {
400                                     legacyCallback.onAuthenticatedAndConfirmed()
401                                 } else {
402                                     legacyCallback.onAuthenticated()
403                                 }
404                             }
405                         }
406                     }
407                 }
408 
409                 // show error & help messages
410                 launch {
411                     viewModel.message.collect { promptMessage ->
412                         val isError = promptMessage is PromptMessage.Error
413                         indicatorMessageView.text = promptMessage.message
414                         indicatorMessageView.setTextColor(
415                             if (isError) textColorError else textColorHint
416                         )
417 
418                         // select to enable marquee unless a screen reader is enabled
419                         // TODO(wenhuiy): this may have recently changed per UX - verify and remove
420                         indicatorMessageView.isSelected =
421                             !accessibilityManager.isEnabled ||
422                                 !accessibilityManager.isTouchExplorationEnabled
423                     }
424                 }
425 
426                 // Talkback directional guidance
427                 udfpsGuidanceView.setOnHoverListener { _, event ->
428                     launch {
429                         viewModel.onAnnounceAccessibilityHint(
430                             event,
431                             accessibilityManager.isTouchExplorationEnabled
432                         )
433                     }
434                     false
435                 }
436                 launch {
437                     viewModel.accessibilityHint.collect { message ->
438                         if (message.isNotBlank()) view.announceForAccessibility(message)
439                     }
440                 }
441 
442                 // Play haptics
443                 launch {
444                     viewModel.hapticsToPlay.collect { haptics ->
445                         if (haptics.hapticFeedbackConstant != HapticFeedbackConstants.NO_HAPTICS) {
446                             if (haptics.flag != null) {
447                                 vibratorHelper.performHapticFeedback(
448                                     view,
449                                     haptics.hapticFeedbackConstant,
450                                     haptics.flag,
451                                 )
452                             } else {
453                                 vibratorHelper.performHapticFeedback(
454                                     view,
455                                     haptics.hapticFeedbackConstant,
456                                 )
457                             }
458                             viewModel.clearHaptics()
459                         }
460                     }
461                 }
462 
463                 // Retry and confirmation when finger on sensor
464                 launch {
465                     combine(viewModel.canTryAgainNow, viewModel.hasFingerOnSensor, ::Pair)
466                         .collect { (canRetry, fingerAcquired) ->
467                             if (canRetry && fingerAcquired) {
468                                 legacyCallback.onButtonTryAgain()
469                             }
470                         }
471                 }
472             }
473         }
474 
475         return adapter
476     }
477 }
478 
479 /**
480  * Adapter for legacy events. Remove once legacy controller can be replaced by flagged code.
481  *
482  * These events can be dispatched when the view is being recreated so they need to be delivered to
483  * the view model (which will be retained) via the application scope.
484  *
485  * Do not reference the [view] for anything other than [asView].
486  */
487 @Deprecated("TODO(b/330788871): remove after replacing AuthContainerView")
488 class Spaghetti(
489     private val view: View,
490     private val viewModel: PromptViewModel,
491     private val applicationContext: Context,
492     private val applicationScope: CoroutineScope,
493 ) {
494 
495     @Deprecated("TODO(b/330788871): remove after replacing AuthContainerView")
496     interface Callback {
onAuthenticatednull497         fun onAuthenticated()
498 
499         fun onUserCanceled()
500 
501         fun onButtonNegative()
502 
503         fun onButtonTryAgain()
504 
505         fun onContentViewMoreOptionsButtonPressed()
506 
507         fun onError()
508 
509         fun onUseDeviceCredential()
510 
511         fun onStartDelayedFingerprintSensor()
512 
513         fun onAuthenticatedAndConfirmed()
514     }
515 
516     @Deprecated("TODO(b/330788871): remove after replacing AuthContainerView")
517     enum class BiometricState {
518         /** Authentication hardware idle. */
519         STATE_IDLE,
520         /** UI animating in, authentication hardware active. */
521         STATE_AUTHENTICATING_ANIMATING_IN,
522         /** UI animated in, authentication hardware active. */
523         STATE_AUTHENTICATING,
524         /** UI animated in, authentication hardware active. */
525         STATE_HELP,
526         /** Hard error, e.g. ERROR_TIMEOUT. Authentication hardware idle. */
527         STATE_ERROR,
528         /** Authenticated, waiting for user confirmation. Authentication hardware idle. */
529         STATE_PENDING_CONFIRMATION,
530         /** Authenticated, dialog animating away soon. */
531         STATE_AUTHENTICATED,
532     }
533 
534     private var lifecycleScope: CoroutineScope? = null
535     private var modalities: BiometricModalities = BiometricModalities()
536     private var legacyCallback: Callback? = null
537 
538     // hacky way to suppress lockout errors
539     private val lockoutErrorStrings =
540         listOf(
541                 BiometricConstants.BIOMETRIC_ERROR_LOCKOUT,
542                 BiometricConstants.BIOMETRIC_ERROR_LOCKOUT_PERMANENT,
543             )
<lambda>null544             .map { FaceManager.getErrorString(applicationContext, it, 0 /* vendorCode */) }
545 
attachnull546     fun attach(
547         lifecycleOwner: LifecycleOwner,
548         activeModalities: BiometricModalities,
549         callback: Callback,
550     ) {
551         modalities = activeModalities
552         legacyCallback = callback
553 
554         lifecycleOwner.lifecycle.addObserver(
555             object : DefaultLifecycleObserver {
556                 override fun onCreate(owner: LifecycleOwner) {
557                     lifecycleScope = owner.lifecycleScope
558                 }
559 
560                 override fun onDestroy(owner: LifecycleOwner) {
561                     lifecycleScope = null
562                 }
563             }
564         )
565     }
566 
onDialogAnimatedInnull567     fun onDialogAnimatedIn(fingerprintWasStarted: Boolean) {
568         if (fingerprintWasStarted) {
569             viewModel.ensureFingerprintHasStarted(isDelayed = false)
570             viewModel.showAuthenticating(modalities.asDefaultHelpMessage(applicationContext))
571         } else {
572             viewModel.showAuthenticating()
573         }
574     }
575 
onAuthenticationSucceedednull576     fun onAuthenticationSucceeded(@BiometricAuthenticator.Modality modality: Int) {
577         applicationScope.launch {
578             val authenticatedModality = modality.asBiometricModality()
579             val msgId = getHelpForSuccessfulAuthentication(authenticatedModality)
580             viewModel.showAuthenticated(
581                 modality = authenticatedModality,
582                 dismissAfterDelay = 500,
583                 helpMessage = if (msgId != null) applicationContext.getString(msgId) else ""
584             )
585         }
586     }
587 
getHelpForSuccessfulAuthenticationnull588     private fun getHelpForSuccessfulAuthentication(
589         authenticatedModality: BiometricModality,
590     ): Int? {
591         // for coex, show a message when face succeeds after fingerprint has also started
592         if (authenticatedModality != BiometricModality.Face) {
593             return null
594         }
595 
596         if (modalities.hasUdfps) {
597             return R.string.biometric_dialog_tap_confirm_with_face_alt_1
598         }
599         if (modalities.hasSfps) {
600             return R.string.biometric_dialog_tap_confirm_with_face_sfps
601         }
602         return null
603     }
604 
onAuthenticationFailednull605     fun onAuthenticationFailed(
606         @BiometricAuthenticator.Modality modality: Int,
607         failureReason: String,
608     ) {
609         val failedModality = modality.asBiometricModality()
610         viewModel.ensureFingerprintHasStarted(isDelayed = true)
611 
612         applicationScope.launch {
613             viewModel.showTemporaryError(
614                 failureReason,
615                 messageAfterError = modalities.asDefaultHelpMessage(applicationContext),
616                 authenticateAfterError = modalities.hasFingerprint,
617                 suppressIf = { currentMessage, history ->
618                     modalities.hasFaceAndFingerprint &&
619                         failedModality == BiometricModality.Face &&
620                         (currentMessage.isError || history.faceFailed)
621                 },
622                 failedModality = failedModality,
623             )
624         }
625     }
626 
onErrornull627     fun onError(modality: Int, error: String) {
628         val errorModality = modality.asBiometricModality()
629         if (ignoreUnsuccessfulEventsFrom(errorModality, error)) {
630             return
631         }
632 
633         applicationScope.launch {
634             viewModel.showTemporaryError(
635                 error,
636                 messageAfterError = modalities.asDefaultHelpMessage(applicationContext),
637                 authenticateAfterError = modalities.hasFingerprint,
638             )
639             delay(BiometricPrompt.HIDE_DIALOG_DELAY.toLong())
640             legacyCallback?.onError()
641         }
642     }
643 
onHelpnull644     fun onHelp(modality: Int, help: String) {
645         if (ignoreUnsuccessfulEventsFrom(modality.asBiometricModality(), "")) {
646             return
647         }
648 
649         applicationScope.launch {
650             // help messages from the HAL should be displayed as temporary (i.e. soft) errors
651             viewModel.showTemporaryError(
652                 help,
653                 messageAfterError = modalities.asDefaultHelpMessage(applicationContext),
654                 authenticateAfterError = modalities.hasFingerprint,
655                 hapticFeedback = false,
656             )
657         }
658     }
659 
ignoreUnsuccessfulEventsFromnull660     private fun ignoreUnsuccessfulEventsFrom(modality: BiometricModality, message: String) =
661         when {
662             modalities.hasFaceAndFingerprint ->
663                 (modality == BiometricModality.Face) &&
664                     !(modalities.isFaceStrong && lockoutErrorStrings.contains(message))
665             else -> false
666         }
667 
startTransitionToCredentialUInull668     fun startTransitionToCredentialUI(isError: Boolean) {
669         applicationScope.launch {
670             viewModel.onSwitchToCredential()
671             legacyCallback?.onUseDeviceCredential()
672         }
673     }
674 
cancelAnimationnull675     fun cancelAnimation() {
676         view.animate()?.cancel()
677     }
678 
isCoexnull679     fun isCoex() = modalities.hasFaceAndFingerprint
680 
681     fun isFaceOnly() = modalities.hasFaceOnly
682 
683     fun asView() = view
684 }
685 
686 private fun BiometricModalities.asDefaultHelpMessage(context: Context): String =
687     when {
688         hasFingerprint -> context.getString(R.string.fingerprint_dialog_touch_sensor)
689         else -> ""
690     }
691 
Booleannull692 private fun Boolean.asVisibleOrGone(): Int = if (this) View.VISIBLE else View.GONE
693 
694 private fun Boolean.asVisibleOrHidden(): Int = if (this) View.VISIBLE else View.INVISIBLE
695 
696 // TODO(b/251476085): proper type?
697 typealias BiometricJankListener = Animator.AnimatorListener
698