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.