summaryrefslogtreecommitdiff
path: root/autofill-parser/src/main
diff options
context:
space:
mode:
Diffstat (limited to 'autofill-parser/src/main')
-rw-r--r--autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillFormParser.kt307
-rw-r--r--autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillHelper.kt120
-rw-r--r--autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillScenario.kt431
-rw-r--r--autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategy.kt334
-rw-r--r--autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategyDsl.kt643
-rw-r--r--autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FeatureAndTrustDetection.kt180
-rw-r--r--autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FormField.kt562
-rw-r--r--autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/PublicSuffixListCache.kt74
-rw-r--r--autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixList.kt74
-rw-r--r--autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListData.kt238
-rw-r--r--autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListLoader.kt40
-rw-r--r--autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/ext/ByteArray.kt181
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
}