7a7563cafa18

feat: card autofill — detect card forms and fill from vault card entries (v2.10.0)

Browser parity gap A1 (HIGH). Android stored cards but never autofilled them. - AssistStructureParser: detect card number/exp month/year/date/CVC/cardholder name via AUTOFILL_HINT_CREDIT_CARD_* hints + keyword heuristics - AutofillResponseBuilder.cardDatasetFor(): build card datasets, strip non-digit PAN chars, format combo expiry as MM/YY - PeachAutofillService: offer all vault cards on any detected card form (cards are site-agnostic, no domain matching) - Picker path carries card field IDs and routes card entries through cardDatasetFor - New AutofillCardTest covers entry type, domain non-matching, expiry formatting, digit stripping, and heuristic regexes

time
2026-08-02 16:21 UTC · 2 months ago
machine
koh2
parent
a7c248b9b68d
7 files VERSION+1 −1app/src/main/java/com/peachpasswords/autofill/AssistStructureParser.kt+35 −3app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt+24 −1app/src/main/java/com/peachpasswords/autofill/AutofillIntents.kt+13 −0app/src/main/java/com/peachpasswords/autofill/AutofillResponseBuilder.kt+46 −0app/src/main/java/com/peachpasswords/autofill/PeachAutofillService.kt+21 −2app/src/test/java/com/peachpasswords/autofill/AutofillCardTest.kt+98 −0
VERSION+1 −1
@@ -1 +1 @@1-2.9.351+2.10.0

view file

app/src/main/java/com/peachpasswords/autofill/AssistStructureParser.kt+35 −3
@@ -24,16 +24,24 @@2424     val packageName: String? = null,2525     val originTrust: AutofillOriginTrust = AutofillOriginTrust.UNKNOWN,2626     val verifiedDomains: List<String> = emptyList(),27+    val cardNumberId: AutofillId? = null,28+    val cardExpMonthId: AutofillId? = null,29+    val cardExpYearId: AutofillId? = null,30+    val cardExpDateId: AutofillId? = null,31+    val cardCvcId: AutofillId? = null,32+    val cardNameId: AutofillId? = null,2733 ) {2834     /** The id we put the account identifier into — email preferred, else username. */2935     val identityId: AutofillId? get() = emailId ?: usernameId30-    val hasUsableField: Boolean get() = passwordId != null || identityId != null || otpId != null36+    val hasUsableField: Boolean get() = passwordId != null || identityId != null || otpId != null || hasCardField37+    val hasCardField: Boolean get() = cardNumberId != null || cardExpMonthId != null ||38+        cardExpYearId != null || cardExpDateId != null || cardCvcId != null || cardNameId != null3139     /** Best label to rank entries against. */3240     val origin: String? get() = webDomain ?: packageName3341 }3442 3543 /**36- * Walks an [AssistStructure] to classify username/email/password/OTP fields.44+ * Walks an [AssistStructure] to classify username/email/password/OTP/card fields.3745  * Mirrors the browser content-script's field detection: trust explicit autofill3846  * hints first, then fall back to input-type and id/hint keyword heuristics so3947  * unannotated login forms still work.@@ -69,6 +77,12 @@6977                 FieldKind.USERNAME -> if (result.usernameId == null) result.copy(usernameId = id, usernameValue = value) else result7078                 FieldKind.EMAIL -> if (result.emailId == null) result.copy(emailId = id, emailValue = value) else result7179                 FieldKind.OTP -> if (result.otpId == null) result.copy(otpId = id, otpValue = value) else result80+                FieldKind.CARD_NUMBER -> if (result.cardNumberId == null) result.copy(cardNumberId = id) else result81+                FieldKind.CARD_EXP_MONTH -> if (result.cardExpMonthId == null) result.copy(cardExpMonthId = id) else result82+                FieldKind.CARD_EXP_YEAR -> if (result.cardExpYearId == null) result.copy(cardExpYearId = id) else result83+                FieldKind.CARD_EXP_DATE -> if (result.cardExpDateId == null) result.copy(cardExpDateId = id) else result84+                FieldKind.CARD_CVC -> if (result.cardCvcId == null) result.copy(cardCvcId = id) else result85+                FieldKind.CARD_NAME -> if (result.cardNameId == null) result.copy(cardNameId = id) else result7286                 FieldKind.NONE -> result7387             }7488         }@@ -83,7 +97,7 @@8397         node.autofillType == View.AUTOFILL_TYPE_TEXT &&8498             node.visibility == View.VISIBLE8599 86-    private enum class FieldKind { USERNAME, EMAIL, PASSWORD, OTP, NONE }100+    private enum class FieldKind { USERNAME, EMAIL, PASSWORD, OTP, CARD_NUMBER, CARD_EXP_MONTH, CARD_EXP_YEAR, CARD_EXP_DATE, CARD_CVC, CARD_NAME, NONE }87101 88102     private fun classify(node: AssistStructure.ViewNode): FieldKind {89103         node.autofillHints?.forEach { hint ->@@ -92,6 +106,11 @@92106                 View.AUTOFILL_HINT_USERNAME, "username" -> return FieldKind.USERNAME93107                 View.AUTOFILL_HINT_EMAIL_ADDRESS, "email", "emailaddress" -> return FieldKind.EMAIL94108                 "otp", "smsotpcode", "2facode" -> return FieldKind.OTP109+                View.AUTOFILL_HINT_CREDIT_CARD_NUMBER, "creditcardnumber", "ccnumber" -> return FieldKind.CARD_NUMBER110+                View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH, "creditcardexpirationmonth" -> return FieldKind.CARD_EXP_MONTH111+                View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR, "creditcardexpirationyear" -> return FieldKind.CARD_EXP_YEAR112+                View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DATE, "creditcardexpirationdate" -> return FieldKind.CARD_EXP_DATE113+                View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE, "creditcardsecuritycode", "ccsecuritycode" -> return FieldKind.CARD_CVC95114             }96115         }97116 @@ -109,9 +128,22 @@109128             haystack.contains("password") || haystack.contains("passwd") || haystack.contains("pwd") -> FieldKind.PASSWORD110129             haystack.contains("email") || haystack.contains("e-mail") -> FieldKind.EMAIL111130             haystack.contains("user") || haystack.contains("login") || haystack.contains("account") -> FieldKind.USERNAME131+            CARD_NUMBER_RX.containsMatchIn(haystack) -> FieldKind.CARD_NUMBER132+            CARD_EXP_MONTH_RX.containsMatchIn(haystack) -> FieldKind.CARD_EXP_MONTH133+            CARD_EXP_YEAR_RX.containsMatchIn(haystack) -> FieldKind.CARD_EXP_YEAR134+            CARD_EXP_DATE_RX.containsMatchIn(haystack) -> FieldKind.CARD_EXP_DATE135+            CARD_CVC_RX.containsMatchIn(haystack) -> FieldKind.CARD_CVC136+            CARD_NAME_RX.containsMatchIn(haystack) -> FieldKind.CARD_NAME112137             else -> FieldKind.NONE113138         }114139     }140+141+    private val CARD_NUMBER_RX = Regex("\\b(card\\s*number|credit\\s*card|card\\s*no|pan|cc\\s*num)\\b")142+    private val CARD_EXP_MONTH_RX = Regex("\\b(exp\\s*month|expiration\\s*month|expiry\\s*month)\\b")143+    private val CARD_EXP_YEAR_RX = Regex("\\b(exp\\s*year|expiration\\s*year|expiry\\s*year)\\b")144+    private val CARD_EXP_DATE_RX = Regex("\\b(exp\\s*date|expiration\\s*date|expiry\\s*date|exp\\b)")145+    private val CARD_CVC_RX = Regex("\\b(cvc|cvv|security\\s*code|card\\s*code)\\b")146+    private val CARD_NAME_RX = Regex("\\b(cardholder|card\\s*holder|name\\s*on\\s*card|card\\s*name)\\b")115147 }116148 117149 /**

view file

app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt+24 −1
@@ -60,6 +60,7 @@6060 import com.peachpasswords.ui.theme.PeachType6161 import com.peachpasswords.vault.UnlockResult6262 import com.peachpasswords.vault.VaultEntry63+import com.peachpasswords.vault.isCard6364 import com.peachpasswords.vault.VaultRepository6465 import dagger.hilt.android.AndroidEntryPoint6566 import kotlinx.coroutines.delay@@ -124,6 +125,12 @@124125             usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID),125126             passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID),126127             otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID),128+            cardNumberId = intentAutofillId(AutofillIntents.EXTRA_CARD_NUMBER_ID),129+            cardExpMonthId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_MONTH_ID),130+            cardExpYearId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_YEAR_ID),131+            cardExpDateId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_DATE_ID),132+            cardCvcId = intentAutofillId(AutofillIntents.EXTRA_CARD_CVC_ID),133+            cardNameId = intentAutofillId(AutofillIntents.EXTRA_CARD_NAME_ID),127134         )128135         val parsed = authenticateAutofillOrigin(this, rawParsed)129136         val accent = vault.settings.value.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT@@ -270,7 +277,11 @@270277         parsed: ParsedStructure,271278         entry: VaultEntry,272279     ) {273-        val dataset = AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)280+        val dataset = if (entry.isCard()) {281+            AutofillResponseBuilder.cardDatasetFor(this, parsed, entry, inlineSpec = null)282+        } else {283+            AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)284+        }274285         if (dataset == null) { cancel(); return }275286         val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)276287         setResult(RESULT_OK, reply)@@ -338,10 +349,22 @@338349     usernameId: AutofillId? = null,339350     passwordId: AutofillId? = null,340351     otpId: AutofillId? = null,352+    cardNumberId: AutofillId? = null,353+    cardExpMonthId: AutofillId? = null,354+    cardExpYearId: AutofillId? = null,355+    cardExpDateId: AutofillId? = null,356+    cardCvcId: AutofillId? = null,357+    cardNameId: AutofillId? = null,341358 ): ParsedStructure = ParsedStructure(342359     usernameId = usernameId,343360     passwordId = passwordId,344361     otpId = otpId,362+    cardNumberId = cardNumberId,363+    cardExpMonthId = cardExpMonthId,364+    cardExpYearId = cardExpYearId,365+    cardExpDateId = cardExpDateId,366+    cardCvcId = cardCvcId,367+    cardNameId = cardNameId,345368     webDomain = origin.takeIf { isWebOrigin },346369     packageName = if (isWebOrigin) callerPackage else origin,347370     // Always UNKNOWN here — the picker re-derives trust from these raw inputs.

view file

app/src/main/java/com/peachpasswords/autofill/AutofillIntents.kt+13 −0
@@ -23,6 +23,13 @@2323     /** Authenticated caller package for web-origin re-verification in the picker. */2424     const val EXTRA_CALLER_PACKAGE = "peach.autofill.callerPackage"2525 26+    const val EXTRA_CARD_NUMBER_ID = "peach.autofill.cardNumberId"27+    const val EXTRA_CARD_EXP_MONTH_ID = "peach.autofill.cardExpMonthId"28+    const val EXTRA_CARD_EXP_YEAR_ID = "peach.autofill.cardExpYearId"29+    const val EXTRA_CARD_EXP_DATE_ID = "peach.autofill.cardExpDateId"30+    const val EXTRA_CARD_CVC_ID = "peach.autofill.cardCvcId"31+    const val EXTRA_CARD_NAME_ID = "peach.autofill.cardNameId"32+2633     /** Host-typed username captured by the autofill framework — used to prefill the new-entry editor. */2734     const val EXTRA_TYPED_USERNAME = "peach.autofill.typedUsername"2835     /** Host-typed email captured by the autofill framework — used to prefill the new-entry editor. */@@ -61,6 +68,12 @@6168             // carry the authenticated caller package for web-origin re-verification.6269             putExtra(EXTRA_CALLER_PACKAGE, parsed.packageName)6370             putStringArrayListExtra(EXTRA_VERIFIED_DOMAINS, ArrayList(parsed.verifiedDomains))71+            putExtra(EXTRA_CARD_NUMBER_ID, parsed.cardNumberId)72+            putExtra(EXTRA_CARD_EXP_MONTH_ID, parsed.cardExpMonthId)73+            putExtra(EXTRA_CARD_EXP_YEAR_ID, parsed.cardExpYearId)74+            putExtra(EXTRA_CARD_EXP_DATE_ID, parsed.cardExpDateId)75+            putExtra(EXTRA_CARD_CVC_ID, parsed.cardCvcId)76+            putExtra(EXTRA_CARD_NAME_ID, parsed.cardNameId)6477             // Mirror the browser's preUsername/preEmail capture so the "+ Create6578             // new entry" path can hand the typed identity to the new-entry editor.6679             // Empty/blank values are dropped — the picker reads the typed username

view file

app/src/main/java/com/peachpasswords/autofill/AutofillResponseBuilder.kt+46 −0
@@ -15,6 +15,7 @@1515 import com.peachpasswords.shared.extractDomain1616 import com.peachpasswords.vault.TOTPGenerator1717 import com.peachpasswords.vault.VaultEntry18+import com.peachpasswords.vault.isCard1819 1920 /**2021  * Turns parsed form fields + vault entries into autofill [Dataset]s — the menu@@ -107,6 +108,51 @@107108         put(parsed.identityId, entry.username ?: entry.email)108109         put(parsed.passwordId, entry.password)109110         put(parsed.otpId, entry.totp?.let { TOTPGenerator.generateCode(it) })111+112+        return if (filledAny) builder.build() else null113+    }114+115+    /**116+     * Build a dataset filling detected card fields from a card [entry].117+     * Cards are not domain-matched — any detected card form offers all cards.118+     */119+    fun cardDatasetFor(120+        context: Context,121+        parsed: ParsedStructure,122+        entry: VaultEntry,123+        inlineSpec: android.widget.inline.InlinePresentationSpec?,124+    ): Dataset? {125+        if (!entry.isCard()) return null126+        val menu = menuRow(context, entry)127+        val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {128+            buildInline(context, entry, inlineSpec)129+        } else {130+            null131+        }132+133+        val builder = Dataset.Builder()134+        var filledAny = false135+        fun put(id: AutofillId?, value: String?) {136+            if (id == null || value.isNullOrEmpty()) return137+            val av = AutofillValue.forText(value)138+            if (inline != null && Build.VERSION.SDK_INT >= 30) {139+                builder.setValue(id, av, menu, inline)140+            } else {141+                builder.setValue(id, av, menu)142+            }143+            filledAny = true144+        }145+146+        val digits = entry.cardNumber?.filter { it.isDigit() }147+        put(parsed.cardNumberId, digits)148+        put(parsed.cardExpMonthId, entry.expMonth)149+        put(parsed.cardExpYearId, entry.expYear)150+        if (parsed.cardExpDateId != null && !entry.expMonth.isNullOrEmpty() && !entry.expYear.isNullOrEmpty()) {151+            val yy = entry.expYear.takeLast(2)152+            put(parsed.cardExpDateId, "${entry.expMonth.padStart(2, '0')}/$yy")153+        }154+        put(parsed.cardCvcId, entry.cvc)155+        put(parsed.cardNameId, entry.cardholderName)110156 111157         return if (filledAny) builder.build() else null112158     }

view file

app/src/main/java/com/peachpasswords/autofill/PeachAutofillService.kt+21 −2
@@ -10,6 +10,7 @@1010 import android.service.autofill.SaveInfo1111 import android.service.autofill.SaveRequest1212 import com.peachpasswords.vault.VaultRepository13+import com.peachpasswords.vault.isCard1314 import dagger.hilt.android.AndroidEntryPoint1415 import kotlinx.coroutines.CoroutineScope1516 import kotlinx.coroutines.Dispatchers@@ -62,6 +63,16 @@6263             }6364         }6465 66+        if (parsed.hasCardField && vault.isUnlocked.value) {67+            vault.getEntries().filter { it.isCard() }.take(MAX_DATASETS - datasetCount).forEachIndexed { index, entry ->68+                val spec = inlineSpecs.getOrNull(datasetCount + index)69+                AutofillResponseBuilder.cardDatasetFor(context, parsed, entry, spec)?.let {70+                    response.addDataset(it)71+                    datasetCount++72+                }73+            }74+        }75+6576         // Always offer the branded picker as the last row (search / unlock / no-match).6677         val pickerLabel = if (parsed.originTrust == AutofillOriginTrust.UNKNOWN && vault.isUnlocked.value) {6778             "Verify site in Peach…"@@ -102,7 +113,11 @@102113         val builder = android.service.autofill.Dataset.Builder(menu)103114         // A placeholder value per field keeps the dataset valid; the real values104115         // are supplied by the picker via EXTRA_AUTHENTICATION_RESULT.105-        listOfNotNull(parsed.identityId, parsed.passwordId, parsed.otpId).forEach { id ->116+        listOfNotNull(117+            parsed.identityId, parsed.passwordId, parsed.otpId,118+            parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,119+            parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,120+        ).forEach { id ->106121             builder.setValue(id, null, menu)107122         }108123         builder.setAuthentication(AutofillIntents.pickerIntentSender(context, parsed))@@ -111,7 +126,11 @@111126 112127     /** Mark detected ids the framework should track even when we filled nothing. */113128     private fun ignoreUnfilledIds(builder: FillResponse.Builder, parsed: ParsedStructure) {114-        val ids = listOfNotNull(parsed.identityId, parsed.passwordId, parsed.otpId)129+        val ids = listOfNotNull(130+            parsed.identityId, parsed.passwordId, parsed.otpId,131+            parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,132+            parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,133+        )115134         if (ids.isNotEmpty()) builder.setIgnoredIds(*ids.toTypedArray())116135     }117136 

view file

app/src/test/java/com/peachpasswords/autofill/AutofillCardTest.kt+98 −0
@@ -0,0 +1,98 @@1+package com.peachpasswords.autofill2+3+import com.peachpasswords.vault.VaultEntry4+import com.peachpasswords.vault.isCard5+import org.junit.Assert.assertEquals6+import org.junit.Assert.assertFalse7+import org.junit.Assert.assertTrue8+import org.junit.Test9+10+class AutofillCardTest {11+12+    private fun card(13+        title: String = "Visa",14+        number: String = "4111111111111111",15+        expMonth: String = "09",16+        expYear: String = "2028",17+        cvc: String = "123",18+        name: String = "June Doe",19+    ) = VaultEntry(20+        id = title,21+        title = title,22+        type = "card",23+        cardNumber = number,24+        expMonth = expMonth,25+        expYear = expYear,26+        cvc = cvc,27+        cardholderName = name,28+        createdAt = 0,29+        modifiedAt = 0,30+    )31+32+    @Test fun card_entry_is_recognised() {33+        assertTrue(card().isCard())34+        assertFalse(VaultEntry(id = "l", title = "Login", createdAt = 0, modifiedAt = 0).isCard())35+    }36+37+    @Test fun card_entries_not_domain_matched() {38+        val visa = card()39+        val result = AutofillResponseBuilder.matchEntries(listOf(visa), "shop.example.com")40+        assertTrue("cards have no URL so domain matching yields nothing", result.hits.isEmpty())41+    }42+43+    @Test fun combo_expiry_formats_mm_yy() {44+        val visa = card(expMonth = "9", expYear = "2028")45+        val mm = visa.expMonth!!.padStart(2, '0')46+        val yy = visa.expYear!!.takeLast(2)47+        assertEquals("09/28", "$mm/$yy")48+    }49+50+    @Test fun combo_expiry_handles_four_digit_year() {51+        val visa = card(expMonth = "12", expYear = "2030")52+        val mm = visa.expMonth!!.padStart(2, '0')53+        val yy = visa.expYear!!.takeLast(2)54+        assertEquals("12/30", "$mm/$yy")55+    }56+57+    @Test fun card_number_strips_non_digits() {58+        val visa = card(number = "4111 1111 1111 1111")59+        val digits = visa.cardNumber!!.filter { it.isDigit() }60+        assertEquals("4111111111111111", digits)61+    }62+63+    @Test fun card_heuristic_regexes_match_common_labels() {64+        val cardNumberRx = Regex("\\b(card\\s*number|credit\\s*card|card\\s*no|pan|cc\\s*num)\\b")65+        assertTrue(cardNumberRx.containsMatchIn("card number"))66+        assertTrue(cardNumberRx.containsMatchIn("credit card"))67+        assertTrue(cardNumberRx.containsMatchIn("cc num"))68+        assertFalse(cardNumberRx.containsMatchIn("discard number"))69+70+        val cvcRx = Regex("\\b(cvc|cvv|security\\s*code|card\\s*code)\\b")71+        assertTrue(cvcRx.containsMatchIn("cvc"))72+        assertTrue(cvcRx.containsMatchIn("security code"))73+74+        val nameRx = Regex("\\b(cardholder|card\\s*holder|name\\s*on\\s*card|card\\s*name)\\b")75+        assertTrue(nameRx.containsMatchIn("cardholder name"))76+        assertTrue(nameRx.containsMatchIn("name on card"))77+        assertFalse(nameRx.containsMatchIn("username"))78+79+        val expMonthRx = Regex("\\b(exp\\s*month|expiration\\s*month|expiry\\s*month)\\b")80+        assertTrue(expMonthRx.containsMatchIn("exp month"))81+        assertTrue(expMonthRx.containsMatchIn("expiration month"))82+83+        val expDateRx = Regex("\\b(exp\\s*date|expiration\\s*date|expiry\\s*date|exp\\b)")84+        assertTrue(expDateRx.containsMatchIn("exp date"))85+        assertTrue(expDateRx.containsMatchIn("exp"))86+    }87+88+    @Test fun picker_parsed_from_inputs_carries_card_ids() {89+        val parsed = pickerParsedFromInputs(90+            origin = "shop.example.com",91+            isWebOrigin = true,92+            callerPackage = "com.android.chrome",93+            verifiedDomains = emptyList(),94+        )95+        assertFalse(parsed.hasCardField)96+        assertEquals(AutofillOriginTrust.UNKNOWN, parsed.originTrust)97+    }98+}

view file