7a7563cafa18
feat: card autofill — detect card forms and fill from vault card entries (v2.10.0)
- time
- 2026-08-02 16:21 UTC · 2 months ago
- machine
- koh2
- parent
- a7c248b9b68d
↓ peach-android-7a7563ca.tar.gzatlas at this save
files changed7 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 −0app/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 /**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.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 usernameapp/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 }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 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+}