Skip to content

Commit

Permalink
Add BiometricPasswordInput
Browse files Browse the repository at this point in the history
Variant of `PasswordInput` that only allows the password to be unmasked after the user has authenticated using `BiometricPrompt`.
  • Loading branch information
cketti committed Oct 12, 2023
1 parent 761f8b5 commit 6dbb94b
Show file tree
Hide file tree
Showing 7 changed files with 292 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions feature/account/server/settings/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
@@ -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(),
)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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>

0 comments on commit 6dbb94b

Please sign in to comment.