1 package com.android.systemui.biometrics.ui.binder
2 
3 import android.view.View
4 import android.view.ViewGroup
5 import android.widget.ImageView
6 import android.widget.TextView
7 import androidx.lifecycle.Lifecycle
8 import androidx.lifecycle.repeatOnLifecycle
9 import com.android.app.animation.Interpolators
10 import com.android.systemui.R
11 import com.android.systemui.biometrics.AuthDialog
12 import com.android.systemui.biometrics.AuthPanelController
13 import com.android.systemui.biometrics.ui.CredentialPasswordView
14 import com.android.systemui.biometrics.ui.CredentialPatternView
15 import com.android.systemui.biometrics.ui.CredentialView
16 import com.android.systemui.biometrics.ui.viewmodel.CredentialViewModel
17 import com.android.systemui.lifecycle.repeatWhenAttached
18 import kotlinx.coroutines.Job
19 import kotlinx.coroutines.delay
20 import kotlinx.coroutines.flow.filter
21 import kotlinx.coroutines.flow.onEach
22 import kotlinx.coroutines.launch
23 
24 /**
25  * View binder for all credential variants of BiometricPrompt, including [CredentialPatternView] and
26  * [CredentialPasswordView].
27  *
28  * This binder delegates to sub-binders for each variant, such as the [CredentialPasswordViewBinder]
29  * and [CredentialPatternViewBinder].
30  */
31 object CredentialViewBinder {
32 
33     /** Binds a [CredentialPasswordView] or [CredentialPatternView] to a [CredentialViewModel]. */
34     @JvmStatic
35     fun bind(
36         view: ViewGroup,
37         host: CredentialView.Host,
38         viewModel: CredentialViewModel,
39         panelViewController: AuthPanelController,
40         animatePanel: Boolean,
41         maxErrorDuration: Long = 3_000L,
42         requestFocusForInput: Boolean = true,
43     ) {
44         val titleView: TextView = view.requireViewById(R.id.title)
45         val subtitleView: TextView = view.requireViewById(R.id.subtitle)
46         val descriptionView: TextView = view.requireViewById(R.id.description)
47         val iconView: ImageView? = view.findViewById(R.id.icon)
48         val errorView: TextView = view.requireViewById(R.id.error)
49 
50         var errorTimer: Job? = null
51 
52         // bind common elements
53         view.repeatWhenAttached {
54             if (animatePanel) {
55                 with(panelViewController) {
56                     // Credential view is always full screen.
57                     setUseFullScreen(true)
58                     updateForContentDimensions(
59                         containerWidth,
60                         containerHeight,
61                         0 /* animateDurationMs */
62                     )
63                 }
64             }
65 
66             repeatOnLifecycle(Lifecycle.State.STARTED) {
67                 // show prompt metadata
68                 launch {
69                     viewModel.header.collect { header ->
70                         titleView.text = header.title
71                         view.announceForAccessibility(header.title)
72 
73                         subtitleView.textOrHide = header.subtitle
74                         descriptionView.textOrHide = header.description
75 
76                         iconView?.setImageDrawable(header.icon)
77 
78                         // Only animate this if we're transitioning from a biometric view.
79                         if (viewModel.animateContents.value) {
80                             view.animateCredentialViewIn()
81                         }
82                     }
83                 }
84 
85                 // show transient error messages
86                 launch {
87                     viewModel.errorMessage
88                         .onEach { msg ->
89                             errorTimer?.cancel()
90                             if (msg.isNotBlank()) {
91                                 errorTimer = launch {
92                                     delay(maxErrorDuration)
93                                     viewModel.resetErrorMessage()
94                                 }
95                             }
96                         }
97                         .collect { errorView.textOrHide = it }
98                 }
99 
100                 // show an extra dialog if the remaining attempts becomes low
101                 launch {
102                     viewModel.remainingAttempts
103                         .filter { it.remaining != null }
104                         .collect { info ->
105                             host.onCredentialAttemptsRemaining(info.remaining!!, info.message)
106                         }
107                 }
108             }
109         }
110 
111         // bind the auth widget
112         when (view) {
113             is CredentialPasswordView ->
114                 CredentialPasswordViewBinder.bind(view, host, viewModel, requestFocusForInput)
115             is CredentialPatternView -> CredentialPatternViewBinder.bind(view, host, viewModel)
116             else -> throw IllegalStateException("unexpected view type: ${view.javaClass.name}")
117         }
118     }
119 }
120 
121 private fun View.animateCredentialViewIn() {
122     translationY = resources.getDimension(R.dimen.biometric_dialog_credential_translation_offset)
123     alpha = 0f
124     postOnAnimation {
125         animate()
126             .translationY(0f)
127             .setDuration(AuthDialog.ANIMATE_CREDENTIAL_INITIAL_DURATION_MS.toLong())
128             .alpha(1f)
129             .setInterpolator(Interpolators.LINEAR_OUT_SLOW_IN)
130             .withLayer()
131             .start()
132     }
133 }
134 
135 private var TextView.textOrHide: String?
136     set(value) {
137         val gone = value.isNullOrBlank()
138         visibility = if (gone) View.GONE else View.VISIBLE
139         text = if (gone) "" else value
140     }
141     get() = text?.toString()
142