Scope Android autofill candidates
ci / lint-test (pull_request) Successful in 5m45s
ci / build (pull_request) Successful in 6m19s

This commit is contained in:
Joe Julian
2026-04-27 19:50:55 -07:00
parent 586de0169d
commit d18627cda4
4 changed files with 196 additions and 30 deletions
@@ -10,9 +10,7 @@ import java.io.IOException;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Locale;
final class AutofillCacheStore { final class AutofillCacheStore {
private static final String TAG = "KeePassGOAutofill"; private static final String TAG = "KeePassGOAutofill";
@@ -41,21 +39,25 @@ final class AutofillCacheStore {
} }
static List<Entry> chooserCandidates(Context context, String rawTarget) { static List<Entry> chooserCandidates(Context context, String rawTarget) {
List<Entry> entries = readEntries(context); return chooserCandidatesFromEntries(readEntries(context), rawTarget);
}
static List<Entry> chooserCandidatesFromEntries(List<Entry> entries, String rawTarget) {
if (entries.isEmpty()) { if (entries.isEmpty()) {
return entries; return entries;
} }
Entry direct = fromMatcherEntry(AutofillTargetMatcher.findBestMatch(toMatcherEntries(entries), rawTarget)); return fromMatcherEntries(AutofillTargetMatcher.chooserCandidates(toMatcherEntries(entries), rawTarget));
if (direct != null) { }
List<Entry> resolved = new ArrayList<>();
resolved.add(direct); static List<Entry> relevantCandidates(Context context, String rawTarget) {
return resolved; return relevantCandidatesFromEntries(readEntries(context), rawTarget);
}
static List<Entry> relevantCandidatesFromEntries(List<Entry> entries, String rawTarget) {
if (entries.isEmpty()) {
return entries;
} }
entries.sort(Comparator return fromMatcherEntries(AutofillTargetMatcher.relevantCandidates(toMatcherEntries(entries), rawTarget));
.comparing((Entry entry) -> entry.title.toLowerCase(Locale.US))
.thenComparing(entry -> String.join("/", entry.path).toLowerCase(Locale.US))
.thenComparing(entry -> entry.id));
return entries;
} }
private static List<AutofillTargetMatcher.Entry> toMatcherEntries(List<Entry> entries) { private static List<AutofillTargetMatcher.Entry> toMatcherEntries(List<Entry> 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); return new Entry(entry.id, entry.title, entry.username, entry.password, entry.host, entry.url, entry.targets, entry.path);
} }
private static List<Entry> fromMatcherEntries(List<AutofillTargetMatcher.Entry> entries) {
List<Entry> converted = new ArrayList<>(entries.size());
for (AutofillTargetMatcher.Entry entry : entries) {
converted.add(fromMatcherEntry(entry));
}
return converted;
}
private static List<Entry> readEntries(Context context) { private static List<Entry> readEntries(Context context) {
File cacheFile = findCacheFile(context); File cacheFile = findCacheFile(context);
if (cacheFile == null) { if (cacheFile == null) {
@@ -37,6 +37,17 @@ final class AutofillTargetMatcher {
} }
static List<Entry> chooserCandidates(List<Entry> entries, String rawTarget) { static List<Entry> chooserCandidates(List<Entry> entries, String rawTarget) {
if (entries == null || entries.isEmpty()) {
return new ArrayList<>();
}
List<Entry> related = relevantCandidates(entries, rawTarget);
if (!related.isEmpty()) {
return related;
}
return sortEntries(entries);
}
static List<Entry> relevantCandidates(List<Entry> entries, String rawTarget) {
if (entries == null || entries.isEmpty()) { if (entries == null || entries.isEmpty()) {
return new ArrayList<>(); return new ArrayList<>();
} }
@@ -47,6 +58,9 @@ final class AutofillTargetMatcher {
return resolved; return resolved;
} }
NormalizedTarget target = normalize(rawTarget); NormalizedTarget target = normalize(rawTarget);
if (target.host.isEmpty()) {
return new ArrayList<>();
}
List<Entry> exactHost = new ArrayList<>(); List<Entry> exactHost = new ArrayList<>();
List<Entry> parentHost = new ArrayList<>(); List<Entry> parentHost = new ArrayList<>();
for (Entry entry : entries) { for (Entry entry : entries) {
@@ -64,7 +78,7 @@ final class AutofillTargetMatcher {
if (!parentHost.isEmpty()) { if (!parentHost.isEmpty()) {
return sortEntries(parentHost); return sortEntries(parentHost);
} }
return sortEntries(entries); return new ArrayList<>();
} }
static NormalizedTarget normalize(String raw) { static NormalizedTarget normalize(String raw) {
@@ -68,17 +68,31 @@ public final class KeePassGOAutofillService extends AutofillService {
return; return;
} }
AutofillCacheStore.Entry entry = findBoundOrBestMatch(target.matchTarget); AutofillCacheStore.Entry entry = findBoundEntry(target.matchTarget);
if (entry == null) { if (entry == null) {
FillResponse chooser = chooserResponse(target, fields); List<AutofillCacheStore.Entry> candidates = AutofillCacheStore.relevantCandidates(this, target.matchTarget);
if (chooser == null) { 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"); Log.i(TAG, "no autofill cache match");
callback.onSuccess(null); callback.onSuccess(null);
return; 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); 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(); callback.onSuccess();
} }
private AutofillCacheStore.Entry findBoundOrBestMatch(String matchTarget) { private AutofillCacheStore.Entry findBoundEntry(String matchTarget) {
String entryID = AutofillBindingStore.entryIDForTarget(this, matchTarget); String entryID = AutofillBindingStore.entryIDForTarget(this, matchTarget);
if (!entryID.isEmpty()) { if (!entryID.isEmpty()) {
AutofillCacheStore.Entry bound = AutofillCacheStore.findByID(this, entryID); AutofillCacheStore.Entry bound = AutofillCacheStore.findByID(this, entryID);
@@ -105,15 +119,30 @@ public final class KeePassGOAutofillService extends AutofillService {
return bound; return bound;
} }
} }
return AutofillCacheStore.findBestMatch(this, matchTarget); return null;
} }
private FillResponse directFillResponse(AutofillCacheStore.Entry entry, ParsedFields fields) { private FillResponse directFillResponse(AutofillCacheStore.Entry entry, ParsedFields fields) {
return new FillResponse.Builder()
.addDataset(datasetForEntry(entry, fields))
.build();
}
private FillResponse candidatesFillResponse(List<AutofillCacheStore.Entry> 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); RemoteViews presentation = new RemoteViews(getPackageName(), android.R.layout.simple_list_item_1);
presentation.setTextViewText( String label = entry.title == null || entry.title.trim().isEmpty() ? "KeePassGO entry" : entry.title;
android.R.id.text1, if (entry.username != null && !entry.username.trim().isEmpty()) {
entry.title + " (" + entry.username + ")" label += " (" + entry.username + ")";
); }
presentation.setTextViewText(android.R.id.text1, label);
Dataset.Builder dataset = new Dataset.Builder(presentation); Dataset.Builder dataset = new Dataset.Builder(presentation);
dataset.setId(entry.id); 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.usernameId, AutofillValue.forText(entry.username));
} }
dataset.setValue(fields.passwordId, AutofillValue.forText(entry.password)); dataset.setValue(fields.passwordId, AutofillValue.forText(entry.password));
return dataset.build();
return new FillResponse.Builder()
.addDataset(dataset.build())
.build();
} }
private FillResponse chooserResponse(ParsedTarget target, ParsedFields fields) { private FillResponse chooserResponse(ParsedTarget target, ParsedFields fields) {
@@ -10,6 +10,9 @@ public final class AutofillCacheStoreBehaviorTest {
testChooserCandidatesCollapseToExactAndroidAppMatch(); testChooserCandidatesCollapseToExactAndroidAppMatch();
testChooserCandidatesStayScopedToExactHostMatches(); testChooserCandidatesStayScopedToExactHostMatches();
testChooserCandidatesStayScopedToParentHostMatches(); testChooserCandidatesStayScopedToParentHostMatches();
testCacheStoreChooserCandidatesStayScopedToExactHostMatches();
testCacheStoreChooserCandidatesFallBackToAllEntriesOnlyWhenUnrelated();
testCacheStoreRelevantCandidatesDoNotFallBackToAllEntries();
} }
private static void testFindBestMatchUsesAndroidAppTargets() { private static void testFindBestMatchUsesAndroidAppTargets() {
@@ -128,6 +131,103 @@ public final class AutofillCacheStoreBehaviorTest {
} }
} }
private static void testCacheStoreChooserCandidatesStayScopedToExactHostMatches() {
List<AutofillCacheStore.Entry> 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<AutofillCacheStore.Entry> 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<AutofillCacheStore.Entry> 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<AutofillCacheStore.Entry> 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<AutofillCacheStore.Entry> 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<AutofillCacheStore.Entry> 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) { private static String describe(AutofillTargetMatcher.Entry entry) {
if (entry == null) { if (entry == null) {
return "null"; return "null";
@@ -150,4 +250,20 @@ public final class AutofillCacheStoreBehaviorTest {
} }
return ids.containsAll(Arrays.asList(wantIDs)) && ids.size() == wantIDs.length; return ids.containsAll(Arrays.asList(wantIDs)) && ids.size() == wantIDs.length;
} }
private static String describeStore(List<AutofillCacheStore.Entry> entries) {
List<String> ids = new ArrayList<>();
for (AutofillCacheStore.Entry entry : entries) {
ids.add(entry.id);
}
return ids.toString();
}
private static boolean containsStoreIDs(List<AutofillCacheStore.Entry> entries, String... wantIDs) {
List<String> ids = new ArrayList<>();
for (AutofillCacheStore.Entry entry : entries) {
ids.add(entry.id);
}
return ids.containsAll(Arrays.asList(wantIDs)) && ids.size() == wantIDs.length;
}
} }