From 7d8a17438843f2713680ec9960cc244f2383e63c Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:57:12 +0000 Subject: [PATCH] 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. --- .../common/platform/Modifier.android.kt | 3 + .../chat/simplex/common/platform/Modifier.kt | 3 + .../views/database/DatabaseEncryptionView.kt | 14 ++++- .../onboarding/SetupDatabasePassphrase.kt | 3 + .../views/usersettings/HiddenProfileView.kt | 4 +- .../views/usersettings/UserProfilesView.kt | 2 +- .../common/platform/Modifier.desktop.kt | 3 + plans/2026-09-20-passphrase-autofill.md | 56 +++++++++++++++++++ 8 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 plans/2026-09-20-passphrase-autofill.md diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt index 5d07aae088..a4f931058c 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt @@ -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 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt index caaf66811c..88d4709dfa 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt @@ -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 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt index ac64c93c73..8f22515370 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt @@ -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? = 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() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt index 0d00c20591..f198fe1f7a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt @@ -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)) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt index a14dab3f1a..8e96bec184 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt @@ -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) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt index dd1e208349..e91d6c2ce0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt @@ -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) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt index b090e301d5..894f31a395 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt @@ -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 diff --git a/plans/2026-09-20-passphrase-autofill.md b/plans/2026-09-20-passphrase-autofill.md new file mode 100644 index 0000000000..a763a444e6 --- /dev/null +++ b/plans/2026-09-20-passphrase-autofill.md @@ -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.