From 5a2c3901f766b47ffb867d9900f5139147c9f82d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montw=C3=A9?= Date: Thu, 30 Oct 2025 18:16:27 +0100 Subject: [PATCH 1/2] fix: biometric prompt is not shown --- ...wordInput.kt => ProtectedPasswordInput.kt} | 29 ++-- .../ProtectedTextFieldOutlinedPassword.kt | 86 ++++++++++++ .../ui/common/ServerSettingsPasswordInput.kt | 2 +- .../TextFieldOutlinedPasswordBiometric.kt | 131 ------------------ .../settings/ui/common/Authenticator.kt | 10 ++ .../ui/common/BiometricAuthenticator.kt | 71 ++++++++++ .../ui/common/ProtectedPasswordInputKtTest.kt | 72 ++++++++++ ...rotectedTextFieldOutlinedPasswordKtTest.kt | 127 +++++++++++++++++ 8 files changed, 385 insertions(+), 143 deletions(-) rename feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/{BiometricPasswordInput.kt => ProtectedPasswordInput.kt} (74%) create mode 100644 feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedTextFieldOutlinedPassword.kt delete mode 100644 feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/TextFieldOutlinedPasswordBiometric.kt create mode 100644 feature/account/server/settings/src/main/kotlin/net/thunderbird/feature/account/server/settings/ui/common/Authenticator.kt create mode 100644 feature/account/server/settings/src/main/kotlin/net/thunderbird/feature/account/server/settings/ui/common/BiometricAuthenticator.kt create mode 100644 feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedPasswordInputKtTest.kt create mode 100644 feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedTextFieldOutlinedPasswordKtTest.kt diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/BiometricPasswordInput.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedPasswordInput.kt similarity index 74% rename from feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/BiometricPasswordInput.kt rename to feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedPasswordInput.kt index 7c56ebe558a..c0c5572cc2d 100644 --- a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/BiometricPasswordInput.kt +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedPasswordInput.kt @@ -1,6 +1,6 @@ package app.k9mail.feature.account.server.settings.ui.common -import androidx.biometric.BiometricPrompt +import androidx.activity.compose.LocalActivity import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable @@ -11,30 +11,29 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.fragment.app.FragmentActivity import app.k9mail.core.ui.compose.designsystem.molecule.input.InputLayout -import app.k9mail.core.ui.compose.designsystem.molecule.input.PasswordInput import app.k9mail.core.ui.compose.designsystem.molecule.input.inputContentPadding import app.k9mail.feature.account.server.settings.R import kotlinx.coroutines.delay +import net.thunderbird.feature.account.server.settings.ui.common.Authenticator +import net.thunderbird.feature.account.server.settings.ui.common.BiometricAuthenticator import app.k9mail.core.ui.compose.designsystem.R as RDesign private const val SHOW_WARNING_DURATION = 5000L /** * Variant of [PasswordInput] that only allows the password to be unmasked after the user has authenticated using - * [BiometricPrompt]. - * - * Note: Due to limitations of [BiometricPrompt] this composable can only be used inside a [FragmentActivity]. + * [Authenticator] that defaults to [BiometricAuthenticator]. */ @Composable -fun BiometricPasswordInput( +fun ProtectedPasswordInput( onPasswordChange: (String) -> Unit, modifier: Modifier = Modifier, password: String = "", isRequired: Boolean = false, errorMessage: String? = null, contentPadding: PaddingValues = inputContentPadding(), + authenticator: Authenticator? = null, ) { var biometricWarning by remember { mutableStateOf(value = null) } @@ -56,13 +55,21 @@ fun BiometricPasswordInput( val needScreenLockMessage = stringResource(R.string.account_server_settings_password_authentication_screen_lock_required) - TextFieldOutlinedPasswordBiometric( + val resolvedAuthenticator: Authenticator = authenticator ?: run { + val activity = LocalActivity.current as androidx.fragment.app.FragmentActivity + BiometricAuthenticator( + activity = activity, + title = title, + subtitle = subtitle, + needScreenLockMessage = needScreenLockMessage, + ) + } + + ProtectedTextFieldOutlinedPassword( value = password, onValueChange = onPasswordChange, - authenticationTitle = title, - authenticationSubtitle = subtitle, - needScreenLockMessage = needScreenLockMessage, onWarningChange = { biometricWarning = it?.toString() }, + authenticator = resolvedAuthenticator, label = stringResource(id = RDesign.string.designsystem_molecule_password_input_label), isRequired = isRequired, hasError = errorMessage != null, diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedTextFieldOutlinedPassword.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedTextFieldOutlinedPassword.kt new file mode 100644 index 00000000000..410785941e3 --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedTextFieldOutlinedPassword.kt @@ -0,0 +1,86 @@ +package app.k9mail.feature.account.server.settings.ui.common + +import android.app.Activity +import android.view.WindowManager +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.textfield.TextFieldOutlinedPassword +import kotlinx.coroutines.launch +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.feature.account.server.settings.ui.common.Authenticator + +/** + * Variant of [TextFieldOutlinedPassword] that only allows the + * password to be unmasked after the user has authenticated using [Authenticator]. + */ +@Suppress("LongParameterList") +@Composable +fun ProtectedTextFieldOutlinedPassword( + value: String, + onValueChange: (String) -> Unit, + onWarningChange: (CharSequence?) -> Unit, + authenticator: Authenticator, + modifier: Modifier = Modifier, + label: String? = null, + isEnabled: Boolean = true, + isReadOnly: Boolean = false, + isRequired: Boolean = false, + hasError: Boolean = false, +) { + var isPasswordVisible by rememberSaveable { mutableStateOf(false) } + var isAuthenticated by rememberSaveable { mutableStateOf(false) } + + val activity = LocalActivity.current as Activity + val scope = rememberCoroutineScope() + + TextFieldOutlinedPassword( + value = value, + onValueChange = onValueChange, + modifier = modifier, + label = label, + isEnabled = isEnabled, + isReadOnly = isReadOnly, + isRequired = isRequired, + hasError = hasError, + isPasswordVisible = isPasswordVisible, + onPasswordVisibilityToggleClicked = { + if (isAuthenticated) { + isPasswordVisible = !isPasswordVisible + activity.setSecure(isPasswordVisible) + } else { + scope.launch { + when (val outcome = authenticator.authenticate()) { + is Outcome.Success -> { + isAuthenticated = true + isPasswordVisible = true + onWarningChange(null) + activity.setSecure(true) + } + is Outcome.Failure -> { + onWarningChange(outcome.error) + } + } + } + } + }, + ) + + DisposableEffect(key1 = "secureWindow") { + activity.setSecure(isPasswordVisible) + + onDispose { + activity.setSecure(false) + } + } +} + +private fun Activity.setSecure(secure: Boolean) { + window.setFlags(if (secure) WindowManager.LayoutParams.FLAG_SECURE else 0, WindowManager.LayoutParams.FLAG_SECURE) +} diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/ServerSettingsPasswordInput.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/ServerSettingsPasswordInput.kt index f2c100f18be..939079b920b 100644 --- a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/ServerSettingsPasswordInput.kt +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/ServerSettingsPasswordInput.kt @@ -27,7 +27,7 @@ fun ServerSettingsPasswordInput( contentPadding = contentPadding, ) } else { - BiometricPasswordInput( + ProtectedPasswordInput( onPasswordChange = onPasswordChange, modifier = modifier, password = password, diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/TextFieldOutlinedPasswordBiometric.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/TextFieldOutlinedPasswordBiometric.kt deleted file mode 100644 index 6e980792823..00000000000 --- a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/TextFieldOutlinedPasswordBiometric.kt +++ /dev/null @@ -1,131 +0,0 @@ -package app.k9mail.feature.account.server.settings.ui.common - -import android.view.WindowManager -import androidx.activity.compose.LocalActivity -import androidx.biometric.BiometricManager -import androidx.biometric.BiometricPrompt -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.fragment.app.FragmentActivity -import app.k9mail.core.ui.compose.designsystem.atom.textfield.TextFieldOutlinedPassword - -/** - * Variant of [TextFieldOutlinedPassword] that only allows the password to be unmasked after the user has authenticated - * using [BiometricPrompt]. - * - * Note: Due to limitations of [BiometricPrompt] this composable can only be used inside a [FragmentActivity]. - */ -@Suppress("LongParameterList") -@Composable -fun TextFieldOutlinedPasswordBiometric( - value: String, - onValueChange: (String) -> Unit, - authenticationTitle: String, - authenticationSubtitle: String, - needScreenLockMessage: String, - onWarningChange: (CharSequence?) -> Unit, - modifier: Modifier = Modifier, - label: String? = null, - isEnabled: Boolean = true, - isReadOnly: Boolean = false, - isRequired: Boolean = false, - hasError: Boolean = false, -) { - var isPasswordVisible by rememberSaveable { mutableStateOf(false) } - var isAuthenticated by rememberSaveable { mutableStateOf(false) } - var isAuthenticationRequired by rememberSaveable { mutableStateOf(true) } - - // If the entire password was removed, we allow the user to unmask the text field without requiring authentication. - if (value.isEmpty()) { - isAuthenticationRequired = false - } - - val activity = LocalActivity.current as FragmentActivity - - TextFieldOutlinedPassword( - value = value, - onValueChange = onValueChange, - modifier = modifier, - label = label, - isEnabled = isEnabled, - isReadOnly = isReadOnly, - isRequired = isRequired, - hasError = hasError, - isPasswordVisible = isPasswordVisible, - onPasswordVisibilityToggleClicked = { - if (!isAuthenticationRequired || isAuthenticated) { - isPasswordVisible = !isPasswordVisible - activity.setSecure(isPasswordVisible) - } else { - showBiometricPrompt( - activity, - authenticationTitle, - authenticationSubtitle, - needScreenLockMessage, - onAuthSuccess = { - isAuthenticated = true - isPasswordVisible = true - onWarningChange(null) - activity.setSecure(true) - }, - onAuthError = onWarningChange, - ) - } - }, - ) - - DisposableEffect(key1 = "secureWindow") { - activity.setSecure(isPasswordVisible) - - onDispose { - activity.setSecure(false) - } - } -} - -private fun showBiometricPrompt( - activity: FragmentActivity, - title: String, - subtitle: String, - needScreenLockMessage: String, - onAuthSuccess: () -> Unit, - onAuthError: (CharSequence) -> Unit, -) { - val authenticationCallback = object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - onAuthSuccess() - } - - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { - if (errorCode == BiometricPrompt.ERROR_HW_NOT_PRESENT || - errorCode == BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL || - errorCode == BiometricPrompt.ERROR_NO_BIOMETRICS - ) { - onAuthError(needScreenLockMessage) - } else if (errString.isNotEmpty()) { - onAuthError(errString) - } - } - } - - val promptInfo = BiometricPrompt.PromptInfo.Builder() - .setAllowedAuthenticators( - BiometricManager.Authenticators.BIOMETRIC_STRONG or - BiometricManager.Authenticators.BIOMETRIC_WEAK or - BiometricManager.Authenticators.DEVICE_CREDENTIAL, - ) - .setTitle(title) - .setSubtitle(subtitle) - .build() - - BiometricPrompt(activity, authenticationCallback).authenticate(promptInfo) -} - -private fun FragmentActivity.setSecure(secure: Boolean) { - window.setFlags(if (secure) WindowManager.LayoutParams.FLAG_SECURE else 0, WindowManager.LayoutParams.FLAG_SECURE) -} diff --git a/feature/account/server/settings/src/main/kotlin/net/thunderbird/feature/account/server/settings/ui/common/Authenticator.kt b/feature/account/server/settings/src/main/kotlin/net/thunderbird/feature/account/server/settings/ui/common/Authenticator.kt new file mode 100644 index 00000000000..56f410c10d0 --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/net/thunderbird/feature/account/server/settings/ui/common/Authenticator.kt @@ -0,0 +1,10 @@ +package net.thunderbird.feature.account.server.settings.ui.common + +import net.thunderbird.core.outcome.Outcome + +/** + * A functional interface for authenticating a user. + */ +fun interface Authenticator { + suspend fun authenticate(): Outcome +} diff --git a/feature/account/server/settings/src/main/kotlin/net/thunderbird/feature/account/server/settings/ui/common/BiometricAuthenticator.kt b/feature/account/server/settings/src/main/kotlin/net/thunderbird/feature/account/server/settings/ui/common/BiometricAuthenticator.kt new file mode 100644 index 00000000000..e9e41b114d3 --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/net/thunderbird/feature/account/server/settings/ui/common/BiometricAuthenticator.kt @@ -0,0 +1,71 @@ +package net.thunderbird.feature.account.server.settings.ui.common + +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import kotlin.coroutines.resume +import kotlinx.coroutines.suspendCancellableCoroutine +import net.thunderbird.core.outcome.Outcome + +/** + * An [Authenticator] implementation that uses Android's BiometricPrompt to authenticate the user. + * + * Note: Due to limitations of [androidx.biometric.BiometricPrompt] this composable can only be used inside a + * [androidx.fragment.app.FragmentActivity]. + * + * @param activity The FragmentActivity context to use for the BiometricPrompt. + * @param title The title to display on the biometric prompt. + * @param subtitle The subtitle to display on the biometric prompt. + * @param needScreenLockMessage The message to display when screen lock is required but not set + */ +class BiometricAuthenticator( + private val activity: FragmentActivity, + private val title: String, + private val subtitle: String, + private val needScreenLockMessage: String, +) : Authenticator { + + @Suppress("TooGenericExceptionCaught") + override suspend fun authenticate(): Outcome = suspendCancellableCoroutine { continuation -> + val authenticationCallback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + if (continuation.isActive) continuation.resume(Outcome.Success(Unit)) + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + val message: String = when (errorCode) { + BiometricPrompt.ERROR_HW_NOT_PRESENT, + BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL, + BiometricPrompt.ERROR_NO_BIOMETRICS, + -> needScreenLockMessage + + else -> if (errString.isNotEmpty()) errString.toString() else "Authentication failed" + } + if (continuation.isActive) continuation.resume(Outcome.Failure(message)) + } + } + + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setAllowedAuthenticators( + BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.BIOMETRIC_WEAK or + BiometricManager.Authenticators.DEVICE_CREDENTIAL, + ) + .setTitle(title) + .setSubtitle(subtitle) + .build() + + val executor = ContextCompat.getMainExecutor(activity) + try { + BiometricPrompt(activity, executor, authenticationCallback).authenticate(promptInfo) + } catch (e: Exception) { + val message: String = e.message ?: "Unable to start biometric prompt" + if (continuation.isActive) continuation.resume(Outcome.Failure(message)) + } + + continuation.invokeOnCancellation { + // No explicit cancellation support for BiometricPrompt + } + } +} diff --git a/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedPasswordInputKtTest.kt b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedPasswordInputKtTest.kt new file mode 100644 index 00000000000..b831954b886 --- /dev/null +++ b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedPasswordInputKtTest.kt @@ -0,0 +1,72 @@ + +package app.k9mail.feature.account.server.settings.ui.common + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import app.k9mail.core.ui.compose.testing.ComposeTest +import app.k9mail.core.ui.compose.testing.setContentWithTheme +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.feature.account.server.settings.ui.common.Authenticator +import org.junit.Test +import app.k9mail.core.ui.compose.designsystem.R as RDesign + +class ProtectedPasswordInputKtTest : ComposeTest() { + + @Test + fun `should display default label from wrapper`() = runComposeTest { + // Arrange + setContentWithTheme { + ProtectedPasswordInput( + password = "", + onPasswordChange = {}, + authenticator = Authenticator { Outcome.Failure("irrelevant") }, + ) + } + + // Assert + onNodeWithText(getString(RDesign.string.designsystem_molecule_password_input_label)) + .assertIsDisplayed() + } + + @Test + fun `should show warning message when authenticator fails`() = runComposeTest { + // Arrange + val password = "Password input" + val errorMessage = "Auth failed" + val failingAuthenticator: Authenticator = Authenticator { Outcome.Failure(errorMessage) } + setContentWithTheme { + ProtectedPasswordInput( + password = password, + onPasswordChange = {}, + authenticator = failingAuthenticator, + ) + } + + // Act + onNodeWithContentDescription( + getString(RDesign.string.designsystem_atom_password_textfield_show_password), + ).performClick() + + // Assert + onNodeWithText(errorMessage).assertIsDisplayed() + } + + @Test + fun `should show error message when provided`() = runComposeTest { + // Arrange + val errorMessage = "Some error" + setContentWithTheme { + ProtectedPasswordInput( + password = "", + onPasswordChange = {}, + errorMessage = errorMessage, + authenticator = Authenticator { Outcome.Failure("irrelevant") }, + ) + } + + // Assert + onNodeWithText(errorMessage).assertIsDisplayed() + } +} diff --git a/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedTextFieldOutlinedPasswordKtTest.kt b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedTextFieldOutlinedPasswordKtTest.kt new file mode 100644 index 00000000000..85eb8864081 --- /dev/null +++ b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedTextFieldOutlinedPasswordKtTest.kt @@ -0,0 +1,127 @@ +package app.k9mail.feature.account.server.settings.ui.common + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import app.k9mail.core.ui.compose.testing.ComposeTest +import app.k9mail.core.ui.compose.testing.setContentWithTheme +import net.thunderbird.core.outcome.Outcome +import org.junit.Test +import app.k9mail.core.ui.compose.designsystem.R as RDesign + +class ProtectedTextFieldOutlinedPasswordKtTest : ComposeTest() { + + @Test + fun `should not reveal password when not authorized`() = runComposeTest { + // Arrange + var value = VALUE + setContentWithTheme { + ProtectedTextFieldOutlinedPassword( + value = value, + onValueChange = { value = it }, + onWarningChange = {}, + authenticator = { Outcome.Failure("Auth required") }, + ) + } + + // Act + onNodeWithContentDescription( + getString(RDesign.string.designsystem_atom_password_textfield_show_password), + ).performClick() + + // Assert + onNodeWithContentDescription( + getString(RDesign.string.designsystem_atom_password_textfield_hide_password), + ).assertIsNotDisplayed() + onNodeWithText(VALUE_MASKED).assertIsDisplayed() + onNodeWithContentDescription( + getString(RDesign.string.designsystem_atom_password_textfield_show_password), + ).assertIsDisplayed() + } + + @Test + fun `should not reveal value when not authorized and value is empty`() = runComposeTest { + // Arrange + var value = "" + setContent { + ProtectedTextFieldOutlinedPassword( + value = value, + onValueChange = { value = it }, + onWarningChange = {}, + authenticator = { Outcome.Failure("Auth required") }, + ) + } + + // Act + onNodeWithContentDescription( + getString(RDesign.string.designsystem_atom_password_textfield_show_password), + ).performClick() + + // Assert + onNodeWithContentDescription( + getString(RDesign.string.designsystem_atom_password_textfield_hide_password), + ).assertIsNotDisplayed() + onNodeWithContentDescription( + getString(RDesign.string.designsystem_atom_password_textfield_show_password), + ).assertIsDisplayed() + } + + @Test + fun `should reveal value when authorized`() = runComposeTest { + // Arrange + val value = VALUE + setContent { + ProtectedTextFieldOutlinedPassword( + value = value, + onValueChange = {}, + onWarningChange = {}, + authenticator = { Outcome.Success(Unit) }, + ) + } + + // Act + onNodeWithContentDescription( + getString(RDesign.string.designsystem_atom_password_textfield_show_password), + ).performClick() + + // Assert + onNodeWithText(VALUE_MASKED).assertIsNotDisplayed() + onNodeWithText(VALUE).assertIsDisplayed() + onNodeWithContentDescription( + getString(RDesign.string.designsystem_atom_password_textfield_hide_password), + ).assertIsDisplayed() + } + + @Test + fun `should reveal empty value when authorized`() = runComposeTest { + // Arrange + val value = "" + setContent { + ProtectedTextFieldOutlinedPassword( + value = value, + onValueChange = {}, + onWarningChange = {}, + authenticator = { Outcome.Success(Unit) }, + ) + } + + // Act + onNodeWithContentDescription( + getString(RDesign.string.designsystem_atom_password_textfield_show_password), + ).performClick() + + // Assert + onNodeWithContentDescription( + getString(RDesign.string.designsystem_atom_password_textfield_hide_password), + ).assertIsDisplayed() + } + + private companion object Companion { + const val VALUE = "Password input" + + // 14 dots to match length of "Password input" + const val VALUE_MASKED = "••••••••••••••" + } +} From f44dad54f8a091971d67e47a53a5cceb5e06261f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montw=C3=A9?= Date: Tue, 4 Nov 2025 19:09:59 +0100 Subject: [PATCH 2/2] refactor: add localization and rememberBiometricAuthenticator --- .../ui/common/ProtectedPasswordInput.kt | 54 ++++++----- .../ProtectedTextFieldOutlinedPassword.kt | 3 +- .../settings/ui/common/Authenticator.kt | 28 +++++- .../ui/common/BiometricAuthenticator.kt | 96 ++++++++++++------- .../settings/src/main/res/values/strings.xml | 2 + .../ui/common/ProtectedPasswordInputKtTest.kt | 13 ++- ...rotectedTextFieldOutlinedPasswordKtTest.kt | 9 +- 7 files changed, 135 insertions(+), 70 deletions(-) diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedPasswordInput.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedPasswordInput.kt index c0c5572cc2d..24a003c1b59 100644 --- a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedPasswordInput.kt +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedPasswordInput.kt @@ -1,6 +1,6 @@ package app.k9mail.feature.account.server.settings.ui.common -import androidx.activity.compose.LocalActivity +import androidx.annotation.StringRes import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable @@ -15,15 +15,16 @@ import app.k9mail.core.ui.compose.designsystem.molecule.input.InputLayout import app.k9mail.core.ui.compose.designsystem.molecule.input.inputContentPadding import app.k9mail.feature.account.server.settings.R import kotlinx.coroutines.delay +import net.thunderbird.feature.account.server.settings.ui.common.AuthenticationError import net.thunderbird.feature.account.server.settings.ui.common.Authenticator -import net.thunderbird.feature.account.server.settings.ui.common.BiometricAuthenticator +import net.thunderbird.feature.account.server.settings.ui.common.rememberBiometricAuthenticator import app.k9mail.core.ui.compose.designsystem.R as RDesign private const val SHOW_WARNING_DURATION = 5000L /** * Variant of [PasswordInput] that only allows the password to be unmasked after the user has authenticated using - * [Authenticator] that defaults to [BiometricAuthenticator]. + * [Authenticator] that defaults to [rememberBiometricAuthenticator]. */ @Composable fun ProtectedPasswordInput( @@ -33,14 +34,18 @@ fun ProtectedPasswordInput( isRequired: Boolean = false, errorMessage: String? = null, contentPadding: PaddingValues = inputContentPadding(), - authenticator: Authenticator? = null, + authenticator: Authenticator = rememberBiometricAuthenticator( + title = stringResource(R.string.account_server_settings_password_authentication_title), + subtitle = stringResource(R.string.account_server_settings_password_authentication_subtitle), + ), ) { - var biometricWarning by remember { mutableStateOf(value = null) } + var authenticationError by remember { mutableStateOf(value = null) } + val authenticationWarning = authenticationError?.let { stringResource(it.mapToStringRes()) } - LaunchedEffect(key1 = biometricWarning) { - if (biometricWarning != null) { + LaunchedEffect(key1 = authenticationError) { + if (authenticationError != null) { delay(SHOW_WARNING_DURATION) - biometricWarning = null + authenticationError = null } } @@ -48,28 +53,13 @@ fun ProtectedPasswordInput( modifier = modifier, contentPadding = contentPadding, errorMessage = errorMessage, - warningMessage = biometricWarning, + warningMessage = authenticationWarning, ) { - val title = stringResource(R.string.account_server_settings_password_authentication_title) - val subtitle = stringResource(R.string.account_server_settings_password_authentication_subtitle) - val needScreenLockMessage = - stringResource(R.string.account_server_settings_password_authentication_screen_lock_required) - - val resolvedAuthenticator: Authenticator = authenticator ?: run { - val activity = LocalActivity.current as androidx.fragment.app.FragmentActivity - BiometricAuthenticator( - activity = activity, - title = title, - subtitle = subtitle, - needScreenLockMessage = needScreenLockMessage, - ) - } - ProtectedTextFieldOutlinedPassword( value = password, onValueChange = onPasswordChange, - onWarningChange = { biometricWarning = it?.toString() }, - authenticator = resolvedAuthenticator, + onWarningChange = { authenticationError = it }, + authenticator = authenticator, label = stringResource(id = RDesign.string.designsystem_molecule_password_input_label), isRequired = isRequired, hasError = errorMessage != null, @@ -77,3 +67,15 @@ fun ProtectedPasswordInput( ) } } + +@StringRes +private fun AuthenticationError.mapToStringRes(): Int { + return when (this) { + AuthenticationError.NotAvailable -> + R.string.account_server_settings_password_authentication_screen_lock_required + AuthenticationError.Failed -> + R.string.account_server_settings_password_authentication_failed + AuthenticationError.UnableToStart -> + R.string.account_server_settings_password_authentication_unable_to_start + } +} diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedTextFieldOutlinedPassword.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedTextFieldOutlinedPassword.kt index 410785941e3..637d1c476f2 100644 --- a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedTextFieldOutlinedPassword.kt +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedTextFieldOutlinedPassword.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.Modifier import app.k9mail.core.ui.compose.designsystem.atom.textfield.TextFieldOutlinedPassword import kotlinx.coroutines.launch import net.thunderbird.core.outcome.Outcome +import net.thunderbird.feature.account.server.settings.ui.common.AuthenticationError import net.thunderbird.feature.account.server.settings.ui.common.Authenticator /** @@ -25,7 +26,7 @@ import net.thunderbird.feature.account.server.settings.ui.common.Authenticator fun ProtectedTextFieldOutlinedPassword( value: String, onValueChange: (String) -> Unit, - onWarningChange: (CharSequence?) -> Unit, + onWarningChange: (AuthenticationError?) -> Unit, authenticator: Authenticator, modifier: Modifier = Modifier, label: String? = null, diff --git a/feature/account/server/settings/src/main/kotlin/net/thunderbird/feature/account/server/settings/ui/common/Authenticator.kt b/feature/account/server/settings/src/main/kotlin/net/thunderbird/feature/account/server/settings/ui/common/Authenticator.kt index 56f410c10d0..57f60c7a3bd 100644 --- a/feature/account/server/settings/src/main/kotlin/net/thunderbird/feature/account/server/settings/ui/common/Authenticator.kt +++ b/feature/account/server/settings/src/main/kotlin/net/thunderbird/feature/account/server/settings/ui/common/Authenticator.kt @@ -6,5 +6,31 @@ import net.thunderbird.core.outcome.Outcome * A functional interface for authenticating a user. */ fun interface Authenticator { - suspend fun authenticate(): Outcome + + /** + * Authenticates the user. + * + * @return An [Outcome] representing the result of the authentication process. + */ + suspend fun authenticate(): Outcome +} + +/** + * Authentication errors that can occur during the authentication process. + */ +sealed interface AuthenticationError { + /** + * The user has not set up any authentication methods (e.g. screen lock, biometrics). + */ + data object NotAvailable : AuthenticationError + + /** + * The authentication failed. + */ + data object Failed : AuthenticationError + + /** + * An unknown error occurred, and authentication could not be started. + */ + data object UnableToStart : AuthenticationError } diff --git a/feature/account/server/settings/src/main/kotlin/net/thunderbird/feature/account/server/settings/ui/common/BiometricAuthenticator.kt b/feature/account/server/settings/src/main/kotlin/net/thunderbird/feature/account/server/settings/ui/common/BiometricAuthenticator.kt index e9e41b114d3..1c4f2c92e75 100644 --- a/feature/account/server/settings/src/main/kotlin/net/thunderbird/feature/account/server/settings/ui/common/BiometricAuthenticator.kt +++ b/feature/account/server/settings/src/main/kotlin/net/thunderbird/feature/account/server/settings/ui/common/BiometricAuthenticator.kt @@ -1,11 +1,15 @@ package net.thunderbird.feature.account.server.settings.ui.common +import androidx.activity.compose.LocalActivity import androidx.biometric.BiometricManager import androidx.biometric.BiometricPrompt +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentActivity import kotlin.coroutines.resume import kotlinx.coroutines.suspendCancellableCoroutine +import net.thunderbird.core.logging.legacy.Log import net.thunderbird.core.outcome.Outcome /** @@ -17,55 +21,81 @@ import net.thunderbird.core.outcome.Outcome * @param activity The FragmentActivity context to use for the BiometricPrompt. * @param title The title to display on the biometric prompt. * @param subtitle The subtitle to display on the biometric prompt. - * @param needScreenLockMessage The message to display when screen lock is required but not set */ class BiometricAuthenticator( private val activity: FragmentActivity, private val title: String, private val subtitle: String, - private val needScreenLockMessage: String, ) : Authenticator { @Suppress("TooGenericExceptionCaught") - override suspend fun authenticate(): Outcome = suspendCancellableCoroutine { continuation -> - val authenticationCallback = object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - if (continuation.isActive) continuation.resume(Outcome.Success(Unit)) - } + override suspend fun authenticate(): Outcome = + suspendCancellableCoroutine { continuation -> + val authenticationCallback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + if (continuation.isActive) continuation.resume(Outcome.Success(Unit)) + } - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { - val message: String = when (errorCode) { - BiometricPrompt.ERROR_HW_NOT_PRESENT, - BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL, - BiometricPrompt.ERROR_NO_BIOMETRICS, - -> needScreenLockMessage + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + val error: AuthenticationError = when (errorCode) { + BiometricPrompt.ERROR_HW_NOT_PRESENT, + BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL, + BiometricPrompt.ERROR_NO_BIOMETRICS, + -> AuthenticationError.NotAvailable - else -> if (errString.isNotEmpty()) errString.toString() else "Authentication failed" + else -> AuthenticationError.Failed + } + if (continuation.isActive) continuation.resume(Outcome.Failure(error)) } - if (continuation.isActive) continuation.resume(Outcome.Failure(message)) } - } - val promptInfo = BiometricPrompt.PromptInfo.Builder() - .setAllowedAuthenticators( - BiometricManager.Authenticators.BIOMETRIC_STRONG or - BiometricManager.Authenticators.BIOMETRIC_WEAK or - BiometricManager.Authenticators.DEVICE_CREDENTIAL, - ) - .setTitle(title) - .setSubtitle(subtitle) - .build() + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setAllowedAuthenticators( + BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.BIOMETRIC_WEAK or + BiometricManager.Authenticators.DEVICE_CREDENTIAL, + ) + .setTitle(title) + .setSubtitle(subtitle) + .build() - val executor = ContextCompat.getMainExecutor(activity) - try { - BiometricPrompt(activity, executor, authenticationCallback).authenticate(promptInfo) - } catch (e: Exception) { - val message: String = e.message ?: "Unable to start biometric prompt" - if (continuation.isActive) continuation.resume(Outcome.Failure(message)) + val executor = ContextCompat.getMainExecutor(activity) + try { + BiometricPrompt(activity, executor, authenticationCallback).authenticate(promptInfo) + } catch (e: Exception) { + Log.e("BiometricAuthenticator", "Failed to start biometric authentication", e) + if (continuation.isActive) continuation.resume(Outcome.Failure(AuthenticationError.UnableToStart)) + } + + continuation.invokeOnCancellation { + // No explicit cancellation support for BiometricPrompt + } } +} - continuation.invokeOnCancellation { - // No explicit cancellation support for BiometricPrompt +/** + * Creates and remembers a [BiometricAuthenticator]. + * + * @param title The title to display on the biometric prompt. + * @param subtitle The subtitle to display on the biometric prompt. + */ +@Composable +fun rememberBiometricAuthenticator( + title: String, + subtitle: String, +): Authenticator { + val activity = LocalActivity.current + return remember(activity, title, subtitle) { + val fragmentActivity = activity as? FragmentActivity + if (fragmentActivity != null) { + BiometricAuthenticator( + activity = fragmentActivity, + title = title, + subtitle = subtitle, + ) + } else { + // Fallback for previews and other non-FragmentActivity contexts + Authenticator { Outcome.Failure(AuthenticationError.UnableToStart) } } } } diff --git a/feature/account/server/settings/src/main/res/values/strings.xml b/feature/account/server/settings/src/main/res/values/strings.xml index 82292d14b6c..5948dc5325a 100644 --- a/feature/account/server/settings/src/main/res/values/strings.xml +++ b/feature/account/server/settings/src/main/res/values/strings.xml @@ -41,4 +41,6 @@ Unlock to view your password To view your password here, enable screen lock on this device. + Authentication failed! + Unable to start biometric prompt. diff --git a/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedPasswordInputKtTest.kt b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedPasswordInputKtTest.kt index b831954b886..7b00bf450a1 100644 --- a/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedPasswordInputKtTest.kt +++ b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedPasswordInputKtTest.kt @@ -7,7 +7,9 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import app.k9mail.core.ui.compose.testing.ComposeTest import app.k9mail.core.ui.compose.testing.setContentWithTheme +import app.k9mail.feature.account.server.settings.R import net.thunderbird.core.outcome.Outcome +import net.thunderbird.feature.account.server.settings.ui.common.AuthenticationError import net.thunderbird.feature.account.server.settings.ui.common.Authenticator import org.junit.Test import app.k9mail.core.ui.compose.designsystem.R as RDesign @@ -21,7 +23,7 @@ class ProtectedPasswordInputKtTest : ComposeTest() { ProtectedPasswordInput( password = "", onPasswordChange = {}, - authenticator = Authenticator { Outcome.Failure("irrelevant") }, + authenticator = Authenticator { Outcome.Failure(AuthenticationError.Failed) }, ) } @@ -34,8 +36,7 @@ class ProtectedPasswordInputKtTest : ComposeTest() { fun `should show warning message when authenticator fails`() = runComposeTest { // Arrange val password = "Password input" - val errorMessage = "Auth failed" - val failingAuthenticator: Authenticator = Authenticator { Outcome.Failure(errorMessage) } + val failingAuthenticator: Authenticator = Authenticator { Outcome.Failure(AuthenticationError.NotAvailable) } setContentWithTheme { ProtectedPasswordInput( password = password, @@ -50,7 +51,9 @@ class ProtectedPasswordInputKtTest : ComposeTest() { ).performClick() // Assert - onNodeWithText(errorMessage).assertIsDisplayed() + onNodeWithText( + getString(R.string.account_server_settings_password_authentication_screen_lock_required), + ).assertIsDisplayed() } @Test @@ -62,7 +65,7 @@ class ProtectedPasswordInputKtTest : ComposeTest() { password = "", onPasswordChange = {}, errorMessage = errorMessage, - authenticator = Authenticator { Outcome.Failure("irrelevant") }, + authenticator = Authenticator { Outcome.Failure(AuthenticationError.Failed) }, ) } diff --git a/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedTextFieldOutlinedPasswordKtTest.kt b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedTextFieldOutlinedPasswordKtTest.kt index 85eb8864081..d1fdd9209c4 100644 --- a/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedTextFieldOutlinedPasswordKtTest.kt +++ b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedTextFieldOutlinedPasswordKtTest.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.test.performClick import app.k9mail.core.ui.compose.testing.ComposeTest import app.k9mail.core.ui.compose.testing.setContentWithTheme import net.thunderbird.core.outcome.Outcome +import net.thunderbird.feature.account.server.settings.ui.common.AuthenticationError import org.junit.Test import app.k9mail.core.ui.compose.designsystem.R as RDesign @@ -21,8 +22,8 @@ class ProtectedTextFieldOutlinedPasswordKtTest : ComposeTest() { ProtectedTextFieldOutlinedPassword( value = value, onValueChange = { value = it }, - onWarningChange = {}, - authenticator = { Outcome.Failure("Auth required") }, + onWarningChange = { _ -> }, + authenticator = { Outcome.Failure(AuthenticationError.Failed) }, ) } @@ -49,8 +50,8 @@ class ProtectedTextFieldOutlinedPasswordKtTest : ComposeTest() { ProtectedTextFieldOutlinedPassword( value = value, onValueChange = { value = it }, - onWarningChange = {}, - authenticator = { Outcome.Failure("Auth required") }, + onWarningChange = { _ -> }, + authenticator = { Outcome.Failure(AuthenticationError.Failed) }, ) }