ba9c7c76f6c9

feat: Add Autofill save prompt handling (v2.3.38)

- Attach native SaveInfo to Autofill responses and implement onSaveRequest - Add/update logins only when unlocked and strict origin checks pass - Capture submitted field values and prefer email identity fields as intended - Cover add/update/lookalike/native-package save planning with unit tests - Verified with focused Autofill tests and full testDebugUnitTest/assembleDebug/lintDebug gate

time
2026-06-26 13:24 UTC · 4 months ago
machine
koh2
parent
954dad0e1f77
5 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/autofill/AssistStructureParser.kt+11 −5app/src/main/java/com/peachpasswords/autofill/AutofillSave.kt+83 −0app/src/main/java/com/peachpasswords/autofill/PeachAutofillService.kt+59 −4app/src/test/java/com/peachpasswords/autofill/AutofillSaveTest.kt+110 −0
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 23370020-        versionName "2.3.37"19+        versionCode 23380020+        versionName "2.3.38"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/autofill/AssistStructureParser.kt+11 −5
@@ -14,11 +14,15 @@1414     val emailId: AutofillId? = null,1515     val passwordId: AutofillId? = null,1616     val otpId: AutofillId? = null,17+    val usernameValue: String? = null,18+    val emailValue: String? = null,19+    val passwordValue: String? = null,20+    val otpValue: String? = null,1721     val webDomain: String? = null,1822     val packageName: String? = null,1923 ) {2024     /** The id we put the account identifier into — email preferred, else username. */21-    val identityId: AutofillId? get() = usernameId ?: emailId25+    val identityId: AutofillId? get() = emailId ?: usernameId2226     val hasUsableField: Boolean get() = passwordId != null || identityId != null || otpId != null2327     /** Best label to rank entries against. */2428     val origin: String? get() = webDomain ?: packageName@@ -53,11 +57,13 @@5357 5458         val id = node.autofillId5559         if (id != null && isFillable(node)) {60+            val value = node.autofillValue?.takeIf { it.isText }?.textValue?.toString()61+                ?: node.text?.toString()5662             result = when (classify(node)) {57-                FieldKind.PASSWORD -> if (result.passwordId == null) result.copy(passwordId = id) else result58-                FieldKind.USERNAME -> if (result.usernameId == null) result.copy(usernameId = id) else result59-                FieldKind.EMAIL -> if (result.emailId == null) result.copy(emailId = id) else result60-                FieldKind.OTP -> if (result.otpId == null) result.copy(otpId = id) else result63+                FieldKind.PASSWORD -> if (result.passwordId == null) result.copy(passwordId = id, passwordValue = value) else result64+                FieldKind.USERNAME -> if (result.usernameId == null) result.copy(usernameId = id, usernameValue = value) else result65+                FieldKind.EMAIL -> if (result.emailId == null) result.copy(emailId = id, emailValue = value) else result66+                FieldKind.OTP -> if (result.otpId == null) result.copy(otpId = id, otpValue = value) else result6167                 FieldKind.NONE -> result6268             }6369         }

view file

app/src/main/java/com/peachpasswords/autofill/AutofillSave.kt+83 −0
@@ -0,0 +1,83 @@1+package com.peachpasswords.autofill2+3+import com.peachpasswords.shared.getDisplayDomain4+import com.peachpasswords.vault.VaultEntry5+import com.peachpasswords.vault.isLogin6+7+internal data class AutofillSaveDraft(8+    val origin: String,9+    val isWebOrigin: Boolean,10+    val identity: String?,11+    val identityIsEmail: Boolean,12+    val password: String,13+    val totp: String?,14+)15+16+internal sealed interface AutofillSavePlan {17+    data class Add(val entry: VaultEntry) : AutofillSavePlan18+    data class Update(val entry: VaultEntry) : AutofillSavePlan19+}20+21+internal fun autofillSaveDraft(parsed: ParsedStructure): AutofillSaveDraft? {22+    val password = parsed.passwordValue?.trim().orEmpty()23+    if (password.isBlank()) return null24+    val origin = parsed.origin?.trim().orEmpty()25+    if (origin.isBlank()) return null26+    val identity = (parsed.emailValue ?: parsed.usernameValue)?.trim()?.takeIf { it.isNotBlank() }27+    return AutofillSaveDraft(28+        origin = origin,29+        isWebOrigin = parsed.webDomain != null,30+        identity = identity,31+        identityIsEmail = parsed.emailValue?.isNotBlank() == true || identity?.contains("@") == true,32+        password = password,33+        totp = parsed.otpValue?.trim()?.takeIf { it.isNotBlank() },34+    )35+}36+37+internal fun planAutofillSave(entries: List<VaultEntry>, draft: AutofillSaveDraft): AutofillSavePlan {38+    val target = findAutofillUpdateTarget(entries, draft)39+    return if (target == null) {40+        AutofillSavePlan.Add(draft.toEntry())41+    } else {42+        AutofillSavePlan.Update(43+            target.copy(44+                username = if (!draft.identityIsEmail) draft.identity ?: target.username else target.username,45+                email = if (draft.identityIsEmail) draft.identity ?: target.email else target.email,46+                password = draft.password,47+                totp = draft.totp ?: target.totp,48+            ),49+        )50+    }51+}52+53+private fun findAutofillUpdateTarget(entries: List<VaultEntry>, draft: AutofillSaveDraft): VaultEntry? {54+    val candidates = if (draft.isWebOrigin) {55+        AutofillResponseBuilder.matchEntries(entries, draft.origin).hits56+    } else {57+        entries.filter { it.url == draft.origin }58+    }.filter { it.deletedAt == null && it.isLogin() }59+60+    val identity = draft.identity?.lowercase()61+    if (identity != null) {62+        candidates.firstOrNull { entry ->63+            entry.username?.lowercase() == identity || entry.email?.lowercase() == identity64+        }?.let { return it }65+    }66+    return candidates.singleOrNull()67+}68+69+private fun AutofillSaveDraft.toEntry(): VaultEntry {70+    val title = if (isWebOrigin) getDisplayDomain(origin) else origin71+    return VaultEntry(72+        id = "",73+        type = "login",74+        title = title,75+        username = if (!identityIsEmail) identity else null,76+        email = if (identityIsEmail) identity else null,77+        password = password,78+        url = if (isWebOrigin && !origin.contains("://")) "https://$origin" else origin,79+        totp = totp,80+        createdAt = 0,81+        modifiedAt = 0,82+    )83+}

view file

app/src/main/java/com/peachpasswords/autofill/PeachAutofillService.kt+59 −4
@@ -7,9 +7,16 @@77 import android.service.autofill.FillRequest88 import android.service.autofill.FillResponse99 import android.service.autofill.SaveCallback10+import android.service.autofill.SaveInfo1011 import android.service.autofill.SaveRequest1112 import com.peachpasswords.vault.VaultRepository1213 import dagger.hilt.android.AndroidEntryPoint14+import kotlinx.coroutines.CoroutineScope15+import kotlinx.coroutines.Dispatchers16+import kotlinx.coroutines.SupervisorJob17+import kotlinx.coroutines.cancel18+import kotlinx.coroutines.launch19+import kotlinx.coroutines.withContext1320 import javax.inject.Inject1421 1522 /**@@ -19,13 +26,14 @@1926  *2027  * The vault is a process singleton, so when the app has the vault unlocked the2128  * service fills directly; when locked it offers only the picker, which routes22- * the user to unlock. The save flow is intentionally a no-op for now — Peach23- * adds entries through its own UI, not the system Save dialog.29+ * the user to unlock. Native save requests add or update login entries only30+ * when the vault is already unlocked and the same strict origin policy passes.2431  */2532 @AndroidEntryPoint2633 class PeachAutofillService : AutofillService() {2734 2835     @Inject lateinit var vault: VaultRepository36+    private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)2937 3038     override fun onFillRequest(3139         request: FillRequest,@@ -77,6 +85,7 @@7785             callback.onSuccess(null)7886         } else {7987             ignoreUnfilledIds(response, parsed)88+            addSaveInfo(response, parsed)8089             callback.onSuccess(response.build())8190         }8291     }@@ -104,6 +113,18 @@104113         if (ids.isNotEmpty()) builder.setIgnoredIds(*ids.toTypedArray())105114     }106115 116+    private fun addSaveInfo(builder: FillResponse.Builder, parsed: ParsedStructure) {117+        val passwordId = parsed.passwordId ?: return118+        val optionalIds = listOfNotNull(parsed.identityId, parsed.otpId).toTypedArray()119+        val saveInfo = SaveInfo.Builder(120+            SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD,121+            arrayOf(passwordId),122+        ).apply {123+            if (optionalIds.isNotEmpty()) setOptionalIds(optionalIds)124+        }.build()125+        builder.setSaveInfo(saveInfo)126+    }127+107128     private fun latestStructure(request: FillRequest): android.app.assist.AssistStructure? =108129         request.fillContexts.lastOrNull()?.structure109130 @@ -113,8 +134,42 @@113134     }114135 115136     override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {116-        // Peach captures new logins through its own add-entry flow.117-        callback.onSuccess()137+        val structure = request.fillContexts.lastOrNull()?.structure138+        if (structure == null || !vault.isUnlocked.value) {139+            callback.onSuccess()140+            return141+        }142+        val parsed = AssistStructureParser.parse(structure)143+        if (parsed.webDomain != null) {144+            val clientPackage = structure.activityComponent?.packageName ?: parsed.packageName145+            if (!TrustedBrowsers.isTrusted(clientPackage)) {146+                callback.onSuccess()147+                return148+            }149+        }150+        val draft = autofillSaveDraft(parsed)151+        if (draft == null) {152+            callback.onSuccess()153+            return154+        }155+        serviceScope.launch {156+            try {157+                withContext(Dispatchers.IO) {158+                    when (val plan = planAutofillSave(vault.getEntries(), draft)) {159+                        is AutofillSavePlan.Add -> vault.addEntry(plan.entry)160+                        is AutofillSavePlan.Update -> vault.updateEntry(plan.entry)161+                    }162+                }163+                callback.onSuccess()164+            } catch (e: Exception) {165+                callback.onFailure(e.message ?: "Peach could not save this login")166+            }167+        }168+    }169+170+    override fun onDestroy() {171+        serviceScope.cancel()172+        super.onDestroy()118173     }119174 120175     private companion object {

view file

app/src/test/java/com/peachpasswords/autofill/AutofillSaveTest.kt+110 −0
@@ -0,0 +1,110 @@1+package com.peachpasswords.autofill2+3+import com.peachpasswords.vault.VaultEntry4+import org.junit.Assert.assertEquals5+import org.junit.Assert.assertNull6+import org.junit.Assert.assertTrue7+import org.junit.Test8+9+class AutofillSaveTest {10+11+    private fun entry(12+        title: String,13+        url: String,14+        username: String? = "june",15+        password: String = "old",16+    ) = VaultEntry(17+        id = title,18+        type = "login",19+        title = title,20+        username = username,21+        password = password,22+        url = url,23+        createdAt = 1,24+        modifiedAt = 1,25+    )26+27+    @Test28+    fun autofillSaveDraft_requiresOriginAndPassword() {29+        assertNull(autofillSaveDraft(ParsedStructure(webDomain = "example.com")))30+        assertNull(autofillSaveDraft(ParsedStructure(passwordValue = "pw")))31+    }32+33+    @Test34+    fun planAutofillSave_addsNewWebLogin() {35+        val draft = autofillSaveDraft(36+            ParsedStructure(37+                emailValue = "[email protected]",38+                passwordValue = "new-secret",39+                webDomain = "login.example.com",40+            ),41+        )!!42+43+        val plan = planAutofillSave(emptyList(), draft)44+45+        assertTrue(plan is AutofillSavePlan.Add)46+        val entry = (plan as AutofillSavePlan.Add).entry47+        assertEquals("login.example.com", entry.title)48+        assertEquals("[email protected]", entry.email)49+        assertEquals("new-secret", entry.password)50+        assertEquals("https://login.example.com", entry.url)51+    }52+53+    @Test54+    fun planAutofillSave_updatesMatchingWebOriginAndIdentity() {55+        val existing = entry("Example", "https://example.com/login", username = "june")56+        val draft = AutofillSaveDraft(57+            origin = "app.example.com",58+            isWebOrigin = true,59+            identity = "june",60+            identityIsEmail = false,61+            password = "new",62+            totp = null,63+        )64+65+        val plan = planAutofillSave(listOf(existing), draft)66+67+        assertTrue(plan is AutofillSavePlan.Update)68+        val updated = (plan as AutofillSavePlan.Update).entry69+        assertEquals(existing.id, updated.id)70+        assertEquals("new", updated.password)71+        assertEquals("june", updated.username)72+    }73+74+    @Test75+    fun planAutofillSave_doesNotUpdateSubstringLookalikeOrigin() {76+        val existing = entry("GitHub", "https://github.com/login", username = "june")77+        val draft = AutofillSaveDraft(78+            origin = "notgithub.com",79+            isWebOrigin = true,80+            identity = "june",81+            identityIsEmail = false,82+            password = "phished",83+            totp = null,84+        )85+86+        val plan = planAutofillSave(listOf(existing), draft)87+88+        assertTrue(plan is AutofillSavePlan.Add)89+    }90+91+    @Test92+    fun planAutofillSave_updatesNativePackageOnlyOnExactOrigin() {93+        val existing = entry("Native", "com.example.app", username = "june")94+        val draft = AutofillSaveDraft(95+            origin = "com.example.app",96+            isWebOrigin = false,97+            identity = "june",98+            identityIsEmail = false,99+            password = "new-native",100+            totp = "123456",101+        )102+103+        val plan = planAutofillSave(listOf(existing), draft)104+105+        assertTrue(plan is AutofillSavePlan.Update)106+        val updated = (plan as AutofillSavePlan.Update).entry107+        assertEquals("new-native", updated.password)108+        assertEquals("123456", updated.totp)109+    }110+}

view file