all: reformat with ktfmt

Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
This commit is contained in:
Harsh Shandilya 2021-03-09 14:53:11 +05:30
parent be31ae37f4
commit 774fda83ac
No known key found for this signature in database
GPG key ID: 366D7BBAD1031E80
145 changed files with 12016 additions and 12490 deletions

View file

@ -4,34 +4,28 @@
*/
plugins {
id("com.android.library")
id("com.vanniktech.maven.publish")
kotlin("android")
`aps-plugin`
id("com.android.library")
id("com.vanniktech.maven.publish")
kotlin("android")
`aps-plugin`
}
android {
defaultConfig {
versionCode = 2
versionName = "2.0"
consumerProguardFiles("consumer-rules.pro")
}
defaultConfig {
versionCode = 2
versionName = "2.0"
consumerProguardFiles("consumer-rules.pro")
}
kotlin {
explicitApi()
}
kotlin { explicitApi() }
kotlinOptions {
freeCompilerArgs = freeCompilerArgs + listOf(
"-Xexplicit-api=strict"
)
}
kotlinOptions { freeCompilerArgs = freeCompilerArgs + listOf("-Xexplicit-api=strict") }
}
dependencies {
compileOnly(Dependencies.AndroidX.annotation)
implementation(Dependencies.AndroidX.autofill)
implementation(Dependencies.Kotlin.Coroutines.android)
implementation(Dependencies.Kotlin.Coroutines.core)
implementation(Dependencies.ThirdParty.timberkt)
compileOnly(Dependencies.AndroidX.annotation)
implementation(Dependencies.AndroidX.autofill)
implementation(Dependencies.Kotlin.Coroutines.android)
implementation(Dependencies.Kotlin.Coroutines.core)
implementation(Dependencies.ThirdParty.timberkt)
}

View file

@ -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 data class Web(override val identifier: String) : FormOrigin(identifier)
public data class App(override val identifier: String) : FormOrigin(identifier)
public companion object {
public companion object {
private const val BUNDLE_KEY_WEB_IDENTIFIER = "webIdentifier"
private const val BUNDLE_KEY_APP_IDENTIFIER = "appIdentifier"
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 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 var appPackage = structure.activityComponent.packageName
private val trustedBrowserInfo = getBrowserAutofillSupportInfoIfTrusted(context, appPackage)
val saveFlags = trustedBrowserInfo?.saveFlags
private val webOrigins = mutableSetOf<String>()
init {
d { "Request from $appPackage (${computeCertificatesHash(context, appPackage)})" }
parseStructure(structure)
}
val scenario = detectFieldsToFill(isManualRequest)
val formOrigin = determineFormOrigin(context)
init {
d { "Origin: $formOrigin" }
}
private fun parseStructure(structure: AssistStructure) {
for (i in 0 until structure.windowNodeCount) {
visitFormNode(structure.getWindowNodeAt(i).rootViewNode)
}
}
private val relevantFields = mutableListOf<FormField>()
val ignoredIds = mutableListOf<AutofillId>()
private var fieldIndex = 0
private var appPackage = structure.activityComponent.packageName
private val trustedBrowserInfo =
getBrowserAutofillSupportInfoIfTrusted(context, appPackage)
val saveFlags = trustedBrowserInfo?.saveFlags
private val webOrigins = mutableSetOf<String>()
init {
d { "Request from $appPackage (${computeCertificatesHash(context, appPackage)})" }
parseStructure(structure)
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)
}
val scenario = detectFieldsToFill(isManualRequest)
val formOrigin = determineFormOrigin(context)
init {
d { "Origin: $formOrigin" }
for (i in 0 until node.childCount) {
visitFormNode(node.getChildAt(i), field.webOriginToPassDown)
}
}
private fun parseStructure(structure: AssistStructure) {
for (i in 0 until structure.windowNodeCount) {
visitFormNode(structure.getWindowNodeAt(i).rootViewNode)
}
}
private fun visitFormNode(node: AssistStructure.ViewNode, inheritedWebOrigin: String? = null) {
trackOrigin(node)
val field =
if (trustedBrowserInfo?.multiOriginMethod == BrowserMultiOriginMethod.WebView) {
FormField(node, fieldIndex, true, inheritedWebOrigin)
} else {
check(inheritedWebOrigin == null)
FormField(node, fieldIndex, false)
}
if (field.relevantField) {
d { "Relevant: $field" }
relevantFields.add(field)
fieldIndex++
} else {
d { "Ignored : $field" }
ignoredIds.add(field.autofillId)
}
for (i in 0 until node.childCount) {
visitFormNode(node.getChildAt(i), field.webOriginToPassDown)
}
}
private fun detectFieldsToFill(isManualRequest: Boolean) = autofillStrategy.match(
relevantFields,
singleOriginMode = trustedBrowserInfo?.multiOriginMethod == BrowserMultiOriginMethod.None,
isManualRequest = isManualRequest
private fun 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)
}
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
)
}
}
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)
}
}
}
}
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()) }
}

View file

@ -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
}

View file

@ -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)
}
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
}
}
@Deprecated(
"Use `fromClientState` instead.",
ReplaceWith(
"fromClientState(clientState)",
"com.github.androidpasswordstore.autofillparser.AutofillScenario.Companion.fromClientState"
)
)
public fun fromBundle(clientState: Bundle): AutofillScenario<AutofillId>? {
return fromClientState(clientState)
}
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>()
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 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 abstract val username: T?
public abstract val passwordFieldsToSave: List<T>
internal class Builder<T : Any> {
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>
var username: T? = null
var fillUsername = false
var otp: T? = null
val currentPassword = mutableListOf<T>()
val newPassword = mutableListOf<T>()
val genericPassword = mutableListOf<T>()
public val fieldsToSave: List<T>
get() = listOfNotNull(username) + passwordFieldsToSave
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()
}
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 fun hasFieldsToFillOn(action: AutofillAction): Boolean {
return fieldsToFillOn(action).isNotEmpty()
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>
public val fieldsToSave: List<T>
get() = listOfNotNull(username) + passwordFieldsToSave
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()
}
}
public val hasFieldsToSave: Boolean
get() = fieldsToSave.isNotEmpty()
public fun hasFieldsToFillOn(action: AutofillAction): Boolean {
return fieldsToFillOn(action).isNotEmpty()
}
public val hasPasswordFieldsToSave: Boolean
get() = fieldsToSave.minus(listOfNotNull(username)).isNotEmpty()
public val hasFieldsToSave: Boolean
get() = fieldsToSave.isNotEmpty()
public val hasUsername: Boolean
get() = username != null
public val hasPasswordFieldsToSave: Boolean
get() = fieldsToSave.minus(listOfNotNull(username)).isNotEmpty()
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()
}

View file

@ -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 } }
}
currentPassword(optional = true) {
takeSingle { alreadyMatched ->
val adjacentToNewPasswords = directlyPrecedes(alreadyMatched) || directlyFollows(alreadyMatched)
// The Autofill framework has not hint that applies to current passwords only.
// In this scenario, we have already matched fields a pair of fields with a specific
// new password hint, so we take a generic Autofill password hint to mean a current
// password.
(hasAutocompleteHintCurrentPassword || hasAutofillHintPassword) && adjacentToNewPasswords
}
}
username(optional = true) {
takeSingle { hasHintUsername }
breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) }
breakTieOnSingle { isFocused }
}
}
// Match a single focused current password field and hidden username field with autocomplete
// hint. This configuration is commonly used in two-step login flows to allow password managers
// to save the username.
// See: https://www.chromium.org/developers/design-documents/form-styles-that-chromium-understands
// Note: The username is never filled in this scenario since usernames are generally only filled
// in visible fields.
rule {
username(matchHidden = true) {
takeSingle {
couldBeTwoStepHiddenUsername
}
}
currentPassword {
takeSingle {
hasAutocompleteHintCurrentPassword && isFocused
}
}
}
// Match a single focused current password field and hidden username field with autocomplete
// hint. This configuration is commonly used in two-step login flows to allow password managers
// to save the username.
// See:
// https://www.chromium.org/developers/design-documents/form-styles-that-chromium-understands
// Note: The username is never filled in this scenario since usernames are generally only filled
// in visible fields.
rule {
username(matchHidden = true) { takeSingle { couldBeTwoStepHiddenUsername } }
currentPassword { takeSingle { hasAutocompleteHintCurrentPassword && isFocused } }
}
// Match a single current password field and optional username field with autocomplete hint.
rule {
currentPassword {
takeSingle { hasAutocompleteHintCurrentPassword }
breakTieOnSingle { isFocused }
}
username(optional = true) {
takeSingle { hasHintUsername }
breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) }
breakTieOnSingle { isFocused }
}
// Match a single current password field and optional username field with autocomplete hint.
rule {
currentPassword {
takeSingle { hasAutocompleteHintCurrentPassword }
breakTieOnSingle { isFocused }
}
username(optional = true) {
takeSingle { hasHintUsername }
breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) }
breakTieOnSingle { isFocused }
}
}
// Match two adjacent password fields, implicitly understood as new passwords, and optional
// username field.
rule {
newPassword {
takePair { all { passwordCertainty >= Likely } }
breakTieOnPair { all { passwordCertainty >= Certain } }
breakTieOnPair { any { isFocused } }
}
username(optional = true) {
takeSingle { usernameCertainty >= Likely }
breakTieOnSingle { usernameCertainty >= Certain }
breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) }
breakTieOnSingle { isFocused }
}
// Match two adjacent password fields, implicitly understood as new passwords, and optional
// username field.
rule {
newPassword {
takePair { all { passwordCertainty >= Likely } }
breakTieOnPair { all { passwordCertainty >= Certain } }
breakTieOnPair { any { isFocused } }
}
username(optional = true) {
takeSingle { usernameCertainty >= Likely }
breakTieOnSingle { usernameCertainty >= Certain }
breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) }
breakTieOnSingle { isFocused }
}
}
// Match a single password field and optional username field.
rule {
genericPassword {
takeSingle { passwordCertainty >= Likely }
breakTieOnSingle { passwordCertainty >= Certain }
breakTieOnSingle { isFocused }
}
username(optional = true) {
takeSingle { usernameCertainty >= Likely }
breakTieOnSingle { usernameCertainty >= Certain }
breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) }
breakTieOnSingle { isFocused }
}
// Match a single password field and optional username field.
rule {
genericPassword {
takeSingle { passwordCertainty >= Likely }
breakTieOnSingle { passwordCertainty >= Certain }
breakTieOnSingle { isFocused }
}
username(optional = true) {
takeSingle { usernameCertainty >= Likely }
breakTieOnSingle { usernameCertainty >= Certain }
breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) }
breakTieOnSingle { isFocused }
}
}
// Match a single focused new password field and optional preceding username field.
// This rule can apply in single origin mode since it only fills into a single focused password
// field.
rule(applyInSingleOriginMode = true) {
newPassword {
takeSingle { hasHintNewPassword && isFocused }
}
username(optional = true) {
takeSingle { alreadyMatched ->
usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull())
}
}
// Match a single focused new password field and optional preceding username field.
// This rule can apply in single origin mode since it only fills into a single focused password
// field.
rule(applyInSingleOriginMode = true) {
newPassword { takeSingle { hasHintNewPassword && isFocused } }
username(optional = true) {
takeSingle { alreadyMatched -> usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull()) }
}
}
// Match a single focused current password field and optional preceding username field.
// This rule can apply in single origin mode since it only fills into a single focused password
// field.
rule(applyInSingleOriginMode = true) {
currentPassword {
takeSingle { hasAutocompleteHintCurrentPassword && isFocused }
}
username(optional = true) {
takeSingle { alreadyMatched ->
usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull())
}
}
// Match a single focused current password field and optional preceding username field.
// This rule can apply in single origin mode since it only fills into a single focused password
// field.
rule(applyInSingleOriginMode = true) {
currentPassword { takeSingle { hasAutocompleteHintCurrentPassword && isFocused } }
username(optional = true) {
takeSingle { alreadyMatched -> usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull()) }
}
}
// Match a single focused password field and optional preceding username field.
// This rule can apply in single origin mode since it only fills into a single focused password
// field.
rule(applyInSingleOriginMode = true) {
genericPassword {
takeSingle { passwordCertainty >= Likely && isFocused }
}
username(optional = true) {
takeSingle { alreadyMatched ->
usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull())
}
}
// Match a single focused password field and optional preceding username field.
// This rule can apply in single origin mode since it only fills into a single focused password
// field.
rule(applyInSingleOriginMode = true) {
genericPassword { takeSingle { passwordCertainty >= Likely && isFocused } }
username(optional = true) {
takeSingle { alreadyMatched -> usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull()) }
}
}
// Match a focused username field with autocomplete hint directly followed by a hidden password
// field, which is a common scenario in two-step login flows. No tie breakers are used to limit
// filling of hidden password fields to scenarios where this is clearly warranted.
rule {
username {
takeSingle { hasHintUsername && isFocused }
}
currentPassword(matchHidden = true) {
takeSingle { alreadyMatched ->
directlyFollows(alreadyMatched.singleOrNull()) && couldBeTwoStepHiddenPassword
}
}
// Match a focused username field with autocomplete hint directly followed by a hidden password
// field, which is a common scenario in two-step login flows. No tie breakers are used to limit
// filling of hidden password fields to scenarios where this is clearly warranted.
rule {
username { takeSingle { hasHintUsername && isFocused } }
currentPassword(matchHidden = true) {
takeSingle { alreadyMatched -> directlyFollows(alreadyMatched.singleOrNull()) && couldBeTwoStepHiddenPassword }
}
}
// Match a single focused OTP field.
rule(applyInSingleOriginMode = true) {
otp {
takeSingle { otpCertainty >= Likely && isFocused }
}
}
// Match a single focused OTP field.
rule(applyInSingleOriginMode = true) { otp { takeSingle { otpCertainty >= Likely && isFocused } } }
// Match a single focused username field without a password field.
rule(applyInSingleOriginMode = true) {
username {
takeSingle { usernameCertainty >= Likely && isFocused }
breakTieOnSingle { usernameCertainty >= Certain }
breakTieOnSingle { hasHintUsername }
}
// Match 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 }
}
}
// 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 }
}
}
// 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 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 }
}
}
// Match any focused username field on manual request.
rule(applyInSingleOriginMode = true, applyOnManualRequestOnly = true) { username { takeSingle { isFocused } } }
}

View file

@ -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 breakTieOnSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean) {
check(takeSingle != null) { "Every block needs a takeSingle block before a breakTieOnSingle block" }
check(takePair == null) { "takePair cannot be mixed with breakTieOnSingle" }
tieBreakersSingle.add(block)
}
fun takePair(block: Pair<FormField, FormField>.(alreadyMatched: List<FormField>) -> Boolean = { true }) {
check(takeSingle == null && takePair == null) { "Every block can only have at most one take{Single,Pair} block" }
takePair = block
}
fun breakTieOnPair(block: Pair<FormField, FormField>.(alreadyMatched: List<FormField>) -> Boolean) {
check(takePair != null) { "Every block needs a takePair block before a breakTieOnPair block" }
check(takeSingle == null) { "takeSingle cannot be mixed with breakTieOnPair" }
tieBreakersPair.add(block)
}
fun build(): FieldMatcher {
val takeSingle = takeSingle
val takePair = takePair
return when {
takeSingle != null -> SingleFieldMatcher(takeSingle, tieBreakersSingle)
takePair != null -> PairOfFieldsMatcher(takePair, tieBreakersPair)
else -> throw IllegalArgumentException("Every block needs a take{Single,Pair} block")
}
}
fun takeSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean = { true }) {
check(takeSingle == null && takePair == null) { "Every block can only have at most one take{Single,Pair} block" }
takeSingle = block
}
fun breakTieOnSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean) {
check(takeSingle != null) { "Every block needs a takeSingle block before a breakTieOnSingle block" }
check(takePair == null) { "takePair cannot be mixed with breakTieOnSingle" }
tieBreakersSingle.add(block)
}
fun takePair(block: Pair<FormField, FormField>.(alreadyMatched: List<FormField>) -> Boolean = { true }) {
check(takeSingle == null && takePair == null) { "Every block can only have at most one take{Single,Pair} block" }
takePair = block
}
fun breakTieOnPair(block: Pair<FormField, FormField>.(alreadyMatched: List<FormField>) -> Boolean) {
check(takePair != null) { "Every block needs a takePair block before a breakTieOnPair block" }
check(takeSingle == null) { "takeSingle cannot be mixed with breakTieOnPair" }
tieBreakersPair.add(block)
}
fun build(): FieldMatcher {
val takeSingle = takeSingle
val takePair = takePair
return when {
takeSingle != null -> SingleFieldMatcher(takeSingle, tieBreakersSingle)
takePair != null -> PairOfFieldsMatcher(takePair, tieBreakersPair)
else -> throw IllegalArgumentException("Every block needs a take{Single,Pair} block")
}
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
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()
private var takeSingle: (FormField.(List<FormField>) -> Boolean)? = null
private val tieBreakersSingle: MutableList<FormField.(List<FormField>) -> Boolean> = mutableListOf()
fun takeSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean = { true }) {
check(takeSingle == null) { "Every block can only have at most one takeSingle block" }
takeSingle = block
}
fun breakTieOnSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean) {
check(takeSingle != null) { "Every block needs a takeSingle block before a breakTieOnSingle block" }
tieBreakersSingle.add(block)
}
fun build() = SingleFieldMatcher(
takeSingle
?: throw IllegalArgumentException("Every block needs a take{Single,Pair} block"),
tieBreakersSingle
)
fun takeSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean = { true }) {
check(takeSingle == null) { "Every block can only have at most one takeSingle block" }
takeSingle = 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 breakTieOnSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean) {
check(takeSingle != null) { "Every block needs a takeSingle block before a breakTieOnSingle block" }
tieBreakersSingle.add(block)
}
fun build() =
SingleFieldMatcher(
takeSingle ?: throw IllegalArgumentException("Every block needs a take{Single,Pair} block"),
tieBreakersSingle
)
}
override fun match(fields: List<FormField>, alreadyMatched: List<FormField>): List<FormField>? {
return fields.minus(alreadyMatched).filter { take(it, alreadyMatched) }.let { contestants ->
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,
enum class FillableFieldType {
Username,
Otp,
CurrentPassword,
NewPassword,
GenericPassword,
}
@AutofillDsl
class Builder(private val applyInSingleOriginMode: Boolean, private val applyOnManualRequestOnly: Boolean) {
companion object {
private var ruleId = 1
}
@AutofillDsl
class Builder(
private val applyInSingleOriginMode: Boolean,
private val applyOnManualRequestOnly: Boolean
private val matchers = mutableListOf<AutofillRuleMatcher>()
var name: String? = null
fun username(
optional: Boolean = false,
matchHidden: Boolean = false,
block: SingleFieldMatcher.Builder.() -> Unit
) {
companion object {
private var ruleId = 1
}
private val matchers = mutableListOf<AutofillRuleMatcher>()
var name: String? = null
fun username(optional: Boolean = false, matchHidden: Boolean = false, block: SingleFieldMatcher.Builder.() -> Unit) {
require(matchers.none { it.type == FillableFieldType.Username }) { "Every rule block can only have at most one username block" }
matchers.add(
AutofillRuleMatcher(
type = FillableFieldType.Username,
matcher = SingleFieldMatcher.Builder().apply(block).build(),
optional = optional,
matchHidden = matchHidden
)
)
}
fun otp(optional: Boolean = false, block: SingleFieldMatcher.Builder.() -> Unit) {
require(matchers.none { it.type == FillableFieldType.Otp }) { "Every rule block can only have at most one otp block" }
matchers.add(
AutofillRuleMatcher(
type = FillableFieldType.Otp,
matcher = SingleFieldMatcher.Builder().apply(block).build(),
optional = optional,
matchHidden = false
)
)
}
fun currentPassword(optional: Boolean = false, matchHidden: Boolean = false, block: FieldMatcher.Builder.() -> Unit) {
require(matchers.none { it.type == FillableFieldType.GenericPassword }) { "Every rule block can only have either genericPassword or {current,new}Password blocks" }
matchers.add(
AutofillRuleMatcher(
type = FillableFieldType.CurrentPassword,
matcher = FieldMatcher.Builder().apply(block).build(),
optional = optional,
matchHidden = matchHidden
)
)
}
fun newPassword(optional: Boolean = false, block: FieldMatcher.Builder.() -> Unit) {
require(matchers.none { it.type == FillableFieldType.GenericPassword }) { "Every rule block can only have either genericPassword or {current,new}Password blocks" }
matchers.add(
AutofillRuleMatcher(
type = FillableFieldType.NewPassword,
matcher = FieldMatcher.Builder().apply(block).build(),
optional = optional,
matchHidden = false
)
)
}
fun genericPassword(optional: Boolean = false, block: FieldMatcher.Builder.() -> Unit) {
require(matchers.none {
it.type in listOf(
FillableFieldType.CurrentPassword,
FillableFieldType.NewPassword,
)
}) { "Every rule block can only have either genericPassword or {current,new}Password blocks" }
matchers.add(
AutofillRuleMatcher(
type = FillableFieldType.GenericPassword,
matcher = FieldMatcher.Builder().apply(block).build(),
optional = optional,
matchHidden = false
)
)
}
fun build(): AutofillRule {
if (applyInSingleOriginMode) {
require(matchers.none { it.matcher is PairOfFieldsMatcher }) { "Rules with applyInSingleOriginMode set to true must only match single fields" }
require(matchers.filter { it.type != FillableFieldType.Username }.size <= 1) { "Rules with applyInSingleOriginMode set to true must only match at most one password field" }
require(matchers.none { it.matchHidden }) { "Rules with applyInSingleOriginMode set to true must not fill into hidden fields" }
}
return AutofillRule(
matchers, applyInSingleOriginMode, applyOnManualRequestOnly, name ?: "Rule #$ruleId"
).also { ruleId++ }
}
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 match(
allPassword: List<FormField>,
allUsername: List<FormField>,
allOtp: List<FormField>,
singleOriginMode: Boolean,
isManualRequest: Boolean
): AutofillScenario<FormField>? {
if (singleOriginMode && !applyInSingleOriginMode) {
d { "$name: Skipped in single origin mode" }
return null
}
if (!isManualRequest && applyOnManualRequestOnly) {
d { "$name: Skipped since not a manual request" }
return null
}
d { "$name: Applying..." }
val scenarioBuilder = AutofillScenario.Builder<FormField>()
val alreadyMatched = mutableListOf<FormField>()
for ((type, matcher, optional, matchHidden) in matchers) {
val fieldsToMatchOn = when (type) {
FillableFieldType.Username -> allUsername
FillableFieldType.Otp -> allOtp
else -> allPassword
}.filter { matchHidden || it.isVisible }
val matchResult = matcher.match(fieldsToMatchOn, alreadyMatched) ?: if (optional) {
d { "$name: Skipping optional $type matcher" }
continue
} else {
d { "$name: Required $type matcher didn't match; passing to next rule" }
return null
}
d { "$name: Matched $type" }
when (type) {
FillableFieldType.Username -> {
check(matchResult.size == 1 && scenarioBuilder.username == null)
scenarioBuilder.username = matchResult.single()
// Hidden username fields should be saved but not filled.
scenarioBuilder.fillUsername = scenarioBuilder.username!!.isVisible == true
}
FillableFieldType.Otp -> {
check(matchResult.size == 1 && scenarioBuilder.otp == null)
scenarioBuilder.otp = matchResult.single()
}
FillableFieldType.CurrentPassword -> scenarioBuilder.currentPassword.addAll(
matchResult
)
FillableFieldType.NewPassword -> scenarioBuilder.newPassword.addAll(matchResult)
FillableFieldType.GenericPassword -> scenarioBuilder.genericPassword.addAll(
matchResult
)
}
alreadyMatched.addAll(matchResult)
}
return scenarioBuilder.build().takeIf { scenario ->
scenario.passesOriginCheck(singleOriginMode = singleOriginMode).also { passed ->
if (passed) {
d { "$name: Detected scenario:\n$scenario" }
} else {
w { "$name: Scenario failed origin check:\n$scenario" }
}
}
}
fun otp(optional: Boolean = false, block: SingleFieldMatcher.Builder.() -> Unit) {
require(matchers.none { it.type == FillableFieldType.Otp }) {
"Every rule block can only have at most one otp block"
}
matchers.add(
AutofillRuleMatcher(
type = FillableFieldType.Otp,
matcher = SingleFieldMatcher.Builder().apply(block).build(),
optional = optional,
matchHidden = false
)
)
}
fun currentPassword(
optional: Boolean = false,
matchHidden: Boolean = false,
block: FieldMatcher.Builder.() -> Unit
) {
require(matchers.none { it.type == FillableFieldType.GenericPassword }) {
"Every rule block can only have either genericPassword or {current,new}Password blocks"
}
matchers.add(
AutofillRuleMatcher(
type = FillableFieldType.CurrentPassword,
matcher = FieldMatcher.Builder().apply(block).build(),
optional = optional,
matchHidden = matchHidden
)
)
}
fun newPassword(optional: Boolean = false, block: FieldMatcher.Builder.() -> Unit) {
require(matchers.none { it.type == FillableFieldType.GenericPassword }) {
"Every rule block can only have either genericPassword or {current,new}Password blocks"
}
matchers.add(
AutofillRuleMatcher(
type = FillableFieldType.NewPassword,
matcher = FieldMatcher.Builder().apply(block).build(),
optional = optional,
matchHidden = false
)
)
}
fun genericPassword(optional: Boolean = false, block: FieldMatcher.Builder.() -> Unit) {
require(
matchers.none {
it.type in
listOf(
FillableFieldType.CurrentPassword,
FillableFieldType.NewPassword,
)
}
) { "Every rule block can only have either genericPassword or {current,new}Password blocks" }
matchers.add(
AutofillRuleMatcher(
type = FillableFieldType.GenericPassword,
matcher = FieldMatcher.Builder().apply(block).build(),
optional = optional,
matchHidden = false
)
)
}
fun build(): AutofillRule {
if (applyInSingleOriginMode) {
require(matchers.none { it.matcher is PairOfFieldsMatcher }) {
"Rules with applyInSingleOriginMode set to true must only match single fields"
}
require(matchers.filter { it.type != FillableFieldType.Username }.size <= 1) {
"Rules with applyInSingleOriginMode set to true must only match at most one password field"
}
require(matchers.none { it.matchHidden }) {
"Rules with applyInSingleOriginMode set to true must not fill into hidden fields"
}
}
return AutofillRule(matchers, applyInSingleOriginMode, applyOnManualRequestOnly, name ?: "Rule #$ruleId").also {
ruleId++
}
}
}
fun match(
allPassword: List<FormField>,
allUsername: List<FormField>,
allOtp: List<FormField>,
singleOriginMode: Boolean,
isManualRequest: Boolean
): AutofillScenario<FormField>? {
if (singleOriginMode && !applyInSingleOriginMode) {
d { "$name: Skipped in single origin mode" }
return null
}
if (!isManualRequest && applyOnManualRequestOnly) {
d { "$name: Skipped since not a manual request" }
return null
}
d { "$name: Applying..." }
val scenarioBuilder = AutofillScenario.Builder<FormField>()
val alreadyMatched = mutableListOf<FormField>()
for ((type, matcher, optional, matchHidden) in matchers) {
val fieldsToMatchOn =
when (type) {
FillableFieldType.Username -> allUsername
FillableFieldType.Otp -> allOtp
else -> allPassword
}.filter { matchHidden || it.isVisible }
val matchResult =
matcher.match(fieldsToMatchOn, alreadyMatched)
?: if (optional) {
d { "$name: Skipping optional $type matcher" }
continue
} else {
d { "$name: Required $type matcher didn't match; passing to next rule" }
return null
}
d { "$name: Matched $type" }
when (type) {
FillableFieldType.Username -> {
check(matchResult.size == 1 && scenarioBuilder.username == null)
scenarioBuilder.username = matchResult.single()
// Hidden username fields should be saved but not filled.
scenarioBuilder.fillUsername = scenarioBuilder.username!!.isVisible == true
}
FillableFieldType.Otp -> {
check(matchResult.size == 1 && scenarioBuilder.otp == null)
scenarioBuilder.otp = matchResult.single()
}
FillableFieldType.CurrentPassword -> scenarioBuilder.currentPassword.addAll(matchResult)
FillableFieldType.NewPassword -> scenarioBuilder.newPassword.addAll(matchResult)
FillableFieldType.GenericPassword -> scenarioBuilder.genericPassword.addAll(matchResult)
}
alreadyMatched.addAll(matchResult)
}
return scenarioBuilder.build().takeIf { scenario ->
scenario.passesOriginCheck(singleOriginMode = singleOriginMode).also { passed ->
if (passed) {
d { "$name: Detected scenario:\n$scenario" }
} else {
w { "$name: Scenario failed origin check:\n$scenario" }
}
}
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
internal class AutofillStrategy private constructor(private val rules: List<AutofillRule>) {
@AutofillDsl
class Builder {
@AutofillDsl
class Builder {
private val rules: MutableList<AutofillRule> = mutableListOf()
private val rules: MutableList<AutofillRule> = mutableListOf()
fun rule(
applyInSingleOriginMode: Boolean = false,
applyOnManualRequestOnly: Boolean = false,
block: AutofillRule.Builder.() -> Unit
) {
rules.add(
AutofillRule.Builder(
applyInSingleOriginMode = applyInSingleOriginMode,
applyOnManualRequestOnly = applyOnManualRequestOnly
).apply(block).build()
)
}
fun build() = AutofillStrategy(rules)
fun 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()

View file

@ -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 }
}

View file

@ -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_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_NEW_PASSWORD = listOf(
HintConstants.AUTOFILL_HINT_NEW_PASSWORD,
private val HINTS_OTP =
listOf(
HintConstants.AUTOFILL_HINT_SMS_OTP,
)
@Suppress("DEPRECATION")
private val HINTS_FILLABLE =
HINTS_USERNAME +
HINTS_PASSWORD +
HINTS_OTP +
listOf(
HintConstants.AUTOFILL_HINT_EMAIL_ADDRESS,
HintConstants.AUTOFILL_HINT_NAME,
HintConstants.AUTOFILL_HINT_PERSON_NAME,
HintConstants.AUTOFILL_HINT_PHONE,
HintConstants.AUTOFILL_HINT_PHONE_NUMBER,
)
private val 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,
)
private const val ANDROID_WEB_VIEW_CLASS_NAME = "android.webkit.WebView"
@Suppress("DEPRECATION")
private val HINTS_FILLABLE = HINTS_USERNAME + HINTS_PASSWORD + HINTS_OTP + listOf(
HintConstants.AUTOFILL_HINT_EMAIL_ADDRESS,
HintConstants.AUTOFILL_HINT_NAME,
HintConstants.AUTOFILL_HINT_PERSON_NAME,
HintConstants.AUTOFILL_HINT_PHONE,
HintConstants.AUTOFILL_HINT_PHONE_NUMBER,
)
private val ANDROID_TEXT_FIELD_CLASS_NAMES = listOf(
"android.widget.EditText",
"android.widget.AutoCompleteTextView",
"androidx.appcompat.widget.AppCompatEditText",
"android.support.v7.widget.AppCompatEditText",
"com.google.android.material.textfield.TextInputEditText",
)
private const val ANDROID_WEB_VIEW_CLASS_NAME = "android.webkit.WebView"
private fun isPasswordInputType(inputType: Int): Boolean {
val typeClass = inputType and InputType.TYPE_MASK_CLASS
val typeVariation = inputType and InputType.TYPE_MASK_VARIATION
return when (typeClass) {
InputType.TYPE_CLASS_NUMBER -> typeVariation == InputType.TYPE_NUMBER_VARIATION_PASSWORD
InputType.TYPE_CLASS_TEXT -> typeVariation in listOf(
InputType.TYPE_TEXT_VARIATION_PASSWORD,
InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD,
InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD,
)
else -> false
}
}
private val HTML_INPUT_FIELD_TYPES_USERNAME = listOf(
"email",
"tel",
"text",
)
private val HTML_INPUT_FIELD_TYPES_PASSWORD = listOf(
"password",
)
private val HTML_INPUT_FIELD_TYPES_OTP = listOf(
"tel",
"text",
)
private val HTML_INPUT_FIELD_TYPES_FILLABLE =
(HTML_INPUT_FIELD_TYPES_USERNAME + HTML_INPUT_FIELD_TYPES_PASSWORD + HTML_INPUT_FIELD_TYPES_OTP).toSet().toList()
@RequiresApi(Build.VERSION_CODES.O)
private fun isSupportedHint(hint: String) = hint in HINTS_FILLABLE
private val EXCLUDED_TERMS = listOf(
"url_bar", // Chrome/Edge/Firefox address bar
"url_field", // Opera address bar
"location_bar_edit_text", // Samsung address bar
"search", "find", "captcha",
"postal", // Prevent postal code fields from being mistaken for OTP fields
)
private val PASSWORD_HEURISTIC_TERMS = listOf(
"pass",
"pswd",
"pwd",
)
private val USERNAME_HEURISTIC_TERMS = listOf(
"alias",
"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 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)
}
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()
val autofillId: AutofillId = node.autofillId!!
@RequiresApi(Build.VERSION_CODES.O) private fun isSupportedHint(hint: String) = hint in HINTS_FILLABLE
// 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
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",
)
}
// Information for advanced heuristics taking multiple fields and page context into account
val isFocused = node.isFocused
private val List<String>.anyMatchesFieldInfo
get() = any { fieldId.contains(it) || hint.contains(it) || htmlName.contains(it) }
// 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
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
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
// 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()
infix fun directlyPrecedes(that: Iterable<FormField>): Boolean {
val firstIndex = that.map { it.index }.minOrNull() ?: return false
return index == firstIndex - 1
}
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
infix fun directlyFollows(that: FormField?): Boolean {
return index == (that ?: return false).index + 1
}
// Basic type detection for native fields
private val hasPasswordInputType = isPasswordInputType(inputType)
infix fun directlyFollows(that: Iterable<FormField>): Boolean {
val lastIndex = that.map { it.index }.maxOrNull() ?: return false
return index == lastIndex + 1
}
// 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
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"
}
private val isTextField = isAndroidTextField || isHtmlTextField
override fun equals(other: Any?): Boolean {
if (other == null) return false
if (this.javaClass != other.javaClass) return false
return autofillId == (other as FormField).autofillId
}
// 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()
override fun hashCode(): Int {
return autofillId.hashCode()
}
// 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()
}
}

View file

@ -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
}

View file

@ -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
}
}
}

View file

@ -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
) {
/** Class wrapping the public suffix list data and offering methods for accessing rules in it. */
internal class PublicSuffixListData(private val rules: ByteArray, private val exceptions: ByteArray) {
private fun binarySearchRules(labels: List<ByteArray>, labelIndex: Int): String? {
return rules.binarySearch(labels, labelIndex)
private fun binarySearchRules(labels: List<ByteArray>, labelIndex: Int): String? {
return rules.binarySearch(labels, labelIndex)
}
private fun binarySearchExceptions(labels: List<ByteArray>, labelIndex: Int): String? {
return exceptions.binarySearch(labels, labelIndex)
}
@Suppress("ReturnCount")
fun getPublicSuffixOffset(domain: String): PublicSuffixOffset? {
if (domain.isEmpty()) {
return null
}
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
}
val rule = findMatchingRule(domainLabels)
if (domainLabels.size == rule.size && rule[0][0] != PublicSuffixListData.EXCEPTION_MARKER) {
// The domain is a public suffix.
return if (rule == PublicSuffixListData.PREVAILING_RULE) {
PublicSuffixOffset.PrevailingRule
} else {
PublicSuffixOffset.PublicSuffix
}
}
return if (rule[0][0] == PublicSuffixListData.EXCEPTION_MARKER) {
// Exception rules hold the effective TLD plus one.
PublicSuffixOffset.Offset(domainLabels.size - rule.size)
} else {
// Otherwise the rule is for a public suffix, so we must take one more label.
PublicSuffixOffset.Offset(domainLabels.size - (rule.size + 1))
}
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) }
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))
}
}
val exactMatch = findExactMatch(domainLabelsBytes)
val wildcardMatch = findWildcardMatch(domainLabelsBytes)
val exceptionMatch = findExceptionMatch(domainLabelsBytes, wildcardMatch)
/**
* 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) }
if (exceptionMatch != null) {
return ("${PublicSuffixListData.EXCEPTION_MARKER}$exceptionMatch").split('.')
}
val exactMatch = findExactMatch(domainLabelsBytes)
val wildcardMatch = findWildcardMatch(domainLabelsBytes)
val exceptionMatch = findExceptionMatch(domainLabelsBytes, wildcardMatch)
if (exactMatch == null && wildcardMatch == null) {
return PublicSuffixListData.PREVAILING_RULE
}
val exactRuleLabels = exactMatch?.split('.') ?: PublicSuffixListData.EMPTY_RULE
val wildcardRuleLabels = wildcardMatch?.split('.') ?: PublicSuffixListData.EMPTY_RULE
return if (exactRuleLabels.size > wildcardRuleLabels.size) {
exactRuleLabels
} else {
wildcardRuleLabels
}
if (exceptionMatch != null) {
return ("${PublicSuffixListData.EXCEPTION_MARKER}$exceptionMatch").split('.')
}
/**
* Returns an exact match or null.
*/
private fun findExactMatch(labels: List<ByteArray>): String? {
// Start by looking for exact matches. We start at the leftmost label. For example, foo.bar.com
// will look like: [foo, bar, com], [bar, com], [com]. The longest matching rule wins.
for (i in 0 until labels.size) {
val rule = binarySearchRules(labels, i)
if (rule != null) {
return rule
}
}
return null
if (exactMatch == null && wildcardMatch == null) {
return PublicSuffixListData.PREVAILING_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
}
}
}
val exactRuleLabels = exactMatch?.split('.') ?: PublicSuffixListData.EMPTY_RULE
val wildcardRuleLabels = wildcardMatch?.split('.') ?: PublicSuffixListData.EMPTY_RULE
return null
return if (exactRuleLabels.size > wildcardRuleLabels.size) {
exactRuleLabels
} else {
wildcardRuleLabels
}
}
/** Returns an exact match or null. */
private fun findExactMatch(labels: List<ByteArray>): String? {
// Start by looking for exact matches. We start at the leftmost label. For example,
// foo.bar.com
// will look like: [foo, bar, com], [bar, com], [com]. The longest matching rule wins.
for (i in 0 until labels.size) {
val rule = binarySearchRules(labels, i)
if (rule != null) {
return rule
}
}
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
}
/** 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
}
}
companion object {
return null
}
val WILDCARD_LABEL = byteArrayOf('*'.toByte())
val PREVAILING_RULE = listOf("*")
val EMPTY_RULE = listOf<String>()
const val EXCEPTION_MARKER = '!'
private fun findExceptionMatch(labels: List<ByteArray>, wildcardMatch: String?): String? {
// Exception rules only apply to wildcard rules, so only try it if we matched a wildcard.
if (wildcardMatch == null) {
return null
}
for (labelIndex in 0 until labels.size) {
val rule = binarySearchExceptions(labels, labelIndex)
if (rule != null) {
return rule
}
}
return null
}
companion object {
val WILDCARD_LABEL = byteArrayOf('*'.toByte())
val PREVAILING_RULE = listOf("*")
val EMPTY_RULE = listOf<String>()
const val EXCEPTION_MARKER = '!'
}
}
internal sealed class PublicSuffixOffset {
data class Offset(val value: Int) : PublicSuffixOffset()
object PublicSuffix : PublicSuffixOffset()
object PrevailingRule : PublicSuffixOffset()
data class Offset(val value: Int) : PublicSuffixOffset()
object PublicSuffix : PublicSuffixOffset()
object PrevailingRule : PublicSuffixOffset()
}

View file

@ -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)
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
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
}

View file

@ -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
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)
while (low < high) {
val mid = (low + high) / 2
val start = findStartOfLineFromIndex(mid)
val end = findEndOfLineFromIndex(start)
val publicSuffixLength = start + end - start
val publicSuffixLength = start + end - start
var compareResult: Int
var currentLabelIndex = labelIndex
var currentLabelByteIndex = 0
var publicSuffixByteIndex = 0
var compareResult: Int
var currentLabelIndex = labelIndex
var currentLabelByteIndex = 0
var publicSuffixByteIndex = 0
var expectDot = false
while (true) {
val byte0 = if (expectDot) {
expectDot = false
'.'.toByte()
} else {
labels[currentLabelIndex][currentLabelByteIndex] and BITMASK
}
val byte1 = this[start + publicSuffixByteIndex] and BITMASK
// Compare the bytes. Note that the file stores UTF-8 encoded bytes, so we must compare the
// unsigned bytes.
@Suppress("EXPERIMENTAL_API_USAGE")
compareResult = (byte0.toUByte() - byte1.toUByte()).toInt()
if (compareResult != 0) {
break
}
publicSuffixByteIndex++
currentLabelByteIndex++
if (publicSuffixByteIndex == publicSuffixLength) {
break
}
if (labels[currentLabelIndex].size == currentLabelByteIndex) {
// We've exhausted our current label. Either there are more labels to compare, in which
// case we expect a dot as the next character. Otherwise, we've checked all our labels.
if (currentLabelIndex == labels.size - 1) {
break
} else {
currentLabelIndex++
currentLabelByteIndex = -1
expectDot = true
}
}
}
if (compareResult < 0) {
high = start - 1
} else if (compareResult > 0) {
low = start + end + 1
var expectDot = false
while (true) {
val byte0 =
if (expectDot) {
expectDot = false
'.'.toByte()
} 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
}
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
}
}
}
return match
if (compareResult < 0) {
high = start - 1
} else if (compareResult > 0) {
low = start + end + 1
} else {
// We found a match, but are the lengths equal?
val publicSuffixBytesLeft = publicSuffixLength - publicSuffixByteIndex
var labelBytesLeft = labels[currentLabelIndex].size - currentLabelByteIndex
for (i in currentLabelIndex + 1 until labels.size) {
labelBytesLeft += labels[i].size
}
if (labelBytesLeft < publicSuffixBytesLeft) {
high = start - 1
} else if (labelBytesLeft > publicSuffixBytesLeft) {
low = start + end + 1
} else {
// Found a match.
match = String(this, start, publicSuffixLength, Charsets.UTF_8)
break
}
}
}
return match
}
/**
* Search for a '\n' that marks the start of a value. Don't go back past the start of the array.
*/
/** 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
}