all: reformat with ktfmt
Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
This commit is contained in:
parent
be31ae37f4
commit
774fda83ac
145 changed files with 12016 additions and 12490 deletions
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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()) }
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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 } } }
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue