Add BiometricPasswordInput

Variant of `PasswordInput` that only allows the password to be unmasked after the user has authenticated using `BiometricPrompt`.
This commit is contained in:
cketti 2023-10-06 18:13:42 +02:00
parent 761f8b51bc
commit 6dbb94b7e3
7 changed files with 292 additions and 1 deletions

View file

@ -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,

View file

@ -11,7 +11,7 @@ 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,

View file

@ -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),

View file

@ -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)
}

View file

@ -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(),
)
}
}

View file

@ -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)
}

View file

@ -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>