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