package com.android.systemui.biometrics.ui.binder import android.view.View import android.view.ViewGroup import android.widget.Button import android.widget.ImageButton import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView import androidx.lifecycle.Lifecycle import androidx.lifecycle.repeatOnLifecycle import com.android.app.animation.Interpolators import com.android.app.tracing.coroutines.launchTraced as launch import com.android.systemui.biometrics.AuthPanelController import com.android.systemui.biometrics.plugins.AuthContextPlugins import com.android.systemui.biometrics.ui.CredentialPasswordView import com.android.systemui.biometrics.ui.CredentialPatternView import com.android.systemui.biometrics.ui.CredentialView import com.android.systemui.biometrics.ui.viewmodel.CredentialViewModel import com.android.systemui.lifecycle.repeatWhenAttached import com.android.systemui.plugins.AuthContextPlugin import com.android.systemui.res.R import kotlinx.coroutines.Job import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.onEach private const val ANIMATE_CREDENTIAL_INITIAL_DURATION_MS = 150 /** * View binder for all credential variants of BiometricPrompt, including [CredentialPatternView] and * [CredentialPasswordView]. * * This binder delegates to sub-binders for each variant, such as the [CredentialPasswordViewBinder] * and [CredentialPatternViewBinder]. */ object CredentialViewBinder { /** Binds a [CredentialPasswordView] or [CredentialPatternView] to a [CredentialViewModel]. */ @JvmStatic fun bind( view: ViewGroup, host: CredentialView.Host, viewModel: CredentialViewModel, panelViewController: AuthPanelController, animatePanel: Boolean, legacyCallback: Spaghetti.Callback, plugins: AuthContextPlugins?, maxErrorDuration: Long = 3_000L, requestFocusForInput: Boolean = true, ) { val titleView: TextView = view.requireViewById(R.id.title) val subtitleView: TextView = view.requireViewById(R.id.subtitle) val descriptionView: TextView = view.requireViewById(R.id.description) val customizedViewContainer: LinearLayout = view.requireViewById(R.id.customized_view_container) val iconView: ImageView? = view.findViewById(R.id.icon) val errorView: TextView = view.requireViewById(R.id.error) val backButton: ImageButton? = view.findViewById(R.id.back_button) val cancelButton: Button? = view.findViewById(R.id.cancel_button) val emergencyButtonView: Button = view.requireViewById(R.id.emergencyCallButton) val fallbackButton: Button? = view.findViewById(R.id.fallback_button) var errorTimer: Job? = null backButton?.setOnClickListener { host.onCredentialAborted() } // bind common elements view.repeatWhenAttached { if (animatePanel) { with(panelViewController) { // Credential view is always full screen. setUseFullScreen(true) updateForContentDimensions( containerWidth, containerHeight, 0, // animateDurationMs ) } } repeatOnLifecycle(Lifecycle.State.STARTED) { if (plugins != null) { launch { plugins.notifyShowingBPCredential(view) } } // show prompt metadata launch { viewModel.header.collect { header -> titleView.text = header.title view.announceForAccessibility(header.title) subtitleView.textOrHide = header.subtitle descriptionView.textOrHide = header.description BiometricCustomizedViewBinder.bind( customizedViewContainer, header.contentView, legacyCallback, ) iconView?.setImageDrawable(header.icon) if (header.showEmergencyCallButton) { emergencyButtonView.visibility = View.VISIBLE emergencyButtonView.setOnClickListener { viewModel.doEmergencyCall(view.context) } } // Only animate this if we're transitioning from a biometric view. if (viewModel.animateContents.value) { view.animateCredentialViewIn() } } } // show transient error messages launch { viewModel.errorMessage .onEach { msg -> errorTimer?.cancel() if (msg.isNotBlank()) { errorTimer = launch { delay(maxErrorDuration) viewModel.resetErrorMessage() } } } .collect { it -> val hasError = !it.isNullOrBlank() errorView.visibility = if (hasError) { View.VISIBLE } else if (cancelButton != null) { View.INVISIBLE } else { View.GONE } errorView.text = if (hasError) it else "" } } // show an extra dialog if the remaining attempts becomes low launch { viewModel.remainingAttempts .filter { it.remaining != null } .collect { info -> host.onCredentialAttemptsRemaining(info.remaining!!, info.message) } } launch { viewModel.isShadeInteracted.collect { isShadeInteracted -> if (isShadeInteracted) { host.onCredentialAborted() } } } launch { viewModel.isBackButtonVisible.collect { visible -> backButton?.visibility = if (visible) View.VISIBLE else View.GONE } } launch { viewModel.showFallbackButton.collect { showFallback -> if (showFallback) { fallbackButton?.visibility = View.VISIBLE } } } launch { viewModel.fallbackOptions.collect { options -> if (options.count() > 1) { fallbackButton?.text = view.context.getString(R.string.biometric_dialog_fallback_button) fallbackButton?.setOnClickListener { viewModel.onSwitchToFallbackScreen() } } else if (options.count() == 1) { // If only one option, use it as the fallback button fallbackButton?.text = options[0].text fallbackButton?.setOnClickListener { legacyCallback.onFallbackOptionPressed(0) } } } } try { awaitCancellation() } catch (_: Throwable) { plugins?.notifyHidingBPCredential() } } } cancelButton?.setOnClickListener { host.onCredentialAborted() } // bind the auth widget when (view) { is CredentialPasswordView -> CredentialPasswordViewBinder.bind(view, host, viewModel, requestFocusForInput) is CredentialPatternView -> CredentialPatternViewBinder.bind(view, host, viewModel) else -> throw IllegalStateException("unexpected view type: ${view.javaClass.name}") } } } private fun View.animateCredentialViewIn() { translationY = resources.getDimension(R.dimen.biometric_dialog_credential_translation_offset) alpha = 0f postOnAnimation { animate() .translationY(0f) .setDuration(ANIMATE_CREDENTIAL_INITIAL_DURATION_MS.toLong()) .alpha(1f) .setInterpolator(Interpolators.LINEAR_OUT_SLOW_IN) .withLayer() .start() } } private var TextView.textOrHide: String? set(value) { val gone = value.isNullOrBlank() visibility = if (gone) View.GONE else View.VISIBLE text = if (gone) "" else value } get() = text?.toString() private suspend fun AuthContextPlugins.notifyShowingBPCredential(view: View) = use { plugin -> plugin.onShowingSensitiveSurface( AuthContextPlugin.SensitiveSurface.BiometricPrompt(view = view, isCredential = true) ) } private fun AuthContextPlugins.notifyHidingBPCredential() = useInBackground { plugin -> plugin.onHidingSensitiveSurface( AuthContextPlugin.SensitiveSurface.BiometricPrompt(isCredential = true) ) }