Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -11,62 +11,71 @@ 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<String?>(value = null) }
var authenticationError by remember { mutableStateOf<AuthenticationError?>(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
}
}

InputLayout(
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,
modifier = Modifier.fillMaxWidth(),
)
}
}

@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
}
}
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Although this is nice to have, I don't think that this input should be responsible for setting the Activity in the secure mode.

Instead, I would have a onPasswordVisibleChange callback, and the screen that consumes this component would change the Activity flag.

Additionally, removing the FLAG_SECURE flag when disposing of this component opens the possibility of retrieving the password via a screenshot.

See video below:

Screen.Recording.2025-11-03.at.3.15.13.PM.mov

}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ fun ServerSettingsPasswordInput(
contentPadding = contentPadding,
)
} else {
BiometricPasswordInput(
ProtectedPasswordInput(
onPasswordChange = onPasswordChange,
modifier = modifier,
password = password,
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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<Unit, AuthenticationError>
}

/**
* 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
}
Loading
Loading