1package com.peachpasswords.autofill23import android.app.assist.AssistStructure4import android.text.InputType5import android.view.View6import android.view.autofill.AutofillId7891011121314151617data class ParsedStructure(18 val usernameId: AutofillId? = null,19 val emailId: AutofillId? = null,20 val passwordId: AutofillId? = null,21 val otpId: AutofillId? = null,22 23 val otpDigitBoxIds: List<AutofillId> = emptyList(),24 val usernameValue: String? = null,25 val emailValue: String? = null,26 val passwordValue: String? = null,27 val otpValue: String? = null,28 val passwordPolicy: PasswordPolicy? = null,29 val isNewPassword: Boolean = false,30 val webDomain: String? = null,31 val packageName: String? = null,32 val appTitle: String? = null,33 val originDomains: List<String> = emptyList(),34 val cardNumberId: AutofillId? = null,35 val cardExpMonthId: AutofillId? = null,36 val cardExpYearId: AutofillId? = null,37 val cardExpDateId: AutofillId? = null,38 val cardCvcId: AutofillId? = null,39 val cardNameId: AutofillId? = null,40) {41 42 val identityId: AutofillId? get() = emailId ?: usernameId43 val hasUsableField: Boolean get() = passwordId != null || identityId != null || otpId != null || otpDigitBoxIds.isNotEmpty() || hasCardField44 val hasCardField: Boolean get() = cardNumberId != null || cardExpMonthId != null ||45 cardExpYearId != null || cardExpDateId != null || cardCvcId != null || cardNameId != null46 47 val origin: String? get() = webDomain ?: packageName48}4950515253545556object AssistStructureParser {5758 fun parse(structure: AssistStructure): ParsedStructure {59 60 61 var result = ParsedStructure(62 packageName = authenticatedPackageName(structure.activityComponent?.packageName),63 )64 val windows = structure.windowNodeCount65 for (i in 0 until windows) {66 val root = structure.getWindowNodeAt(i).rootViewNode67 result = traverse(root, result)68 }69 70 if (result.otpDigitBoxIds.size !in 4..10) {71 result = result.copy(otpDigitBoxIds = emptyList())72 }73 return result74 }7576 private fun traverse(node: AssistStructure.ViewNode, acc: ParsedStructure): ParsedStructure {77 var result = acc78 if (result.webDomain == null && !node.webDomain.isNullOrBlank()) {79 result = result.copy(webDomain = node.webDomain)80 }8182 val id = node.autofillId83 if (id != null && isFillable(node)) {84 if (isDigitBoxCandidate(node)) {85 result = result.copy(otpDigitBoxIds = result.otpDigitBoxIds + id)86 } else {87 val value = node.autofillValue?.takeIf { it.isText }?.textValue?.toString()88 ?: node.text?.toString()89 result = when (classify(node)) {90 FieldKind.PASSWORD -> if (result.passwordId == null) {91 val attrs = htmlAttributes(node)92 result.copy(93 passwordId = id,94 passwordValue = value,95 passwordPolicy = PasswordPolicy.fromHtmlAttributes(attrs)96 .takeIf { it.minLength != null || it.maxLength != null },97 isNewPassword = attrs["autocomplete"]98 ?.contains("new-password", ignoreCase = true) == true,99 )100 } else result101 FieldKind.USERNAME -> if (result.usernameId == null) result.copy(usernameId = id, usernameValue = value) else result102 FieldKind.EMAIL -> if (result.emailId == null) result.copy(emailId = id, emailValue = value) else result103 FieldKind.OTP -> if (result.otpId == null) result.copy(otpId = id, otpValue = value) else result104 FieldKind.CARD_NUMBER -> if (result.cardNumberId == null) result.copy(cardNumberId = id) else result105 FieldKind.CARD_EXP_MONTH -> if (result.cardExpMonthId == null) result.copy(cardExpMonthId = id) else result106 FieldKind.CARD_EXP_YEAR -> if (result.cardExpYearId == null) result.copy(cardExpYearId = id) else result107 FieldKind.CARD_EXP_DATE -> if (result.cardExpDateId == null) result.copy(cardExpDateId = id) else result108 FieldKind.CARD_CVC -> if (result.cardCvcId == null) result.copy(cardCvcId = id) else result109 FieldKind.CARD_NAME -> if (result.cardNameId == null) result.copy(cardNameId = id) else result110 FieldKind.NONE -> result111 }112 }113 }114115 for (i in 0 until node.childCount) {116 result = traverse(node.getChildAt(i), result)117 }118 return result119 }120121 private fun isFillable(node: AssistStructure.ViewNode): Boolean =122 node.autofillType == View.AUTOFILL_TYPE_TEXT &&123 node.visibility == View.VISIBLE124125 126127128129130 private fun htmlAttributes(node: AssistStructure.ViewNode): Map<String, String> {131 val info = node.htmlInfo ?: return emptyMap()132 val out = mutableMapOf<String, String>()133 for (attr in info.attributes.orEmpty()) {134 val key = attr.first?.lowercase() ?: continue135 val value = attr.second ?: continue136 out[key] = value137 }138 return out139 }140141 142143144145146147148 private fun isDigitBoxCandidate(node: AssistStructure.ViewNode): Boolean {149 val attrs = htmlAttributes(node)150 if (attrs.isNotEmpty()) {151 if (attrs["maxlength"] != "1") return false152 return (attrs["type"]?.lowercase() ?: "") in setOf("", "text", "number", "tel")153 }154 if (node.autofillHints?.any { it.equals("smsOTPCode", true) || it.equals("oneTimeCode", true) } == true) {155 return true156 }157 val type = node.inputType158 if (type and InputType.TYPE_MASK_VARIATION == InputType.TYPE_NUMBER_VARIATION_PASSWORD) return false159 if (type and InputType.TYPE_MASK_CLASS != InputType.TYPE_CLASS_NUMBER) return false160 val haystack = listOfNotNull(node.idEntry, node.hint, node.text?.toString())161 .joinToString(" ").lowercase()162 return haystack.isBlank() ||163 Regex("\b(otp|2fa|mfa|one[- ]?time|verification code|auth code|pin)\b").containsMatchIn(haystack)164 }165166 private enum class FieldKind { USERNAME, EMAIL, PASSWORD, OTP, CARD_NUMBER, CARD_EXP_MONTH, CARD_EXP_YEAR, CARD_EXP_DATE, CARD_CVC, CARD_NAME, NONE }167168 private fun classify(node: AssistStructure.ViewNode): FieldKind {169 node.autofillHints?.forEach { hint ->170 when (hint.lowercase()) {171 View.AUTOFILL_HINT_PASSWORD, "password" -> return FieldKind.PASSWORD172 View.AUTOFILL_HINT_USERNAME, "username" -> return FieldKind.USERNAME173 View.AUTOFILL_HINT_EMAIL_ADDRESS, "email", "emailaddress" -> return FieldKind.EMAIL174 "otp", "smsotpcode", "2facode" -> return FieldKind.OTP175 View.AUTOFILL_HINT_CREDIT_CARD_NUMBER, "creditcardnumber", "ccnumber" -> return FieldKind.CARD_NUMBER176 View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH, "creditcardexpirationmonth" -> return FieldKind.CARD_EXP_MONTH177 View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR, "creditcardexpirationyear" -> return FieldKind.CARD_EXP_YEAR178 View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DATE, "creditcardexpirationdate" -> return FieldKind.CARD_EXP_DATE179 View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE, "creditcardsecuritycode", "ccsecuritycode" -> return FieldKind.CARD_CVC180 }181 }182183 val type = node.inputType184 val isPasswordType = type and InputType.TYPE_MASK_VARIATION == InputType.TYPE_TEXT_VARIATION_PASSWORD ||185 type and InputType.TYPE_MASK_VARIATION == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD ||186 type and InputType.TYPE_MASK_VARIATION == InputType.TYPE_NUMBER_VARIATION_PASSWORD187 if (isPasswordType) return FieldKind.PASSWORD188189 val haystack = listOfNotNull(node.idEntry, node.hint, node.text?.toString())190 .joinToString(" ").lowercase()191 return when {192 haystack.isBlank() -> FieldKind.NONE193 Regex("\\b(otp|2fa|mfa|one[- ]?time|verification code|auth code)\\b").containsMatchIn(haystack) -> FieldKind.OTP194 haystack.contains("password") || haystack.contains("passwd") || haystack.contains("pwd") -> FieldKind.PASSWORD195 haystack.contains("email") || haystack.contains("e-mail") -> FieldKind.EMAIL196 haystack.contains("user") || haystack.contains("login") || haystack.contains("account") -> FieldKind.USERNAME197 CARD_NUMBER_RX.containsMatchIn(haystack) -> FieldKind.CARD_NUMBER198 CARD_EXP_MONTH_RX.containsMatchIn(haystack) -> FieldKind.CARD_EXP_MONTH199 CARD_EXP_YEAR_RX.containsMatchIn(haystack) -> FieldKind.CARD_EXP_YEAR200 CARD_EXP_DATE_RX.containsMatchIn(haystack) -> FieldKind.CARD_EXP_DATE201 CARD_CVC_RX.containsMatchIn(haystack) -> FieldKind.CARD_CVC202 CARD_NAME_RX.containsMatchIn(haystack) -> FieldKind.CARD_NAME203 else -> FieldKind.NONE204 }205 }206207 private val CARD_NUMBER_RX = Regex("\\b(card\\s*number|credit\\s*card|card\\s*no|pan|cc\\s*num)\\b")208 private val CARD_EXP_MONTH_RX = Regex("\\b(exp\\s*month|expiration\\s*month|expiry\\s*month)\\b")209 private val CARD_EXP_YEAR_RX = Regex("\\b(exp\\s*year|expiration\\s*year|expiry\\s*year)\\b")210 private val CARD_EXP_DATE_RX = Regex("\\b(exp\\s*date|expiration\\s*date|expiry\\s*date|exp\\b)")211 private val CARD_CVC_RX = Regex("\\b(cvc|cvv|security\\s*code|card\\s*code)\\b")212 private val CARD_NAME_RX = Regex("\\b(cardholder|card\\s*holder|name\\s*on\\s*card|card\\s*name)\\b")213}214215216217218219220internal fun authenticatedPackageName(packageName: String?): String? =221 packageName?.takeIf { it.isNotBlank() }