Migrate to SecureTextField (#1191)

* Using `SecureTextField`

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* `OutlinedSecureTextField` doesn't support `readOnly`

* fixed string conversions

* - Update AddWebdavMountScreen to use enabled instead of readOnly
- Ensure onKeyboardAction checks canContinue before proceeding
- Update UrlLogin and EmailLogin to ensure onKeyboardAction checks canContinue before proceeding
- Update InputDialogs to ensure confirmEnabled is checked before proceeding

* Use get() for deriving things from a mutable state

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
This commit is contained in:
Arnau Mora
2025-10-27 11:15:11 +01:00
committed by GitHub
parent 19458aa95c
commit 0304d7168a
12 changed files with 83 additions and 111 deletions

View File

@@ -10,8 +10,8 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
@@ -22,10 +22,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextField
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
@@ -34,7 +31,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@@ -49,12 +45,12 @@ fun EditTextInputDialog(
onValueEntered: (String) -> Unit = {},
onDismiss: () -> Unit = {},
) {
var textValue by remember {
mutableStateOf(TextFieldValue(
initialValue ?: "", selection = TextRange(initialValue?.length ?: 0)
))
}
val state = rememberTextFieldState(
initialText = initialValue ?: "",
initialSelection = TextRange(initialValue?.length ?: 0)
)
val confirmEnabled = state.text != initialValue
AlertDialog(
onDismissRequest = onDismiss,
title = {
@@ -67,27 +63,24 @@ fun EditTextInputDialog(
val focusRequester = remember { FocusRequester() }
if (passwordField)
PasswordTextField(
password = textValue.text,
password = state,
labelText = inputLabel,
onPasswordChange = { textValue = TextFieldValue(it) },
modifier = Modifier.focusRequester(focusRequester)
)
else
TextField(
label = { inputLabel?.let { Text(it) } },
value = textValue,
onValueChange = { textValue = it },
singleLine = true,
state = state,
keyboardOptions = KeyboardOptions(
keyboardType = keyboardType,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
onValueEntered(textValue.text)
onKeyboardAction = {
if (confirmEnabled) {
onValueEntered(state.text.toString())
onDismiss()
}
),
},
modifier = Modifier.focusRequester(focusRequester)
)
LaunchedEffect(Unit) {
@@ -97,10 +90,10 @@ fun EditTextInputDialog(
confirmButton = {
Button(
onClick = {
onValueEntered(textValue.text)
onValueEntered(state.text.toString())
onDismiss()
},
enabled = textValue.text != initialValue
enabled = confirmEnabled
) {
Text(stringResource(android.R.string.ok))
}

View File

@@ -10,12 +10,16 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.KeyboardActionHandler
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.TextObfuscationMode
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedSecureTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -25,8 +29,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.text.HtmlCompat
@@ -36,33 +38,28 @@ import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString
@Composable
fun PasswordTextField(
password: String,
password: TextFieldState,
labelText: String?,
onPasswordChange: (String) -> Unit,
modifier: Modifier = Modifier,
leadingIcon: @Composable (() -> Unit)? = null,
keyboardOptions: KeyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
keyboardActions: KeyboardActions = KeyboardActions.Default,
onKeyboardAction: KeyboardActionHandler? = null,
enabled: Boolean = true,
readOnly: Boolean = false,
isError: Boolean = false
) {
var passwordVisible by remember { mutableStateOf(false) }
Column {
OutlinedTextField(
value = password,
onValueChange = onPasswordChange,
OutlinedSecureTextField(
state = password,
label = labelText?.let { { Text(it) } },
leadingIcon = leadingIcon,
isError = isError,
singleLine = true,
enabled = enabled,
readOnly = readOnly,
modifier = modifier.focusGroup(),
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
onKeyboardAction = onKeyboardAction,
textObfuscationMode = if (passwordVisible) TextObfuscationMode.Visible else TextObfuscationMode.RevealLastTyped,
trailingIcon = {
IconButton(
enabled = enabled,
@@ -98,11 +95,10 @@ fun appPasswordHelpUrl(): Uri = ExternalUris.Manual.baseUrl.buildUpon()
@Preview
fun PasswordTextField_Sample() {
PasswordTextField(
password = "",
password = rememberTextFieldState(""),
labelText = "labelText",
enabled = true,
isError = false,
onPasswordChange = {},
)
}
@@ -110,11 +106,10 @@ fun PasswordTextField_Sample() {
@Preview
fun PasswordTextField_Sample_Filled() {
PasswordTextField(
password = "password",
password = rememberTextFieldState("password"),
labelText = "labelText",
enabled = true,
isError = false,
onPasswordChange = {},
)
}
@@ -122,11 +117,10 @@ fun PasswordTextField_Sample_Filled() {
@Preview
fun PasswordTextField_Sample_Error() {
PasswordTextField(
password = "password",
password = rememberTextFieldState("password"),
labelText = "labelText",
enabled = true,
isError = true,
onPasswordChange = {},
)
}
@@ -134,10 +128,9 @@ fun PasswordTextField_Sample_Error() {
@Preview
fun PasswordTextField_Sample_Disabled() {
PasswordTextField(
password = "password",
password = rememberTextFieldState("password"),
labelText = "labelText",
enabled = false,
isError = false,
onPasswordChange = {},
)
}

View File

@@ -11,6 +11,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Folder
@@ -69,7 +71,6 @@ object AdvancedLogin : LoginType {
username = uiState.username,
onSetUsername = model::setUsername,
password = uiState.password,
onSetPassword = model::setPassword,
certAlias = uiState.certAlias,
onSetCertAlias = model::setCertAlias,
canContinue = uiState.canContinue,
@@ -88,8 +89,7 @@ fun AdvancedLoginScreen(
onSetUrl: (String) -> Unit = {},
username: String,
onSetUsername: (String) -> Unit = {},
password: String,
onSetPassword: (String) -> Unit = {},
password: TextFieldState,
certAlias: String,
onSetCertAlias: (String) -> Unit = {},
canContinue: Boolean,
@@ -159,7 +159,6 @@ fun AdvancedLoginScreen(
PasswordTextField(
password = password,
onPasswordChange = onSetPassword,
labelText = stringResource(R.string.login_password_optional),
leadingIcon = {
Icon(Icons.Default.Password, null)
@@ -194,7 +193,7 @@ fun AdvancedLoginScreen_Preview_Empty() {
snackbarHostState = SnackbarHostState(),
url = "",
username = "",
password = "",
password = rememberTextFieldState(""),
certAlias = "",
canContinue = false
)
@@ -207,7 +206,7 @@ fun AdvancedLoginScreen_Preview_AllFilled() {
snackbarHostState = SnackbarHostState(),
url = "dav.example.com",
username = "someuser",
password = "password",
password = rememberTextFieldState("password"),
certAlias = "someCert",
canContinue = true
)

View File

@@ -4,6 +4,7 @@
package at.bitfire.davdroid.ui.setup
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -30,7 +31,7 @@ class AdvancedLoginModel @AssistedInject constructor(
data class UiState(
val url: String = "",
val username: String = "",
val password: String = "",
val password: TextFieldState = TextFieldState(),
val certAlias: String = ""
) {
@@ -47,7 +48,7 @@ class AdvancedLoginModel @AssistedInject constructor(
baseUri = uri,
credentials = Credentials(
username = username.trimToNull(),
password = password.trimToNull()?.toSensitiveString(),
password = password.text.trimToNull()?.toSensitiveString(),
certificateAlias = certAlias.trimToNull()
)
)
@@ -61,7 +62,7 @@ class AdvancedLoginModel @AssistedInject constructor(
uiState = uiState.copy(
url = initialLoginInfo.baseUri?.toString()?.removePrefix("https://") ?: "",
username = initialLoginInfo.credentials?.username ?: "",
password = initialLoginInfo.credentials?.password?.asString() ?: "",
password = TextFieldState(initialLoginInfo.credentials?.password?.asString() ?: ""),
certAlias = initialLoginInfo.credentials?.certificateAlias ?: ""
)
}
@@ -74,10 +75,6 @@ class AdvancedLoginModel @AssistedInject constructor(
uiState = uiState.copy(username = username)
}
fun setPassword(password: String) {
uiState = uiState.copy(password = password)
}
fun setCertAlias(certAlias: String) {
uiState = uiState.copy(certAlias = certAlias)
}

View File

@@ -8,8 +8,9 @@ import android.net.Uri
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Password
@@ -63,7 +64,6 @@ object EmailLogin : LoginType {
email = uiState.email,
onSetEmail = model::setEmail,
password = uiState.password,
onSetPassword = model::setPassword,
canContinue = uiState.canContinue,
onLogin = { onLogin(uiState.asLoginInfo()) }
)
@@ -76,8 +76,7 @@ object EmailLogin : LoginType {
fun EmailLoginScreen(
email: String,
onSetEmail: (String) -> Unit = {},
password: String,
onSetPassword: (String) -> Unit = {},
password: TextFieldState,
canContinue: Boolean,
onLogin: () -> Unit = {}
) {
@@ -129,7 +128,6 @@ fun EmailLoginScreen(
PasswordTextField(
password = password,
onPasswordChange = onSetPassword,
labelText = stringResource(R.string.login_password),
leadingIcon = {
Icon(Icons.Default.Password, null)
@@ -138,8 +136,9 @@ fun EmailLoginScreen(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions {
if (canContinue) onLogin()
onKeyboardAction = {
if (canContinue)
onLogin()
},
modifier = Modifier.fillMaxWidth()
)
@@ -157,7 +156,7 @@ fun EmailLoginScreen(
fun EmailLoginScreen_Preview() {
EmailLoginScreen(
email = "test@example.com",
password = "",
password = rememberTextFieldState(""),
canContinue = false
)
}

View File

@@ -4,6 +4,7 @@
package at.bitfire.davdroid.ui.setup
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -28,18 +29,19 @@ class EmailLoginModel @AssistedInject constructor(
data class UiState(
val email: String = "",
val password: String = ""
val password: TextFieldState = TextFieldState()
) {
val uri = "mailto:$email".toURIorNull()
val canContinue = uri != null && password.isNotEmpty()
val canContinue // we have to use get() because password is not immutable
get() = uri != null && password.text.toString().isNotEmpty()
fun asLoginInfo(): LoginInfo {
return LoginInfo(
baseUri = uri,
credentials = Credentials(
username = email,
password = password.toSensitiveString()
password = password.text.toSensitiveString()
)
)
}
@@ -51,7 +53,7 @@ class EmailLoginModel @AssistedInject constructor(
init {
uiState = uiState.copy(
email = initialLoginInfo.credentials?.username ?: "",
password = initialLoginInfo.credentials?.password?.asString() ?: ""
password = TextFieldState(initialLoginInfo.credentials?.password?.asString() ?: "")
)
}
@@ -59,8 +61,4 @@ class EmailLoginModel @AssistedInject constructor(
uiState = uiState.copy(email = email)
}
fun setPassword(password: String) {
uiState = uiState.copy(password = password)
}
}

View File

@@ -8,8 +8,9 @@ import android.net.Uri
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Folder
@@ -65,7 +66,6 @@ object UrlLogin : LoginType {
username = uiState.username,
onSetUsername = model::setUsername,
password = uiState.password,
onSetPassword = model::setPassword,
canContinue = uiState.canContinue,
onLogin = {
if (uiState.canContinue)
@@ -82,8 +82,7 @@ fun UrlLoginScreen(
onSetUrl: (String) -> Unit = {},
username: String,
onSetUsername: (String) -> Unit = {},
password: String,
onSetPassword: (String) -> Unit = {},
password: TextFieldState,
canContinue: Boolean,
onLogin: () -> Unit = {}
) {
@@ -151,7 +150,6 @@ fun UrlLoginScreen(
PasswordTextField(
password = password,
onPasswordChange = onSetPassword,
labelText = stringResource(R.string.login_password),
leadingIcon = {
Icon(Icons.Default.Password, null)
@@ -160,8 +158,9 @@ fun UrlLoginScreen(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions {
if (canContinue) onLogin()
onKeyboardAction = {
if (canContinue)
onLogin()
},
modifier = Modifier.fillMaxWidth()
)
@@ -179,7 +178,7 @@ fun UrlLoginScreen_Preview() {
UrlLoginScreen(
url = "https://example.com",
username = "user",
password = "",
password = rememberTextFieldState(""),
canContinue = false
)
}

View File

@@ -4,6 +4,7 @@
package at.bitfire.davdroid.ui.setup
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -30,7 +31,7 @@ class UrlLoginModel @AssistedInject constructor(
data class UiState(
val url: String = "",
val username: String = "",
val password: String = ""
val password: TextFieldState = TextFieldState()
) {
val urlWithPrefix =
@@ -40,14 +41,15 @@ class UrlLoginModel @AssistedInject constructor(
"https://$url"
val uri = urlWithPrefix.trim().toURIorNull()
val canContinue = uri != null && username.isNotEmpty() && password.isNotEmpty()
val canContinue // we have to use get() because password is not immutable
get() = uri != null && username.isNotEmpty() && password.text.toString().isNotEmpty()
fun asLoginInfo(): LoginInfo =
LoginInfo(
baseUri = uri,
credentials = Credentials(
username = username.trimToNull(),
password = password.trimToNull()?.toSensitiveString()
password = password.text.toString().trimToNull()?.toSensitiveString()
)
)
@@ -60,7 +62,7 @@ class UrlLoginModel @AssistedInject constructor(
uiState = UiState(
url = initialLoginInfo.baseUri?.toString()?.removePrefix("https://") ?: "",
username = initialLoginInfo.credentials?.username ?: "",
password = initialLoginInfo.credentials?.password?.asString() ?: ""
password = TextFieldState(initialLoginInfo.credentials?.password?.asString() ?: "")
)
}
@@ -72,8 +74,4 @@ class UrlLoginModel @AssistedInject constructor(
uiState = uiState.copy(username = username)
}
fun setPassword(password: String) {
uiState = uiState.copy(password = password)
}
}

View File

@@ -5,6 +5,7 @@
package at.bitfire.davdroid.ui.webdav
import android.content.Context
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -36,7 +37,7 @@ class AddWebdavMountModel @Inject constructor(
val displayName: String = "",
val url: String = "",
val username: String = "",
val password: String = "",
val password: TextFieldState = TextFieldState(),
val certificateAlias: String? = null
) {
val urlWithPrefix =
@@ -67,10 +68,6 @@ class AddWebdavMountModel @Inject constructor(
uiState = uiState.copy(username = username)
}
fun setPassword(password: String) {
uiState = uiState.copy(password = password)
}
fun setCertificateAlias(certAlias: String) {
uiState = uiState.copy(certificateAlias = certAlias)
}
@@ -85,7 +82,7 @@ class AddWebdavMountModel @Inject constructor(
val displayName = uiState.displayName
val credentials = Credentials(
username = uiState.username.trimToNull(),
password = uiState.password.trimToNull()?.toSensitiveString(),
password = uiState.password.text.trimToNull()?.toSensitiveString(),
certificateAlias = uiState.certificateAlias
)

View File

@@ -7,8 +7,9 @@ package at.bitfire.davdroid.ui.webdav
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Help
@@ -70,7 +71,6 @@ fun AddWebdavMountScreen(
username = uiState.username,
onSetUsername = model::setUsername,
password = uiState.password,
onSetPassword = model::setPassword,
certificateAlias = uiState.certificateAlias,
onSetCertificateAlias = model::setCertificateAlias,
canContinue = uiState.canContinue,
@@ -92,8 +92,7 @@ fun AddWebDavMountScreen(
onSetUrl: (String) -> Unit = {},
username: String,
onSetUsername: (String) -> Unit = {},
password: String,
onSetPassword: (String) -> Unit = {},
password: TextFieldState,
certificateAlias: String?,
onSetCertificateAlias: (String) -> Unit = {},
canContinue: Boolean,
@@ -163,7 +162,7 @@ fun AddWebDavMountScreen(
value = url,
onValueChange = onSetUrl,
singleLine = true,
readOnly = isLoading,
enabled = !isLoading,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next,
keyboardType = KeyboardType.Uri
@@ -185,7 +184,7 @@ fun AddWebDavMountScreen(
leadingIcon = {
Icon(Icons.Default.Sell, null)
},
readOnly = isLoading,
enabled = !isLoading,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
modifier = Modifier
.fillMaxWidth()
@@ -207,7 +206,7 @@ fun AddWebDavMountScreen(
leadingIcon = {
Icon(Icons.Default.AccountCircle, null)
},
readOnly = isLoading,
enabled = !isLoading,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next,
keyboardType = KeyboardType.Email
@@ -218,18 +217,19 @@ fun AddWebDavMountScreen(
)
PasswordTextField(
password = password,
onPasswordChange = onSetPassword,
labelText = stringResource(R.string.login_password_optional),
readOnly = isLoading,
enabled = !isLoading,
leadingIcon = {
Icon(Icons.Default.Password, null)
},
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { onAddMount() }
),
onKeyboardAction = {
// can only be called when not loading
if (canContinue)
onAddMount()
},
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
@@ -258,7 +258,7 @@ fun AddWebDavMountScreen_Preview() {
displayName = "Test",
url = "https://example.com",
username = "user",
password = "password",
password = rememberTextFieldState("password"),
certificateAlias = null,
canContinue = true
)

View File

@@ -61,8 +61,8 @@ class SensitiveString private constructor(
fun CharArray.toSensitiveString() =
SensitiveString(this.concatToString())
fun String.toSensitiveString() =
SensitiveString(this)
fun CharSequence.toSensitiveString() =
SensitiveString(this.toString())
}

View File

@@ -4,10 +4,9 @@
package at.bitfire.davdroid.util
import com.google.common.base.Joiner
import com.google.common.base.Strings
fun String?.trimToNull() = Strings.emptyToNull(this?.trim())
fun CharSequence?.trimToNull() = Strings.emptyToNull(this?.trim()?.toString())
fun String.withTrailingSlash() =
if (this.endsWith('/'))