2d77bd8340ee
feat: policy-aware password generation in autofill (parity A3) (v2.10.4)
- time
- 2026-08-02 17:07 UTC · 2 months ago
- machine
- koh2
- parent
- d8244eb8a3b9
↓ peach-android-2d77bd83.tar.gzatlas at this save
files changed6 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 −0app/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 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, butapp/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+}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 }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+}