AssistStructureParser.kt

5.5 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) {28    /** The id we put the account identifier into — email preferred, else username. */29    val identityId: AutofillId? get() = emailId ?: usernameId30    val hasUsableField: Boolean get() = passwordId != null || identityId != null || otpId != null31    /** Best label to rank entries against. */32    val origin: String? get() = webDomain ?: packageName33}3435/**36 * Walks an [AssistStructure] to classify username/email/password/OTP fields.37 * Mirrors the browser content-script's field detection: trust explicit autofill38 * hints first, then fall back to input-type and id/hint keyword heuristics so39 * unannotated login forms still work.40 */41object AssistStructureParser {4243    fun parse(structure: AssistStructure): ParsedStructure {44        // View-node package ids are supplied by the foreground app; the framework-owned45        // activity component is the authenticated caller identity.46        var result = ParsedStructure(47            packageName = authenticatedPackageName(structure.activityComponent?.packageName),48        )49        val windows = structure.windowNodeCount50        for (i in 0 until windows) {51            val root = structure.getWindowNodeAt(i).rootViewNode52            result = traverse(root, result)53        }54        return result55    }5657    private fun traverse(node: AssistStructure.ViewNode, acc: ParsedStructure): ParsedStructure {58        var result = acc59        if (result.webDomain == null && !node.webDomain.isNullOrBlank()) {60            result = result.copy(webDomain = node.webDomain)61        }6263        val id = node.autofillId64        if (id != null && isFillable(node)) {65            val value = node.autofillValue?.takeIf { it.isText }?.textValue?.toString()66                ?: node.text?.toString()67            result = when (classify(node)) {68                FieldKind.PASSWORD -> if (result.passwordId == null) result.copy(passwordId = id, passwordValue = value) else result69                FieldKind.USERNAME -> if (result.usernameId == null) result.copy(usernameId = id, usernameValue = value) else result70                FieldKind.EMAIL -> if (result.emailId == null) result.copy(emailId = id, emailValue = value) else result71                FieldKind.OTP -> if (result.otpId == null) result.copy(otpId = id, otpValue = value) else result72                FieldKind.NONE -> result73            }74        }7576        for (i in 0 until node.childCount) {77            result = traverse(node.getChildAt(i), result)78        }79        return result80    }8182    private fun isFillable(node: AssistStructure.ViewNode): Boolean =83        node.autofillType == View.AUTOFILL_TYPE_TEXT &&84            node.visibility == View.VISIBLE8586    private enum class FieldKind { USERNAME, EMAIL, PASSWORD, OTP, NONE }8788    private fun classify(node: AssistStructure.ViewNode): FieldKind {89        node.autofillHints?.forEach { hint ->90            when (hint.lowercase()) {91                View.AUTOFILL_HINT_PASSWORD, "password" -> return FieldKind.PASSWORD92                View.AUTOFILL_HINT_USERNAME, "username" -> return FieldKind.USERNAME93                View.AUTOFILL_HINT_EMAIL_ADDRESS, "email", "emailaddress" -> return FieldKind.EMAIL94                "otp", "smsotpcode", "2facode" -> return FieldKind.OTP95            }96        }9798        val type = node.inputType99        val isPasswordType = type and InputType.TYPE_MASK_VARIATION == InputType.TYPE_TEXT_VARIATION_PASSWORD ||100            type and InputType.TYPE_MASK_VARIATION == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD ||101            type and InputType.TYPE_MASK_VARIATION == InputType.TYPE_NUMBER_VARIATION_PASSWORD102        if (isPasswordType) return FieldKind.PASSWORD103104        val haystack = listOfNotNull(node.idEntry, node.hint, node.text?.toString())105            .joinToString(" ").lowercase()106        return when {107            haystack.isBlank() -> FieldKind.NONE108            Regex("\\b(otp|2fa|mfa|one[- ]?time|verification code|auth code)\\b").containsMatchIn(haystack) -> FieldKind.OTP109            haystack.contains("password") || haystack.contains("passwd") || haystack.contains("pwd") -> FieldKind.PASSWORD110            haystack.contains("email") || haystack.contains("e-mail") -> FieldKind.EMAIL111            haystack.contains("user") || haystack.contains("login") || haystack.contains("account") -> FieldKind.USERNAME112            else -> FieldKind.NONE113        }114    }115}116117/**118 * The framework-owned activity component is the caller identity. Never infer119 * the package from a view node or an autofill hint: those are host-controlled120 * form metadata and cannot authenticate an origin.121 */122internal fun authenticatedPackageName(packageName: String?): String? =123    packageName?.takeIf { it.isNotBlank() }