AssistStructureParser.kt

13.1 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 * [webDomain] is untrusted form metadata. The service may use it only after13 * verifying the framework-authenticated package and its signing identity.14 * [originDomains]/[appTitle] are populated by the service, not the parser.15*/16data class ParsedStructure(17    val usernameId: AutofillId? = null,18    val emailId: AutofillId? = null,19    val passwordId: AutofillId? = null,20    val otpId: AutofillId? = null,21    /** Split single-character OTP boxes in traversal order (browser A6 parity). */22    val otpDigitBoxIds: List<AutofillId> = emptyList(),23    val usernameValue: String? = null,24    val emailValue: String? = null,25    val passwordValue: String? = null,26    val otpValue: String? = null,27    val passwordPolicy: PasswordPolicy? = null,28    val isNewPassword: Boolean = false,29    val webDomain: String? = null,30    val packageName: String? = null,31    val appTitle: String? = null,32    val originDomains: List<String> = emptyList(),33    /** False when an app claimed a web origin without a verified browser identity. */34    val credentialReleaseAllowed: Boolean = true,35    val cardNumberId: AutofillId? = null,36    val cardExpMonthId: AutofillId? = null,37    val cardExpYearId: AutofillId? = null,38    val cardExpDateId: AutofillId? = null,39    val cardCvcId: AutofillId? = null,40    val cardNameId: AutofillId? = null,41) {42    /** The id we put the account identifier into — email preferred, else username. */43    val identityId: AutofillId? get() = emailId ?: usernameId44    /**45     * Forms that earn an autofill response: a password field (login/signup),46     * an OTP field (2FA entry), or card fields. Bare text/identity fields —47     * chat composers, search boxes — never get a suggestion (the Discord48     * message-input false positive).49     */50    val hasCredentialField: Boolean get() = passwordId != null || otpId != null || otpDigitBoxIds.isNotEmpty() || hasCardField51    val hasCardField: Boolean get() = cardNumberId != null || cardExpMonthId != null ||52        cardExpYearId != null || cardExpDateId != null || cardCvcId != null || cardNameId != null53    /** Best label to rank entries against. */54    val origin: String? get() = webDomain ?: packageName55}5657/**58 * Walks an [AssistStructure] to classify username/email/password/OTP/card fields.59 * Mirrors the browser content-script's field detection: trust explicit autofill60 * hints first, then fall back to input-type and id/hint keyword heuristics so61 * unannotated login forms still work.62 */63object AssistStructureParser {6465    fun parse(structure: AssistStructure): ParsedStructure {66        // View-node package ids are supplied by the foreground app; the framework-owned67        // activity component is the authenticated caller identity.68        var result = ParsedStructure(69            packageName = authenticatedPackageName(structure.activityComponent?.packageName),70        )71        val windows = structure.windowNodeCount72        for (i in 0 until windows) {73            val root = structure.getWindowNodeAt(i).rootViewNode74            result = traverse(root, result)75        }76        // Browser parity: a split-OTP group is 4-10 single-character boxes.77        if (result.otpDigitBoxIds.size !in 4..10) {78            result = result.copy(otpDigitBoxIds = emptyList())79        }80        return result81    }8283    private fun traverse(node: AssistStructure.ViewNode, acc: ParsedStructure): ParsedStructure {84        var result = acc85        if (result.webDomain == null && !node.webDomain.isNullOrBlank()) {86            result = result.copy(webDomain = node.webDomain)87        }8889        val id = node.autofillId90        if (id != null && isFillable(node)) {91            if (isDigitBoxCandidate(node)) {92                result = result.copy(otpDigitBoxIds = result.otpDigitBoxIds + id)93            } else {94            val value = node.autofillValue?.takeIf { it.isText }?.textValue?.toString()95                ?: node.text?.toString()96            result = when (classify(node)) {97                FieldKind.PASSWORD -> if (result.passwordId == null) {98                    val attrs = htmlAttributes(node)99                    result.copy(100                        passwordId = id,101                        passwordValue = value,102                        passwordPolicy = PasswordPolicy.fromHtmlAttributes(attrs)103                            .takeIf { it.minLength != null || it.maxLength != null },104                        isNewPassword = attrs["autocomplete"]105                            ?.contains("new-password", ignoreCase = true) == true,106                    )107                } else result108                FieldKind.USERNAME -> if (result.usernameId == null) result.copy(usernameId = id, usernameValue = value) else result109                FieldKind.EMAIL -> if (result.emailId == null) result.copy(emailId = id, emailValue = value) else result110                FieldKind.OTP -> if (result.otpId == null) result.copy(otpId = id, otpValue = value) else result111                FieldKind.CARD_NUMBER -> if (result.cardNumberId == null) result.copy(cardNumberId = id) else result112                FieldKind.CARD_EXP_MONTH -> if (result.cardExpMonthId == null) result.copy(cardExpMonthId = id) else result113                FieldKind.CARD_EXP_YEAR -> if (result.cardExpYearId == null) result.copy(cardExpYearId = id) else result114                FieldKind.CARD_EXP_DATE -> if (result.cardExpDateId == null) result.copy(cardExpDateId = id) else result115                FieldKind.CARD_CVC -> if (result.cardCvcId == null) result.copy(cardCvcId = id) else result116                FieldKind.CARD_NAME -> if (result.cardNameId == null) result.copy(cardNameId = id) else result117                FieldKind.NONE -> result118            }119            }120        }121122        for (i in 0 until node.childCount) {123            result = traverse(node.getChildAt(i), result)124        }125        return result126    }127128    private fun isFillable(node: AssistStructure.ViewNode): Boolean =129        node.autofillType == View.AUTOFILL_TYPE_TEXT &&130            node.visibility == View.VISIBLE131132    /**133     * A web field's HTML attributes (minlength/maxlength/pattern/autocomplete…),134     * lower-cased by key. Empty for native views — [ViewNode.getHtmlInfo] is only135     * populated for web content, so policy detection degrades to "no constraint".136     */137    private fun htmlAttributes(node: AssistStructure.ViewNode): Map<String, String> {138        val info = node.htmlInfo ?: return emptyMap()139        val out = mutableMapOf<String, String>()140        for (attr in info.attributes.orEmpty()) {141            val key = attr.first?.lowercase() ?: continue142            val value = attr.second ?: continue143            out[key] = value144        }145        return out146    }147148    /**149     * A single-character input in a split-OTP group (browser's150     * findOTPDigitBoxGroup). Web: maxlength=1 with a text/number/tel type.151     * Native: numeric input type, but only when unlabeled or OTP-labeled —152     * labeled numeric fields (card number, expiry) still go through [classify].153     * The 4..10 group gate in [parse] keeps stray singles out.154     */155    private fun isDigitBoxCandidate(node: AssistStructure.ViewNode): Boolean {156        val attrs = htmlAttributes(node)157        if (attrs.isNotEmpty()) {158            if (attrs["maxlength"] != "1") return false159            return (attrs["type"]?.lowercase() ?: "") in setOf("", "text", "number", "tel")160        }161        if (nonNullHints(node.autofillHints).any { it.equals("smsOTPCode", true) || it.equals("oneTimeCode", true) }) {162            return true163        }164        val type = node.inputType165        if (type and InputType.TYPE_MASK_VARIATION == InputType.TYPE_NUMBER_VARIATION_PASSWORD) return false166        if (type and InputType.TYPE_MASK_CLASS != InputType.TYPE_CLASS_NUMBER) return false167        val haystack = listOfNotNull(node.idEntry, node.hint, node.text?.toString())168            .joinToString(" ").lowercase()169        return haystack.isBlank() ||170            Regex("\b(otp|2fa|mfa|one[- ]?time|verification code|auth code|pin)\b").containsMatchIn(haystack)171    }172173    private enum class FieldKind { USERNAME, EMAIL, PASSWORD, OTP, CARD_NUMBER, CARD_EXP_MONTH, CARD_EXP_YEAR, CARD_EXP_DATE, CARD_CVC, CARD_NAME, NONE }174175    /**176     * Third-party apps supply the autofillHints array and some ship null177     * entries (Facebook Messenger, 2026-08-07 Fold 7 crash loop). Hints are178     * untrusted input: drop nulls so classification never dereferences one.179     */180    internal fun nonNullHints(hints: Array<String?>?): List<String> = hints?.filterNotNull().orEmpty()181182    private fun classify(node: AssistStructure.ViewNode): FieldKind {183        nonNullHints(node.autofillHints).forEach { hint ->184            when (hint.lowercase()) {185                View.AUTOFILL_HINT_PASSWORD, "password" -> return FieldKind.PASSWORD186                View.AUTOFILL_HINT_USERNAME, "username" -> return FieldKind.USERNAME187                View.AUTOFILL_HINT_EMAIL_ADDRESS, "email", "emailaddress" -> return FieldKind.EMAIL188                "otp", "smsotpcode", "2facode",189                "one-time-password", "one_time_password", "totp" -> return FieldKind.OTP190                View.AUTOFILL_HINT_CREDIT_CARD_NUMBER, "creditcardnumber", "ccnumber" -> return FieldKind.CARD_NUMBER191                View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH, "creditcardexpirationmonth" -> return FieldKind.CARD_EXP_MONTH192                View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR, "creditcardexpirationyear" -> return FieldKind.CARD_EXP_YEAR193                View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DATE, "creditcardexpirationdate" -> return FieldKind.CARD_EXP_DATE194                View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE, "creditcardsecuritycode", "ccsecuritycode" -> return FieldKind.CARD_CVC195            }196        }197198        val type = node.inputType199        val isPasswordType = type and InputType.TYPE_MASK_VARIATION == InputType.TYPE_TEXT_VARIATION_PASSWORD ||200            type and InputType.TYPE_MASK_VARIATION == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD ||201            type and InputType.TYPE_MASK_VARIATION == InputType.TYPE_NUMBER_VARIATION_PASSWORD202        if (isPasswordType) return FieldKind.PASSWORD203204        val haystack = listOfNotNull(node.idEntry, node.hint, node.text?.toString())205            .joinToString(" ").lowercase()206        return when {207            haystack.isBlank() -> FieldKind.NONE208            // OTP/2FA haystack: cover the labels real 2FA forms actually use,209            // not just the textbook ones. The framework only sets "otp" hint210            // when the developer opts in to autofill; most don't, so the field211            // ends up with a generic label and a missed detection. Standalone212            // "code" is intentionally absent -- promo / coupon / discount codes213            // match it too and would mis-fill with a TOTP.214            OTP_HAYSTACK_RX.containsMatchIn(haystack) -> FieldKind.OTP215            haystack.contains("password") || haystack.contains("passwd") || haystack.contains("pwd") -> FieldKind.PASSWORD216            haystack.contains("email") || haystack.contains("e-mail") -> FieldKind.EMAIL217            haystack.contains("user") || haystack.contains("login") || haystack.contains("account") -> FieldKind.USERNAME218            CARD_NUMBER_RX.containsMatchIn(haystack) -> FieldKind.CARD_NUMBER219            CARD_EXP_MONTH_RX.containsMatchIn(haystack) -> FieldKind.CARD_EXP_MONTH220            CARD_EXP_YEAR_RX.containsMatchIn(haystack) -> FieldKind.CARD_EXP_YEAR221            CARD_EXP_DATE_RX.containsMatchIn(haystack) -> FieldKind.CARD_EXP_DATE222            CARD_CVC_RX.containsMatchIn(haystack) -> FieldKind.CARD_CVC223            CARD_NAME_RX.containsMatchIn(haystack) -> FieldKind.CARD_NAME224            else -> FieldKind.NONE225        }226    }227228    /**229     * Heuristic for OTP / 2FA fields. Covers the labels real 2FA forms230     * actually use -- not just the textbook ones. Standalone "code" is231     * intentionally absent (promo / coupon / discount codes would mis-match).232     */233    internal val OTP_HAYSTACK_RX = Regex(234        "\\b(otp|2fa|mfa|one[- ]?time|verification|auth(entication)?|security code|token|confirm)\\b",235    )236237    private val CARD_NUMBER_RX = Regex("\\b(card\\s*number|credit\\s*card|card\\s*no|pan|cc\\s*num)\\b")238    private val CARD_EXP_MONTH_RX = Regex("\\b(exp\\s*month|expiration\\s*month|expiry\\s*month)\\b")239    private val CARD_EXP_YEAR_RX = Regex("\\b(exp\\s*year|expiration\\s*year|expiry\\s*year)\\b")240    private val CARD_EXP_DATE_RX = Regex("\\b(exp\\s*date|expiration\\s*date|expiry\\s*date|exp\\b)")241    private val CARD_CVC_RX = Regex("\\b(cvc|cvv|security\\s*code|card\\s*code)\\b")242    private val CARD_NAME_RX = Regex("\\b(cardholder|card\\s*holder|name\\s*on\\s*card|card\\s*name)\\b")243}244245/**246 * The framework-owned activity component is the caller identity. Never infer247 * the package from a view node or an autofill hint: those are host-controlled248 * form metadata and cannot authenticate an origin.249 */250internal fun authenticatedPackageName(packageName: String?): String? =251    packageName?.takeIf { it.isNotBlank() }