AssistStructureParser.kt

11.6 KB · raw

1package com.peachpasswords.autofill23import android.app.assist.AssistStructure4import android.text.InputType5import android.view.View6import android.view.autofill.AutofillId78/**9* The autofillable fields discovered in one [AssistStructure], plus the origin10* Android authenticated for ranking vault entries.11 *12 * Origin verification was removed (queue #3, June 2026-08-02): matches are13 * ranked by [originDomains] (the page domain, or a native app's app-link14 * domains) and [appTitle] (a native app's display label), with no caller-trust15 * gate. [originDomains]/[appTitle] are populated by the service, not the parser.16*/17data class ParsedStructure(18    val usernameId: AutofillId? = null,19    val emailId: AutofillId? = null,20    val passwordId: AutofillId? = null,21    val otpId: AutofillId? = null,22    /** Split single-character OTP boxes in traversal order (browser A6 parity). */23    val otpDigitBoxIds: List<AutofillId> = emptyList(),24    val usernameValue: String? = null,25    val emailValue: String? = null,26    val passwordValue: String? = null,27    val otpValue: String? = null,28    val passwordPolicy: PasswordPolicy? = null,29    val isNewPassword: Boolean = false,30    val webDomain: String? = null,31    val packageName: String? = null,32    val appTitle: String? = null,33    val originDomains: List<String> = emptyList(),34    val cardNumberId: AutofillId? = null,35    val cardExpMonthId: AutofillId? = null,36    val cardExpYearId: AutofillId? = null,37    val cardExpDateId: AutofillId? = null,38    val cardCvcId: AutofillId? = null,39    val cardNameId: AutofillId? = null,40) {41    /** The id we put the account identifier into — email preferred, else username. */42    val identityId: AutofillId? get() = emailId ?: usernameId43    val hasUsableField: Boolean get() = passwordId != null || identityId != null || otpId != null || otpDigitBoxIds.isNotEmpty() || hasCardField44    val hasCardField: Boolean get() = cardNumberId != null || cardExpMonthId != null ||45        cardExpYearId != null || cardExpDateId != null || cardCvcId != null || cardNameId != null46    /** Best label to rank entries against. */47    val origin: String? get() = webDomain ?: packageName48}4950/**51 * Walks an [AssistStructure] to classify username/email/password/OTP/card fields.52 * Mirrors the browser content-script's field detection: trust explicit autofill53 * hints first, then fall back to input-type and id/hint keyword heuristics so54 * unannotated login forms still work.55 */56object AssistStructureParser {5758    fun parse(structure: AssistStructure): ParsedStructure {59        // View-node package ids are supplied by the foreground app; the framework-owned60        // activity component is the authenticated caller identity.61        var result = ParsedStructure(62            packageName = authenticatedPackageName(structure.activityComponent?.packageName),63        )64        val windows = structure.windowNodeCount65        for (i in 0 until windows) {66            val root = structure.getWindowNodeAt(i).rootViewNode67            result = traverse(root, result)68        }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        }73        return result74    }7576    private fun traverse(node: AssistStructure.ViewNode, acc: ParsedStructure): ParsedStructure {77        var result = acc78        if (result.webDomain == null && !node.webDomain.isNullOrBlank()) {79            result = result.copy(webDomain = node.webDomain)80        }8182        val id = node.autofillId83        if (id != null && isFillable(node)) {84            if (isDigitBoxCandidate(node)) {85                result = result.copy(otpDigitBoxIds = result.otpDigitBoxIds + id)86            } else {87            val value = node.autofillValue?.takeIf { it.isText }?.textValue?.toString()88                ?: node.text?.toString()89            result = when (classify(node)) {90                FieldKind.PASSWORD -> if (result.passwordId == null) {91                    val attrs = htmlAttributes(node)92                    result.copy(93                        passwordId = id,94                        passwordValue = value,95                        passwordPolicy = PasswordPolicy.fromHtmlAttributes(attrs)96                            .takeIf { it.minLength != null || it.maxLength != null },97                        isNewPassword = attrs["autocomplete"]98                            ?.contains("new-password", ignoreCase = true) == true,99                    )100                } else result101                FieldKind.USERNAME -> if (result.usernameId == null) result.copy(usernameId = id, usernameValue = value) else result102                FieldKind.EMAIL -> if (result.emailId == null) result.copy(emailId = id, emailValue = value) else result103                FieldKind.OTP -> if (result.otpId == null) result.copy(otpId = id, otpValue = value) else result104                FieldKind.CARD_NUMBER -> if (result.cardNumberId == null) result.copy(cardNumberId = id) else result105                FieldKind.CARD_EXP_MONTH -> if (result.cardExpMonthId == null) result.copy(cardExpMonthId = id) else result106                FieldKind.CARD_EXP_YEAR -> if (result.cardExpYearId == null) result.copy(cardExpYearId = id) else result107                FieldKind.CARD_EXP_DATE -> if (result.cardExpDateId == null) result.copy(cardExpDateId = id) else result108                FieldKind.CARD_CVC -> if (result.cardCvcId == null) result.copy(cardCvcId = id) else result109                FieldKind.CARD_NAME -> if (result.cardNameId == null) result.copy(cardNameId = id) else result110                FieldKind.NONE -> result111            }112            }113        }114115        for (i in 0 until node.childCount) {116            result = traverse(node.getChildAt(i), result)117        }118        return result119    }120121    private fun isFillable(node: AssistStructure.ViewNode): Boolean =122        node.autofillType == View.AUTOFILL_TYPE_TEXT &&123            node.visibility == View.VISIBLE124125    /**126     * A web field's HTML attributes (minlength/maxlength/pattern/autocomplete…),127     * lower-cased by key. Empty for native views — [ViewNode.getHtmlInfo] is only128     * populated for web content, so policy detection degrades to "no constraint".129     */130    private fun htmlAttributes(node: AssistStructure.ViewNode): Map<String, String> {131        val info = node.htmlInfo ?: return emptyMap()132        val out = mutableMapOf<String, String>()133        for (attr in info.attributes.orEmpty()) {134            val key = attr.first?.lowercase() ?: continue135            val value = attr.second ?: continue136            out[key] = value137        }138        return out139    }140141    /**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)164    }165166    private enum class FieldKind { USERNAME, EMAIL, PASSWORD, OTP, CARD_NUMBER, CARD_EXP_MONTH, CARD_EXP_YEAR, CARD_EXP_DATE, CARD_CVC, CARD_NAME, NONE }167168    private fun classify(node: AssistStructure.ViewNode): FieldKind {169        node.autofillHints?.forEach { hint ->170            when (hint.lowercase()) {171                View.AUTOFILL_HINT_PASSWORD, "password" -> return FieldKind.PASSWORD172                View.AUTOFILL_HINT_USERNAME, "username" -> return FieldKind.USERNAME173                View.AUTOFILL_HINT_EMAIL_ADDRESS, "email", "emailaddress" -> return FieldKind.EMAIL174                "otp", "smsotpcode", "2facode" -> return FieldKind.OTP175                View.AUTOFILL_HINT_CREDIT_CARD_NUMBER, "creditcardnumber", "ccnumber" -> return FieldKind.CARD_NUMBER176                View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH, "creditcardexpirationmonth" -> return FieldKind.CARD_EXP_MONTH177                View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR, "creditcardexpirationyear" -> return FieldKind.CARD_EXP_YEAR178                View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DATE, "creditcardexpirationdate" -> return FieldKind.CARD_EXP_DATE179                View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE, "creditcardsecuritycode", "ccsecuritycode" -> return FieldKind.CARD_CVC180            }181        }182183        val type = node.inputType184        val isPasswordType = type and InputType.TYPE_MASK_VARIATION == InputType.TYPE_TEXT_VARIATION_PASSWORD ||185            type and InputType.TYPE_MASK_VARIATION == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD ||186            type and InputType.TYPE_MASK_VARIATION == InputType.TYPE_NUMBER_VARIATION_PASSWORD187        if (isPasswordType) return FieldKind.PASSWORD188189        val haystack = listOfNotNull(node.idEntry, node.hint, node.text?.toString())190            .joinToString(" ").lowercase()191        return when {192            haystack.isBlank() -> FieldKind.NONE193            Regex("\\b(otp|2fa|mfa|one[- ]?time|verification code|auth code)\\b").containsMatchIn(haystack) -> FieldKind.OTP194            haystack.contains("password") || haystack.contains("passwd") || haystack.contains("pwd") -> FieldKind.PASSWORD195            haystack.contains("email") || haystack.contains("e-mail") -> FieldKind.EMAIL196            haystack.contains("user") || haystack.contains("login") || haystack.contains("account") -> FieldKind.USERNAME197            CARD_NUMBER_RX.containsMatchIn(haystack) -> FieldKind.CARD_NUMBER198            CARD_EXP_MONTH_RX.containsMatchIn(haystack) -> FieldKind.CARD_EXP_MONTH199            CARD_EXP_YEAR_RX.containsMatchIn(haystack) -> FieldKind.CARD_EXP_YEAR200            CARD_EXP_DATE_RX.containsMatchIn(haystack) -> FieldKind.CARD_EXP_DATE201            CARD_CVC_RX.containsMatchIn(haystack) -> FieldKind.CARD_CVC202            CARD_NAME_RX.containsMatchIn(haystack) -> FieldKind.CARD_NAME203            else -> FieldKind.NONE204        }205    }206207    private val CARD_NUMBER_RX = Regex("\\b(card\\s*number|credit\\s*card|card\\s*no|pan|cc\\s*num)\\b")208    private val CARD_EXP_MONTH_RX = Regex("\\b(exp\\s*month|expiration\\s*month|expiry\\s*month)\\b")209    private val CARD_EXP_YEAR_RX = Regex("\\b(exp\\s*year|expiration\\s*year|expiry\\s*year)\\b")210    private val CARD_EXP_DATE_RX = Regex("\\b(exp\\s*date|expiration\\s*date|expiry\\s*date|exp\\b)")211    private val CARD_CVC_RX = Regex("\\b(cvc|cvv|security\\s*code|card\\s*code)\\b")212    private val CARD_NAME_RX = Regex("\\b(cardholder|card\\s*holder|name\\s*on\\s*card|card\\s*name)\\b")213}214215/**216 * The framework-owned activity component is the caller identity. Never infer217 * the package from a view node or an autofill hint: those are host-controlled218 * form metadata and cannot authenticate an origin.219 */220internal fun authenticatedPackageName(packageName: String?): String? =221    packageName?.takeIf { it.isNotBlank() }