ba9c7c76f6c9
feat: Add Autofill save prompt handling (v2.3.38)
- time
- 2026-06-26 13:24 UTC · 4 months ago
- machine
- koh2
- parent
- 954dad0e1f77
↓ peach-android-ba9c7c76.tar.gzatlas at this save
files changed5 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 −0app/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 {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 }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+}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 {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+}