From 515eb730f0b27a7b33ffb45df9f3914a503631f8 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Thu, 23 Apr 2026 20:44:32 -0700 Subject: [PATCH] Broaden Android accessibility autofill fallback --- .../keepassgo/AutofillCacheStore.java | 207 ++-------------- .../keepassgo/AutofillFallbackTarget.java | 24 ++ .../keepassgo/AutofillTargetMatcher.java | 232 ++++++++++++++++++ .../KeePassGOAccessibilityService.java | 33 +-- .../AutofillCacheStoreBehaviorTest.java | 75 ++++++ .../AutofillFallbackTargetBehaviorTest.java | 30 +++ docs/android-autofill.md | 38 +++ 7 files changed, 444 insertions(+), 195 deletions(-) create mode 100644 androidsrc/org/julianfamily/keepassgo/AutofillFallbackTarget.java create mode 100644 androidsrc/org/julianfamily/keepassgo/AutofillTargetMatcher.java create mode 100644 androidtestsrc/org/julianfamily/keepassgo/AutofillCacheStoreBehaviorTest.java create mode 100644 androidtestsrc/org/julianfamily/keepassgo/AutofillFallbackTargetBehaviorTest.java create mode 100644 docs/android-autofill.md diff --git a/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java b/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java index 5686193..187dc49 100644 --- a/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java +++ b/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java @@ -25,26 +25,7 @@ final class AutofillCacheStore { if (entries.isEmpty()) { return null; } - NormalizedTarget target = normalizeURL(webDomain); - if (target.host.isEmpty()) { - return null; - } - List exactHost = new ArrayList<>(); - List parentHost = new ArrayList<>(); - for (Entry entry : entries) { - if (entryMatchesHost(entry, target.host)) { - exactHost.add(entry); - continue; - } - if (entryMatchesParentHost(entry, target.host)) { - parentHost.add(entry); - } - } - Entry matched = chooseEntry(target, exactHost); - if (matched != null) { - return matched; - } - return chooseEntry(target, parentHost); + return fromMatcherEntry(AutofillTargetMatcher.findBestMatch(toMatcherEntries(entries), webDomain)); } static Entry findByID(Context context, String entryID) { @@ -64,7 +45,7 @@ final class AutofillCacheStore { if (entries.isEmpty()) { return entries; } - Entry direct = findBestMatch(context, rawTarget); + Entry direct = fromMatcherEntry(AutofillTargetMatcher.findBestMatch(toMatcherEntries(entries), rawTarget)); if (direct != null) { List resolved = new ArrayList<>(); resolved.add(direct); @@ -77,6 +58,30 @@ final class AutofillCacheStore { return entries; } + private static List toMatcherEntries(List entries) { + List converted = new ArrayList<>(entries.size()); + for (Entry entry : entries) { + converted.add(new AutofillTargetMatcher.Entry( + entry.id, + entry.title, + entry.username, + entry.password, + entry.host, + entry.url, + entry.targets, + entry.path + )); + } + return converted; + } + + private static Entry fromMatcherEntry(AutofillTargetMatcher.Entry entry) { + if (entry == null) { + return null; + } + return new Entry(entry.id, entry.title, entry.username, entry.password, entry.host, entry.url, entry.targets, entry.path); + } + private static List readEntries(Context context) { File cacheFile = findCacheFile(context); if (cacheFile == null) { @@ -199,143 +204,7 @@ final class AutofillCacheStore { } private static String normalizeHost(String raw) { - return normalizeURL(raw).host; - } - - private static NormalizedTarget normalizeURL(String raw) { - if (raw == null) { - return new NormalizedTarget("", "", ""); - } - String value = raw.trim().toLowerCase(Locale.US); - if (value.startsWith("http://")) { - value = value.substring("http://".length()); - } else if (value.startsWith("https://")) { - value = value.substring("https://".length()); - } - int slash = value.indexOf('/'); - if (slash >= 0) { - value = value.substring(0, slash); - } - int colon = value.indexOf(':'); - if (colon >= 0) { - value = value.substring(0, colon); - } - String host = value; - String path = "/"; - int schemeSep = raw.indexOf("://"); - String original = raw.trim(); - if (schemeSep < 0) { - original = "https://" + original; - } - try { - java.net.URI uri = java.net.URI.create(original); - if (uri.getHost() != null) { - host = uri.getHost().toLowerCase(Locale.US); - } - path = cleanPath(uri.getPath()); - } catch (IllegalArgumentException ignored) { - path = "/"; - } - return new NormalizedTarget(host, path, host + path); - } - - private static String cleanPath(String raw) { - if (raw == null || raw.trim().isEmpty() || "/".equals(raw.trim())) { - return "/"; - } - String value = raw.trim(); - while (value.endsWith("/") && value.length() > 1) { - value = value.substring(0, value.length() - 1); - } - if (!value.startsWith("/")) { - value = "/" + value; - } - return value; - } - - private static Entry chooseEntry(NormalizedTarget target, List entries) { - if (entries.isEmpty()) { - return null; - } - if (entries.size() == 1) { - return entries.get(0); - } - - List exact = new ArrayList<>(); - List prefix = new ArrayList<>(); - int bestPrefixLen = -1; - for (Entry entry : entries) { - MatchQuality quality = bestTargetMatch(entry, target); - if (quality.exact) { - exact.add(entry); - continue; - } - if (quality.prefixLength <= 0) { - continue; - } - if (quality.prefixLength > bestPrefixLen) { - prefix.clear(); - prefix.add(entry); - bestPrefixLen = quality.prefixLength; - } else if (quality.prefixLength == bestPrefixLen) { - prefix.add(entry); - } - } - if (exact.size() == 1) { - return exact.get(0); - } - if (exact.size() > 1 || prefix.isEmpty()) { - return null; - } - - return prefix.size() == 1 ? prefix.get(0) : null; - } - - private static boolean entryMatchesHost(Entry entry, String host) { - for (NormalizedTarget target : entryTargets(entry)) { - if (target.host.equals(host)) { - return true; - } - } - return false; - } - - private static boolean entryMatchesParentHost(Entry entry, String host) { - for (NormalizedTarget target : entryTargets(entry)) { - if (!target.host.isEmpty() && host.endsWith("." + target.host)) { - return true; - } - } - return false; - } - - private static List entryTargets(Entry entry) { - List rawTargets = entry.targets; - if (rawTargets.isEmpty()) { - rawTargets = new ArrayList<>(); - rawTargets.add(entry.url); - } - List targets = new ArrayList<>(); - for (String rawTarget : rawTargets) { - NormalizedTarget target = normalizeURL(rawTarget); - if (!target.host.isEmpty()) { - targets.add(target); - } - } - return targets; - } - - private static MatchQuality bestTargetMatch(Entry entry, NormalizedTarget target) { - int bestPrefixLen = -1; - for (NormalizedTarget entryTarget : entryTargets(entry)) { - if (entryTarget.url.equals(target.url)) { - return new MatchQuality(true, 0); - } - if (!"/".equals(entryTarget.path) && target.path.startsWith(entryTarget.path)) { - bestPrefixLen = Math.max(bestPrefixLen, entryTarget.path.length()); - } - } - return new MatchQuality(false, bestPrefixLen); + return AutofillTargetMatcher.normalize(raw).host; } static final class Entry { @@ -359,26 +228,4 @@ final class AutofillCacheStore { this.path = new ArrayList<>(path); } } - - private static final class MatchQuality { - final boolean exact; - final int prefixLength; - - MatchQuality(boolean exact, int prefixLength) { - this.exact = exact; - this.prefixLength = prefixLength; - } - } - - private static final class NormalizedTarget { - final String host; - final String path; - final String url; - - NormalizedTarget(String host, String path, String url) { - this.host = host; - this.path = path; - this.url = url; - } - } } diff --git a/androidsrc/org/julianfamily/keepassgo/AutofillFallbackTarget.java b/androidsrc/org/julianfamily/keepassgo/AutofillFallbackTarget.java new file mode 100644 index 0000000..821e175 --- /dev/null +++ b/androidsrc/org/julianfamily/keepassgo/AutofillFallbackTarget.java @@ -0,0 +1,24 @@ +package org.julianfamily.keepassgo; + +final class AutofillFallbackTarget { + private static final String APP_SCHEME = "androidapp://"; + + private AutofillFallbackTarget() { + } + + static String resolve(String packageName, String webDomain) { + String domain = trim(webDomain); + if (!domain.isEmpty()) { + return domain; + } + String pkg = trim(packageName); + if (pkg.isEmpty()) { + return ""; + } + return APP_SCHEME + pkg; + } + + private static String trim(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/androidsrc/org/julianfamily/keepassgo/AutofillTargetMatcher.java b/androidsrc/org/julianfamily/keepassgo/AutofillTargetMatcher.java new file mode 100644 index 0000000..404527d --- /dev/null +++ b/androidsrc/org/julianfamily/keepassgo/AutofillTargetMatcher.java @@ -0,0 +1,232 @@ +package org.julianfamily.keepassgo; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +final class AutofillTargetMatcher { + private AutofillTargetMatcher() { + } + + static Entry findBestMatch(List entries, String rawTarget) { + if (entries == null || entries.isEmpty()) { + return null; + } + NormalizedTarget target = normalize(rawTarget); + if (target.host.isEmpty()) { + return null; + } + List exactHost = new ArrayList<>(); + List parentHost = new ArrayList<>(); + for (Entry entry : entries) { + if (entryMatchesHost(entry, target.host)) { + exactHost.add(entry); + continue; + } + if (entryMatchesParentHost(entry, target.host)) { + parentHost.add(entry); + } + } + Entry matched = chooseEntry(target, exactHost); + if (matched != null) { + return matched; + } + return chooseEntry(target, parentHost); + } + + static List chooserCandidates(List entries, String rawTarget) { + if (entries == null || entries.isEmpty()) { + return new ArrayList<>(); + } + Entry direct = findBestMatch(entries, rawTarget); + if (direct != null) { + List resolved = new ArrayList<>(); + resolved.add(direct); + return resolved; + } + return new ArrayList<>(entries); + } + + static NormalizedTarget normalize(String raw) { + if (raw == null) { + return new NormalizedTarget("", "", ""); + } + String trimmed = raw.trim(); + if (trimmed.isEmpty()) { + return new NormalizedTarget("", "", ""); + } + String original = trimmed; + String value = trimmed.toLowerCase(Locale.US); + if (value.startsWith("http://")) { + value = value.substring("http://".length()); + } else if (value.startsWith("https://")) { + value = value.substring("https://".length()); + } + int slash = value.indexOf('/'); + if (slash >= 0) { + value = value.substring(0, slash); + } + int colon = value.indexOf(':'); + if (colon >= 0) { + value = value.substring(0, colon); + } + String host = value; + String path = "/"; + int schemeSep = original.indexOf("://"); + if (schemeSep < 0) { + original = "https://" + original; + } + try { + URI uri = URI.create(original); + if (uri.getHost() != null) { + host = uri.getHost().toLowerCase(Locale.US); + } + path = cleanPath(uri.getPath()); + } catch (IllegalArgumentException ignored) { + path = "/"; + } + return new NormalizedTarget(host, path, host + path); + } + + private static String cleanPath(String raw) { + if (raw == null || raw.trim().isEmpty() || "/".equals(raw.trim())) { + return "/"; + } + String value = raw.trim(); + while (value.endsWith("/") && value.length() > 1) { + value = value.substring(0, value.length() - 1); + } + if (!value.startsWith("/")) { + value = "/" + value; + } + return value; + } + + private static Entry chooseEntry(NormalizedTarget target, List entries) { + if (entries.isEmpty()) { + return null; + } + if (entries.size() == 1) { + return entries.get(0); + } + List exact = new ArrayList<>(); + List prefix = new ArrayList<>(); + int bestPrefixLen = -1; + for (Entry entry : entries) { + MatchQuality quality = bestTargetMatch(entry, target); + if (quality.exact) { + exact.add(entry); + continue; + } + if (quality.prefixLength <= 0) { + continue; + } + if (quality.prefixLength > bestPrefixLen) { + prefix.clear(); + prefix.add(entry); + bestPrefixLen = quality.prefixLength; + } else if (quality.prefixLength == bestPrefixLen) { + prefix.add(entry); + } + } + if (exact.size() == 1) { + return exact.get(0); + } + if (exact.size() > 1 || prefix.isEmpty()) { + return null; + } + return prefix.size() == 1 ? prefix.get(0) : null; + } + + private static boolean entryMatchesHost(Entry entry, String host) { + for (NormalizedTarget target : entryTargets(entry)) { + if (target.host.equals(host)) { + return true; + } + } + return false; + } + + private static boolean entryMatchesParentHost(Entry entry, String host) { + for (NormalizedTarget target : entryTargets(entry)) { + if (!target.host.isEmpty() && host.endsWith("." + target.host)) { + return true; + } + } + return false; + } + + private static List entryTargets(Entry entry) { + List rawTargets = entry.targets; + if (rawTargets.isEmpty()) { + rawTargets = new ArrayList<>(); + rawTargets.add(entry.url); + } + List targets = new ArrayList<>(); + for (String rawTarget : rawTargets) { + NormalizedTarget target = normalize(rawTarget); + if (!target.host.isEmpty()) { + targets.add(target); + } + } + return targets; + } + + private static MatchQuality bestTargetMatch(Entry entry, NormalizedTarget target) { + int bestPrefixLen = -1; + for (NormalizedTarget entryTarget : entryTargets(entry)) { + if (entryTarget.url.equals(target.url)) { + return new MatchQuality(true, 0); + } + if (!"/".equals(entryTarget.path) && target.path.startsWith(entryTarget.path)) { + bestPrefixLen = Math.max(bestPrefixLen, entryTarget.path.length()); + } + } + return new MatchQuality(false, bestPrefixLen); + } + + static final class NormalizedTarget { + final String host; + final String path; + final String url; + + NormalizedTarget(String host, String path, String url) { + this.host = host; + this.path = path; + this.url = url; + } + } + + static final class Entry { + final String id; + final String title; + final String username; + final String password; + final String host; + final String url; + final List targets; + final List path; + + Entry(String id, String title, String username, String password, String host, String url, List targets, List path) { + this.id = id; + this.title = title; + this.username = username; + this.password = password; + this.host = host; + this.url = url; + this.targets = new ArrayList<>(targets); + this.path = new ArrayList<>(path); + } + } + + private static final class MatchQuality { + final boolean exact; + final int prefixLength; + + MatchQuality(boolean exact, int prefixLength) { + this.exact = exact; + this.prefixLength = prefixLength; + } + } +} diff --git a/androidsrc/org/julianfamily/keepassgo/KeePassGOAccessibilityService.java b/androidsrc/org/julianfamily/keepassgo/KeePassGOAccessibilityService.java index b347812..40d5f67 100644 --- a/androidsrc/org/julianfamily/keepassgo/KeePassGOAccessibilityService.java +++ b/androidsrc/org/julianfamily/keepassgo/KeePassGOAccessibilityService.java @@ -12,7 +12,6 @@ import java.util.List; public final class KeePassGOAccessibilityService extends AccessibilityService { private static final String TAG = "KeePassGOA11y"; - private String lastFilledSignature = ""; @Override @@ -22,22 +21,17 @@ public final class KeePassGOAccessibilityService extends AccessibilityService { if (root == null) { return; } - CharSequence packageName = root.getPackageName(); - if (packageName == null || !"com.android.chrome".contentEquals(packageName)) { - return; - } - - ChromeForm form = inspectChrome(root); + ChromeForm form = inspectWindow(root); if (form == null || form.passwordField == null) { return; } - AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, form.url); + AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, form.matchTarget); if (entry == null) { - Log.i(TAG, "no accessibility-fill match for " + form.url); + Log.i(TAG, "no accessibility-fill match for " + form.matchTarget); return; } - String signature = form.url + "|" + entry.username + "|" + nodeKey(form.passwordField); + String signature = form.matchTarget + "|" + entry.username + "|" + nodeKey(form.passwordField); if (signature.equals(lastFilledSignature)) { return; } @@ -49,7 +43,7 @@ public final class KeePassGOAccessibilityService extends AccessibilityService { filled |= setNodeText(form.passwordField, entry.password); if (filled) { lastFilledSignature = signature; - Log.i(TAG, "filled login form for " + form.url + " with " + entry.username); + Log.i(TAG, "filled login form for " + form.matchTarget + " with " + entry.username); } } catch (Exception err) { Log.e(TAG, "accessibility fill failed", err); @@ -61,11 +55,11 @@ public final class KeePassGOAccessibilityService extends AccessibilityService { Log.i(TAG, "accessibility service interrupted"); } - private static ChromeForm inspectChrome(AccessibilityNodeInfo root) { + private static ChromeForm inspectWindow(AccessibilityNodeInfo root) { List editables = new ArrayList<>(); ChromeForm form = new ChromeForm(); walk(root, editables, form); - if (form.url.isEmpty()) { + if (form.matchTarget.isEmpty()) { return null; } if (form.passwordField == null) { @@ -111,7 +105,13 @@ public final class KeePassGOAccessibilityService extends AccessibilityService { if (viewID != null && viewID.toString().endsWith(":id/url_bar")) { CharSequence text = node.getText(); if (text != null) { - form.url = text.toString().trim(); + form.webDomain = text.toString().trim(); + } + } + if (form.packageName.isEmpty()) { + CharSequence packageName = node.getPackageName(); + if (packageName != null) { + form.packageName = packageName.toString().trim(); } } if (node.isEditable()) { @@ -128,6 +128,7 @@ public final class KeePassGOAccessibilityService extends AccessibilityService { walk(child, editables, form); } } + form.matchTarget = AutofillFallbackTarget.resolve(form.packageName, form.webDomain); } private static boolean isPasswordNode(AccessibilityNodeInfo node) { @@ -188,7 +189,9 @@ public final class KeePassGOAccessibilityService extends AccessibilityService { } private static final class ChromeForm { - String url = ""; + String packageName = ""; + String webDomain = ""; + String matchTarget = ""; AccessibilityNodeInfo usernameField; AccessibilityNodeInfo passwordField; } diff --git a/androidtestsrc/org/julianfamily/keepassgo/AutofillCacheStoreBehaviorTest.java b/androidtestsrc/org/julianfamily/keepassgo/AutofillCacheStoreBehaviorTest.java new file mode 100644 index 0000000..2ba5e77 --- /dev/null +++ b/androidtestsrc/org/julianfamily/keepassgo/AutofillCacheStoreBehaviorTest.java @@ -0,0 +1,75 @@ +package org.julianfamily.keepassgo; + +import java.util.Arrays; +import java.util.ArrayList; +import java.util.List; + +public final class AutofillCacheStoreBehaviorTest { + public static void main(String[] args) { + testFindBestMatchUsesAndroidAppTargets(); + testChooserCandidatesCollapseToExactAndroidAppMatch(); + } + + private static void testFindBestMatchUsesAndroidAppTargets() { + List entries = new ArrayList<>(); + entries.add(new AutofillTargetMatcher.Entry( + "blink-entry", + "Blink", + "linuscaldwell", + "bellagio-stack", + "account.blinknetwork.com", + "https://account.blinknetwork.com", + Arrays.asList("https://account.blinknetwork.com", "androidapp://com.blinknetwork.mobile2"), + Arrays.asList("Crew", "Apps") + )); + + AutofillTargetMatcher.Entry got = AutofillTargetMatcher.findBestMatch(entries, "androidapp://com.blinknetwork.mobile2"); + if (got == null || !"blink-entry".equals(got.id)) { + throw new AssertionError("findBestMatch(entries, androidapp target) = " + describe(got) + ", want blink-entry"); + } + } + + private static void testChooserCandidatesCollapseToExactAndroidAppMatch() { + List entries = new ArrayList<>(); + entries.add(new AutofillTargetMatcher.Entry( + "blink-entry", + "Blink", + "linuscaldwell", + "bellagio-stack", + "account.blinknetwork.com", + "https://account.blinknetwork.com", + Arrays.asList("https://account.blinknetwork.com", "androidapp://com.blinknetwork.mobile2"), + Arrays.asList("Crew", "Apps") + )); + entries.add(new AutofillTargetMatcher.Entry( + "night-fox-entry", + "Night Fox", + "nightfox", + "vault-code", + "gitlab.com", + "https://gitlab.com", + Arrays.asList("https://gitlab.com"), + Arrays.asList("Crew", "Internet") + )); + + List got = AutofillTargetMatcher.chooserCandidates(entries, "androidapp://com.blinknetwork.mobile2"); + if (got.size() != 1 || !"blink-entry".equals(got.get(0).id)) { + throw new AssertionError("chooserCandidates(entries, androidapp target) = " + describe(got) + ", want [blink-entry]"); + } + } + + private static String describe(AutofillTargetMatcher.Entry entry) { + if (entry == null) { + return "null"; + } + return entry.id; + } + + private static String describe(List entries) { + List ids = new ArrayList<>(); + for (AutofillTargetMatcher.Entry entry : entries) { + ids.add(entry.id); + } + return ids.toString(); + } +} diff --git a/androidtestsrc/org/julianfamily/keepassgo/AutofillFallbackTargetBehaviorTest.java b/androidtestsrc/org/julianfamily/keepassgo/AutofillFallbackTargetBehaviorTest.java new file mode 100644 index 0000000..1cdc4e2 --- /dev/null +++ b/androidtestsrc/org/julianfamily/keepassgo/AutofillFallbackTargetBehaviorTest.java @@ -0,0 +1,30 @@ +package org.julianfamily.keepassgo; + +public final class AutofillFallbackTargetBehaviorTest { + public static void main(String[] args) { + testPrefersWebDomainWhenPresent(); + testFallsBackToAndroidAppPackage(); + testEmptyWhenNeitherSignalExists(); + } + + private static void testPrefersWebDomainWhenPresent() { + String got = AutofillFallbackTarget.resolve("com.android.chrome", "gitlab.com"); + if (!"gitlab.com".equals(got)) { + throw new AssertionError("resolve(chrome, gitlab.com) = " + got + ", want gitlab.com"); + } + } + + private static void testFallsBackToAndroidAppPackage() { + String got = AutofillFallbackTarget.resolve("com.blinknetwork.mobile2", ""); + if (!"androidapp://com.blinknetwork.mobile2".equals(got)) { + throw new AssertionError("resolve(package-only) = " + got + ", want androidapp://com.blinknetwork.mobile2"); + } + } + + private static void testEmptyWhenNeitherSignalExists() { + String got = AutofillFallbackTarget.resolve("", ""); + if (!"".equals(got)) { + throw new AssertionError("resolve(empty) = " + got + ", want empty"); + } + } +} diff --git a/docs/android-autofill.md b/docs/android-autofill.md new file mode 100644 index 0000000..2e7780f --- /dev/null +++ b/docs/android-autofill.md @@ -0,0 +1,38 @@ +# Android Autofill + +## App Target Matching + +User story: + +- When an entry carries an Android-specific target such as + `androidapp://com.blinknetwork.mobile2`, KeePassGO should treat that as a + first-class autofill target on Android. +- If an exact app target exists, Android autofill should resolve that entry + directly instead of falling back to a generic chooser for the whole cache. + +Expected behavior: + +- `AndroidApp*` custom fields exported into the autofill cache must match the + Android package target used by the autofill and accessibility services. +- The Android-side matcher must normalize `androidapp://...` targets the same + way the Go cache builder does. +- The chooser path should still collapse to a single direct result when there + is one exact app-target match. + +## Accessibility Fallback + +User story: + +- When Android accessibility fallback is needed, KeePassGO should not be + limited to Chrome-only URL bar parsing. +- Apps with stable package identities should still be fillable when an entry + carries a matching `AndroidApp*` target. + +Expected behavior: + +- Accessibility fallback derives its match target from the web domain when one + is available. +- If no web domain is available, accessibility fallback uses the active app + package as `androidapp://`. +- The fallback path can therefore fill supported apps that never expose a + browser-style URL bar.