92419f6d20c2
feat: TOTP digit-box filling — one character per box (v2.10.12)
- time
- 2026-08-02 23:55 UTC · 2 months ago
- machine
- koh2
- parent
- c9a6cfec817e
↓ peach-android-92419f6d.tar.gzatlas at this save
files changed7 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 −0app/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 }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 )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 fromapp/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 }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 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+}