summaryrefslogtreecommitdiff
path: root/autofill-parser/src/main/java
diff options
context:
space:
mode:
authorVincent Breitmoser <look@my.amazin.horse>2020-09-16 20:17:55 +0200
committerGitHub <noreply@github.com>2020-09-16 23:47:55 +0530
commit08102734445257f9bbd4a7477c49e9e55fd88eb2 (patch)
tree2e9170d8483e7b5f1cb440d44e67d3dd42221f97 /autofill-parser/src/main/java
parent4ba3b75f858251099f18f07be700f9900128f8e1 (diff)
Autofill: Extract AutofillParser into separate subproject (#1101)
Co-authored-by: Harsh Shandilya <me@msfjarvis.dev> Co-authored-by: Fabian Henneke <fabian@henneke.me>
Diffstat (limited to 'autofill-parser/src/main/java')
-rw-r--r--autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillFormParser.kt217
-rw-r--r--autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillHelper.kt127
-rw-r--r--autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillScenario.kt296
-rw-r--r--autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategy.kt208
-rw-r--r--autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategyDsl.kt381
-rw-r--r--autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FeatureAndTrustDetection.kt213
-rw-r--r--autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FormField.kt303
-rw-r--r--autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/PublicSuffixListCache.kt80
-rw-r--r--autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixList.kt139
-rw-r--r--autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListData.kt163
-rw-r--r--autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListLoader.kt52
-rw-r--r--autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/ext/ByteArray.kt125
12 files changed, 2304 insertions, 0 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
new file mode 100644
index 00000000..30ff40d3
--- /dev/null
+++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillFormParser.kt
@@ -0,0 +1,217 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception
+ */
+package com.github.androidpasswordstore.autofillparser
+
+import android.app.assist.AssistStructure
+import android.content.Context
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.view.autofill.AutofillId
+import androidx.annotation.RequiresApi
+import androidx.core.os.bundleOf
+import com.github.ajalt.timberkt.d
+
+/**
+ * A unique identifier for either an Android app (package name) or a website (origin minus port).
+ */
+sealed class FormOrigin(open val identifier: String) {
+
+ data class Web(override val identifier: String) : FormOrigin(identifier)
+ data class App(override val identifier: String) : FormOrigin(identifier)
+
+ companion object {
+
+ private const val BUNDLE_KEY_WEB_IDENTIFIER = "webIdentifier"
+ private const val BUNDLE_KEY_APP_IDENTIFIER = "appIdentifier"
+
+ 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)
+ }
+ }
+ }
+
+ fun getPrettyIdentifier(context: Context, untrusted: Boolean = true) = 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"
+ }
+ }
+
+ fun toBundle() = when (this) {
+ is Web -> bundleOf(BUNDLE_KEY_WEB_IDENTIFIER to identifier)
+ is App -> bundleOf(BUNDLE_KEY_APP_IDENTIFIER to identifier)
+ }
+}
+
+/**
+ * Manages the detection of fields to fill in an [AssistStructure] and determines the [FormOrigin].
+ */
+@RequiresApi(Build.VERSION_CODES.O)
+private class AutofillFormParser(
+ context: Context,
+ structure: AssistStructure,
+ isManualRequest: Boolean,
+ private val customSuffixes: Sequence<String>
+) {
+
+ companion object {
+ private val SUPPORTED_SCHEMES = listOf("http", "https")
+ }
+
+ private val relevantFields = mutableListOf<FormField>()
+ val ignoredIds = mutableListOf<AutofillId>()
+ private var fieldIndex = 0
+
+ private var appPackage = structure.activityComponent.packageName
+
+ private val trustedBrowserInfo =
+ getBrowserAutofillSupportInfoIfTrusted(context, appPackage)
+ val saveFlags = trustedBrowserInfo?.saveFlags
+
+ private val webOrigins = mutableSetOf<String>()
+
+ init {
+ d { "Request from $appPackage (${computeCertificatesHash(context, appPackage)})" }
+ parseStructure(structure)
+ }
+
+ val scenario = detectFieldsToFill(isManualRequest)
+ val formOrigin = determineFormOrigin(context)
+
+ init {
+ d { "Origin: $formOrigin" }
+ }
+
+ 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 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 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)
+ }
+ 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
+ )
+ }
+ }
+ }
+}
+
+data class Credentials(val username: String?, val password: String?, val otp: String?)
+
+/**
+ * Represents a collection of fields in a specific app that can be filled or saved. This is the
+ * entry point to all fill and save features.
+ */
+@RequiresApi(Build.VERSION_CODES.O)
+class FillableForm private constructor(
+ val formOrigin: FormOrigin,
+ val scenario: AutofillScenario<FormField>,
+ val ignoredIds: List<AutofillId>,
+ val saveFlags: Int?
+) {
+ companion object {
+ /**
+ * Returns a [FillableForm] if a login form could be detected in [structure].
+ */
+ 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, form.ignoredIds, form.saveFlags)
+ }
+ }
+
+ fun toClientState() = 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
new file mode 100644
index 00000000..9273f432
--- /dev/null
+++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillHelper.kt
@@ -0,0 +1,127 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception
+ */
+package com.github.androidpasswordstore.autofillparser
+
+import android.annotation.SuppressLint
+import android.app.assist.AssistStructure
+import android.content.Context
+import android.content.IntentSender
+import android.content.pm.PackageManager
+import android.os.Build
+import android.service.autofill.SaveCallback
+import android.util.Base64
+import android.view.autofill.AutofillId
+import android.widget.Toast
+import androidx.annotation.RequiresApi
+import com.github.ajalt.timberkt.Timber.tag
+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()
+ }
+}
+
+private fun ByteArray.base64(): String {
+ 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 = ";")
+}
+
+/**
+ * Computes a stable hash of all certificates associated to the installed app with package name
+ * [appPackage].
+ *
+ * In most cases apps will only have a single certificate. If there are multiple, this functions
+ * returns all of them in sorted order and separated with `;`.
+ */
+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
+}
+
+/**
+ * Returns the "origin" (without port information) of the [AssistStructure.ViewNode] derived from
+ * its `webDomain` and `webScheme`, if available.
+ */
+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)
+class FixedSaveCallback(context: Context, private val callback: SaveCallback) {
+
+ private val applicationContext = context.applicationContext
+
+ 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()
+ }
+ }
+
+ 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)
+ }
+}
+
+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)
+fun AssistStructure.findNodeByAutofillId(autofillId: AutofillId): AssistStructure.ViewNode? {
+ 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
new file mode 100644
index 00000000..a374bc37
--- /dev/null
+++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillScenario.kt
@@ -0,0 +1,296 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception
+ */
+package com.github.androidpasswordstore.autofillparser
+
+import android.app.assist.AssistStructure
+import android.os.Build
+import android.os.Bundle
+import android.service.autofill.Dataset
+import android.view.autofill.AutofillId
+import android.view.autofill.AutofillValue
+import androidx.annotation.RequiresApi
+import com.github.ajalt.timberkt.e
+
+enum class AutofillAction {
+ Match, Search, Generate, FillOtpFromSms
+}
+
+/**
+ * Represents a set of form fields with associated roles (e.g., username or new password) and
+ * contains the logic that decides which fields should be filled or saved. The type [T] is one of
+ * [FormField], [AssistStructure.ViewNode] or [AutofillId], depending on how much metadata about the
+ * field is needed and available in the particular situation.
+ */
+@RequiresApi(Build.VERSION_CODES.O)
+sealed class AutofillScenario<out T : Any> {
+
+ companion object {
+
+ const val BUNDLE_KEY_USERNAME_ID = "usernameId"
+ const val BUNDLE_KEY_FILL_USERNAME = "fillUsername"
+ const val BUNDLE_KEY_OTP_ID = "otpId"
+ const val BUNDLE_KEY_CURRENT_PASSWORD_IDS = "currentPasswordIds"
+ const val BUNDLE_KEY_NEW_PASSWORD_IDS = "newPasswordIds"
+ const val BUNDLE_KEY_GENERIC_PASSWORD_IDS = "genericPasswordIds"
+
+ fun fromBundle(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
+ }
+ }
+ }
+
+ 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>()
+
+ 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
+ )
+ }
+ }
+ }
+
+ abstract val username: T?
+ abstract val fillUsername: Boolean
+ abstract val otp: T?
+ abstract val allPasswordFields: List<T>
+ abstract val passwordFieldsToFillOnMatch: List<T>
+ abstract val passwordFieldsToFillOnSearch: List<T>
+ abstract val passwordFieldsToFillOnGenerate: List<T>
+ abstract val passwordFieldsToSave: List<T>
+
+ val fieldsToSave
+ get() = listOfNotNull(username) + passwordFieldsToSave
+
+ val allFields
+ get() = listOfNotNull(username, otp) + allPasswordFields
+
+ 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()
+ }
+ }
+}
+
+@RequiresApi(Build.VERSION_CODES.O)
+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>
+) : 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
+}
+
+@RequiresApi(Build.VERSION_CODES.O)
+data class GenericAutofillScenario<T : Any>(
+ 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
+}
+
+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
+ }
+}
+
+@RequiresApi(Build.VERSION_CODES.O)
+@JvmName("fillWithAutofillId")
+fun Dataset.Builder.fillWith(
+ 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))
+ }
+}
+
+@RequiresApi(Build.VERSION_CODES.O)
+@JvmName("fillWithFormField")
+fun Dataset.Builder.fillWith(
+ scenario: AutofillScenario<FormField>,
+ action: AutofillAction,
+ credentials: Credentials?
+) {
+ fillWith(scenario.map { it.autofillId }, action, credentials)
+}
+
+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))
+ }
+ }
+ return builder.build()
+}
+
+@RequiresApi(Build.VERSION_CODES.O)
+@JvmName("toBundleAutofillId")
+private 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)
+ )
+ }
+ }
+ 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)
+ )
+ }
+ }
+}
+
+@RequiresApi(Build.VERSION_CODES.O)
+@JvmName("toBundleFormField")
+fun AutofillScenario<FormField>.toBundle(): Bundle = map { it.autofillId }.toBundle()
+
+@RequiresApi(Build.VERSION_CODES.O)
+fun AutofillScenario<AutofillId>.recoverNodes(structure: AssistStructure): AutofillScenario<AssistStructure.ViewNode>? {
+ return map { autofillId ->
+ structure.findNodeByAutofillId(autofillId) ?: return null
+ }
+}
+
+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
+ }
+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()
+ }
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
new file mode 100644
index 00000000..b8356783
--- /dev/null
+++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategy.kt
@@ -0,0 +1,208 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception
+ */
+package com.github.androidpasswordstore.autofillparser
+
+import android.os.Build
+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>.any(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
+ * [AutofillDsl].
+ */
+@RequiresApi(Build.VERSION_CODES.O)
+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 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 }
+ }
+ 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 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 }
+ }
+ }
+
+ // 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 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())
+ }
+ }
+ }
+
+ // 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 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 a single focused OTP field.
+ rule(applyInSingleOriginMode = true) {
+ otp {
+ takeSingle { otpCertainty >= Likely && isFocused }
+ }
+ }
+
+ // Match a single focused username field without a password field.
+ rule(applyInSingleOriginMode = true) {
+ username {
+ takeSingle { usernameCertainty >= Likely && isFocused }
+ breakTieOnSingle { usernameCertainty >= Certain }
+ breakTieOnSingle { hasHintUsername }
+ }
+ }
+
+ // 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
new file mode 100644
index 00000000..86201be8
--- /dev/null
+++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategyDsl.kt
@@ -0,0 +1,381 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception
+ */
+package com.github.androidpasswordstore.autofillparser
+
+import android.os.Build
+import androidx.annotation.RequiresApi
+import com.github.ajalt.timberkt.d
+import com.github.ajalt.timberkt.w
+
+@DslMarker
+annotation class AutofillDsl
+
+@RequiresApi(Build.VERSION_CODES.O)
+interface FieldMatcher {
+
+ fun match(fields: List<FormField>, alreadyMatched: List<FormField>): List<FormField>?
+
+ @AutofillDsl
+ class Builder {
+
+ 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()
+
+ 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 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 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)
+class SingleFieldMatcher(
+ private val take: (FormField, List<FormField>) -> Boolean,
+ private val tieBreakers: List<(FormField, List<FormField>) -> Boolean>
+) : FieldMatcher {
+
+ @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
+ }
+
+ 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 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 ->
+ 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>
+) : 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 ->
+ 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 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
+ }
+ current.singleOrNull()?.toList()
+ }
+ }
+}
+
+@RequiresApi(Build.VERSION_CODES.O)
+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
+ )
+
+ enum class FillableFieldType {
+ Username, Otp, CurrentPassword, NewPassword, GenericPassword,
+ }
+
+ @AutofillDsl
+ class Builder(
+ private val applyInSingleOriginMode: Boolean,
+ private val applyOnManualRequestOnly: Boolean
+ ) {
+
+ companion object {
+
+ private var ruleId = 1
+ }
+
+ 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 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 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 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 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 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++ }
+ }
+ }
+
+ 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
+ }
+ 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)
+ }
+ 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)
+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()
+ )
+ }
+
+ 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
+ }
+}
+
+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
new file mode 100644
index 00000000..b243a4c0
--- /dev/null
+++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FeatureAndTrustDetection.kt
@@ -0,0 +1,213 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception
+ */
+package com.github.androidpasswordstore.autofillparser
+
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
+import androidx.annotation.RequiresApi
+
+/*
+ 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.
+
+ 2. Run
+
+ 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.
+
+ 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
+
+ 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.
+
+ 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.
+
+ 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
+ * window as part of their AssistStructure.
+ *
+ * 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(
+ "com.android.chrome" to "8P1sW0EPJcslw7UzRsiXL64w+O50Ed+RBICtay1g24M=",
+ "com.brave.browser" to "nC23BRNRX9v7vFhbPt89cSPU3GfJT/0wY2HB15u/GKw=",
+ "com.chrome.beta" to "2mM9NLaeY64hA7SdU84FL8X388U6q5T9wqIIvf0UJJw=",
+ "com.chrome.canary" to "IBnfofsj779wxbzRRDxb6rBPPy/0Nm6aweNFdjmiTPw=",
+ "com.chrome.dev" to "kETuX+5LvF4h3URmVDHE6x8fcaMnFqC8knvLs5Izyr8=",
+ "com.duckduckgo.mobile.android" to "u3uzHFc8RqHaf8XFKKas9DIQhFb+7FCBDH8zaU6z0tQ=",
+ "com.microsoft.emmx" to "AeGZlxCoLCdJtNUMRF3IXWcLYTYInQp2anOCfIKh6sk=",
+ "com.opera.mini.native" to "V6y8Ul8bLr0ZGWzW8BQ5fMkQ/RiEHgroUP68Ph5ZP/I=",
+ "com.opera.mini.native.beta" to "V6y8Ul8bLr0ZGWzW8BQ5fMkQ/RiEHgroUP68Ph5ZP/I=",
+ "com.opera.touch" to "qtjiBNJNF3k0yc0MY8xqo4779CxKaVcJfiIQ9X+qZ6o=",
+ "org.bromite.bromite" to "4e5c0HbXsNyEyytF+3i4bfLrOaO2xWuj3CkqXgw7lQQ=",
+ "org.gnu.icecat" to "wi2iuVvK/WYZUzd2g0Qzn9ef3kAisQURZ8U1WSMTkcM=",
+ "org.mozilla.fenix" to "UAR3kIjn+YjVvFzF+HmP6/T4zQhKGypG79TI7krq8hE=",
+ "org.mozilla.fenix.nightly" to "d+rEzu02r++6dheZMd1MwZWrDNVLrzVdIV57vdKOQCo=",
+ "org.mozilla.fennec_aurora" to "vASIg40G9Mpr8yOG2qsN2OvPPncweHRZ9i+zzRShuqo=",
+ "org.mozilla.fennec_fdroid" to "BmZTWO/YugW+I2pHoSywlY19dd2TnXfCsx9TmFN+vcU=",
+ "org.mozilla.firefox" to "p4tipRZbRJSy/q2edqKA0i2Tf+5iUa7OWZRGsuoxmwQ=",
+ "org.mozilla.firefox_beta" to "p4tipRZbRJSy/q2edqKA0i2Tf+5iUa7OWZRGsuoxmwQ=",
+ "org.mozilla.focus" to "YgOkc7421k7jf4f6UA7bx56rkwYQq5ufpMp9XB8bT/w=",
+ "org.mozilla.klar" to "YgOkc7421k7jf4f6UA7bx56rkwYQq5ufpMp9XB8bT/w=",
+ "org.torproject.torbrowser" to "IAYfBF5zfGc3XBd5TP7bQ2oDzsa6y3y5+WZCIFyizsg=",
+ "org.ungoogled.chromium.stable" to "29UOO5cXoxO/e/hH3hOu6bbtg1My4tK6Eik2Ym5Krtk=",
+ "org.ungoogled.chromium.extensions.stable" to "29UOO5cXoxO/e/hH3hOu6bbtg1My4tK6Eik2Ym5Krtk=",
+ "com.kiwibrowser.browser" to "wGnqlmMy6R4KDDzFd+b1Cf49ndr3AVrQxcXvj9o/hig=",
+)
+
+private fun isTrustedBrowser(context: Context, appPackage: String): Boolean {
+ val expectedCertificateHash = TRUSTED_BROWSER_CERTIFICATE_HASH[appPackage] ?: return false
+ val certificateHash = computeCertificatesHash(context, appPackage)
+ return certificateHash == expectedCertificateHash
+}
+
+enum class BrowserMultiOriginMethod {
+ None, WebView, Field
+}
+
+/**
+ * **Security assumption**: Browsers on this list correctly distinguish the web origins of form
+ * fields, e.g. on a page which contains both a first-party login form and an iframe with a
+ * (potentially malicious) third-party login form.
+ *
+ * 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]).
+ * - Browsers with custom Autofill implementations report web domains on each input field (
+ * [BrowserMultiOriginMethod.Field]).
+ */
+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,
+ "com.opera.touch" to BrowserMultiOriginMethod.WebView,
+ "org.gnu.icecat" to BrowserMultiOriginMethod.WebView,
+ "org.mozilla.fenix" to BrowserMultiOriginMethod.Field,
+ "org.mozilla.fenix.nightly" to BrowserMultiOriginMethod.Field,
+ "org.mozilla.fennec_aurora" to BrowserMultiOriginMethod.Field,
+ "org.mozilla.fennec_fdroid" to BrowserMultiOriginMethod.Field,
+ "org.mozilla.firefox" to BrowserMultiOriginMethod.WebView,
+ "org.mozilla.firefox_beta" to BrowserMultiOriginMethod.WebView,
+ "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
+
+/**
+ * Browsers on this list issue Autofill save requests and provide unmasked passwords as
+ * `autofillValue`.
+ *
+ * Some browsers may not issue save requests automatically and thus need
+ * `FLAG_SAVE_ON_ALL_VIEW_INVISIBLE` to be set.
+ */
+@RequiresApi(Build.VERSION_CODES.O)
+private val BROWSER_SAVE_FLAG = mapOf(
+ "com.duckduckgo.mobile.android" to 0,
+ "org.mozilla.klar" to 0,
+ "org.mozilla.focus" to 0,
+ "org.mozilla.fenix" to 0,
+ "org.mozilla.fenix.nightly" to 0,
+ "org.mozilla.fennec_aurora" to 0,
+ "com.opera.mini.native" to 0,
+ "com.opera.mini.native.beta" to 0,
+ "com.opera.touch" to 0,
+)
+
+@RequiresApi(Build.VERSION_CODES.O)
+private fun getBrowserSaveFlag(appPackage: String): Int? = BROWSER_SAVE_FLAG[appPackage]
+
+data class BrowserAutofillSupportInfo(
+ val multiOriginMethod: BrowserMultiOriginMethod,
+ val saveFlags: Int?
+)
+
+@RequiresApi(Build.VERSION_CODES.O)
+fun getBrowserAutofillSupportInfoIfTrusted(
+ context: Context,
+ appPackage: String
+): BrowserAutofillSupportInfo? {
+ if (!isTrustedBrowser(context, appPackage)) return null
+ return BrowserAutofillSupportInfo(
+ multiOriginMethod = getBrowserMultiOriginMethod(appPackage),
+ saveFlags = getBrowserSaveFlag(appPackage)
+ )
+}
+
+private val FLAKY_BROWSERS = listOf(
+ "com.android.chrome",
+ "com.chrome.beta",
+ "com.chrome.canary",
+ "com.chrome.dev",
+ "org.bromite.bromite",
+ "org.ungoogled.chromium.stable",
+ "com.kiwibrowser.browser",
+)
+
+enum class BrowserAutofillSupportLevel {
+ None,
+ FlakyFill,
+ PasswordFill,
+ 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
+ browserInfo.multiOriginMethod == BrowserMultiOriginMethod.None -> BrowserAutofillSupportLevel.PasswordFill
+ browserInfo.saveFlags == null -> BrowserAutofillSupportLevel.GeneralFill
+ else -> BrowserAutofillSupportLevel.GeneralFillAndSave
+ }
+}
+
+@RequiresApi(Build.VERSION_CODES.O)
+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
new file mode 100644
index 00000000..ae16a995
--- /dev/null
+++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FormField.kt
@@ -0,0 +1,303 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception
+ */
+package com.github.androidpasswordstore.autofillparser
+
+import android.app.assist.AssistStructure
+import android.os.Build
+import android.text.InputType
+import android.view.View
+import android.view.autofill.AutofillId
+import androidx.annotation.RequiresApi
+import androidx.autofill.HintConstants
+import java.util.Locale
+
+enum class CertaintyLevel {
+ Impossible, Possible, Likely, Certain
+}
+
+/**
+ * Represents a single potentially fillable or saveable field together with all meta data
+ * extracted from its [AssistStructure.ViewNode].
+ */
+@RequiresApi(Build.VERSION_CODES.O)
+class FormField(
+ node: AssistStructure.ViewNode,
+ private val index: Int,
+ passDownWebViewOrigins: Boolean,
+ passedDownWebOrigin: String? = null
+) {
+
+ 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_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 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 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",
+ "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)
+ 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
new file mode 100644
index 00000000..316d102b
--- /dev/null
+++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/PublicSuffixListCache.kt
@@ -0,0 +1,80 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception
+ */
+package com.github.androidpasswordstore.autofillparser
+
+import android.content.Context
+import android.util.Patterns
+import kotlinx.coroutines.runBlocking
+import mozilla.components.lib.publicsuffixlist.PublicSuffixList
+
+private object PublicSuffixListCache {
+
+ 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 cachePublicSuffixList(context: Context) {
+ PublicSuffixListCache.getOrCachePublicSuffixList(context)
+}
+
+/**
+ * Returns the eTLD+1 (also called registrable domain), i.e. the direct subdomain of the public
+ * suffix of [domain].
+ *
+ * Note: Invalid domains, such as IP addresses, are returned unchanged and thus never collide with
+ * the return value for valid domains.
+ */
+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)
+ }
+}
+
+/**
+ * Returns:
+ * - [domain], if [domain] equals [suffix];
+ * - null, if [domain] does not have [suffix] as a domain suffix or only with an empty prefix;
+ * - the direct subdomain of [suffix] of which [domain] is a subdomain.
+ */
+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"
+}
+
+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
new file mode 100644
index 00000000..d4d1c1af
--- /dev/null
+++ b/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixList.kt
@@ -0,0 +1,139 @@
+/*
+ * SPDX-License-Identifier: (LGPL-3.0-only WITH LGPL-3.0-linking-exception) OR MPL-2.0
+ */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.lib.publicsuffixlist
+
+import android.content.Context
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.Dispatchers
+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.
+ *
+ * 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
+ */
+class PublicSuffixList(
+ context: Context,
+ dispatcher: CoroutineDispatcher = Dispatchers.IO,
+ private val scope: CoroutineScope = CoroutineScope(dispatcher)
+) {
+
+ private val data: PublicSuffixListData by lazy { 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 }
+ }
+
+ /**
+ * Returns true if the given [domain] is a public suffix; false otherwise.
+ *
+ * E.g.:
+ * ```
+ * co.uk -> true
+ * com -> true
+ * mozilla.org -> false
+ * org -> true
+ * ```
+ *
+ * Note that this method ignores the default "prevailing rule" described in the formal public suffix list algorithm:
+ * If no rule matches then the passed [domain] is assumed to *not* be a public suffix.
+ *
+ * @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 isPublicSuffix(domain: String): Deferred<Boolean> = scope.async {
+ when (data.getPublicSuffixOffset(domain)) {
+ is PublicSuffixOffset.PublicSuffix -> true
+ else -> false
+ }
+ }
+
+ /**
+ * 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 of the given [domain]; known as the effective top-level domain (eTLD). Returns `null`
+ * if the [domain] is a public suffix itself.
+ *
+ * E.g.:
+ * ```
+ * wwww.mozilla.org -> org
+ * www.bcc.co.uk -> co.uk
+ * a.b.ide.kyoto.jp -> 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 getPublicSuffix(domain: String) = scope.async {
+ when (val offset = data.getPublicSuffixOffset(domain)) {
+ is PublicSuffixOffset.Offset -> domain
+ .split('.')
+ .drop(offset.value + 1)
+ .joinToString(separator = ".")
+ else -> null
+ }
+ }
+
+ /**
+ * Strips the public suffix from the given [domain]. Returns the original domain if no public suffix could be
+ * stripped.
+ *
+ * E.g.:
+ * ```
+ * wwww.mozilla.org -> www.mozilla
+ * www.bcc.co.uk -> www.bbc
+ * a.b.ide.kyoto.jp -> a.b
+ * ```
+ *
+ * @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 stripPublicSuffix(domain: String) = scope.async {
+ when (val offset = data.getPublicSuffixOffset(domain)) {
+ is PublicSuffixOffset.Offset -> domain
+ .split('.')
+ .joinToString(separator = ".", limit = offset.value + 1, truncated = "")
+ .dropLast(1)
+ else -> domain
+ }
+ }
+}
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
new file mode 100644
index 00000000..595d46b1
--- /dev/null
+++ b/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListData.kt
@@ -0,0 +1,163 @@
+/*
+ * SPDX-License-Identifier: (LGPL-3.0-only WITH LGPL-3.0-linking-exception) OR MPL-2.0
+ */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.lib.publicsuffixlist
+
+import mozilla.components.lib.publicsuffixlist.ext.binarySearch
+import java.net.IDN
+
+/**
+ * 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)
+ }
+
+ private fun binarySearchExceptions(labels: List<ByteArray>, labelIndex: Int): String? {
+ return exceptions.binarySearch(labels, labelIndex)
+ }
+
+ @Suppress("ReturnCount")
+ fun getPublicSuffixOffset(domain: String): PublicSuffixOffset? {
+ if (domain.isEmpty()) {
+ return null
+ }
+
+ val domainLabels = IDN.toUnicode(domain).split('.')
+ if (domainLabels.find { it.isEmpty() } != null) {
+ // At least one of the labels is empty: Bail out.
+ return null
+ }
+
+ val rule = findMatchingRule(domainLabels)
+
+ 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
+ }
+ }
+
+ 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
+ }
+
+ val exactRuleLabels = exactMatch?.split('.') ?: PublicSuffixListData.EMPTY_RULE
+ val wildcardRuleLabels = wildcardMatch?.split('.') ?: PublicSuffixListData.EMPTY_RULE
+
+ 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)
+
+ 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
+ }
+
+ for (labelIndex in 0 until labels.size) {
+ val rule = binarySearchExceptions(labels, labelIndex)
+ if (rule != null) {
+ return rule
+ }
+ }
+
+ return null
+ }
+
+ 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()
+}
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
new file mode 100644
index 00000000..d8542f94
--- /dev/null
+++ b/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListLoader.kt
@@ -0,0 +1,52 @@
+/*
+ * SPDX-License-Identifier: (LGPL-3.0-only WITH LGPL-3.0-linking-exception) OR MPL-2.0
+ */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.lib.publicsuffixlist
+
+import android.content.Context
+import java.io.BufferedInputStream
+import java.io.IOException
+
+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)
+
+ val exceptionSize = stream.readInt()
+ val exceptionBytes = stream.readFully(exceptionSize)
+
+ 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))
+}
+
+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
+ }
+
+ 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
new file mode 100644
index 00000000..fbe8ec5c
--- /dev/null
+++ b/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/ext/ByteArray.kt
@@ -0,0 +1,125 @@
+/*
+ * SPDX-License-Identifier: (LGPL-3.0-only WITH LGPL-3.0-linking-exception) OR MPL-2.0
+ */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.lib.publicsuffixlist.ext
+
+import kotlin.experimental.and
+
+private const val BITMASK = 0xff.toByte()
+
+/**
+ * Performs a binary search for the provided [labels] on the [ByteArray]'s data.
+ *
+ * This algorithm is based on OkHttp's PublicSuffixDatabase class:
+ * https://github.com/square/okhttp/blob/1977136/okhttp/src/main/kotlin/okhttp3/internal/publicsuffix/PublicSuffixDatabase.kt
+ */
+@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
+ }
+ }
+ }
+
+ 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
+}
+
+/**
+ * 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
+}
+
+/**
+ * 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
+}