AssistStructureParser.kt

8.4 KB · raw

1package com.peachpasswords.autofill23import android.app.assist.AssistStructure4import android.text.InputType5import android.view.View6import android.view.autofill.AutofillId78enum class AutofillOriginTrust { TRUSTED_WEB, TRUSTED_NATIVE, UNKNOWN }910/**11 * The autofillable fields discovered in one [AssistStructure], plus the origin12 * Android authenticated for ranking vault entries.13 */14data class ParsedStructure(15    val usernameId: AutofillId? = null,16    val emailId: AutofillId? = null,17    val passwordId: AutofillId? = null,18    val otpId: AutofillId? = null,19    val usernameValue: String? = null,20    val emailValue: String? = null,21    val passwordValue: String? = null,22    val otpValue: String? = null,23    val webDomain: String? = null,24    val packageName: String? = null,25    val originTrust: AutofillOriginTrust = AutofillOriginTrust.UNKNOWN,26    val verifiedDomains: List<String> = emptyList(),27    val cardNumberId: AutofillId? = null,28    val cardExpMonthId: AutofillId? = null,29    val cardExpYearId: AutofillId? = null,30    val cardExpDateId: AutofillId? = null,31    val cardCvcId: AutofillId? = null,32    val cardNameId: AutofillId? = null,33) {34    /** The id we put the account identifier into — email preferred, else username. */35    val identityId: AutofillId? get() = emailId ?: usernameId36    val hasUsableField: Boolean get() = passwordId != null || identityId != null || otpId != null || hasCardField37    val hasCardField: Boolean get() = cardNumberId != null || cardExpMonthId != null ||38        cardExpYearId != null || cardExpDateId != null || cardCvcId != null || cardNameId != null39    /** Best label to rank entries against. */40    val origin: String? get() = webDomain ?: packageName41}4243/**44 * Walks an [AssistStructure] to classify username/email/password/OTP/card fields.45 * Mirrors the browser content-script's field detection: trust explicit autofill46 * hints first, then fall back to input-type and id/hint keyword heuristics so47 * unannotated login forms still work.48 */49object AssistStructureParser {5051    fun parse(structure: AssistStructure): ParsedStructure {52        // View-node package ids are supplied by the foreground app; the framework-owned53        // activity component is the authenticated caller identity.54        var result = ParsedStructure(55            packageName = authenticatedPackageName(structure.activityComponent?.packageName),56        )57        val windows = structure.windowNodeCount58        for (i in 0 until windows) {59            val root = structure.getWindowNodeAt(i).rootViewNode60            result = traverse(root, result)61        }62        return result63    }6465    private fun traverse(node: AssistStructure.ViewNode, acc: ParsedStructure): ParsedStructure {66        var result = acc67        if (result.webDomain == null && !node.webDomain.isNullOrBlank()) {68            result = result.copy(webDomain = node.webDomain)69        }7071        val id = node.autofillId72        if (id != null && isFillable(node)) {73            val value = node.autofillValue?.takeIf { it.isText }?.textValue?.toString()74                ?: node.text?.toString()75            result = when (classify(node)) {76                FieldKind.PASSWORD -> if (result.passwordId == null) result.copy(passwordId = id, passwordValue = value) else result77                FieldKind.USERNAME -> if (result.usernameId == null) result.copy(usernameId = id, usernameValue = value) else result78                FieldKind.EMAIL -> if (result.emailId == null) result.copy(emailId = id, emailValue = value) else result79                FieldKind.OTP -> if (result.otpId == null) result.copy(otpId = id, otpValue = value) else result80                FieldKind.CARD_NUMBER -> if (result.cardNumberId == null) result.copy(cardNumberId = id) else result81                FieldKind.CARD_EXP_MONTH -> if (result.cardExpMonthId == null) result.copy(cardExpMonthId = id) else result82                FieldKind.CARD_EXP_YEAR -> if (result.cardExpYearId == null) result.copy(cardExpYearId = id) else result83                FieldKind.CARD_EXP_DATE -> if (result.cardExpDateId == null) result.copy(cardExpDateId = id) else result84                FieldKind.CARD_CVC -> if (result.cardCvcId == null) result.copy(cardCvcId = id) else result85                FieldKind.CARD_NAME -> if (result.cardNameId == null) result.copy(cardNameId = id) else result86                FieldKind.NONE -> result87            }88        }8990        for (i in 0 until node.childCount) {91            result = traverse(node.getChildAt(i), result)92        }93        return result94    }9596    private fun isFillable(node: AssistStructure.ViewNode): Boolean =97        node.autofillType == View.AUTOFILL_TYPE_TEXT &&98            node.visibility == View.VISIBLE99100    private enum class FieldKind { USERNAME, EMAIL, PASSWORD, OTP, CARD_NUMBER, CARD_EXP_MONTH, CARD_EXP_YEAR, CARD_EXP_DATE, CARD_CVC, CARD_NAME, NONE }101102    private fun classify(node: AssistStructure.ViewNode): FieldKind {103        node.autofillHints?.forEach { hint ->104            when (hint.lowercase()) {105                View.AUTOFILL_HINT_PASSWORD, "password" -> return FieldKind.PASSWORD106                View.AUTOFILL_HINT_USERNAME, "username" -> return FieldKind.USERNAME107                View.AUTOFILL_HINT_EMAIL_ADDRESS, "email", "emailaddress" -> return FieldKind.EMAIL108                "otp", "smsotpcode", "2facode" -> return FieldKind.OTP109                View.AUTOFILL_HINT_CREDIT_CARD_NUMBER, "creditcardnumber", "ccnumber" -> return FieldKind.CARD_NUMBER110                View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH, "creditcardexpirationmonth" -> return FieldKind.CARD_EXP_MONTH111                View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR, "creditcardexpirationyear" -> return FieldKind.CARD_EXP_YEAR112                View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DATE, "creditcardexpirationdate" -> return FieldKind.CARD_EXP_DATE113                View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE, "creditcardsecuritycode", "ccsecuritycode" -> return FieldKind.CARD_CVC114            }115        }116117        val type = node.inputType118        val isPasswordType = type and InputType.TYPE_MASK_VARIATION == InputType.TYPE_TEXT_VARIATION_PASSWORD ||119            type and InputType.TYPE_MASK_VARIATION == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD ||120            type and InputType.TYPE_MASK_VARIATION == InputType.TYPE_NUMBER_VARIATION_PASSWORD121        if (isPasswordType) return FieldKind.PASSWORD122123        val haystack = listOfNotNull(node.idEntry, node.hint, node.text?.toString())124            .joinToString(" ").lowercase()125        return when {126            haystack.isBlank() -> FieldKind.NONE127            Regex("\\b(otp|2fa|mfa|one[- ]?time|verification code|auth code)\\b").containsMatchIn(haystack) -> FieldKind.OTP128            haystack.contains("password") || haystack.contains("passwd") || haystack.contains("pwd") -> FieldKind.PASSWORD129            haystack.contains("email") || haystack.contains("e-mail") -> FieldKind.EMAIL130            haystack.contains("user") || haystack.contains("login") || haystack.contains("account") -> FieldKind.USERNAME131            CARD_NUMBER_RX.containsMatchIn(haystack) -> FieldKind.CARD_NUMBER132            CARD_EXP_MONTH_RX.containsMatchIn(haystack) -> FieldKind.CARD_EXP_MONTH133            CARD_EXP_YEAR_RX.containsMatchIn(haystack) -> FieldKind.CARD_EXP_YEAR134            CARD_EXP_DATE_RX.containsMatchIn(haystack) -> FieldKind.CARD_EXP_DATE135            CARD_CVC_RX.containsMatchIn(haystack) -> FieldKind.CARD_CVC136            CARD_NAME_RX.containsMatchIn(haystack) -> FieldKind.CARD_NAME137            else -> FieldKind.NONE138        }139    }140141    private val CARD_NUMBER_RX = Regex("\\b(card\\s*number|credit\\s*card|card\\s*no|pan|cc\\s*num)\\b")142    private val CARD_EXP_MONTH_RX = Regex("\\b(exp\\s*month|expiration\\s*month|expiry\\s*month)\\b")143    private val CARD_EXP_YEAR_RX = Regex("\\b(exp\\s*year|expiration\\s*year|expiry\\s*year)\\b")144    private val CARD_EXP_DATE_RX = Regex("\\b(exp\\s*date|expiration\\s*date|expiry\\s*date|exp\\b)")145    private val CARD_CVC_RX = Regex("\\b(cvc|cvv|security\\s*code|card\\s*code)\\b")146    private val CARD_NAME_RX = Regex("\\b(cardholder|card\\s*holder|name\\s*on\\s*card|card\\s*name)\\b")147}148149/**150 * The framework-owned activity component is the caller identity. Never infer151 * the package from a view node or an autofill hint: those are host-controlled152 * form metadata and cannot authenticate an origin.153 */154internal fun authenticatedPackageName(packageName: String?): String? =155    packageName?.takeIf { it.isNotBlank() }