AssistStructureParser.kt

4.9 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 * (web domain for browsers, package name otherwise) used to rank vault entries.11 */12data class ParsedStructure(13    val usernameId: AutofillId? = null,14    val emailId: AutofillId? = null,15    val passwordId: AutofillId? = null,16    val otpId: AutofillId? = null,17    val usernameValue: String? = null,18    val emailValue: String? = null,19    val passwordValue: String? = null,20    val otpValue: String? = null,21    val webDomain: String? = null,22    val packageName: String? = null,23) {24    /** The id we put the account identifier into — email preferred, else username. */25    val identityId: AutofillId? get() = emailId ?: usernameId26    val hasUsableField: Boolean get() = passwordId != null || identityId != null || otpId != null27    /** Best label to rank entries against. */28    val origin: String? get() = webDomain ?: packageName29}3031/**32 * Walks an [AssistStructure] to classify username/email/password/OTP fields.33 * Mirrors the browser content-script's field detection: trust explicit autofill34 * hints first, then fall back to input-type and id/hint keyword heuristics so35 * unannotated login forms still work.36 */37object AssistStructureParser {3839    fun parse(structure: AssistStructure): ParsedStructure {40        var result = ParsedStructure()41        val windows = structure.windowNodeCount42        for (i in 0 until windows) {43            val root = structure.getWindowNodeAt(i).rootViewNode44            result = traverse(root, result)45        }46        return result47    }4849    private fun traverse(node: AssistStructure.ViewNode, acc: ParsedStructure): ParsedStructure {50        var result = acc51        if (result.webDomain == null && !node.webDomain.isNullOrBlank()) {52            result = result.copy(webDomain = node.webDomain)53        }54        if (result.packageName == null && !node.idPackage.isNullOrBlank()) {55            result = result.copy(packageName = node.idPackage)56        }5758        val id = node.autofillId59        if (id != null && isFillable(node)) {60            val value = node.autofillValue?.takeIf { it.isText }?.textValue?.toString()61                ?: node.text?.toString()62            result = when (classify(node)) {63                FieldKind.PASSWORD -> if (result.passwordId == null) result.copy(passwordId = id, passwordValue = value) else result64                FieldKind.USERNAME -> if (result.usernameId == null) result.copy(usernameId = id, usernameValue = value) else result65                FieldKind.EMAIL -> if (result.emailId == null) result.copy(emailId = id, emailValue = value) else result66                FieldKind.OTP -> if (result.otpId == null) result.copy(otpId = id, otpValue = value) else result67                FieldKind.NONE -> result68            }69        }7071        for (i in 0 until node.childCount) {72            result = traverse(node.getChildAt(i), result)73        }74        return result75    }7677    private fun isFillable(node: AssistStructure.ViewNode): Boolean =78        node.autofillType == View.AUTOFILL_TYPE_TEXT &&79            node.visibility == View.VISIBLE8081    private enum class FieldKind { USERNAME, EMAIL, PASSWORD, OTP, NONE }8283    private fun classify(node: AssistStructure.ViewNode): FieldKind {84        node.autofillHints?.forEach { hint ->85            when (hint.lowercase()) {86                View.AUTOFILL_HINT_PASSWORD, "password" -> return FieldKind.PASSWORD87                View.AUTOFILL_HINT_USERNAME, "username" -> return FieldKind.USERNAME88                View.AUTOFILL_HINT_EMAIL_ADDRESS, "email", "emailaddress" -> return FieldKind.EMAIL89                "otp", "smsotpcode", "2facode" -> return FieldKind.OTP90            }91        }9293        val type = node.inputType94        val isPasswordType = type and InputType.TYPE_MASK_VARIATION == InputType.TYPE_TEXT_VARIATION_PASSWORD ||95            type and InputType.TYPE_MASK_VARIATION == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD ||96            type and InputType.TYPE_MASK_VARIATION == InputType.TYPE_NUMBER_VARIATION_PASSWORD97        if (isPasswordType) return FieldKind.PASSWORD9899        val haystack = listOfNotNull(node.idEntry, node.hint, node.text?.toString())100            .joinToString(" ").lowercase()101        return when {102            haystack.isBlank() -> FieldKind.NONE103            Regex("\\b(otp|2fa|mfa|one[- ]?time|verification code|auth code)\\b").containsMatchIn(haystack) -> FieldKind.OTP104            haystack.contains("password") || haystack.contains("passwd") || haystack.contains("pwd") -> FieldKind.PASSWORD105            haystack.contains("email") || haystack.contains("e-mail") -> FieldKind.EMAIL106            haystack.contains("user") || haystack.contains("login") || haystack.contains("account") -> FieldKind.USERNAME107            else -> FieldKind.NONE108        }109    }110}