aboutsummaryrefslogtreecommitdiff
path: root/app/src/main/java/com/zeapo
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main/java/com/zeapo')
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/PasswordEntry.kt31
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt12
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/UserPreference.kt126
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt178
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillMatcher.kt178
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillScenario.kt275
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategy.kt179
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategyDsl.kt328
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FeatureAndTrustDetection.kt199
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/Form.kt354
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FormField.kt240
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt110
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/PublicSuffixListCache.kt39
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt238
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt196
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillPublisherChangedActivity.kt96
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt139
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/PasswordViewHolder.kt15
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/crypto/PgpActivity.kt98
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt7
20 files changed, 2983 insertions, 55 deletions
diff --git a/app/src/main/java/com/zeapo/pwdstore/PasswordEntry.kt b/app/src/main/java/com/zeapo/pwdstore/PasswordEntry.kt
index e692c5af..c75fb02d 100644
--- a/app/src/main/java/com/zeapo/pwdstore/PasswordEntry.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/PasswordEntry.kt
@@ -20,7 +20,7 @@ class PasswordEntry(private val content: String) {
val totpAlgorithm: String
val hotpSecret: String?
val hotpCounter: Long?
- var extraContent: String? = null
+ var extraContent: String
private set
private var isIncremented = false
@@ -41,7 +41,7 @@ class PasswordEntry(private val content: String) {
}
fun hasExtraContent(): Boolean {
- return !extraContent.isNullOrEmpty()
+ return extraContent.isNotEmpty()
}
fun hasUsername(): Boolean {
@@ -63,19 +63,30 @@ class PasswordEntry(private val content: String) {
fun incrementHotp() {
content.split("\n".toRegex()).forEach { line ->
if (line.startsWith("otpauth://hotp/")) {
- extraContent = extraContent?.replaceFirst("counter=[0-9]+".toRegex(), "counter=${hotpCounter!! + 1}")
+ extraContent = extraContent.replaceFirst("counter=[0-9]+".toRegex(), "counter=${hotpCounter!! + 1}")
isIncremented = true
}
}
}
+ val extraContentWithoutUsername by lazy {
+ var usernameFound = false
+ extraContent.splitToSequence("\n").filter { line ->
+ if (usernameFound)
+ return@filter true
+ if (USERNAME_FIELDS.any { prefix -> line.startsWith(prefix, ignoreCase = true) }) {
+ usernameFound = true
+ return@filter false
+ }
+ true
+ }.joinToString(separator = "\n")
+ }
+
private fun findUsername(): String? {
- val extraLines = extraContent!!.split("\n".toRegex())
- for (line in extraLines) {
- for (field in USERNAME_FIELDS) {
- if (line.toLowerCase().startsWith("$field:", ignoreCase = true)) {
- return line.split(": *".toRegex(), 2).toTypedArray()[1]
- }
+ extraContent.splitToSequence("\n").forEach { line ->
+ for (prefix in USERNAME_FIELDS) {
+ if (line.startsWith(prefix, ignoreCase = true))
+ return line.substring(prefix.length).trimStart()
}
}
return null
@@ -152,6 +163,6 @@ class PasswordEntry(private val content: String) {
companion object {
- private val USERNAME_FIELDS = arrayOf("login", "username")
+ private val USERNAME_FIELDS = arrayOf("login:", "username:")
}
}
diff --git a/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt b/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt
index e4c94a56..4744b259 100644
--- a/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt
@@ -32,6 +32,7 @@ import androidx.fragment.app.FragmentManager
import androidx.preference.PreferenceManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
+import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher
import com.zeapo.pwdstore.crypto.PgpActivity
import com.zeapo.pwdstore.crypto.PgpActivity.Companion.getLongName
import com.zeapo.pwdstore.git.GitActivity
@@ -650,10 +651,21 @@ class PasswordStore : AppCompatActivity() {
.setPositiveButton("Okay", null)
.show()
}
+ val sourceDestinationMap = if (source.isDirectory) {
+ check(destinationFile.isDirectory) { "Moving a directory to a file" }
+ // Recursively list all files (not directories) below `source`, then
+ // obtain the corresponding target file by resolving the relative path
+ // starting at the destination folder.
+ val sourceFiles = FileUtils.listFiles(source, null, true)
+ sourceFiles.associateWith { destinationFile.resolve(it.relativeTo(source)) }
+ } else {
+ mapOf(source to destinationFile)
+ }
if (!source.renameTo(destinationFile)) {
// TODO this should show a warning to the user
Timber.tag(TAG).e("Something went wrong while moving.")
} else {
+ AutofillMatcher.updateMatchesFor(this, sourceDestinationMap)
commitChange(this.resources
.getString(
R.string.git_commit_move_text,
diff --git a/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt b/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt
index 8667110a..895338e9 100644
--- a/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt
@@ -6,7 +6,6 @@ package com.zeapo.pwdstore
import android.accessibilityservice.AccessibilityServiceInfo
import android.app.Activity
-import android.content.Context
import android.content.Intent
import android.content.pm.ShortcutManager
import android.net.Uri
@@ -20,6 +19,7 @@ import android.view.MenuItem
import android.view.accessibility.AccessibilityManager
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.widget.AppCompatTextView
import androidx.biometric.BiometricManager
import androidx.core.content.getSystemService
import androidx.documentfile.provider.DocumentFile
@@ -32,6 +32,8 @@ import androidx.preference.SwitchPreferenceCompat
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
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.PgpActivity
import com.zeapo.pwdstore.git.GitActivity
import com.zeapo.pwdstore.pwgenxkpwd.XkpwdDictionary
@@ -40,6 +42,7 @@ import com.zeapo.pwdstore.sshkeygen.SshKeyGenActivity
import com.zeapo.pwdstore.utils.PasswordRepository
import com.zeapo.pwdstore.utils.auth.AuthenticationResult
import com.zeapo.pwdstore.utils.auth.Authenticator
+import com.zeapo.pwdstore.utils.autofillManager
import java.io.File
import java.io.IOException
import java.time.LocalDateTime
@@ -127,9 +130,7 @@ class UserPreference : AppCompatActivity() {
openkeystoreIdPreference?.isVisible = sharedPreferences.getString("ssh_openkeystore_keyid", null)?.isNotEmpty()
?: false
- // see if the autofill service is enabled and check the preference accordingly
- autoFillEnablePreference?.isChecked = callingActivity.isServiceEnabled
- autofillDependencies.forEach { it?.isVisible = callingActivity.isServiceEnabled }
+ updateAutofillSettings()
appVersionPreference?.summary = "Version: ${BuildConfig.VERSION_NAME}"
@@ -242,24 +243,7 @@ class UserPreference : AppCompatActivity() {
}
autoFillEnablePreference?.onPreferenceClickListener = ClickListener {
- var isEnabled = callingActivity.isServiceEnabled
- if (isEnabled) {
- startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
- } else {
- MaterialAlertDialogBuilder(callingActivity)
- .setTitle(R.string.pref_autofill_enable_title)
- .setView(R.layout.autofill_instructions)
- .setPositiveButton(R.string.dialog_ok) { _, _ ->
- startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
- }
- .setNegativeButton(R.string.dialog_cancel, null)
- .setOnDismissListener {
- isEnabled = callingActivity.isServiceEnabled
- autoFillEnablePreference?.isChecked = isEnabled
- autofillDependencies.forEach { it?.isVisible = isEnabled }
- }
- .show()
- }
+ onEnableAutofillClick()
true
}
@@ -370,11 +354,79 @@ class UserPreference : AppCompatActivity() {
}
}
+ private fun updateAutofillSettings() {
+ val isAccessibilityServiceEnabled = callingActivity.isAccessibilityServiceEnabled
+ autoFillEnablePreference?.isChecked =
+ isAccessibilityServiceEnabled || callingActivity.isAutofillServiceEnabled
+ autofillDependencies.forEach {
+ it?.isVisible = isAccessibilityServiceEnabled
+ }
+ }
+
+ private fun onEnableAutofillClick() {
+ if (callingActivity.isAccessibilityServiceEnabled) {
+ startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
+ } else if (callingActivity.isAutofillServiceEnabled) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
+ callingActivity.autofillManager!!.disableAutofillServices()
+ else
+ throw IllegalStateException("isAutofillServiceEnabled == true, but Build.VERSION.SDK_INT < Build.VERSION_CODES.O")
+ } else {
+ val enableOreoAutofill = callingActivity.isAutofillServiceSupported
+ MaterialAlertDialogBuilder(callingActivity).run {
+ setTitle(R.string.pref_autofill_enable_title)
+ if (enableOreoAutofill && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val layout =
+ layoutInflater.inflate(R.layout.oreo_autofill_instructions, null)
+ val supportedBrowsersTextView =
+ layout.findViewById<AppCompatTextView>(R.id.supportedBrowsers)
+ supportedBrowsersTextView.text =
+ getInstalledBrowsersWithAutofillSupportLevel(context).joinToString(
+ separator = "\n"
+ ) {
+ val appLabel = it.first
+ val supportDescription = when (it.second) {
+ BrowserAutofillSupportLevel.None -> getString(R.string.oreo_autofill_no_support)
+ BrowserAutofillSupportLevel.FlakyFill -> getString(R.string.oreo_autofill_flaky_fill_support)
+ BrowserAutofillSupportLevel.Fill -> getString(R.string.oreo_autofill_fill_support)
+ BrowserAutofillSupportLevel.FillAndSave -> getString(R.string.oreo_autofill_fill_and_save_support)
+ }
+ "$appLabel: $supportDescription"
+ }
+ setView(layout)
+ } else {
+ setView(R.layout.autofill_instructions)
+ }
+ setPositiveButton(R.string.dialog_ok) { _, _ ->
+ val intent =
+ if (enableOreoAutofill && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE).apply {
+ data = Uri.parse("package:${BuildConfig.APPLICATION_ID}")
+ }
+ } else {
+ Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)
+ }
+ startActivity(intent)
+ }
+ setNegativeButton(R.string.dialog_cancel, null)
+ setOnDismissListener {
+ val isEnabled =
+ if (enableOreoAutofill && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ callingActivity.isAutofillServiceEnabled
+ } else {
+ callingActivity.isAccessibilityServiceEnabled
+ }
+ autoFillEnablePreference?.isChecked = isEnabled
+ autofillDependencies.forEach { it?.isVisible = isEnabled }
+ }
+ show()
+ }
+ }
+ }
+
override fun onResume() {
super.onResume()
- val isEnabled = callingActivity.isServiceEnabled
- autoFillEnablePreference?.isChecked = isEnabled
- autofillDependencies.forEach { it?.isVisible = isEnabled }
+ updateAutofillSettings()
}
}
@@ -487,16 +539,26 @@ class UserPreference : AppCompatActivity() {
}
}
- // Returns whether the autofill service is enabled
- private val isServiceEnabled: Boolean
+ private val isAccessibilityServiceEnabled: Boolean
get() {
- val am = this
- .getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
+ val am = getSystemService(AccessibilityManager::class.java)
val runningServices = am
- .getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_GENERIC)
+ .getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_GENERIC)
return runningServices
- .map { it.id.substringBefore("/") }
- .any { it == BuildConfig.APPLICATION_ID }
+ .map { it.id.substringBefore("/") }
+ .any { it == BuildConfig.APPLICATION_ID }
+ }
+
+ private val isAutofillServiceSupported: Boolean
+ get() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
+ return autofillManager?.isAutofillSupported != null
+ }
+
+ private val isAutofillServiceEnabled: Boolean
+ get() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
+ return autofillManager?.hasEnabledAutofillServices() == true
}
override fun onActivityResult(
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
new file mode 100644
index 00000000..4b7b47e2
--- /dev/null
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt
@@ -0,0 +1,178 @@
+/*
+ * 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.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.PasswordEntry
+import com.zeapo.pwdstore.R
+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 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 {
+ 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) {
+ companion object {
+ fun fromStoreEntry(file: File, entry: PasswordEntry): Credentials {
+ return if (entry.hasUsername()) Credentials(entry.username, entry.password)
+ else Credentials(file.nameWithoutExtension, entry.password)
+ }
+ }
+}
+
+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 summary = file.nameWithoutExtension
+ 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 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
new file mode 100644
index 00000000..5f9c081d
--- /dev/null
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillMatcher.kt
@@ -0,0 +1,178 @@
+/*
+ * 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.SharedPreferences
+import android.widget.Toast
+import androidx.core.content.edit
+import com.github.ajalt.timberkt.Timber.e
+import com.github.ajalt.timberkt.d
+import com.github.ajalt.timberkt.w
+import com.zeapo.pwdstore.R
+import java.io.File
+
+private const val PREFERENCES_AUTOFILL_APP_MATCHES = "oreo_autofill_app_matches"
+private val Context.autofillAppMatches
+ get() = getSharedPreferences(PREFERENCES_AUTOFILL_APP_MATCHES, Context.MODE_PRIVATE)
+
+private const val PREFERENCES_AUTOFILL_WEB_MATCHES = "oreo_autofill_web_matches"
+private val Context.autofillWebMatches
+ get() = getSharedPreferences(PREFERENCES_AUTOFILL_WEB_MATCHES, Context.MODE_PRIVATE)
+
+private fun Context.matchPreferences(formOrigin: FormOrigin): SharedPreferences {
+ return when (formOrigin) {
+ is FormOrigin.App -> autofillAppMatches
+ is FormOrigin.Web -> autofillWebMatches
+ }
+}
+
+class AutofillPublisherChangedException(val formOrigin: FormOrigin) :
+ Exception("The publisher of '${formOrigin.identifier}' changed since an entry was first matched with this app") {
+ init {
+ require(formOrigin is FormOrigin.App)
+ }
+}
+
+/**
+ * Manages "matches", i.e., associations between apps or websites and Password Store entries.
+ */
+class AutofillMatcher {
+ companion object {
+ private const val MAX_NUM_MATCHES = 10
+
+ private const val PREFERENCE_PREFIX_TOKEN = "token;"
+ private fun tokenKey(formOrigin: FormOrigin.App) =
+ "$PREFERENCE_PREFIX_TOKEN${formOrigin.identifier}"
+
+ private const val PREFERENCE_PREFIX_MATCHES = "matches;"
+ private fun matchesKey(formOrigin: FormOrigin) =
+ "$PREFERENCE_PREFIX_MATCHES${formOrigin.identifier}"
+
+ private fun hasFormOriginHashChanged(context: Context, formOrigin: FormOrigin): Boolean {
+ return when (formOrigin) {
+ is FormOrigin.Web -> false
+ is FormOrigin.App -> {
+ val packageName = formOrigin.identifier
+ val certificatesHash = computeCertificatesHash(context, packageName)
+ val storedCertificatesHash =
+ context.autofillAppMatches.getString(tokenKey(formOrigin), null)
+ ?: return false
+ val hashHasChanged = certificatesHash != storedCertificatesHash
+ if (hashHasChanged) {
+ e { "$packageName: stored=$storedCertificatesHash, new=$certificatesHash" }
+ true
+ } else {
+ false
+ }
+ }
+ }
+ }
+
+ private fun storeFormOriginHash(context: Context, formOrigin: FormOrigin) {
+ if (formOrigin is FormOrigin.App) {
+ val packageName = formOrigin.identifier
+ val certificatesHash = computeCertificatesHash(context, packageName)
+ context.autofillAppMatches.edit {
+ putString(tokenKey(formOrigin), certificatesHash)
+ }
+ }
+ // We don't need to store a hash for FormOrigin.Web since it can only originate from
+ // browsers we trust to verify the origin.
+ }
+
+ /**
+ * Get all Password Store entries that have already been associated with [formOrigin] by the
+ * user.
+ *
+ * If [formOrigin] represents an app and that app's certificates have changed since the
+ * first time the user associated an entry with it, an [AutofillPublisherChangedException]
+ * will be thrown.
+ */
+ fun getMatchesFor(context: Context, formOrigin: FormOrigin): List<File> {
+ if (hasFormOriginHashChanged(context, formOrigin)) {
+ throw AutofillPublisherChangedException(formOrigin)
+ }
+ val matchPreferences = context.matchPreferences(formOrigin)
+ val matchedFiles =
+ matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) }
+ return matchedFiles.filter { it.exists() }.also { validFiles ->
+ matchPreferences.edit {
+ putStringSet(matchesKey(formOrigin), validFiles.map { it.absolutePath }.toSet())
+ }
+ }
+ }
+
+ fun clearMatchesFor(context: Context, formOrigin: FormOrigin) {
+ context.matchPreferences(formOrigin).edit {
+ remove(matchesKey(formOrigin))
+ if (formOrigin is FormOrigin.App) remove(tokenKey(formOrigin))
+ }
+ }
+
+ /**
+ * Associates the store entry [file] with [formOrigin], such that future Autofill responses
+ * to requests from this app or website offer this entry as a dataset.
+ *
+ * The maximum number of matches is limited by [MAX_NUM_MATCHES] since older versions of
+ * Android may crash when too many datasets are offered.
+ */
+ fun addMatchFor(context: Context, formOrigin: FormOrigin, file: File) {
+ if (!file.exists()) return
+ if (hasFormOriginHashChanged(context, formOrigin)) {
+ // This should never happen since we already verified the publisher in
+ // getMatchesFor.
+ e { "App publisher changed between getMatchesFor and addMatchFor" }
+ throw AutofillPublisherChangedException(formOrigin)
+ }
+ val matchPreferences = context.matchPreferences(formOrigin)
+ val matchedFiles =
+ matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) }
+ val newFiles = setOf(file.absoluteFile).union(matchedFiles)
+ if (newFiles.size > MAX_NUM_MATCHES) {
+ Toast.makeText(
+ context,
+ context.getString(R.string.oreo_autofill_max_matches_reached, MAX_NUM_MATCHES),
+ Toast.LENGTH_LONG
+ ).show()
+ return
+ }
+ matchPreferences.edit {
+ putStringSet(matchesKey(formOrigin), newFiles.map { it.absolutePath }.toSet())
+ }
+ storeFormOriginHash(context, formOrigin)
+ d { "Stored match for $formOrigin" }
+ }
+
+ /**
+ * Goes through all existing matches and updates their associated entries by using
+ * [sourceDestinationMap] as a lookup table.
+ */
+ fun updateMatchesFor(context: Context, sourceDestinationMap: Map<File, File>) {
+ val oldNewPathMap = sourceDestinationMap.mapValues { it.value.absolutePath }
+ .mapKeys { it.key.absolutePath }
+ for (prefs in listOf(context.autofillAppMatches, context.autofillWebMatches)) {
+ for ((key, value) in prefs.all) {
+ if (!key.startsWith(PREFERENCE_PREFIX_MATCHES)) continue
+ val oldMatches = value as? Set<String>
+ if (oldMatches == null) {
+ w { "Failed to read matches for $key" }
+ continue
+ }
+ // Delete all matches for file locations that are going to be overwritten, then
+ // transfer matches over to the files at their new locations.
+ val newMatches =
+ oldMatches.asSequence().minus(oldNewPathMap.values).map { match ->
+ val newPath = oldNewPathMap[match] ?: return@map match
+ d { "Updating match for $key: $match --> $newPath" }
+ newPath
+ }.toSet()
+ if (newMatches != oldMatches)
+ prefs.edit { putStringSet(key, newMatches) }
+ }
+ }
+ }
+ }
+}
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
new file mode 100644
index 00000000..0536c507
--- /dev/null
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillScenario.kt
@@ -0,0 +1,275 @@
+/*
+ * 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
+
+enum class AutofillAction {
+ Match, Search, Generate
+}
+
+/**
+ * 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_CURRENT_PASSWORD_IDS = "currentPasswordIds"
+ const val BUNDLE_KEY_NEW_PASSWORD_IDS = "newPasswordIds"
+ const val BUNDLE_KEY_GENERIC_PASSWORD_IDS = "genericPasswordIds"
+
+ fun fromBundle(clientState: Bundle): AutofillScenario<AutofillId>? {
+ return try {
+ Builder<AutofillId>().apply {
+ username = clientState.getParcelable(BUNDLE_KEY_USERNAME_ID)
+ fillUsername = clientState.getBoolean(BUNDLE_KEY_FILL_USERNAME)
+ currentPassword.addAll(
+ clientState.getParcelableArrayList(
+ BUNDLE_KEY_CURRENT_PASSWORD_IDS
+ ) ?: emptyList()
+ )
+ newPassword.addAll(
+ clientState.getParcelableArrayList(
+ BUNDLE_KEY_NEW_PASSWORD_IDS
+ ) ?: emptyList()
+ )
+ genericPassword.addAll(
+ clientState.getParcelableArrayList(
+ BUNDLE_KEY_GENERIC_PASSWORD_IDS
+ ) ?: emptyList()
+ )
+ }.build()
+ } catch (exception: IllegalArgumentException) {
+ e(exception)
+ null
+ }
+ }
+ }
+
+ class Builder<T : Any> {
+ var username: T? = null
+ var fillUsername = false
+ 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,
+ currentPassword = currentPassword,
+ newPassword = newPassword
+ )
+ } else {
+ GenericAutofillScenario(
+ username = username,
+ fillUsername = fillUsername,
+ genericPassword = genericPassword
+ )
+ }
+ }
+ }
+
+ abstract val username: T?
+ abstract val fillUsername: Boolean
+ 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) + allPasswordFields
+
+ fun fieldsToFillOn(action: AutofillAction): List<T> {
+ val passwordFieldsToFill = when (action) {
+ AutofillAction.Match -> passwordFieldsToFillOnMatch
+ AutofillAction.Search -> passwordFieldsToFillOnSearch
+ AutofillAction.Generate -> passwordFieldsToFillOnGenerate
+ }
+ return when {
+ passwordFieldsToFill.isNotEmpty() -> {
+ // If the current action would fill into any password field, we also fill into the
+ // username field if possible.
+ listOfNotNull(username.takeIf { fillUsername }) + passwordFieldsToFill
+ }
+ 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,
+ 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,
+ 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"
+ )
+ for (field in scenario.fieldsToFillOn(action)) {
+ val value = if (field == scenario.username) {
+ credentialsToFill.username
+ } else {
+ credentialsToFill.password
+ } ?: continue
+ 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
+ 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(4).apply {
+ putParcelable(AutofillScenario.BUNDLE_KEY_USERNAME_ID, username)
+ putBoolean(AutofillScenario.BUNDLE_KEY_FILL_USERNAME, fillUsername)
+ putParcelableArrayList(
+ AutofillScenario.BUNDLE_KEY_CURRENT_PASSWORD_IDS, ArrayList(currentPassword)
+ )
+ putParcelableArrayList(
+ AutofillScenario.BUNDLE_KEY_NEW_PASSWORD_IDS, ArrayList(newPassword)
+ )
+ }
+ }
+ is GenericAutofillScenario<AutofillId> -> {
+ Bundle(3).apply {
+ putParcelable(AutofillScenario.BUNDLE_KEY_USERNAME_ID, username)
+ putBoolean(AutofillScenario.BUNDLE_KEY_FILL_USERNAME, fillUsername)
+ 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
new file mode 100644
index 00000000..e1b157d5
--- /dev/null
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategy.kt
@@ -0,0 +1,179 @@
+/*
+ * 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 { hasAutocompleteHintNewPassword } }
+ breakTieOnPair { any { isFocused } }
+ }
+ currentPassword(optional = true) {
+ takeSingle { alreadyMatched ->
+ val adjacentToNewPasswords =
+ directlyPrecedes(alreadyMatched) || directlyFollows(alreadyMatched)
+ hasAutocompleteHintCurrentPassword && adjacentToNewPasswords
+ }
+ }
+ username(optional = true) {
+ takeSingle { hasAutocompleteHintUsername }
+ 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 { alreadyMatched ->
+ 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 { hasAutocompleteHintUsername }
+ 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()
+ breakTieOnSingle { 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()
+ breakTieOnSingle { 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 { hasAutocompleteHintNewPassword && 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 { hasAutocompleteHintUsername && isFocused }
+ }
+ currentPassword(matchHidden = true) {
+ takeSingle { alreadyMatched ->
+ directlyFollows(alreadyMatched.singleOrNull()) && couldBeTwoStepHiddenPassword
+ }
+ }
+ }
+
+ // Match a single focused username field without a password field.
+ rule(applyInSingleOriginMode = true) {
+ username {
+ takeSingle { usernameCertainty >= Likely && isFocused }
+ breakTieOnSingle { usernameCertainty >= Certain }
+ breakTieOnSingle { hasAutocompleteHintUsername }
+ }
+ }
+}
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
new file mode 100644
index 00000000..32ffaa2a
--- /dev/null
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategyDsl.kt
@@ -0,0 +1,328 @@
+/*
+ * 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 name: String
+) {
+
+ data class AutofillRuleMatcher(
+ val type: FillableFieldType,
+ val matcher: FieldMatcher,
+ val optional: Boolean,
+ val matchHidden: Boolean
+ )
+
+ enum class FillableFieldType {
+ Username, CurrentPassword, NewPassword, GenericPassword,
+ }
+
+ @AutofillDsl
+ class Builder(private val applyInSingleOriginMode: 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 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, name ?: "Rule #$ruleId"
+ ).also { ruleId++ }
+ }
+ }
+
+ fun apply(
+ allPassword: List<FormField>,
+ allUsername: List<FormField>,
+ singleOriginMode: Boolean
+ ): AutofillScenario<FormField>? {
+ if (singleOriginMode && !applyInSingleOriginMode) {
+ d { "$name: Skipped in single origin mode" }
+ 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
+ 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.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,
+ block: AutofillRule.Builder.() -> Unit
+ ) {
+ rules.add(AutofillRule.Builder(applyInSingleOriginMode).apply(block).build())
+ }
+
+ fun build() = AutofillStrategy(rules)
+ }
+
+ fun apply(fields: List<FormField>, multiOriginSupport: 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}" }
+ // Return the result of the first rule that matches
+ d { "Rules: ${rules.size}" }
+ for (rule in rules) {
+ return rule.apply(possiblePasswordFields, possibleUsernameFields, multiOriginSupport)
+ ?: 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/FeatureAndTrustDetection.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FeatureAndTrustDetection.kt
new file mode 100644
index 00000000..fdd862ad
--- /dev/null
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FeatureAndTrustDetection.kt
@@ -0,0 +1,199 @@
+/*
+ * 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.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="
+)
+
+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.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 saveFlag: Int?
+)
+
+@RequiresApi(Build.VERSION_CODES.O)
+fun getBrowserAutofillSupportInfoIfTrusted(
+ context: Context,
+ appPackage: String
+): BrowserAutofillSupportInfo? {
+ if (!isTrustedBrowser(context, appPackage)) return null
+ return BrowserAutofillSupportInfo(
+ multiOriginMethod = getBrowserMultiOriginMethod(appPackage),
+ saveFlag = getBrowserSaveFlag(appPackage)
+ )
+}
+
+private val FLAKY_BROWSERS = listOf(
+ "com.android.chrome"
+)
+
+enum class BrowserAutofillSupportLevel {
+ None,
+ FlakyFill,
+ Fill,
+ FillAndSave
+}
+
+@RequiresApi(Build.VERSION_CODES.O)
+private fun getBrowserAutofillSupportLevel(
+ context: Context,
+ appPackage: String
+): BrowserAutofillSupportLevel {
+ val browserInfo = getBrowserAutofillSupportInfoIfTrusted(context, appPackage)
+ return when {
+ browserInfo == null -> BrowserAutofillSupportLevel.None
+ browserInfo.saveFlag != null -> BrowserAutofillSupportLevel.FillAndSave
+ appPackage in FLAKY_BROWSERS -> BrowserAutofillSupportLevel.FlakyFill
+ else -> BrowserAutofillSupportLevel.Fill
+ }
+}
+
+@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
new file mode 100644
index 00000000..308391ec
--- /dev/null
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/Form.kt
@@ -0,0 +1,354 @@
+/*
+ * 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.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 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) {
+
+ 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 browserAutofillSupportInfo =
+ getBrowserAutofillSupportInfoIfTrusted(context, appPackage)
+ private val isTrustedBrowser = browserAutofillSupportInfo != null
+
+ private val browserMultiOriginMethod =
+ browserAutofillSupportInfo?.multiOriginMethod ?: BrowserMultiOriginMethod.None
+ private val singleOriginMode = browserMultiOriginMethod == BrowserMultiOriginMethod.None
+
+ val saveFlags = browserAutofillSupportInfo?.saveFlag
+
+ private val webOrigins = mutableSetOf<String>()
+
+ init {
+ d { "Request from $appPackage (${computeCertificatesHash(context, appPackage)})" }
+ parseStructure(structure)
+ }
+
+ val scenario = detectFieldsToFill()
+ 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 (browserMultiOriginMethod == 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() = autofillStrategy.apply(relevantFields, singleOriginMode)
+
+ private fun trackOrigin(node: AssistStructure.ViewNode) {
+ if (!isTrustedBrowser) 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 (!isTrustedBrowser || 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 (browserMultiOriginMethod) {
+ 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)
+ return Dataset.Builder(remoteView).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): FillableForm? {
+ val form = Form(context, structure)
+ 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 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)
+ }
+ 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) {
+ val matchedFiles = try {
+ AutofillMatcher.getMatchesFor(context, formOrigin)
+ } catch (publisherChangedException: AutofillPublisherChangedException) {
+ e(publisherChangedException)
+ callback.onSuccess(makePublisherChangedResponse(context, publisherChangedException))
+ return
+ }
+ callback.onSuccess(makeFillResponse(context, matchedFiles))
+ }
+}
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
new file mode 100644
index 00000000..cf2937f3
--- /dev/null
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FormField.kt
@@ -0,0 +1,240 @@
+/*
+ * 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 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 {
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ private val HINTS_USERNAME = listOf(View.AUTOFILL_HINT_USERNAME)
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ private val HINTS_PASSWORD = listOf(View.AUTOFILL_HINT_PASSWORD)
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ private val HINTS_FILLABLE = HINTS_USERNAME + HINTS_PASSWORD + listOf(
+ View.AUTOFILL_HINT_EMAIL_ADDRESS, View.AUTOFILL_HINT_NAME, View.AUTOFILL_HINT_PHONE
+ )
+
+ 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_FILLABLE =
+ HTML_INPUT_FIELD_TYPES_USERNAME + HTML_INPUT_FIELD_TYPES_PASSWORD
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ private fun isSupportedHint(hint: String) = hint in HINTS_USERNAME + HINTS_PASSWORD
+
+ 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"
+ )
+ private val PASSWORD_HEURISTIC_TERMS = listOf(
+ "password", "pwd", "pswd", "passwort"
+ )
+ private val USERNAME_HEURISTIC_TERMS = listOf(
+ "user", "name", "email"
+ )
+ }
+
+ 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 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()
+ private val hasAutofillHintPassword = autofillHints.intersect(HINTS_PASSWORD).isNotEmpty()
+ private val hasAutofillHintUsername = autofillHints.intersect(HINTS_USERNAME).isNotEmpty()
+
+ // W3C autocomplete hint detection for HTML fields
+ private val htmlAutocomplete = htmlAttributes["autocomplete"]
+
+ // Ignored for now, see excludedByHints
+ private val excludedByAutocompleteHint = htmlAutocomplete == "off"
+ val hasAutocompleteHintUsername = htmlAutocomplete == "username"
+ val hasAutocompleteHintCurrentPassword = htmlAutocomplete == "current-password"
+ val hasAutocompleteHintNewPassword = htmlAutocomplete == "new-password"
+ private val hasAutocompleteHintPassword =
+ hasAutocompleteHintCurrentPassword || hasAutocompleteHintNewPassword
+
+ // 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
+
+ val relevantField = isTextField && hasAutofillTypeText && !excludedByHints
+
+ // Exclude fields based on hint and resource ID
+ // 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.any { fieldId.contains(it) || hint.contains(it) }
+ private val notExcluded = relevantField && !hasExcludedTerm
+
+ // Password field heuristics (based only on the current field)
+ private val isPossiblePasswordField =
+ notExcluded && (isAndroidPasswordField || isHtmlPasswordField)
+ private val isCertainPasswordField =
+ isPossiblePasswordField && (isHtmlPasswordField || hasAutofillHintPassword || hasAutocompleteHintPassword)
+ private val isLikelyPasswordField = isPossiblePasswordField && (isCertainPasswordField || (PASSWORD_HEURISTIC_TERMS.any {
+ fieldId.contains(it) || hint.contains(it) || htmlName.contains(it)
+ }))
+ val passwordCertainty =
+ if (isCertainPasswordField) CertaintyLevel.Certain else if (isLikelyPasswordField) CertaintyLevel.Likely else if (isPossiblePasswordField) CertaintyLevel.Possible else CertaintyLevel.Impossible
+
+ // Username field heuristics (based only on the current field)
+ private val isPossibleUsernameField = notExcluded && !isPossiblePasswordField
+ private val isCertainUsernameField =
+ isPossibleUsernameField && (hasAutofillHintUsername || hasAutocompleteHintUsername)
+ private val isLikelyUsernameField = isPossibleUsernameField && (isCertainUsernameField || (USERNAME_HEURISTIC_TERMS.any {
+ fieldId.contains(it) || hint.contains(it) || htmlName.contains(it)
+ }))
+ 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 }.min() ?: 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 }.max() ?: 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"
+ return "$field ($description): password=$passwordCertainty, username=$usernameCertainty"
+ }
+
+ 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
new file mode 100644
index 00000000..00fa3aa4
--- /dev/null
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt
@@ -0,0 +1,110 @@
+/*
+ * 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 android.os.CancellationSignal
+import android.service.autofill.AutofillService
+import android.service.autofill.FillCallback
+import android.service.autofill.FillRequest
+import android.service.autofill.FillResponse
+import android.service.autofill.SaveCallback
+import android.service.autofill.SaveRequest
+import androidx.annotation.RequiresApi
+import com.github.ajalt.timberkt.d
+import com.github.ajalt.timberkt.e
+import com.zeapo.pwdstore.BuildConfig
+import com.zeapo.pwdstore.R
+import com.zeapo.pwdstore.autofill.oreo.ui.AutofillSaveActivity
+
+@RequiresApi(Build.VERSION_CODES.O)
+class OreoAutofillService : AutofillService() {
+
+ companion object {
+ // TODO: Provide a user-configurable denylist
+ private val DENYLISTED_PACKAGES = listOf(
+ BuildConfig.APPLICATION_ID,
+ "android",
+ "com.android.settings",
+ "com.android.settings.intelligence",
+ "com.android.systemui",
+ "com.oneplus.applocker",
+ "org.sufficientlysecure.keychain"
+ )
+
+ private const val DISABLE_AUTOFILL_DURATION_MS = 1000 * 60 * 60 * 24L
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ cachePublicSuffixList(applicationContext)
+ }
+
+ override fun onFillRequest(
+ request: FillRequest,
+ cancellationSignal: CancellationSignal,
+ callback: FillCallback
+ ) {
+ val structure = request.fillContexts.lastOrNull()?.structure ?: run {
+ callback.onSuccess(null)
+ return
+ }
+ if (structure.activityComponent.packageName in DENYLISTED_PACKAGES) {
+ if (Build.VERSION.SDK_INT >= 28) {
+ callback.onSuccess(FillResponse.Builder().run {
+ disableAutofill(DISABLE_AUTOFILL_DURATION_MS)
+ build()
+ })
+ } else {
+ callback.onSuccess(null)
+ }
+ return
+ }
+ val formToFill = FillableForm.parseAssistStructure(this, structure) ?: run {
+ d { "Form cannot be filled" }
+ callback.onSuccess(null)
+ return
+ }
+ formToFill.fillCredentials(this, callback)
+ }
+
+ override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {
+ // SaveCallback's behavior and feature set differs based on both target and device SDK, so
+ // we replace it with a wrapper that works the same in all situations.
+ @Suppress("NAME_SHADOWING") val callback = FixedSaveCallback(this, callback)
+ val structure = request.fillContexts.lastOrNull()?.structure ?: run {
+ callback.onFailure(getString(R.string.oreo_autofill_save_app_not_supported))
+ return
+ }
+ val clientState = request.clientState ?: run {
+ e { "Received save request without client state" }
+ callback.onFailure(getString(R.string.oreo_autofill_save_internal_error))
+ return
+ }
+ val scenario = AutofillScenario.fromBundle(clientState)?.recoverNodes(structure) ?: run {
+ e { "Failed to recover client state or nodes from client state" }
+ callback.onFailure(getString(R.string.oreo_autofill_save_internal_error))
+ return
+ }
+ val formOrigin = FormOrigin.fromBundle(clientState) ?: run {
+ e { "Failed to recover form origin from client state" }
+ callback.onFailure(getString(R.string.oreo_autofill_save_internal_error))
+ return
+ }
+
+ val username = scenario.usernameValue
+ val password = scenario.passwordValue ?: run {
+ callback.onFailure(getString(R.string.oreo_autofill_save_passwords_dont_match))
+ return
+ }
+ callback.onSuccess(
+ AutofillSaveActivity.makeSaveIntentSender(
+ this,
+ credentials = Credentials(username, password),
+ formOrigin = formOrigin
+ )
+ )
+ }
+}
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
new file mode 100644
index 00000000..c4f80f1a
--- /dev/null
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/PublicSuffixListCache.kt
@@ -0,0 +1,39 @@
+/*
+ * 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 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 {
+ PublicSuffixListCache.getOrCachePublicSuffixList(context).getPublicSuffixPlusOne(domain)
+ .await() ?: domain
+}
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
new file mode 100644
index 00000000..2f3824f9
--- /dev/null
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt
@@ -0,0 +1,238 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package com.zeapo.pwdstore.autofill.oreo.ui
+
+import android.app.Activity
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.IntentSender
+import android.os.Build
+import android.os.Bundle
+import android.view.autofill.AutofillManager
+import android.widget.Toast
+import androidx.annotation.RequiresApi
+import com.github.ajalt.timberkt.d
+import com.github.ajalt.timberkt.e
+import com.zeapo.pwdstore.PasswordEntry
+import com.zeapo.pwdstore.autofill.oreo.AutofillAction
+import com.zeapo.pwdstore.autofill.oreo.Credentials
+import com.zeapo.pwdstore.autofill.oreo.FillableForm
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.io.FileNotFoundException
+import java.io.InputStream
+import java.io.OutputStream
+import java.io.UnsupportedEncodingException
+import kotlin.coroutines.Continuation
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+import kotlin.coroutines.suspendCoroutine
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancelChildren
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import me.msfjarvis.openpgpktx.util.OpenPgpApi
+import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection
+import org.openintents.openpgp.IOpenPgpService2
+import org.openintents.openpgp.OpenPgpError
+
+@RequiresApi(Build.VERSION_CODES.O)
+class AutofillDecryptActivity : Activity(), CoroutineScope {
+
+ companion object {
+ private const val EXTRA_FILE_PATH = "com.zeapo.pwdstore.autofill.oreo.EXTRA_FILE_PATH"
+ private const val EXTRA_SEARCH_ACTION =
+ "com.zeapo.pwdstore.autofill.oreo.EXTRA_SEARCH_ACTION"
+ private const val REQUEST_CODE_CONTINUE_AFTER_USER_INTERACTION = 1
+ private const val OPENPGP_PROVIDER = "org.sufficientlysecure.keychain"
+
+ private var decryptFileRequestCode = 1
+
+ fun makeDecryptFileIntent(file: File, forwardedExtras: Bundle, context: Context): Intent {
+ return Intent(context, AutofillDecryptActivity::class.java).apply {
+ putExtras(forwardedExtras)
+ putExtra(EXTRA_SEARCH_ACTION, true)
+ putExtra(EXTRA_FILE_PATH, file.absolutePath)
+ }
+ }
+
+ fun makeDecryptFileIntentSender(file: File, context: Context): IntentSender {
+ val intent = Intent(context, AutofillDecryptActivity::class.java).apply {
+ putExtra(EXTRA_SEARCH_ACTION, false)
+ putExtra(EXTRA_FILE_PATH, file.absolutePath)
+ }
+ return PendingIntent.getActivity(
+ context,
+ decryptFileRequestCode++,
+ intent,
+ PendingIntent.FLAG_CANCEL_CURRENT
+ ).intentSender
+ }
+ }
+
+ private var continueAfterUserInteraction: Continuation<Intent>? = null
+
+ override val coroutineContext
+ get() = Dispatchers.IO + SupervisorJob()
+
+ override fun onStart() {
+ super.onStart()
+ val filePath = intent?.getStringExtra(EXTRA_FILE_PATH) ?: run {
+ e { "AutofillDecryptActivity started without EXTRA_FILE_PATH" }
+ finish()
+ return
+ }
+ val clientState = intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) ?: run {
+ e { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" }
+ finish()
+ return
+ }
+ val isSearchAction = intent?.getBooleanExtra(EXTRA_SEARCH_ACTION, true)!!
+ val action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match
+ d { action.toString() }
+ launch {
+ val credentials = decryptUsernameAndPassword(File(filePath))
+ if (credentials == null) {
+ setResult(RESULT_CANCELED)
+ } else {
+ val fillInDataset =
+ FillableForm.makeFillInDataset(
+ this@AutofillDecryptActivity,
+ credentials,
+ clientState,
+ action
+ )
+ withContext(Dispatchers.Main) {
+ setResult(RESULT_OK, Intent().apply {
+ putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset)
+ })
+ }
+ }
+ withContext(Dispatchers.Main) {
+ finish()
+ }
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ coroutineContext.cancelChildren()
+ }
+
+ private suspend fun executeOpenPgpApi(
+ data: Intent,
+ input: InputStream,
+ output: OutputStream
+ ): Intent? {
+ var openPgpServiceConnection: OpenPgpServiceConnection? = null
+ val openPgpService = suspendCoroutine<IOpenPgpService2> { cont ->
+ openPgpServiceConnection = OpenPgpServiceConnection(
+ this,
+ OPENPGP_PROVIDER,
+ object : OpenPgpServiceConnection.OnBound {
+ override fun onBound(service: IOpenPgpService2) {
+ cont.resume(service)
+ }
+
+ override fun onError(e: Exception) {
+ cont.resumeWithException(e)
+ }
+ }).also { it.bindToService() }
+ }
+ return OpenPgpApi(this, openPgpService).executeApi(data, input, output).also {
+ openPgpServiceConnection?.unbindFromService()
+ }
+ }
+
+ private suspend fun decryptUsernameAndPassword(
+ file: File,
+ resumeIntent: Intent? = null
+ ): Credentials? {
+ val command = resumeIntent ?: Intent().apply {
+ action = OpenPgpApi.ACTION_DECRYPT_VERIFY
+ }
+ val encryptedInput = try {
+ file.inputStream()
+ } catch (e: FileNotFoundException) {
+ e(e) { "File to decrypt not found" }
+ return null
+ }
+ val decryptedOutput = ByteArrayOutputStream()
+ val result = try {
+ executeOpenPgpApi(command, encryptedInput, decryptedOutput)
+ } catch (e: Exception) {
+ e(e) { "OpenPgpApi ACTION_DECRYPT_VERIFY failed" }
+ return null
+ }
+ return when (val resultCode =
+ result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) {
+ OpenPgpApi.RESULT_CODE_SUCCESS -> {
+ try {
+ val entry = withContext(Dispatchers.IO) {
+ PasswordEntry(decryptedOutput)
+ }
+ Credentials.fromStoreEntry(file, entry)
+ } catch (e: UnsupportedEncodingException) {
+ e(e) { "Failed to parse password entry" }
+ null
+ }
+ }
+ OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
+ val pendingIntent: PendingIntent =
+ result.getParcelableExtra(OpenPgpApi.RESULT_INTENT)
+ try {
+ val intentToResume = withContext(Dispatchers.Main) {
+ suspendCoroutine<Intent> { cont ->
+ continueAfterUserInteraction = cont
+ startIntentSenderForResult(
+ pendingIntent.intentSender,
+ REQUEST_CODE_CONTINUE_AFTER_USER_INTERACTION,
+ null,
+ 0,
+ 0,
+ 0
+ )
+ }
+ }
+ decryptUsernameAndPassword(file, intentToResume)
+ } catch (e: Exception) {
+ e(e) { "OpenPgpApi ACTION_DECRYPT_VERIFY failed with user interaction" }
+ null
+ }
+ }
+ OpenPgpApi.RESULT_CODE_ERROR -> {
+ val error = result.getParcelableExtra<OpenPgpError>(OpenPgpApi.RESULT_ERROR)
+ if (error != null) {
+ Toast.makeText(
+ applicationContext,
+ "Error from OpenKeyChain: ${error.message}",
+ Toast.LENGTH_LONG
+ ).show()
+ e { "OpenPgpApi ACTION_DECRYPT_VERIFY failed (${error.errorId}): ${error.message}" }
+ }
+ null
+ }
+ else -> {
+ e { "Unrecognized OpenPgpApi result: $resultCode" }
+ null
+ }
+ }
+ }
+
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ super.onActivityResult(requestCode, resultCode, data)
+ if (requestCode == REQUEST_CODE_CONTINUE_AFTER_USER_INTERACTION && continueAfterUserInteraction != null) {
+ if (resultCode == RESULT_OK && data != null) {
+ continueAfterUserInteraction?.resume(data)
+ } else {
+ continueAfterUserInteraction?.resumeWithException(Exception("OpenPgpApi ACTION_DECRYPT_VERIFY failed to continue after user interaction"))
+ }
+ continueAfterUserInteraction = 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
new file mode 100644
index 00000000..8c77fff8
--- /dev/null
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt
@@ -0,0 +1,196 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package com.zeapo.pwdstore.autofill.oreo.ui
+
+import android.annotation.TargetApi
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.IntentSender
+import android.os.Build
+import android.os.Bundle
+import android.view.autofill.AutofillManager
+import android.widget.TextView
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.widget.addTextChangedListener
+import androidx.preference.PreferenceManager
+import androidx.recyclerview.widget.DividerItemDecoration
+import com.afollestad.recyclical.datasource.dataSourceOf
+import com.afollestad.recyclical.setup
+import com.afollestad.recyclical.withItem
+import com.github.ajalt.timberkt.e
+import com.zeapo.pwdstore.R
+import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher
+import com.zeapo.pwdstore.autofill.oreo.FormOrigin
+import com.zeapo.pwdstore.utils.PasswordItem
+import com.zeapo.pwdstore.utils.PasswordRepository
+import java.io.File
+import java.util.Locale
+import kotlinx.android.synthetic.main.activity_oreo_autofill_filter.*
+
+@TargetApi(Build.VERSION_CODES.O)
+class AutofillFilterView : AppCompatActivity() {
+
+ companion object {
+ private const val HEIGHT_PERCENTAGE = 0.9
+ private const val WIDTH_PERCENTAGE = 0.75
+ private const val DECRYPT_FILL_REQUEST_CODE = 1
+
+ private const val EXTRA_FORM_ORIGIN_WEB =
+ "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_FORM_ORIGIN_WEB"
+ private const val EXTRA_FORM_ORIGIN_APP =
+ "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_FORM_ORIGIN_APP"
+ private var matchAndDecryptFileRequestCode = 1
+
+ fun makeMatchAndDecryptFileIntentSender(
+ context: Context,
+ formOrigin: FormOrigin
+ ): IntentSender {
+ val intent = Intent(context, AutofillFilterView::class.java).apply {
+ when (formOrigin) {
+ is FormOrigin.Web -> putExtra(EXTRA_FORM_ORIGIN_WEB, formOrigin.identifier)
+ is FormOrigin.App -> putExtra(EXTRA_FORM_ORIGIN_APP, formOrigin.identifier)
+ }
+ }
+ return PendingIntent.getActivity(
+ context,
+ matchAndDecryptFileRequestCode++,
+ intent,
+ PendingIntent.FLAG_CANCEL_CURRENT
+ ).intentSender
+ }
+ }
+
+ private val dataSource = dataSourceOf()
+ private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) }
+ private val sortOrder
+ get() = PasswordRepository.PasswordSortOrder.getSortOrder(preferences)
+
+ private lateinit var formOrigin: FormOrigin
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_oreo_autofill_filter)
+ setFinishOnTouchOutside(true)
+
+ val params = window.attributes
+ params.height = (HEIGHT_PERCENTAGE * resources.displayMetrics.heightPixels).toInt()
+ params.width = (WIDTH_PERCENTAGE * resources.displayMetrics.widthPixels).toInt()
+ window.attributes = params
+
+ if (intent?.hasExtra(AutofillManager.EXTRA_CLIENT_STATE) != true) {
+ e { "AutofillFilterActivity started without EXTRA_CLIENT_STATE" }
+ finish()
+ return
+ }
+ formOrigin = when {
+ intent?.hasExtra(EXTRA_FORM_ORIGIN_WEB) == true -> {
+ FormOrigin.Web(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_WEB)!!)
+ }
+ intent?.hasExtra(EXTRA_FORM_ORIGIN_APP) == true -> {
+ FormOrigin.App(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_APP)!!)
+ }
+ else -> {
+ e { "AutofillFilterActivity started without EXTRA_FORM_ORIGIN_WEB or EXTRA_FORM_ORIGIN_APP" }
+ finish()
+ return
+ }
+ }
+
+ supportActionBar?.hide()
+ bindUI()
+ setResult(RESULT_CANCELED)
+ }
+
+ private fun bindUI() {
+ // setup is an extension method provided by recyclical
+ rvPassword.setup {
+ withDataSource(dataSource)
+ withItem<PasswordItem, PasswordViewHolder>(R.layout.oreo_autofill_filter_row) {
+ onBind(::PasswordViewHolder) { _, item ->
+ title.text = item.fullPathToParent
+ // drop the .gpg extension
+ subtitle.text = item.name.dropLast(4)
+ }
+ onClick { decryptAndFill(item) }
+ }
+ }
+ rvPassword.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL))
+
+ search.addTextChangedListener { recursiveFilter(it.toString(), strict = false) }
+ val initialFilter =
+ formOrigin.getPrettyIdentifier(applicationContext, untrusted = false)
+ search.setText(initialFilter, TextView.BufferType.EDITABLE)
+ recursiveFilter(initialFilter, strict = formOrigin is FormOrigin.Web)
+
+ shouldMatch.text = getString(
+ R.string.oreo_autofill_match_with,
+ formOrigin.getPrettyIdentifier(applicationContext)
+ )
+ }
+
+ private fun decryptAndFill(item: PasswordItem) {
+ if (shouldClear.isChecked) AutofillMatcher.clearMatchesFor(applicationContext, formOrigin)
+ if (shouldMatch.isChecked) AutofillMatcher.addMatchFor(
+ applicationContext,
+ formOrigin,
+ item.file
+ )
+ // intent?.extras? is checked to be non-null in onCreate
+ startActivityForResult(
+ AutofillDecryptActivity.makeDecryptFileIntent(
+ item.file,
+ intent!!.extras!!,
+ this
+ ), DECRYPT_FILL_REQUEST_CODE
+ )
+ }
+
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ super.onActivityResult(requestCode, resultCode, data)
+ if (requestCode == DECRYPT_FILL_REQUEST_CODE) {
+ if (resultCode == RESULT_OK) setResult(RESULT_OK, data)
+ finish()
+ }
+ }
+
+ private fun recursiveFilter(filter: String, dir: File? = null, strict: Boolean = true) {
+ val root = PasswordRepository.getRepositoryDirectory(this)
+ // on the root the pathStack is empty
+ val passwordItems = if (dir == null) {
+ PasswordRepository.getPasswords(
+ PasswordRepository.getRepositoryDirectory(this),
+ sortOrder
+ )
+ } else {
+ PasswordRepository.getPasswords(
+ dir,
+ PasswordRepository.getRepositoryDirectory(this),
+ sortOrder
+ )
+ }
+
+ for (item in passwordItems) {
+ if (item.type == PasswordItem.TYPE_CATEGORY) {
+ recursiveFilter(filter, item.file, strict = strict)
+ }
+
+ // TODO: Implement fuzzy search if strict == false?
+ val matches = if (strict) item.file.parentFile.name.let {
+ it == filter || it.endsWith(".$filter") || it.endsWith("://$filter")
+ }
+ else "${item.file.relativeTo(root).path}/${item.file.nameWithoutExtension}".toLowerCase(
+ Locale.getDefault()
+ ).contains(filter.toLowerCase(Locale.getDefault()))
+
+ val inAdapter = dataSource.contains(item)
+ if (item.type == PasswordItem.TYPE_PASSWORD && matches && !inAdapter) {
+ dataSource.add(item)
+ } else if (!matches && inAdapter) {
+ dataSource.remove(item)
+ }
+ }
+ }
+}
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
new file mode 100644
index 00000000..bef0e536
--- /dev/null
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillPublisherChangedActivity.kt
@@ -0,0 +1,96 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package com.zeapo.pwdstore.autofill.oreo.ui
+
+import android.annotation.TargetApi
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.IntentSender
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Bundle
+import android.text.format.DateUtils
+import android.view.View
+import androidx.appcompat.app.AppCompatActivity
+import com.github.ajalt.timberkt.e
+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 kotlinx.android.synthetic.main.activity_oreo_autofill_publisher_changed.*
+
+@TargetApi(Build.VERSION_CODES.O)
+class AutofillPublisherChangedActivity : AppCompatActivity() {
+
+ companion object {
+ private const val EXTRA_APP_PACKAGE =
+ "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_APP_PACKAGE"
+ private var publisherChangedRequestCode = 1
+
+ fun makePublisherChangedIntentSender(
+ context: Context,
+ publisherChangedException: AutofillPublisherChangedException
+ ): IntentSender {
+ val intent = Intent(context, AutofillPublisherChangedActivity::class.java).apply {
+ putExtra(EXTRA_APP_PACKAGE, publisherChangedException.formOrigin.identifier)
+ }
+ return PendingIntent.getActivity(
+ context, publisherChangedRequestCode++, intent, PendingIntent.FLAG_CANCEL_CURRENT
+ ).intentSender
+ }
+ }
+
+ private lateinit var appPackage: String
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_oreo_autofill_publisher_changed)
+ setFinishOnTouchOutside(true)
+
+ appPackage = intent.getStringExtra(EXTRA_APP_PACKAGE) ?: run {
+ e { "AutofillPublisherChangedActivity started without EXTRA_PACKAGE_NAME" }
+ finish()
+ return
+ }
+ supportActionBar?.hide()
+ showPackageInfo()
+
+ okButton.setOnClickListener { finish() }
+ advancedButton.setOnClickListener {
+ advancedButton.visibility = View.INVISIBLE
+ warningAppAdvancedInfo.visibility = View.VISIBLE
+ resetButton.visibility = View.VISIBLE
+ }
+ resetButton.setOnClickListener {
+ AutofillMatcher.clearMatchesFor(this, FormOrigin.App(appPackage))
+ finish()
+ }
+ }
+
+ private fun showPackageInfo() {
+ try {
+ val packageInfo =
+ packageManager.getPackageInfo(appPackage, PackageManager.GET_META_DATA)
+ val installTime = DateUtils.getRelativeTimeSpanString(packageInfo.firstInstallTime)
+ warningAppInstallDate.text =
+ getString(R.string.oreo_autofill_warning_publisher_install_time, installTime)
+ val appInfo =
+ packageManager.getApplicationInfo(appPackage, PackageManager.GET_META_DATA)
+ warningAppName.text = "“${packageManager.getApplicationLabel(appInfo)}”"
+
+ val currentHash = computeCertificatesHash(this, appPackage)
+ warningAppAdvancedInfo.text = getString(
+ R.string.oreo_autofill_warning_publisher_advanced_info_template,
+ appPackage,
+ currentHash
+ )
+ } catch (exception: Exception) {
+ e(exception) { "Failed to retrieve package info for $appPackage" }
+ finish()
+ }
+ }
+}
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
new file mode 100644
index 00000000..e5368b73
--- /dev/null
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt
@@ -0,0 +1,139 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package com.zeapo.pwdstore.autofill.oreo.ui
+
+import android.app.Activity
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.IntentSender
+import android.os.Build
+import android.os.Bundle
+import android.view.autofill.AutofillManager
+import androidx.annotation.RequiresApi
+import androidx.core.os.bundleOf
+import com.github.ajalt.timberkt.e
+import com.zeapo.pwdstore.PasswordStore
+import com.zeapo.pwdstore.autofill.oreo.AutofillAction
+import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher
+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.crypto.PgpActivity
+import com.zeapo.pwdstore.utils.PasswordRepository
+import java.io.File
+
+@RequiresApi(Build.VERSION_CODES.O)
+class AutofillSaveActivity : Activity() {
+
+ companion object {
+ private const val EXTRA_FOLDER_NAME =
+ "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_FOLDER_NAME"
+ private const val EXTRA_PASSWORD = "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_PASSWORD"
+ private const val EXTRA_USERNAME = "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_USERNAME"
+ private const val EXTRA_SHOULD_MATCH_APP =
+ "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_SHOULD_MATCH_APP"
+ private const val EXTRA_SHOULD_MATCH_WEB =
+ "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_SHOULD_MATCH_WEB"
+ private const val EXTRA_GENERATE_PASSWORD =
+ "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_GENERATE_PASSWORD"
+
+ private var saveRequestCode = 1
+
+ fun makeSaveIntentSender(
+ context: Context,
+ credentials: Credentials?,
+ formOrigin: FormOrigin
+ ): IntentSender {
+ val identifier = formOrigin.getPrettyIdentifier(context, untrusted = false)
+ val sanitizedIdentifier = identifier.replace("""[\\\/]""", "")
+ val folderName =
+ sanitizedIdentifier.takeUnless { it.isBlank() } ?: formOrigin.identifier
+ val intent = Intent(context, AutofillSaveActivity::class.java).apply {
+ putExtras(
+ bundleOf(
+ EXTRA_FOLDER_NAME to folderName,
+ EXTRA_PASSWORD to credentials?.password,
+ EXTRA_USERNAME to credentials?.username,
+ EXTRA_SHOULD_MATCH_APP to formOrigin.identifier.takeIf { formOrigin is FormOrigin.App },
+ EXTRA_SHOULD_MATCH_WEB to formOrigin.identifier.takeIf { formOrigin is FormOrigin.Web },
+ EXTRA_GENERATE_PASSWORD to (credentials == null)
+ )
+ )
+ }
+ return PendingIntent.getActivity(
+ context,
+ saveRequestCode++,
+ intent,
+ PendingIntent.FLAG_CANCEL_CURRENT
+ ).intentSender
+ }
+ }
+
+ private val formOrigin: FormOrigin? by lazy {
+ val shouldMatchApp: String? = intent.getStringExtra(EXTRA_SHOULD_MATCH_APP)
+ val shouldMatchWeb: String? = intent.getStringExtra(EXTRA_SHOULD_MATCH_WEB)
+ if (shouldMatchApp != null && shouldMatchWeb == null) {
+ FormOrigin.App(shouldMatchApp)
+ } else if (shouldMatchApp == null && shouldMatchWeb != null) {
+ FormOrigin.Web(shouldMatchWeb)
+ } else {
+ null
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val repo = PasswordRepository.getRepositoryDirectory(applicationContext)
+ val username = intent.getStringExtra(EXTRA_USERNAME)
+
+ val saveIntent = Intent(this, PgpActivity::class.java).apply {
+ putExtras(
+ bundleOf(
+ "REPO_PATH" to repo.absolutePath,
+ "FILE_PATH" to repo.resolve(intent.getStringExtra(EXTRA_FOLDER_NAME)).absolutePath,
+ "OPERATION" to "ENCRYPT",
+ "SUGGESTED_NAME" to username,
+ "SUGGESTED_PASS" to intent.getStringExtra(EXTRA_PASSWORD),
+ "GENERATE_PASSWORD" to intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false)
+ )
+ )
+ }
+ startActivityForResult(saveIntent, PasswordStore.REQUEST_CODE_ENCRYPT)
+ }
+
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ super.onActivityResult(requestCode, resultCode, data)
+ if (requestCode == PasswordStore.REQUEST_CODE_ENCRYPT && resultCode == RESULT_OK && data != null) {
+ val createdPath = data.getStringExtra("CREATED_FILE")
+ if (createdPath != null) {
+ formOrigin?.let {
+ AutofillMatcher.addMatchFor(this, it, File(createdPath))
+ }
+ }
+ val password = data.getStringExtra("PASSWORD")
+ val username = data.getStringExtra("USERNAME")
+ if (password != null) {
+ val clientState =
+ intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) ?: run {
+ e { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" }
+ finish()
+ return
+ }
+ val credentials = Credentials(username, password)
+ val fillInDataset = FillableForm.makeFillInDataset(
+ this,
+ credentials,
+ clientState,
+ AutofillAction.Generate
+ )
+ setResult(RESULT_OK, Intent().apply {
+ putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset)
+ })
+ }
+ }
+ finish()
+ }
+}
diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/PasswordViewHolder.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/PasswordViewHolder.kt
new file mode 100644
index 00000000..f6ad7a4d
--- /dev/null
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/PasswordViewHolder.kt
@@ -0,0 +1,15 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package com.zeapo.pwdstore.autofill.oreo.ui
+
+import android.view.View
+import android.widget.TextView
+import androidx.recyclerview.widget.RecyclerView
+import com.zeapo.pwdstore.R
+
+class PasswordViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ val title: TextView = itemView.findViewById(R.id.title)
+ val subtitle: TextView = itemView.findViewById(R.id.subtitle)
+}
diff --git a/app/src/main/java/com/zeapo/pwdstore/crypto/PgpActivity.kt b/app/src/main/java/com/zeapo/pwdstore/crypto/PgpActivity.kt
index 6f09ca5a..90efd583 100644
--- a/app/src/main/java/com/zeapo/pwdstore/crypto/PgpActivity.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/crypto/PgpActivity.kt
@@ -16,6 +16,7 @@ import android.content.SharedPreferences
import android.graphics.Typeface
import android.os.Build
import android.os.Bundle
+import android.text.InputType
import android.text.TextUtils
import android.text.format.DateUtils
import android.text.method.PasswordTransformationMethod
@@ -30,6 +31,7 @@ import android.widget.CheckBox
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.core.widget.doOnTextChanged
import androidx.lifecycle.lifecycleScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.preference.PreferenceManager
@@ -48,11 +50,7 @@ import java.io.File
import java.nio.charset.Charset
import java.util.Date
import kotlinx.android.synthetic.main.decrypt_layout.*
-import kotlinx.android.synthetic.main.encrypt_layout.crypto_extra_edit
-import kotlinx.android.synthetic.main.encrypt_layout.crypto_password_category
-import kotlinx.android.synthetic.main.encrypt_layout.crypto_password_edit
-import kotlinx.android.synthetic.main.encrypt_layout.crypto_password_file_edit
-import kotlinx.android.synthetic.main.encrypt_layout.generate_password
+import kotlinx.android.synthetic.main.encrypt_layout.*
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
import me.msfjarvis.openpgpktx.util.OpenPgpApi
@@ -81,6 +79,11 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
private var editPass: String? = null
private var editExtra: String? = null
+ private val suggestedName by lazy { intent.getStringExtra("SUGGESTED_NAME") }
+ private val suggestedPass by lazy { intent.getStringExtra("SUGGESTED_PASS") }
+ private val suggestedExtra by lazy { intent.getStringExtra("SUGGESTED_EXTRA") }
+ private val shouldGeneratePassword by lazy { intent.getBooleanExtra("GENERATE_PASSWORD", false) }
+
private val operation: String by lazy { intent.getStringExtra("OPERATION") }
private val repoPath: String by lazy { intent.getStringExtra("REPO_PATH") }
@@ -150,25 +153,85 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
setContentView(R.layout.encrypt_layout)
generate_password?.setOnClickListener {
- when (settings.getString("pref_key_pwgen_type", KEY_PWGEN_TYPE_CLASSIC)) {
- KEY_PWGEN_TYPE_CLASSIC -> PasswordGeneratorDialogFragment()
- .show(supportFragmentManager, "generator")
- KEY_PWGEN_TYPE_XKPASSWD -> XkPasswordGeneratorDialogFragment()
- .show(supportFragmentManager, "xkpwgenerator")
- }
+ generatePassword()
}
title = getString(R.string.new_password_title)
crypto_password_category.text = getRelativePath(fullPath, repoPath)
+ suggestedName?.let {
+ crypto_password_file_edit.setText(it)
+ encrypt_username.apply {
+ visibility = View.VISIBLE
+ setOnClickListener {
+ if (isChecked) {
+ // User wants to enable username encryption, so we add it to the
+ // encrypted extras as the first line.
+ val username = crypto_password_file_edit.text!!.toString()
+ val extras = "username:$username\n${crypto_extra_edit.text!!}"
+
+ crypto_password_file_edit.setText("")
+ crypto_extra_edit.setText(extras)
+ } else {
+ // User wants to disable username encryption, so we extract the
+ // username from the encrypted extras and use it as the filename.
+ val entry = PasswordEntry("PASSWORD\n${crypto_extra_edit.text!!}")
+ val username = entry.username
+
+ // username should not be null here by the logic in
+ // updateEncryptUsernameState, but it could still happen due to
+ // input lag.
+ if (username != null) {
+ crypto_password_file_edit.setText(username)
+ crypto_extra_edit.setText(entry.extraContentWithoutUsername)
+ }
+ }
+ updateEncryptUsernameState()
+ }
+ }
+ crypto_password_file_edit.doOnTextChanged { _, _, _, _ -> updateEncryptUsernameState() }
+ crypto_extra_edit.doOnTextChanged { _, _, _, _ -> updateEncryptUsernameState() }
+ updateEncryptUsernameState()
+ }
+ suggestedPass?.let {
+ crypto_password_edit.setText(it)
+ crypto_password_edit.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
+ }
+ suggestedExtra?.let { crypto_extra_edit.setText(it) }
+ if (shouldGeneratePassword) {
+ generatePassword()
+ crypto_password_edit.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
+ }
}
}
}
+ fun updateEncryptUsernameState() {
+ encrypt_username.apply {
+ if (visibility != View.VISIBLE)
+ return
+ val hasUsernameInFileName = crypto_password_file_edit.text!!.toString().isNotBlank()
+ // Use PasswordEntry to parse extras for username
+ val entry = PasswordEntry("PLACEHOLDER\n${crypto_extra_edit.text!!}")
+ val hasUsernameInExtras = entry.hasUsername()
+ isEnabled = hasUsernameInFileName xor hasUsernameInExtras
+ isChecked = hasUsernameInExtras
+ }
+ }
+
override fun onResume() {
super.onResume()
LocalBroadcastManager.getInstance(this).registerReceiver(receiver, IntentFilter(ACTION_CLEAR))
}
+ private fun generatePassword() {
+ when (settings.getString("pref_key_pwgen_type", KEY_PWGEN_TYPE_CLASSIC)) {
+ KEY_PWGEN_TYPE_CLASSIC -> PasswordGeneratorDialogFragment()
+ .show(supportFragmentManager, "generator")
+ KEY_PWGEN_TYPE_XKPASSWD -> XkPasswordGeneratorDialogFragment()
+ .show(supportFragmentManager, "xkpwgenerator")
+ }
+ }
+
override fun onStop() {
LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver)
super.onStop()
@@ -482,7 +545,8 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
data.putExtra(OpenPgpApi.EXTRA_REQUEST_ASCII_ARMOR, true)
// TODO Check if we could use PasswordEntry to generate the file
- val iStream = ByteArrayInputStream("$editPass\n$editExtra".toByteArray(Charset.forName("UTF-8")))
+ val content = "$editPass\n$editExtra"
+ val iStream = ByteArrayInputStream(content.toByteArray(Charset.forName("UTF-8")))
val oStream = ByteArrayOutputStream()
val path = if (intent.getBooleanExtra("fromDecrypt", false)) fullPath else "$fullPath/$editName.gpg"
@@ -494,7 +558,8 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
RESULT_CODE_SUCCESS -> {
try {
// TODO This might fail, we should check that the write is successful
- val outputStream = FileUtils.openOutputStream(File(path))
+ val file = File(path)
+ val outputStream = FileUtils.openOutputStream(file)
outputStream.write(oStream.toByteArray())
outputStream.close()
@@ -508,6 +573,13 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
returnIntent.putExtra("OPERATION", "EDIT")
returnIntent.putExtra("needCommit", true)
}
+
+ if (shouldGeneratePassword) {
+ val entry = PasswordEntry(content)
+ returnIntent.putExtra("PASSWORD", entry.password)
+ returnIntent.putExtra("USERNAME", entry.username ?: file.nameWithoutExtension)
+ }
+
setResult(RESULT_OK, returnIntent)
finish()
} catch (e: Exception) {
diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt b/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt
index ff4fab69..68f8df27 100644
--- a/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt
@@ -5,7 +5,10 @@
package com.zeapo.pwdstore.utils
import android.content.Context
+import android.os.Build
import android.util.TypedValue
+import android.view.autofill.AutofillManager
+import androidx.annotation.RequiresApi
fun String.splitLines(): Array<String> {
return split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
@@ -16,3 +19,7 @@ fun Context.resolveAttribute(attr: Int): Int {
this.theme.resolveAttribute(attr, typedValue, true)
return typedValue.data
}
+
+val Context.autofillManager: AutofillManager?
+ @RequiresApi(Build.VERSION_CODES.O)
+ get() = getSystemService(AutofillManager::class.java)