mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-12 15:29:04 +00:00
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:
+3
@@ -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
@@ -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
|
||||
|
||||
+12
-2
@@ -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()
|
||||
|
||||
+3
@@ -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))
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user