Merge pull request #7227 from thundernest/textfield_reveal_authentication
Add password input field that requires user authentication for unmasking
This commit is contained in:
commit
61b76c31da
8 changed files with 320 additions and 1 deletions
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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<String?>(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(),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -35,4 +35,9 @@
|
|||
<string name="account_server_settings_validation_error_username_required">Username is required.</string>
|
||||
<string name="account_server_settings_validation_error_password_required">Password is required.</string>
|
||||
<string name="account_server_settings_validation_error_imap_prefix_blank">Imap prefix can\'t be blank.</string>
|
||||
|
||||
<string name="account_server_settings_password_authentication_title">Verify your identity</string>
|
||||
<string name="account_server_settings_password_authentication_subtitle">Unlock to view your password</string>
|
||||
<!-- Please use the same translation for "screen lock" as is used in the 'Security' section in Android's settings app -->
|
||||
<string name="account_server_settings_password_authentication_screen_lock_required">To view your password here, enable screen lock on this device.</string>
|
||||
</resources>
|
||||
|
|
Loading…
Reference in a new issue