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" /> +