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 51% 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..24a003c1b59 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.annotation.StringRes import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable @@ -11,37 +11,41 @@ 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.AuthenticationError +import net.thunderbird.feature.account.server.settings.ui.common.Authenticator +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 - * [BiometricPrompt]. - * - * Note: Due to limitations of [BiometricPrompt] this composable can only be used inside a [FragmentActivity]. + * [Authenticator] that defaults to [rememberBiometricAuthenticator]. */ @Composable -fun BiometricPasswordInput( +fun ProtectedPasswordInput( onPasswordChange: (String) -> Unit, modifier: Modifier = Modifier, password: String = "", isRequired: Boolean = false, errorMessage: String? = null, contentPadding: PaddingValues = inputContentPadding(), + 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 } } @@ -49,20 +53,13 @@ fun BiometricPasswordInput( 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) - - TextFieldOutlinedPasswordBiometric( + ProtectedTextFieldOutlinedPassword( value = password, onValueChange = onPasswordChange, - authenticationTitle = title, - authenticationSubtitle = subtitle, - needScreenLockMessage = needScreenLockMessage, - onWarningChange = { biometricWarning = it?.toString() }, + onWarningChange = { authenticationError = it }, + authenticator = authenticator, label = stringResource(id = RDesign.string.designsystem_molecule_password_input_label), isRequired = isRequired, hasError = errorMessage != null, @@ -70,3 +67,15 @@ fun BiometricPasswordInput( ) } } + +@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 new file mode 100644 index 00000000000..637d1c476f2 --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedTextFieldOutlinedPassword.kt @@ -0,0 +1,87 @@ +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.AuthenticationError +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: (AuthenticationError?) -> 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..57f60c7a3bd --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/net/thunderbird/feature/account/server/settings/ui/common/Authenticator.kt @@ -0,0 +1,36 @@ +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 { + + /** + * 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 new file mode 100644 index 00000000000..1c4f2c92e75 --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/net/thunderbird/feature/account/server/settings/ui/common/BiometricAuthenticator.kt @@ -0,0 +1,101 @@ +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 + +/** + * 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. + */ +class BiometricAuthenticator( + private val activity: FragmentActivity, + private val title: String, + private val subtitle: 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 error: AuthenticationError = when (errorCode) { + BiometricPrompt.ERROR_HW_NOT_PRESENT, + BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL, + BiometricPrompt.ERROR_NO_BIOMETRICS, + -> AuthenticationError.NotAvailable + + else -> AuthenticationError.Failed + } + if (continuation.isActive) continuation.resume(Outcome.Failure(error)) + } + } + + 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) { + 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 + } + } +} + +/** + * 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 new file mode 100644 index 00000000000..7b00bf450a1 --- /dev/null +++ b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedPasswordInputKtTest.kt @@ -0,0 +1,75 @@ + +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 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 + +class ProtectedPasswordInputKtTest : ComposeTest() { + + @Test + fun `should display default label from wrapper`() = runComposeTest { + // Arrange + setContentWithTheme { + ProtectedPasswordInput( + password = "", + onPasswordChange = {}, + authenticator = Authenticator { Outcome.Failure(AuthenticationError.Failed) }, + ) + } + + // 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 failingAuthenticator: Authenticator = Authenticator { Outcome.Failure(AuthenticationError.NotAvailable) } + setContentWithTheme { + ProtectedPasswordInput( + password = password, + onPasswordChange = {}, + authenticator = failingAuthenticator, + ) + } + + // Act + onNodeWithContentDescription( + getString(RDesign.string.designsystem_atom_password_textfield_show_password), + ).performClick() + + // Assert + onNodeWithText( + getString(R.string.account_server_settings_password_authentication_screen_lock_required), + ).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(AuthenticationError.Failed) }, + ) + } + + // 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..d1fdd9209c4 --- /dev/null +++ b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedTextFieldOutlinedPasswordKtTest.kt @@ -0,0 +1,128 @@ +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 net.thunderbird.feature.account.server.settings.ui.common.AuthenticationError +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(AuthenticationError.Failed) }, + ) + } + + // 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(AuthenticationError.Failed) }, + ) + } + + // 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 = "••••••••••••••" + } +}