AssistStructureParser.kt

4.5 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 webDomain: String? = null,18    val packageName: String? = null,19) {20    /** The id we put the account identifier into — email preferred, else username. */21    val identityId: AutofillId? get() = usernameId ?: emailId22    val hasUsableField: Boolean get() = passwordId != null || identityId != null || otpId != null23    /** Best label to rank entries against. */24    val origin: String? get() = webDomain ?: packageName25}2627/**28 * Walks an [AssistStructure] to classify username/email/password/OTP fields.29 * Mirrors the browser content-script's field detection: trust explicit autofill30 * hints first, then fall back to input-type and id/hint keyword heuristics so31 * unannotated login forms still work.32 */33object AssistStructureParser {3435    fun parse(structure: AssistStructure): ParsedStructure {36        var result = ParsedStructure()37        val windows = structure.windowNodeCount38        for (i in 0 until windows) {39            val root = structure.getWindowNodeAt(i).rootViewNode40            result = traverse(root, result)41        }42        return result43    }4445    private fun traverse(node: AssistStructure.ViewNode, acc: ParsedStructure): ParsedStructure {46        var result = acc47        if (result.webDomain == null && !node.webDomain.isNullOrBlank()) {48            result = result.copy(webDomain = node.webDomain)49        }50        if (result.packageName == null && !node.idPackage.isNullOrBlank()) {51            result = result.copy(packageName = node.idPackage)52        }5354        val id = node.autofillId55        if (id != null && isFillable(node)) {56            result = when (classify(node)) {57                FieldKind.PASSWORD -> if (result.passwordId == null) result.copy(passwordId = id) else result58                FieldKind.USERNAME -> if (result.usernameId == null) result.copy(usernameId = id) else result59                FieldKind.EMAIL -> if (result.emailId == null) result.copy(emailId = id) else result60                FieldKind.OTP -> if (result.otpId == null) result.copy(otpId = id) else result61                FieldKind.NONE -> result62            }63        }6465        for (i in 0 until node.childCount) {66            result = traverse(node.getChildAt(i), result)67        }68        return result69    }7071    private fun isFillable(node: AssistStructure.ViewNode): Boolean =72        node.autofillType == View.AUTOFILL_TYPE_TEXT &&73            node.visibility == View.VISIBLE7475    private enum class FieldKind { USERNAME, EMAIL, PASSWORD, OTP, NONE }7677    private fun classify(node: AssistStructure.ViewNode): FieldKind {78        node.autofillHints?.forEach { hint ->79            when (hint.lowercase()) {80                View.AUTOFILL_HINT_PASSWORD, "password" -> return FieldKind.PASSWORD81                View.AUTOFILL_HINT_USERNAME, "username" -> return FieldKind.USERNAME82                View.AUTOFILL_HINT_EMAIL_ADDRESS, "email", "emailaddress" -> return FieldKind.EMAIL83                "otp", "smsotpcode", "2facode" -> return FieldKind.OTP84            }85        }8687        val type = node.inputType88        val isPasswordType = type and InputType.TYPE_MASK_VARIATION == InputType.TYPE_TEXT_VARIATION_PASSWORD ||89            type and InputType.TYPE_MASK_VARIATION == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD ||90            type and InputType.TYPE_MASK_VARIATION == InputType.TYPE_NUMBER_VARIATION_PASSWORD91        if (isPasswordType) return FieldKind.PASSWORD9293        val haystack = listOfNotNull(node.idEntry, node.hint, node.text?.toString())94            .joinToString(" ").lowercase()95        return when {96            haystack.isBlank() -> FieldKind.NONE97            Regex("\\b(otp|2fa|mfa|one[- ]?time|verification code|auth code)\\b").containsMatchIn(haystack) -> FieldKind.OTP98            haystack.contains("password") || haystack.contains("passwd") || haystack.contains("pwd") -> FieldKind.PASSWORD99            haystack.contains("email") || haystack.contains("e-mail") -> FieldKind.EMAIL100            haystack.contains("user") || haystack.contains("login") || haystack.contains("account") -> FieldKind.USERNAME101            else -> FieldKind.NONE102        }103    }104}