92419f6d20c2

feat: TOTP digit-box filling — one character per box (v2.10.12)

Parity queue #7 (A6, final item) — browser fillOTPDigitBoxes parity. - Parser collects split single-character OTP boxes: web = maxlength=1 with text/number/tel type (exact browser gate); native = numeric input class, unlabeled or OTP-labeled only (labeled numeric fields still classify as card/expiry), number-password excluded. Group gated to 4-10 boxes. - ParsedStructure.otpDigitBoxIds joins hasUsableField - datasetFor fills a full-code otpId when present, else distributes the TOTP code char-by-char across the box ids - Picker carries the ids via EXTRA_OTP_DIGIT_BOX_IDS (API-33-safe parcelable read) so picker picks fill boxes too; service placeholder + ignored ids include them - tests: OtpDigitsTest (4). Full suite 902, only the 5 known pre-existing failures. Live digit-box fill needs on-device QA.

time
2026-08-02 23:55 UTC · 2 months ago
machine
koh2
parent
c9a6cfec817e
7 files VERSION+1 −1app/src/main/java/com/peachpasswords/autofill/AssistStructureParser.kt+36 −1app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt+9 −0app/src/main/java/com/peachpasswords/autofill/AutofillIntents.kt+2 −0app/src/main/java/com/peachpasswords/autofill/AutofillResponseBuilder.kt+14 −1app/src/main/java/com/peachpasswords/autofill/PeachAutofillService.kt+3 −3app/src/test/java/com/peachpasswords/autofill/OtpDigitsTest.kt+29 −0
VERSION+1 −1
@@ -1 +1 @@1-2.10.111+2.10.12

view file

app/src/main/java/com/peachpasswords/autofill/AssistStructureParser.kt+36 −1
@@ -19,6 +19,8 @@1919     val emailId: AutofillId? = null,2020     val passwordId: AutofillId? = null,2121     val otpId: AutofillId? = null,22+    /** Split single-character OTP boxes in traversal order (browser A6 parity). */23+    val otpDigitBoxIds: List<AutofillId> = emptyList(),2224     val usernameValue: String? = null,2325     val emailValue: String? = null,2426     val passwordValue: String? = null,@@ -38,7 +40,7 @@3840 ) {3941     /** The id we put the account identifier into — email preferred, else username. */4042     val identityId: AutofillId? get() = emailId ?: usernameId41-    val hasUsableField: Boolean get() = passwordId != null || identityId != null || otpId != null || hasCardField43+    val hasUsableField: Boolean get() = passwordId != null || identityId != null || otpId != null || otpDigitBoxIds.isNotEmpty() || hasCardField4244     val hasCardField: Boolean get() = cardNumberId != null || cardExpMonthId != null ||4345         cardExpYearId != null || cardExpDateId != null || cardCvcId != null || cardNameId != null4446     /** Best label to rank entries against. */@@ -64,6 +66,10 @@6466             val root = structure.getWindowNodeAt(i).rootViewNode6567             result = traverse(root, result)6668         }69+        // Browser parity: a split-OTP group is 4-10 single-character boxes.70+        if (result.otpDigitBoxIds.size !in 4..10) {71+            result = result.copy(otpDigitBoxIds = emptyList())72+        }6773         return result6874     }6975 @@ -75,6 +81,9 @@7581 7682         val id = node.autofillId7783         if (id != null && isFillable(node)) {84+            if (isDigitBoxCandidate(node)) {85+                result = result.copy(otpDigitBoxIds = result.otpDigitBoxIds + id)86+            } else {7887             val value = node.autofillValue?.takeIf { it.isText }?.textValue?.toString()7988                 ?: node.text?.toString()8089             result = when (classify(node)) {@@ -100,6 +109,7 @@100109                 FieldKind.CARD_NAME -> if (result.cardNameId == null) result.copy(cardNameId = id) else result101110                 FieldKind.NONE -> result102111             }112+            }103113         }104114 105115         for (i in 0 until node.childCount) {@@ -126,6 +136,31 @@126136             out[key] = value127137         }128138         return out139+    }140+141+    /**142+     * A single-character input in a split-OTP group (browser's143+     * findOTPDigitBoxGroup). Web: maxlength=1 with a text/number/tel type.144+     * Native: numeric input type, but only when unlabeled or OTP-labeled —145+     * labeled numeric fields (card number, expiry) still go through [classify].146+     * The 4..10 group gate in [parse] keeps stray singles out.147+     */148+    private fun isDigitBoxCandidate(node: AssistStructure.ViewNode): Boolean {149+        val attrs = htmlAttributes(node)150+        if (attrs.isNotEmpty()) {151+            if (attrs["maxlength"] != "1") return false152+            return (attrs["type"]?.lowercase() ?: "") in setOf("", "text", "number", "tel")153+        }154+        if (node.autofillHints?.any { it.equals("smsOTPCode", true) || it.equals("oneTimeCode", true) } == true) {155+            return true156+        }157+        val type = node.inputType158+        if (type and InputType.TYPE_MASK_VARIATION == InputType.TYPE_NUMBER_VARIATION_PASSWORD) return false159+        if (type and InputType.TYPE_MASK_CLASS != InputType.TYPE_CLASS_NUMBER) return false160+        val haystack = listOfNotNull(node.idEntry, node.hint, node.text?.toString())161+            .joinToString(" ").lowercase()162+        return haystack.isBlank() ||163+            Regex("\b(otp|2fa|mfa|one[- ]?time|verification code|auth code|pin)\b").containsMatchIn(haystack)129164     }130165 131166     private enum class FieldKind { USERNAME, EMAIL, PASSWORD, OTP, CARD_NUMBER, CARD_EXP_MONTH, CARD_EXP_YEAR, CARD_EXP_DATE, CARD_CVC, CARD_NAME, NONE }

view file

app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt+9 −0
@@ -1,6 +1,7 @@11 package com.peachpasswords.autofill22 33 import android.content.Intent4+import android.os.Build45 import android.os.Bundle56 import android.service.autofill.Dataset67 import android.view.autofill.AutofillId@@ -128,6 +129,12 @@128129             usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID),129130             passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID),130131             otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID),132+            otpDigitBoxIds = if (Build.VERSION.SDK_INT >= 33) {133+                intent.getParcelableArrayListExtra(AutofillIntents.EXTRA_OTP_DIGIT_BOX_IDS, AutofillId::class.java).orEmpty()134+            } else {135+                @Suppress("DEPRECATION")136+                intent.getParcelableArrayListExtra<AutofillId>(AutofillIntents.EXTRA_OTP_DIGIT_BOX_IDS).orEmpty()137+            },131138             cardNumberId = intentAutofillId(AutofillIntents.EXTRA_CARD_NUMBER_ID),132139             cardExpMonthId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_MONTH_ID),133140             cardExpYearId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_YEAR_ID),@@ -391,6 +398,7 @@391398     usernameId: AutofillId? = null,392399     passwordId: AutofillId? = null,393400     otpId: AutofillId? = null,401+    otpDigitBoxIds: List<AutofillId> = emptyList(),394402     cardNumberId: AutofillId? = null,395403     cardExpMonthId: AutofillId? = null,396404     cardExpYearId: AutofillId? = null,@@ -407,6 +415,7 @@407415     cardExpDateId = cardExpDateId,408416     cardCvcId = cardCvcId,409417     cardNameId = cardNameId,418+    otpDigitBoxIds = otpDigitBoxIds,410419     webDomain = origin.takeIf { isWebOrigin },411420     packageName = if (isWebOrigin) callerPackage else origin,412421 )

view file

app/src/main/java/com/peachpasswords/autofill/AutofillIntents.kt+2 −0
@@ -17,6 +17,7 @@1717     const val EXTRA_IDENTITY_ID = "peach.autofill.identityId"1818     const val EXTRA_PASSWORD_ID = "peach.autofill.passwordId"1919     const val EXTRA_OTP_ID = "peach.autofill.otpId"20+    const val EXTRA_OTP_DIGIT_BOX_IDS = "peach.autofill.otpDigitBoxIds"2021     const val EXTRA_ORIGIN = "peach.autofill.origin"2122     const val EXTRA_IS_WEB_ORIGIN = "peach.autofill.isWebOrigin"2223     /** Authenticated caller package so the picker can re-resolve web-origin domains in-process. */@@ -61,6 +62,7 @@6162             putExtra(EXTRA_IDENTITY_ID, parsed.identityId)6263             putExtra(EXTRA_PASSWORD_ID, parsed.passwordId)6364             putExtra(EXTRA_OTP_ID, parsed.otpId)65+            putParcelableArrayListExtra(EXTRA_OTP_DIGIT_BOX_IDS, ArrayList(parsed.otpDigitBoxIds))6466             putExtra(EXTRA_ORIGIN, parsed.origin)6567             putExtra(EXTRA_IS_WEB_ORIGIN, parsed.webDomain != null)6668             // The picker re-resolves origin domains/app title in-process from

view file

app/src/main/java/com/peachpasswords/autofill/AutofillResponseBuilder.kt+14 −1
@@ -64,6 +64,10 @@6464             rest = emptyList(),6565         )6666 67+    /** Distribute a TOTP code character-by-character over [boxCount] split boxes. */68+    internal fun otpDigitsFor(boxCount: Int, code: String): List<String> =69+        code.filter { !it.isWhitespace() }.take(boxCount).map { it.toString() }70+6771     private fun VaultEntry.matchesOriginDomain(originDomain: String): Boolean =6872         sequenceOf(url)6973             .plus(additionalUrls.orEmpty().asSequence())@@ -116,7 +120,16 @@116120         }117121         put(parsed.identityId, entry.username ?: entry.email)118122         put(parsed.passwordId, entry.password)119-        put(parsed.otpId, entry.totp?.let { TOTPGenerator.generateCode(it) })123+        val otpCode = entry.totp?.let { TOTPGenerator.generateCode(it) }124+        put(parsed.otpId, otpCode)125+        // A6: split single-character OTP boxes — one character per box126+        // (browser's fillOTPDigitBoxes). Only when no full-code field exists.127+        if (parsed.otpId == null && otpCode != null && parsed.otpDigitBoxIds.isNotEmpty()) {128+            otpDigitsFor(parsed.otpDigitBoxIds.size, otpCode).forEachIndexed { index, digit ->129+                builder.setValue(parsed.otpDigitBoxIds[index], AutofillValue.forText(digit), menu)130+            }131+            filledAny = true132+        }120133 121134         return if (filledAny) builder.build() else null122135     }

view file

app/src/main/java/com/peachpasswords/autofill/PeachAutofillService.kt+3 −3
@@ -120,11 +120,11 @@120120         val builder = android.service.autofill.Dataset.Builder(menu)121121         // A placeholder value per field keeps the dataset valid; the real values122122         // are supplied by the picker via EXTRA_AUTHENTICATION_RESULT.123-        listOfNotNull(123+        (listOfNotNull(124124             parsed.identityId, parsed.passwordId, parsed.otpId,125125             parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,126126             parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,127-        ).forEach { id ->127+        ) + parsed.otpDigitBoxIds).forEach { id ->128128             builder.setValue(id, null, menu)129129         }130130         builder.setAuthentication(AutofillIntents.pickerIntentSender(context, parsed))@@ -137,7 +137,7 @@137137             parsed.identityId, parsed.passwordId, parsed.otpId,138138             parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,139139             parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,140-        )140+        ) + parsed.otpDigitBoxIds141141         if (ids.isNotEmpty()) builder.setIgnoredIds(*ids.toTypedArray())142142     }143143 

view file

app/src/test/java/com/peachpasswords/autofill/OtpDigitsTest.kt+29 −0
@@ -0,0 +1,29 @@1+package com.peachpasswords.autofill2+3+import org.junit.Assert.assertEquals4+import org.junit.Test5+6+class OtpDigitsTest {7+8+    @Test fun distributes_a_code_one_character_per_box() {9+        assertEquals(10+            listOf("1", "2", "3", "4", "5", "6"),11+            AutofillResponseBuilder.otpDigitsFor(6, "123456"),12+        )13+    }14+15+    @Test fun stops_at_the_box_count_when_the_code_is_longer() {16+        assertEquals(listOf("1", "2", "3", "4"), AutofillResponseBuilder.otpDigitsFor(4, "123456"))17+    }18+19+    @Test fun fills_fewer_boxes_than_code_length_when_there_are_more_boxes() {20+        assertEquals(21+            listOf("1", "2", "3", "4", "5", "6"),22+            AutofillResponseBuilder.otpDigitsFor(8, "123456"),23+        )24+    }25+26+    @Test fun strips_whitespace_before_distributing() {27+        assertEquals(listOf("1", "2", "3"), AutofillResponseBuilder.otpDigitsFor(6, "1 2 3"))28+    }29+}

view file