From d18627cda4a51bafdb9215ba65c5e3775352cfbb Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Mon, 27 Apr 2026 19:50:55 -0700 Subject: [PATCH] Scope Android autofill candidates --- .../keepassgo/AutofillCacheStore.java | 36 ++++-- .../keepassgo/AutofillTargetMatcher.java | 16 ++- .../keepassgo/KeePassGOAutofillService.java | 58 ++++++--- .../AutofillCacheStoreBehaviorTest.java | 116 ++++++++++++++++++ 4 files changed, 196 insertions(+), 30 deletions(-) diff --git a/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java b/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java index 187dc49..f2cc641 100644 --- a/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java +++ b/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java @@ -10,9 +10,7 @@ import java.io.IOException; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.util.ArrayList; -import java.util.Comparator; import java.util.List; -import java.util.Locale; final class AutofillCacheStore { private static final String TAG = "KeePassGOAutofill"; @@ -41,21 +39,25 @@ final class AutofillCacheStore { } static List chooserCandidates(Context context, String rawTarget) { - List entries = readEntries(context); + return chooserCandidatesFromEntries(readEntries(context), rawTarget); + } + + static List chooserCandidatesFromEntries(List entries, String rawTarget) { if (entries.isEmpty()) { return entries; } - Entry direct = fromMatcherEntry(AutofillTargetMatcher.findBestMatch(toMatcherEntries(entries), rawTarget)); - if (direct != null) { - List resolved = new ArrayList<>(); - resolved.add(direct); - return resolved; + return fromMatcherEntries(AutofillTargetMatcher.chooserCandidates(toMatcherEntries(entries), rawTarget)); + } + + static List relevantCandidates(Context context, String rawTarget) { + return relevantCandidatesFromEntries(readEntries(context), rawTarget); + } + + static List relevantCandidatesFromEntries(List entries, String rawTarget) { + if (entries.isEmpty()) { + return entries; } - entries.sort(Comparator - .comparing((Entry entry) -> entry.title.toLowerCase(Locale.US)) - .thenComparing(entry -> String.join("/", entry.path).toLowerCase(Locale.US)) - .thenComparing(entry -> entry.id)); - return entries; + return fromMatcherEntries(AutofillTargetMatcher.relevantCandidates(toMatcherEntries(entries), rawTarget)); } private static List toMatcherEntries(List entries) { @@ -82,6 +84,14 @@ final class AutofillCacheStore { return new Entry(entry.id, entry.title, entry.username, entry.password, entry.host, entry.url, entry.targets, entry.path); } + private static List fromMatcherEntries(List entries) { + List converted = new ArrayList<>(entries.size()); + for (AutofillTargetMatcher.Entry entry : entries) { + converted.add(fromMatcherEntry(entry)); + } + return converted; + } + private static List readEntries(Context context) { File cacheFile = findCacheFile(context); if (cacheFile == null) { diff --git a/androidsrc/org/julianfamily/keepassgo/AutofillTargetMatcher.java b/androidsrc/org/julianfamily/keepassgo/AutofillTargetMatcher.java index 706da68..1b2462b 100644 --- a/androidsrc/org/julianfamily/keepassgo/AutofillTargetMatcher.java +++ b/androidsrc/org/julianfamily/keepassgo/AutofillTargetMatcher.java @@ -37,6 +37,17 @@ final class AutofillTargetMatcher { } static List chooserCandidates(List entries, String rawTarget) { + if (entries == null || entries.isEmpty()) { + return new ArrayList<>(); + } + List related = relevantCandidates(entries, rawTarget); + if (!related.isEmpty()) { + return related; + } + return sortEntries(entries); + } + + static List relevantCandidates(List entries, String rawTarget) { if (entries == null || entries.isEmpty()) { return new ArrayList<>(); } @@ -47,6 +58,9 @@ final class AutofillTargetMatcher { return resolved; } NormalizedTarget target = normalize(rawTarget); + if (target.host.isEmpty()) { + return new ArrayList<>(); + } List exactHost = new ArrayList<>(); List parentHost = new ArrayList<>(); for (Entry entry : entries) { @@ -64,7 +78,7 @@ final class AutofillTargetMatcher { if (!parentHost.isEmpty()) { return sortEntries(parentHost); } - return sortEntries(entries); + return new ArrayList<>(); } static NormalizedTarget normalize(String raw) { diff --git a/androidsrc/org/julianfamily/keepassgo/KeePassGOAutofillService.java b/androidsrc/org/julianfamily/keepassgo/KeePassGOAutofillService.java index 557e855..2686c4b 100644 --- a/androidsrc/org/julianfamily/keepassgo/KeePassGOAutofillService.java +++ b/androidsrc/org/julianfamily/keepassgo/KeePassGOAutofillService.java @@ -68,17 +68,31 @@ public final class KeePassGOAutofillService extends AutofillService { return; } - AutofillCacheStore.Entry entry = findBoundOrBestMatch(target.matchTarget); + AutofillCacheStore.Entry entry = findBoundEntry(target.matchTarget); if (entry == null) { - FillResponse chooser = chooserResponse(target, fields); - if (chooser == null) { + List candidates = AutofillCacheStore.relevantCandidates(this, target.matchTarget); + if (candidates.isEmpty()) { + FillResponse chooser = chooserResponse(target, fields); + if (chooser == null) { + Log.i(TAG, "no autofill cache match"); + callback.onSuccess(null); + return; + } + Log.i(TAG, "returning searchable chooser dataset for " + target.matchTarget); + callback.onSuccess(chooser); + return; + } + if (candidates.size() > 1) { + Log.i(TAG, "returning " + candidates.size() + " scoped autofill datasets for " + target.matchTarget); + callback.onSuccess(candidatesFillResponse(candidates, fields)); + return; + } + entry = candidates.get(0); + if (entry == null) { Log.i(TAG, "no autofill cache match"); callback.onSuccess(null); return; } - Log.i(TAG, "returning chooser dataset for " + target.matchTarget); - callback.onSuccess(chooser); - return; } Log.i(TAG, "matched entry title=" + entry.title + " user=" + entry.username + " host=" + entry.host); @@ -97,7 +111,7 @@ public final class KeePassGOAutofillService extends AutofillService { callback.onSuccess(); } - private AutofillCacheStore.Entry findBoundOrBestMatch(String matchTarget) { + private AutofillCacheStore.Entry findBoundEntry(String matchTarget) { String entryID = AutofillBindingStore.entryIDForTarget(this, matchTarget); if (!entryID.isEmpty()) { AutofillCacheStore.Entry bound = AutofillCacheStore.findByID(this, entryID); @@ -105,15 +119,30 @@ public final class KeePassGOAutofillService extends AutofillService { return bound; } } - return AutofillCacheStore.findBestMatch(this, matchTarget); + return null; } private FillResponse directFillResponse(AutofillCacheStore.Entry entry, ParsedFields fields) { + return new FillResponse.Builder() + .addDataset(datasetForEntry(entry, fields)) + .build(); + } + + private FillResponse candidatesFillResponse(List entries, ParsedFields fields) { + FillResponse.Builder response = new FillResponse.Builder(); + for (AutofillCacheStore.Entry entry : entries) { + response.addDataset(datasetForEntry(entry, fields)); + } + return response.build(); + } + + private Dataset datasetForEntry(AutofillCacheStore.Entry entry, ParsedFields fields) { RemoteViews presentation = new RemoteViews(getPackageName(), android.R.layout.simple_list_item_1); - presentation.setTextViewText( - android.R.id.text1, - entry.title + " (" + entry.username + ")" - ); + String label = entry.title == null || entry.title.trim().isEmpty() ? "KeePassGO entry" : entry.title; + if (entry.username != null && !entry.username.trim().isEmpty()) { + label += " (" + entry.username + ")"; + } + presentation.setTextViewText(android.R.id.text1, label); Dataset.Builder dataset = new Dataset.Builder(presentation); dataset.setId(entry.id); @@ -121,10 +150,7 @@ public final class KeePassGOAutofillService extends AutofillService { dataset.setValue(fields.usernameId, AutofillValue.forText(entry.username)); } dataset.setValue(fields.passwordId, AutofillValue.forText(entry.password)); - - return new FillResponse.Builder() - .addDataset(dataset.build()) - .build(); + return dataset.build(); } private FillResponse chooserResponse(ParsedTarget target, ParsedFields fields) { diff --git a/androidtestsrc/org/julianfamily/keepassgo/AutofillCacheStoreBehaviorTest.java b/androidtestsrc/org/julianfamily/keepassgo/AutofillCacheStoreBehaviorTest.java index 6c29946..ef4e4e9 100644 --- a/androidtestsrc/org/julianfamily/keepassgo/AutofillCacheStoreBehaviorTest.java +++ b/androidtestsrc/org/julianfamily/keepassgo/AutofillCacheStoreBehaviorTest.java @@ -10,6 +10,9 @@ public final class AutofillCacheStoreBehaviorTest { testChooserCandidatesCollapseToExactAndroidAppMatch(); testChooserCandidatesStayScopedToExactHostMatches(); testChooserCandidatesStayScopedToParentHostMatches(); + testCacheStoreChooserCandidatesStayScopedToExactHostMatches(); + testCacheStoreChooserCandidatesFallBackToAllEntriesOnlyWhenUnrelated(); + testCacheStoreRelevantCandidatesDoNotFallBackToAllEntries(); } private static void testFindBestMatchUsesAndroidAppTargets() { @@ -128,6 +131,103 @@ public final class AutofillCacheStoreBehaviorTest { } } + private static void testCacheStoreChooserCandidatesStayScopedToExactHostMatches() { + List entries = new ArrayList<>(); + entries.add(new AutofillCacheStore.Entry( + "bellagio-primary", + "Bellagio Primary", + "dannyocean", + "vault-code", + "bellagio.example.invalid", + "https://bellagio.example.invalid/login", + Arrays.asList("https://bellagio.example.invalid/login"), + Arrays.asList("Crew", "Internet") + )); + entries.add(new AutofillCacheStore.Entry( + "bellagio-backup", + "Bellagio Backup", + "rustyryan", + "backup-code", + "bellagio.example.invalid", + "https://bellagio.example.invalid/admin", + Arrays.asList("https://bellagio.example.invalid/admin"), + Arrays.asList("Crew", "Internet") + )); + entries.add(new AutofillCacheStore.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 = AutofillCacheStore.chooserCandidatesFromEntries(entries, "https://bellagio.example.invalid/security"); + if (got.size() != 2 || !containsStoreIDs(got, "bellagio-primary", "bellagio-backup")) { + throw new AssertionError("AutofillCacheStore chooser candidates = " + describeStore(got) + ", want only Bellagio entries"); + } + } + + private static void testCacheStoreChooserCandidatesFallBackToAllEntriesOnlyWhenUnrelated() { + List entries = new ArrayList<>(); + entries.add(new AutofillCacheStore.Entry( + "bellagio-primary", + "Bellagio Primary", + "dannyocean", + "vault-code", + "bellagio.example.invalid", + "https://bellagio.example.invalid/login", + Arrays.asList("https://bellagio.example.invalid/login"), + Arrays.asList("Crew", "Internet") + )); + entries.add(new AutofillCacheStore.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 = AutofillCacheStore.chooserCandidatesFromEntries(entries, "https://tessio.example.invalid/login"); + if (got.size() != 2 || !containsStoreIDs(got, "bellagio-primary", "night-fox-entry")) { + throw new AssertionError("AutofillCacheStore unrelated chooser candidates = " + describeStore(got) + ", want full fallback list"); + } + } + + private static void testCacheStoreRelevantCandidatesDoNotFallBackToAllEntries() { + List entries = new ArrayList<>(); + entries.add(new AutofillCacheStore.Entry( + "bellagio-primary", + "Bellagio Primary", + "dannyocean", + "vault-code", + "bellagio.example.invalid", + "https://bellagio.example.invalid/login", + Arrays.asList("https://bellagio.example.invalid/login"), + Arrays.asList("Crew", "Internet") + )); + entries.add(new AutofillCacheStore.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 = AutofillCacheStore.relevantCandidatesFromEntries(entries, "https://tessio.example.invalid/login"); + if (!got.isEmpty()) { + throw new AssertionError("AutofillCacheStore relevant unrelated candidates = " + describeStore(got) + ", want []"); + } + } + private static String describe(AutofillTargetMatcher.Entry entry) { if (entry == null) { return "null"; @@ -150,4 +250,20 @@ public final class AutofillCacheStoreBehaviorTest { } return ids.containsAll(Arrays.asList(wantIDs)) && ids.size() == wantIDs.length; } + + private static String describeStore(List entries) { + List ids = new ArrayList<>(); + for (AutofillCacheStore.Entry entry : entries) { + ids.add(entry.id); + } + return ids.toString(); + } + + private static boolean containsStoreIDs(List entries, String... wantIDs) { + List ids = new ArrayList<>(); + for (AutofillCacheStore.Entry entry : entries) { + ids.add(entry.id); + } + return ids.containsAll(Arrays.asList(wantIDs)) && ids.size() == wantIDs.length; + } } -- 2.52.0