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