2d77bd8340ee

feat: policy-aware password generation in autofill (parity A3) (v2.10.4)

Browser parity gap A3 (MEDIUM). The autofill surface now detects a signup / password field's length constraints and offers to generate a password the site will accept, instead of a fixed-length one. - PasswordPolicy (pure): fromHtmlAttributes reads minlength/maxlength and falls back to pattern quantifiers (.{N,M} / .{N,} / .{N}) with the same precedence as the browser's policy.ts; generationLength offers the strongest length the site accepts (generator-policy.ts); constraintNote for the UI - AssistStructureParser: read the password field's htmlInfo attributes into passwordPolicy, and flag isNewPassword from autocomplete=new-password - AutofillResponseBuilder.generateDatasetFor: fill the password field with a PasswordGenerator value sized to the policy; menu-only row - PeachAutofillService: offer the generate dataset on new-password fields or any password form with no matching login (never over a known match); the existing save flow captures the generated value like any other fill - PasswordPolicyTest: 15 tests Scope: only HTML attribute constraints port — AssistStructure does not expose the page prose the browser also scrapes, so prose-derived char-class rules are out of reach on Android. Live generate-row needs on-device QA.

time
2026-08-02 17:07 UTC · 2 months ago
machine
koh2
parent
d8244eb8a3b9
6 files VERSION+1 −1app/src/main/java/com/peachpasswords/autofill/AssistStructureParser.kt+29 −1app/src/main/java/com/peachpasswords/autofill/AutofillResponseBuilder.kt+21 −0app/src/main/java/com/peachpasswords/autofill/PasswordPolicy.kt+70 −0app/src/main/java/com/peachpasswords/autofill/PeachAutofillService.kt+17 −0app/src/test/java/com/peachpasswords/autofill/PasswordPolicyTest.kt+89 −0
VERSION+1 −1
@@ -1 +1 @@1-2.10.31+2.10.4

view file

app/src/main/java/com/peachpasswords/autofill/AssistStructureParser.kt+29 −1
@@ -20,6 +20,8 @@2020     val emailValue: String? = null,2121     val passwordValue: String? = null,2222     val otpValue: String? = null,23+    val passwordPolicy: PasswordPolicy? = null,24+    val isNewPassword: Boolean = false,2325     val webDomain: String? = null,2426     val packageName: String? = null,2527     val originTrust: AutofillOriginTrust = AutofillOriginTrust.UNKNOWN,@@ -73,7 +75,17 @@7375             val value = node.autofillValue?.takeIf { it.isText }?.textValue?.toString()7476                 ?: node.text?.toString()7577             result = when (classify(node)) {76-                FieldKind.PASSWORD -> if (result.passwordId == null) result.copy(passwordId = id, passwordValue = value) else result78+                FieldKind.PASSWORD -> if (result.passwordId == null) {79+                    val attrs = htmlAttributes(node)80+                    result.copy(81+                        passwordId = id,82+                        passwordValue = value,83+                        passwordPolicy = PasswordPolicy.fromHtmlAttributes(attrs)84+                            .takeIf { it.minLength != null || it.maxLength != null },85+                        isNewPassword = attrs["autocomplete"]86+                            ?.contains("new-password", ignoreCase = true) == true,87+                    )88+                } else result7789                 FieldKind.USERNAME -> if (result.usernameId == null) result.copy(usernameId = id, usernameValue = value) else result7890                 FieldKind.EMAIL -> if (result.emailId == null) result.copy(emailId = id, emailValue = value) else result7991                 FieldKind.OTP -> if (result.otpId == null) result.copy(otpId = id, otpValue = value) else result@@ -96,6 +108,22 @@96108     private fun isFillable(node: AssistStructure.ViewNode): Boolean =97109         node.autofillType == View.AUTOFILL_TYPE_TEXT &&98110             node.visibility == View.VISIBLE111+112+    /**113+     * A web field's HTML attributes (minlength/maxlength/pattern/autocomplete…),114+     * lower-cased by key. Empty for native views — [ViewNode.getHtmlInfo] is only115+     * populated for web content, so policy detection degrades to "no constraint".116+     */117+    private fun htmlAttributes(node: AssistStructure.ViewNode): Map<String, String> {118+        val info = node.htmlInfo ?: return emptyMap()119+        val out = mutableMapOf<String, String>()120+        for (attr in info.attributes.orEmpty()) {121+            val key = attr.first?.lowercase() ?: continue122+            val value = attr.second ?: continue123+            out[key] = value124+        }125+        return out126+    }99127 100128     private enum class FieldKind { USERNAME, EMAIL, PASSWORD, OTP, CARD_NUMBER, CARD_EXP_MONTH, CARD_EXP_YEAR, CARD_EXP_DATE, CARD_CVC, CARD_NAME, NONE }101129 

view file

app/src/main/java/com/peachpasswords/autofill/AutofillResponseBuilder.kt+21 −0
@@ -13,6 +13,7 @@1313 import androidx.autofill.inline.UiVersions1414 import com.peachpasswords.R1515 import com.peachpasswords.shared.extractDomain16+import com.peachpasswords.vault.PasswordGenerator1617 import com.peachpasswords.vault.TOTPGenerator1718 import com.peachpasswords.vault.VaultEntry1819 import com.peachpasswords.vault.isCard@@ -155,6 +156,26 @@155156         put(parsed.cardNameId, entry.cardholderName)156157 157158         return if (filledAny) builder.build() else null159+    }160+161+    /**162+     * A "generate strong password" dataset for signup / no-match forms. Fills the163+     * detected password field with a freshly generated password sized to the164+     * site's [PasswordPolicy] (A3). The standard save flow captures whatever ends165+     * up in the password field, so the generated value is offered for save like166+     * any other fill. Menu-only (no inline chip) — there is no vault entry to167+     * render. Parity for the browser's inline generator on create forms.168+     */169+    fun generateDatasetFor(context: Context, parsed: ParsedStructure): Dataset? {170+        val passwordId = parsed.passwordId ?: return null171+        val policy = parsed.passwordPolicy172+        val length = policy?.generationLength() ?: PasswordPolicy.DEFAULT_LENGTH173+        val password = PasswordGenerator.generate(PasswordGenerator.Options(length = length))174+        val note = policy?.constraintNote(length).orEmpty()175+        val menu = simpleRow(context, "Generate strong password", note.ifEmpty { "Peach" })176+        return Dataset.Builder()177+            .setValue(passwordId, AutofillValue.forText(password), menu)178+            .build()158179     }159180 160181     // InlineSuggestionUi.Content.getSlice is @RestrictedApi to androidx, but

view file

app/src/main/java/com/peachpasswords/autofill/PasswordPolicy.kt+70 −0
@@ -0,0 +1,70 @@1+package com.peachpasswords.autofill2+3+/**4+ * Site password constraints detected from a password field's HTML attributes5+ * (exposed by [android.app.assist.AssistStructure.ViewNode.getHtmlInfo]).6+ *7+ * Android parity for the browser's `content/policy.ts` + `popup/generator-policy.ts`,8+ * scoped to what the platform actually surfaces: AssistStructure carries a web9+ * field's `minlength` / `maxlength` / `pattern` attributes, but NOT the surrounding10+ * page prose the browser also scrapes ("8-20 characters, must contain a number").11+ * Used to size a generated password so the site will accept it.12+ */13+data class PasswordPolicy(14+    val minLength: Int? = null,15+    val maxLength: Int? = null,16+) {17+    /**18+     * Length to generate at: the strongest password the site accepts. Mirrors19+     * `generator-policy.ts` — prefer [maxLength] (capped at [hardMax]); otherwise20+     * a strong [default]; then clamp into the site's bounds.21+     */22+    fun generationLength(default: Int = DEFAULT_LENGTH, hardMax: Int = HARD_MAX): Int {23+        val target = (maxLength ?: default).coerceAtMost(hardMax)24+        val raised = minLength?.let { target.coerceAtLeast(it) } ?: target25+        val capped = maxLength?.let { raised.coerceAtMost(it) } ?: raised26+        return capped.coerceAtLeast(1)27+    }28+29+    /** Short human note about the active constraint, or empty when unconstrained. */30+    fun constraintNote(length: Int): String = when {31+        maxLength != null && length >= maxLength -> "This site allows max $maxLength characters"32+        minLength != null && length <= minLength -> "This site requires at least $minLength characters"33+        else -> ""34+    }35+36+    companion object {37+        const val DEFAULT_LENGTH = 2038+        const val HARD_MAX = 12839+40+        /**41+         * Build a policy from a password field's HTML attributes (keys lower-cased).42+         * Reads `minlength`/`maxlength` directly, then falls back to `pattern`43+         * quantifiers — `.{N,M}` (range), `.{N,}` (min only), `.{N}` (exact) — the44+         * same precedence policy.ts uses. Attribute values win over pattern.45+         */46+        fun fromHtmlAttributes(attrs: Map<String, String>): PasswordPolicy {47+            var min = attrs["minlength"]?.toIntOrNull()?.takeIf { it > 0 }48+            var max = attrs["maxlength"]?.toIntOrNull()?.takeIf { it > 0 }49+            val pattern = attrs["pattern"]50+            if (pattern != null && (min == null || max == null)) {51+                val range = Regex("\\{(\\d+),(\\d+)\\}").find(pattern)52+                val minOnly = Regex("\\{(\\d+),\\}").find(pattern)53+                val exact = Regex("\\{(\\d+)\\}").find(pattern)54+                when {55+                    range != null -> {56+                        if (min == null) min = range.groupValues[1].toIntOrNull()57+                        if (max == null) max = range.groupValues[2].toIntOrNull()58+                    }59+                    minOnly != null -> if (min == null) min = minOnly.groupValues[1].toIntOrNull()60+                    exact != null -> {61+                        val n = exact.groupValues[1].toIntOrNull()62+                        if (min == null) min = n63+                        if (max == null) max = n64+                    }65+                }66+            }67+            return PasswordPolicy(minLength = min, maxLength = max)68+        }69+    }70+}

view file

app/src/main/java/com/peachpasswords/autofill/PeachAutofillService.kt+17 −0
@@ -49,6 +49,7 @@4949         val inlineSpecs = inlineSpecs(request)5050         val response = FillResponse.Builder()5151         var datasetCount = 052+        var hasLoginMatch = false5253 5354         if (vault.isUnlocked.value) {5455             val matches = AutofillResponseBuilder@@ -59,6 +60,7 @@5960                 AutofillResponseBuilder.datasetFor(context, parsed, entry, spec)?.let {6061                     response.addDataset(it)6162                     datasetCount++63+                    hasLoginMatch = true6264                 }6365             }6466         }@@ -67,6 +69,21 @@6769             vault.getEntries().filter { it.isCard() }.take(MAX_DATASETS - datasetCount).forEachIndexed { index, entry ->6870                 val spec = inlineSpecs.getOrNull(datasetCount + index)6971                 AutofillResponseBuilder.cardDatasetFor(context, parsed, entry, spec)?.let {72+                    response.addDataset(it)73+                    datasetCount++74+                }75+            }76+        }77+78+        // A3: offer to generate a strong password on signup forms (new-password79+        // field) or any password form with no matching login, sized to the site's80+        // detected policy. Gated to no-match so it never appears over a known81+        // login the user is about to fill.82+        if (vault.isUnlocked.value && parsed.passwordId != null &&83+            (parsed.isNewPassword || !hasLoginMatch)84+        ) {85+            runCatching {86+                AutofillResponseBuilder.generateDatasetFor(context, parsed)?.let {7087                     response.addDataset(it)7188                     datasetCount++7289                 }

view file

app/src/test/java/com/peachpasswords/autofill/PasswordPolicyTest.kt+89 −0
@@ -0,0 +1,89 @@1+package com.peachpasswords.autofill2+3+import org.junit.Assert.assertEquals4+import org.junit.Assert.assertNull5+import org.junit.Test6+7+class PasswordPolicyTest {8+9+    @Test fun reads_minlength_and_maxlength_attributes() {10+        val p = PasswordPolicy.fromHtmlAttributes(mapOf("minlength" to "8", "maxlength" to "64"))11+        assertEquals(8, p.minLength)12+        assertEquals(64, p.maxLength)13+    }14+15+    @Test fun ignores_non_positive_lengths() {16+        val p = PasswordPolicy.fromHtmlAttributes(mapOf("minlength" to "0", "maxlength" to "-1"))17+        assertNull(p.minLength)18+        assertNull(p.maxLength)19+    }20+21+    @Test fun pattern_range_fills_both_bounds() {22+        val p = PasswordPolicy.fromHtmlAttributes(mapOf("pattern" to ".{8,20}"))23+        assertEquals(8, p.minLength)24+        assertEquals(20, p.maxLength)25+    }26+27+    @Test fun pattern_min_only() {28+        val p = PasswordPolicy.fromHtmlAttributes(mapOf("pattern" to ".{12,}"))29+        assertEquals(12, p.minLength)30+        assertNull(p.maxLength)31+    }32+33+    @Test fun pattern_exact_sets_both() {34+        val p = PasswordPolicy.fromHtmlAttributes(mapOf("pattern" to "[a-z]{10}"))35+        assertEquals(10, p.minLength)36+        assertEquals(10, p.maxLength)37+    }38+39+    @Test fun attributes_win_over_pattern() {40+        val p = PasswordPolicy.fromHtmlAttributes(41+            mapOf("minlength" to "6", "maxlength" to "30", "pattern" to ".{8,20}"),42+        )43+        assertEquals(6, p.minLength)44+        assertEquals(30, p.maxLength)45+    }46+47+    @Test fun no_constraints_yields_empty_policy() {48+        val p = PasswordPolicy.fromHtmlAttributes(mapOf("autocomplete" to "new-password"))49+        assertNull(p.minLength)50+        assertNull(p.maxLength)51+    }52+53+    @Test fun generation_prefers_max_length() {54+        // Strongest the site accepts.55+        assertEquals(64, PasswordPolicy(minLength = 8, maxLength = 64).generationLength())56+    }57+58+    @Test fun generation_caps_at_hard_max() {59+        assertEquals(128, PasswordPolicy(maxLength = 500).generationLength())60+    }61+62+    @Test fun generation_snaps_up_to_min() {63+        // default 20 < min 25 -> snap up.64+        assertEquals(25, PasswordPolicy(minLength = 25).generationLength())65+    }66+67+    @Test fun generation_default_when_unconstrained() {68+        assertEquals(PasswordPolicy.DEFAULT_LENGTH, PasswordPolicy().generationLength())69+    }70+71+    @Test fun generation_never_below_one() {72+        assertEquals(1, PasswordPolicy(maxLength = 0).generationLength())73+    }74+75+    @Test fun constraint_note_max() {76+        val p = PasswordPolicy(maxLength = 20)77+        assertEquals("This site allows max 20 characters", p.constraintNote(20))78+    }79+80+    @Test fun constraint_note_min() {81+        val p = PasswordPolicy(minLength = 12)82+        assertEquals("This site requires at least 12 characters", p.constraintNote(12))83+    }84+85+    @Test fun constraint_note_empty_when_interior() {86+        val p = PasswordPolicy(minLength = 8, maxLength = 64)87+        assertEquals("", p.constraintNote(30))88+    }89+}

view file