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