diff options
Diffstat (limited to 'autofill-parser/src')
12 files changed, 1572 insertions, 1612 deletions
diff --git a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillFormParser.kt b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillFormParser.kt index 248db2c7..fb951cf8 100644 --- a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillFormParser.kt +++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillFormParser.kt @@ -19,41 +19,40 @@ import com.github.ajalt.timberkt.d */ public sealed class FormOrigin(public open val identifier: String) { - public data class Web(override val identifier: String) : FormOrigin(identifier) - public data class App(override val identifier: String) : FormOrigin(identifier) - - public companion object { - - private const val BUNDLE_KEY_WEB_IDENTIFIER = "webIdentifier" - private const val BUNDLE_KEY_APP_IDENTIFIER = "appIdentifier" - - public fun fromBundle(bundle: Bundle): FormOrigin? { - val webIdentifier = bundle.getString(BUNDLE_KEY_WEB_IDENTIFIER) - if (webIdentifier != null) { - return Web(webIdentifier) - } else { - return App(bundle.getString(BUNDLE_KEY_APP_IDENTIFIER) ?: return null) - } - } + public data class Web(override val identifier: String) : FormOrigin(identifier) + public data class App(override val identifier: String) : FormOrigin(identifier) + + public companion object { + + private const val BUNDLE_KEY_WEB_IDENTIFIER = "webIdentifier" + private const val BUNDLE_KEY_APP_IDENTIFIER = "appIdentifier" + + public fun fromBundle(bundle: Bundle): FormOrigin? { + val webIdentifier = bundle.getString(BUNDLE_KEY_WEB_IDENTIFIER) + if (webIdentifier != null) { + return Web(webIdentifier) + } else { + return App(bundle.getString(BUNDLE_KEY_APP_IDENTIFIER) ?: return null) + } + } + } + + public fun getPrettyIdentifier(context: Context, untrusted: Boolean = true): String = + when (this) { + is Web -> identifier + is App -> { + val info = context.packageManager.getApplicationInfo(identifier, PackageManager.GET_META_DATA) + val label = context.packageManager.getApplicationLabel(info) + if (untrusted) "“$label”" else "$label" + } } - public fun getPrettyIdentifier(context: Context, untrusted: Boolean = true): String = - when (this) { - is Web -> identifier - is App -> { - val info = context.packageManager.getApplicationInfo( - identifier, PackageManager.GET_META_DATA - ) - val label = context.packageManager.getApplicationLabel(info) - if (untrusted) "“$label”" else "$label" - } - } - - public fun toBundle(): Bundle = Bundle().apply { - when (this@FormOrigin) { - is Web -> putString(BUNDLE_KEY_WEB_IDENTIFIER, identifier) - is App -> putString(BUNDLE_KEY_APP_IDENTIFIER, identifier) - } + public fun toBundle(): Bundle = + Bundle().apply { + when (this@FormOrigin) { + is Web -> putString(BUNDLE_KEY_WEB_IDENTIFIER, identifier) + is App -> putString(BUNDLE_KEY_APP_IDENTIFIER, identifier) + } } } @@ -62,126 +61,123 @@ public sealed class FormOrigin(public open val identifier: String) { */ @RequiresApi(Build.VERSION_CODES.O) private class AutofillFormParser( - context: Context, - structure: AssistStructure, - isManualRequest: Boolean, - private val customSuffixes: Sequence<String> + context: Context, + structure: AssistStructure, + isManualRequest: Boolean, + private val customSuffixes: Sequence<String> ) { - companion object { - private val SUPPORTED_SCHEMES = listOf("http", "https") - } + companion object { + private val SUPPORTED_SCHEMES = listOf("http", "https") + } - private val relevantFields = mutableListOf<FormField>() - val ignoredIds = mutableListOf<AutofillId>() - private var fieldIndex = 0 + private val relevantFields = mutableListOf<FormField>() + val ignoredIds = mutableListOf<AutofillId>() + private var fieldIndex = 0 - private var appPackage = structure.activityComponent.packageName + private var appPackage = structure.activityComponent.packageName - private val trustedBrowserInfo = - getBrowserAutofillSupportInfoIfTrusted(context, appPackage) - val saveFlags = trustedBrowserInfo?.saveFlags + private val trustedBrowserInfo = getBrowserAutofillSupportInfoIfTrusted(context, appPackage) + val saveFlags = trustedBrowserInfo?.saveFlags - private val webOrigins = mutableSetOf<String>() + private val webOrigins = mutableSetOf<String>() - init { - d { "Request from $appPackage (${computeCertificatesHash(context, appPackage)})" } - parseStructure(structure) - } + init { + d { "Request from $appPackage (${computeCertificatesHash(context, appPackage)})" } + parseStructure(structure) + } - val scenario = detectFieldsToFill(isManualRequest) - val formOrigin = determineFormOrigin(context) + val scenario = detectFieldsToFill(isManualRequest) + val formOrigin = determineFormOrigin(context) - init { - d { "Origin: $formOrigin" } - } + init { + d { "Origin: $formOrigin" } + } - private fun parseStructure(structure: AssistStructure) { - for (i in 0 until structure.windowNodeCount) { - visitFormNode(structure.getWindowNodeAt(i).rootViewNode) - } + private fun parseStructure(structure: AssistStructure) { + for (i in 0 until structure.windowNodeCount) { + visitFormNode(structure.getWindowNodeAt(i).rootViewNode) } - - private fun visitFormNode(node: AssistStructure.ViewNode, inheritedWebOrigin: String? = null) { - trackOrigin(node) - val field = - if (trustedBrowserInfo?.multiOriginMethod == BrowserMultiOriginMethod.WebView) { - FormField(node, fieldIndex, true, inheritedWebOrigin) - } else { - check(inheritedWebOrigin == null) - FormField(node, fieldIndex, false) - } - if (field.relevantField) { - d { "Relevant: $field" } - relevantFields.add(field) - fieldIndex++ - } else { - d { "Ignored : $field" } - ignoredIds.add(field.autofillId) - } - for (i in 0 until node.childCount) { - visitFormNode(node.getChildAt(i), field.webOriginToPassDown) - } + } + + private fun visitFormNode(node: AssistStructure.ViewNode, inheritedWebOrigin: String? = null) { + trackOrigin(node) + val field = + if (trustedBrowserInfo?.multiOriginMethod == BrowserMultiOriginMethod.WebView) { + FormField(node, fieldIndex, true, inheritedWebOrigin) + } else { + check(inheritedWebOrigin == null) + FormField(node, fieldIndex, false) + } + if (field.relevantField) { + d { "Relevant: $field" } + relevantFields.add(field) + fieldIndex++ + } else { + d { "Ignored : $field" } + ignoredIds.add(field.autofillId) + } + for (i in 0 until node.childCount) { + visitFormNode(node.getChildAt(i), field.webOriginToPassDown) } + } - private fun detectFieldsToFill(isManualRequest: Boolean) = autofillStrategy.match( - relevantFields, - singleOriginMode = trustedBrowserInfo?.multiOriginMethod == BrowserMultiOriginMethod.None, - isManualRequest = isManualRequest + private fun detectFieldsToFill(isManualRequest: Boolean) = + autofillStrategy.match( + relevantFields, + singleOriginMode = trustedBrowserInfo?.multiOriginMethod == BrowserMultiOriginMethod.None, + isManualRequest = isManualRequest ) - private fun trackOrigin(node: AssistStructure.ViewNode) { - if (trustedBrowserInfo == null) return - node.webOrigin?.let { - if (it !in webOrigins) { - d { "Origin encountered: $it" } - webOrigins.add(it) - } - } + private fun trackOrigin(node: AssistStructure.ViewNode) { + if (trustedBrowserInfo == null) return + node.webOrigin?.let { + if (it !in webOrigins) { + d { "Origin encountered: $it" } + webOrigins.add(it) + } } - - private fun webOriginToFormOrigin(context: Context, origin: String): FormOrigin? { - val uri = Uri.parse(origin) ?: return null - val scheme = uri.scheme ?: return null - if (scheme !in SUPPORTED_SCHEMES) return null - val host = uri.host ?: return null - return FormOrigin.Web(getPublicSuffixPlusOne(context, host, customSuffixes)) + } + + private fun webOriginToFormOrigin(context: Context, origin: String): FormOrigin? { + val uri = Uri.parse(origin) ?: return null + val scheme = uri.scheme ?: return null + if (scheme !in SUPPORTED_SCHEMES) return null + val host = uri.host ?: return null + return FormOrigin.Web(getPublicSuffixPlusOne(context, host, customSuffixes)) + } + + private fun determineFormOrigin(context: Context): FormOrigin? { + if (scenario == null) return null + if (trustedBrowserInfo == null || webOrigins.isEmpty()) { + // Security assumption: If a trusted browser includes no web origin in the provided + // AssistStructure, then the form is a native browser form (e.g. for a sync password). + // TODO: Support WebViews in apps via Digital Asset Links + // See: + // https://developer.android.com/reference/android/service/autofill/AutofillService#web-security + return FormOrigin.App(appPackage) } - - private fun determineFormOrigin(context: Context): FormOrigin? { - if (scenario == null) return null - if (trustedBrowserInfo == null || webOrigins.isEmpty()) { - // Security assumption: If a trusted browser includes no web origin in the provided - // AssistStructure, then the form is a native browser form (e.g. for a sync password). - // TODO: Support WebViews in apps via Digital Asset Links - // See: https://developer.android.com/reference/android/service/autofill/AutofillService#web-security - return FormOrigin.App(appPackage) - } - return when (trustedBrowserInfo.multiOriginMethod) { - BrowserMultiOriginMethod.None -> { - // Security assumption: If a browser is trusted but does not support tracking - // multiple origins, it is expected to annotate a single field, in most cases its - // URL bar, with a webOrigin. We err on the side of caution and only trust the - // reported web origin if no other web origin appears on the page. - webOriginToFormOrigin(context, webOrigins.singleOrNull() ?: return null) - } - BrowserMultiOriginMethod.WebView, - BrowserMultiOriginMethod.Field -> { - // Security assumption: For browsers with full autofill support (the `Field` case), - // every form field is annotated with its origin. For browsers based on WebView, - // this is true after the web origins of WebViews are passed down to their children. - // - // For browsers with the WebView or Field method of multi origin support, we take - // the single origin among the detected fillable or saveable fields. If this origin - // is null, but we encountered web origins elsewhere in the AssistStructure, the - // situation is uncertain and Autofill should not be offered. - webOriginToFormOrigin( - context, - scenario.allFields.map { it.webOrigin }.toSet().singleOrNull() ?: return null - ) - } - } + return when (trustedBrowserInfo.multiOriginMethod) { + BrowserMultiOriginMethod.None -> { + // Security assumption: If a browser is trusted but does not support tracking + // multiple origins, it is expected to annotate a single field, in most cases its + // URL bar, with a webOrigin. We err on the side of caution and only trust the + // reported web origin if no other web origin appears on the page. + webOriginToFormOrigin(context, webOrigins.singleOrNull() ?: return null) + } + BrowserMultiOriginMethod.WebView, BrowserMultiOriginMethod.Field -> { + // Security assumption: For browsers with full autofill support (the `Field` case), + // every form field is annotated with its origin. For browsers based on WebView, + // this is true after the web origins of WebViews are passed down to their children. + // + // For browsers with the WebView or Field method of multi origin support, we take + // the single origin among the detected fillable or saveable fields. If this origin + // is null, but we encountered web origins elsewhere in the AssistStructure, the + // situation is uncertain and Autofill should not be offered. + webOriginToFormOrigin(context, scenario.allFields.map { it.webOrigin }.toSet().singleOrNull() ?: return null) + } } + } } public data class Credentials(val username: String?, val password: String?, val otp: String?) @@ -191,29 +187,26 @@ public data class Credentials(val username: String?, val password: String?, val * entry point to all fill and save features. */ @RequiresApi(Build.VERSION_CODES.O) -public class FillableForm private constructor( - public val formOrigin: FormOrigin, - public val scenario: AutofillScenario<AutofillId>, - public val ignoredIds: List<AutofillId>, - public val saveFlags: Int? +public class FillableForm +private constructor( + public val formOrigin: FormOrigin, + public val scenario: AutofillScenario<AutofillId>, + public val ignoredIds: List<AutofillId>, + public val saveFlags: Int? ) { - public companion object { - /** - * Returns a [FillableForm] if a login form could be detected in [structure]. - */ - public fun parseAssistStructure( - context: Context, - structure: AssistStructure, - isManualRequest: Boolean, - customSuffixes: Sequence<String> = emptySequence(), - ): FillableForm? { - val form = AutofillFormParser(context, structure, isManualRequest, customSuffixes) - if (form.formOrigin == null || form.scenario == null) return null - return FillableForm(form.formOrigin, form.scenario.map { it.autofillId }, form.ignoredIds, form.saveFlags) - } + public companion object { + /** Returns a [FillableForm] if a login form could be detected in [structure]. */ + public fun parseAssistStructure( + context: Context, + structure: AssistStructure, + isManualRequest: Boolean, + customSuffixes: Sequence<String> = emptySequence(), + ): FillableForm? { + val form = AutofillFormParser(context, structure, isManualRequest, customSuffixes) + if (form.formOrigin == null || form.scenario == null) return null + return FillableForm(form.formOrigin, form.scenario.map { it.autofillId }, form.ignoredIds, form.saveFlags) } + } - public fun toClientState(): Bundle = scenario.toBundle().apply { - putAll(formOrigin.toBundle()) - } + public fun toClientState(): Bundle = scenario.toBundle().apply { putAll(formOrigin.toBundle()) } } diff --git a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillHelper.kt b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillHelper.kt index c60a00ec..7f193cfc 100644 --- a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillHelper.kt +++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillHelper.kt @@ -20,19 +20,19 @@ import com.github.ajalt.timberkt.e import java.security.MessageDigest private fun ByteArray.sha256(): ByteArray { - return MessageDigest.getInstance("SHA-256").run { - update(this@sha256) - digest() - } + return MessageDigest.getInstance("SHA-256").run { + update(this@sha256) + digest() + } } private fun ByteArray.base64(): String { - return Base64.encodeToString(this, Base64.NO_WRAP) + return Base64.encodeToString(this, Base64.NO_WRAP) } private fun stableHash(array: Collection<ByteArray>): String { - val hashes = array.map { it.sha256().base64() } - return hashes.sorted().joinToString(separator = ";") + val hashes = array.map { it.sha256().base64() } + return hashes.sorted().joinToString(separator = ";") } /** @@ -43,25 +43,22 @@ private fun stableHash(array: Collection<ByteArray>): String { * returns all of them in sorted order and separated with `;`. */ public fun computeCertificatesHash(context: Context, appPackage: String): String { - // The warning does not apply since 1) we are specifically hashing **all** signatures and 2) it - // no longer applies to Android 4.4+. - // Even though there is a new way to get the certificates as of Android Pie, we need to keep - // hashes comparable between versions and hence default to using the deprecated API. - @SuppressLint("PackageManagerGetSignatures") - @Suppress("DEPRECATION") - val signaturesOld = - context.packageManager.getPackageInfo(appPackage, PackageManager.GET_SIGNATURES).signatures - val stableHashOld = stableHash(signaturesOld.map { it.toByteArray() }) - if (Build.VERSION.SDK_INT >= 28) { - val info = context.packageManager.getPackageInfo( - appPackage, PackageManager.GET_SIGNING_CERTIFICATES - ) - val signaturesNew = - info.signingInfo.signingCertificateHistory ?: info.signingInfo.apkContentsSigners - val stableHashNew = stableHash(signaturesNew.map { it.toByteArray() }) - if (stableHashNew != stableHashOld) tag("CertificatesHash").e { "Mismatch between old and new hash: $stableHashNew != $stableHashOld" } - } - return stableHashOld + // The warning does not apply since 1) we are specifically hashing **all** signatures and 2) it + // no longer applies to Android 4.4+. + // Even though there is a new way to get the certificates as of Android Pie, we need to keep + // hashes comparable between versions and hence default to using the deprecated API. + @SuppressLint("PackageManagerGetSignatures") + @Suppress("DEPRECATION") + val signaturesOld = context.packageManager.getPackageInfo(appPackage, PackageManager.GET_SIGNATURES).signatures + val stableHashOld = stableHash(signaturesOld.map { it.toByteArray() }) + if (Build.VERSION.SDK_INT >= 28) { + val info = context.packageManager.getPackageInfo(appPackage, PackageManager.GET_SIGNING_CERTIFICATES) + val signaturesNew = info.signingInfo.signingCertificateHistory ?: info.signingInfo.apkContentsSigners + val stableHashNew = stableHash(signaturesNew.map { it.toByteArray() }) + if (stableHashNew != stableHashOld) + tag("CertificatesHash").e { "Mismatch between old and new hash: $stableHashNew != $stableHashOld" } + } + return stableHashOld } /** @@ -69,59 +66,56 @@ public fun computeCertificatesHash(context: Context, appPackage: String): String * its `webDomain` and `webScheme`, if available. */ internal val AssistStructure.ViewNode.webOrigin: String? - @RequiresApi(Build.VERSION_CODES.O) get() = webDomain?.let { domain -> - val scheme = (if (Build.VERSION.SDK_INT >= 28) webScheme else null) ?: "https" - "$scheme://$domain" + @RequiresApi(Build.VERSION_CODES.O) + get() = + webDomain?.let { domain -> + val scheme = (if (Build.VERSION.SDK_INT >= 28) webScheme else null) ?: "https" + "$scheme://$domain" } @RequiresApi(Build.VERSION_CODES.O) public class FixedSaveCallback(context: Context, private val callback: SaveCallback) { - private val applicationContext = context.applicationContext + private val applicationContext = context.applicationContext - public fun onFailure(message: CharSequence) { - callback.onFailure(message) - // When targeting SDK 29, the message is no longer shown as a toast. - // See https://developer.android.com/reference/android/service/autofill/SaveCallback#onFailure(java.lang.CharSequence) - if (applicationContext.applicationInfo.targetSdkVersion >= 29) { - Toast.makeText(applicationContext, message, Toast.LENGTH_LONG).show() - } + public fun onFailure(message: CharSequence) { + callback.onFailure(message) + // When targeting SDK 29, the message is no longer shown as a toast. + // See + // https://developer.android.com/reference/android/service/autofill/SaveCallback#onFailure(java.lang.CharSequence) + if (applicationContext.applicationInfo.targetSdkVersion >= 29) { + Toast.makeText(applicationContext, message, Toast.LENGTH_LONG).show() } + } - public fun onSuccess(intentSender: IntentSender) { - if (Build.VERSION.SDK_INT >= 28) { - callback.onSuccess(intentSender) - } else { - callback.onSuccess() - // On SDKs < 28, we cannot advise the Autofill framework to launch the save intent in - // the context of the app that triggered the save request. Hence, we launch it here. - applicationContext.startIntentSender(intentSender, null, 0, 0, 0) - } + public fun onSuccess(intentSender: IntentSender) { + if (Build.VERSION.SDK_INT >= 28) { + callback.onSuccess(intentSender) + } else { + callback.onSuccess() + // On SDKs < 28, we cannot advise the Autofill framework to launch the save intent in + // the context of the app that triggered the save request. Hence, we launch it here. + applicationContext.startIntentSender(intentSender, null, 0, 0, 0) } + } } private fun visitViewNodes(structure: AssistStructure, block: (AssistStructure.ViewNode) -> Unit) { - for (i in 0 until structure.windowNodeCount) { - visitViewNode(structure.getWindowNodeAt(i).rootViewNode, block) - } + for (i in 0 until structure.windowNodeCount) { + visitViewNode(structure.getWindowNodeAt(i).rootViewNode, block) + } } -private fun visitViewNode( - node: AssistStructure.ViewNode, - block: (AssistStructure.ViewNode) -> Unit -) { - block(node) - for (i in 0 until node.childCount) { - visitViewNode(node.getChildAt(i), block) - } +private fun visitViewNode(node: AssistStructure.ViewNode, block: (AssistStructure.ViewNode) -> Unit) { + block(node) + for (i in 0 until node.childCount) { + visitViewNode(node.getChildAt(i), block) + } } @RequiresApi(Build.VERSION_CODES.O) internal fun AssistStructure.findNodeByAutofillId(autofillId: AutofillId): AssistStructure.ViewNode? { - var node: AssistStructure.ViewNode? = null - visitViewNodes(this) { - if (it.autofillId == autofillId) - node = it - } - return node + var node: AssistStructure.ViewNode? = null + visitViewNodes(this) { if (it.autofillId == autofillId) node = it } + return node } diff --git a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillScenario.kt b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillScenario.kt index ad26a36c..7df5d9c5 100644 --- a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillScenario.kt +++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillScenario.kt @@ -14,7 +14,10 @@ import androidx.annotation.RequiresApi import com.github.ajalt.timberkt.e public enum class AutofillAction { - Match, Search, Generate, FillOtpFromSms + Match, + Search, + Generate, + FillOtpFromSms } /** @@ -26,276 +29,270 @@ public enum class AutofillAction { @RequiresApi(Build.VERSION_CODES.O) public sealed class AutofillScenario<out T : Any> { - public companion object { + public companion object { - internal const val BUNDLE_KEY_USERNAME_ID = "usernameId" - internal const val BUNDLE_KEY_FILL_USERNAME = "fillUsername" - internal const val BUNDLE_KEY_OTP_ID = "otpId" - internal const val BUNDLE_KEY_CURRENT_PASSWORD_IDS = "currentPasswordIds" - internal const val BUNDLE_KEY_NEW_PASSWORD_IDS = "newPasswordIds" - internal const val BUNDLE_KEY_GENERIC_PASSWORD_IDS = "genericPasswordIds" + internal const val BUNDLE_KEY_USERNAME_ID = "usernameId" + internal const val BUNDLE_KEY_FILL_USERNAME = "fillUsername" + internal const val BUNDLE_KEY_OTP_ID = "otpId" + internal const val BUNDLE_KEY_CURRENT_PASSWORD_IDS = "currentPasswordIds" + internal const val BUNDLE_KEY_NEW_PASSWORD_IDS = "newPasswordIds" + internal const val BUNDLE_KEY_GENERIC_PASSWORD_IDS = "genericPasswordIds" - @Deprecated("Use `fromClientState` instead.", ReplaceWith("fromClientState(clientState)", "com.github.androidpasswordstore.autofillparser.AutofillScenario.Companion.fromClientState")) - public fun fromBundle(clientState: Bundle): AutofillScenario<AutofillId>? { - return fromClientState(clientState) - } + @Deprecated( + "Use `fromClientState` instead.", + ReplaceWith( + "fromClientState(clientState)", + "com.github.androidpasswordstore.autofillparser.AutofillScenario.Companion.fromClientState" + ) + ) + public fun fromBundle(clientState: Bundle): AutofillScenario<AutofillId>? { + return fromClientState(clientState) + } - public fun fromClientState(clientState: Bundle): AutofillScenario<AutofillId>? { - return try { - Builder<AutofillId>().apply { - username = clientState.getParcelable(BUNDLE_KEY_USERNAME_ID) - fillUsername = clientState.getBoolean(BUNDLE_KEY_FILL_USERNAME) - otp = clientState.getParcelable(BUNDLE_KEY_OTP_ID) - currentPassword.addAll( - clientState.getParcelableArrayList( - BUNDLE_KEY_CURRENT_PASSWORD_IDS - ) ?: emptyList() - ) - newPassword.addAll( - clientState.getParcelableArrayList( - BUNDLE_KEY_NEW_PASSWORD_IDS - ) ?: emptyList() - ) - genericPassword.addAll( - clientState.getParcelableArrayList( - BUNDLE_KEY_GENERIC_PASSWORD_IDS - ) ?: emptyList() - ) - }.build() - } catch(e: Throwable) { - e(e) - null - } - } + public fun fromClientState(clientState: Bundle): AutofillScenario<AutofillId>? { + return try { + Builder<AutofillId>() + .apply { + username = clientState.getParcelable(BUNDLE_KEY_USERNAME_ID) + fillUsername = clientState.getBoolean(BUNDLE_KEY_FILL_USERNAME) + otp = clientState.getParcelable(BUNDLE_KEY_OTP_ID) + currentPassword.addAll(clientState.getParcelableArrayList(BUNDLE_KEY_CURRENT_PASSWORD_IDS) ?: emptyList()) + newPassword.addAll(clientState.getParcelableArrayList(BUNDLE_KEY_NEW_PASSWORD_IDS) ?: emptyList()) + genericPassword.addAll(clientState.getParcelableArrayList(BUNDLE_KEY_GENERIC_PASSWORD_IDS) ?: emptyList()) + } + .build() + } catch (e: Throwable) { + e(e) + null + } } + } - internal class Builder<T : Any> { + internal class Builder<T : Any> { - var username: T? = null - var fillUsername = false - var otp: T? = null - val currentPassword = mutableListOf<T>() - val newPassword = mutableListOf<T>() - val genericPassword = mutableListOf<T>() + var username: T? = null + var fillUsername = false + var otp: T? = null + val currentPassword = mutableListOf<T>() + val newPassword = mutableListOf<T>() + val genericPassword = mutableListOf<T>() - fun build(): AutofillScenario<T> { - require(genericPassword.isEmpty() || (currentPassword.isEmpty() && newPassword.isEmpty())) - return if (currentPassword.isNotEmpty() || newPassword.isNotEmpty()) { - ClassifiedAutofillScenario( - username = username, - fillUsername = fillUsername, - otp = otp, - currentPassword = currentPassword, - newPassword = newPassword - ) - } else { - GenericAutofillScenario( - username = username, - fillUsername = fillUsername, - otp = otp, - genericPassword = genericPassword - ) - } - } + fun build(): AutofillScenario<T> { + require(genericPassword.isEmpty() || (currentPassword.isEmpty() && newPassword.isEmpty())) + return if (currentPassword.isNotEmpty() || newPassword.isNotEmpty()) { + ClassifiedAutofillScenario( + username = username, + fillUsername = fillUsername, + otp = otp, + currentPassword = currentPassword, + newPassword = newPassword + ) + } else { + GenericAutofillScenario( + username = username, + fillUsername = fillUsername, + otp = otp, + genericPassword = genericPassword + ) + } } + } - public abstract val username: T? - public abstract val passwordFieldsToSave: List<T> + public abstract val username: T? + public abstract val passwordFieldsToSave: List<T> - internal abstract val otp: T? - internal abstract val allPasswordFields: List<T> - internal abstract val fillUsername: Boolean - internal abstract val passwordFieldsToFillOnMatch: List<T> - internal abstract val passwordFieldsToFillOnSearch: List<T> - internal abstract val passwordFieldsToFillOnGenerate: List<T> + internal abstract val otp: T? + internal abstract val allPasswordFields: List<T> + internal abstract val fillUsername: Boolean + internal abstract val passwordFieldsToFillOnMatch: List<T> + internal abstract val passwordFieldsToFillOnSearch: List<T> + internal abstract val passwordFieldsToFillOnGenerate: List<T> - public val fieldsToSave: List<T> - get() = listOfNotNull(username) + passwordFieldsToSave + public val fieldsToSave: List<T> + get() = listOfNotNull(username) + passwordFieldsToSave - internal val allFields: List<T> - get() = listOfNotNull(username, otp) + allPasswordFields + internal val allFields: List<T> + get() = listOfNotNull(username, otp) + allPasswordFields - internal fun fieldsToFillOn(action: AutofillAction): List<T> { - val credentialFieldsToFill = when (action) { - AutofillAction.Match -> passwordFieldsToFillOnMatch + listOfNotNull(otp) - AutofillAction.Search -> passwordFieldsToFillOnSearch + listOfNotNull(otp) - AutofillAction.Generate -> passwordFieldsToFillOnGenerate - AutofillAction.FillOtpFromSms -> listOfNotNull(otp) - } - return when { - action == AutofillAction.FillOtpFromSms -> { - // When filling from an SMS, we cannot get any data other than the OTP itself. - credentialFieldsToFill - } - credentialFieldsToFill.isNotEmpty() -> { - // If the current action would fill into any password field, we also fill into the - // username field if possible. - listOfNotNull(username.takeIf { fillUsername }) + credentialFieldsToFill - } - allPasswordFields.isEmpty() && action != AutofillAction.Generate -> { - // If there no password fields at all, we still offer to fill the username, e.g. in - // two-step login scenarios, but we do not offer to generate a password. - listOfNotNull(username.takeIf { fillUsername }) - } - else -> emptyList() - } + internal fun fieldsToFillOn(action: AutofillAction): List<T> { + val credentialFieldsToFill = + when (action) { + AutofillAction.Match -> passwordFieldsToFillOnMatch + listOfNotNull(otp) + AutofillAction.Search -> passwordFieldsToFillOnSearch + listOfNotNull(otp) + AutofillAction.Generate -> passwordFieldsToFillOnGenerate + AutofillAction.FillOtpFromSms -> listOfNotNull(otp) + } + return when { + action == AutofillAction.FillOtpFromSms -> { + // When filling from an SMS, we cannot get any data other than the OTP itself. + credentialFieldsToFill + } + credentialFieldsToFill.isNotEmpty() -> { + // If the current action would fill into any password field, we also fill into the + // username field if possible. + listOfNotNull(username.takeIf { fillUsername }) + credentialFieldsToFill + } + allPasswordFields.isEmpty() && action != AutofillAction.Generate -> { + // If there no password fields at all, we still offer to fill the username, e.g. in + // two-step login scenarios, but we do not offer to generate a password. + listOfNotNull(username.takeIf { fillUsername }) + } + else -> emptyList() } + } - public fun hasFieldsToFillOn(action: AutofillAction): Boolean { - return fieldsToFillOn(action).isNotEmpty() - } + public fun hasFieldsToFillOn(action: AutofillAction): Boolean { + return fieldsToFillOn(action).isNotEmpty() + } - public val hasFieldsToSave: Boolean - get() = fieldsToSave.isNotEmpty() + public val hasFieldsToSave: Boolean + get() = fieldsToSave.isNotEmpty() - public val hasPasswordFieldsToSave: Boolean - get() = fieldsToSave.minus(listOfNotNull(username)).isNotEmpty() + public val hasPasswordFieldsToSave: Boolean + get() = fieldsToSave.minus(listOfNotNull(username)).isNotEmpty() - public val hasUsername: Boolean - get() = username != null + public val hasUsername: Boolean + get() = username != null } @RequiresApi(Build.VERSION_CODES.O) internal data class ClassifiedAutofillScenario<T : Any>( - override val username: T?, - override val fillUsername: Boolean, - override val otp: T?, - val currentPassword: List<T>, - val newPassword: List<T> + override val username: T?, + override val fillUsername: Boolean, + override val otp: T?, + val currentPassword: List<T>, + val newPassword: List<T> ) : AutofillScenario<T>() { - override val allPasswordFields - get() = currentPassword + newPassword - override val passwordFieldsToFillOnMatch - get() = currentPassword - override val passwordFieldsToFillOnSearch - get() = currentPassword - override val passwordFieldsToFillOnGenerate - get() = newPassword - override val passwordFieldsToSave - get() = if (newPassword.isNotEmpty()) newPassword else currentPassword + override val allPasswordFields + get() = currentPassword + newPassword + override val passwordFieldsToFillOnMatch + get() = currentPassword + override val passwordFieldsToFillOnSearch + get() = currentPassword + override val passwordFieldsToFillOnGenerate + get() = newPassword + override val passwordFieldsToSave + get() = if (newPassword.isNotEmpty()) newPassword else currentPassword } @RequiresApi(Build.VERSION_CODES.O) internal data class GenericAutofillScenario<T : Any>( - override val username: T?, - override val fillUsername: Boolean, - override val otp: T?, - val genericPassword: List<T> + override val username: T?, + override val fillUsername: Boolean, + override val otp: T?, + val genericPassword: List<T> ) : AutofillScenario<T>() { - override val allPasswordFields - get() = genericPassword - override val passwordFieldsToFillOnMatch - get() = if (genericPassword.size == 1) genericPassword else emptyList() - override val passwordFieldsToFillOnSearch - get() = if (genericPassword.size == 1) genericPassword else emptyList() - override val passwordFieldsToFillOnGenerate - get() = genericPassword - override val passwordFieldsToSave - get() = genericPassword + override val allPasswordFields + get() = genericPassword + override val passwordFieldsToFillOnMatch + get() = if (genericPassword.size == 1) genericPassword else emptyList() + override val passwordFieldsToFillOnSearch + get() = if (genericPassword.size == 1) genericPassword else emptyList() + override val passwordFieldsToFillOnGenerate + get() = genericPassword + override val passwordFieldsToSave + get() = genericPassword } internal fun AutofillScenario<FormField>.passesOriginCheck(singleOriginMode: Boolean): Boolean { - return if (singleOriginMode) { - // In single origin mode, only the browsers URL bar (which is never filled) should have - // a webOrigin. - allFields.all { it.webOrigin == null } - } else { - // In apps or browsers in multi origin mode, every field in a dataset has to belong to - // the same (possibly null) origin. - allFields.map { it.webOrigin }.toSet().size == 1 - } + return if (singleOriginMode) { + // In single origin mode, only the browsers URL bar (which is never filled) should have + // a webOrigin. + allFields.all { it.webOrigin == null } + } else { + // In apps or browsers in multi origin mode, every field in a dataset has to belong to + // the same (possibly null) origin. + allFields.map { it.webOrigin }.toSet().size == 1 + } } @RequiresApi(Build.VERSION_CODES.O) @JvmName("fillWithAutofillId") public fun Dataset.Builder.fillWith( - scenario: AutofillScenario<AutofillId>, - action: AutofillAction, - credentials: Credentials? + scenario: AutofillScenario<AutofillId>, + action: AutofillAction, + credentials: Credentials? ) { - val credentialsToFill = credentials ?: Credentials( - "USERNAME", - "PASSWORD", - "OTP" - ) - for (field in scenario.fieldsToFillOn(action)) { - val value = when (field) { - scenario.username -> credentialsToFill.username - scenario.otp -> credentialsToFill.otp - else -> credentialsToFill.password - } - setValue(field, AutofillValue.forText(value)) - } + val credentialsToFill = credentials ?: Credentials("USERNAME", "PASSWORD", "OTP") + for (field in scenario.fieldsToFillOn(action)) { + val value = + when (field) { + scenario.username -> credentialsToFill.username + scenario.otp -> credentialsToFill.otp + else -> credentialsToFill.password + } + setValue(field, AutofillValue.forText(value)) + } } internal inline fun <T : Any, S : Any> AutofillScenario<T>.map(transform: (T) -> S): AutofillScenario<S> { - val builder = AutofillScenario.Builder<S>() - builder.username = username?.let(transform) - builder.fillUsername = fillUsername - builder.otp = otp?.let(transform) - when (this) { - is ClassifiedAutofillScenario -> { - builder.currentPassword.addAll(currentPassword.map(transform)) - builder.newPassword.addAll(newPassword.map(transform)) - } - is GenericAutofillScenario -> { - builder.genericPassword.addAll(genericPassword.map(transform)) - } + val builder = AutofillScenario.Builder<S>() + builder.username = username?.let(transform) + builder.fillUsername = fillUsername + builder.otp = otp?.let(transform) + when (this) { + is ClassifiedAutofillScenario -> { + builder.currentPassword.addAll(currentPassword.map(transform)) + builder.newPassword.addAll(newPassword.map(transform)) } - return builder.build() + is GenericAutofillScenario -> { + builder.genericPassword.addAll(genericPassword.map(transform)) + } + } + return builder.build() } @RequiresApi(Build.VERSION_CODES.O) @JvmName("toBundleAutofillId") -internal fun AutofillScenario<AutofillId>.toBundle(): Bundle = when (this) { +internal fun AutofillScenario<AutofillId>.toBundle(): Bundle = + when (this) { is ClassifiedAutofillScenario<AutofillId> -> { - Bundle(5).apply { - putParcelable(AutofillScenario.BUNDLE_KEY_USERNAME_ID, username) - putBoolean(AutofillScenario.BUNDLE_KEY_FILL_USERNAME, fillUsername) - putParcelable(AutofillScenario.BUNDLE_KEY_OTP_ID, otp) - putParcelableArrayList( - AutofillScenario.BUNDLE_KEY_CURRENT_PASSWORD_IDS, ArrayList(currentPassword) - ) - putParcelableArrayList( - AutofillScenario.BUNDLE_KEY_NEW_PASSWORD_IDS, ArrayList(newPassword) - ) - } + Bundle(5).apply { + putParcelable(AutofillScenario.BUNDLE_KEY_USERNAME_ID, username) + putBoolean(AutofillScenario.BUNDLE_KEY_FILL_USERNAME, fillUsername) + putParcelable(AutofillScenario.BUNDLE_KEY_OTP_ID, otp) + putParcelableArrayList(AutofillScenario.BUNDLE_KEY_CURRENT_PASSWORD_IDS, ArrayList(currentPassword)) + putParcelableArrayList(AutofillScenario.BUNDLE_KEY_NEW_PASSWORD_IDS, ArrayList(newPassword)) + } } is GenericAutofillScenario<AutofillId> -> { - Bundle(4).apply { - putParcelable(AutofillScenario.BUNDLE_KEY_USERNAME_ID, username) - putBoolean(AutofillScenario.BUNDLE_KEY_FILL_USERNAME, fillUsername) - putParcelable(AutofillScenario.BUNDLE_KEY_OTP_ID, otp) - putParcelableArrayList( - AutofillScenario.BUNDLE_KEY_GENERIC_PASSWORD_IDS, ArrayList(genericPassword) - ) - } + Bundle(4).apply { + putParcelable(AutofillScenario.BUNDLE_KEY_USERNAME_ID, username) + putBoolean(AutofillScenario.BUNDLE_KEY_FILL_USERNAME, fillUsername) + putParcelable(AutofillScenario.BUNDLE_KEY_OTP_ID, otp) + putParcelableArrayList(AutofillScenario.BUNDLE_KEY_GENERIC_PASSWORD_IDS, ArrayList(genericPassword)) + } } -} + } @RequiresApi(Build.VERSION_CODES.O) -public fun AutofillScenario<AutofillId>.recoverNodes(structure: AssistStructure): AutofillScenario<AssistStructure.ViewNode>? { - return map { autofillId -> - structure.findNodeByAutofillId(autofillId) ?: return null - } +public fun AutofillScenario<AutofillId>.recoverNodes( + structure: AssistStructure +): AutofillScenario<AssistStructure.ViewNode>? { + return map { autofillId -> structure.findNodeByAutofillId(autofillId) ?: return null } } public val AutofillScenario<AssistStructure.ViewNode>.usernameValue: String? - @RequiresApi(Build.VERSION_CODES.O) get() { - val value = username?.autofillValue ?: return null - return if (value.isText) value.textValue.toString() else null - } + @RequiresApi(Build.VERSION_CODES.O) + get() { + val value = username?.autofillValue ?: return null + return if (value.isText) value.textValue.toString() else null + } public val AutofillScenario<AssistStructure.ViewNode>.passwordValue: String? - @RequiresApi(Build.VERSION_CODES.O) get() { - val distinctValues = passwordFieldsToSave.map { - if (it.autofillValue?.isText == true) { - it.autofillValue?.textValue?.toString() - } else { - null - } - }.toSet() - // Only return a non-null password value when all password fields agree - return distinctValues.singleOrNull() - } + @RequiresApi(Build.VERSION_CODES.O) + get() { + val distinctValues = + passwordFieldsToSave + .map { + if (it.autofillValue?.isText == true) { + it.autofillValue?.textValue?.toString() + } else { + null + } + } + .toSet() + // Only return a non-null password value when all password fields agree + return distinctValues.singleOrNull() + } diff --git a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategy.kt b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategy.kt index 94cc17ba..c1e0f234 100644 --- a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategy.kt +++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategy.kt @@ -9,220 +9,172 @@ import androidx.annotation.RequiresApi import com.github.androidpasswordstore.autofillparser.CertaintyLevel.Certain import com.github.androidpasswordstore.autofillparser.CertaintyLevel.Likely -private inline fun <T> Pair<T, T>.all(predicate: T.() -> Boolean) = - predicate(first) && predicate(second) +private inline fun <T> Pair<T, T>.all(predicate: T.() -> Boolean) = predicate(first) && predicate(second) -private inline fun <T> Pair<T, T>.any(predicate: T.() -> Boolean) = - predicate(first) || predicate(second) +private inline fun <T> Pair<T, T>.any(predicate: T.() -> Boolean) = predicate(first) || predicate(second) -private inline fun <T> Pair<T, T>.none(predicate: T.() -> Boolean) = - !predicate(first) && !predicate(second) +private inline fun <T> Pair<T, T>.none(predicate: T.() -> Boolean) = !predicate(first) && !predicate(second) /** - * The strategy used to detect [AutofillScenario]s; expressed using the DSL implemented in + * The strategy used to detect [AutofillScenario] s; expressed using the DSL implemented in * [AutofillDsl]. */ @RequiresApi(Build.VERSION_CODES.O) internal val autofillStrategy = strategy { - // Match two new password fields, an optional current password field right below or above, and - // an optional username field with autocomplete hint. - // TODO: Introduce a custom fill/generate/update flow for this scenario - rule { - newPassword { - takePair { all { hasHintNewPassword } } - breakTieOnPair { any { isFocused } } - } - currentPassword(optional = true) { - takeSingle { alreadyMatched -> - val adjacentToNewPasswords = - directlyPrecedes(alreadyMatched) || directlyFollows(alreadyMatched) - // The Autofill framework has not hint that applies to current passwords only. - // In this scenario, we have already matched fields a pair of fields with a specific - // new password hint, so we take a generic Autofill password hint to mean a current - // password. - (hasAutocompleteHintCurrentPassword || hasAutofillHintPassword) && - adjacentToNewPasswords - } - } - username(optional = true) { - takeSingle { hasHintUsername } - breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) } - breakTieOnSingle { isFocused } - } + // Match two new password fields, an optional current password field right below or above, and + // an optional username field with autocomplete hint. + // TODO: Introduce a custom fill/generate/update flow for this scenario + rule { + newPassword { + takePair { all { hasHintNewPassword } } + breakTieOnPair { any { isFocused } } } - - // Match a single focused current password field and hidden username field with autocomplete - // hint. This configuration is commonly used in two-step login flows to allow password managers - // to save the username. - // See: https://www.chromium.org/developers/design-documents/form-styles-that-chromium-understands - // Note: The username is never filled in this scenario since usernames are generally only filled - // in visible fields. - rule { - username(matchHidden = true) { - takeSingle { - couldBeTwoStepHiddenUsername - } - } - currentPassword { - takeSingle { - hasAutocompleteHintCurrentPassword && isFocused - } - } + currentPassword(optional = true) { + takeSingle { alreadyMatched -> + val adjacentToNewPasswords = directlyPrecedes(alreadyMatched) || directlyFollows(alreadyMatched) + // The Autofill framework has not hint that applies to current passwords only. + // In this scenario, we have already matched fields a pair of fields with a specific + // new password hint, so we take a generic Autofill password hint to mean a current + // password. + (hasAutocompleteHintCurrentPassword || hasAutofillHintPassword) && adjacentToNewPasswords + } } - - // Match a single current password field and optional username field with autocomplete hint. - rule { - currentPassword { - takeSingle { hasAutocompleteHintCurrentPassword } - breakTieOnSingle { isFocused } - } - username(optional = true) { - takeSingle { hasHintUsername } - breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) } - breakTieOnSingle { isFocused } - } + username(optional = true) { + takeSingle { hasHintUsername } + breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) } + breakTieOnSingle { isFocused } } - - // Match two adjacent password fields, implicitly understood as new passwords, and optional - // username field. - rule { - newPassword { - takePair { all { passwordCertainty >= Likely } } - breakTieOnPair { all { passwordCertainty >= Certain } } - breakTieOnPair { any { isFocused } } - } - username(optional = true) { - takeSingle { usernameCertainty >= Likely } - breakTieOnSingle { usernameCertainty >= Certain } - breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) } - breakTieOnSingle { isFocused } - } + } + + // Match a single focused current password field and hidden username field with autocomplete + // hint. This configuration is commonly used in two-step login flows to allow password managers + // to save the username. + // See: + // https://www.chromium.org/developers/design-documents/form-styles-that-chromium-understands + // Note: The username is never filled in this scenario since usernames are generally only filled + // in visible fields. + rule { + username(matchHidden = true) { takeSingle { couldBeTwoStepHiddenUsername } } + currentPassword { takeSingle { hasAutocompleteHintCurrentPassword && isFocused } } + } + + // Match a single current password field and optional username field with autocomplete hint. + rule { + currentPassword { + takeSingle { hasAutocompleteHintCurrentPassword } + breakTieOnSingle { isFocused } } - - // Match a single password field and optional username field. - rule { - genericPassword { - takeSingle { passwordCertainty >= Likely } - breakTieOnSingle { passwordCertainty >= Certain } - breakTieOnSingle { isFocused } - } - username(optional = true) { - takeSingle { usernameCertainty >= Likely } - breakTieOnSingle { usernameCertainty >= Certain } - breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) } - breakTieOnSingle { isFocused } - } + username(optional = true) { + takeSingle { hasHintUsername } + breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) } + breakTieOnSingle { isFocused } } - - // Match a single focused new password field and optional preceding username field. - // This rule can apply in single origin mode since it only fills into a single focused password - // field. - rule(applyInSingleOriginMode = true) { - newPassword { - takeSingle { hasHintNewPassword && isFocused } - } - username(optional = true) { - takeSingle { alreadyMatched -> - usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull()) - } - } + } + + // Match two adjacent password fields, implicitly understood as new passwords, and optional + // username field. + rule { + newPassword { + takePair { all { passwordCertainty >= Likely } } + breakTieOnPair { all { passwordCertainty >= Certain } } + breakTieOnPair { any { isFocused } } } - - // Match a single focused current password field and optional preceding username field. - // This rule can apply in single origin mode since it only fills into a single focused password - // field. - rule(applyInSingleOriginMode = true) { - currentPassword { - takeSingle { hasAutocompleteHintCurrentPassword && isFocused } - } - username(optional = true) { - takeSingle { alreadyMatched -> - usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull()) - } - } + username(optional = true) { + takeSingle { usernameCertainty >= Likely } + breakTieOnSingle { usernameCertainty >= Certain } + breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) } + breakTieOnSingle { isFocused } } - - // Match a single focused password field and optional preceding username field. - // This rule can apply in single origin mode since it only fills into a single focused password - // field. - rule(applyInSingleOriginMode = true) { - genericPassword { - takeSingle { passwordCertainty >= Likely && isFocused } - } - username(optional = true) { - takeSingle { alreadyMatched -> - usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull()) - } - } + } + + // Match a single password field and optional username field. + rule { + genericPassword { + takeSingle { passwordCertainty >= Likely } + breakTieOnSingle { passwordCertainty >= Certain } + breakTieOnSingle { isFocused } } - - // Match a focused username field with autocomplete hint directly followed by a hidden password - // field, which is a common scenario in two-step login flows. No tie breakers are used to limit - // filling of hidden password fields to scenarios where this is clearly warranted. - rule { - username { - takeSingle { hasHintUsername && isFocused } - } - currentPassword(matchHidden = true) { - takeSingle { alreadyMatched -> - directlyFollows(alreadyMatched.singleOrNull()) && couldBeTwoStepHiddenPassword - } - } + username(optional = true) { + takeSingle { usernameCertainty >= Likely } + breakTieOnSingle { usernameCertainty >= Certain } + breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) } + breakTieOnSingle { isFocused } } - - // Match a single focused OTP field. - rule(applyInSingleOriginMode = true) { - otp { - takeSingle { otpCertainty >= Likely && isFocused } - } + } + + // Match a single focused new password field and optional preceding username field. + // This rule can apply in single origin mode since it only fills into a single focused password + // field. + rule(applyInSingleOriginMode = true) { + newPassword { takeSingle { hasHintNewPassword && isFocused } } + username(optional = true) { + takeSingle { alreadyMatched -> usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull()) } } - - // Match a single focused username field without a password field. - rule(applyInSingleOriginMode = true) { - username { - takeSingle { usernameCertainty >= Likely && isFocused } - breakTieOnSingle { usernameCertainty >= Certain } - breakTieOnSingle { hasHintUsername } - } + } + + // Match a single focused current password field and optional preceding username field. + // This rule can apply in single origin mode since it only fills into a single focused password + // field. + rule(applyInSingleOriginMode = true) { + currentPassword { takeSingle { hasAutocompleteHintCurrentPassword && isFocused } } + username(optional = true) { + takeSingle { alreadyMatched -> usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull()) } } - - // Fallback rule for the case of a login form with a password field and other fields that are - // not recognized by any other rule. If one of the other fields is focused and we return no - // response, the system will not invoke the service again if focus later changes to the password - // field. Hence, we must mark it as fillable now. - // This rule can apply in single origin mode since even though the password field may not be - // focused at the time the rule runs, the fill suggestion will only show if it ever receives - // focus. - rule(applyInSingleOriginMode = true) { - currentPassword { - takeSingle { hasAutocompleteHintCurrentPassword } - } + } + + // Match a single focused password field and optional preceding username field. + // This rule can apply in single origin mode since it only fills into a single focused password + // field. + rule(applyInSingleOriginMode = true) { + genericPassword { takeSingle { passwordCertainty >= Likely && isFocused } } + username(optional = true) { + takeSingle { alreadyMatched -> usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull()) } } - - // See above. - rule(applyInSingleOriginMode = true) { - genericPassword { - takeSingle { true } - } + } + + // Match a focused username field with autocomplete hint directly followed by a hidden password + // field, which is a common scenario in two-step login flows. No tie breakers are used to limit + // filling of hidden password fields to scenarios where this is clearly warranted. + rule { + username { takeSingle { hasHintUsername && isFocused } } + currentPassword(matchHidden = true) { + takeSingle { alreadyMatched -> directlyFollows(alreadyMatched.singleOrNull()) && couldBeTwoStepHiddenPassword } } + } - // Match any focused password field with optional username field on manual request. - rule(applyInSingleOriginMode = true, applyOnManualRequestOnly = true) { - genericPassword { - takeSingle { isFocused } - } - username(optional = true) { - takeSingle { alreadyMatched -> - usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull()) - } - } - } + // Match a single focused OTP field. + rule(applyInSingleOriginMode = true) { otp { takeSingle { otpCertainty >= Likely && isFocused } } } - // Match any focused username field on manual request. - rule(applyInSingleOriginMode = true, applyOnManualRequestOnly = true) { - username { - takeSingle { isFocused } - } + // Match a single focused username field without a password field. + rule(applyInSingleOriginMode = true) { + username { + takeSingle { usernameCertainty >= Likely && isFocused } + breakTieOnSingle { usernameCertainty >= Certain } + breakTieOnSingle { hasHintUsername } + } + } + + // Fallback rule for the case of a login form with a password field and other fields that are + // not recognized by any other rule. If one of the other fields is focused and we return no + // response, the system will not invoke the service again if focus later changes to the password + // field. Hence, we must mark it as fillable now. + // This rule can apply in single origin mode since even though the password field may not be + // focused at the time the rule runs, the fill suggestion will only show if it ever receives + // focus. + rule(applyInSingleOriginMode = true) { currentPassword { takeSingle { hasAutocompleteHintCurrentPassword } } } + + // See above. + rule(applyInSingleOriginMode = true) { genericPassword { takeSingle { true } } } + + // Match any focused password field with optional username field on manual request. + rule(applyInSingleOriginMode = true, applyOnManualRequestOnly = true) { + genericPassword { takeSingle { isFocused } } + username(optional = true) { + takeSingle { alreadyMatched -> usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull()) } } + } + + // Match any focused username field on manual request. + rule(applyInSingleOriginMode = true, applyOnManualRequestOnly = true) { username { takeSingle { isFocused } } } } diff --git a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategyDsl.kt b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategyDsl.kt index 3e15fda8..293ec467 100644 --- a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategyDsl.kt +++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategyDsl.kt @@ -9,381 +9,404 @@ import androidx.annotation.RequiresApi import com.github.ajalt.timberkt.d import com.github.ajalt.timberkt.w -@DslMarker -internal annotation class AutofillDsl +@DslMarker internal annotation class AutofillDsl @RequiresApi(Build.VERSION_CODES.O) internal interface FieldMatcher { - fun match(fields: List<FormField>, alreadyMatched: List<FormField>): List<FormField>? + fun match(fields: List<FormField>, alreadyMatched: List<FormField>): List<FormField>? - @AutofillDsl - class Builder { + @AutofillDsl + class Builder { - private var takeSingle: (FormField.(List<FormField>) -> Boolean)? = null - private val tieBreakersSingle: MutableList<FormField.(List<FormField>) -> Boolean> = - mutableListOf() + private var takeSingle: (FormField.(List<FormField>) -> Boolean)? = null + private val tieBreakersSingle: MutableList<FormField.(List<FormField>) -> Boolean> = mutableListOf() - private var takePair: (Pair<FormField, FormField>.(List<FormField>) -> Boolean)? = null - private var tieBreakersPair: MutableList<Pair<FormField, FormField>.(List<FormField>) -> Boolean> = - mutableListOf() + private var takePair: (Pair<FormField, FormField>.(List<FormField>) -> Boolean)? = null + private var tieBreakersPair: MutableList<Pair<FormField, FormField>.(List<FormField>) -> Boolean> = mutableListOf() - fun takeSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean = { true }) { - check(takeSingle == null && takePair == null) { "Every block can only have at most one take{Single,Pair} block" } - takeSingle = block - } + fun takeSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean = { true }) { + check(takeSingle == null && takePair == null) { "Every block can only have at most one take{Single,Pair} block" } + takeSingle = block + } - fun breakTieOnSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean) { - check(takeSingle != null) { "Every block needs a takeSingle block before a breakTieOnSingle block" } - check(takePair == null) { "takePair cannot be mixed with breakTieOnSingle" } - tieBreakersSingle.add(block) - } + fun breakTieOnSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean) { + check(takeSingle != null) { "Every block needs a takeSingle block before a breakTieOnSingle block" } + check(takePair == null) { "takePair cannot be mixed with breakTieOnSingle" } + tieBreakersSingle.add(block) + } - fun takePair(block: Pair<FormField, FormField>.(alreadyMatched: List<FormField>) -> Boolean = { true }) { - check(takeSingle == null && takePair == null) { "Every block can only have at most one take{Single,Pair} block" } - takePair = block - } + fun takePair(block: Pair<FormField, FormField>.(alreadyMatched: List<FormField>) -> Boolean = { true }) { + check(takeSingle == null && takePair == null) { "Every block can only have at most one take{Single,Pair} block" } + takePair = block + } - fun breakTieOnPair(block: Pair<FormField, FormField>.(alreadyMatched: List<FormField>) -> Boolean) { - check(takePair != null) { "Every block needs a takePair block before a breakTieOnPair block" } - check(takeSingle == null) { "takeSingle cannot be mixed with breakTieOnPair" } - tieBreakersPair.add(block) - } + fun breakTieOnPair(block: Pair<FormField, FormField>.(alreadyMatched: List<FormField>) -> Boolean) { + check(takePair != null) { "Every block needs a takePair block before a breakTieOnPair block" } + check(takeSingle == null) { "takeSingle cannot be mixed with breakTieOnPair" } + tieBreakersPair.add(block) + } - fun build(): FieldMatcher { - val takeSingle = takeSingle - val takePair = takePair - return when { - takeSingle != null -> SingleFieldMatcher(takeSingle, tieBreakersSingle) - takePair != null -> PairOfFieldsMatcher(takePair, tieBreakersPair) - else -> throw IllegalArgumentException("Every block needs a take{Single,Pair} block") - } - } + fun build(): FieldMatcher { + val takeSingle = takeSingle + val takePair = takePair + return when { + takeSingle != null -> SingleFieldMatcher(takeSingle, tieBreakersSingle) + takePair != null -> PairOfFieldsMatcher(takePair, tieBreakersPair) + else -> throw IllegalArgumentException("Every block needs a take{Single,Pair} block") + } } + } } @RequiresApi(Build.VERSION_CODES.O) internal class SingleFieldMatcher( - private val take: (FormField, List<FormField>) -> Boolean, - private val tieBreakers: List<(FormField, List<FormField>) -> Boolean> + private val take: (FormField, List<FormField>) -> Boolean, + private val tieBreakers: List<(FormField, List<FormField>) -> Boolean> ) : FieldMatcher { - @AutofillDsl - class Builder { + @AutofillDsl + class Builder { - private var takeSingle: (FormField.(List<FormField>) -> Boolean)? = null - private val tieBreakersSingle: MutableList<FormField.(List<FormField>) -> Boolean> = - mutableListOf() - - fun takeSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean = { true }) { - check(takeSingle == null) { "Every block can only have at most one takeSingle block" } - takeSingle = block - } + private var takeSingle: (FormField.(List<FormField>) -> Boolean)? = null + private val tieBreakersSingle: MutableList<FormField.(List<FormField>) -> Boolean> = mutableListOf() - fun breakTieOnSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean) { - check(takeSingle != null) { "Every block needs a takeSingle block before a breakTieOnSingle block" } - tieBreakersSingle.add(block) - } + fun takeSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean = { true }) { + check(takeSingle == null) { "Every block can only have at most one takeSingle block" } + takeSingle = block + } - fun build() = SingleFieldMatcher( - takeSingle - ?: throw IllegalArgumentException("Every block needs a take{Single,Pair} block"), - tieBreakersSingle - ) + fun breakTieOnSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean) { + check(takeSingle != null) { "Every block needs a takeSingle block before a breakTieOnSingle block" } + tieBreakersSingle.add(block) } - override fun match(fields: List<FormField>, alreadyMatched: List<FormField>): List<FormField>? { - return fields.minus(alreadyMatched).filter { take(it, alreadyMatched) }.let { contestants -> - when (contestants.size) { - 1 -> return@let listOf(contestants.single()) - 0 -> return@let null - } - var current = contestants - for ((i, tieBreaker) in tieBreakers.withIndex()) { - // Successively filter matched fields via tie breakers... - val new = current.filter { tieBreaker(it, alreadyMatched) } - // skipping those tie breakers that are not satisfied for any remaining field... - if (new.isEmpty()) { - d { "Tie breaker #${i + 1}: Didn't match any field; skipping" } - continue - } - // and return if the available options have been narrowed to a single field. - if (new.size == 1) { - d { "Tie breaker #${i + 1}: Success" } - current = new - break - } - d { "Tie breaker #${i + 1}: Matched ${new.size} fields; continuing" } - current = new - } - listOf(current.singleOrNull() ?: return null) + fun build() = + SingleFieldMatcher( + takeSingle ?: throw IllegalArgumentException("Every block needs a take{Single,Pair} block"), + tieBreakersSingle + ) + } + + override fun match(fields: List<FormField>, alreadyMatched: List<FormField>): List<FormField>? { + return fields.minus(alreadyMatched).filter { take(it, alreadyMatched) }.let { contestants -> + when (contestants.size) { + 1 -> return@let listOf(contestants.single()) + 0 -> return@let null + } + var current = contestants + for ((i, tieBreaker) in tieBreakers.withIndex()) { + // Successively filter matched fields via tie breakers... + val new = current.filter { tieBreaker(it, alreadyMatched) } + // skipping those tie breakers that are not satisfied for any remaining field... + if (new.isEmpty()) { + d { "Tie breaker #${i + 1}: Didn't match any field; skipping" } + continue } + // and return if the available options have been narrowed to a single field. + if (new.size == 1) { + d { "Tie breaker #${i + 1}: Success" } + current = new + break + } + d { "Tie breaker #${i + 1}: Matched ${new.size} fields; continuing" } + current = new + } + listOf(current.singleOrNull() ?: return null) } + } } @RequiresApi(Build.VERSION_CODES.O) private class PairOfFieldsMatcher( - private val take: (Pair<FormField, FormField>, List<FormField>) -> Boolean, - private val tieBreakers: List<(Pair<FormField, FormField>, List<FormField>) -> Boolean> + private val take: (Pair<FormField, FormField>, List<FormField>) -> Boolean, + private val tieBreakers: List<(Pair<FormField, FormField>, List<FormField>) -> Boolean> ) : FieldMatcher { - override fun match(fields: List<FormField>, alreadyMatched: List<FormField>): List<FormField>? { - return fields.minus(alreadyMatched).zipWithNext() - .filter { it.first directlyPrecedes it.second }.filter { take(it, alreadyMatched) } - .let { contestants -> - when (contestants.size) { - 1 -> return@let contestants.single().toList() - 0 -> return@let null - } - var current = contestants - for ((i, tieBreaker) in tieBreakers.withIndex()) { - val new = current.filter { tieBreaker(it, alreadyMatched) } - if (new.isEmpty()) { - d { "Tie breaker #${i + 1}: Didn't match any pair of fields; skipping" } - continue - } - // and return if the available options have been narrowed to a single field. - if (new.size == 1) { - d { "Tie breaker #${i + 1}: Success" } - current = new - break - } - d { "Tie breaker #${i + 1}: Matched ${new.size} pairs of fields; continuing" } - current = new - } - current.singleOrNull()?.toList() - } - } + override fun match(fields: List<FormField>, alreadyMatched: List<FormField>): List<FormField>? { + return fields + .minus(alreadyMatched) + .zipWithNext() + .filter { it.first directlyPrecedes it.second } + .filter { take(it, alreadyMatched) } + .let { contestants -> + when (contestants.size) { + 1 -> return@let contestants.single().toList() + 0 -> return@let null + } + var current = contestants + for ((i, tieBreaker) in tieBreakers.withIndex()) { + val new = current.filter { tieBreaker(it, alreadyMatched) } + if (new.isEmpty()) { + d { "Tie breaker #${i + 1}: Didn't match any pair of fields; skipping" } + continue + } + // and return if the available options have been narrowed to a single field. + if (new.size == 1) { + d { "Tie breaker #${i + 1}: Success" } + current = new + break + } + d { "Tie breaker #${i + 1}: Matched ${new.size} pairs of fields; continuing" } + current = new + } + current.singleOrNull()?.toList() + } + } } @RequiresApi(Build.VERSION_CODES.O) -internal class AutofillRule private constructor( - private val matchers: List<AutofillRuleMatcher>, - private val applyInSingleOriginMode: Boolean, - private val applyOnManualRequestOnly: Boolean, - private val name: String +internal class AutofillRule +private constructor( + private val matchers: List<AutofillRuleMatcher>, + private val applyInSingleOriginMode: Boolean, + private val applyOnManualRequestOnly: Boolean, + private val name: String ) { - data class AutofillRuleMatcher( - val type: FillableFieldType, - val matcher: FieldMatcher, - val optional: Boolean, - val matchHidden: Boolean - ) + data class AutofillRuleMatcher( + val type: FillableFieldType, + val matcher: FieldMatcher, + val optional: Boolean, + val matchHidden: Boolean + ) + + enum class FillableFieldType { + Username, + Otp, + CurrentPassword, + NewPassword, + GenericPassword, + } + + @AutofillDsl + class Builder(private val applyInSingleOriginMode: Boolean, private val applyOnManualRequestOnly: Boolean) { + + companion object { - enum class FillableFieldType { - Username, Otp, CurrentPassword, NewPassword, GenericPassword, + private var ruleId = 1 } - @AutofillDsl - class Builder( - private val applyInSingleOriginMode: Boolean, - private val applyOnManualRequestOnly: Boolean - ) { + private val matchers = mutableListOf<AutofillRuleMatcher>() + var name: String? = null - companion object { + fun username( + optional: Boolean = false, + matchHidden: Boolean = false, + block: SingleFieldMatcher.Builder.() -> Unit + ) { + require(matchers.none { it.type == FillableFieldType.Username }) { + "Every rule block can only have at most one username block" + } + matchers.add( + AutofillRuleMatcher( + type = FillableFieldType.Username, + matcher = SingleFieldMatcher.Builder().apply(block).build(), + optional = optional, + matchHidden = matchHidden + ) + ) + } - private var ruleId = 1 - } + fun otp(optional: Boolean = false, block: SingleFieldMatcher.Builder.() -> Unit) { + require(matchers.none { it.type == FillableFieldType.Otp }) { + "Every rule block can only have at most one otp block" + } + matchers.add( + AutofillRuleMatcher( + type = FillableFieldType.Otp, + matcher = SingleFieldMatcher.Builder().apply(block).build(), + optional = optional, + matchHidden = false + ) + ) + } - private val matchers = mutableListOf<AutofillRuleMatcher>() - var name: String? = null - - fun username(optional: Boolean = false, matchHidden: Boolean = false, block: SingleFieldMatcher.Builder.() -> Unit) { - require(matchers.none { it.type == FillableFieldType.Username }) { "Every rule block can only have at most one username block" } - matchers.add( - AutofillRuleMatcher( - type = FillableFieldType.Username, - matcher = SingleFieldMatcher.Builder().apply(block).build(), - optional = optional, - matchHidden = matchHidden - ) - ) - } + fun currentPassword( + optional: Boolean = false, + matchHidden: Boolean = false, + block: FieldMatcher.Builder.() -> Unit + ) { + require(matchers.none { it.type == FillableFieldType.GenericPassword }) { + "Every rule block can only have either genericPassword or {current,new}Password blocks" + } + matchers.add( + AutofillRuleMatcher( + type = FillableFieldType.CurrentPassword, + matcher = FieldMatcher.Builder().apply(block).build(), + optional = optional, + matchHidden = matchHidden + ) + ) + } - fun otp(optional: Boolean = false, block: SingleFieldMatcher.Builder.() -> Unit) { - require(matchers.none { it.type == FillableFieldType.Otp }) { "Every rule block can only have at most one otp block" } - matchers.add( - AutofillRuleMatcher( - type = FillableFieldType.Otp, - matcher = SingleFieldMatcher.Builder().apply(block).build(), - optional = optional, - matchHidden = false - ) - ) - } + fun newPassword(optional: Boolean = false, block: FieldMatcher.Builder.() -> Unit) { + require(matchers.none { it.type == FillableFieldType.GenericPassword }) { + "Every rule block can only have either genericPassword or {current,new}Password blocks" + } + matchers.add( + AutofillRuleMatcher( + type = FillableFieldType.NewPassword, + matcher = FieldMatcher.Builder().apply(block).build(), + optional = optional, + matchHidden = false + ) + ) + } - fun currentPassword(optional: Boolean = false, matchHidden: Boolean = false, block: FieldMatcher.Builder.() -> Unit) { - require(matchers.none { it.type == FillableFieldType.GenericPassword }) { "Every rule block can only have either genericPassword or {current,new}Password blocks" } - matchers.add( - AutofillRuleMatcher( - type = FillableFieldType.CurrentPassword, - matcher = FieldMatcher.Builder().apply(block).build(), - optional = optional, - matchHidden = matchHidden - ) + fun genericPassword(optional: Boolean = false, block: FieldMatcher.Builder.() -> Unit) { + require( + matchers.none { + it.type in + listOf( + FillableFieldType.CurrentPassword, + FillableFieldType.NewPassword, ) } + ) { "Every rule block can only have either genericPassword or {current,new}Password blocks" } + matchers.add( + AutofillRuleMatcher( + type = FillableFieldType.GenericPassword, + matcher = FieldMatcher.Builder().apply(block).build(), + optional = optional, + matchHidden = false + ) + ) + } - fun newPassword(optional: Boolean = false, block: FieldMatcher.Builder.() -> Unit) { - require(matchers.none { it.type == FillableFieldType.GenericPassword }) { "Every rule block can only have either genericPassword or {current,new}Password blocks" } - matchers.add( - AutofillRuleMatcher( - type = FillableFieldType.NewPassword, - matcher = FieldMatcher.Builder().apply(block).build(), - optional = optional, - matchHidden = false - ) - ) + fun build(): AutofillRule { + if (applyInSingleOriginMode) { + require(matchers.none { it.matcher is PairOfFieldsMatcher }) { + "Rules with applyInSingleOriginMode set to true must only match single fields" } - - fun genericPassword(optional: Boolean = false, block: FieldMatcher.Builder.() -> Unit) { - require(matchers.none { - it.type in listOf( - FillableFieldType.CurrentPassword, - FillableFieldType.NewPassword, - ) - }) { "Every rule block can only have either genericPassword or {current,new}Password blocks" } - matchers.add( - AutofillRuleMatcher( - type = FillableFieldType.GenericPassword, - matcher = FieldMatcher.Builder().apply(block).build(), - optional = optional, - matchHidden = false - ) - ) + require(matchers.filter { it.type != FillableFieldType.Username }.size <= 1) { + "Rules with applyInSingleOriginMode set to true must only match at most one password field" } - - fun build(): AutofillRule { - if (applyInSingleOriginMode) { - require(matchers.none { it.matcher is PairOfFieldsMatcher }) { "Rules with applyInSingleOriginMode set to true must only match single fields" } - require(matchers.filter { it.type != FillableFieldType.Username }.size <= 1) { "Rules with applyInSingleOriginMode set to true must only match at most one password field" } - require(matchers.none { it.matchHidden }) { "Rules with applyInSingleOriginMode set to true must not fill into hidden fields" } - } - return AutofillRule( - matchers, applyInSingleOriginMode, applyOnManualRequestOnly, name ?: "Rule #$ruleId" - ).also { ruleId++ } + require(matchers.none { it.matchHidden }) { + "Rules with applyInSingleOriginMode set to true must not fill into hidden fields" } + } + return AutofillRule(matchers, applyInSingleOriginMode, applyOnManualRequestOnly, name ?: "Rule #$ruleId").also { + ruleId++ + } } - - fun match( - allPassword: List<FormField>, - allUsername: List<FormField>, - allOtp: List<FormField>, - singleOriginMode: Boolean, - isManualRequest: Boolean - ): AutofillScenario<FormField>? { - if (singleOriginMode && !applyInSingleOriginMode) { - d { "$name: Skipped in single origin mode" } - return null - } - if (!isManualRequest && applyOnManualRequestOnly) { - d { "$name: Skipped since not a manual request" } + } + + fun match( + allPassword: List<FormField>, + allUsername: List<FormField>, + allOtp: List<FormField>, + singleOriginMode: Boolean, + isManualRequest: Boolean + ): AutofillScenario<FormField>? { + if (singleOriginMode && !applyInSingleOriginMode) { + d { "$name: Skipped in single origin mode" } + return null + } + if (!isManualRequest && applyOnManualRequestOnly) { + d { "$name: Skipped since not a manual request" } + return null + } + d { "$name: Applying..." } + val scenarioBuilder = AutofillScenario.Builder<FormField>() + val alreadyMatched = mutableListOf<FormField>() + for ((type, matcher, optional, matchHidden) in matchers) { + val fieldsToMatchOn = + when (type) { + FillableFieldType.Username -> allUsername + FillableFieldType.Otp -> allOtp + else -> allPassword + }.filter { matchHidden || it.isVisible } + val matchResult = + matcher.match(fieldsToMatchOn, alreadyMatched) + ?: if (optional) { + d { "$name: Skipping optional $type matcher" } + continue + } else { + d { "$name: Required $type matcher didn't match; passing to next rule" } return null + } + d { "$name: Matched $type" } + when (type) { + FillableFieldType.Username -> { + check(matchResult.size == 1 && scenarioBuilder.username == null) + scenarioBuilder.username = matchResult.single() + // Hidden username fields should be saved but not filled. + scenarioBuilder.fillUsername = scenarioBuilder.username!!.isVisible == true } - d { "$name: Applying..." } - val scenarioBuilder = AutofillScenario.Builder<FormField>() - val alreadyMatched = mutableListOf<FormField>() - for ((type, matcher, optional, matchHidden) in matchers) { - val fieldsToMatchOn = when (type) { - FillableFieldType.Username -> allUsername - FillableFieldType.Otp -> allOtp - else -> allPassword - }.filter { matchHidden || it.isVisible } - val matchResult = matcher.match(fieldsToMatchOn, alreadyMatched) ?: if (optional) { - d { "$name: Skipping optional $type matcher" } - continue - } else { - d { "$name: Required $type matcher didn't match; passing to next rule" } - return null - } - d { "$name: Matched $type" } - when (type) { - FillableFieldType.Username -> { - check(matchResult.size == 1 && scenarioBuilder.username == null) - scenarioBuilder.username = matchResult.single() - // Hidden username fields should be saved but not filled. - scenarioBuilder.fillUsername = scenarioBuilder.username!!.isVisible == true - } - FillableFieldType.Otp -> { - check(matchResult.size == 1 && scenarioBuilder.otp == null) - scenarioBuilder.otp = matchResult.single() - } - FillableFieldType.CurrentPassword -> scenarioBuilder.currentPassword.addAll( - matchResult - ) - FillableFieldType.NewPassword -> scenarioBuilder.newPassword.addAll(matchResult) - FillableFieldType.GenericPassword -> scenarioBuilder.genericPassword.addAll( - matchResult - ) - } - alreadyMatched.addAll(matchResult) + FillableFieldType.Otp -> { + check(matchResult.size == 1 && scenarioBuilder.otp == null) + scenarioBuilder.otp = matchResult.single() } - return scenarioBuilder.build().takeIf { scenario -> - scenario.passesOriginCheck(singleOriginMode = singleOriginMode).also { passed -> - if (passed) { - d { "$name: Detected scenario:\n$scenario" } - } else { - w { "$name: Scenario failed origin check:\n$scenario" } - } - } + FillableFieldType.CurrentPassword -> scenarioBuilder.currentPassword.addAll(matchResult) + FillableFieldType.NewPassword -> scenarioBuilder.newPassword.addAll(matchResult) + FillableFieldType.GenericPassword -> scenarioBuilder.genericPassword.addAll(matchResult) + } + alreadyMatched.addAll(matchResult) + } + return scenarioBuilder.build().takeIf { scenario -> + scenario.passesOriginCheck(singleOriginMode = singleOriginMode).also { passed -> + if (passed) { + d { "$name: Detected scenario:\n$scenario" } + } else { + w { "$name: Scenario failed origin check:\n$scenario" } } + } } + } } @RequiresApi(Build.VERSION_CODES.O) internal class AutofillStrategy private constructor(private val rules: List<AutofillRule>) { - @AutofillDsl - class Builder { - - private val rules: MutableList<AutofillRule> = mutableListOf() - - fun rule( - applyInSingleOriginMode: Boolean = false, - applyOnManualRequestOnly: Boolean = false, - block: AutofillRule.Builder.() -> Unit - ) { - rules.add( - AutofillRule.Builder( - applyInSingleOriginMode = applyInSingleOriginMode, - applyOnManualRequestOnly = applyOnManualRequestOnly - ).apply(block).build() - ) - } + @AutofillDsl + class Builder { + + private val rules: MutableList<AutofillRule> = mutableListOf() - fun build() = AutofillStrategy(rules) + fun rule( + applyInSingleOriginMode: Boolean = false, + applyOnManualRequestOnly: Boolean = false, + block: AutofillRule.Builder.() -> Unit + ) { + rules.add( + AutofillRule.Builder( + applyInSingleOriginMode = applyInSingleOriginMode, + applyOnManualRequestOnly = applyOnManualRequestOnly + ) + .apply(block) + .build() + ) } - fun match( - fields: List<FormField>, - singleOriginMode: Boolean, - isManualRequest: Boolean - ): AutofillScenario<FormField>? { - val possiblePasswordFields = - fields.filter { it.passwordCertainty >= CertaintyLevel.Possible } - d { "Possible password fields: ${possiblePasswordFields.size}" } - val possibleUsernameFields = - fields.filter { it.usernameCertainty >= CertaintyLevel.Possible } - d { "Possible username fields: ${possibleUsernameFields.size}" } - val possibleOtpFields = - fields.filter { it.otpCertainty >= CertaintyLevel.Possible } - d { "Possible otp fields: ${possibleOtpFields.size}" } - // Return the result of the first rule that matches - d { "Rules: ${rules.size}" } - for (rule in rules) { - return rule.match( - possiblePasswordFields, - possibleUsernameFields, - possibleOtpFields, - singleOriginMode = singleOriginMode, - isManualRequest = isManualRequest - ) - ?: continue - } - return null + fun build() = AutofillStrategy(rules) + } + + fun match( + fields: List<FormField>, + singleOriginMode: Boolean, + isManualRequest: Boolean + ): AutofillScenario<FormField>? { + val possiblePasswordFields = fields.filter { it.passwordCertainty >= CertaintyLevel.Possible } + d { "Possible password fields: ${possiblePasswordFields.size}" } + val possibleUsernameFields = fields.filter { it.usernameCertainty >= CertaintyLevel.Possible } + d { "Possible username fields: ${possibleUsernameFields.size}" } + val possibleOtpFields = fields.filter { it.otpCertainty >= CertaintyLevel.Possible } + d { "Possible otp fields: ${possibleOtpFields.size}" } + // Return the result of the first rule that matches + d { "Rules: ${rules.size}" } + for (rule in rules) { + return rule.match( + possiblePasswordFields, + possibleUsernameFields, + possibleOtpFields, + singleOriginMode = singleOriginMode, + isManualRequest = isManualRequest + ) + ?: continue } + return null + } } -internal fun strategy(block: AutofillStrategy.Builder.() -> Unit) = - AutofillStrategy.Builder().apply(block).build() +internal fun strategy(block: AutofillStrategy.Builder.() -> Unit) = AutofillStrategy.Builder().apply(block).build() diff --git a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FeatureAndTrustDetection.kt b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FeatureAndTrustDetection.kt index 6d7bd7fb..8dba907c 100644 --- a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FeatureAndTrustDetection.kt +++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FeatureAndTrustDetection.kt @@ -14,40 +14,40 @@ import android.service.autofill.SaveInfo import androidx.annotation.RequiresApi /* - In order to add a new browser, do the following: + In order to add a new browser, do the following: - 1. Obtain the .apk from a trusted source. For example, download it from the Play Store on your - phone and use adb pull to get it onto your computer. We will assume that it is called - browser.apk. + 1. Obtain the .apk from a trusted source. For example, download it from the Play Store on your + phone and use adb pull to get it onto your computer. We will assume that it is called + browser.apk. - 2. Run + 2. Run - aapt dump badging browser.apk | grep package: | grep -Eo " name='[a-zA-Z0-9_\.]*" | cut -c8- + aapt dump badging browser.apk | grep package: | grep -Eo " name='[a-zA-Z0-9_\.]*" | cut -c8- - to obtain the package name (actually, the application ID) of the app in the .apk. + to obtain the package name (actually, the application ID) of the app in the .apk. - 3. Run + 3. Run - apksigner verify --print-certs browser.apk | grep "#1 certificate SHA-256" | grep -Eo "[a-f0-9]{64}" | tr -d '\n' | xxd -r -p | base64 + apksigner verify --print-certs browser.apk | grep "#1 certificate SHA-256" | grep -Eo "[a-f0-9]{64}" | tr -d '\n' | xxd -r -p | base64 - to calculate the hash of browser.apk's first signing certificate. - Note: This will only work if the apk has a single signing certificate. Apps with multiple - signers are very rare, so there is probably no need to add them. - Refer to computeCertificatesHash to learn how the hash would be computed in this case. + to calculate the hash of browser.apk's first signing certificate. + Note: This will only work if the apk has a single signing certificate. Apps with multiple + signers are very rare, so there is probably no need to add them. + Refer to computeCertificatesHash to learn how the hash would be computed in this case. - 4. Verify the package name and the hash, for example by asking other people to repeat the steps - above. + 4. Verify the package name and the hash, for example by asking other people to repeat the steps + above. - 5. Add an entry with the browser apps's package name and the hash to - TRUSTED_BROWSER_CERTIFICATE_HASH. + 5. Add an entry with the browser apps's package name and the hash to + TRUSTED_BROWSER_CERTIFICATE_HASH. - 6. Optionally, try adding the browser's package name to BROWSERS_WITH_SAVE_SUPPORT and check - whether a save request to Password Store is triggered when you submit a registration form. + 6. Optionally, try adding the browser's package name to BROWSERS_WITH_SAVE_SUPPORT and check + whether a save request to Password Store is triggered when you submit a registration form. - 7. Optionally, try adding the browser's package name to BROWSERS_WITH_MULTI_ORIGIN_SUPPORT and - check whether it correctly distinguishes web origins even if iframes are present on the page. - You can use https://fabianhenneke.github.io/Android-Password-Store/ as a test form. - */ + 7. Optionally, try adding the browser's package name to BROWSERS_WITH_MULTI_ORIGIN_SUPPORT and + check whether it correctly distinguishes web origins even if iframes are present on the page. + You can use https://fabianhenneke.github.io/Android-Password-Store/ as a test form. +*/ /* * **Security assumption**: Browsers on this list correctly report the web origin of the top-level @@ -56,13 +56,15 @@ import androidx.annotation.RequiresApi * Note: Browsers can be on this list even if they don't report the correct web origins of all * fields on the page, e.g. of those in iframes. */ -private val TRUSTED_BROWSER_CERTIFICATE_HASH = mapOf( +private val TRUSTED_BROWSER_CERTIFICATE_HASH = + mapOf( "com.android.chrome" to arrayOf("8P1sW0EPJcslw7UzRsiXL64w+O50Ed+RBICtay1g24M="), "com.brave.browser" to arrayOf("nC23BRNRX9v7vFhbPt89cSPU3GfJT/0wY2HB15u/GKw="), "com.chrome.beta" to arrayOf("2mM9NLaeY64hA7SdU84FL8X388U6q5T9wqIIvf0UJJw="), "com.chrome.canary" to arrayOf("IBnfofsj779wxbzRRDxb6rBPPy/0Nm6aweNFdjmiTPw="), "com.chrome.dev" to arrayOf("kETuX+5LvF4h3URmVDHE6x8fcaMnFqC8knvLs5Izyr8="), - "com.duckduckgo.mobile.android" to arrayOf("u3uzHFc8RqHaf8XFKKas9DIQhFb+7FCBDH8zaU6z0tQ=", "8HB9AhwL8+b43MEbo/VwBCXVl9yjAaMeIQVWk067Gwo="), + "com.duckduckgo.mobile.android" to + arrayOf("u3uzHFc8RqHaf8XFKKas9DIQhFb+7FCBDH8zaU6z0tQ=", "8HB9AhwL8+b43MEbo/VwBCXVl9yjAaMeIQVWk067Gwo="), "com.microsoft.emmx" to arrayOf("AeGZlxCoLCdJtNUMRF3IXWcLYTYInQp2anOCfIKh6sk="), "com.opera.mini.native" to arrayOf("V6y8Ul8bLr0ZGWzW8BQ5fMkQ/RiEHgroUP68Ph5ZP/I="), "com.opera.mini.native.beta" to arrayOf("V6y8Ul8bLr0ZGWzW8BQ5fMkQ/RiEHgroUP68Ph5ZP/I="), @@ -81,16 +83,18 @@ private val TRUSTED_BROWSER_CERTIFICATE_HASH = mapOf( "org.ungoogled.chromium.stable" to arrayOf("29UOO5cXoxO/e/hH3hOu6bbtg1My4tK6Eik2Ym5Krtk="), "org.ungoogled.chromium.extensions.stable" to arrayOf("29UOO5cXoxO/e/hH3hOu6bbtg1My4tK6Eik2Ym5Krtk="), "com.kiwibrowser.browser" to arrayOf("wGnqlmMy6R4KDDzFd+b1Cf49ndr3AVrQxcXvj9o/hig="), -) + ) private fun isTrustedBrowser(context: Context, appPackage: String): Boolean { - val expectedCertificateHashes = TRUSTED_BROWSER_CERTIFICATE_HASH[appPackage] ?: return false - val certificateHash = computeCertificatesHash(context, appPackage) - return certificateHash in expectedCertificateHashes + val expectedCertificateHashes = TRUSTED_BROWSER_CERTIFICATE_HASH[appPackage] ?: return false + val certificateHash = computeCertificatesHash(context, appPackage) + return certificateHash in expectedCertificateHashes } internal enum class BrowserMultiOriginMethod { - None, WebView, Field + None, + WebView, + Field } /** @@ -100,11 +104,12 @@ internal enum class BrowserMultiOriginMethod { * * There are two methods used by browsers: * - Browsers based on Android's WebView report web domains on each WebView view node, which then - * needs to be propagated to the child nodes ([BrowserMultiOriginMethod.WebView]). + * needs to be propagated to the child nodes ( [BrowserMultiOriginMethod.WebView]). * - Browsers with custom Autofill implementations report web domains on each input field ( - * [BrowserMultiOriginMethod.Field]). + * [BrowserMultiOriginMethod.Field]). */ -private val BROWSER_MULTI_ORIGIN_METHOD = mapOf( +private val BROWSER_MULTI_ORIGIN_METHOD = + mapOf( "com.duckduckgo.mobile.android" to BrowserMultiOriginMethod.WebView, "com.opera.mini.native" to BrowserMultiOriginMethod.WebView, "com.opera.mini.native.beta" to BrowserMultiOriginMethod.WebView, @@ -119,10 +124,10 @@ private val BROWSER_MULTI_ORIGIN_METHOD = mapOf( "org.mozilla.focus" to BrowserMultiOriginMethod.Field, "org.mozilla.klar" to BrowserMultiOriginMethod.Field, "org.torproject.torbrowser" to BrowserMultiOriginMethod.WebView, -) + ) private fun getBrowserMultiOriginMethod(appPackage: String): BrowserMultiOriginMethod = - BROWSER_MULTI_ORIGIN_METHOD[appPackage] ?: BrowserMultiOriginMethod.None + BROWSER_MULTI_ORIGIN_METHOD[appPackage] ?: BrowserMultiOriginMethod.None /** * Browsers on this list issue Autofill save requests and provide unmasked passwords as @@ -132,7 +137,8 @@ private fun getBrowserMultiOriginMethod(appPackage: String): BrowserMultiOriginM * `FLAG_SAVE_ON_ALL_VIEW_INVISIBLE` to be set. */ @RequiresApi(Build.VERSION_CODES.O) -private val BROWSER_SAVE_FLAG = mapOf( +private val BROWSER_SAVE_FLAG = + mapOf( "com.duckduckgo.mobile.android" to 0, "org.mozilla.klar" to 0, "org.mozilla.focus" to 0, @@ -142,89 +148,77 @@ private val BROWSER_SAVE_FLAG = mapOf( "com.opera.mini.native" to 0, "com.opera.mini.native.beta" to 0, "com.opera.touch" to 0, -) + ) @RequiresApi(Build.VERSION_CODES.O) -private val BROWSER_SAVE_FLAG_IF_NO_ACCESSIBILITY = mapOf( +private val BROWSER_SAVE_FLAG_IF_NO_ACCESSIBILITY = + mapOf( "com.android.chrome" to SaveInfo.FLAG_SAVE_ON_ALL_VIEWS_INVISIBLE, "com.chrome.beta" to SaveInfo.FLAG_SAVE_ON_ALL_VIEWS_INVISIBLE, "com.chrome.canary" to SaveInfo.FLAG_SAVE_ON_ALL_VIEWS_INVISIBLE, "com.chrome.dev" to SaveInfo.FLAG_SAVE_ON_ALL_VIEWS_INVISIBLE, -) + ) private fun isNoAccessibilityServiceEnabled(context: Context): Boolean { - // See https://chromium.googlesource.com/chromium/src/+/447a31e977a65e2eb78804e4a09633699b4ede33 - return Settings.Secure.getString(context.contentResolver, - Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES).isNullOrEmpty() + // See https://chromium.googlesource.com/chromium/src/+/447a31e977a65e2eb78804e4a09633699b4ede33 + return Settings.Secure.getString(context.contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES) + .isNullOrEmpty() } @RequiresApi(Build.VERSION_CODES.O) private fun getBrowserSaveFlag(context: Context, appPackage: String): Int? = - BROWSER_SAVE_FLAG[appPackage] ?: BROWSER_SAVE_FLAG_IF_NO_ACCESSIBILITY[appPackage]?.takeIf { - isNoAccessibilityServiceEnabled(context) - } + BROWSER_SAVE_FLAG[appPackage] + ?: BROWSER_SAVE_FLAG_IF_NO_ACCESSIBILITY[appPackage]?.takeIf { isNoAccessibilityServiceEnabled(context) } -internal data class BrowserAutofillSupportInfo( - val multiOriginMethod: BrowserMultiOriginMethod, - val saveFlags: Int? -) +internal data class BrowserAutofillSupportInfo(val multiOriginMethod: BrowserMultiOriginMethod, val saveFlags: Int?) @RequiresApi(Build.VERSION_CODES.O) -internal fun getBrowserAutofillSupportInfoIfTrusted( - context: Context, - appPackage: String -): BrowserAutofillSupportInfo? { - if (!isTrustedBrowser(context, appPackage)) return null - return BrowserAutofillSupportInfo( - multiOriginMethod = getBrowserMultiOriginMethod(appPackage), - saveFlags = getBrowserSaveFlag(context, appPackage) - ) +internal fun getBrowserAutofillSupportInfoIfTrusted(context: Context, appPackage: String): BrowserAutofillSupportInfo? { + if (!isTrustedBrowser(context, appPackage)) return null + return BrowserAutofillSupportInfo( + multiOriginMethod = getBrowserMultiOriginMethod(appPackage), + saveFlags = getBrowserSaveFlag(context, appPackage) + ) } -private val FLAKY_BROWSERS = listOf( +private val FLAKY_BROWSERS = + listOf( "org.bromite.bromite", "org.ungoogled.chromium.stable", "com.kiwibrowser.browser", -) + ) public enum class BrowserAutofillSupportLevel { - None, - FlakyFill, - PasswordFill, - PasswordFillAndSaveIfNoAccessibility, - GeneralFill, - GeneralFillAndSave, + None, + FlakyFill, + PasswordFill, + PasswordFillAndSaveIfNoAccessibility, + GeneralFill, + GeneralFillAndSave, } @RequiresApi(Build.VERSION_CODES.O) -private fun getBrowserAutofillSupportLevel( - context: Context, - appPackage: String -): BrowserAutofillSupportLevel { - val browserInfo = getBrowserAutofillSupportInfoIfTrusted(context, appPackage) - return when { - browserInfo == null -> BrowserAutofillSupportLevel.None - appPackage in FLAKY_BROWSERS -> BrowserAutofillSupportLevel.FlakyFill - appPackage in BROWSER_SAVE_FLAG_IF_NO_ACCESSIBILITY -> BrowserAutofillSupportLevel.PasswordFillAndSaveIfNoAccessibility - browserInfo.multiOriginMethod == BrowserMultiOriginMethod.None -> BrowserAutofillSupportLevel.PasswordFill - browserInfo.saveFlags == null -> BrowserAutofillSupportLevel.GeneralFill - else -> BrowserAutofillSupportLevel.GeneralFillAndSave - } +private fun getBrowserAutofillSupportLevel(context: Context, appPackage: String): BrowserAutofillSupportLevel { + val browserInfo = getBrowserAutofillSupportInfoIfTrusted(context, appPackage) + return when { + browserInfo == null -> BrowserAutofillSupportLevel.None + appPackage in FLAKY_BROWSERS -> BrowserAutofillSupportLevel.FlakyFill + appPackage in BROWSER_SAVE_FLAG_IF_NO_ACCESSIBILITY -> + BrowserAutofillSupportLevel.PasswordFillAndSaveIfNoAccessibility + browserInfo.multiOriginMethod == BrowserMultiOriginMethod.None -> BrowserAutofillSupportLevel.PasswordFill + browserInfo.saveFlags == null -> BrowserAutofillSupportLevel.GeneralFill + else -> BrowserAutofillSupportLevel.GeneralFillAndSave + } } @RequiresApi(Build.VERSION_CODES.O) -public fun getInstalledBrowsersWithAutofillSupportLevel(context: Context): List<Pair<String, BrowserAutofillSupportLevel>> { - val testWebIntent = Intent(Intent.ACTION_VIEW).apply { - data = Uri.parse("http://example.org") - } - val installedBrowsers = context.packageManager.queryIntentActivities( - testWebIntent, - PackageManager.MATCH_ALL - ) - return installedBrowsers.map { - it to getBrowserAutofillSupportLevel(context, it.activityInfo.packageName) - }.filter { it.first.isDefault || it.second != BrowserAutofillSupportLevel.None }.map { - context.packageManager.getApplicationLabel(it.first.activityInfo.applicationInfo) - .toString() to it.second - } +public fun getInstalledBrowsersWithAutofillSupportLevel( + context: Context +): List<Pair<String, BrowserAutofillSupportLevel>> { + val testWebIntent = Intent(Intent.ACTION_VIEW).apply { data = Uri.parse("http://example.org") } + val installedBrowsers = context.packageManager.queryIntentActivities(testWebIntent, PackageManager.MATCH_ALL) + return installedBrowsers + .map { it to getBrowserAutofillSupportLevel(context, it.activityInfo.packageName) } + .filter { it.first.isDefault || it.second != BrowserAutofillSupportLevel.None } + .map { context.packageManager.getApplicationLabel(it.first.activityInfo.applicationInfo).toString() to it.second } } diff --git a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FormField.kt b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FormField.kt index f5e980dc..d3ce8408 100644 --- a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FormField.kt +++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FormField.kt @@ -14,291 +14,321 @@ import androidx.autofill.HintConstants import java.util.Locale internal enum class CertaintyLevel { - Impossible, Possible, Likely, Certain + Impossible, + Possible, + Likely, + Certain } /** - * Represents a single potentially fillable or saveable field together with all meta data - * extracted from its [AssistStructure.ViewNode]. + * Represents a single potentially fillable or saveable field together with all meta data extracted + * from its [AssistStructure.ViewNode]. */ @RequiresApi(Build.VERSION_CODES.O) internal class FormField( - node: AssistStructure.ViewNode, - private val index: Int, - passDownWebViewOrigins: Boolean, - passedDownWebOrigin: String? = null + node: AssistStructure.ViewNode, + private val index: Int, + passDownWebViewOrigins: Boolean, + passedDownWebOrigin: String? = null ) { - companion object { + companion object { - private val HINTS_USERNAME = listOf( - HintConstants.AUTOFILL_HINT_USERNAME, - HintConstants.AUTOFILL_HINT_NEW_USERNAME, - ) - - private val HINTS_NEW_PASSWORD = listOf( - HintConstants.AUTOFILL_HINT_NEW_PASSWORD, - ) - - private val HINTS_PASSWORD = HINTS_NEW_PASSWORD + listOf( - HintConstants.AUTOFILL_HINT_PASSWORD, - ) + private val HINTS_USERNAME = + listOf( + HintConstants.AUTOFILL_HINT_USERNAME, + HintConstants.AUTOFILL_HINT_NEW_USERNAME, + ) - private val HINTS_OTP = listOf( - HintConstants.AUTOFILL_HINT_SMS_OTP, - ) + private val HINTS_NEW_PASSWORD = + listOf( + HintConstants.AUTOFILL_HINT_NEW_PASSWORD, + ) - @Suppress("DEPRECATION") - private val HINTS_FILLABLE = HINTS_USERNAME + HINTS_PASSWORD + HINTS_OTP + listOf( - HintConstants.AUTOFILL_HINT_EMAIL_ADDRESS, - HintConstants.AUTOFILL_HINT_NAME, - HintConstants.AUTOFILL_HINT_PERSON_NAME, - HintConstants.AUTOFILL_HINT_PHONE, - HintConstants.AUTOFILL_HINT_PHONE_NUMBER, + private val HINTS_PASSWORD = + HINTS_NEW_PASSWORD + + listOf( + HintConstants.AUTOFILL_HINT_PASSWORD, ) - private val ANDROID_TEXT_FIELD_CLASS_NAMES = listOf( - "android.widget.EditText", - "android.widget.AutoCompleteTextView", - "androidx.appcompat.widget.AppCompatEditText", - "android.support.v7.widget.AppCompatEditText", - "com.google.android.material.textfield.TextInputEditText", + private val HINTS_OTP = + listOf( + HintConstants.AUTOFILL_HINT_SMS_OTP, + ) + + @Suppress("DEPRECATION") + private val HINTS_FILLABLE = + HINTS_USERNAME + + HINTS_PASSWORD + + HINTS_OTP + + listOf( + HintConstants.AUTOFILL_HINT_EMAIL_ADDRESS, + HintConstants.AUTOFILL_HINT_NAME, + HintConstants.AUTOFILL_HINT_PERSON_NAME, + HintConstants.AUTOFILL_HINT_PHONE, + HintConstants.AUTOFILL_HINT_PHONE_NUMBER, ) - private const val ANDROID_WEB_VIEW_CLASS_NAME = "android.webkit.WebView" - - private fun isPasswordInputType(inputType: Int): Boolean { - val typeClass = inputType and InputType.TYPE_MASK_CLASS - val typeVariation = inputType and InputType.TYPE_MASK_VARIATION - return when (typeClass) { - InputType.TYPE_CLASS_NUMBER -> typeVariation == InputType.TYPE_NUMBER_VARIATION_PASSWORD - InputType.TYPE_CLASS_TEXT -> typeVariation in listOf( - InputType.TYPE_TEXT_VARIATION_PASSWORD, - InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD, - InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD, - ) - else -> false - } - } - - private val HTML_INPUT_FIELD_TYPES_USERNAME = listOf( - "email", - "tel", - "text", - ) - private val HTML_INPUT_FIELD_TYPES_PASSWORD = listOf( - "password", - ) - private val HTML_INPUT_FIELD_TYPES_OTP = listOf( - "tel", - "text", - ) - private val HTML_INPUT_FIELD_TYPES_FILLABLE = - (HTML_INPUT_FIELD_TYPES_USERNAME + HTML_INPUT_FIELD_TYPES_PASSWORD + HTML_INPUT_FIELD_TYPES_OTP).toSet().toList() - - @RequiresApi(Build.VERSION_CODES.O) - private fun isSupportedHint(hint: String) = hint in HINTS_FILLABLE - - private val EXCLUDED_TERMS = listOf( - "url_bar", // Chrome/Edge/Firefox address bar - "url_field", // Opera address bar - "location_bar_edit_text", // Samsung address bar - "search", "find", "captcha", - "postal", // Prevent postal code fields from being mistaken for OTP fields - - ) - private val PASSWORD_HEURISTIC_TERMS = listOf( - "pass", - "pswd", - "pwd", - ) - private val USERNAME_HEURISTIC_TERMS = listOf( - "alias", - "benutzername", - "e-mail", - "email", - "login", - "user", - ) - private val OTP_HEURISTIC_TERMS = listOf( - "einmal", - "otp", - "challenge", - "verification", - ) - private val OTP_WEAK_HEURISTIC_TERMS = listOf( - "code", - ) + private val ANDROID_TEXT_FIELD_CLASS_NAMES = + listOf( + "android.widget.EditText", + "android.widget.AutoCompleteTextView", + "androidx.appcompat.widget.AppCompatEditText", + "android.support.v7.widget.AppCompatEditText", + "com.google.android.material.textfield.TextInputEditText", + ) + + private const val ANDROID_WEB_VIEW_CLASS_NAME = "android.webkit.WebView" + + private fun isPasswordInputType(inputType: Int): Boolean { + val typeClass = inputType and InputType.TYPE_MASK_CLASS + val typeVariation = inputType and InputType.TYPE_MASK_VARIATION + return when (typeClass) { + InputType.TYPE_CLASS_NUMBER -> typeVariation == InputType.TYPE_NUMBER_VARIATION_PASSWORD + InputType.TYPE_CLASS_TEXT -> + typeVariation in + listOf( + InputType.TYPE_TEXT_VARIATION_PASSWORD, + InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD, + InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD, + ) + else -> false + } } - private val List<String>.anyMatchesFieldInfo - get() = any { - fieldId.contains(it) || hint.contains(it) || htmlName.contains(it) - } - - val autofillId: AutofillId = node.autofillId!! - - // Information for heuristics and exclusion rules based only on the current field - private val htmlId = node.htmlInfo?.attributes?.firstOrNull { it.first == "id" }?.second - private val resourceId = node.idEntry - private val fieldId = (htmlId ?: resourceId ?: "").toLowerCase(Locale.US) - private val hint = node.hint?.toLowerCase(Locale.US) ?: "" - private val className: String? = node.className - private val inputType = node.inputType - - // Information for advanced heuristics taking multiple fields and page context into account - val isFocused = node.isFocused - - // The webOrigin of a WebView should be passed down to its children in certain browsers - private val isWebView = node.className == ANDROID_WEB_VIEW_CLASS_NAME - val webOrigin = node.webOrigin ?: if (passDownWebViewOrigins) passedDownWebOrigin else null - val webOriginToPassDown = if (passDownWebViewOrigins) { - if (isWebView) webOrigin else passedDownWebOrigin + private val HTML_INPUT_FIELD_TYPES_USERNAME = + listOf( + "email", + "tel", + "text", + ) + private val HTML_INPUT_FIELD_TYPES_PASSWORD = + listOf( + "password", + ) + private val HTML_INPUT_FIELD_TYPES_OTP = + listOf( + "tel", + "text", + ) + private val HTML_INPUT_FIELD_TYPES_FILLABLE = + (HTML_INPUT_FIELD_TYPES_USERNAME + HTML_INPUT_FIELD_TYPES_PASSWORD + HTML_INPUT_FIELD_TYPES_OTP).toSet().toList() + + @RequiresApi(Build.VERSION_CODES.O) private fun isSupportedHint(hint: String) = hint in HINTS_FILLABLE + + private val EXCLUDED_TERMS = + listOf( + "url_bar", // Chrome/Edge/Firefox address bar + "url_field", // Opera address bar + "location_bar_edit_text", // Samsung address bar + "search", + "find", + "captcha", + "postal", // Prevent postal code fields from being mistaken for OTP fields + ) + private val PASSWORD_HEURISTIC_TERMS = + listOf( + "pass", + "pswd", + "pwd", + ) + private val USERNAME_HEURISTIC_TERMS = + listOf( + "alias", + "benutzername", + "e-mail", + "email", + "login", + "user", + ) + private val OTP_HEURISTIC_TERMS = + listOf( + "einmal", + "otp", + "challenge", + "verification", + ) + private val OTP_WEAK_HEURISTIC_TERMS = + listOf( + "code", + ) + } + + private val List<String>.anyMatchesFieldInfo + get() = any { fieldId.contains(it) || hint.contains(it) || htmlName.contains(it) } + + val autofillId: AutofillId = node.autofillId!! + + // Information for heuristics and exclusion rules based only on the current field + private val htmlId = node.htmlInfo?.attributes?.firstOrNull { it.first == "id" }?.second + private val resourceId = node.idEntry + private val fieldId = (htmlId ?: resourceId ?: "").toLowerCase(Locale.US) + private val hint = node.hint?.toLowerCase(Locale.US) ?: "" + private val className: String? = node.className + private val inputType = node.inputType + + // Information for advanced heuristics taking multiple fields and page context into account + val isFocused = node.isFocused + + // The webOrigin of a WebView should be passed down to its children in certain browsers + private val isWebView = node.className == ANDROID_WEB_VIEW_CLASS_NAME + val webOrigin = node.webOrigin ?: if (passDownWebViewOrigins) passedDownWebOrigin else null + val webOriginToPassDown = + if (passDownWebViewOrigins) { + if (isWebView) webOrigin else passedDownWebOrigin } else { - null - } - - // Basic type detection for HTML fields - private val htmlTag = node.htmlInfo?.tag - private val htmlAttributes: Map<String, String> = - node.htmlInfo?.attributes?.filter { it.first != null && it.second != null } - ?.associate { Pair(it.first.toLowerCase(Locale.US), it.second.toLowerCase(Locale.US)) } - ?: emptyMap() - - private val htmlAttributesDebug = - htmlAttributes.entries.joinToString { "${it.key}=${it.value}" } - private val htmlInputType = htmlAttributes["type"] - private val htmlName = htmlAttributes["name"] ?: "" - private val htmlMaxLength = htmlAttributes["maxlength"]?.toIntOrNull() - private val isHtmlField = htmlTag == "input" - private val isHtmlPasswordField = - isHtmlField && htmlInputType in HTML_INPUT_FIELD_TYPES_PASSWORD - private val isHtmlTextField = isHtmlField && htmlInputType in HTML_INPUT_FIELD_TYPES_FILLABLE - - // Basic type detection for native fields - private val hasPasswordInputType = isPasswordInputType(inputType) - - // HTML fields with non-fillable types (such as submit buttons) should be excluded here - private val isAndroidTextField = !isHtmlField && className in ANDROID_TEXT_FIELD_CLASS_NAMES - private val isAndroidPasswordField = isAndroidTextField && hasPasswordInputType - - private val isTextField = isAndroidTextField || isHtmlTextField - - // Autofill hint detection for native fields - private val autofillHints = node.autofillHints?.filter { isSupportedHint(it) } ?: emptyList() - private val excludedByAutofillHints = - if (autofillHints.isEmpty()) false else autofillHints.intersect(HINTS_FILLABLE).isEmpty() - val hasAutofillHintPassword = autofillHints.intersect(HINTS_PASSWORD).isNotEmpty() - private val hasAutofillHintNewPassword = autofillHints.intersect(HINTS_NEW_PASSWORD).isNotEmpty() - private val hasAutofillHintUsername = autofillHints.intersect(HINTS_USERNAME).isNotEmpty() - private val hasAutofillHintOtp = autofillHints.intersect(HINTS_OTP).isNotEmpty() - - // W3C autocomplete hint detection for HTML fields - private val htmlAutocomplete = htmlAttributes["autocomplete"] - - // Ignored for now, see excludedByHints - private val excludedByAutocompleteHint = htmlAutocomplete == "off" - private val hasAutocompleteHintUsername = htmlAutocomplete == "username" - val hasAutocompleteHintCurrentPassword = htmlAutocomplete == "current-password" - private val hasAutocompleteHintNewPassword = htmlAutocomplete == "new-password" - private val hasAutocompleteHintPassword = - hasAutocompleteHintCurrentPassword || hasAutocompleteHintNewPassword - private val hasAutocompleteHintOtp = htmlAutocomplete == "one-time-code" - - // Results of hint-based field type detection - val hasHintUsername = hasAutofillHintUsername || hasAutocompleteHintUsername - val hasHintPassword = hasAutofillHintPassword || hasAutocompleteHintPassword - val hasHintNewPassword = hasAutofillHintNewPassword || hasAutocompleteHintNewPassword - val hasHintOtp = hasAutofillHintOtp || hasAutocompleteHintOtp - - // Basic autofill exclusion checks - private val hasAutofillTypeText = node.autofillType == View.AUTOFILL_TYPE_TEXT - val isVisible = node.visibility == View.VISIBLE && htmlAttributes["aria-hidden"] != "true" - - // Hidden username fields are used to help password managers save credentials in two-step login - // flows. - // See: https://www.chromium.org/developers/design-documents/form-styles-that-chromium-understands - val couldBeTwoStepHiddenUsername = !isVisible && isHtmlTextField && hasAutocompleteHintUsername - - // Some websites with two-step login flows offer hidden password fields to fill the password - // already in the first step. Thus, we delegate the decision about filling invisible password - // fields to the fill rules and only exclude those fields that have incompatible autocomplete - // hint. - val couldBeTwoStepHiddenPassword = - !isVisible && isHtmlPasswordField && (hasAutocompleteHintCurrentPassword || htmlAutocomplete == null) - - // Since many site put autocomplete=off on login forms for compliance reasons or since they are - // worried of the user's browser automatically (i.e., without any user interaction) filling - // them, which we never do, we choose to ignore the value of excludedByAutocompleteHint. - // TODO: Revisit this decision in the future - private val excludedByHints = excludedByAutofillHints - - // Only offer to fill into custom views if they explicitly opted into Autofill. - val relevantField = hasAutofillTypeText && (isTextField || autofillHints.isNotEmpty()) && !excludedByHints - - // Exclude fields based on hint, resource ID or HTML name. - // Note: We still report excluded fields as relevant since they count for adjacency heuristics, - // but ensure that they are never detected as password or username fields. - private val hasExcludedTerm = EXCLUDED_TERMS.anyMatchesFieldInfo - private val notExcluded = relevantField && !hasExcludedTerm - - // Password field heuristics (based only on the current field) - private val isPossiblePasswordField = - notExcluded && (isAndroidPasswordField || isHtmlPasswordField || hasHintPassword) - private val isCertainPasswordField = isPossiblePasswordField && hasHintPassword - private val isLikelyPasswordField = isPossiblePasswordField && - (isCertainPasswordField || PASSWORD_HEURISTIC_TERMS.anyMatchesFieldInfo) - val passwordCertainty = - if (isCertainPasswordField) CertaintyLevel.Certain else if (isLikelyPasswordField) CertaintyLevel.Likely else if (isPossiblePasswordField) CertaintyLevel.Possible else CertaintyLevel.Impossible - - // OTP field heuristics (based only on the current field) - private val isPossibleOtpField = notExcluded && !isPossiblePasswordField - private val isCertainOtpField = isPossibleOtpField && hasHintOtp - private val isLikelyOtpField = isPossibleOtpField && ( - isCertainOtpField || OTP_HEURISTIC_TERMS.anyMatchesFieldInfo || - ((htmlMaxLength == null || htmlMaxLength in 6..8) && OTP_WEAK_HEURISTIC_TERMS.anyMatchesFieldInfo)) - val otpCertainty = - if (isCertainOtpField) CertaintyLevel.Certain else if (isLikelyOtpField) CertaintyLevel.Likely else if (isPossibleOtpField) CertaintyLevel.Possible else CertaintyLevel.Impossible - - // Username field heuristics (based only on the current field) - private val isPossibleUsernameField = notExcluded && !isPossiblePasswordField && !isCertainOtpField - private val isCertainUsernameField = isPossibleUsernameField && hasHintUsername - private val isLikelyUsernameField = isPossibleUsernameField && (isCertainUsernameField || (USERNAME_HEURISTIC_TERMS.anyMatchesFieldInfo)) - val usernameCertainty = - if (isCertainUsernameField) CertaintyLevel.Certain else if (isLikelyUsernameField) CertaintyLevel.Likely else if (isPossibleUsernameField) CertaintyLevel.Possible else CertaintyLevel.Impossible - - infix fun directlyPrecedes(that: FormField?): Boolean { - return index == (that ?: return false).index - 1 - } - - infix fun directlyPrecedes(that: Iterable<FormField>): Boolean { - val firstIndex = that.map { it.index }.minOrNull() ?: return false - return index == firstIndex - 1 - } - - infix fun directlyFollows(that: FormField?): Boolean { - return index == (that ?: return false).index + 1 - } - - infix fun directlyFollows(that: Iterable<FormField>): Boolean { - val lastIndex = that.map { it.index }.maxOrNull() ?: return false - return index == lastIndex + 1 - } - - override fun toString(): String { - val field = if (isHtmlTextField) "$htmlTag[type=$htmlInputType]" else className - val description = - "\"$hint\", \"$fieldId\"${if (isFocused) ", focused" else ""}${if (isVisible) ", visible" else ""}, $webOrigin, $htmlAttributesDebug, $autofillHints" - return "$field ($description): password=$passwordCertainty, username=$usernameCertainty, otp=$otpCertainty" - } - - override fun equals(other: Any?): Boolean { - if (other == null) return false - if (this.javaClass != other.javaClass) return false - return autofillId == (other as FormField).autofillId + null } - override fun hashCode(): Int { - return autofillId.hashCode() + // Basic type detection for HTML fields + private val htmlTag = node.htmlInfo?.tag + private val htmlAttributes: Map<String, String> = + node.htmlInfo?.attributes?.filter { it.first != null && it.second != null }?.associate { + Pair(it.first.toLowerCase(Locale.US), it.second.toLowerCase(Locale.US)) } + ?: emptyMap() + + private val htmlAttributesDebug = htmlAttributes.entries.joinToString { "${it.key}=${it.value}" } + private val htmlInputType = htmlAttributes["type"] + private val htmlName = htmlAttributes["name"] ?: "" + private val htmlMaxLength = htmlAttributes["maxlength"]?.toIntOrNull() + private val isHtmlField = htmlTag == "input" + private val isHtmlPasswordField = isHtmlField && htmlInputType in HTML_INPUT_FIELD_TYPES_PASSWORD + private val isHtmlTextField = isHtmlField && htmlInputType in HTML_INPUT_FIELD_TYPES_FILLABLE + + // Basic type detection for native fields + private val hasPasswordInputType = isPasswordInputType(inputType) + + // HTML fields with non-fillable types (such as submit buttons) should be excluded here + private val isAndroidTextField = !isHtmlField && className in ANDROID_TEXT_FIELD_CLASS_NAMES + private val isAndroidPasswordField = isAndroidTextField && hasPasswordInputType + + private val isTextField = isAndroidTextField || isHtmlTextField + + // Autofill hint detection for native fields + private val autofillHints = node.autofillHints?.filter { isSupportedHint(it) } ?: emptyList() + private val excludedByAutofillHints = + if (autofillHints.isEmpty()) false else autofillHints.intersect(HINTS_FILLABLE).isEmpty() + val hasAutofillHintPassword = autofillHints.intersect(HINTS_PASSWORD).isNotEmpty() + private val hasAutofillHintNewPassword = autofillHints.intersect(HINTS_NEW_PASSWORD).isNotEmpty() + private val hasAutofillHintUsername = autofillHints.intersect(HINTS_USERNAME).isNotEmpty() + private val hasAutofillHintOtp = autofillHints.intersect(HINTS_OTP).isNotEmpty() + + // W3C autocomplete hint detection for HTML fields + private val htmlAutocomplete = htmlAttributes["autocomplete"] + + // Ignored for now, see excludedByHints + private val excludedByAutocompleteHint = htmlAutocomplete == "off" + private val hasAutocompleteHintUsername = htmlAutocomplete == "username" + val hasAutocompleteHintCurrentPassword = htmlAutocomplete == "current-password" + private val hasAutocompleteHintNewPassword = htmlAutocomplete == "new-password" + private val hasAutocompleteHintPassword = hasAutocompleteHintCurrentPassword || hasAutocompleteHintNewPassword + private val hasAutocompleteHintOtp = htmlAutocomplete == "one-time-code" + + // Results of hint-based field type detection + val hasHintUsername = hasAutofillHintUsername || hasAutocompleteHintUsername + val hasHintPassword = hasAutofillHintPassword || hasAutocompleteHintPassword + val hasHintNewPassword = hasAutofillHintNewPassword || hasAutocompleteHintNewPassword + val hasHintOtp = hasAutofillHintOtp || hasAutocompleteHintOtp + + // Basic autofill exclusion checks + private val hasAutofillTypeText = node.autofillType == View.AUTOFILL_TYPE_TEXT + val isVisible = node.visibility == View.VISIBLE && htmlAttributes["aria-hidden"] != "true" + + // Hidden username fields are used to help password managers save credentials in two-step login + // flows. + // See: + // https://www.chromium.org/developers/design-documents/form-styles-that-chromium-understands + val couldBeTwoStepHiddenUsername = !isVisible && isHtmlTextField && hasAutocompleteHintUsername + + // Some websites with two-step login flows offer hidden password fields to fill the password + // already in the first step. Thus, we delegate the decision about filling invisible password + // fields to the fill rules and only exclude those fields that have incompatible autocomplete + // hint. + val couldBeTwoStepHiddenPassword = + !isVisible && isHtmlPasswordField && (hasAutocompleteHintCurrentPassword || htmlAutocomplete == null) + + // Since many site put autocomplete=off on login forms for compliance reasons or since they are + // worried of the user's browser automatically (i.e., without any user interaction) filling + // them, which we never do, we choose to ignore the value of excludedByAutocompleteHint. + // TODO: Revisit this decision in the future + private val excludedByHints = excludedByAutofillHints + + // Only offer to fill into custom views if they explicitly opted into Autofill. + val relevantField = hasAutofillTypeText && (isTextField || autofillHints.isNotEmpty()) && !excludedByHints + + // Exclude fields based on hint, resource ID or HTML name. + // Note: We still report excluded fields as relevant since they count for adjacency heuristics, + // but ensure that they are never detected as password or username fields. + private val hasExcludedTerm = EXCLUDED_TERMS.anyMatchesFieldInfo + private val notExcluded = relevantField && !hasExcludedTerm + + // Password field heuristics (based only on the current field) + private val isPossiblePasswordField = + notExcluded && (isAndroidPasswordField || isHtmlPasswordField || hasHintPassword) + private val isCertainPasswordField = isPossiblePasswordField && hasHintPassword + private val isLikelyPasswordField = + isPossiblePasswordField && (isCertainPasswordField || PASSWORD_HEURISTIC_TERMS.anyMatchesFieldInfo) + val passwordCertainty = + if (isCertainPasswordField) CertaintyLevel.Certain + else if (isLikelyPasswordField) CertaintyLevel.Likely + else if (isPossiblePasswordField) CertaintyLevel.Possible else CertaintyLevel.Impossible + + // OTP field heuristics (based only on the current field) + private val isPossibleOtpField = notExcluded && !isPossiblePasswordField + private val isCertainOtpField = isPossibleOtpField && hasHintOtp + private val isLikelyOtpField = + isPossibleOtpField && + (isCertainOtpField || + OTP_HEURISTIC_TERMS.anyMatchesFieldInfo || + ((htmlMaxLength == null || htmlMaxLength in 6..8) && OTP_WEAK_HEURISTIC_TERMS.anyMatchesFieldInfo)) + val otpCertainty = + if (isCertainOtpField) CertaintyLevel.Certain + else if (isLikelyOtpField) CertaintyLevel.Likely + else if (isPossibleOtpField) CertaintyLevel.Possible else CertaintyLevel.Impossible + + // Username field heuristics (based only on the current field) + private val isPossibleUsernameField = notExcluded && !isPossiblePasswordField && !isCertainOtpField + private val isCertainUsernameField = isPossibleUsernameField && hasHintUsername + private val isLikelyUsernameField = + isPossibleUsernameField && (isCertainUsernameField || (USERNAME_HEURISTIC_TERMS.anyMatchesFieldInfo)) + val usernameCertainty = + if (isCertainUsernameField) CertaintyLevel.Certain + else if (isLikelyUsernameField) CertaintyLevel.Likely + else if (isPossibleUsernameField) CertaintyLevel.Possible else CertaintyLevel.Impossible + + infix fun directlyPrecedes(that: FormField?): Boolean { + return index == (that ?: return false).index - 1 + } + + infix fun directlyPrecedes(that: Iterable<FormField>): Boolean { + val firstIndex = that.map { it.index }.minOrNull() ?: return false + return index == firstIndex - 1 + } + + infix fun directlyFollows(that: FormField?): Boolean { + return index == (that ?: return false).index + 1 + } + + infix fun directlyFollows(that: Iterable<FormField>): Boolean { + val lastIndex = that.map { it.index }.maxOrNull() ?: return false + return index == lastIndex + 1 + } + + override fun toString(): String { + val field = if (isHtmlTextField) "$htmlTag[type=$htmlInputType]" else className + val description = + "\"$hint\", \"$fieldId\"${if (isFocused) ", focused" else ""}${if (isVisible) ", visible" else ""}, $webOrigin, $htmlAttributesDebug, $autofillHints" + return "$field ($description): password=$passwordCertainty, username=$usernameCertainty, otp=$otpCertainty" + } + + override fun equals(other: Any?): Boolean { + if (other == null) return false + if (this.javaClass != other.javaClass) return false + return autofillId == (other as FormField).autofillId + } + + override fun hashCode(): Int { + return autofillId.hashCode() + } } diff --git a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/PublicSuffixListCache.kt b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/PublicSuffixListCache.kt index f7f24ef6..8c95ee90 100644 --- a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/PublicSuffixListCache.kt +++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/PublicSuffixListCache.kt @@ -11,21 +11,20 @@ import mozilla.components.lib.publicsuffixlist.PublicSuffixList private object PublicSuffixListCache { - private lateinit var publicSuffixList: PublicSuffixList + private lateinit var publicSuffixList: PublicSuffixList - fun getOrCachePublicSuffixList(context: Context): PublicSuffixList { - if (!::publicSuffixList.isInitialized) { - publicSuffixList = PublicSuffixList(context) - // Trigger loading the actual public suffix list, but don't block. - @Suppress("DeferredResultUnused") - publicSuffixList.prefetch() - } - return publicSuffixList + fun getOrCachePublicSuffixList(context: Context): PublicSuffixList { + if (!::publicSuffixList.isInitialized) { + publicSuffixList = PublicSuffixList(context) + // Trigger loading the actual public suffix list, but don't block. + @Suppress("DeferredResultUnused") publicSuffixList.prefetch() } + return publicSuffixList + } } public fun cachePublicSuffixList(context: Context) { - PublicSuffixListCache.getOrCachePublicSuffixList(context) + PublicSuffixListCache.getOrCachePublicSuffixList(context) } /** @@ -36,17 +35,15 @@ public fun cachePublicSuffixList(context: Context) { * the return value for valid domains. */ internal fun getPublicSuffixPlusOne(context: Context, domain: String, customSuffixes: Sequence<String>) = runBlocking { - // We only feed valid domain names which are not IP addresses into getPublicSuffixPlusOne. - // We do not check whether the domain actually exists (actually, not even whether its TLD - // exists). As long as we restrict ourselves to syntactically valid domain names, - // getPublicSuffixPlusOne will return non-colliding results. - if (!Patterns.DOMAIN_NAME.matcher(domain).matches() || Patterns.IP_ADDRESS.matcher(domain) - .matches() - ) { - domain - } else { - getCanonicalSuffix(context, domain, customSuffixes) - } + // We only feed valid domain names which are not IP addresses into getPublicSuffixPlusOne. + // We do not check whether the domain actually exists (actually, not even whether its TLD + // exists). As long as we restrict ourselves to syntactically valid domain names, + // getPublicSuffixPlusOne will return non-colliding results. + if (!Patterns.DOMAIN_NAME.matcher(domain).matches() || Patterns.IP_ADDRESS.matcher(domain).matches()) { + domain + } else { + getCanonicalSuffix(context, domain, customSuffixes) + } } /** @@ -56,26 +53,21 @@ internal fun getPublicSuffixPlusOne(context: Context, domain: String, customSuff * - the direct subdomain of [suffix] of which [domain] is a subdomain. */ private fun getSuffixPlusUpToOne(domain: String, suffix: String): String? { - if (domain == suffix) - return domain - val prefix = domain.removeSuffix(".$suffix") - if (prefix == domain || prefix.isEmpty()) - return null - val lastPrefixPart = prefix.takeLastWhile { it != '.' } - return "$lastPrefixPart.$suffix" + if (domain == suffix) return domain + val prefix = domain.removeSuffix(".$suffix") + if (prefix == domain || prefix.isEmpty()) return null + val lastPrefixPart = prefix.takeLastWhile { it != '.' } + return "$lastPrefixPart.$suffix" } -private suspend fun getCanonicalSuffix( - context: Context, domain: String, customSuffixes: Sequence<String>): String { - val publicSuffixList = PublicSuffixListCache.getOrCachePublicSuffixList(context) - val publicSuffixPlusOne = publicSuffixList.getPublicSuffixPlusOne(domain).await() - ?: return domain - var longestSuffix = publicSuffixPlusOne - for (customSuffix in customSuffixes) { - val suffixPlusUpToOne = getSuffixPlusUpToOne(domain, customSuffix) ?: continue - // A shorter suffix is automatically a substring. - if (suffixPlusUpToOne.length > longestSuffix.length) - longestSuffix = suffixPlusUpToOne - } - return longestSuffix +private suspend fun getCanonicalSuffix(context: Context, domain: String, customSuffixes: Sequence<String>): String { + val publicSuffixList = PublicSuffixListCache.getOrCachePublicSuffixList(context) + val publicSuffixPlusOne = publicSuffixList.getPublicSuffixPlusOne(domain).await() ?: return domain + var longestSuffix = publicSuffixPlusOne + for (customSuffix in customSuffixes) { + val suffixPlusUpToOne = getSuffixPlusUpToOne(domain, customSuffix) ?: continue + // A shorter suffix is automatically a substring. + if (suffixPlusUpToOne.length > longestSuffix.length) longestSuffix = suffixPlusUpToOne + } + return longestSuffix } diff --git a/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixList.kt b/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixList.kt index f50d0d5a..c31df752 100644 --- a/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixList.kt +++ b/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixList.kt @@ -22,52 +22,48 @@ import kotlinx.coroutines.async /** * API for reading and accessing the public suffix list. * - * > A "public suffix" is one under which Internet users can (or historically could) directly register names. Some - * > examples of public suffixes are .com, .co.uk and pvt.k12.ma.us. The Public Suffix List is a list of all known - * > public suffixes. + * > A "public suffix" is one under which Internet users can (or historically could) directly + * register names. Some > examples of public suffixes are .com, .co.uk and pvt.k12.ma.us. The Public + * Suffix List is a list of all known > public suffixes. * - * Note that this implementation applies the rules of the public suffix list only and does not validate domains. + * Note that this implementation applies the rules of the public suffix list only and does not + * validate domains. * - * https://publicsuffix.org/ - * https://github.com/publicsuffix/list + * https://publicsuffix.org/ https://github.com/publicsuffix/list */ internal class PublicSuffixList( - context: Context, - dispatcher: CoroutineDispatcher = Dispatchers.IO, - private val scope: CoroutineScope = CoroutineScope(dispatcher) + context: Context, + dispatcher: CoroutineDispatcher = Dispatchers.IO, + private val scope: CoroutineScope = CoroutineScope(dispatcher) ) { - private val data: PublicSuffixListData by lazy(LazyThreadSafetyMode.PUBLICATION) { PublicSuffixListLoader.load(context) } + private val data: PublicSuffixListData by lazy(LazyThreadSafetyMode.PUBLICATION) { + PublicSuffixListLoader.load(context) + } - /** - * Prefetch the public suffix list from disk so that it is available in memory. - */ - fun prefetch(): Deferred<Unit> = scope.async { - data.run { Unit } - } + /** Prefetch the public suffix list from disk so that it is available in memory. */ + fun prefetch(): Deferred<Unit> = scope.async { data.run { Unit } } - /** - * Returns the public suffix and one more level; known as the registrable domain. Returns `null` if - * [domain] is a public suffix itself. - * - * E.g.: - * ``` - * wwww.mozilla.org -> mozilla.org - * www.bcc.co.uk -> bbc.co.uk - * a.b.ide.kyoto.jp -> b.ide.kyoto.jp - * ``` - * - * @param [domain] _must_ be a valid domain. [PublicSuffixList] performs no validation, and if any unexpected values - * are passed (e.g., a full URL, a domain with a trailing '/', etc) this may return an incorrect result. - */ - fun getPublicSuffixPlusOne(domain: String): Deferred<String?> = scope.async { - when (val offset = data.getPublicSuffixOffset(domain)) { - is PublicSuffixOffset.Offset -> domain - .split('.') - .drop(offset.value) - .joinToString(separator = ".") - else -> null - } + /** + * Returns the public suffix and one more level; known as the registrable domain. Returns `null` + * if [domain] is a public suffix itself. + * + * E.g.: + * ``` + * wwww.mozilla.org -> mozilla.org + * www.bcc.co.uk -> bbc.co.uk + * a.b.ide.kyoto.jp -> b.ide.kyoto.jp + * ``` + * + * @param [domain] _must_ be a valid domain. [PublicSuffixList] performs no validation, and if any + * unexpected values are passed (e.g., a full URL, a domain with a trailing '/', etc) this may + * return an incorrect result. + */ + fun getPublicSuffixPlusOne(domain: String): Deferred<String?> = + scope.async { + when (val offset = data.getPublicSuffixOffset(domain)) { + is PublicSuffixOffset.Offset -> domain.split('.').drop(offset.value).joinToString(separator = ".") + else -> null + } } - } diff --git a/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListData.kt b/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListData.kt index 47816ea0..eb59f3d3 100644 --- a/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListData.kt +++ b/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListData.kt @@ -15,154 +15,148 @@ package mozilla.components.lib.publicsuffixlist import java.net.IDN import mozilla.components.lib.publicsuffixlist.ext.binarySearch -/** - * Class wrapping the public suffix list data and offering methods for accessing rules in it. - */ -internal class PublicSuffixListData( - private val rules: ByteArray, - private val exceptions: ByteArray -) { - - private fun binarySearchRules(labels: List<ByteArray>, labelIndex: Int): String? { - return rules.binarySearch(labels, labelIndex) - } +/** Class wrapping the public suffix list data and offering methods for accessing rules in it. */ +internal class PublicSuffixListData(private val rules: ByteArray, private val exceptions: ByteArray) { - private fun binarySearchExceptions(labels: List<ByteArray>, labelIndex: Int): String? { - return exceptions.binarySearch(labels, labelIndex) - } + private fun binarySearchRules(labels: List<ByteArray>, labelIndex: Int): String? { + return rules.binarySearch(labels, labelIndex) + } - @Suppress("ReturnCount") - fun getPublicSuffixOffset(domain: String): PublicSuffixOffset? { - if (domain.isEmpty()) { - return null - } + private fun binarySearchExceptions(labels: List<ByteArray>, labelIndex: Int): String? { + return exceptions.binarySearch(labels, labelIndex) + } - val domainLabels = IDN.toUnicode(domain).split('.') - if (domainLabels.find { it.isEmpty() } != null) { - // At least one of the labels is empty: Bail out. - return null - } + @Suppress("ReturnCount") + fun getPublicSuffixOffset(domain: String): PublicSuffixOffset? { + if (domain.isEmpty()) { + return null + } - val rule = findMatchingRule(domainLabels) + val domainLabels = IDN.toUnicode(domain).split('.') + if (domainLabels.find { it.isEmpty() } != null) { + // At least one of the labels is empty: Bail out. + return null + } - if (domainLabels.size == rule.size && rule[0][0] != PublicSuffixListData.EXCEPTION_MARKER) { - // The domain is a public suffix. - return if (rule == PublicSuffixListData.PREVAILING_RULE) { - PublicSuffixOffset.PrevailingRule - } else { - PublicSuffixOffset.PublicSuffix - } - } + val rule = findMatchingRule(domainLabels) - return if (rule[0][0] == PublicSuffixListData.EXCEPTION_MARKER) { - // Exception rules hold the effective TLD plus one. - PublicSuffixOffset.Offset(domainLabels.size - rule.size) - } else { - // Otherwise the rule is for a public suffix, so we must take one more label. - PublicSuffixOffset.Offset(domainLabels.size - (rule.size + 1)) - } + if (domainLabels.size == rule.size && rule[0][0] != PublicSuffixListData.EXCEPTION_MARKER) { + // The domain is a public suffix. + return if (rule == PublicSuffixListData.PREVAILING_RULE) { + PublicSuffixOffset.PrevailingRule + } else { + PublicSuffixOffset.PublicSuffix + } } - /** - * Find a matching rule for the given domain labels. - * - * This algorithm is based on OkHttp's PublicSuffixDatabase class: - * https://github.com/square/okhttp/blob/master/okhttp/src/main/java/okhttp3/internal/publicsuffix/PublicSuffixDatabase.java - */ - private fun findMatchingRule(domainLabels: List<String>): List<String> { - // Break apart the domain into UTF-8 labels, i.e. foo.bar.com turns into [foo, bar, com]. - val domainLabelsBytes = domainLabels.map { it.toByteArray(Charsets.UTF_8) } - - val exactMatch = findExactMatch(domainLabelsBytes) - val wildcardMatch = findWildcardMatch(domainLabelsBytes) - val exceptionMatch = findExceptionMatch(domainLabelsBytes, wildcardMatch) - - if (exceptionMatch != null) { - return ("${PublicSuffixListData.EXCEPTION_MARKER}$exceptionMatch").split('.') - } + return if (rule[0][0] == PublicSuffixListData.EXCEPTION_MARKER) { + // Exception rules hold the effective TLD plus one. + PublicSuffixOffset.Offset(domainLabels.size - rule.size) + } else { + // Otherwise the rule is for a public suffix, so we must take one more label. + PublicSuffixOffset.Offset(domainLabels.size - (rule.size + 1)) + } + } + + /** + * Find a matching rule for the given domain labels. + * + * This algorithm is based on OkHttp's PublicSuffixDatabase class: + * https://github.com/square/okhttp/blob/master/okhttp/src/main/java/okhttp3/internal/publicsuffix/PublicSuffixDatabase.java + */ + private fun findMatchingRule(domainLabels: List<String>): List<String> { + // Break apart the domain into UTF-8 labels, i.e. foo.bar.com turns into [foo, bar, com]. + val domainLabelsBytes = domainLabels.map { it.toByteArray(Charsets.UTF_8) } + + val exactMatch = findExactMatch(domainLabelsBytes) + val wildcardMatch = findWildcardMatch(domainLabelsBytes) + val exceptionMatch = findExceptionMatch(domainLabelsBytes, wildcardMatch) + + if (exceptionMatch != null) { + return ("${PublicSuffixListData.EXCEPTION_MARKER}$exceptionMatch").split('.') + } - if (exactMatch == null && wildcardMatch == null) { - return PublicSuffixListData.PREVAILING_RULE - } + if (exactMatch == null && wildcardMatch == null) { + return PublicSuffixListData.PREVAILING_RULE + } - val exactRuleLabels = exactMatch?.split('.') ?: PublicSuffixListData.EMPTY_RULE - val wildcardRuleLabels = wildcardMatch?.split('.') ?: PublicSuffixListData.EMPTY_RULE + val exactRuleLabels = exactMatch?.split('.') ?: PublicSuffixListData.EMPTY_RULE + val wildcardRuleLabels = wildcardMatch?.split('.') ?: PublicSuffixListData.EMPTY_RULE - return if (exactRuleLabels.size > wildcardRuleLabels.size) { - exactRuleLabels - } else { - wildcardRuleLabels - } + return if (exactRuleLabels.size > wildcardRuleLabels.size) { + exactRuleLabels + } else { + wildcardRuleLabels } + } - /** - * Returns an exact match or null. - */ - private fun findExactMatch(labels: List<ByteArray>): String? { - // Start by looking for exact matches. We start at the leftmost label. For example, foo.bar.com - // will look like: [foo, bar, com], [bar, com], [com]. The longest matching rule wins. - - for (i in 0 until labels.size) { - val rule = binarySearchRules(labels, i) + /** Returns an exact match or null. */ + private fun findExactMatch(labels: List<ByteArray>): String? { + // Start by looking for exact matches. We start at the leftmost label. For example, + // foo.bar.com + // will look like: [foo, bar, com], [bar, com], [com]. The longest matching rule wins. - if (rule != null) { - return rule - } - } + for (i in 0 until labels.size) { + val rule = binarySearchRules(labels, i) - return null + if (rule != null) { + return rule + } } - /** - * Returns a wildcard match or null. - */ - private fun findWildcardMatch(labels: List<ByteArray>): String? { - // In theory, wildcard rules are not restricted to having the wildcard in the leftmost position. - // In practice, wildcards are always in the leftmost position. For now, this implementation - // cheats and does not attempt every possible permutation. Instead, it only considers wildcards - // in the leftmost position. We assert this fact when we generate the public suffix file. If - // this assertion ever fails we'll need to refactor this implementation. - if (labels.size > 1) { - val labelsWithWildcard = labels.toMutableList() - for (labelIndex in 0 until labelsWithWildcard.size) { - labelsWithWildcard[labelIndex] = PublicSuffixListData.WILDCARD_LABEL - val rule = binarySearchRules(labelsWithWildcard, labelIndex) - if (rule != null) { - return rule - } - } + return null + } + + /** Returns a wildcard match or null. */ + private fun findWildcardMatch(labels: List<ByteArray>): String? { + // In theory, wildcard rules are not restricted to having the wildcard in the leftmost + // position. + // In practice, wildcards are always in the leftmost position. For now, this implementation + // cheats and does not attempt every possible permutation. Instead, it only considers + // wildcards + // in the leftmost position. We assert this fact when we generate the public suffix file. If + // this assertion ever fails we'll need to refactor this implementation. + if (labels.size > 1) { + val labelsWithWildcard = labels.toMutableList() + for (labelIndex in 0 until labelsWithWildcard.size) { + labelsWithWildcard[labelIndex] = PublicSuffixListData.WILDCARD_LABEL + val rule = binarySearchRules(labelsWithWildcard, labelIndex) + if (rule != null) { + return rule } - - return null + } } - private fun findExceptionMatch(labels: List<ByteArray>, wildcardMatch: String?): String? { - // Exception rules only apply to wildcard rules, so only try it if we matched a wildcard. - if (wildcardMatch == null) { - return null - } + return null + } - for (labelIndex in 0 until labels.size) { - val rule = binarySearchExceptions(labels, labelIndex) - if (rule != null) { - return rule - } - } + private fun findExceptionMatch(labels: List<ByteArray>, wildcardMatch: String?): String? { + // Exception rules only apply to wildcard rules, so only try it if we matched a wildcard. + if (wildcardMatch == null) { + return null + } - return null + for (labelIndex in 0 until labels.size) { + val rule = binarySearchExceptions(labels, labelIndex) + if (rule != null) { + return rule + } } - companion object { + return null + } - val WILDCARD_LABEL = byteArrayOf('*'.toByte()) - val PREVAILING_RULE = listOf("*") - val EMPTY_RULE = listOf<String>() - const val EXCEPTION_MARKER = '!' - } + companion object { + + val WILDCARD_LABEL = byteArrayOf('*'.toByte()) + val PREVAILING_RULE = listOf("*") + val EMPTY_RULE = listOf<String>() + const val EXCEPTION_MARKER = '!' + } } internal sealed class PublicSuffixOffset { - data class Offset(val value: Int) : PublicSuffixOffset() - object PublicSuffix : PublicSuffixOffset() - object PrevailingRule : PublicSuffixOffset() + data class Offset(val value: Int) : PublicSuffixOffset() + object PublicSuffix : PublicSuffixOffset() + object PrevailingRule : PublicSuffixOffset() } diff --git a/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListLoader.kt b/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListLoader.kt index 785ee342..9fede799 100644 --- a/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListLoader.kt +++ b/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListLoader.kt @@ -20,38 +20,34 @@ private const val PUBLIC_SUFFIX_LIST_FILE = "publicsuffixes" internal object PublicSuffixListLoader { - fun load(context: Context): PublicSuffixListData = context.assets.open( - PUBLIC_SUFFIX_LIST_FILE - ).buffered().use { stream -> - val publicSuffixSize = stream.readInt() - val publicSuffixBytes = stream.readFully(publicSuffixSize) + fun load(context: Context): PublicSuffixListData = + context.assets.open(PUBLIC_SUFFIX_LIST_FILE).buffered().use { stream -> + val publicSuffixSize = stream.readInt() + val publicSuffixBytes = stream.readFully(publicSuffixSize) - val exceptionSize = stream.readInt() - val exceptionBytes = stream.readFully(exceptionSize) + val exceptionSize = stream.readInt() + val exceptionBytes = stream.readFully(exceptionSize) - PublicSuffixListData(publicSuffixBytes, exceptionBytes) + PublicSuffixListData(publicSuffixBytes, exceptionBytes) } } @Suppress("MagicNumber") private fun BufferedInputStream.readInt(): Int { - return (read() and 0xff shl 24 - or (read() and 0xff shl 16) - or (read() and 0xff shl 8) - or (read() and 0xff)) + return (read() and 0xff shl 24 or (read() and 0xff shl 16) or (read() and 0xff shl 8) or (read() and 0xff)) } private fun BufferedInputStream.readFully(size: Int): ByteArray { - val bytes = ByteArray(size) - - var offset = 0 - while (offset < size) { - val read = read(bytes, offset, size - offset) - if (read == -1) { - throw IOException("Unexpected end of stream") - } - offset += read + val bytes = ByteArray(size) + + var offset = 0 + while (offset < size) { + val read = read(bytes, offset, size - offset) + if (read == -1) { + throw IOException("Unexpected end of stream") } + offset += read + } - return bytes + return bytes } diff --git a/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/ext/ByteArray.kt b/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/ext/ByteArray.kt index 735cf21d..8a8f3e94 100644 --- a/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/ext/ByteArray.kt +++ b/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/ext/ByteArray.kt @@ -24,107 +24,106 @@ private const val BITMASK = 0xff.toByte() */ @Suppress("ComplexMethod", "NestedBlockDepth") internal fun ByteArray.binarySearch(labels: List<ByteArray>, labelIndex: Int): String? { - var low = 0 - var high = size - var match: String? = null - - while (low < high) { - val mid = (low + high) / 2 - val start = findStartOfLineFromIndex(mid) - val end = findEndOfLineFromIndex(start) - - val publicSuffixLength = start + end - start - - var compareResult: Int - var currentLabelIndex = labelIndex - var currentLabelByteIndex = 0 - var publicSuffixByteIndex = 0 - - var expectDot = false - while (true) { - val byte0 = if (expectDot) { - expectDot = false - '.'.toByte() - } else { - labels[currentLabelIndex][currentLabelByteIndex] and BITMASK - } - - val byte1 = this[start + publicSuffixByteIndex] and BITMASK - - // Compare the bytes. Note that the file stores UTF-8 encoded bytes, so we must compare the - // unsigned bytes. - @Suppress("EXPERIMENTAL_API_USAGE") - compareResult = (byte0.toUByte() - byte1.toUByte()).toInt() - if (compareResult != 0) { - break - } - - publicSuffixByteIndex++ - currentLabelByteIndex++ - - if (publicSuffixByteIndex == publicSuffixLength) { - break - } - - if (labels[currentLabelIndex].size == currentLabelByteIndex) { - // We've exhausted our current label. Either there are more labels to compare, in which - // case we expect a dot as the next character. Otherwise, we've checked all our labels. - if (currentLabelIndex == labels.size - 1) { - break - } else { - currentLabelIndex++ - currentLabelByteIndex = -1 - expectDot = true - } - } + var low = 0 + var high = size + var match: String? = null + + while (low < high) { + val mid = (low + high) / 2 + val start = findStartOfLineFromIndex(mid) + val end = findEndOfLineFromIndex(start) + + val publicSuffixLength = start + end - start + + var compareResult: Int + var currentLabelIndex = labelIndex + var currentLabelByteIndex = 0 + var publicSuffixByteIndex = 0 + + var expectDot = false + while (true) { + val byte0 = + if (expectDot) { + expectDot = false + '.'.toByte() + } else { + labels[currentLabelIndex][currentLabelByteIndex] and BITMASK } - if (compareResult < 0) { - high = start - 1 - } else if (compareResult > 0) { - low = start + end + 1 + val byte1 = this[start + publicSuffixByteIndex] and BITMASK + + // Compare the bytes. Note that the file stores UTF-8 encoded bytes, so we must compare + // the + // unsigned bytes. + @Suppress("EXPERIMENTAL_API_USAGE") compareResult = (byte0.toUByte() - byte1.toUByte()).toInt() + if (compareResult != 0) { + break + } + + publicSuffixByteIndex++ + currentLabelByteIndex++ + + if (publicSuffixByteIndex == publicSuffixLength) { + break + } + + if (labels[currentLabelIndex].size == currentLabelByteIndex) { + // We've exhausted our current label. Either there are more labels to compare, in + // which + // case we expect a dot as the next character. Otherwise, we've checked all our + // labels. + if (currentLabelIndex == labels.size - 1) { + break } else { - // We found a match, but are the lengths equal? - val publicSuffixBytesLeft = publicSuffixLength - publicSuffixByteIndex - var labelBytesLeft = labels[currentLabelIndex].size - currentLabelByteIndex - for (i in currentLabelIndex + 1 until labels.size) { - labelBytesLeft += labels[i].size - } - - if (labelBytesLeft < publicSuffixBytesLeft) { - high = start - 1 - } else if (labelBytesLeft > publicSuffixBytesLeft) { - low = start + end + 1 - } else { - // Found a match. - match = String(this, start, publicSuffixLength, Charsets.UTF_8) - break - } + currentLabelIndex++ + currentLabelByteIndex = -1 + expectDot = true } + } + } + + if (compareResult < 0) { + high = start - 1 + } else if (compareResult > 0) { + low = start + end + 1 + } else { + // We found a match, but are the lengths equal? + val publicSuffixBytesLeft = publicSuffixLength - publicSuffixByteIndex + var labelBytesLeft = labels[currentLabelIndex].size - currentLabelByteIndex + for (i in currentLabelIndex + 1 until labels.size) { + labelBytesLeft += labels[i].size + } + + if (labelBytesLeft < publicSuffixBytesLeft) { + high = start - 1 + } else if (labelBytesLeft > publicSuffixBytesLeft) { + low = start + end + 1 + } else { + // Found a match. + match = String(this, start, publicSuffixLength, Charsets.UTF_8) + break + } } + } - return match + return match } -/** - * Search for a '\n' that marks the start of a value. Don't go back past the start of the array. - */ +/** Search for a '\n' that marks the start of a value. Don't go back past the start of the array. */ private fun ByteArray.findStartOfLineFromIndex(start: Int): Int { - var index = start - while (index > -1 && this[index] != '\n'.toByte()) { - index-- - } - index++ - return index + var index = start + while (index > -1 && this[index] != '\n'.toByte()) { + index-- + } + index++ + return index } -/** - * Search for a '\n' that marks the end of a value. - */ +/** Search for a '\n' that marks the end of a value. */ private fun ByteArray.findEndOfLineFromIndex(start: Int): Int { - var end = 1 - while (this[start + end] != '\n'.toByte()) { - end++ - } - return end + var end = 1 + while (this[start + end] != '\n'.toByte()) { + end++ + } + return end } |