Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 1d90cca3 authored by Austin Delgado's avatar Austin Delgado Committed by Android (Google) Code Review
Browse files

Merge "Improve BP button state" into main

parents 8f09b042 f1dd51ec
Loading
Loading
Loading
Loading
+50 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.systemui.biometrics.shared.model

/** The state of the positive (right) button in the biometric prompt. */
sealed class PositiveButtonState {
    // Do not show button
    data object Gone : PositiveButtonState()

    // Confirmation button after auth
    data object Confirm : PositiveButtonState()

    // Try again button after failure
    data object TryAgain : PositiveButtonState()
}

/** The state of the negative (left) button in the biometric prompt. */
sealed class NegativeButtonState(open val text: String?) {
    // Do not show button
    data object Gone : NegativeButtonState(null)

    // Cancel, default or after auth
    data class Cancel(override val text: String) : NegativeButtonState(text)

    // App provided setNegativeButton
    data class SetNegative(override val text: String) : NegativeButtonState(text)

    // Single fallback option
    data class SingleFallback(override val text: String) : NegativeButtonState(text)

    // Use credential button
    data class UseCredential(override val text: String) : NegativeButtonState(text)

    // Fallback option page button
    data class FallbackOptions(override val text: String) : NegativeButtonState(text)
}
+119 −48
Original line number Diff line number Diff line
@@ -50,6 +50,8 @@ import com.android.compose.theme.PlatformTheme
import com.android.systemui.biometrics.Utils.ellipsize
import com.android.systemui.biometrics.shared.model.BiometricModalities
import com.android.systemui.biometrics.shared.model.BiometricModality
import com.android.systemui.biometrics.shared.model.NegativeButtonState
import com.android.systemui.biometrics.shared.model.PositiveButtonState
import com.android.systemui.biometrics.shared.model.PromptKind
import com.android.systemui.biometrics.shared.model.asBiometricModality
import com.android.systemui.biometrics.ui.view.BiometricPromptFallbackView
@@ -319,6 +321,64 @@ object BiometricViewBinder {
                    }
                }

                if (Flags.bpFallbackOptions()) {
                    launch {
                        viewModel.positiveButtonState.collect { state ->
                            when (state) {
                                is PositiveButtonState.Confirm -> {
                                    confirmationButton.visibility = View.VISIBLE
                                }
                                is PositiveButtonState.TryAgain -> {
                                    retryButton.visibility = View.VISIBLE
                                }
                                is PositiveButtonState.Gone -> {
                                    confirmationButton.visibility = View.GONE
                                    retryButton.visibility = View.GONE
                                }
                            }
                        }
                    }
                    launch {
                        viewModel.negativeButtonState.collect { state ->
                            when (state) {
                                is NegativeButtonState.Cancel -> {
                                    cancelButton.text = state.text
                                    cancelButton.visibility = View.VISIBLE
                                }
                                is NegativeButtonState.SetNegative -> {
                                    negativeButton.text = state.text
                                    negativeButton.visibility = View.VISIBLE
                                    negativeButton.setOnClickListener {
                                        legacyCallback.onButtonNegative()
                                    }
                                }
                                is NegativeButtonState.SingleFallback -> {
                                    negativeButton.text = state.text
                                    negativeButton.visibility = View.VISIBLE
                                    // If using the negative button to show a fallback, there's only
                                    // one
                                    negativeButton.setOnClickListener {
                                        legacyCallback.onFallbackOptionPressed(0)
                                    }
                                }
                                is NegativeButtonState.UseCredential -> {
                                    credentialFallbackButton.text = state.text
                                    credentialFallbackButton.visibility = View.VISIBLE
                                }
                                is NegativeButtonState.FallbackOptions -> {
                                    fallbackButton.text = state.text
                                    fallbackButton.visibility = View.VISIBLE
                                }
                                is NegativeButtonState.Gone -> {
                                    negativeButton.visibility = View.GONE
                                    cancelButton.visibility = View.GONE
                                    credentialFallbackButton.visibility = View.GONE
                                    fallbackButton.visibility = View.GONE
                                }
                            }
                        }
                    }
                } else {
                    // configure & hide/disable buttons
                    launch {
                        viewModel.credentialKind
@@ -326,10 +386,17 @@ object BiometricViewBinder {
                                when (kind) {
                                    PromptKind.Pin ->
                                        view.resources.getString(R.string.biometric_dialog_use_pin)

                                    PromptKind.Password ->
                                    view.resources.getString(R.string.biometric_dialog_use_password)
                                        view.resources.getString(
                                            R.string.biometric_dialog_use_password
                                        )

                                    PromptKind.Pattern ->
                                    view.resources.getString(R.string.biometric_dialog_use_pattern)
                                        view.resources.getString(
                                            R.string.biometric_dialog_use_pattern
                                        )

                                    else -> ""
                                }
                            }
@@ -339,11 +406,14 @@ object BiometricViewBinder {
                        viewModel.usingFallbackAsNegative.collect { usingFallbackAsNegative ->
                            if (usingFallbackAsNegative) {
                                negativeButton.setOnClickListener {
                                // If using the negative button to show a fallback, there's only one
                                    // If using the negative button to show a fallback, there's only
                                    // one
                                    legacyCallback.onFallbackOptionPressed(0)
                                }
                            } else {
                            negativeButton.setOnClickListener { legacyCallback.onButtonNegative() }
                                negativeButton.setOnClickListener {
                                    legacyCallback.onButtonNegative()
                                }
                            }
                        }
                    }
@@ -380,6 +450,7 @@ object BiometricViewBinder {
                            }
                        }
                    }
                }

                // reuse the icon as a confirm button
                launch {
+4 −4
Original line number Diff line number Diff line
@@ -101,13 +101,13 @@ constructor(val promptSelectorInteractor: PromptSelectorInteractor) {
     * option. If credential is allowed and identity Check is enabled, this counts as another option
     */
    val optionCount: Flow<Int> =
        combine(credentialAllowed, identityCheckActive, fallbackOptions) {
            credentialAllowed,
        combine(showCredential, identityCheckActive, fallbackOptions) {
            showCredential,
            identityCheckEnabled,
            fallbackOptions ->
            var total = 0
            if (credentialAllowed) total++
            if (identityCheckEnabled && credentialAllowed) total++
            if (showCredential) total++
            if (identityCheckEnabled && showCredential) total++
            total += fallbackOptions.size
            total
        }
+112 −18
Original line number Diff line number Diff line
@@ -50,6 +50,8 @@ import com.android.systemui.biometrics.domain.interactor.UdfpsOverlayInteractor
import com.android.systemui.biometrics.domain.model.BiometricPromptRequest
import com.android.systemui.biometrics.shared.model.BiometricModalities
import com.android.systemui.biometrics.shared.model.BiometricModality
import com.android.systemui.biometrics.shared.model.NegativeButtonState
import com.android.systemui.biometrics.shared.model.PositiveButtonState
import com.android.systemui.biometrics.shared.model.PromptKind
import com.android.systemui.biometrics.shared.model.UdfpsOverlayParams
import com.android.systemui.dagger.qualifiers.Application
@@ -537,6 +539,105 @@ constructor(
            if (contentView == null) description else ""
        }

    private val isIdentityCheckEnabled: Flow<Boolean> =
        promptSelectorInteractor.isIdentityCheckActive

    private val _canTryAgainNow = MutableStateFlow(false)
    /**
     * If authentication can be manually restarted via the try again button or touching a
     * fingerprint sensor.
     */
    val canTryAgainNow: Flow<Boolean> =
        combine(_canTryAgainNow, size, position, isAuthenticated, isRetrySupported) {
            readyToTryAgain,
            size,
            _,
            authState,
            supportsRetry ->
            readyToTryAgain && size.isNotSmall && supportsRetry && authState.isNotAuthenticated
        }

    /** State of the positive (right) button */
    val positiveButtonState: Flow<PositiveButtonState> =
        combine(size, isPendingConfirmation, canTryAgainNow, modalities) {
                size,
                isPendingConfirmation,
                canTryAgain,
                modalities ->
                when {
                    canTryAgain && modalities.hasFaceOnly -> PositiveButtonState.TryAgain

                    size.isNotSmall && isPendingConfirmation -> PositiveButtonState.Confirm

                    else -> PositiveButtonState.Gone
                }
            }
            .distinctUntilChanged()

    /** State of the negative (left) button */
    val negativeButtonState: Flow<NegativeButtonState> =
        combine(
                size,
                isAuthenticated,
                promptSelectorInteractor.isCredentialAllowed,
                isIdentityCheckEnabled,
                promptSelectorInteractor.prompt,
                credentialKind,
                positiveButtonState,
            ) {
                size,
                authState,
                isCredentialAllowed,
                isIdentityCheck,
                prompt,
                credential,
                positiveState ->
                val fallbackOptionsCount = prompt?.fallbackOptions?.size ?: 0
                val hasMultipleFallbackOptions =
                    (if (isCredentialAllowed && isIdentityCheck) 2
                    else (if (isCredentialAllowed) 1 else 0)) + fallbackOptionsCount >= 2

                if (size.isSmall) {
                    NegativeButtonState.Gone
                } else if (authState.isAuthenticated) {
                    // Hide negative button if authed and confirmation not needed
                    if (positiveState == PositiveButtonState.Confirm) {
                        NegativeButtonState.Cancel(context.getString(android.R.string.cancel))
                    } else {
                        NegativeButtonState.Gone
                    }
                } else {
                    when {
                        // If the app provides one, setNegativeButton takes priority
                        prompt?.negativeButtonText != null &&
                            prompt.negativeButtonText.isNotBlank() -> {
                            NegativeButtonState.SetNegative(prompt.negativeButtonText)
                        }

                        hasMultipleFallbackOptions ->
                            NegativeButtonState.FallbackOptions(
                                context.getString(R.string.biometric_dialog_fallback_button)
                            )

                        isCredentialAllowed -> {
                            NegativeButtonState.UseCredential(
                                context.getCredentialString(credential)
                            )
                        }

                        (prompt?.fallbackOptions?.size ?: 0) == 1 -> {
                            NegativeButtonState.SingleFallback(
                                prompt!!.fallbackOptions[0].text.toString()
                            )
                        }

                        else ->
                            NegativeButtonState.Cancel(context.getString(android.R.string.cancel))
                    }
                }
            }
            .distinctUntilChanged()

    private val hasOnlyOneLineTitle: Flow<Boolean> =
        combine(title, subtitle, contentView, description) {
            title,
@@ -628,9 +729,6 @@ constructor(
    val isIconConfirmButton: Flow<Boolean> =
        combine(modalities, size) { modalities, size -> modalities.hasUdfps && size.isNotSmall }

    private val isIdentityCheckEnabled: Flow<Boolean> =
        promptSelectorInteractor.isIdentityCheckActive

    val isFallbackButtonVisible: Flow<Boolean> =
        combine(
            size,
@@ -676,21 +774,6 @@ constructor(
            size.isNotSmall && authState.isAuthenticated && !showNegativeButton && showConfirmButton
        }

    private val _canTryAgainNow = MutableStateFlow(false)
    /**
     * If authentication can be manually restarted via the try again button or touching a
     * fingerprint sensor.
     */
    val canTryAgainNow: Flow<Boolean> =
        combine(_canTryAgainNow, size, position, isAuthenticated, isRetrySupported) {
            readyToTryAgain,
            size,
            _,
            authState,
            supportsRetry ->
            readyToTryAgain && size.isNotSmall && supportsRetry && authState.isNotAuthenticated
        }

    /** If the try again button show be shown (only the button, see [canTryAgainNow]). */
    val isTryAgainButtonVisible: Flow<Boolean> =
        combine(canTryAgainNow, modalities) { tryAgainIsPossible, modalities ->
@@ -1119,6 +1202,17 @@ private fun Context.getActivityInfo(componentName: ComponentName): ActivityInfo?
        null
    }

fun Context.getCredentialString(kind: PromptKind): String =
    when (kind) {
        PromptKind.Pin -> this.getString(R.string.biometric_dialog_use_pin)

        PromptKind.Password -> this.getString(R.string.biometric_dialog_use_password)

        PromptKind.Pattern -> this.getString(R.string.biometric_dialog_use_pattern)

        else -> ""
    }

/** How the fingerprint sensor was started for the prompt. */
enum class FingerprintStartMode {
    /** Fingerprint sensor has not started. */
+99 −11
Original line number Diff line number Diff line
@@ -28,6 +28,9 @@ import android.graphics.Rect
import android.graphics.drawable.BitmapDrawable
import android.hardware.biometrics.BiometricFingerprintConstants
import android.hardware.biometrics.BiometricPrompt
import android.hardware.biometrics.FallbackOption
import android.hardware.biometrics.Flags.FLAG_BP_FALLBACK_OPTIONS
import android.hardware.biometrics.Flags.bpFallbackOptions
import android.hardware.biometrics.PromptContentItemBulletedText
import android.hardware.biometrics.PromptContentView
import android.hardware.biometrics.PromptContentViewWithMoreOptionsButton
@@ -62,6 +65,8 @@ import com.android.systemui.biometrics.fingerprintSensorPropertiesInternal
import com.android.systemui.biometrics.shared.model.AuthenticationReason
import com.android.systemui.biometrics.shared.model.BiometricModalities
import com.android.systemui.biometrics.shared.model.BiometricModality
import com.android.systemui.biometrics.shared.model.NegativeButtonState
import com.android.systemui.biometrics.shared.model.PositiveButtonState
import com.android.systemui.biometrics.shared.model.UdfpsOverlayParams
import com.android.systemui.biometrics.shared.model.toSensorStrength
import com.android.systemui.biometrics.shared.model.toSensorType
@@ -373,8 +378,12 @@ internal class PromptViewModelTest(private val testCase: TestCase) : SysuiTestCa
            assertThat(authenticating).isTrue()
            assertThat(authenticated?.isNotAuthenticated).isTrue()
            assertThat(size).isEqualTo(expectedPromptSize)
            if (bpFallbackOptions()) {
                assertButtonsVisible(cancel = expectedPromptSize != PromptSize.SMALL)
            } else {
                assertButtonsVisible(negative = expectedPromptSize != PromptSize.SMALL)
            }
        }

    @Test
    fun start_authenticating_show_and_clear_error() = runGenericTest {
@@ -912,7 +921,11 @@ internal class PromptViewModelTest(private val testCase: TestCase) : SysuiTestCa
        assertThat(authenticating).isTrue()
        assertThat(authenticated?.isNotAuthenticated).isTrue()
        assertThat(size).isEqualTo(if (authWithSmallPrompt) PromptSize.SMALL else PromptSize.MEDIUM)
        if (bpFallbackOptions()) {
            assertButtonsVisible(cancel = !authWithSmallPrompt)
        } else {
            assertButtonsVisible(negative = !authWithSmallPrompt)
        }

        kosmos.promptViewModel.showAuthenticated(authenticatedModality, DELAY)

@@ -933,7 +946,13 @@ internal class PromptViewModelTest(private val testCase: TestCase) : SysuiTestCa

    @Test
    fun shows_temporary_errors() = runGenericTest {
        val checkAtEnd = suspend { assertButtonsVisible(negative = true) }
        val checkAtEnd = suspend {
            if (bpFallbackOptions()) {
                assertButtonsVisible(cancel = true)
            } else {
                assertButtonsVisible(negative = true)
            }
        }

        showTemporaryErrors(restart = false) { checkAtEnd() }
        showTemporaryErrors(restart = false, helpAfterError = "foo") { checkAtEnd() }
@@ -1430,7 +1449,11 @@ internal class PromptViewModelTest(private val testCase: TestCase) : SysuiTestCa
        assertThat(message).isEqualTo(PromptMessage.Error(errorMessage))
        assertThat(messageVisible).isTrue()
        assertThat(canTryAgain).isEqualTo(testCase.authenticatedByFace)
        if (bpFallbackOptions()) {
            assertButtonsVisible(cancel = true, tryAgain = expectTryAgainButton)
        } else {
            assertButtonsVisible(negative = true, tryAgain = expectTryAgainButton)
        }

        errorJob.join()

@@ -1439,7 +1462,11 @@ internal class PromptViewModelTest(private val testCase: TestCase) : SysuiTestCa
        assertThat(message).isEqualTo(PromptMessage.Help(helpMessage))
        assertThat(messageVisible).isTrue()
        assertThat(canTryAgain).isEqualTo(testCase.authenticatedByFace)
        if (bpFallbackOptions()) {
            assertButtonsVisible(cancel = true, tryAgain = expectTryAgainButton)
        } else {
            assertButtonsVisible(negative = true, tryAgain = expectTryAgainButton)
        }

        val helpMessage2 = "foo"
        kosmos.promptViewModel.showAuthenticating(helpMessage2, isRetry = true)
@@ -1447,8 +1474,12 @@ internal class PromptViewModelTest(private val testCase: TestCase) : SysuiTestCa
        assertThat(authenticated?.isAuthenticated).isFalse()
        assertThat(message).isEqualTo(PromptMessage.Help(helpMessage2))
        assertThat(messageVisible).isTrue()
        if (bpFallbackOptions()) {
            assertButtonsVisible(cancel = true)
        } else {
            assertButtonsVisible(negative = true)
        }
    }

    @Test
    fun switch_to_credential_fallback() = runGenericTest {
@@ -1706,6 +1737,30 @@ internal class PromptViewModelTest(private val testCase: TestCase) : SysuiTestCa
        assertThat(isIconViewLoaded).isTrue()
    }

    @Test
    @EnableFlags(FLAG_BP_FALLBACK_OPTIONS)
    fun show_single_fallback_button() =
        runGenericTest(fallbackOptions = listOf(FallbackOption("Fallback", 0))) {
            if (!testCase.shouldStartAsImplicitFlow) {
                assertButtonsVisible(singleFallback = true)
            } else {
                assertButtonsVisible()
            }
        }

    @Test
    @EnableFlags(FLAG_BP_FALLBACK_OPTIONS)
    fun show_fallback_options_button() =
        runGenericTest(
            fallbackOptions = listOf(FallbackOption("Fallback1", 0), FallbackOption("Fallback2", 0))
        ) {
            if (!testCase.shouldStartAsImplicitFlow) {
                assertButtonsVisible(fallbackOptions = true)
            } else {
                assertButtonsVisible()
            }
        }

    /** Asserts that the selected buttons are visible now. */
    private suspend fun TestScope.assertButtonsVisible(
        tryAgain: Boolean = false,
@@ -1713,13 +1768,40 @@ internal class PromptViewModelTest(private val testCase: TestCase) : SysuiTestCa
        cancel: Boolean = false,
        negative: Boolean = false,
        credential: Boolean = false,
        singleFallback: Boolean = false,
        fallbackOptions: Boolean = false,
    ) {
        runCurrent()
        if (bpFallbackOptions()) {
            val expectedPositiveState =
                when {
                    tryAgain -> PositiveButtonState.TryAgain::class
                    confirm -> PositiveButtonState.Confirm::class
                    else -> PositiveButtonState.Gone::class
                }

            val expectedNegativeState =
                when {
                    cancel -> NegativeButtonState.Cancel::class
                    negative -> NegativeButtonState.SetNegative::class
                    credential -> NegativeButtonState.UseCredential::class
                    fallbackOptions -> NegativeButtonState.FallbackOptions::class
                    singleFallback -> NegativeButtonState.SingleFallback::class
                    else -> NegativeButtonState.Gone::class
                }

            assertThat(kosmos.promptViewModel.positiveButtonState.first())
                .isInstanceOf(expectedPositiveState.java)
            assertThat(kosmos.promptViewModel.negativeButtonState.first())
                .isInstanceOf(expectedNegativeState.java)
        } else {
            assertThat(kosmos.promptViewModel.isTryAgainButtonVisible.first()).isEqualTo(tryAgain)
            assertThat(kosmos.promptViewModel.isConfirmButtonVisible.first()).isEqualTo(confirm)
            assertThat(kosmos.promptViewModel.isCancelButtonVisible.first()).isEqualTo(cancel)
            assertThat(kosmos.promptViewModel.isNegativeButtonVisible.first()).isEqualTo(negative)
        assertThat(kosmos.promptViewModel.isCredentialButtonVisible.first()).isEqualTo(credential)
            assertThat(kosmos.promptViewModel.isCredentialButtonVisible.first())
                .isEqualTo(credential)
        }
    }

    private fun runGenericTest(
@@ -1733,6 +1815,7 @@ internal class PromptViewModelTest(private val testCase: TestCase) : SysuiTestCa
        logoDescription: String? = null,
        packageName: String = OP_PACKAGE_NAME_WITH_APP_LOGO,
        userId: Int = USER_ID,
        fallbackOptions: List<FallbackOption> = emptyList(),
        block: suspend TestScope.() -> Unit,
    ) {
        val topActivity = ComponentName(packageName, "test app")
@@ -1753,6 +1836,7 @@ internal class PromptViewModelTest(private val testCase: TestCase) : SysuiTestCa
            logoDescriptionFromApp = logoDescription,
            packageName = packageName,
            userId = userId,
            fallbackOptions = fallbackOptions,
        )

        kosmos.biometricStatusRepository.setFingerprintAcquiredStatus(
@@ -1979,6 +2063,7 @@ private fun PromptSelectorInteractor.initializePrompt(
    logoDescriptionFromApp: String? = null,
    packageName: String = OP_PACKAGE_NAME_WITH_APP_LOGO,
    userId: Int = USER_ID,
    fallbackOptions: List<FallbackOption> = emptyList(),
) {
    val info =
        PromptInfo().apply {
@@ -1990,6 +2075,9 @@ private fun PromptSelectorInteractor.initializePrompt(
            authenticators = listOf(face, fingerprint).extractAuthenticatorTypes()
            isDeviceCredentialAllowed = allowCredentialFallback
            isConfirmationRequested = requireConfirmation
            for (option in fallbackOptions) {
                addFallbackOption(option)
            }
        }
    if (logoBitmapFromApp != null) {
        info.setLogo(logoResFromApp, logoBitmapFromApp)