Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add password input field that requires user authentication for unmasking #7227

Merged
merged 3 commits into from
Oct 12, 2023
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
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
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,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(
Expand All @@ -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,
)
}
}
}
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>