android: recognise passphrase fields as passwords for password managers (#7612)

* android: recognise passphrase fields as passwords for password managers

Passphrase fields used a hand-rolled VisualTransformation lambda, so Compose never
marked them as password fields: the autofill structure carried no hints and no
password inputType, and password managers could not identify them. The raw
passphrase was also exposed through the CopyText/ACTION_COPY accessibility action.

PassphraseField now declares an autofill content type and uses
PasswordVisualTransformation, which sets the password semantics Compose derives
that flag from.

Fields that create a passphrase declare NewPassword. Where no field would carry
plain password (first-time setup, hidden profile password) they declare both, as
managers do not recognise a form whose every field is newPassword. On the change
form the current-passphrase field supplies password, so the new fields declare
only newPassword - adding password there leaves the manager unable to tell which
field holds the new value and suppresses the save prompt.

* android: do not offer to save hidden profile passwords

Hidden profile password fields no longer declare an autofill content type.
Compose commits the autofill session, which shows the password manager's
save prompt, only after the last field with a content type leaves the
screen, so these fields never trigger it. A saved entry would reveal that
the hidden profile exists.

Rewrite the plan to match the code.
This commit is contained in:
Narasimha-sc
2026-10-10 21:57:12 +01:00
committed by GitHub
parent afcaf22100
commit 7d8a174388
8 changed files with 83 additions and 5 deletions
@@ -2,6 +2,7 @@ package chat.simplex.common.platform
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.graphics.painter.Painter
import java.io.File
@@ -18,3 +19,5 @@ actual fun Modifier.onRightClick(action: () -> Unit): Modifier = this
actual fun Modifier.desktopPointerHoverIconHand(): Modifier = this
actual fun Modifier.desktopOnHovered(action: (Boolean) -> Unit): Modifier = Modifier
actual fun newPasswordContentType(): ContentType = ContentType.NewPassword + ContentType.Password
@@ -3,6 +3,7 @@ package chat.simplex.common.platform
import androidx.compose.foundation.combinedClickable
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.ImageBitmap
@@ -127,3 +128,5 @@ private fun ImageBitmap.blurredBy(radius: Int, drawnWidthDp: Float = CHAT_MEDIA_
while (maxOf(image.width, image.height) < BLURRED_MEDIA_WIDTH_DP / 2) image = image.scale(image.width * 2, image.height * 2)
return image
}
expect fun newPasswordContentType(): ContentType
@@ -25,6 +25,9 @@ import androidx.compose.ui.text.input.*
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.semantics.contentType
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
@@ -141,7 +144,10 @@ fun DatabaseEncryptionLayout(
}
}
if (!initialRandomDBPassphrase.value && chatDbEncrypted == true) {
val hasCurrentField = !initialRandomDBPassphrase.value && chatDbEncrypted == true
val newKeyContentType =
if (hasCurrentField) ContentType.NewPassword else newPasswordContentType()
if (hasCurrentField) {
PassphraseField(
currentKey,
generalGetString(MR.strings.current_passphrase),
@@ -158,6 +164,7 @@ fun DatabaseEncryptionLayout(
showStrength = true,
isValid = ::validKey,
keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }),
contentType = newKeyContentType,
)
val onClickUpdate = {
// Don't do things concurrently. Shouldn't be here concurrently, just in case
@@ -187,6 +194,7 @@ fun DatabaseEncryptionLayout(
generalGetString(MR.strings.confirm_new_passphrase),
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
isValid = { confirmNewKey.value == "" || newKey.value == confirmNewKey.value },
contentType = newKeyContentType,
keyboardActions = KeyboardActions(onDone = {
if (!disabled) onClickUpdate()
defaultKeyboardAction(ImeAction.Done)
@@ -323,6 +331,7 @@ fun PassphraseField(
keyboardActions: KeyboardActions = KeyboardActions(),
dependsOn: State<Any?>? = null,
requestFocus: Boolean = false,
contentType: ContentType? = ContentType.Password,
) {
var valid by remember { mutableStateOf(validKey(key.value)) }
var showKey by remember { mutableStateOf(false) }
@@ -362,6 +371,7 @@ fun PassphraseField(
minWidth = TextFieldDefaults.MinWidth,
minHeight = TextFieldDefaults.MinHeight
)
.semantics { if (contentType != null) this.contentType = contentType }
.focusRequester(focusRequester),
onValueChange = {
state.value = it
@@ -372,7 +382,7 @@ fun PassphraseField(
visualTransformation = if (showKey)
VisualTransformation.None
else
VisualTransformation { TransformedText(AnnotatedString(it.text.map { "*" }.joinToString(separator = "")), OffsetMapping.Identity) },
PasswordVisualTransformation('*'),
keyboardOptions = keyboardOptions,
keyboardActions = KeyboardActions(onDone = {
keyboard?.hide()
@@ -7,6 +7,7 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.focus.*
import androidx.compose.ui.input.key.*
import androidx.compose.ui.platform.LocalFocusManager
@@ -152,6 +153,7 @@ private fun SetupDatabasePassphraseLayout(
showStrength = true,
isValid = ::validKey,
keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }),
contentType = newPasswordContentType(),
)
PassphraseField(
@@ -169,6 +171,7 @@ private fun SetupDatabasePassphraseLayout(
},
isValid = { confirmNewKey.value == "" || newKey.value == confirmNewKey.value },
keyboardActions = KeyboardActions(onDone = { defaultKeyboardAction(ImeAction.Done) }),
contentType = newPasswordContentType(),
)
}
Spacer(Modifier.weight(1f))
@@ -70,10 +70,10 @@ private fun HiddenProfileLayout(
val saveDisabled by remember { derivedStateOf { hidePassword.value == "" || !passwordValid || confirmHidePassword.value == "" || !confirmValid } }
SectionView(stringResource(MR.strings.hidden_profile_password)) {
SectionItemViewWithoutMinPadding {
PassphraseField(hidePassword, generalGetString(MR.strings.password_to_show), isValid = { passwordValid }, showStrength = true)
PassphraseField(hidePassword, generalGetString(MR.strings.password_to_show), isValid = { passwordValid }, showStrength = true, contentType = null)
}
SectionItemViewWithoutMinPadding {
PassphraseField(confirmHidePassword, stringResource(MR.strings.confirm_password), isValid = { confirmValid }, dependsOn = hidePassword)
PassphraseField(confirmHidePassword, stringResource(MR.strings.confirm_password), isValid = { confirmValid }, dependsOn = hidePassword, contentType = null)
}
SectionItemViewSpaceBetween({ saveProfilePassword(hidePassword.value) }, disabled = saveDisabled, minHeight = TextFieldDefaults.MinHeight) {
Text(generalGetString(MR.strings.save_profile_password), color = if (saveDisabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
@@ -277,7 +277,7 @@ private fun ProfileActionView(action: UserProfileAction, user: User, doAction: (
@Composable fun PasswordAndAction(label: StringResource, color: Color = MaterialTheme.colors.primary) {
SectionView() {
SectionItemViewWithoutMinPadding {
PassphraseField(actionPassword, generalGetString(MR.strings.profile_password), isValid = { passwordValid }, showStrength = true)
PassphraseField(actionPassword, generalGetString(MR.strings.profile_password), isValid = { passwordValid }, showStrength = true, contentType = null)
}
SectionItemViewSpaceBetween({ doAction(actionPassword.value) }, disabled = !actionEnabled, minHeight = TextFieldDefaults.MinHeight) {
Text(generalGetString(label), color = if (actionEnabled) color else MaterialTheme.colors.secondary)
@@ -5,6 +5,7 @@ import androidx.compose.foundation.draganddrop.dragAndDropTarget
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.*
import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.draganddrop.*
import androidx.compose.ui.draganddrop.DragData
import androidx.compose.ui.input.pointer.*
@@ -85,3 +86,5 @@ actual fun Modifier.desktopOnHovered(action: (Boolean) -> Unit): Modifier =
this then Modifier
.onPointerEvent(PointerEventType.Enter) { action(true) }
.onPointerEvent(PointerEventType.Exit) { action(false) }
actual fun newPasswordContentType(): ContentType = ContentType.NewPassword
+56
View File
@@ -0,0 +1,56 @@
# Passphrase fields not recognised by password managers (Android)
PR #7612, branch `nd/passphrase-autofill`.
## Problem
> The passphase isn't detected as a password box for any password manager so it refuses to autofill.
`PassphraseField` (`DatabaseEncryptionView.kt`) was never classified as a password, so no manager offered to fill or save the database passphrase. While verifying this, a second defect turned up: the raw passphrase could be copied to the clipboard from the selection toolbar and through accessibility `ACTION_COPY`.
## Root cause (Compose 1.8.2, from bytecode)
- Semantic autofill is enabled (`ComposeUiFlags.isSemanticAutofillEnabled = true`). `PopulateViewStructure.populate` builds each node from semantics. It sets autofill hints only from `SemanticsProperties.ContentType`, and `inputType = 0x81` plus `dataIsSensitive` only from `password()` or a `"password"` hint.
- The legacy `BasicTextField` never sets `ContentType`. It sets `password()` only when `visualTransformation is PasswordVisualTransformation`. The hand-rolled `VisualTransformation { … }` lambda fails that check, so the field reached managers with no hints and no password `inputType`.
- The same `is PasswordVisualTransformation` check gates Copy/Cut in the selection toolbar and the `CopyText`/`CutText` accessibility actions. Both copy `value.getSelectedText()`, which is the raw passphrase.
## Fix
`PassphraseField`:
- uses `PasswordVisualTransformation('*')`. The mask looks the same, and it restores `password()` semantics and the copy/cut gates.
- takes `contentType: ContentType? = ContentType.Password` and applies it with `.semantics { if (contentType != null) this.contentType = contentType }`.
`newPasswordContentType()` (expect/actual in `platform/Modifier*.kt`) returns `NewPassword + Password` on Android and `NewPassword` on desktop, where `ContentType` has no `plus`. Desktop has no OS autofill, so it is inert there.
| Screen | Current | New | Confirm |
| --- | --- | --- | --- |
| Change passphrase (`DatabaseEncryptionView`, current field shown) | `password` | `newPassword` | `newPassword` |
| First passphrase (`DatabaseEncryptionView` with no current field, `SetupDatabasePassphrase`) | — | `newPassword`+`password` | `newPassword`+`password` |
| Unlock / migrate (`DatabaseErrorView`, `MigrateToDevice`, `MigrateFromDevice`) | `password` | — | — |
| Hidden profile password (`HiddenProfileView`), unhide/delete (`UserProfilesView`) | none | none | none |
Why the hints vary:
- **First passphrase:** KeePassDX does not recognise a form whose every field is `newPassword`, so these fields also carry `password`.
- **Change form:** adding `password` to the new fields here gives three identical `password` fields, and the manager stops offering to save.
- **Hidden profile:** these fields carry no `ContentType`, so no password manager offers to save them. A stored entry would reveal that the hidden profile exists. `AndroidAutofillManager.onEndApplyChanges` commits the session, which raises the save prompt, only after the last node carrying `ContentType` leaves the screen (`isRelatedToAutoCommit` = has `ContentType`), so leaving these screens never commits. Compose 1.8.2 cannot mark a node not important for autofill (`populate` never calls `setImportantForAutofill`). The fields still carry `password()`, so a manager may still offer to fill them.
The app never calls `AutofillManager.commit()`. Committing on submit was tried and reverted: it ended the session while the fields were still shown, and autofill stayed dead until the app restarted.
## Verification
- **Semantics** (desktop Compose test `~/passphrase-review/semtest`, a two-field A/B): with the fix the node has `ContentType`, `Password = true`, and no `CopyText`. Without the fix it has no `ContentType`, `Password = false`, and `CopyText` present. `EditableText` is masked either way; autofill never received the raw value.
- **Device, structure** (stub `AutofillService` dumping `AssistStructure`, `~/passphrase-review/harness`, on an earlier `newPassword`-only build): the passphrase fields arrived as `EditText` with `inputType=0x81` and their hints.
- **Device, KeePassDX:** the hint combinations in the table were settled by testing with KeePassDX. It fills and offers to save on the change and first-passphrase forms, and the save prompt appears when the screen is closed.
- **Not yet checked on a device:** the hidden-profile screens (no `ContentType`) showing no save prompt.
## Limitations
- KeePassDX offers to fill only the last password field in the form (Confirm rather than New). New and Confirm emit identical hints and `inputType`, so this has to be fixed in KeePassDX.
- The framework still commits when the activity finishes (`AutofillManager.onActivityFinishing`) unless the service set `FLAG_DONT_SAVE_ON_FINISH`. A session opened by focusing a hidden-profile field could therefore still prompt to save if the activity finishes before that session ends.
- With a hardware keyboard or mouse, Ctrl+C and the Android right-click menu still copy, because those paths don't check the transformation (an upstream Compose gap). Only `BasicSecureTextField` blocks copying outright.
- The Compose text-toolbar "Autofill" item does nothing for the legacy `BasicTextField`, because `requestAutofillAction` is never assigned. Also upstream.
## Out of scope
- `DefaultConfigurableTextField` (`DefaultBasicTextField.kt:163–166`), which backs the SOCKS proxy password (`NetworkAndServers.kt:525`), has the same lambda and no `ContentType`. It has the same clipboard exposure, and the same two-line fix applies.
- iOS `PassphraseField` (`DatabaseEncryptionView.swift:304`) is a `SecureField` without `.textContentType`, which is the same gap.