summaryrefslogtreecommitdiff
path: root/app/src/main/java
diff options
context:
space:
mode:
authorVincent Breitmoser <look@my.amazin.horse>2020-09-16 20:17:55 +0200
committerGitHub <noreply@github.com>2020-09-16 23:47:55 +0530
commit08102734445257f9bbd4a7477c49e9e55fd88eb2 (patch)
tree2e9170d8483e7b5f1cb440d44e67d3dd42221f97 /app/src/main/java
parent4ba3b75f858251099f18f07be700f9900128f8e1 (diff)
Autofill: Extract AutofillParser into separate subproject (#1101)
Co-authored-by: Harsh Shandilya <me@msfjarvis.dev> Co-authored-by: Fabian Henneke <fabian@henneke.me>
Diffstat (limited to 'app/src/main/java')
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt4
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/UserPreference.kt4
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt210
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillMatcher.kt2
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillPreferences.kt15
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillResponseBuilder.kt192
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillScenario.kt298
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategy.kt208
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategyDsl.kt381
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillViewUtils.kt67
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FeatureAndTrustDetection.kt213
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/Form.kt383
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FormField.kt303
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt27
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/PublicSuffixListCache.kt90
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt10
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt2
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillPublisherChangedActivity.kt4
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt10
-rw-r--r--app/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixList.kt139
-rw-r--r--app/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListData.kt163
-rw-r--r--app/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListLoader.kt52
-rw-r--r--app/src/main/java/mozilla/components/lib/publicsuffixlist/ext/ByteArray.kt125
23 files changed, 318 insertions, 2584 deletions
diff --git a/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt b/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt
index 4e4bf85e..eb339ac3 100644
--- a/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt
@@ -35,6 +35,8 @@ import com.github.ajalt.timberkt.d
import com.github.ajalt.timberkt.e
import com.github.ajalt.timberkt.i
import com.github.ajalt.timberkt.w
+import com.github.androidpasswordstore.autofillparser.BrowserAutofillSupportLevel
+import com.github.androidpasswordstore.autofillparser.getInstalledBrowsersWithAutofillSupportLevel
import com.github.michaelbull.result.fold
import com.github.michaelbull.result.getOr
import com.github.michaelbull.result.onFailure
@@ -43,8 +45,6 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.textfield.TextInputEditText
import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher
-import com.zeapo.pwdstore.autofill.oreo.BrowserAutofillSupportLevel
-import com.zeapo.pwdstore.autofill.oreo.getInstalledBrowsersWithAutofillSupportLevel
import com.zeapo.pwdstore.crypto.BasePgpActivity.Companion.getLongName
import com.zeapo.pwdstore.crypto.DecryptActivity
import com.zeapo.pwdstore.crypto.PasswordCreationActivity
diff --git a/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt b/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt
index d41a7fe7..7aeec148 100644
--- a/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt
@@ -36,13 +36,13 @@ import androidx.preference.SwitchPreferenceCompat
import com.github.ajalt.timberkt.Timber.tag
import com.github.ajalt.timberkt.d
import com.github.ajalt.timberkt.w
+import com.github.androidpasswordstore.autofillparser.BrowserAutofillSupportLevel
+import com.github.androidpasswordstore.autofillparser.getInstalledBrowsersWithAutofillSupportLevel
import com.github.michaelbull.result.getOr
import com.github.michaelbull.result.onFailure
import com.github.michaelbull.result.runCatching
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.zeapo.pwdstore.autofill.AutofillPreferenceActivity
-import com.zeapo.pwdstore.autofill.oreo.BrowserAutofillSupportLevel
-import com.zeapo.pwdstore.autofill.oreo.getInstalledBrowsersWithAutofillSupportLevel
import com.zeapo.pwdstore.crypto.BasePgpActivity
import com.zeapo.pwdstore.git.GitConfigActivity
import com.zeapo.pwdstore.git.GitServerConfigActivity
diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt
deleted file mode 100644
index 502c9423..00000000
--- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt
+++ /dev/null
@@ -1,210 +0,0 @@
-/*
- * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package com.zeapo.pwdstore.autofill.oreo
-
-import android.annotation.SuppressLint
-import android.app.assist.AssistStructure
-import android.content.Context
-import android.content.IntentSender
-import android.content.pm.PackageManager
-import android.os.Build
-import android.service.autofill.SaveCallback
-import android.util.Base64
-import android.view.autofill.AutofillId
-import android.widget.RemoteViews
-import android.widget.Toast
-import androidx.annotation.RequiresApi
-import com.github.ajalt.timberkt.Timber.tag
-import com.github.ajalt.timberkt.e
-import com.zeapo.pwdstore.R
-import com.zeapo.pwdstore.model.PasswordEntry
-import com.zeapo.pwdstore.utils.PasswordRepository
-import com.zeapo.pwdstore.utils.PreferenceKeys
-import com.zeapo.pwdstore.utils.getString
-import com.zeapo.pwdstore.utils.sharedPrefs
-import java.io.File
-import java.security.MessageDigest
-
-private fun ByteArray.sha256(): ByteArray {
- return MessageDigest.getInstance("SHA-256").run {
- update(this@sha256)
- digest()
- }
-}
-
-private fun ByteArray.base64(): String {
- return Base64.encodeToString(this, Base64.NO_WRAP)
-}
-
-private fun Context.getDefaultUsername() = sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME)
-
-private fun stableHash(array: Collection<ByteArray>): String {
- val hashes = array.map { it.sha256().base64() }
- return hashes.sorted().joinToString(separator = ";")
-}
-
-/**
- * Computes a stable hash of all certificates associated to the installed app with package name
- * [appPackage].
- *
- * In most cases apps will only have a single certificate. If there are multiple, this functions
- * returns all of them in sorted order and separated with `;`.
- */
-fun computeCertificatesHash(context: Context, appPackage: String): String {
- // The warning does not apply since 1) we are specifically hashing **all** signatures and 2) it
- // no longer applies to Android 4.4+.
- // Even though there is a new way to get the certificates as of Android Pie, we need to keep
- // hashes comparable between versions and hence default to using the deprecated API.
- @SuppressLint("PackageManagerGetSignatures")
- @Suppress("DEPRECATION")
- val signaturesOld =
- context.packageManager.getPackageInfo(appPackage, PackageManager.GET_SIGNATURES).signatures
- val stableHashOld = stableHash(signaturesOld.map { it.toByteArray() })
- if (Build.VERSION.SDK_INT >= 28) {
- val info = context.packageManager.getPackageInfo(
- appPackage, PackageManager.GET_SIGNING_CERTIFICATES
- )
- val signaturesNew =
- info.signingInfo.signingCertificateHistory ?: info.signingInfo.apkContentsSigners
- val stableHashNew = stableHash(signaturesNew.map { it.toByteArray() })
- if (stableHashNew != stableHashOld) tag("CertificatesHash").e { "Mismatch between old and new hash: $stableHashNew != $stableHashOld" }
- }
- return stableHashOld
-}
-
-/**
- * Returns the "origin" (without port information) of the [AssistStructure.ViewNode] derived from
- * its `webDomain` and `webScheme`, if available.
- */
-val AssistStructure.ViewNode.webOrigin: String?
- @RequiresApi(Build.VERSION_CODES.O) get() = webDomain?.let { domain ->
- val scheme = (if (Build.VERSION.SDK_INT >= 28) webScheme else null) ?: "https"
- "$scheme://$domain"
- }
-
-data class Credentials(val username: String?, val password: String?, val otp: String?) {
- companion object {
-
- fun fromStoreEntry(
- context: Context,
- file: File,
- entry: PasswordEntry,
- directoryStructure: DirectoryStructure
- ): Credentials {
- // Always give priority to a username stored in the encrypted extras
- val username = entry.username
- ?: directoryStructure.getUsernameFor(file)
- ?: context.getDefaultUsername()
- return Credentials(username, entry.password, entry.calculateTotpCode())
- }
- }
-}
-
-private fun makeRemoteView(
- context: Context,
- title: String,
- summary: String,
- iconRes: Int
-): RemoteViews {
- return RemoteViews(context.packageName, R.layout.oreo_autofill_dataset).apply {
- setTextViewText(R.id.title, title)
- setTextViewText(R.id.summary, summary)
- setImageViewResource(R.id.icon, iconRes)
- }
-}
-
-fun makeFillMatchRemoteView(context: Context, file: File, formOrigin: FormOrigin): RemoteViews {
- val title = formOrigin.getPrettyIdentifier(context, untrusted = false)
- val directoryStructure = AutofillPreferences.directoryStructure(context)
- val relativeFile = file.relativeTo(PasswordRepository.getRepositoryDirectory())
- val summary = directoryStructure.getUsernameFor(relativeFile)
- ?: directoryStructure.getPathToIdentifierFor(relativeFile) ?: ""
- val iconRes = R.drawable.ic_person_black_24dp
- return makeRemoteView(context, title, summary, iconRes)
-}
-
-fun makeSearchAndFillRemoteView(context: Context, formOrigin: FormOrigin): RemoteViews {
- val title = formOrigin.getPrettyIdentifier(context, untrusted = true)
- val summary = context.getString(R.string.oreo_autofill_search_in_store)
- val iconRes = R.drawable.ic_search_black_24dp
- return makeRemoteView(context, title, summary, iconRes)
-}
-
-fun makeGenerateAndFillRemoteView(context: Context, formOrigin: FormOrigin): RemoteViews {
- val title = formOrigin.getPrettyIdentifier(context, untrusted = true)
- val summary = context.getString(R.string.oreo_autofill_generate_password)
- val iconRes = R.drawable.ic_autofill_new_password
- return makeRemoteView(context, title, summary, iconRes)
-}
-
-fun makeFillOtpFromSmsRemoteView(context: Context, formOrigin: FormOrigin): RemoteViews {
- val title = formOrigin.getPrettyIdentifier(context, untrusted = true)
- val summary = context.getString(R.string.oreo_autofill_fill_otp_from_sms)
- val iconRes = R.drawable.ic_autofill_sms
- return makeRemoteView(context, title, summary, iconRes)
-}
-
-fun makePlaceholderRemoteView(context: Context): RemoteViews {
- return makeRemoteView(context, "PLACEHOLDER", "PLACEHOLDER", R.mipmap.ic_launcher)
-}
-
-fun makeWarningRemoteView(context: Context): RemoteViews {
- val title = context.getString(R.string.oreo_autofill_warning_publisher_dataset_title)
- val summary = context.getString(R.string.oreo_autofill_warning_publisher_dataset_summary)
- val iconRes = R.drawable.ic_warning_red_24dp
- return makeRemoteView(context, title, summary, iconRes)
-}
-
-@RequiresApi(Build.VERSION_CODES.O)
-class FixedSaveCallback(context: Context, private val callback: SaveCallback) {
-
- private val applicationContext = context.applicationContext
-
- fun onFailure(message: CharSequence) {
- callback.onFailure(message)
- // When targeting SDK 29, the message is no longer shown as a toast.
- // See https://developer.android.com/reference/android/service/autofill/SaveCallback#onFailure(java.lang.CharSequence)
- if (applicationContext.applicationInfo.targetSdkVersion >= 29) {
- Toast.makeText(applicationContext, message, Toast.LENGTH_LONG).show()
- }
- }
-
- fun onSuccess(intentSender: IntentSender) {
- if (Build.VERSION.SDK_INT >= 28) {
- callback.onSuccess(intentSender)
- } else {
- callback.onSuccess()
- // On SDKs < 28, we cannot advise the Autofill framework to launch the save intent in
- // the context of the app that triggered the save request. Hence, we launch it here.
- applicationContext.startIntentSender(intentSender, null, 0, 0, 0)
- }
- }
-}
-
-private fun visitViewNodes(structure: AssistStructure, block: (AssistStructure.ViewNode) -> Unit) {
- for (i in 0 until structure.windowNodeCount) {
- visitViewNode(structure.getWindowNodeAt(i).rootViewNode, block)
- }
-}
-
-private fun visitViewNode(
- node: AssistStructure.ViewNode,
- block: (AssistStructure.ViewNode) -> Unit
-) {
- block(node)
- for (i in 0 until node.childCount) {
- visitViewNode(node.getChildAt(i), block)
- }
-}
-
-@RequiresApi(Build.VERSION_CODES.O)
-fun AssistStructure.findNodeByAutofillId(autofillId: AutofillId): AssistStructure.ViewNode? {
- var node: AssistStructure.ViewNode? = null
- visitViewNodes(this) {
- if (it.autofillId == autofillId)
- node = it
- }
- return node
-}
diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillMatcher.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillMatcher.kt
index 1b0ff35d..601be34a 100644
--- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillMatcher.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillMatcher.kt
@@ -14,6 +14,8 @@ import com.github.ajalt.timberkt.w
import com.github.michaelbull.result.Err
import com.github.michaelbull.result.Ok
import com.github.michaelbull.result.Result
+import com.github.androidpasswordstore.autofillparser.FormOrigin
+import com.github.androidpasswordstore.autofillparser.computeCertificatesHash
import com.zeapo.pwdstore.R
import java.io.File
diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillPreferences.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillPreferences.kt
index 9c84e1ad..cc0875f3 100644
--- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillPreferences.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillPreferences.kt
@@ -7,6 +7,8 @@ package com.zeapo.pwdstore.autofill.oreo
import android.content.Context
import android.os.Build
import androidx.annotation.RequiresApi
+import com.github.androidpasswordstore.autofillparser.Credentials
+import com.zeapo.pwdstore.model.PasswordEntry
import com.zeapo.pwdstore.utils.sharedPrefs
import java.io.File
import java.nio.file.Paths
@@ -121,4 +123,17 @@ object AutofillPreferences {
val value = context.sharedPrefs.getString(DirectoryStructure.PREFERENCE, null)
return DirectoryStructure.fromValue(value)
}
+
+ fun credentialsFromStoreEntry(
+ context: Context,
+ file: File,
+ entry: PasswordEntry,
+ directoryStructure: DirectoryStructure
+ ): Credentials {
+ // Always give priority to a username stored in the encrypted extras
+ val username = entry.username
+ ?: directoryStructure.getUsernameFor(file)
+ ?: context.getDefaultUsername()
+ return Credentials(username, entry.password, entry.calculateTotpCode())
+ }
}
diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillResponseBuilder.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillResponseBuilder.kt
new file mode 100644
index 00000000..0e73b908
--- /dev/null
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillResponseBuilder.kt
@@ -0,0 +1,192 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package com.zeapo.pwdstore.autofill.oreo
+
+import android.content.Context
+import android.content.IntentSender
+import android.os.Build
+import android.os.Bundle
+import android.service.autofill.Dataset
+import android.service.autofill.FillCallback
+import android.service.autofill.FillResponse
+import android.service.autofill.SaveInfo
+import android.widget.RemoteViews
+import androidx.annotation.RequiresApi
+import com.github.ajalt.timberkt.e
+import com.github.michaelbull.result.fold
+import com.github.androidpasswordstore.autofillparser.AutofillAction
+import com.github.androidpasswordstore.autofillparser.AutofillScenario
+import com.github.androidpasswordstore.autofillparser.Credentials
+import com.github.androidpasswordstore.autofillparser.FillableForm
+import com.github.androidpasswordstore.autofillparser.fillWith
+import com.zeapo.pwdstore.autofill.oreo.ui.AutofillDecryptActivity
+import com.zeapo.pwdstore.autofill.oreo.ui.AutofillFilterView
+import com.zeapo.pwdstore.autofill.oreo.ui.AutofillPublisherChangedActivity
+import com.zeapo.pwdstore.autofill.oreo.ui.AutofillSaveActivity
+import com.zeapo.pwdstore.autofill.oreo.ui.AutofillSmsActivity
+import java.io.File
+
+@RequiresApi(Build.VERSION_CODES.O)
+class AutofillResponseBuilder(form: FillableForm) {
+ private val formOrigin = form.formOrigin
+ private val scenario = form.scenario
+ private val ignoredIds = form.ignoredIds
+ private val saveFlags = form.saveFlags
+ private val clientState = form.toClientState()
+
+ // We do not offer save when the only relevant field is a username field or there is no field.
+ private val scenarioSupportsSave =
+ scenario.fieldsToSave.minus(listOfNotNull(scenario.username)).isNotEmpty()
+ private val canBeSaved = saveFlags != null && scenarioSupportsSave
+
+ private fun makePlaceholderDataset(
+ remoteView: RemoteViews,
+ intentSender: IntentSender,
+ action: AutofillAction
+ ): Dataset {
+ return Dataset.Builder(remoteView).run {
+ fillWith(scenario, action, credentials = null)
+ setAuthentication(intentSender)
+ build()
+ }
+ }
+
+ private fun makeMatchDataset(context: Context, file: File): Dataset? {
+ if (scenario.fieldsToFillOn(AutofillAction.Match).isEmpty()) return null
+ val remoteView = makeFillMatchRemoteView(context, file, formOrigin)
+ val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
+ return makePlaceholderDataset(remoteView, intentSender, AutofillAction.Match)
+ }
+
+ private fun makeSearchDataset(context: Context): Dataset? {
+ if (scenario.fieldsToFillOn(AutofillAction.Search).isEmpty()) return null
+ val remoteView = makeSearchAndFillRemoteView(context, formOrigin)
+ val intentSender =
+ AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin)
+ return makePlaceholderDataset(remoteView, intentSender, AutofillAction.Search)
+ }
+
+ private fun makeGenerateDataset(context: Context): Dataset? {
+ if (scenario.fieldsToFillOn(AutofillAction.Generate).isEmpty()) return null
+ val remoteView = makeGenerateAndFillRemoteView(context, formOrigin)
+ val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin)
+ return makePlaceholderDataset(remoteView, intentSender, AutofillAction.Generate)
+ }
+
+ private fun makeFillOtpFromSmsDataset(context: Context): Dataset? {
+ if (scenario.fieldsToFillOn(AutofillAction.FillOtpFromSms).isEmpty()) return null
+ if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null
+ val remoteView = makeFillOtpFromSmsRemoteView(context, formOrigin)
+ val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context)
+ return makePlaceholderDataset(remoteView, intentSender, AutofillAction.FillOtpFromSms)
+ }
+
+ private fun makePublisherChangedDataset(
+ context: Context,
+ publisherChangedException: AutofillPublisherChangedException
+ ): Dataset {
+ val remoteView = makeWarningRemoteView(context)
+ val intentSender = AutofillPublisherChangedActivity.makePublisherChangedIntentSender(
+ context, publisherChangedException
+ )
+ return makePlaceholderDataset(remoteView, intentSender, AutofillAction.Match)
+ }
+
+ private fun makePublisherChangedResponse(
+ context: Context,
+ publisherChangedException: AutofillPublisherChangedException
+ ): FillResponse {
+ return FillResponse.Builder().run {
+ addDataset(makePublisherChangedDataset(context, publisherChangedException))
+ setIgnoredIds(*ignoredIds.toTypedArray())
+ build()
+ }
+ }
+
+ // TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE
+ // See: https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE
+ private fun makeSaveInfo(): SaveInfo? {
+ if (!canBeSaved) return null
+ check(saveFlags != null)
+ val idsToSave = scenario.fieldsToSave.map { it.autofillId }.toTypedArray()
+ if (idsToSave.isEmpty()) return null
+ var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD
+ if (scenario.username != null) {
+ saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME
+ }
+ return SaveInfo.Builder(saveDataTypes, idsToSave).run {
+ setFlags(saveFlags)
+ build()
+ }
+ }
+
+ private fun makeFillResponse(context: Context, matchedFiles: List<File>): FillResponse? {
+ var hasDataset = false
+ return FillResponse.Builder().run {
+ for (file in matchedFiles) {
+ makeMatchDataset(context, file)?.let {
+ hasDataset = true
+ addDataset(it)
+ }
+ }
+ makeSearchDataset(context)?.let {
+ hasDataset = true
+ addDataset(it)
+ }
+ makeGenerateDataset(context)?.let {
+ hasDataset = true
+ addDataset(it)
+ }
+ makeFillOtpFromSmsDataset(context)?.let {
+ hasDataset = true
+ addDataset(it)
+ }
+ if (!hasDataset) return null
+ makeSaveInfo()?.let { setSaveInfo(it) }
+ setClientState(clientState)
+ setIgnoredIds(*ignoredIds.toTypedArray())
+ build()
+ }
+ }
+
+ /**
+ * Creates and returns a suitable [FillResponse] to the Autofill framework.
+ */
+ fun fillCredentials(context: Context, callback: FillCallback) {
+ AutofillMatcher.getMatchesFor(context, formOrigin).fold(
+ success = { matchedFiles ->
+ callback.onSuccess(makeFillResponse(context, matchedFiles))
+ },
+ failure = { e ->
+ e(e)
+ callback.onSuccess(makePublisherChangedResponse(context, e))
+ }
+ )
+ }
+
+ companion object {
+ fun makeFillInDataset(
+ context: Context,
+ credentials: Credentials,
+ clientState: Bundle,
+ action: AutofillAction
+ ): Dataset {
+ val remoteView = makePlaceholderRemoteView(context)
+ val scenario = AutofillScenario.fromBundle(clientState)
+ // Before Android P, Datasets used for fill-in had to come with a RemoteViews, even
+ // though they are never shown.
+ val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ Dataset.Builder()
+ } else {
+ Dataset.Builder(remoteView)
+ }
+ return builder.run {
+ if (scenario != null) fillWith(scenario, action, credentials)
+ else e { "Failed to recover scenario from client state" }
+ build()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillScenario.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillScenario.kt
deleted file mode 100644
index f4fd5cf1..00000000
--- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillScenario.kt
+++ /dev/null
@@ -1,298 +0,0 @@
-/*
- * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package com.zeapo.pwdstore.autofill.oreo
-
-import android.app.assist.AssistStructure
-import android.os.Build
-import android.os.Bundle
-import android.service.autofill.Dataset
-import android.view.autofill.AutofillId
-import android.view.autofill.AutofillValue
-import androidx.annotation.RequiresApi
-import com.github.ajalt.timberkt.e
-import com.github.michaelbull.result.getOrElse
-import com.github.michaelbull.result.runCatching
-
-enum class AutofillAction {
- Match, Search, Generate, FillOtpFromSms
-}
-
-/**
- * Represents a set of form fields with associated roles (e.g., username or new password) and
- * contains the logic that decides which fields should be filled or saved. The type [T] is one of
- * [FormField], [AssistStructure.ViewNode] or [AutofillId], depending on how much metadata about the
- * field is needed and available in the particular situation.
- */
-@RequiresApi(Build.VERSION_CODES.O)
-sealed class AutofillScenario<out T : Any> {
-
- companion object {
-
- const val BUNDLE_KEY_USERNAME_ID = "usernameId"
- const val BUNDLE_KEY_FILL_USERNAME = "fillUsername"
- const val BUNDLE_KEY_OTP_ID = "otpId"
- const val BUNDLE_KEY_CURRENT_PASSWORD_IDS = "currentPasswordIds"
- const val BUNDLE_KEY_NEW_PASSWORD_IDS = "newPasswordIds"
- const val BUNDLE_KEY_GENERIC_PASSWORD_IDS = "genericPasswordIds"
-
- fun fromBundle(clientState: Bundle): AutofillScenario<AutofillId>? {
- return runCatching {
- 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()
- }.getOrElse { e ->
- e(e)
- null
- }
- }
- }
-
- class Builder<T : Any> {
-
- var username: T? = null
- var fillUsername = false
- var otp: T? = null
- val currentPassword = mutableListOf<T>()
- val newPassword = mutableListOf<T>()
- val genericPassword = mutableListOf<T>()
-
- fun build(): AutofillScenario<T> {
- require(genericPassword.isEmpty() || (currentPassword.isEmpty() && newPassword.isEmpty()))
- return if (currentPassword.isNotEmpty() || newPassword.isNotEmpty()) {
- ClassifiedAutofillScenario(
- username = username,
- fillUsername = fillUsername,
- otp = otp,
- currentPassword = currentPassword,
- newPassword = newPassword
- )
- } else {
- GenericAutofillScenario(
- username = username,
- fillUsername = fillUsername,
- otp = otp,
- genericPassword = genericPassword
- )
- }
- }
- }
-
- abstract val username: T?
- abstract val fillUsername: Boolean
- abstract val otp: T?
- abstract val allPasswordFields: List<T>
- abstract val passwordFieldsToFillOnMatch: List<T>
- abstract val passwordFieldsToFillOnSearch: List<T>
- abstract val passwordFieldsToFillOnGenerate: List<T>
- abstract val passwordFieldsToSave: List<T>
-
- val fieldsToSave
- get() = listOfNotNull(username) + passwordFieldsToSave
-
- val allFields
- get() = listOfNotNull(username, otp) + allPasswordFields
-
- fun fieldsToFillOn(action: AutofillAction): List<T> {
- val credentialFieldsToFill = when (action) {
- AutofillAction.Match -> passwordFieldsToFillOnMatch + listOfNotNull(otp)
- AutofillAction.Search -> passwordFieldsToFillOnSearch + listOfNotNull(otp)
- AutofillAction.Generate -> passwordFieldsToFillOnGenerate
- AutofillAction.FillOtpFromSms -> listOfNotNull(otp)
- }
- return when {
- action == AutofillAction.FillOtpFromSms -> {
- // When filling from an SMS, we cannot get any data other than the OTP itself.
- credentialFieldsToFill
- }
- credentialFieldsToFill.isNotEmpty() -> {
- // If the current action would fill into any password field, we also fill into the
- // username field if possible.
- listOfNotNull(username.takeIf { fillUsername }) + credentialFieldsToFill
- }
- allPasswordFields.isEmpty() && action != AutofillAction.Generate -> {
- // If there no password fields at all, we still offer to fill the username, e.g. in
- // two-step login scenarios, but we do not offer to generate a password.
- listOfNotNull(username.takeIf { fillUsername })
- }
- else -> emptyList()
- }
- }
-}
-
-@RequiresApi(Build.VERSION_CODES.O)
-data class ClassifiedAutofillScenario<T : Any>(
- override val username: T?,
- override val fillUsername: Boolean,
- override val otp: T?,
- val currentPassword: List<T>,
- val newPassword: List<T>
-) : AutofillScenario<T>() {
-
- override val allPasswordFields
- get() = currentPassword + newPassword
- override val passwordFieldsToFillOnMatch
- get() = currentPassword
- override val passwordFieldsToFillOnSearch
- get() = currentPassword
- override val passwordFieldsToFillOnGenerate
- get() = newPassword
- override val passwordFieldsToSave
- get() = if (newPassword.isNotEmpty()) newPassword else currentPassword
-}
-
-@RequiresApi(Build.VERSION_CODES.O)
-data class GenericAutofillScenario<T : Any>(
- override val username: T?,
- override val fillUsername: Boolean,
- override val otp: T?,
- val genericPassword: List<T>
-) : AutofillScenario<T>() {
-
- override val allPasswordFields
- get() = genericPassword
- override val passwordFieldsToFillOnMatch
- get() = if (genericPassword.size == 1) genericPassword else emptyList()
- override val passwordFieldsToFillOnSearch
- get() = if (genericPassword.size == 1) genericPassword else emptyList()
- override val passwordFieldsToFillOnGenerate
- get() = genericPassword
- override val passwordFieldsToSave
- get() = genericPassword
-}
-
-fun AutofillScenario<FormField>.passesOriginCheck(singleOriginMode: Boolean): Boolean {
- return if (singleOriginMode) {
- // In single origin mode, only the browsers URL bar (which is never filled) should have
- // a webOrigin.
- allFields.all { it.webOrigin == null }
- } else {
- // In apps or browsers in multi origin mode, every field in a dataset has to belong to
- // the same (possibly null) origin.
- allFields.map { it.webOrigin }.toSet().size == 1
- }
-}
-
-@RequiresApi(Build.VERSION_CODES.O)
-@JvmName("fillWithAutofillId")
-fun Dataset.Builder.fillWith(
- scenario: AutofillScenario<AutofillId>,
- action: AutofillAction,
- credentials: Credentials?
-) {
- val credentialsToFill = credentials ?: Credentials(
- "USERNAME",
- "PASSWORD",
- "OTP"
- )
- for (field in scenario.fieldsToFillOn(action)) {
- val value = when (field) {
- scenario.username -> credentialsToFill.username
- scenario.otp -> credentialsToFill.otp
- else -> credentialsToFill.password
- }
- setValue(field, AutofillValue.forText(value))
- }
-}
-
-@RequiresApi(Build.VERSION_CODES.O)
-@JvmName("fillWithFormField")
-fun Dataset.Builder.fillWith(
- scenario: AutofillScenario<FormField>,
- action: AutofillAction,
- credentials: Credentials?
-) {
- fillWith(scenario.map { it.autofillId }, action, credentials)
-}
-
-inline fun <T : Any, S : Any> AutofillScenario<T>.map(transform: (T) -> S): AutofillScenario<S> {
- val builder = AutofillScenario.Builder<S>()
- builder.username = username?.let(transform)
- builder.fillUsername = fillUsername
- builder.otp = otp?.let(transform)
- when (this) {
- is ClassifiedAutofillScenario -> {
- builder.currentPassword.addAll(currentPassword.map(transform))
- builder.newPassword.addAll(newPassword.map(transform))
- }
- is GenericAutofillScenario -> {
- builder.genericPassword.addAll(genericPassword.map(transform))
- }
- }
- return builder.build()
-}
-
-@RequiresApi(Build.VERSION_CODES.O)
-@JvmName("toBundleAutofillId")
-private fun AutofillScenario<AutofillId>.toBundle(): Bundle = when (this) {
- is ClassifiedAutofillScenario<AutofillId> -> {
- Bundle(5).apply {
- putParcelable(AutofillScenario.BUNDLE_KEY_USERNAME_ID, username)
- putBoolean(AutofillScenario.BUNDLE_KEY_FILL_USERNAME, fillUsername)
- putParcelable(AutofillScenario.BUNDLE_KEY_OTP_ID, otp)
- putParcelableArrayList(
- AutofillScenario.BUNDLE_KEY_CURRENT_PASSWORD_IDS, ArrayList(currentPassword)
- )
- putParcelableArrayList(
- AutofillScenario.BUNDLE_KEY_NEW_PASSWORD_IDS, ArrayList(newPassword)
- )
- }
- }
- is GenericAutofillScenario<AutofillId> -> {
- Bundle(4).apply {
- putParcelable(AutofillScenario.BUNDLE_KEY_USERNAME_ID, username)
- putBoolean(AutofillScenario.BUNDLE_KEY_FILL_USERNAME, fillUsername)
- putParcelable(AutofillScenario.BUNDLE_KEY_OTP_ID, otp)
- putParcelableArrayList(
- AutofillScenario.BUNDLE_KEY_GENERIC_PASSWORD_IDS, ArrayList(genericPassword)
- )
- }
- }
-}
-
-@RequiresApi(Build.VERSION_CODES.O)
-@JvmName("toBundleFormField")
-fun AutofillScenario<FormField>.toBundle(): Bundle = map { it.autofillId }.toBundle()
-
-@RequiresApi(Build.VERSION_CODES.O)
-fun AutofillScenario<AutofillId>.recoverNodes(structure: AssistStructure): AutofillScenario<AssistStructure.ViewNode>? {
- return map { autofillId ->
- structure.findNodeByAutofillId(autofillId) ?: return null
- }
-}
-
-val AutofillScenario<AssistStructure.ViewNode>.usernameValue: String?
- @RequiresApi(Build.VERSION_CODES.O) get() {
- val value = username?.autofillValue ?: return null
- return if (value.isText) value.textValue.toString() else null
- }
-val AutofillScenario<AssistStructure.ViewNode>.passwordValue: String?
- @RequiresApi(Build.VERSION_CODES.O) get() {
- val distinctValues = passwordFieldsToSave.map {
- if (it.autofillValue?.isText == true) {
- it.autofillValue?.textValue?.toString()
- } else {
- null
- }
- }.toSet()
- // Only return a non-null password value when all password fields agree
- return distinctValues.singleOrNull()
- }
diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategy.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategy.kt
deleted file mode 100644
index 90bb7051..00000000
--- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategy.kt
+++ /dev/null
@@ -1,208 +0,0 @@
-/*
- * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package com.zeapo.pwdstore.autofill.oreo
-
-import android.os.Build
-import androidx.annotation.RequiresApi
-import com.zeapo.pwdstore.autofill.oreo.CertaintyLevel.Certain
-import com.zeapo.pwdstore.autofill.oreo.CertaintyLevel.Likely
-
-private inline fun <T> Pair<T, T>.all(predicate: T.() -> Boolean) =
- predicate(first) && predicate(second)
-
-private inline fun <T> Pair<T, T>.any(predicate: T.() -> Boolean) =
- predicate(first) || predicate(second)
-
-private inline fun <T> Pair<T, T>.none(predicate: T.() -> Boolean) =
- !predicate(first) && !predicate(second)
-
-/**
- * The strategy used to detect [AutofillScenario]s; expressed using the DSL implemented in
- * [AutofillDsl].
- */
-@RequiresApi(Build.VERSION_CODES.O)
-val autofillStrategy = strategy {
-
- // Match two new password fields, an optional current password field right below or above, and
- // an optional username field with autocomplete hint.
- // TODO: Introduce a custom fill/generate/update flow for this scenario
- rule {
- newPassword {
- takePair { all { hasHintNewPassword } }
- breakTieOnPair { any { isFocused } }
- }
- currentPassword(optional = true) {
- takeSingle { alreadyMatched ->
- val adjacentToNewPasswords =
- directlyPrecedes(alreadyMatched) || directlyFollows(alreadyMatched)
- // The Autofill framework has not hint that applies to current passwords only.
- // In this scenario, we have already matched fields a pair of fields with a specific
- // new password hint, so we take a generic Autofill password hint to mean a current
- // password.
- (hasAutocompleteHintCurrentPassword || hasAutofillHintPassword) &&
- adjacentToNewPasswords
- }
- }
- username(optional = true) {
- takeSingle { hasHintUsername }
- breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) }
- breakTieOnSingle { isFocused }
- }
- }
-
- // Match a single focused current password field and hidden username field with autocomplete
- // hint. This configuration is commonly used in two-step login flows to allow password managers
- // to save the username.
- // See: https://www.chromium.org/developers/design-documents/form-styles-that-chromium-understands
- // Note: The username is never filled in this scenario since usernames are generally only filled
- // in visible fields.
- rule {
- username(matchHidden = true) {
- takeSingle {
- couldBeTwoStepHiddenUsername
- }
- }
- currentPassword {
- takeSingle { _ ->
- hasAutocompleteHintCurrentPassword && isFocused
- }
- }
- }
-
- // Match a single current password field and optional username field with autocomplete hint.
- rule {
- currentPassword {
- takeSingle { hasAutocompleteHintCurrentPassword }
- breakTieOnSingle { isFocused }
- }
- username(optional = true) {
- takeSingle { hasHintUsername }
- breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) }
- breakTieOnSingle { isFocused }
- }
- }
-
- // Match two adjacent password fields, implicitly understood as new passwords, and optional
- // username field.
- rule {
- newPassword {
- takePair { all { passwordCertainty >= Likely } }
- breakTieOnPair { all { passwordCertainty >= Certain } }
- breakTieOnPair { any { isFocused } }
- }
- username(optional = true) {
- takeSingle { usernameCertainty >= Likely }
- breakTieOnSingle { usernameCertainty >= Certain }
- breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) }
- breakTieOnSingle { isFocused }
- }
- }
-
- // Match a single password field and optional username field.
- rule {
- genericPassword {
- takeSingle { passwordCertainty >= Likely }
- breakTieOnSingle { passwordCertainty >= Certain }
- breakTieOnSingle { isFocused }
- }
- username(optional = true) {
- takeSingle { usernameCertainty >= Likely }
- breakTieOnSingle { usernameCertainty >= Certain }
- breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) }
- breakTieOnSingle { isFocused }
- }
- }
-
- // Match a single focused new password field and optional preceding username field.
- // This rule can apply in single origin mode since it only fills into a single focused password
- // field.
- rule(applyInSingleOriginMode = true) {
- newPassword {
- takeSingle { hasHintNewPassword && isFocused }
- }
- username(optional = true) {
- takeSingle { alreadyMatched ->
- usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull())
- }
- }
- }
-
- // Match a single focused current password field and optional preceding username field.
- // This rule can apply in single origin mode since it only fills into a single focused password
- // field.
- rule(applyInSingleOriginMode = true) {
- currentPassword {
- takeSingle { hasAutocompleteHintCurrentPassword && isFocused }
- }
- username(optional = true) {
- takeSingle { alreadyMatched ->
- usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull())
- }
- }
- }
-
- // Match a single focused password field and optional preceding username field.
- // This rule can apply in single origin mode since it only fills into a single focused password
- // field.
- rule(applyInSingleOriginMode = true) {
- genericPassword {
- takeSingle { passwordCertainty >= Likely && isFocused }
- }
- username(optional = true) {
- takeSingle { alreadyMatched ->
- usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull())
- }
- }
- }
-
- // Match a focused username field with autocomplete hint directly followed by a hidden password
- // field, which is a common scenario in two-step login flows. No tie breakers are used to limit
- // filling of hidden password fields to scenarios where this is clearly warranted.
- rule {
- username {
- takeSingle { hasHintUsername && isFocused }
- }
- currentPassword(matchHidden = true) {
- takeSingle { alreadyMatched ->
- directlyFollows(alreadyMatched.singleOrNull()) && couldBeTwoStepHiddenPassword
- }
- }
- }
-
- // Match a single focused OTP field.
- rule(applyInSingleOriginMode = true) {
- otp {
- takeSingle { otpCertainty >= Likely && isFocused }
- }
- }
-
- // Match a single focused username field without a password field.
- rule(applyInSingleOriginMode = true) {
- username {
- takeSingle { usernameCertainty >= Likely && isFocused }
- breakTieOnSingle { usernameCertainty >= Certain }
- breakTieOnSingle { hasHintUsername }
- }
- }
-
- // Match any focused password field with optional username field on manual request.
- rule(applyInSingleOriginMode = true, applyOnManualRequestOnly = true) {
- genericPassword {
- takeSingle { isFocused }
- }
- username(optional = true) {
- takeSingle { alreadyMatched ->
- usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull())
- }
- }
- }
-
- // Match any focused username field on manual request.
- rule(applyInSingleOriginMode = true, applyOnManualRequestOnly = true) {
- username {
- takeSingle { isFocused }
- }
- }
-}
diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategyDsl.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategyDsl.kt
deleted file mode 100644
index cae84d54..00000000
--- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategyDsl.kt
+++ /dev/null
@@ -1,381 +0,0 @@
-/*
- * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package com.zeapo.pwdstore.autofill.oreo
-
-import android.os.Build
-import androidx.annotation.RequiresApi
-import com.github.ajalt.timberkt.d
-import com.github.ajalt.timberkt.w
-
-@DslMarker
-annotation class AutofillDsl
-
-@RequiresApi(Build.VERSION_CODES.O)
-interface FieldMatcher {
-
- fun match(fields: List<FormField>, alreadyMatched: List<FormField>): List<FormField>?
-
- @AutofillDsl
- class Builder {
-
- private var takeSingle: (FormField.(List<FormField>) -> Boolean)? = null
- private val tieBreakersSingle: MutableList<FormField.(List<FormField>) -> Boolean> =
- mutableListOf()
-
- private var takePair: (Pair<FormField, FormField>.(List<FormField>) -> Boolean)? = null
- private var tieBreakersPair: MutableList<Pair<FormField, FormField>.(List<FormField>) -> Boolean> =
- mutableListOf()
-
- fun takeSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean = { true }) {
- check(takeSingle == null && takePair == null) { "Every block can only have at most one take{Single,Pair} block" }
- takeSingle = block
- }
-
- fun breakTieOnSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean) {
- check(takeSingle != null) { "Every block needs a takeSingle block before a breakTieOnSingle block" }
- check(takePair == null) { "takePair cannot be mixed with breakTieOnSingle" }
- tieBreakersSingle.add(block)
- }
-
- fun takePair(block: Pair<FormField, FormField>.(alreadyMatched: List<FormField>) -> Boolean = { true }) {
- check(takeSingle == null && takePair == null) { "Every block can only have at most one take{Single,Pair} block" }
- takePair = block
- }
-
- fun breakTieOnPair(block: Pair<FormField, FormField>.(alreadyMatched: List<FormField>) -> Boolean) {
- check(takePair != null) { "Every block needs a takePair block before a breakTieOnPair block" }
- check(takeSingle == null) { "takeSingle cannot be mixed with breakTieOnPair" }
- tieBreakersPair.add(block)
- }
-
- fun build(): FieldMatcher {
- val takeSingle = takeSingle
- val takePair = takePair
- return when {
- takeSingle != null -> SingleFieldMatcher(takeSingle, tieBreakersSingle)
- takePair != null -> PairOfFieldsMatcher(takePair, tieBreakersPair)
- else -> throw IllegalArgumentException("Every block needs a take{Single,Pair} block")
- }
- }
- }
-}
-
-@RequiresApi(Build.VERSION_CODES.O)
-class SingleFieldMatcher(
- private val take: (FormField, List<FormField>) -> Boolean,
- private val tieBreakers: List<(FormField, List<FormField>) -> Boolean>
-) : FieldMatcher {
-
- @AutofillDsl
- class Builder {
-
- private var takeSingle: (FormField.(List<FormField>) -> Boolean)? = null
- private val tieBreakersSingle: MutableList<FormField.(List<FormField>) -> Boolean> =
- mutableListOf()
-
- fun takeSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean = { true }) {
- check(takeSingle == null) { "Every block can only have at most one takeSingle block" }
- takeSingle = block
- }
-
- fun breakTieOnSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean) {
- check(takeSingle != null) { "Every block needs a takeSingle block before a breakTieOnSingle block" }
- tieBreakersSingle.add(block)
- }
-
- fun build() = SingleFieldMatcher(
- takeSingle
- ?: throw IllegalArgumentException("Every block needs a take{Single,Pair} block"),
- tieBreakersSingle
- )
- }
-
- override fun match(fields: List<FormField>, alreadyMatched: List<FormField>): List<FormField>? {
- return fields.minus(alreadyMatched).filter { take(it, alreadyMatched) }.let { contestants ->
- var current = contestants
- for ((i, tieBreaker) in tieBreakers.withIndex()) {
- // Successively filter matched fields via tie breakers...
- val new = current.filter { tieBreaker(it, alreadyMatched) }
- // skipping those tie breakers that are not satisfied for any remaining field...
- if (new.isEmpty()) {
- d { "Tie breaker #${i + 1}: Didn't match any field; skipping" }
- continue
- }
- // and return if the available options have been narrowed to a single field.
- if (new.size == 1) {
- d { "Tie breaker #${i + 1}: Success" }
- current = new
- break
- }
- d { "Tie breaker #${i + 1}: Matched ${new.size} fields; continuing" }
- current = new
- }
- listOf(current.singleOrNull() ?: return null)
- }
- }
-}
-
-@RequiresApi(Build.VERSION_CODES.O)
-private class PairOfFieldsMatcher(
- private val take: (Pair<FormField, FormField>, List<FormField>) -> Boolean,
- private val tieBreakers: List<(Pair<FormField, FormField>, List<FormField>) -> Boolean>
-) : FieldMatcher {
-
- override fun match(fields: List<FormField>, alreadyMatched: List<FormField>): List<FormField>? {
- return fields.minus(alreadyMatched).zipWithNext()
- .filter { it.first directlyPrecedes it.second }.filter { take(it, alreadyMatched) }
- .let { contestants ->
- var current = contestants
- for ((i, tieBreaker) in tieBreakers.withIndex()) {
- val new = current.filter { tieBreaker(it, alreadyMatched) }
- if (new.isEmpty()) {
- d { "Tie breaker #${i + 1}: Didn't match any field; skipping" }
- continue
- }
- // and return if the available options have been narrowed to a single field.
- if (new.size == 1) {
- d { "Tie breaker #${i + 1}: Success" }
- current = new
- break
- }
- d { "Tie breaker #${i + 1}: Matched ${new.size} fields; continuing" }
- current = new
- }
- current.singleOrNull()?.toList()
- }
- }
-}
-
-@RequiresApi(Build.VERSION_CODES.O)
-class AutofillRule private constructor(
- private val matchers: List<AutofillRuleMatcher>,
- private val applyInSingleOriginMode: Boolean,
- private val applyOnManualRequestOnly: Boolean,
- private val name: String
-) {
-
- data class AutofillRuleMatcher(
- val type: FillableFieldType,
- val matcher: FieldMatcher,
- val optional: Boolean,
- val matchHidden: Boolean
- )
-
- enum class FillableFieldType {
- Username, Otp, CurrentPassword, NewPassword, GenericPassword,
- }
-
- @AutofillDsl
- class Builder(
- private val applyInSingleOriginMode: Boolean,
- private val applyOnManualRequestOnly: Boolean
- ) {
-
- companion object {
-
- private var ruleId = 1
- }
-
- private val matchers = mutableListOf<AutofillRuleMatcher>()
- var name: String? = null
-
- fun username(optional: Boolean = false, matchHidden: Boolean = false, block: SingleFieldMatcher.Builder.() -> Unit) {
- require(matchers.none { it.type == FillableFieldType.Username }) { "Every rule block can only have at most one username block" }
- matchers.add(
- AutofillRuleMatcher(
- type = FillableFieldType.Username,
- matcher = SingleFieldMatcher.Builder().apply(block).build(),
- optional = optional,
- matchHidden = matchHidden
- )
- )
- }
-
- fun otp(optional: Boolean = false, block: SingleFieldMatcher.Builder.() -> Unit) {
- require(matchers.none { it.type == FillableFieldType.Otp }) { "Every rule block can only have at most one otp block" }
- matchers.add(
- AutofillRuleMatcher(
- type = FillableFieldType.Otp,
- matcher = SingleFieldMatcher.Builder().apply(block).build(),
- optional = optional,
- matchHidden = false
- )
- )
- }
-
- fun currentPassword(optional: Boolean = false, matchHidden: Boolean = false, block: FieldMatcher.Builder.() -> Unit) {
- require(matchers.none { it.type == FillableFieldType.GenericPassword }) { "Every rule block can only have either genericPassword or {current,new}Password blocks" }
- matchers.add(
- AutofillRuleMatcher(
- type = FillableFieldType.CurrentPassword,
- matcher = FieldMatcher.Builder().apply(block).build(),
- optional = optional,
- matchHidden = matchHidden
- )
- )
- }
-
- fun newPassword(optional: Boolean = false, block: FieldMatcher.Builder.() -> Unit) {
- require(matchers.none { it.type == FillableFieldType.GenericPassword }) { "Every rule block can only have either genericPassword or {current,new}Password blocks" }
- matchers.add(
- AutofillRuleMatcher(
- type = FillableFieldType.NewPassword,
- matcher = FieldMatcher.Builder().apply(block).build(),
- optional = optional,
- matchHidden = false
- )
- )
- }
-
- fun genericPassword(optional: Boolean = false, block: FieldMatcher.Builder.() -> Unit) {
- require(matchers.none {
- it.type in listOf(
- FillableFieldType.CurrentPassword,
- FillableFieldType.NewPassword,
- )
- }) { "Every rule block can only have either genericPassword or {current,new}Password blocks" }
- matchers.add(
- AutofillRuleMatcher(
- type = FillableFieldType.GenericPassword,
- matcher = FieldMatcher.Builder().apply(block).build(),
- optional = optional,
- matchHidden = false
- )
- )
- }
-
- fun build(): AutofillRule {
- if (applyInSingleOriginMode) {
- require(matchers.none { it.matcher is PairOfFieldsMatcher }) { "Rules with applyInSingleOriginMode set to true must only match single fields" }
- require(matchers.filter { it.type != FillableFieldType.Username }.size <= 1) { "Rules with applyInSingleOriginMode set to true must only match at most one password field" }
- require(matchers.none { it.matchHidden }) { "Rules with applyInSingleOriginMode set to true must not fill into hidden fields" }
- }
- return AutofillRule(
- matchers, applyInSingleOriginMode, applyOnManualRequestOnly, name ?: "Rule #$ruleId"
- ).also { ruleId++ }
- }
- }
-
- fun match(
- allPassword: List<FormField>,
- allUsername: List<FormField>,
- allOtp: List<FormField>,
- singleOriginMode: Boolean,
- isManualRequest: Boolean
- ): AutofillScenario<FormField>? {
- if (singleOriginMode && !applyInSingleOriginMode) {
- d { "$name: Skipped in single origin mode" }
- return null
- }
- if (!isManualRequest && applyOnManualRequestOnly) {
- d { "$name: Skipped since not a manual request" }
- return null
- }
- d { "$name: Applying..." }
- val scenarioBuilder = AutofillScenario.Builder<FormField>()
- val alreadyMatched = mutableListOf<FormField>()
- for ((type, matcher, optional, matchHidden) in matchers) {
- val fieldsToMatchOn = when (type) {
- FillableFieldType.Username -> allUsername
- FillableFieldType.Otp -> allOtp
- else -> allPassword
- }.filter { matchHidden || it.isVisible }
- val matchResult = matcher.match(fieldsToMatchOn, alreadyMatched) ?: if (optional) {
- d { "$name: Skipping optional $type matcher" }
- continue
- } else {
- d { "$name: Required $type matcher didn't match; passing to next rule" }
- return null
- }
- d { "$name: Matched $type" }
- when (type) {
- FillableFieldType.Username -> {
- check(matchResult.size == 1 && scenarioBuilder.username == null)
- scenarioBuilder.username = matchResult.single()
- // Hidden username fields should be saved but not filled.
- scenarioBuilder.fillUsername = scenarioBuilder.username!!.isVisible == true
- }
- FillableFieldType.Otp -> {
- check(matchResult.size == 1 && scenarioBuilder.otp == null)
- scenarioBuilder.otp = matchResult.single()
- }
- FillableFieldType.CurrentPassword -> scenarioBuilder.currentPassword.addAll(
- matchResult
- )
- FillableFieldType.NewPassword -> scenarioBuilder.newPassword.addAll(matchResult)
- FillableFieldType.GenericPassword -> scenarioBuilder.genericPassword.addAll(
- matchResult
- )
- }
- alreadyMatched.addAll(matchResult)
- }
- return scenarioBuilder.build().takeIf { scenario ->
- scenario.passesOriginCheck(singleOriginMode = singleOriginMode).also { passed ->
- if (passed) {
- d { "$name: Detected scenario:\n$scenario" }
- } else {
- w { "$name: Scenario failed origin check:\n$scenario" }
- }
- }
- }
- }
-}
-
-@RequiresApi(Build.VERSION_CODES.O)
-class AutofillStrategy private constructor(private val rules: List<AutofillRule>) {
-
- @AutofillDsl
- class Builder {
-
- private val rules: MutableList<AutofillRule> = mutableListOf()
-
- fun rule(
- applyInSingleOriginMode: Boolean = false,
- applyOnManualRequestOnly: Boolean = false,
- block: AutofillRule.Builder.() -> Unit
- ) {
- rules.add(
- AutofillRule.Builder(
- applyInSingleOriginMode = applyInSingleOriginMode,
- applyOnManualRequestOnly = applyOnManualRequestOnly
- ).apply(block).build()
- )
- }
-
- fun build() = AutofillStrategy(rules)
- }
-
- fun match(
- fields: List<FormField>,
- singleOriginMode: Boolean,
- isManualRequest: Boolean
- ): AutofillScenario<FormField>? {
- val possiblePasswordFields =
- fields.filter { it.passwordCertainty >= CertaintyLevel.Possible }
- d { "Possible password fields: ${possiblePasswordFields.size}" }
- val possibleUsernameFields =
- fields.filter { it.usernameCertainty >= CertaintyLevel.Possible }
- d { "Possible username fields: ${possibleUsernameFields.size}" }
- val possibleOtpFields =
- fields.filter { it.otpCertainty >= CertaintyLevel.Possible }
- d { "Possible otp fields: ${possibleOtpFields.size}" }
- // Return the result of the first rule that matches
- d { "Rules: ${rules.size}" }
- for (rule in rules) {
- return rule.match(
- possiblePasswordFields,
- possibleUsernameFields,
- possibleOtpFields,
- singleOriginMode = singleOriginMode,
- isManualRequest = isManualRequest
- )
- ?: continue
- }
- return null
- }
-}
-
-fun strategy(block: AutofillStrategy.Builder.() -> Unit) =
- AutofillStrategy.Builder().apply(block).build()
diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillViewUtils.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillViewUtils.kt
new file mode 100644
index 00000000..5e8061a2
--- /dev/null
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillViewUtils.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package com.zeapo.pwdstore.autofill.oreo
+
+import android.content.Context
+import android.widget.RemoteViews
+import com.github.androidpasswordstore.autofillparser.FormOrigin
+import com.zeapo.pwdstore.R
+import com.zeapo.pwdstore.utils.PasswordRepository
+import java.io.File
+
+private fun makeRemoteView(
+ context: Context,
+ title: String,
+ summary: String,
+ iconRes: Int
+): RemoteViews {
+ return RemoteViews(context.packageName, R.layout.oreo_autofill_dataset).apply {
+ setTextViewText(R.id.title, title)
+ setTextViewText(R.id.summary, summary)
+ setImageViewResource(R.id.icon, iconRes)
+ }
+}
+
+fun makeFillMatchRemoteView(context: Context, file: File, formOrigin: FormOrigin): RemoteViews {
+ val title = formOrigin.getPrettyIdentifier(context, untrusted = false)
+ val directoryStructure = AutofillPreferences.directoryStructure(context)
+ val relativeFile = file.relativeTo(PasswordRepository.getRepositoryDirectory())
+ val summary = directoryStructure.getUsernameFor(relativeFile)
+ ?: directoryStructure.getPathToIdentifierFor(relativeFile) ?: ""
+ val iconRes = R.drawable.ic_person_black_24dp
+ return makeRemoteView(context, title, summary, iconRes)
+}
+
+fun makeSearchAndFillRemoteView(context: Context, formOrigin: FormOrigin): RemoteViews {
+ val title = formOrigin.getPrettyIdentifier(context, untrusted = true)
+ val summary = context.getString(R.string.oreo_autofill_search_in_store)
+ val iconRes = R.drawable.ic_search_black_24dp
+ return makeRemoteView(context, title, summary, iconRes)
+}
+
+fun makeGenerateAndFillRemoteView(context: Context, formOrigin: FormOrigin): RemoteViews {
+ val title = formOrigin.getPrettyIdentifier(context, untrusted = true)
+ val summary = context.getString(R.string.oreo_autofill_generate_password)
+ val iconRes = R.drawable.ic_autofill_new_password
+ return makeRemoteView(context, title, summary, iconRes)
+}
+
+fun makeFillOtpFromSmsRemoteView(context: Context, formOrigin: FormOrigin): RemoteViews {
+ val title = formOrigin.getPrettyIdentifier(context, untrusted = true)
+ val summary = context.getString(R.string.oreo_autofill_fill_otp_from_sms)
+ val iconRes = R.drawable.ic_autofill_sms
+ return makeRemoteView(context, title, summary, iconRes)
+}
+
+fun makePlaceholderRemoteView(context: Context): RemoteViews {
+ return makeRemoteView(context, "PLACEHOLDER", "PLACEHOLDER", R.mipmap.ic_launcher)
+}
+
+fun makeWarningRemoteView(context: Context): RemoteViews {
+ val title = context.getString(R.string.oreo_autofill_warning_publisher_dataset_title)
+ val summary = context.getString(R.string.oreo_autofill_warning_publisher_dataset_summary)
+ val iconRes = R.drawable.ic_warning_red_24dp
+ return makeRemoteView(context, title, summary, iconRes)
+}
diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FeatureAndTrustDetection.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FeatureAndTrustDetection.kt
deleted file mode 100644
index e49f7e81..00000000
--- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FeatureAndTrustDetection.kt
+++ /dev/null
@@ -1,213 +0,0 @@
-/*
- * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package com.zeapo.pwdstore.autofill.oreo
-
-import android.content.Context
-import android.content.Intent
-import android.content.pm.PackageManager
-import android.net.Uri
-import android.os.Build
-import androidx.annotation.RequiresApi
-
-/*
- In order to add a new browser, do the following:
-
- 1. Obtain the .apk from a trusted source. For example, download it from the Play Store on your
- phone and use adb pull to get it onto your computer. We will assume that it is called
- browser.apk.
-
- 2. Run
-
- aapt dump badging browser.apk | grep package: | grep -Eo " name='[a-zA-Z0-9_\.]*" | cut -c8-
-
- to obtain the package name (actually, the application ID) of the app in the .apk.
-
- 3. Run
-
- apksigner verify --print-certs browser.apk | grep "#1 certificate SHA-256" | grep -Eo "[a-f0-9]{64}" | tr -d '\n' | xxd -r -p | base64
-
- to calculate the hash of browser.apk's first signing certificate.
- Note: This will only work if the apk has a single signing certificate. Apps with multiple
- signers are very rare, so there is probably no need to add them.
- Refer to computeCertificatesHash to learn how the hash would be computed in this case.
-
- 4. Verify the package name and the hash, for example by asking other people to repeat the steps
- above.
-
- 5. Add an entry with the browser apps's package name and the hash to
- TRUSTED_BROWSER_CERTIFICATE_HASH.
-
- 6. Optionally, try adding the browser's package name to BROWSERS_WITH_SAVE_SUPPORT and check
- whether a save request to Password Store is triggered when you submit a registration form.
-
- 7. Optionally, try adding the browser's package name to BROWSERS_WITH_MULTI_ORIGIN_SUPPORT and
- check whether it correctly distinguishes web origins even if iframes are present on the page.
- You can use https://fabianhenneke.github.io/Android-Password-Store/ as a test form.
- */
-
-/*
- * **Security assumption**: Browsers on this list correctly report the web origin of the top-level
- * window as part of their AssistStructure.
- *
- * Note: Browsers can be on this list even if they don't report the correct web origins of all
- * fields on the page, e.g. of those in iframes.
- */
-private val TRUSTED_BROWSER_CERTIFICATE_HASH = mapOf(
- "com.android.chrome" to "8P1sW0EPJcslw7UzRsiXL64w+O50Ed+RBICtay1g24M=",
- "com.brave.browser" to "nC23BRNRX9v7vFhbPt89cSPU3GfJT/0wY2HB15u/GKw=",
- "com.chrome.beta" to "2mM9NLaeY64hA7SdU84FL8X388U6q5T9wqIIvf0UJJw=",
- "com.chrome.canary" to "IBnfofsj779wxbzRRDxb6rBPPy/0Nm6aweNFdjmiTPw=",
- "com.chrome.dev" to "kETuX+5LvF4h3URmVDHE6x8fcaMnFqC8knvLs5Izyr8=",
- "com.duckduckgo.mobile.android" to "u3uzHFc8RqHaf8XFKKas9DIQhFb+7FCBDH8zaU6z0tQ=",
- "com.microsoft.emmx" to "AeGZlxCoLCdJtNUMRF3IXWcLYTYInQp2anOCfIKh6sk=",
- "com.opera.mini.native" to "V6y8Ul8bLr0ZGWzW8BQ5fMkQ/RiEHgroUP68Ph5ZP/I=",
- "com.opera.mini.native.beta" to "V6y8Ul8bLr0ZGWzW8BQ5fMkQ/RiEHgroUP68Ph5ZP/I=",
- "com.opera.touch" to "qtjiBNJNF3k0yc0MY8xqo4779CxKaVcJfiIQ9X+qZ6o=",
- "org.bromite.bromite" to "4e5c0HbXsNyEyytF+3i4bfLrOaO2xWuj3CkqXgw7lQQ=",
- "org.gnu.icecat" to "wi2iuVvK/WYZUzd2g0Qzn9ef3kAisQURZ8U1WSMTkcM=",
- "org.mozilla.fenix" to "UAR3kIjn+YjVvFzF+HmP6/T4zQhKGypG79TI7krq8hE=",
- "org.mozilla.fenix.nightly" to "d+rEzu02r++6dheZMd1MwZWrDNVLrzVdIV57vdKOQCo=",
- "org.mozilla.fennec_aurora" to "vASIg40G9Mpr8yOG2qsN2OvPPncweHRZ9i+zzRShuqo=",
- "org.mozilla.fennec_fdroid" to "BmZTWO/YugW+I2pHoSywlY19dd2TnXfCsx9TmFN+vcU=",
- "org.mozilla.firefox" to "p4tipRZbRJSy/q2edqKA0i2Tf+5iUa7OWZRGsuoxmwQ=",
- "org.mozilla.firefox_beta" to "p4tipRZbRJSy/q2edqKA0i2Tf+5iUa7OWZRGsuoxmwQ=",
- "org.mozilla.focus" to "YgOkc7421k7jf4f6UA7bx56rkwYQq5ufpMp9XB8bT/w=",
- "org.mozilla.klar" to "YgOkc7421k7jf4f6UA7bx56rkwYQq5ufpMp9XB8bT/w=",
- "org.torproject.torbrowser" to "IAYfBF5zfGc3XBd5TP7bQ2oDzsa6y3y5+WZCIFyizsg=",
- "org.ungoogled.chromium.stable" to "29UOO5cXoxO/e/hH3hOu6bbtg1My4tK6Eik2Ym5Krtk=",
- "org.ungoogled.chromium.extensions.stable" to "29UOO5cXoxO/e/hH3hOu6bbtg1My4tK6Eik2Ym5Krtk=",
- "com.kiwibrowser.browser" to "wGnqlmMy6R4KDDzFd+b1Cf49ndr3AVrQxcXvj9o/hig=",
-)
-
-private fun isTrustedBrowser(context: Context, appPackage: String): Boolean {
- val expectedCertificateHash = TRUSTED_BROWSER_CERTIFICATE_HASH[appPackage] ?: return false
- val certificateHash = computeCertificatesHash(context, appPackage)
- return certificateHash == expectedCertificateHash
-}
-
-enum class BrowserMultiOriginMethod {
- None, WebView, Field
-}
-
-/**
- * **Security assumption**: Browsers on this list correctly distinguish the web origins of form
- * fields, e.g. on a page which contains both a first-party login form and an iframe with a
- * (potentially malicious) third-party login form.
- *
- * There are two methods used by browsers:
- * - Browsers based on Android's WebView report web domains on each WebView view node, which then
- * needs to be propagated to the child nodes ([BrowserMultiOriginMethod.WebView]).
- * - Browsers with custom Autofill implementations report web domains on each input field (
- * [BrowserMultiOriginMethod.Field]).
- */
-private val BROWSER_MULTI_ORIGIN_METHOD = mapOf(
- "com.duckduckgo.mobile.android" to BrowserMultiOriginMethod.WebView,
- "com.opera.mini.native" to BrowserMultiOriginMethod.WebView,
- "com.opera.mini.native.beta" to BrowserMultiOriginMethod.WebView,
- "com.opera.touch" to BrowserMultiOriginMethod.WebView,
- "org.gnu.icecat" to BrowserMultiOriginMethod.WebView,
- "org.mozilla.fenix" to BrowserMultiOriginMethod.Field,
- "org.mozilla.fenix.nightly" to BrowserMultiOriginMethod.Field,
- "org.mozilla.fennec_aurora" to BrowserMultiOriginMethod.Field,
- "org.mozilla.fennec_fdroid" to BrowserMultiOriginMethod.Field,
- "org.mozilla.firefox" to BrowserMultiOriginMethod.WebView,
- "org.mozilla.firefox_beta" to BrowserMultiOriginMethod.WebView,
- "org.mozilla.focus" to BrowserMultiOriginMethod.Field,
- "org.mozilla.klar" to BrowserMultiOriginMethod.Field,
- "org.torproject.torbrowser" to BrowserMultiOriginMethod.WebView,
-)
-
-private fun getBrowserMultiOriginMethod(appPackage: String): BrowserMultiOriginMethod =
- BROWSER_MULTI_ORIGIN_METHOD[appPackage] ?: BrowserMultiOriginMethod.None
-
-/**
- * Browsers on this list issue Autofill save requests and provide unmasked passwords as
- * `autofillValue`.
- *
- * Some browsers may not issue save requests automatically and thus need
- * `FLAG_SAVE_ON_ALL_VIEW_INVISIBLE` to be set.
- */
-@RequiresApi(Build.VERSION_CODES.O)
-private val BROWSER_SAVE_FLAG = mapOf(
- "com.duckduckgo.mobile.android" to 0,
- "org.mozilla.klar" to 0,
- "org.mozilla.focus" to 0,
- "org.mozilla.fenix" to 0,
- "org.mozilla.fenix.nightly" to 0,
- "org.mozilla.fennec_aurora" to 0,
- "com.opera.mini.native" to 0,
- "com.opera.mini.native.beta" to 0,
- "com.opera.touch" to 0,
-)
-
-@RequiresApi(Build.VERSION_CODES.O)
-private fun getBrowserSaveFlag(appPackage: String): Int? = BROWSER_SAVE_FLAG[appPackage]
-
-data class BrowserAutofillSupportInfo(
- val multiOriginMethod: BrowserMultiOriginMethod,
- val saveFlags: Int?
-)
-
-@RequiresApi(Build.VERSION_CODES.O)
-fun getBrowserAutofillSupportInfoIfTrusted(
- context: Context,
- appPackage: String
-): BrowserAutofillSupportInfo? {
- if (!isTrustedBrowser(context, appPackage)) return null
- return BrowserAutofillSupportInfo(
- multiOriginMethod = getBrowserMultiOriginMethod(appPackage),
- saveFlags = getBrowserSaveFlag(appPackage)
- )
-}
-
-private val FLAKY_BROWSERS = listOf(
- "com.android.chrome",
- "com.chrome.beta",
- "com.chrome.canary",
- "com.chrome.dev",
- "org.bromite.bromite",
- "org.ungoogled.chromium.stable",
- "com.kiwibrowser.browser",
-)
-
-enum class BrowserAutofillSupportLevel {
- None,
- FlakyFill,
- PasswordFill,
- GeneralFill,
- GeneralFillAndSave,
-}
-
-@RequiresApi(Build.VERSION_CODES.O)
-private fun getBrowserAutofillSupportLevel(
- context: Context,
- appPackage: String
-): BrowserAutofillSupportLevel {
- val browserInfo = getBrowserAutofillSupportInfoIfTrusted(context, appPackage)
- return when {
- browserInfo == null -> BrowserAutofillSupportLevel.None
- appPackage in FLAKY_BROWSERS -> BrowserAutofillSupportLevel.FlakyFill
- browserInfo.multiOriginMethod == BrowserMultiOriginMethod.None -> BrowserAutofillSupportLevel.PasswordFill
- browserInfo.saveFlags == null -> BrowserAutofillSupportLevel.GeneralFill
- else -> BrowserAutofillSupportLevel.GeneralFillAndSave
- }
-}
-
-@RequiresApi(Build.VERSION_CODES.O)
-fun getInstalledBrowsersWithAutofillSupportLevel(context: Context): List<Pair<String, BrowserAutofillSupportLevel>> {
- val testWebIntent = Intent(Intent.ACTION_VIEW).apply {
- data = Uri.parse("http://example.org")
- }
- val installedBrowsers = context.packageManager.queryIntentActivities(
- testWebIntent,
- PackageManager.MATCH_ALL
- )
- return installedBrowsers.map {
- it to getBrowserAutofillSupportLevel(context, it.activityInfo.packageName)
- }.filter { it.first.isDefault || it.second != BrowserAutofillSupportLevel.None }.map {
- context.packageManager.getApplicationLabel(it.first.activityInfo.applicationInfo)
- .toString() to it.second
- }
-}
diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/Form.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/Form.kt
deleted file mode 100644
index 5d24c882..00000000
--- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/Form.kt
+++ /dev/null
@@ -1,383 +0,0 @@
-/*
- * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package com.zeapo.pwdstore.autofill.oreo
-
-import android.app.assist.AssistStructure
-import android.content.Context
-import android.content.IntentSender
-import android.content.pm.PackageManager
-import android.net.Uri
-import android.os.Build
-import android.os.Bundle
-import android.service.autofill.Dataset
-import android.service.autofill.FillCallback
-import android.service.autofill.FillResponse
-import android.service.autofill.SaveInfo
-import android.view.autofill.AutofillId
-import android.widget.RemoteViews
-import androidx.annotation.RequiresApi
-import androidx.core.os.bundleOf
-import com.github.ajalt.timberkt.d
-import com.github.ajalt.timberkt.e
-import com.github.michaelbull.result.fold
-import com.zeapo.pwdstore.autofill.oreo.ui.AutofillDecryptActivity
-import com.zeapo.pwdstore.autofill.oreo.ui.AutofillFilterView
-import com.zeapo.pwdstore.autofill.oreo.ui.AutofillPublisherChangedActivity
-import com.zeapo.pwdstore.autofill.oreo.ui.AutofillSaveActivity
-import com.zeapo.pwdstore.autofill.oreo.ui.AutofillSmsActivity
-import java.io.File
-
-/**
- * A unique identifier for either an Android app (package name) or a website (origin minus port).
- */
-sealed class FormOrigin(open val identifier: String) {
-
- data class Web(override val identifier: String) : FormOrigin(identifier)
- data class App(override val identifier: String) : FormOrigin(identifier)
-
- companion object {
-
- private const val BUNDLE_KEY_WEB_IDENTIFIER = "webIdentifier"
- private const val BUNDLE_KEY_APP_IDENTIFIER = "appIdentifier"
-
- fun fromBundle(bundle: Bundle): FormOrigin? {
- val webIdentifier = bundle.getString(BUNDLE_KEY_WEB_IDENTIFIER)
- if (webIdentifier != null) {
- return Web(webIdentifier)
- } else {
- return App(bundle.getString(BUNDLE_KEY_APP_IDENTIFIER) ?: return null)
- }
- }
- }
-
- fun getPrettyIdentifier(context: Context, untrusted: Boolean = true) = when (this) {
- is Web -> identifier
- is App -> {
- val info = context.packageManager.getApplicationInfo(
- identifier, PackageManager.GET_META_DATA
- )
- val label = context.packageManager.getApplicationLabel(info)
- if (untrusted) "“$label”" else "$label"
- }
- }
-
- fun toBundle() = when (this) {
- is Web -> bundleOf(BUNDLE_KEY_WEB_IDENTIFIER to identifier)
- is App -> bundleOf(BUNDLE_KEY_APP_IDENTIFIER to identifier)
- }
-}
-
-/**
- * Manages the detection of fields to fill in an [AssistStructure] and determines the [FormOrigin].
- */
-@RequiresApi(Build.VERSION_CODES.O)
-private class Form(context: Context, structure: AssistStructure, isManualRequest: Boolean) {
-
- companion object {
-
- private val SUPPORTED_SCHEMES = listOf("http", "https")
- }
-
- private val relevantFields = mutableListOf<FormField>()
- val ignoredIds = mutableListOf<AutofillId>()
- private var fieldIndex = 0
-
- private var appPackage = structure.activityComponent.packageName
-
- private val trustedBrowserInfo =
- getBrowserAutofillSupportInfoIfTrusted(context, appPackage)
- val saveFlags = trustedBrowserInfo?.saveFlags
-
- private val webOrigins = mutableSetOf<String>()
-
- init {
- d { "Request from $appPackage (${computeCertificatesHash(context, appPackage)})" }
- parseStructure(structure)
- }
-
- val scenario = detectFieldsToFill(isManualRequest)
- val formOrigin = determineFormOrigin(context)
-
- init {
- d { "Origin: $formOrigin" }
- }
-
- private fun parseStructure(structure: AssistStructure) {
- for (i in 0 until structure.windowNodeCount) {
- visitFormNode(structure.getWindowNodeAt(i).rootViewNode)
- }
- }
-
- private fun visitFormNode(node: AssistStructure.ViewNode, inheritedWebOrigin: String? = null) {
- trackOrigin(node)
- val field =
- if (trustedBrowserInfo?.multiOriginMethod == BrowserMultiOriginMethod.WebView) {
- FormField(node, fieldIndex, true, inheritedWebOrigin)
- } else {
- check(inheritedWebOrigin == null)
- FormField(node, fieldIndex, false)
- }
- if (field.relevantField) {
- d { "Relevant: $field" }
- relevantFields.add(field)
- fieldIndex++
- } else {
- d { "Ignored : $field" }
- ignoredIds.add(field.autofillId)
- }
- for (i in 0 until node.childCount) {
- visitFormNode(node.getChildAt(i), field.webOriginToPassDown)
- }
- }
-
- private fun detectFieldsToFill(isManualRequest: Boolean) = autofillStrategy.match(
- relevantFields,
- singleOriginMode = trustedBrowserInfo?.multiOriginMethod == BrowserMultiOriginMethod.None,
- isManualRequest = isManualRequest
- )
-
- private fun trackOrigin(node: AssistStructure.ViewNode) {
- if (trustedBrowserInfo == null) return
- node.webOrigin?.let {
- if (it !in webOrigins) {
- d { "Origin encountered: $it" }
- webOrigins.add(it)
- }
- }
- }
-
- private fun webOriginToFormOrigin(context: Context, origin: String): FormOrigin? {
- val uri = Uri.parse(origin) ?: return null
- val scheme = uri.scheme ?: return null
- if (scheme !in SUPPORTED_SCHEMES) return null
- val host = uri.host ?: return null
- return FormOrigin.Web(getPublicSuffixPlusOne(context, host))
- }
-
- 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
- )
- }
- }
- }
-}
-
-/**
- * Represents a collection of fields in a specific app that can be filled or saved. This is the
- * entry point to all fill and save features.
- */
-@RequiresApi(Build.VERSION_CODES.O)
-class FillableForm private constructor(
- private val formOrigin: FormOrigin,
- private val scenario: AutofillScenario<FormField>,
- private val ignoredIds: List<AutofillId>,
- private val saveFlags: Int?
-) {
-
- companion object {
-
- fun makeFillInDataset(
- context: Context,
- credentials: Credentials,
- clientState: Bundle,
- action: AutofillAction
- ): Dataset {
- val remoteView = makePlaceholderRemoteView(context)
- val scenario = AutofillScenario.fromBundle(clientState)
- // Before Android P, Datasets used for fill-in had to come with a RemoteViews, even
- // though they are never shown.
- val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
- Dataset.Builder()
- } else {
- Dataset.Builder(remoteView)
- }
- return builder.run {
- if (scenario != null) fillWith(scenario, action, credentials)
- else e { "Failed to recover scenario from client state" }
- build()
- }
- }
-
- /**
- * Returns a [FillableForm] if a login form could be detected in [structure].
- */
- fun parseAssistStructure(
- context: Context,
- structure: AssistStructure,
- isManualRequest: Boolean
- ): FillableForm? {
- val form = Form(context, structure, isManualRequest)
- if (form.formOrigin == null || form.scenario == null) return null
- return FillableForm(
- form.formOrigin,
- form.scenario,
- form.ignoredIds,
- form.saveFlags
- )
- }
- }
-
- private val clientState = scenario.toBundle().apply {
- putAll(formOrigin.toBundle())
- }
-
- // We do not offer save when the only relevant field is a username field or there is no field.
- private val scenarioSupportsSave =
- scenario.fieldsToSave.minus(listOfNotNull(scenario.username)).isNotEmpty()
- private val canBeSaved = saveFlags != null && scenarioSupportsSave
-
- private fun makePlaceholderDataset(
- remoteView: RemoteViews,
- intentSender: IntentSender,
- action: AutofillAction
- ): Dataset {
- return Dataset.Builder(remoteView).run {
- fillWith(scenario, action, credentials = null)
- setAuthentication(intentSender)
- build()
- }
- }
-
- private fun makeMatchDataset(context: Context, file: File): Dataset? {
- if (scenario.fieldsToFillOn(AutofillAction.Match).isEmpty()) return null
- val remoteView = makeFillMatchRemoteView(context, file, formOrigin)
- val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
- return makePlaceholderDataset(remoteView, intentSender, AutofillAction.Match)
- }
-
- private fun makeSearchDataset(context: Context): Dataset? {
- if (scenario.fieldsToFillOn(AutofillAction.Search).isEmpty()) return null
- val remoteView = makeSearchAndFillRemoteView(context, formOrigin)
- val intentSender =
- AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin)
- return makePlaceholderDataset(remoteView, intentSender, AutofillAction.Search)
- }
-
- private fun makeGenerateDataset(context: Context): Dataset? {
- if (scenario.fieldsToFillOn(AutofillAction.Generate).isEmpty()) return null
- val remoteView = makeGenerateAndFillRemoteView(context, formOrigin)
- val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin)
- return makePlaceholderDataset(remoteView, intentSender, AutofillAction.Generate)
- }
-
- private fun makeFillOtpFromSmsDataset(context: Context): Dataset? {
- if (scenario.fieldsToFillOn(AutofillAction.FillOtpFromSms).isEmpty()) return null
- if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null
- val remoteView = makeFillOtpFromSmsRemoteView(context, formOrigin)
- val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context)
- return makePlaceholderDataset(remoteView, intentSender, AutofillAction.FillOtpFromSms)
- }
-
- private fun makePublisherChangedDataset(
- context: Context,
- publisherChangedException: AutofillPublisherChangedException
- ): Dataset {
- val remoteView = makeWarningRemoteView(context)
- val intentSender = AutofillPublisherChangedActivity.makePublisherChangedIntentSender(
- context, publisherChangedException
- )
- return makePlaceholderDataset(remoteView, intentSender, AutofillAction.Match)
- }
-
- private fun makePublisherChangedResponse(
- context: Context,
- publisherChangedException: AutofillPublisherChangedException
- ): FillResponse {
- return FillResponse.Builder().run {
- addDataset(makePublisherChangedDataset(context, publisherChangedException))
- setIgnoredIds(*ignoredIds.toTypedArray())
- build()
- }
- }
-
- // TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE
- // See: https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE
- private fun makeSaveInfo(): SaveInfo? {
- if (!canBeSaved) return null
- check(saveFlags != null)
- val idsToSave = scenario.fieldsToSave.map { it.autofillId }.toTypedArray()
- if (idsToSave.isEmpty()) return null
- var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD
- if (scenario.username != null) {
- saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME
- }
- return SaveInfo.Builder(saveDataTypes, idsToSave).run {
- setFlags(saveFlags)
- build()
- }
- }
-
- private fun makeFillResponse(context: Context, matchedFiles: List<File>): FillResponse? {
- var hasDataset = false
- return FillResponse.Builder().run {
- for (file in matchedFiles) {
- makeMatchDataset(context, file)?.let {
- hasDataset = true
- addDataset(it)
- }
- }
- makeSearchDataset(context)?.let {
- hasDataset = true
- addDataset(it)
- }
- makeGenerateDataset(context)?.let {
- hasDataset = true
- addDataset(it)
- }
- makeFillOtpFromSmsDataset(context)?.let {
- hasDataset = true
- addDataset(it)
- }
- if (!hasDataset) return null
- makeSaveInfo()?.let { setSaveInfo(it) }
- setClientState(clientState)
- setIgnoredIds(*ignoredIds.toTypedArray())
- build()
- }
- }
-
- /**
- * Creates and returns a suitable [FillResponse] to the Autofill framework.
- */
- fun fillCredentials(context: Context, callback: FillCallback) {
- AutofillMatcher.getMatchesFor(context, formOrigin).fold(
- success = { matchedFiles ->
- callback.onSuccess(makeFillResponse(context, matchedFiles))
- },
- failure = { e ->
- e(e)
- callback.onSuccess(makePublisherChangedResponse(context, e))
- }
- )
- }
-}
diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FormField.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FormField.kt
deleted file mode 100644
index 6435a83f..00000000
--- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FormField.kt
+++ /dev/null
@@ -1,303 +0,0 @@
-/*
- * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package com.zeapo.pwdstore.autofill.oreo
-
-import android.app.assist.AssistStructure
-import android.os.Build
-import android.text.InputType
-import android.view.View
-import android.view.autofill.AutofillId
-import androidx.annotation.RequiresApi
-import androidx.autofill.HintConstants
-import java.util.Locale
-
-enum class CertaintyLevel {
- Impossible, Possible, Likely, Certain
-}
-
-/**
- * Represents a single potentially fillable or saveable field together with all meta data
- * extracted from its [AssistStructure.ViewNode].
- */
-@RequiresApi(Build.VERSION_CODES.O)
-class FormField(
- node: AssistStructure.ViewNode,
- private val index: Int,
- passDownWebViewOrigins: Boolean,
- passedDownWebOrigin: String? = null
-) {
-
- companion object {
-
- private val HINTS_USERNAME = listOf(
- HintConstants.AUTOFILL_HINT_USERNAME,
- HintConstants.AUTOFILL_HINT_NEW_USERNAME,
- )
-
- private val HINTS_NEW_PASSWORD = listOf(
- HintConstants.AUTOFILL_HINT_NEW_PASSWORD,
- )
-
- private val HINTS_PASSWORD = HINTS_NEW_PASSWORD + listOf(
- HintConstants.AUTOFILL_HINT_PASSWORD,
- )
-
- private val HINTS_OTP = listOf(
- HintConstants.AUTOFILL_HINT_SMS_OTP,
- )
-
- @Suppress("DEPRECATION")
- private val HINTS_FILLABLE = HINTS_USERNAME + HINTS_PASSWORD + HINTS_OTP + listOf(
- HintConstants.AUTOFILL_HINT_EMAIL_ADDRESS,
- HintConstants.AUTOFILL_HINT_NAME,
- HintConstants.AUTOFILL_HINT_PERSON_NAME,
- HintConstants.AUTOFILL_HINT_PHONE,
- HintConstants.AUTOFILL_HINT_PHONE_NUMBER,
- )
-
- private val ANDROID_TEXT_FIELD_CLASS_NAMES = listOf(
- "android.widget.EditText",
- "android.widget.AutoCompleteTextView",
- "androidx.appcompat.widget.AppCompatEditText",
- "android.support.v7.widget.AppCompatEditText",
- "com.google.android.material.textfield.TextInputEditText",
- )
-
- private const val ANDROID_WEB_VIEW_CLASS_NAME = "android.webkit.WebView"
-
- private fun isPasswordInputType(inputType: Int): Boolean {
- val typeClass = inputType and InputType.TYPE_MASK_CLASS
- val typeVariation = inputType and InputType.TYPE_MASK_VARIATION
- return when (typeClass) {
- InputType.TYPE_CLASS_NUMBER -> typeVariation == InputType.TYPE_NUMBER_VARIATION_PASSWORD
- InputType.TYPE_CLASS_TEXT -> typeVariation in listOf(
- InputType.TYPE_TEXT_VARIATION_PASSWORD,
- InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD,
- InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD,
- )
- else -> false
- }
- }
-
- private val HTML_INPUT_FIELD_TYPES_USERNAME = listOf(
- "email",
- "tel",
- "text",
- )
- private val HTML_INPUT_FIELD_TYPES_PASSWORD = listOf(
- "password",
- )
- private val HTML_INPUT_FIELD_TYPES_OTP = listOf(
- "tel",
- "text",
- )
- private val HTML_INPUT_FIELD_TYPES_FILLABLE =
- (HTML_INPUT_FIELD_TYPES_USERNAME + HTML_INPUT_FIELD_TYPES_PASSWORD + HTML_INPUT_FIELD_TYPES_OTP).toSet().toList()
-
- @RequiresApi(Build.VERSION_CODES.O)
- private fun isSupportedHint(hint: String) = hint in HINTS_FILLABLE
-
- private val EXCLUDED_TERMS = listOf(
- "url_bar", // Chrome/Edge/Firefox address bar
- "url_field", // Opera address bar
- "location_bar_edit_text", // Samsung address bar
- "search", "find", "captcha",
- "postal", // Prevent postal code fields from being mistaken for OTP fields
-
- )
- private val PASSWORD_HEURISTIC_TERMS = listOf(
- "pass",
- "pswd",
- "pwd",
- )
- private val USERNAME_HEURISTIC_TERMS = listOf(
- "alias",
- "e-mail",
- "email",
- "login",
- "user",
- )
- private val OTP_HEURISTIC_TERMS = listOf(
- "einmal",
- "otp",
- "challenge",
- "verification",
- )
- private val OTP_WEAK_HEURISTIC_TERMS = listOf(
- "code",
- )
- }
-
- private val List<String>.anyMatchesFieldInfo
- get() = any {
- fieldId.contains(it) || hint.contains(it) || htmlName.contains(it)
- }
-
- val autofillId: AutofillId = node.autofillId!!
-
- // Information for heuristics and exclusion rules based only on the current field
- private val htmlId = node.htmlInfo?.attributes?.firstOrNull { it.first == "id" }?.second
- private val resourceId = node.idEntry
- private val fieldId = (htmlId ?: resourceId ?: "").toLowerCase(Locale.US)
- private val hint = node.hint?.toLowerCase(Locale.US) ?: ""
- private val className: String? = node.className
- private val inputType = node.inputType
-
- // Information for advanced heuristics taking multiple fields and page context into account
- val isFocused = node.isFocused
-
- // The webOrigin of a WebView should be passed down to its children in certain browsers
- private val isWebView = node.className == ANDROID_WEB_VIEW_CLASS_NAME
- val webOrigin = node.webOrigin ?: if (passDownWebViewOrigins) passedDownWebOrigin else null
- val webOriginToPassDown = if (passDownWebViewOrigins) {
- if (isWebView) webOrigin else passedDownWebOrigin
- } else {
- null
- }
-
- // Basic type detection for HTML fields
- private val htmlTag = node.htmlInfo?.tag
- private val htmlAttributes: Map<String, String> =
- node.htmlInfo?.attributes?.filter { it.first != null && it.second != null }
- ?.associate { Pair(it.first.toLowerCase(Locale.US), it.second.toLowerCase(Locale.US)) }
- ?: emptyMap()
-
- private val htmlAttributesDebug =
- htmlAttributes.entries.joinToString { "${it.key}=${it.value}" }
- private val htmlInputType = htmlAttributes["type"]
- private val htmlName = htmlAttributes["name"] ?: ""
- private val htmlMaxLength = htmlAttributes["maxlength"]?.toIntOrNull()
- private val isHtmlField = htmlTag == "input"
- private val isHtmlPasswordField =
- isHtmlField && htmlInputType in HTML_INPUT_FIELD_TYPES_PASSWORD
- private val isHtmlTextField = isHtmlField && htmlInputType in HTML_INPUT_FIELD_TYPES_FILLABLE
-
- // Basic type detection for native fields
- private val hasPasswordInputType = isPasswordInputType(inputType)
-
- // HTML fields with non-fillable types (such as submit buttons) should be excluded here
- private val isAndroidTextField = !isHtmlField && className in ANDROID_TEXT_FIELD_CLASS_NAMES
- private val isAndroidPasswordField = isAndroidTextField && hasPasswordInputType
-
- private val isTextField = isAndroidTextField || isHtmlTextField
-
- // Autofill hint detection for native fields
- private val autofillHints = node.autofillHints?.filter { isSupportedHint(it) } ?: emptyList()
- private val excludedByAutofillHints =
- if (autofillHints.isEmpty()) false else autofillHints.intersect(HINTS_FILLABLE).isEmpty()
- val hasAutofillHintPassword = autofillHints.intersect(HINTS_PASSWORD).isNotEmpty()
- private val hasAutofillHintNewPassword = autofillHints.intersect(HINTS_NEW_PASSWORD).isNotEmpty()
- private val hasAutofillHintUsername = autofillHints.intersect(HINTS_USERNAME).isNotEmpty()
- private val hasAutofillHintOtp = autofillHints.intersect(HINTS_OTP).isNotEmpty()
-
- // W3C autocomplete hint detection for HTML fields
- private val htmlAutocomplete = htmlAttributes["autocomplete"]
-
- // Ignored for now, see excludedByHints
- private val excludedByAutocompleteHint = htmlAutocomplete == "off"
- private val hasAutocompleteHintUsername = htmlAutocomplete == "username"
- val hasAutocompleteHintCurrentPassword = htmlAutocomplete == "current-password"
- private val hasAutocompleteHintNewPassword = htmlAutocomplete == "new-password"
- private val hasAutocompleteHintPassword =
- hasAutocompleteHintCurrentPassword || hasAutocompleteHintNewPassword
- private val hasAutocompleteHintOtp = htmlAutocomplete == "one-time-code"
-
- // Results of hint-based field type detection
- val hasHintUsername = hasAutofillHintUsername || hasAutocompleteHintUsername
- val hasHintPassword = hasAutofillHintPassword || hasAutocompleteHintPassword
- val hasHintNewPassword = hasAutofillHintNewPassword || hasAutocompleteHintNewPassword
- val hasHintOtp = hasAutofillHintOtp || hasAutocompleteHintOtp
-
- // Basic autofill exclusion checks
- private val hasAutofillTypeText = node.autofillType == View.AUTOFILL_TYPE_TEXT
- val isVisible = node.visibility == View.VISIBLE && htmlAttributes["aria-hidden"] != "true"
-
- // Hidden username fields are used to help password managers save credentials in two-step login
- // flows.
- // See: https://www.chromium.org/developers/design-documents/form-styles-that-chromium-understands
- val couldBeTwoStepHiddenUsername = !isVisible && isHtmlTextField && hasAutocompleteHintUsername
-
- // Some websites with two-step login flows offer hidden password fields to fill the password
- // already in the first step. Thus, we delegate the decision about filling invisible password
- // fields to the fill rules and only exclude those fields that have incompatible autocomplete
- // hint.
- val couldBeTwoStepHiddenPassword =
- !isVisible && isHtmlPasswordField && (hasAutocompleteHintCurrentPassword || htmlAutocomplete == null)
-
- // Since many site put autocomplete=off on login forms for compliance reasons or since they are
- // worried of the user's browser automatically (i.e., without any user interaction) filling
- // them, which we never do, we choose to ignore the value of excludedByAutocompleteHint.
- // TODO: Revisit this decision in the future
- private val excludedByHints = excludedByAutofillHints
-
- // Only offer to fill into custom views if they explicitly opted into Autofill.
- val relevantField = hasAutofillTypeText && (isTextField || autofillHints.isNotEmpty()) && !excludedByHints
-
- // Exclude fields based on hint, resource ID or HTML name.
- // Note: We still report excluded fields as relevant since they count for adjacency heuristics,
- // but ensure that they are never detected as password or username fields.
- private val hasExcludedTerm = EXCLUDED_TERMS.anyMatchesFieldInfo
- private val notExcluded = relevantField && !hasExcludedTerm
-
- // Password field heuristics (based only on the current field)
- private val isPossiblePasswordField =
- notExcluded && (isAndroidPasswordField || isHtmlPasswordField)
- private val isCertainPasswordField = isPossiblePasswordField && hasHintPassword
- private val isLikelyPasswordField = isPossiblePasswordField &&
- (isCertainPasswordField || PASSWORD_HEURISTIC_TERMS.anyMatchesFieldInfo)
- val passwordCertainty =
- if (isCertainPasswordField) CertaintyLevel.Certain else if (isLikelyPasswordField) CertaintyLevel.Likely else if (isPossiblePasswordField) CertaintyLevel.Possible else CertaintyLevel.Impossible
-
- // OTP field heuristics (based only on the current field)
- private val isPossibleOtpField = notExcluded && !isPossiblePasswordField
- private val isCertainOtpField = isPossibleOtpField && hasHintOtp
- private val isLikelyOtpField = isPossibleOtpField && (
- isCertainOtpField || OTP_HEURISTIC_TERMS.anyMatchesFieldInfo ||
- ((htmlMaxLength == null || htmlMaxLength in 6..8) && OTP_WEAK_HEURISTIC_TERMS.anyMatchesFieldInfo))
- val otpCertainty =
- if (isCertainOtpField) CertaintyLevel.Certain else if (isLikelyOtpField) CertaintyLevel.Likely else if (isPossibleOtpField) CertaintyLevel.Possible else CertaintyLevel.Impossible
-
- // Username field heuristics (based only on the current field)
- private val isPossibleUsernameField = notExcluded && !isPossiblePasswordField && !isCertainOtpField
- private val isCertainUsernameField = isPossibleUsernameField && hasHintUsername
- private val isLikelyUsernameField = isPossibleUsernameField && (isCertainUsernameField || (USERNAME_HEURISTIC_TERMS.anyMatchesFieldInfo))
- val usernameCertainty =
- if (isCertainUsernameField) CertaintyLevel.Certain else if (isLikelyUsernameField) CertaintyLevel.Likely else if (isPossibleUsernameField) CertaintyLevel.Possible else CertaintyLevel.Impossible
-
- infix fun directlyPrecedes(that: FormField?): Boolean {
- return index == (that ?: return false).index - 1
- }
-
- infix fun directlyPrecedes(that: Iterable<FormField>): Boolean {
- val firstIndex = that.map { it.index }.minOrNull() ?: return false
- return index == firstIndex - 1
- }
-
- infix fun directlyFollows(that: FormField?): Boolean {
- return index == (that ?: return false).index + 1
- }
-
- infix fun directlyFollows(that: Iterable<FormField>): Boolean {
- val lastIndex = that.map { it.index }.maxOrNull() ?: return false
- return index == lastIndex + 1
- }
-
- override fun toString(): String {
- val field = if (isHtmlTextField) "$htmlTag[type=$htmlInputType]" else className
- val description =
- "\"$hint\", \"$fieldId\"${if (isFocused) ", focused" else ""}${if (isVisible) ", visible" else ""}, $webOrigin, $htmlAttributesDebug, $autofillHints"
- return "$field ($description): password=$passwordCertainty, username=$usernameCertainty, otp=$otpCertainty"
- }
-
- override fun equals(other: Any?): Boolean {
- if (other == null) return false
- if (this.javaClass != other.javaClass) return false
- return autofillId == (other as FormField).autofillId
- }
-
- override fun hashCode(): Int {
- return autofillId.hashCode()
- }
-}
diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt
index fd2997d8..ecec6747 100644
--- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt
@@ -4,6 +4,7 @@
*/
package com.zeapo.pwdstore.autofill.oreo
+import android.content.Context
import android.os.Build
import android.os.CancellationSignal
import android.service.autofill.AutofillService
@@ -15,10 +16,22 @@ import android.service.autofill.SaveRequest
import androidx.annotation.RequiresApi
import com.github.ajalt.timberkt.d
import com.github.ajalt.timberkt.e
+import com.github.androidpasswordstore.autofillparser.AutofillScenario
+import com.github.androidpasswordstore.autofillparser.Credentials
+import com.github.androidpasswordstore.autofillparser.FillableForm
+import com.github.androidpasswordstore.autofillparser.FixedSaveCallback
+import com.github.androidpasswordstore.autofillparser.FormOrigin
+import com.github.androidpasswordstore.autofillparser.cachePublicSuffixList
+import com.github.androidpasswordstore.autofillparser.passwordValue
+import com.github.androidpasswordstore.autofillparser.recoverNodes
+import com.github.androidpasswordstore.autofillparser.usernameValue
import com.zeapo.pwdstore.BuildConfig
import com.zeapo.pwdstore.R
import com.zeapo.pwdstore.autofill.oreo.ui.AutofillSaveActivity
+import com.zeapo.pwdstore.utils.PreferenceKeys
+import com.zeapo.pwdstore.utils.getString
import com.zeapo.pwdstore.utils.hasFlag
+import com.zeapo.pwdstore.utils.sharedPrefs
@RequiresApi(Build.VERSION_CODES.O)
class OreoAutofillService : AutofillService() {
@@ -66,13 +79,14 @@ class OreoAutofillService : AutofillService() {
}
val formToFill = FillableForm.parseAssistStructure(
this, structure,
- isManualRequest = request.flags hasFlag FillRequest.FLAG_MANUAL_REQUEST
+ isManualRequest = request.flags hasFlag FillRequest.FLAG_MANUAL_REQUEST,
+ getCustomSuffixes(),
) ?: run {
d { "Form cannot be filled" }
callback.onSuccess(null)
return
}
- formToFill.fillCredentials(this, callback)
+ AutofillResponseBuilder(formToFill).fillCredentials(this, callback)
}
override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {
@@ -113,3 +127,12 @@ class OreoAutofillService : AutofillService() {
)
}
}
+
+fun Context.getDefaultUsername() = sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME)
+
+fun Context.getCustomSuffixes(): Sequence<String> {
+ return sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES)
+ ?.splitToSequence('\n')
+ ?.filter { it.isNotBlank() && it.first() != '.' && it.last() != '.' }
+ ?: emptySequence()
+}
diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/PublicSuffixListCache.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/PublicSuffixListCache.kt
deleted file mode 100644
index 8107248e..00000000
--- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/PublicSuffixListCache.kt
+++ /dev/null
@@ -1,90 +0,0 @@
-/*
- * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package com.zeapo.pwdstore.autofill.oreo
-
-import android.content.Context
-import android.util.Patterns
-import com.zeapo.pwdstore.utils.PreferenceKeys
-import com.zeapo.pwdstore.utils.getString
-import com.zeapo.pwdstore.utils.sharedPrefs
-import kotlinx.coroutines.runBlocking
-import mozilla.components.lib.publicsuffixlist.PublicSuffixList
-
-private object PublicSuffixListCache {
-
- private lateinit var publicSuffixList: PublicSuffixList
-
- fun getOrCachePublicSuffixList(context: Context): PublicSuffixList {
- if (!::publicSuffixList.isInitialized) {
- publicSuffixList = PublicSuffixList(context)
- // Trigger loading the actual public suffix list, but don't block.
- @Suppress("DeferredResultUnused")
- publicSuffixList.prefetch()
- }
- return publicSuffixList
- }
-}
-
-fun cachePublicSuffixList(context: Context) {
- PublicSuffixListCache.getOrCachePublicSuffixList(context)
-}
-
-/**
- * Returns the eTLD+1 (also called registrable domain), i.e. the direct subdomain of the public
- * suffix of [domain].
- *
- * Note: Invalid domains, such as IP addresses, are returned unchanged and thus never collide with
- * the return value for valid domains.
- */
-fun getPublicSuffixPlusOne(context: Context, domain: String) = 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)
- }
-}
-
-/**
- * Returns:
- * - [domain], if [domain] equals [suffix];
- * - null, if [domain] does not have [suffix] as a domain suffix or only with an empty prefix;
- * - the direct subdomain of [suffix] of which [domain] is a subdomain.
- */
-fun getSuffixPlusUpToOne(domain: String, suffix: String): String? {
- if (domain == suffix)
- return domain
- val prefix = domain.removeSuffix(".$suffix")
- if (prefix == domain || prefix.isEmpty())
- return null
- val lastPrefixPart = prefix.takeLastWhile { it != '.' }
- return "$lastPrefixPart.$suffix"
-}
-
-fun getCustomSuffixes(context: Context): Sequence<String> {
- return context.sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES)
- ?.splitToSequence('\n')
- ?.filter { it.isNotBlank() && it.first() != '.' && it.last() != '.' }
- ?: emptySequence()
-}
-
-suspend fun getCanonicalSuffix(context: Context, domain: String): String {
- val publicSuffixList = PublicSuffixListCache.getOrCachePublicSuffixList(context)
- val publicSuffixPlusOne = publicSuffixList.getPublicSuffixPlusOne(domain).await()
- ?: return domain
- var longestSuffix = publicSuffixPlusOne
- for (customSuffix in getCustomSuffixes(context)) {
- val suffixPlusUpToOne = getSuffixPlusUpToOne(domain, customSuffix) ?: continue
- // A shorter suffix is automatically a substring.
- if (suffixPlusUpToOne.length > longestSuffix.length)
- longestSuffix = suffixPlusUpToOne
- }
- return longestSuffix
-}
diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt
index 2c63c5c3..9bab9e6f 100644
--- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt
@@ -22,11 +22,11 @@ import com.github.michaelbull.result.getOrElse
import com.github.michaelbull.result.onFailure
import com.github.michaelbull.result.onSuccess
import com.github.michaelbull.result.runCatching
-import com.zeapo.pwdstore.autofill.oreo.AutofillAction
+import com.github.androidpasswordstore.autofillparser.AutofillAction
+import com.github.androidpasswordstore.autofillparser.Credentials
import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences
-import com.zeapo.pwdstore.autofill.oreo.Credentials
+import com.zeapo.pwdstore.autofill.oreo.AutofillResponseBuilder
import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure
-import com.zeapo.pwdstore.autofill.oreo.FillableForm
import com.zeapo.pwdstore.model.PasswordEntry
import com.zeapo.pwdstore.utils.OPENPGP_PROVIDER
import java.io.ByteArrayOutputStream
@@ -109,7 +109,7 @@ class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope {
setResult(RESULT_CANCELED)
} else {
val fillInDataset =
- FillableForm.makeFillInDataset(
+ AutofillResponseBuilder.makeFillInDataset(
this@AutofillDecryptActivity,
credentials,
clientState,
@@ -185,7 +185,7 @@ class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope {
@Suppress("BlockingMethodInNonBlockingContext")
PasswordEntry(decryptedOutput)
}
- Credentials.fromStoreEntry(this, file, entry, directoryStructure)
+ AutofillPreferences.credentialsFromStoreEntry(this, file, entry, directoryStructure)
}.getOrElse { e ->
e(e) { "Failed to parse password entry" }
return null
diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt
index 7e29b061..95e49fdd 100644
--- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt
@@ -24,6 +24,7 @@ import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.LinearLayoutManager
import com.github.ajalt.timberkt.e
+import com.github.androidpasswordstore.autofillparser.FormOrigin
import com.zeapo.pwdstore.FilterMode
import com.zeapo.pwdstore.ListMode
import com.zeapo.pwdstore.R
@@ -33,7 +34,6 @@ import com.zeapo.pwdstore.SearchableRepositoryViewModel
import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher
import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences
import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure
-import com.zeapo.pwdstore.autofill.oreo.FormOrigin
import com.zeapo.pwdstore.databinding.ActivityOreoAutofillFilterBinding
import com.zeapo.pwdstore.utils.PasswordItem
import com.zeapo.pwdstore.utils.viewBinding
diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillPublisherChangedActivity.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillPublisherChangedActivity.kt
index bcb27e65..44ed3446 100644
--- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillPublisherChangedActivity.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillPublisherChangedActivity.kt
@@ -16,13 +16,13 @@ import android.text.format.DateUtils
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import com.github.ajalt.timberkt.e
+import com.github.androidpasswordstore.autofillparser.FormOrigin
+import com.github.androidpasswordstore.autofillparser.computeCertificatesHash
import com.github.michaelbull.result.onFailure
import com.github.michaelbull.result.runCatching
import com.zeapo.pwdstore.R
import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher
import com.zeapo.pwdstore.autofill.oreo.AutofillPublisherChangedException
-import com.zeapo.pwdstore.autofill.oreo.FormOrigin
-import com.zeapo.pwdstore.autofill.oreo.computeCertificatesHash
import com.zeapo.pwdstore.databinding.ActivityOreoAutofillPublisherChangedBinding
import com.zeapo.pwdstore.utils.viewBinding
diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt
index 0052ff65..7f83d483 100644
--- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt
@@ -16,12 +16,12 @@ import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.core.os.bundleOf
import com.github.ajalt.timberkt.e
-import com.zeapo.pwdstore.autofill.oreo.AutofillAction
+import com.github.androidpasswordstore.autofillparser.AutofillAction
+import com.github.androidpasswordstore.autofillparser.Credentials
+import com.github.androidpasswordstore.autofillparser.FormOrigin
import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher
import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences
-import com.zeapo.pwdstore.autofill.oreo.Credentials
-import com.zeapo.pwdstore.autofill.oreo.FillableForm
-import com.zeapo.pwdstore.autofill.oreo.FormOrigin
+import com.zeapo.pwdstore.autofill.oreo.AutofillResponseBuilder
import com.zeapo.pwdstore.crypto.PasswordCreationActivity
import com.zeapo.pwdstore.utils.PasswordRepository
import java.io.File
@@ -126,7 +126,7 @@ class AutofillSaveActivity : AppCompatActivity() {
return@registerForActivityResult
}
val credentials = Credentials(username, password, null)
- val fillInDataset = FillableForm.makeFillInDataset(
+ val fillInDataset = AutofillResponseBuilder.makeFillInDataset(
this,
credentials,
clientState,
diff --git a/app/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixList.kt b/app/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixList.kt
deleted file mode 100644
index 0fb59002..00000000
--- a/app/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixList.kt
+++ /dev/null
@@ -1,139 +0,0 @@
-/*
- * SPDX-License-Identifier: GPL-3.0-only OR MPL-2.0
- */
-/* This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this
- * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
-
-package mozilla.components.lib.publicsuffixlist
-
-import android.content.Context
-import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Deferred
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.async
-
-/**
- * API for reading and accessing the public suffix list.
- *
- * > A "public suffix" is one under which Internet users can (or historically could) directly register names. Some
- * > examples of public suffixes are .com, .co.uk and pvt.k12.ma.us. The Public Suffix List is a list of all known
- * > public suffixes.
- *
- * Note that this implementation applies the rules of the public suffix list only and does not validate domains.
- *
- * https://publicsuffix.org/
- * https://github.com/publicsuffix/list
- */
-class PublicSuffixList(
- context: Context,
- dispatcher: CoroutineDispatcher = Dispatchers.IO,
- private val scope: CoroutineScope = CoroutineScope(dispatcher)
-) {
-
- private val data: PublicSuffixListData by lazy { PublicSuffixListLoader.load(context) }
-
- /**
- * Prefetch the public suffix list from disk so that it is available in memory.
- */
- fun prefetch(): Deferred<Unit> = scope.async {
- data.run { Unit }
- }
-
- /**
- * Returns true if the given [domain] is a public suffix; false otherwise.
- *
- * E.g.:
- * ```
- * co.uk -> true
- * com -> true
- * mozilla.org -> false
- * org -> true
- * ```
- *
- * Note that this method ignores the default "prevailing rule" described in the formal public suffix list algorithm:
- * If no rule matches then the passed [domain] is assumed to *not* be a public suffix.
- *
- * @param [domain] _must_ be a valid domain. [PublicSuffixList] performs no validation, and if any unexpected values
- * are passed (e.g., a full URL, a domain with a trailing '/', etc) this may return an incorrect result.
- */
- fun isPublicSuffix(domain: String): Deferred<Boolean> = scope.async {
- when (data.getPublicSuffixOffset(domain)) {
- is PublicSuffixOffset.PublicSuffix -> true
- else -> false
- }
- }
-
- /**
- * Returns the public suffix and one more level; known as the registrable domain. Returns `null` if
- * [domain] is a public suffix itself.
- *
- * E.g.:
- * ```
- * wwww.mozilla.org -> mozilla.org
- * www.bcc.co.uk -> bbc.co.uk
- * a.b.ide.kyoto.jp -> b.ide.kyoto.jp
- * ```
- *
- * @param [domain] _must_ be a valid domain. [PublicSuffixList] performs no validation, and if any unexpected values
- * are passed (e.g., a full URL, a domain with a trailing '/', etc) this may return an incorrect result.
- */
- fun getPublicSuffixPlusOne(domain: String): Deferred<String?> = scope.async {
- when (val offset = data.getPublicSuffixOffset(domain)) {
- is PublicSuffixOffset.Offset -> domain
- .split('.')
- .drop(offset.value)
- .joinToString(separator = ".")
- else -> null
- }
- }
-
- /**
- * Returns the public suffix of the given [domain]; known as the effective top-level domain (eTLD). Returns `null`
- * if the [domain] is a public suffix itself.
- *
- * E.g.:
- * ```
- * wwww.mozilla.org -> org
- * www.bcc.co.uk -> co.uk
- * a.b.ide.kyoto.jp -> ide.kyoto.jp
- * ```
- *
- * @param [domain] _must_ be a valid domain. [PublicSuffixList] performs no validation, and if any unexpected values
- * are passed (e.g., a full URL, a domain with a trailing '/', etc) this may return an incorrect result.
- */
- fun getPublicSuffix(domain: String) = scope.async {
- when (val offset = data.getPublicSuffixOffset(domain)) {
- is PublicSuffixOffset.Offset -> domain
- .split('.')
- .drop(offset.value + 1)
- .joinToString(separator = ".")
- else -> null
- }
- }
-
- /**
- * Strips the public suffix from the given [domain]. Returns the original domain if no public suffix could be
- * stripped.
- *
- * E.g.:
- * ```
- * wwww.mozilla.org -> www.mozilla
- * www.bcc.co.uk -> www.bbc
- * a.b.ide.kyoto.jp -> a.b
- * ```
- *
- * @param [domain] _must_ be a valid domain. [PublicSuffixList] performs no validation, and if any unexpected values
- * are passed (e.g., a full URL, a domain with a trailing '/', etc) this may return an incorrect result.
- */
- fun stripPublicSuffix(domain: String) = scope.async {
- when (val offset = data.getPublicSuffixOffset(domain)) {
- is PublicSuffixOffset.Offset -> domain
- .split('.')
- .joinToString(separator = ".", limit = offset.value + 1, truncated = "")
- .dropLast(1)
- else -> domain
- }
- }
-}
diff --git a/app/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListData.kt b/app/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListData.kt
deleted file mode 100644
index 778e9fee..00000000
--- a/app/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListData.kt
+++ /dev/null
@@ -1,163 +0,0 @@
-/*
- * SPDX-License-Identifier: GPL-3.0-only OR MPL-2.0
- */
-/* This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this
- * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
-
-package mozilla.components.lib.publicsuffixlist
-
-import mozilla.components.lib.publicsuffixlist.ext.binarySearch
-import java.net.IDN
-
-/**
- * Class wrapping the public suffix list data and offering methods for accessing rules in it.
- */
-internal class PublicSuffixListData(
- private val rules: ByteArray,
- private val exceptions: ByteArray
-) {
-
- private fun binarySearchRules(labels: List<ByteArray>, labelIndex: Int): String? {
- return rules.binarySearch(labels, labelIndex)
- }
-
- private fun binarySearchExceptions(labels: List<ByteArray>, labelIndex: Int): String? {
- return exceptions.binarySearch(labels, labelIndex)
- }
-
- @Suppress("ReturnCount")
- fun getPublicSuffixOffset(domain: String): PublicSuffixOffset? {
- if (domain.isEmpty()) {
- return null
- }
-
- val domainLabels = IDN.toUnicode(domain).split('.')
- if (domainLabels.find { it.isEmpty() } != null) {
- // At least one of the labels is empty: Bail out.
- return null
- }
-
- val rule = findMatchingRule(domainLabels)
-
- if (domainLabels.size == rule.size && rule[0][0] != PublicSuffixListData.EXCEPTION_MARKER) {
- // The domain is a public suffix.
- return if (rule == PublicSuffixListData.PREVAILING_RULE) {
- PublicSuffixOffset.PrevailingRule
- } else {
- PublicSuffixOffset.PublicSuffix
- }
- }
-
- return if (rule[0][0] == PublicSuffixListData.EXCEPTION_MARKER) {
- // Exception rules hold the effective TLD plus one.
- PublicSuffixOffset.Offset(domainLabels.size - rule.size)
- } else {
- // Otherwise the rule is for a public suffix, so we must take one more label.
- PublicSuffixOffset.Offset(domainLabels.size - (rule.size + 1))
- }
- }
-
- /**
- * Find a matching rule for the given domain labels.
- *
- * This algorithm is based on OkHttp's PublicSuffixDatabase class:
- * https://github.com/square/okhttp/blob/master/okhttp/src/main/java/okhttp3/internal/publicsuffix/PublicSuffixDatabase.java
- */
- private fun findMatchingRule(domainLabels: List<String>): List<String> {
- // Break apart the domain into UTF-8 labels, i.e. foo.bar.com turns into [foo, bar, com].
- val domainLabelsBytes = domainLabels.map { it.toByteArray(Charsets.UTF_8) }
-
- val exactMatch = findExactMatch(domainLabelsBytes)
- val wildcardMatch = findWildcardMatch(domainLabelsBytes)
- val exceptionMatch = findExceptionMatch(domainLabelsBytes, wildcardMatch)
-
- if (exceptionMatch != null) {
- return ("${PublicSuffixListData.EXCEPTION_MARKER}$exceptionMatch").split('.')
- }
-
- if (exactMatch == null && wildcardMatch == null) {
- return PublicSuffixListData.PREVAILING_RULE
- }
-
- val exactRuleLabels = exactMatch?.split('.') ?: PublicSuffixListData.EMPTY_RULE
- val wildcardRuleLabels = wildcardMatch?.split('.') ?: PublicSuffixListData.EMPTY_RULE
-
- return if (exactRuleLabels.size > wildcardRuleLabels.size) {
- exactRuleLabels
- } else {
- wildcardRuleLabels
- }
- }
-
- /**
- * Returns an exact match or null.
- */
- private fun findExactMatch(labels: List<ByteArray>): String? {
- // Start by looking for exact matches. We start at the leftmost label. For example, foo.bar.com
- // will look like: [foo, bar, com], [bar, com], [com]. The longest matching rule wins.
-
- for (i in 0 until labels.size) {
- val rule = binarySearchRules(labels, i)
-
- if (rule != null) {
- return rule
- }
- }
-
- return null
- }
-
- /**
- * Returns a wildcard match or null.
- */
- private fun findWildcardMatch(labels: List<ByteArray>): String? {
- // In theory, wildcard rules are not restricted to having the wildcard in the leftmost position.
- // In practice, wildcards are always in the leftmost position. For now, this implementation
- // cheats and does not attempt every possible permutation. Instead, it only considers wildcards
- // in the leftmost position. We assert this fact when we generate the public suffix file. If
- // this assertion ever fails we'll need to refactor this implementation.
- if (labels.size > 1) {
- val labelsWithWildcard = labels.toMutableList()
- for (labelIndex in 0 until labelsWithWildcard.size) {
- labelsWithWildcard[labelIndex] = PublicSuffixListData.WILDCARD_LABEL
- val rule = binarySearchRules(labelsWithWildcard, labelIndex)
- if (rule != null) {
- return rule
- }
- }
- }
-
- return null
- }
-
- private fun findExceptionMatch(labels: List<ByteArray>, wildcardMatch: String?): String? {
- // Exception rules only apply to wildcard rules, so only try it if we matched a wildcard.
- if (wildcardMatch == null) {
- return null
- }
-
- for (labelIndex in 0 until labels.size) {
- val rule = binarySearchExceptions(labels, labelIndex)
- if (rule != null) {
- return rule
- }
- }
-
- return null
- }
-
- companion object {
-
- val WILDCARD_LABEL = byteArrayOf('*'.toByte())
- val PREVAILING_RULE = listOf("*")
- val EMPTY_RULE = listOf<String>()
- const val EXCEPTION_MARKER = '!'
- }
-}
-
-internal sealed class PublicSuffixOffset {
- data class Offset(val value: Int) : PublicSuffixOffset()
- object PublicSuffix : PublicSuffixOffset()
- object PrevailingRule : PublicSuffixOffset()
-}
diff --git a/app/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListLoader.kt b/app/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListLoader.kt
deleted file mode 100644
index 65caeae5..00000000
--- a/app/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListLoader.kt
+++ /dev/null
@@ -1,52 +0,0 @@
-/*
- * SPDX-License-Identifier: GPL-3.0-only OR MPL-2.0
- */
-/* This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this
- * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
-
-package mozilla.components.lib.publicsuffixlist
-
-import android.content.Context
-import java.io.BufferedInputStream
-import java.io.IOException
-
-private const val PUBLIC_SUFFIX_LIST_FILE = "publicsuffixes"
-
-internal object PublicSuffixListLoader {
-
- fun load(context: Context): PublicSuffixListData = context.assets.open(
- PUBLIC_SUFFIX_LIST_FILE
- ).buffered().use { stream ->
- val publicSuffixSize = stream.readInt()
- val publicSuffixBytes = stream.readFully(publicSuffixSize)
-
- val exceptionSize = stream.readInt()
- val exceptionBytes = stream.readFully(exceptionSize)
-
- PublicSuffixListData(publicSuffixBytes, exceptionBytes)
- }
-}
-
-@Suppress("MagicNumber")
-private fun BufferedInputStream.readInt(): Int {
- return (read() and 0xff shl 24
- or (read() and 0xff shl 16)
- or (read() and 0xff shl 8)
- or (read() and 0xff))
-}
-
-private fun BufferedInputStream.readFully(size: Int): ByteArray {
- val bytes = ByteArray(size)
-
- var offset = 0
- while (offset < size) {
- val read = read(bytes, offset, size - offset)
- if (read == -1) {
- throw IOException("Unexpected end of stream")
- }
- offset += read
- }
-
- return bytes
-}
diff --git a/app/src/main/java/mozilla/components/lib/publicsuffixlist/ext/ByteArray.kt b/app/src/main/java/mozilla/components/lib/publicsuffixlist/ext/ByteArray.kt
deleted file mode 100644
index 43fb7ab1..00000000
--- a/app/src/main/java/mozilla/components/lib/publicsuffixlist/ext/ByteArray.kt
+++ /dev/null
@@ -1,125 +0,0 @@
-/*
- * SPDX-License-Identifier: GPL-3.0-only OR MPL-2.0
- */
-/* This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this
- * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
-
-package mozilla.components.lib.publicsuffixlist.ext
-
-import kotlin.experimental.and
-
-private const val BITMASK = 0xff.toByte()
-
-/**
- * Performs a binary search for the provided [labels] on the [ByteArray]'s data.
- *
- * This algorithm is based on OkHttp's PublicSuffixDatabase class:
- * https://github.com/square/okhttp/blob/1977136/okhttp/src/main/kotlin/okhttp3/internal/publicsuffix/PublicSuffixDatabase.kt
- */
-@Suppress("ComplexMethod", "NestedBlockDepth")
-internal fun ByteArray.binarySearch(labels: List<ByteArray>, labelIndex: Int): String? {
- var low = 0
- var high = size
- var match: String? = null
-
- while (low < high) {
- val mid = (low + high) / 2
- val start = findStartOfLineFromIndex(mid)
- val end = findEndOfLineFromIndex(start)
-
- val publicSuffixLength = start + end - start
-
- var compareResult: Int
- var currentLabelIndex = labelIndex
- var currentLabelByteIndex = 0
- var publicSuffixByteIndex = 0
-
- var expectDot = false
- while (true) {
- val byte0 = if (expectDot) {
- expectDot = false
- '.'.toByte()
- } else {
- labels[currentLabelIndex][currentLabelByteIndex] and BITMASK
- }
-
- val byte1 = this[start + publicSuffixByteIndex] and BITMASK
-
- // Compare the bytes. Note that the file stores UTF-8 encoded bytes, so we must compare the
- // unsigned bytes.
- @Suppress("EXPERIMENTAL_API_USAGE")
- compareResult = (byte0.toUByte() - byte1.toUByte()).toInt()
- if (compareResult != 0) {
- break
- }
-
- publicSuffixByteIndex++
- currentLabelByteIndex++
-
- if (publicSuffixByteIndex == publicSuffixLength) {
- break
- }
-
- if (labels[currentLabelIndex].size == currentLabelByteIndex) {
- // We've exhausted our current label. Either there are more labels to compare, in which
- // case we expect a dot as the next character. Otherwise, we've checked all our labels.
- if (currentLabelIndex == labels.size - 1) {
- break
- } else {
- currentLabelIndex++
- currentLabelByteIndex = -1
- expectDot = true
- }
- }
- }
-
- if (compareResult < 0) {
- high = start - 1
- } else if (compareResult > 0) {
- low = start + end + 1
- } else {
- // We found a match, but are the lengths equal?
- val publicSuffixBytesLeft = publicSuffixLength - publicSuffixByteIndex
- var labelBytesLeft = labels[currentLabelIndex].size - currentLabelByteIndex
- for (i in currentLabelIndex + 1 until labels.size) {
- labelBytesLeft += labels[i].size
- }
-
- if (labelBytesLeft < publicSuffixBytesLeft) {
- high = start - 1
- } else if (labelBytesLeft > publicSuffixBytesLeft) {
- low = start + end + 1
- } else {
- // Found a match.
- match = String(this, start, publicSuffixLength, Charsets.UTF_8)
- break
- }
- }
- }
-
- return match
-}
-
-/**
- * Search for a '\n' that marks the start of a value. Don't go back past the start of the array.
- */
-private fun ByteArray.findStartOfLineFromIndex(start: Int): Int {
- var index = start
- while (index > -1 && this[index] != '\n'.toByte()) {
- index--
- }
- index++
- return index
-}
-
-/**
- * Search for a '\n' that marks the end of a value.
- */
-private fun ByteArray.findEndOfLineFromIndex(start: Int): Int {
- var end = 1
- while (this[start + end] != '\n'.toByte()) {
- end++
- }
- return end
-}