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