diff --git a/CHANGELOG.md b/CHANGELOG.md
index d83343e17..1ee494978 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,7 @@ All notable changes to this project will be documented in this file.
- Fix authentication failure with usernames that contain the `@` character
- Text input boxes were illegible on dark theme
- Top-level password names had inconsistent top margin making them look askew
+- Autofill can now be made more reliable in Chrome by enabling an accessibility service that works around known Chrome limitations
### Added
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 40bcb4819..1a49b0176 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -99,6 +99,19 @@
android:name="android.accessibilityservice"
android:resource="@xml/autofill_config" />
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt b/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt
index ea3bac389..4d68834b5 100644
--- a/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt
@@ -41,7 +41,9 @@ import com.github.ajalt.timberkt.w
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.AutofillService
import com.zeapo.pwdstore.autofill.oreo.BrowserAutofillSupportLevel
+import com.zeapo.pwdstore.autofill.oreo.ChromeCompatFix
import com.zeapo.pwdstore.autofill.oreo.getInstalledBrowsersWithAutofillSupportLevel
import com.zeapo.pwdstore.crypto.BasePgpActivity
import com.zeapo.pwdstore.crypto.GetKeyIdsActivity
@@ -73,6 +75,7 @@ class UserPreference : AppCompatActivity() {
class PrefsFragment : PreferenceFragmentCompat() {
private var autoFillEnablePreference: SwitchPreferenceCompat? = null
+ private var oreoAutofillChromeCompatFix: SwitchPreferenceCompat? = null
private var clearSavedPassPreference: Preference? = null
private lateinit var autofillDependencies: List
private lateinit var oreoAutofillDependencies: List
@@ -118,6 +121,7 @@ class UserPreference : AppCompatActivity() {
// Autofill preferences
autoFillEnablePreference = findPreference(PreferenceKeys.AUTOFILL_ENABLE)
+ oreoAutofillChromeCompatFix = findPreference(PreferenceKeys.OREO_AUTOFILL_CHROME_COMPAT_FIX)
val oreoAutofillDirectoryStructurePreference = findPreference(PreferenceKeys.OREO_AUTOFILL_DIRECTORY_STRUCTURE)
val oreoAutofillDefaultUsername = findPreference(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME)
val oreoAutofillCustomPublixSuffixes = findPreference(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES)
@@ -276,6 +280,16 @@ class UserPreference : AppCompatActivity() {
true
}
+ oreoAutofillChromeCompatFix?.onPreferenceClickListener = ClickListener {
+ if (oreoAutofillChromeCompatFix!!.isChecked) {
+ startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
+ true
+ } else {
+ // Service will disable itself on startup if the preference has the value false.
+ false
+ }
+ }
+
findPreference(PreferenceKeys.EXPORT_PASSWORDS)?.apply {
isVisible = sharedPreferences.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)
onPreferenceClickListener = Preference.OnPreferenceClickListener {
@@ -398,16 +412,20 @@ class UserPreference : AppCompatActivity() {
}
private fun updateAutofillSettings() {
- val isAccessibilityServiceEnabled = callingActivity.isAccessibilityServiceEnabled
+ val isAccessibilityAutofillServiceEnabled = callingActivity.isAccessibilityAutofillServiceEnabled
val isAutofillServiceEnabled = callingActivity.isAutofillServiceEnabled
autoFillEnablePreference?.isChecked =
- isAccessibilityServiceEnabled || isAutofillServiceEnabled
+ isAccessibilityAutofillServiceEnabled || isAutofillServiceEnabled
autofillDependencies.forEach {
- it.isVisible = isAccessibilityServiceEnabled
+ it.isVisible = isAccessibilityAutofillServiceEnabled
}
oreoAutofillDependencies.forEach {
it.isVisible = isAutofillServiceEnabled
}
+ oreoAutofillChromeCompatFix?.apply {
+ isChecked = callingActivity.isChromeCompatFixServiceEnabled
+ isVisible = callingActivity.isChromeCompatFixServiceSupported
+ }
}
private fun updateClearSavedPassphrasePrefs() {
@@ -428,13 +446,16 @@ class UserPreference : AppCompatActivity() {
}
private fun onEnableAutofillClick() {
- if (callingActivity.isAccessibilityServiceEnabled) {
+ if (callingActivity.isAccessibilityAutofillServiceEnabled) {
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
} else if (callingActivity.isAutofillServiceEnabled) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
callingActivity.autofillManager!!.disableAutofillServices()
- else
+ ChromeCompatFix.setStatusInPreferences(requireContext(), false)
+ updateAutofillSettings()
+ } else {
throw IllegalStateException("isAutofillServiceEnabled == true, but Build.VERSION.SDK_INT < Build.VERSION_CODES.O")
+ }
} else {
val enableOreoAutofill = callingActivity.isAutofillServiceSupported
MaterialAlertDialogBuilder(callingActivity).run {
@@ -710,14 +731,32 @@ class UserPreference : AppCompatActivity() {
File("$filesDir/.ssh_key").writeText(lines.joinToString("\n"))
}
- private val isAccessibilityServiceEnabled: Boolean
+ private val isAccessibilityAutofillServiceEnabled: Boolean
get() {
val am = getSystemService() ?: return false
val runningServices = am
.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_GENERIC)
return runningServices
- .map { it.id.substringBefore("/") }
- .any { it == BuildConfig.APPLICATION_ID }
+ .mapNotNull { it?.resolveInfo?.serviceInfo }
+ .any { it.packageName == BuildConfig.APPLICATION_ID && it.name == AutofillService::class.java.name }
+ }
+
+ private val isChromeCompatFixServiceEnabled: Boolean
+ get() {
+ val am = getSystemService() ?: return false
+ val runningServices = am
+ .getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_GENERIC)
+ return runningServices
+ .mapNotNull { it?.resolveInfo?.serviceInfo }
+ .any { it.packageName == BuildConfig.APPLICATION_ID && it.name == ChromeCompatFix::class.java.name }
+ }
+
+ private val isChromeCompatFixServiceSupported: Boolean
+ get() {
+ // Autofill compat mode is only available starting with Android Pie and only makes sense
+ // when used with Autofill enabled.
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return false
+ return isAutofillServiceEnabled
}
private val isAutofillServiceSupported: Boolean
diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ChromeCompatFix.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ChromeCompatFix.kt
new file mode 100644
index 000000000..75d9539aa
--- /dev/null
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ChromeCompatFix.kt
@@ -0,0 +1,93 @@
+/*
+ * 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.accessibilityservice.AccessibilityService
+import android.content.Context
+import android.content.SharedPreferences
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import android.view.accessibility.AccessibilityEvent
+import androidx.annotation.RequiresApi
+import androidx.core.content.edit
+import androidx.preference.PreferenceManager
+import com.github.ajalt.timberkt.i
+import com.github.ajalt.timberkt.v
+import com.github.ajalt.timberkt.w
+import com.zeapo.pwdstore.utils.PreferenceKeys
+import com.zeapo.pwdstore.utils.autofillManager
+
+@RequiresApi(Build.VERSION_CODES.P)
+class ChromeCompatFix : AccessibilityService() {
+
+ companion object {
+ fun setStatusInPreferences(context: Context, enabled: Boolean) {
+ PreferenceManager.getDefaultSharedPreferences(context).edit {
+ putBoolean(PreferenceKeys.OREO_AUTOFILL_CHROME_COMPAT_FIX, enabled)
+ }
+ }
+ }
+
+ private val isEnabledInPreferences
+ get() = PreferenceManager.getDefaultSharedPreferences(this).getBoolean(PreferenceKeys.OREO_AUTOFILL_CHROME_COMPAT_FIX, true)
+
+ private val handler = Handler(Looper.getMainLooper())
+ private val forceRootNodePopulation = Runnable {
+ val rootPackageName = rootInActiveWindow?.packageName.toString()
+ v { "$rootPackageName: forced root node population" }
+ }
+ private val disableListener = SharedPreferences.OnSharedPreferenceChangeListener { prefs: SharedPreferences, key: String ->
+ if (key != PreferenceKeys.OREO_AUTOFILL_CHROME_COMPAT_FIX)
+ return@OnSharedPreferenceChangeListener
+ if (!isEnabledInPreferences) {
+ i { "Disabled in settings, shutting down..." }
+ disableSelf()
+ }
+ }
+
+ override fun onAccessibilityEvent(event: AccessibilityEvent) {
+ handler.removeCallbacks(forceRootNodePopulation)
+ when (event.eventType) {
+ AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED, AccessibilityEvent.TYPE_ANNOUNCEMENT -> {
+ // WINDOW_STATE_CHANGED: Triggered on long press in a text field, replacement for
+ // the missing Autofill action menu item.
+ // ANNOUNCEMENT: Triggered when a password field is selected.
+ //
+ // These events are triggered only by user actions and thus don't need to be handled
+ // with debounce. However, they only trigger Autofill popups on the *next* input
+ // field selected by the user.
+ forceRootNodePopulation.run()
+ v { "${event.packageName} (${AccessibilityEvent.eventTypeToString(event.eventType)}): forced root node population" }
+ }
+ AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> {
+ // WINDOW_CONTENT_CHANGED: Triggered whenever the page contents change.
+ //
+ // This event is triggered many times during page load, which makes a debounce
+ // necessary to prevent huge performance regressions in Chrome. However, it is the
+ // only event that reliably runs before the user selects a text field.
+ handler.postDelayed(forceRootNodePopulation, 300)
+ v { "${event.packageName} (${AccessibilityEvent.eventTypeToString(event.eventType)}): debounced root node population" }
+ }
+ }
+ }
+
+ override fun onServiceConnected() {
+ super.onServiceConnected()
+ // Allow the service to be activated only if the Autofill service is already enabled.
+ if (autofillManager?.hasEnabledAutofillServices() != true) {
+ w { "Autofill service not enabled, shutting down..." }
+ disableSelf()
+ return
+ }
+ // Update preferences if the user manually activated the service.
+ setStatusInPreferences(this, true)
+
+ PreferenceManager.getDefaultSharedPreferences(this).registerOnSharedPreferenceChangeListener(disableListener)
+ }
+
+ override fun onInterrupt() {}
+}
+
diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/PreferenceKeys.kt b/app/src/main/java/com/zeapo/pwdstore/utils/PreferenceKeys.kt
index 05f9c741b..7d0195084 100644
--- a/app/src/main/java/com/zeapo/pwdstore/utils/PreferenceKeys.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/utils/PreferenceKeys.kt
@@ -35,6 +35,7 @@ object PreferenceKeys {
const val OPENPGP_KEY_IDS_SET = "openpgp_key_ids_set"
const val OPENPGP_KEY_ID_PREF = "openpgp_key_id_pref"
const val OPENPGP_PROVIDER_LIST = "openpgp_provider_list"
+ const val OREO_AUTOFILL_CHROME_COMPAT_FIX = "oreo_autofill_chrome_compat_fix"
const val OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES = "oreo_autofill_custom_public_suffixes"
const val OREO_AUTOFILL_DEFAULT_USERNAME = "oreo_autofill_default_username"
const val OREO_AUTOFILL_DIRECTORY_STRUCTURE = "oreo_autofill_directory_structure"
diff --git a/app/src/main/res/values-v28/bools.xml b/app/src/main/res/values-v28/bools.xml
new file mode 100644
index 000000000..0ce64e0bb
--- /dev/null
+++ b/app/src/main/res/values-v28/bools.xml
@@ -0,0 +1,4 @@
+
+
+ true
+
diff --git a/app/src/main/res/values/bools.xml b/app/src/main/res/values/bools.xml
index fcf624a71..fbcc1c735 100644
--- a/app/src/main/res/values/bools.xml
+++ b/app/src/main/res/values/bools.xml
@@ -3,4 +3,5 @@
true
true
true
+ false
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d62b52929..01b63579d 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -275,6 +275,16 @@
Password Store can offer to fill login forms and even save credentials you enter in apps or on websites.
To enable this feature, tap OK to go to Autofill settings. There, select Password Store from the list and confirm the confirmation prompt with OK.
Autofill support with installed browsers:
+ Make Autofill more reliable in Chrome
+ This accessibility service makes
+ Autofill work more reliably in Chrome. It can only be activated if you are already using
+ Password Store as your Autofill service.\n\nThis service is only active while you are
+ using Chrome. It does not access any data or take any actions on your behalf, but forces
+ Chrome to properly forward user interactions to the Password Store Autofill
+ service.\n\nChrome\'s performance should not be noticeably affected. If you are experiencing
+ any problems with this service, please create an issue at
+ https://msfjarvis.dev/aps.
+
Autofills password fields in apps. Only works for Android versions 4.3 and up. Does not rely on the clipboard for Android versions 5.0 and up.
@@ -388,4 +398,6 @@
Add OTP
Successfully imported TOTP configuration
Failed to import TOTP configuration
+ Improve reliability in Chrome
+ Requires activating an accessibility service and may affect overall Chrome performance
diff --git a/app/src/main/res/xml/oreo_autofill_chrome_compat_fix.xml b/app/src/main/res/xml/oreo_autofill_chrome_compat_fix.xml
new file mode 100644
index 000000000..196c93d57
--- /dev/null
+++ b/app/src/main/res/xml/oreo_autofill_chrome_compat_fix.xml
@@ -0,0 +1,13 @@
+
+
diff --git a/app/src/main/res/xml/preference.xml b/app/src/main/res/xml/preference.xml
index 0d71d6cc7..d4ec4139f 100644
--- a/app/src/main/res/xml/preference.xml
+++ b/app/src/main/res/xml/preference.xml
@@ -10,6 +10,11 @@
app:defaultValue="true"
app:key="autofill_enable"
app:title="@string/pref_autofill_enable_title" />
+