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