diff --git a/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/ui/atom/items/ColorItems.kt b/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/ui/atom/items/ColorItems.kt index 5a442ffae..8bed26d8c 100644 --- a/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/ui/atom/items/ColorItems.kt +++ b/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/ui/atom/items/ColorItems.kt @@ -14,6 +14,7 @@ import app.k9mail.core.ui.compose.theme.MainTheme import app.k9mail.ui.catalog.ui.common.list.itemDefaultPadding import app.k9mail.ui.catalog.ui.common.list.sectionHeaderItem +@Suppress("LongMethod") fun LazyGridScope.colorItems() { sectionHeaderItem(text = "Material theme colors") item { @@ -52,12 +53,30 @@ fun LazyGridScope.colorItems() { color = MainTheme.colors.surface, ) } + item { + ColorContent( + name = "Success", + color = MainTheme.colors.success, + ) + } item { ColorContent( name = "Error", color = MainTheme.colors.error, ) } + item { + ColorContent( + name = "Warning", + color = MainTheme.colors.warning, + ) + } + item { + ColorContent( + name = "Info", + color = MainTheme.colors.info, + ) + } } @Composable diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedPassword.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedPassword.kt index 55cb25160..b07f850fe 100644 --- a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedPassword.kt +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedPassword.kt @@ -55,6 +55,41 @@ fun TextFieldOutlinedPassword( ) } +@Composable +fun TextFieldOutlinedPassword( + value: String, + onValueChange: (String) -> Unit, + isPasswordVisible: Boolean, + onPasswordVisibilityToggleClicked: () -> Unit, + modifier: Modifier = Modifier, + label: String? = null, + isEnabled: Boolean = true, + isReadOnly: Boolean = false, + isRequired: Boolean = false, + hasError: Boolean = false, +) { + MaterialOutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier, + enabled = isEnabled, + label = selectLabel(label, isRequired), + trailingIcon = selectTrailingIcon( + isEnabled = isEnabled, + isPasswordVisible = isPasswordVisible, + onClick = onPasswordVisibilityToggleClicked, + ), + readOnly = isReadOnly, + isError = hasError, + visualTransformation = selectVisualTransformation( + isEnabled = isEnabled, + isPasswordVisible = isPasswordVisible, + ), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + singleLine = true, + ) +} + private fun selectTrailingIcon( isEnabled: Boolean, isPasswordVisible: Boolean, diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/InputLayout.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/InputLayout.kt index 466183c9d..87edd57bd 100644 --- a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/InputLayout.kt +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/InputLayout.kt @@ -11,10 +11,11 @@ import app.k9mail.core.ui.compose.designsystem.atom.text.TextCaption import app.k9mail.core.ui.compose.theme.MainTheme @Composable -internal fun InputLayout( +fun InputLayout( modifier: Modifier = Modifier, contentPadding: PaddingValues = inputContentPadding(), errorMessage: String? = null, + warningMessage: String? = null, content: @Composable () -> Unit, ) { Column( @@ -32,5 +33,13 @@ internal fun InputLayout( color = MainTheme.colors.error, ) } + + AnimatedVisibility(visible = warningMessage != null) { + TextCaption( + text = warningMessage ?: "", + modifier = Modifier.padding(start = MainTheme.spacings.double, top = MainTheme.spacings.half), + color = MainTheme.colors.warning, + ) + } } } diff --git a/core/ui/compose/designsystem/src/test/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedPasswordKtTest.kt b/core/ui/compose/designsystem/src/test/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedPasswordKtTest.kt index a4bbfa6cf..ffddca54e 100644 --- a/core/ui/compose/designsystem/src/test/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedPasswordKtTest.kt +++ b/core/ui/compose/designsystem/src/test/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedPasswordKtTest.kt @@ -8,6 +8,8 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import app.k9mail.core.ui.compose.designsystem.R import app.k9mail.core.ui.compose.testing.ComposeTest +import assertk.assertThat +import assertk.assertions.isTrue import org.junit.Test private const val PASSWORD = "Password input" @@ -84,6 +86,51 @@ class TextFieldOutlinedPasswordKtTest : ComposeTest() { onShowPasswordNode().assertIsDisplayed() } + @Test + fun `should call callback when password visibility toggle icon is clicked`() = runComposeTest { + var clicked = false + setContent { + TextFieldOutlinedPassword( + value = PASSWORD, + onValueChange = {}, + isPasswordVisible = false, + onPasswordVisibilityToggleClicked = { clicked = true }, + ) + } + + onShowPasswordNode().performClick() + + assertThat(clicked).isTrue() + } + + @Test + fun `should display password when isPasswordVisible = true`() = runComposeTest { + setContent { + TextFieldOutlinedPassword( + value = PASSWORD, + onValueChange = {}, + isPasswordVisible = true, + onPasswordVisibilityToggleClicked = {}, + ) + } + + onNodeWithText(PASSWORD).assertIsDisplayed() + } + + @Test + fun `should not display password when isPasswordVisible = false`() = runComposeTest { + setContent { + TextFieldOutlinedPassword( + value = PASSWORD, + onValueChange = {}, + isPasswordVisible = false, + onPasswordVisibilityToggleClicked = {}, + ) + } + + onNodeWithText(PASSWORD).assertDoesNotExist() + } + private fun SemanticsNodeInteractionsProvider.onShowPasswordNode(): SemanticsNodeInteraction { return onNodeWithContentDescription( getString(R.string.designsystem_atom_password_textfield_show_password), diff --git a/feature/account/server/settings/build.gradle.kts b/feature/account/server/settings/build.gradle.kts index 5e9c127b1..05a0a02c0 100644 --- a/feature/account/server/settings/build.gradle.kts +++ b/feature/account/server/settings/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { implementation(projects.mail.protocols.imap) implementation(projects.feature.account.common) + implementation(libs.androidx.biometric) testImplementation(projects.core.ui.compose.testing) } 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/BiometricPasswordInput.kt new file mode 100644 index 000000000..7c56ebe55 --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/BiometricPasswordInput.kt @@ -0,0 +1,72 @@ +package app.k9mail.feature.account.server.settings.ui.common + +import androidx.biometric.BiometricPrompt +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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 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]. + */ +@Composable +fun BiometricPasswordInput( + onPasswordChange: (String) -> Unit, + modifier: Modifier = Modifier, + password: String = "", + isRequired: Boolean = false, + errorMessage: String? = null, + contentPadding: PaddingValues = inputContentPadding(), +) { + var biometricWarning by remember { mutableStateOf(value = null) } + + LaunchedEffect(key1 = biometricWarning) { + if (biometricWarning != null) { + delay(SHOW_WARNING_DURATION) + biometricWarning = null + } + } + + InputLayout( + modifier = modifier, + contentPadding = contentPadding, + errorMessage = errorMessage, + warningMessage = biometricWarning, + ) { + 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( + value = password, + onValueChange = onPasswordChange, + authenticationTitle = title, + authenticationSubtitle = subtitle, + needScreenLockMessage = needScreenLockMessage, + onWarningChange = { biometricWarning = it?.toString() }, + label = stringResource(id = RDesign.string.designsystem_molecule_password_input_label), + isRequired = isRequired, + hasError = errorMessage != null, + modifier = Modifier.fillMaxWidth(), + ) + } +} 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 new file mode 100644 index 000000000..b50bf5cca --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/TextFieldOutlinedPasswordBiometric.kt @@ -0,0 +1,131 @@ +package app.k9mail.feature.account.server.settings.ui.common + +import android.view.WindowManager +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.common.activity.LocalActivity +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/res/values/strings.xml b/feature/account/server/settings/src/main/res/values/strings.xml index 7b65001b2..c7fb5db7a 100644 --- a/feature/account/server/settings/src/main/res/values/strings.xml +++ b/feature/account/server/settings/src/main/res/values/strings.xml @@ -35,4 +35,9 @@ Username is required. Password is required. Imap prefix can\'t be blank. + + Verify your identity + Unlock to view your password + + To view your password here, enable screen lock on this device.