1package com.peachpasswords.autofill23import android.app.assist.AssistStructure4import android.text.InputType5import android.view.View6import android.view.autofill.AutofillId78910111213141516data class ParsedStructure(17 val usernameId: AutofillId? = null,18 val emailId: AutofillId? = null,19 val passwordId: AutofillId? = null,20 val otpId: AutofillId? = null,21 22 val otpDigitBoxIds: List<AutofillId> = emptyList(),23 val usernameValue: String? = null,24 val emailValue: String? = null,25 val passwordValue: String? = null,26 val otpValue: String? = null,27 val passwordPolicy: PasswordPolicy? = null,28 val isNewPassword: Boolean = false,29 val webDomain: String? = null,30 val packageName: String? = null,31 val nativeSignerDigests: Set<String> = emptySet(),32 val originDomains: List<String> = emptyList(),33 34 val credentialReleaseAllowed: Boolean = true,35 val cardNumberId: AutofillId? = null,36 val cardExpMonthId: AutofillId? = null,37 val cardExpYearId: AutofillId? = null,38 val cardExpDateId: AutofillId? = null,39 val cardCvcId: AutofillId? = null,40 val cardNameId: AutofillId? = null,41) {42 43 val identityId: AutofillId? get() = emailId ?: usernameId44 454647484950 val hasCredentialField: Boolean get() = passwordId != null || otpId != null || otpDigitBoxIds.isNotEmpty() || hasCardField51 val hasCardField: Boolean get() = cardNumberId != null || cardExpMonthId != null ||52 cardExpYearId != null || cardExpDateId != null || cardCvcId != null || cardNameId != null53 54 val origin: String? get() = webDomain ?: packageName55}5657585960616263object AssistStructureParser {6465 fun parse(structure: AssistStructure): ParsedStructure {66 67 68 var result = ParsedStructure(69 packageName = authenticatedPackageName(structure.activityComponent?.packageName),70 )71 val windows = structure.windowNodeCount72 for (i in 0 until windows) {73 val root = structure.getWindowNodeAt(i).rootViewNode74 result = traverse(root, result)75 }76 77 if (result.otpDigitBoxIds.size !in 4..10) {78 result = result.copy(otpDigitBoxIds = emptyList())79 }80 return result81 }8283 private fun traverse(node: AssistStructure.ViewNode, acc: ParsedStructure): ParsedStructure {84 var result = acc85 if (result.webDomain == null && !node.webDomain.isNullOrBlank()) {86 result = result.copy(webDomain = node.webDomain)87 }8889 val id = node.autofillId90 if (id != null && isFillable(node)) {91 if (isDigitBoxCandidate(node)) {92 result = result.copy(otpDigitBoxIds = result.otpDigitBoxIds + id)93 } else {94 val value = node.autofillValue?.takeIf { it.isText }?.textValue?.toString()95 ?: node.text?.toString()96 result = when (classify(node)) {97 FieldKind.PASSWORD -> if (result.passwordId == null) {98 val attrs = htmlAttributes(node)99 result.copy(100 passwordId = id,101 passwordValue = value,102 passwordPolicy = PasswordPolicy.fromHtmlAttributes(attrs)103 .takeIf { it.minLength != null || it.maxLength != null },104 isNewPassword = attrs["autocomplete"]105 ?.contains("new-password", ignoreCase = true) == true,106 )107 } else result108 FieldKind.USERNAME -> if (result.usernameId == null) result.copy(usernameId = id, usernameValue = value) else result109 FieldKind.EMAIL -> if (result.emailId == null) result.copy(emailId = id, emailValue = value) else result110 FieldKind.OTP -> if (result.otpId == null) result.copy(otpId = id, otpValue = value) else result111 FieldKind.CARD_NUMBER -> if (result.cardNumberId == null) result.copy(cardNumberId = id) else result112 FieldKind.CARD_EXP_MONTH -> if (result.cardExpMonthId == null) result.copy(cardExpMonthId = id) else result113 FieldKind.CARD_EXP_YEAR -> if (result.cardExpYearId == null) result.copy(cardExpYearId = id) else result114 FieldKind.CARD_EXP_DATE -> if (result.cardExpDateId == null) result.copy(cardExpDateId = id) else result115 FieldKind.CARD_CVC -> if (result.cardCvcId == null) result.copy(cardCvcId = id) else result116 FieldKind.CARD_NAME -> if (result.cardNameId == null) result.copy(cardNameId = id) else result117 FieldKind.NONE -> result118 }119 }120 }121122 for (i in 0 until node.childCount) {123 result = traverse(node.getChildAt(i), result)124 }125 return result126 }127128 private fun isFillable(node: AssistStructure.ViewNode): Boolean =129 node.autofillType == View.AUTOFILL_TYPE_TEXT &&130 node.visibility == View.VISIBLE131132 133134135136137 private fun htmlAttributes(node: AssistStructure.ViewNode): Map<String, String> {138 val info = node.htmlInfo ?: return emptyMap()139 val out = mutableMapOf<String, String>()140 for (attr in info.attributes.orEmpty()) {141 val key = attr.first?.lowercase() ?: continue142 val value = attr.second ?: continue143 out[key] = value144 }145 return out146 }147148 149150151152153154155 private fun isDigitBoxCandidate(node: AssistStructure.ViewNode): Boolean {156 val attrs = htmlAttributes(node)157 if (attrs.isNotEmpty()) {158 if (attrs["maxlength"] != "1") return false159 return (attrs["type"]?.lowercase() ?: "") in setOf("", "text", "number", "tel")160 }161 if (nonNullHints(node.autofillHints).any { it.equals("smsOTPCode", true) || it.equals("oneTimeCode", true) }) {162 return true163 }164 val type = node.inputType165 if (type and InputType.TYPE_MASK_VARIATION == InputType.TYPE_NUMBER_VARIATION_PASSWORD) return false166 if (type and InputType.TYPE_MASK_CLASS != InputType.TYPE_CLASS_NUMBER) return false167 val haystack = listOfNotNull(node.idEntry, node.hint, node.text?.toString())168 .joinToString(" ").lowercase()169 return haystack.isBlank() || OTP_HAYSTACK_RX.containsMatchIn(haystack)170 }171172 private enum class FieldKind { USERNAME, EMAIL, PASSWORD, OTP, CARD_NUMBER, CARD_EXP_MONTH, CARD_EXP_YEAR, CARD_EXP_DATE, CARD_CVC, CARD_NAME, NONE }173174 175176177178179 internal fun nonNullHints(hints: Array<String?>?): List<String> = hints?.filterNotNull().orEmpty()180181 private fun classify(node: AssistStructure.ViewNode): FieldKind {182 nonNullHints(node.autofillHints).forEach { hint ->183 when (hint.lowercase()) {184 View.AUTOFILL_HINT_PASSWORD, "password" -> return FieldKind.PASSWORD185 View.AUTOFILL_HINT_USERNAME, "username" -> return FieldKind.USERNAME186 View.AUTOFILL_HINT_EMAIL_ADDRESS, "email", "emailaddress" -> return FieldKind.EMAIL187 "otp", "smsotpcode", "2facode",188 "one-time-password", "one_time_password", "totp" -> return FieldKind.OTP189 View.AUTOFILL_HINT_CREDIT_CARD_NUMBER, "creditcardnumber", "ccnumber" -> return FieldKind.CARD_NUMBER190 View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH, "creditcardexpirationmonth" -> return FieldKind.CARD_EXP_MONTH191 View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR, "creditcardexpirationyear" -> return FieldKind.CARD_EXP_YEAR192 View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DATE, "creditcardexpirationdate" -> return FieldKind.CARD_EXP_DATE193 View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE, "creditcardsecuritycode", "ccsecuritycode" -> return FieldKind.CARD_CVC194 }195 }196197 val type = node.inputType198 val isPasswordType = type and InputType.TYPE_MASK_VARIATION == InputType.TYPE_TEXT_VARIATION_PASSWORD ||199 type and InputType.TYPE_MASK_VARIATION == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD ||200 type and InputType.TYPE_MASK_VARIATION == InputType.TYPE_NUMBER_VARIATION_PASSWORD201 if (isPasswordType) return FieldKind.PASSWORD202203 val haystack = listOfNotNull(node.idEntry, node.hint, node.text?.toString())204 .joinToString(" ").lowercase()205 return when {206 haystack.isBlank() -> FieldKind.NONE207 208 209 210 211 212 213 OTP_HAYSTACK_RX.containsMatchIn(haystack) -> FieldKind.OTP214 haystack.contains("password") || haystack.contains("passwd") || haystack.contains("pwd") -> FieldKind.PASSWORD215 haystack.contains("email") || haystack.contains("e-mail") -> FieldKind.EMAIL216 haystack.contains("user") || haystack.contains("login") || haystack.contains("account") -> FieldKind.USERNAME217 CARD_NUMBER_RX.containsMatchIn(haystack) -> FieldKind.CARD_NUMBER218 CARD_EXP_MONTH_RX.containsMatchIn(haystack) -> FieldKind.CARD_EXP_MONTH219 CARD_EXP_YEAR_RX.containsMatchIn(haystack) -> FieldKind.CARD_EXP_YEAR220 CARD_EXP_DATE_RX.containsMatchIn(haystack) -> FieldKind.CARD_EXP_DATE221 CARD_CVC_RX.containsMatchIn(haystack) -> FieldKind.CARD_CVC222 CARD_NAME_RX.containsMatchIn(haystack) -> FieldKind.CARD_NAME223 else -> FieldKind.NONE224 }225 }226227 228229230231232 internal val OTP_HAYSTACK_RX = Regex(233 "\\b(otp|2fa|mfa|one[- ]?time|verification|auth(entication)?|security code|token|confirm)\\b",234 )235236 private val CARD_NUMBER_RX = Regex("\\b(card\\s*number|credit\\s*card|card\\s*no|pan|cc\\s*num)\\b")237 private val CARD_EXP_MONTH_RX = Regex("\\b(exp\\s*month|expiration\\s*month|expiry\\s*month)\\b")238 private val CARD_EXP_YEAR_RX = Regex("\\b(exp\\s*year|expiration\\s*year|expiry\\s*year)\\b")239 private val CARD_EXP_DATE_RX = Regex("\\b(exp\\s*date|expiration\\s*date|expiry\\s*date|exp\\b)")240 private val CARD_CVC_RX = Regex("\\b(cvc|cvv|security\\s*code|card\\s*code)\\b")241 private val CARD_NAME_RX = Regex("\\b(cardholder|card\\s*holder|name\\s*on\\s*card|card\\s*name)\\b")242}243244245246247248249internal fun authenticatedPackageName(packageName: String?): String? =250 packageName?.takeIf { it.isNotBlank() }