package com.peachpasswords.autofill import android.app.assist.AssistStructure import android.text.InputType import android.view.View import android.view.autofill.AutofillId /** * The autofillable fields discovered in one [AssistStructure], plus the origin * Android authenticated for ranking vault entries. * * Origin verification was removed (queue #3, June 2026-08-02): matches are * ranked by [originDomains] (the page domain, or a native app's app-link * domains) and [appTitle] (a native app's display label), with no caller-trust * gate. [originDomains]/[appTitle] are populated by the service, not the parser. */ data class ParsedStructure( val usernameId: AutofillId? = null, val emailId: AutofillId? = null, val passwordId: AutofillId? = null, val otpId: AutofillId? = null, val usernameValue: String? = null, val emailValue: String? = null, val passwordValue: String? = null, val otpValue: String? = null, val passwordPolicy: PasswordPolicy? = null, val isNewPassword: Boolean = false, val webDomain: String? = null, val packageName: String? = null, val appTitle: String? = null, val originDomains: List = emptyList(), val cardNumberId: AutofillId? = null, val cardExpMonthId: AutofillId? = null, val cardExpYearId: AutofillId? = null, val cardExpDateId: AutofillId? = null, val cardCvcId: AutofillId? = null, val cardNameId: AutofillId? = null, ) { /** The id we put the account identifier into — email preferred, else username. */ val identityId: AutofillId? get() = emailId ?: usernameId val hasUsableField: Boolean get() = passwordId != null || identityId != null || otpId != null || hasCardField val hasCardField: Boolean get() = cardNumberId != null || cardExpMonthId != null || cardExpYearId != null || cardExpDateId != null || cardCvcId != null || cardNameId != null /** Best label to rank entries against. */ val origin: String? get() = webDomain ?: packageName } /** * Walks an [AssistStructure] to classify username/email/password/OTP/card fields. * Mirrors the browser content-script's field detection: trust explicit autofill * hints first, then fall back to input-type and id/hint keyword heuristics so * unannotated login forms still work. */ object AssistStructureParser { fun parse(structure: AssistStructure): ParsedStructure { // View-node package ids are supplied by the foreground app; the framework-owned // activity component is the authenticated caller identity. var result = ParsedStructure( packageName = authenticatedPackageName(structure.activityComponent?.packageName), ) val windows = structure.windowNodeCount for (i in 0 until windows) { val root = structure.getWindowNodeAt(i).rootViewNode result = traverse(root, result) } return result } private fun traverse(node: AssistStructure.ViewNode, acc: ParsedStructure): ParsedStructure { var result = acc if (result.webDomain == null && !node.webDomain.isNullOrBlank()) { result = result.copy(webDomain = node.webDomain) } val id = node.autofillId if (id != null && isFillable(node)) { val value = node.autofillValue?.takeIf { it.isText }?.textValue?.toString() ?: node.text?.toString() result = when (classify(node)) { FieldKind.PASSWORD -> if (result.passwordId == null) { val attrs = htmlAttributes(node) result.copy( passwordId = id, passwordValue = value, passwordPolicy = PasswordPolicy.fromHtmlAttributes(attrs) .takeIf { it.minLength != null || it.maxLength != null }, isNewPassword = attrs["autocomplete"] ?.contains("new-password", ignoreCase = true) == true, ) } else result FieldKind.USERNAME -> if (result.usernameId == null) result.copy(usernameId = id, usernameValue = value) else result FieldKind.EMAIL -> if (result.emailId == null) result.copy(emailId = id, emailValue = value) else result FieldKind.OTP -> if (result.otpId == null) result.copy(otpId = id, otpValue = value) else result FieldKind.CARD_NUMBER -> if (result.cardNumberId == null) result.copy(cardNumberId = id) else result FieldKind.CARD_EXP_MONTH -> if (result.cardExpMonthId == null) result.copy(cardExpMonthId = id) else result FieldKind.CARD_EXP_YEAR -> if (result.cardExpYearId == null) result.copy(cardExpYearId = id) else result FieldKind.CARD_EXP_DATE -> if (result.cardExpDateId == null) result.copy(cardExpDateId = id) else result FieldKind.CARD_CVC -> if (result.cardCvcId == null) result.copy(cardCvcId = id) else result FieldKind.CARD_NAME -> if (result.cardNameId == null) result.copy(cardNameId = id) else result FieldKind.NONE -> result } } for (i in 0 until node.childCount) { result = traverse(node.getChildAt(i), result) } return result } private fun isFillable(node: AssistStructure.ViewNode): Boolean = node.autofillType == View.AUTOFILL_TYPE_TEXT && node.visibility == View.VISIBLE /** * A web field's HTML attributes (minlength/maxlength/pattern/autocomplete…), * lower-cased by key. Empty for native views — [ViewNode.getHtmlInfo] is only * populated for web content, so policy detection degrades to "no constraint". */ private fun htmlAttributes(node: AssistStructure.ViewNode): Map { val info = node.htmlInfo ?: return emptyMap() val out = mutableMapOf() for (attr in info.attributes.orEmpty()) { val key = attr.first?.lowercase() ?: continue val value = attr.second ?: continue out[key] = value } return out } private enum class FieldKind { USERNAME, EMAIL, PASSWORD, OTP, CARD_NUMBER, CARD_EXP_MONTH, CARD_EXP_YEAR, CARD_EXP_DATE, CARD_CVC, CARD_NAME, NONE } private fun classify(node: AssistStructure.ViewNode): FieldKind { node.autofillHints?.forEach { hint -> when (hint.lowercase()) { View.AUTOFILL_HINT_PASSWORD, "password" -> return FieldKind.PASSWORD View.AUTOFILL_HINT_USERNAME, "username" -> return FieldKind.USERNAME View.AUTOFILL_HINT_EMAIL_ADDRESS, "email", "emailaddress" -> return FieldKind.EMAIL "otp", "smsotpcode", "2facode" -> return FieldKind.OTP View.AUTOFILL_HINT_CREDIT_CARD_NUMBER, "creditcardnumber", "ccnumber" -> return FieldKind.CARD_NUMBER View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH, "creditcardexpirationmonth" -> return FieldKind.CARD_EXP_MONTH View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR, "creditcardexpirationyear" -> return FieldKind.CARD_EXP_YEAR View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DATE, "creditcardexpirationdate" -> return FieldKind.CARD_EXP_DATE View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE, "creditcardsecuritycode", "ccsecuritycode" -> return FieldKind.CARD_CVC } } val type = node.inputType val isPasswordType = type and InputType.TYPE_MASK_VARIATION == InputType.TYPE_TEXT_VARIATION_PASSWORD || type and InputType.TYPE_MASK_VARIATION == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD || type and InputType.TYPE_MASK_VARIATION == InputType.TYPE_NUMBER_VARIATION_PASSWORD if (isPasswordType) return FieldKind.PASSWORD val haystack = listOfNotNull(node.idEntry, node.hint, node.text?.toString()) .joinToString(" ").lowercase() return when { haystack.isBlank() -> FieldKind.NONE Regex("\\b(otp|2fa|mfa|one[- ]?time|verification code|auth code)\\b").containsMatchIn(haystack) -> FieldKind.OTP haystack.contains("password") || haystack.contains("passwd") || haystack.contains("pwd") -> FieldKind.PASSWORD haystack.contains("email") || haystack.contains("e-mail") -> FieldKind.EMAIL haystack.contains("user") || haystack.contains("login") || haystack.contains("account") -> FieldKind.USERNAME CARD_NUMBER_RX.containsMatchIn(haystack) -> FieldKind.CARD_NUMBER CARD_EXP_MONTH_RX.containsMatchIn(haystack) -> FieldKind.CARD_EXP_MONTH CARD_EXP_YEAR_RX.containsMatchIn(haystack) -> FieldKind.CARD_EXP_YEAR CARD_EXP_DATE_RX.containsMatchIn(haystack) -> FieldKind.CARD_EXP_DATE CARD_CVC_RX.containsMatchIn(haystack) -> FieldKind.CARD_CVC CARD_NAME_RX.containsMatchIn(haystack) -> FieldKind.CARD_NAME else -> FieldKind.NONE } } private val CARD_NUMBER_RX = Regex("\\b(card\\s*number|credit\\s*card|card\\s*no|pan|cc\\s*num)\\b") private val CARD_EXP_MONTH_RX = Regex("\\b(exp\\s*month|expiration\\s*month|expiry\\s*month)\\b") private val CARD_EXP_YEAR_RX = Regex("\\b(exp\\s*year|expiration\\s*year|expiry\\s*year)\\b") private val CARD_EXP_DATE_RX = Regex("\\b(exp\\s*date|expiration\\s*date|expiry\\s*date|exp\\b)") private val CARD_CVC_RX = Regex("\\b(cvc|cvv|security\\s*code|card\\s*code)\\b") private val CARD_NAME_RX = Regex("\\b(cardholder|card\\s*holder|name\\s*on\\s*card|card\\s*name)\\b") } /** * The framework-owned activity component is the caller identity. Never infer * the package from a view node or an autofill hint: those are host-controlled * form metadata and cannot authenticate an origin. */ internal fun authenticatedPackageName(packageName: String?): String? = packageName?.takeIf { it.isNotBlank() }