From 2431467aa72ff40ebc5e2477abbb2032cb74b5a0 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Mon, 13 Apr 2026 22:02:51 -0700 Subject: [PATCH] Add Android autofill chooser and app binding --- android/application_snippets.xml | 4 + .../keepassgo/AutofillBindingStore.java | 116 +++++++++++ .../keepassgo/AutofillCacheStore.java | 78 ++++++-- .../KeePassGOAutofillPickerActivity.java | 182 ++++++++++++++++++ .../keepassgo/KeePassGOAutofillService.java | 96 +++++++-- 5 files changed, 444 insertions(+), 32 deletions(-) create mode 100644 androidsrc/org/julianfamily/keepassgo/AutofillBindingStore.java create mode 100644 androidsrc/org/julianfamily/keepassgo/KeePassGOAutofillPickerActivity.java diff --git a/android/application_snippets.xml b/android/application_snippets.xml index 8889103..4b05a19 100644 --- a/android/application_snippets.xml +++ b/android/application_snippets.xml @@ -22,6 +22,10 @@ android:name="android.accessibilityservice" android:resource="@xml/keepassgo_accessibility_service" /> + entry : bindings.apps.entrySet()) { + writer.name(entry.getKey()).value(entry.getValue()); + } + writer.endObject(); + writer.endObject(); + } catch (IOException err) { + Log.e(TAG, "failed to write autofill bindings", err); + } + } + + private static String nextString(JsonReader reader) throws IOException { + if (reader.peek() == android.util.JsonToken.NULL) { + reader.nextNull(); + return ""; + } + return reader.nextString(); + } + + private static File path(Context context) { + return new File(new File(context.getFilesDir(), "keepassgo"), "autofill-bindings.json"); + } + + private static String normalize(String rawTarget) { + return rawTarget == null ? "" : rawTarget.trim(); + } + + private static final class Bindings { + String updatedAt = ""; + final Map apps = new LinkedHashMap<>(); + } +} diff --git a/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java b/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java index 770f949..5686193 100644 --- a/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java +++ b/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java @@ -10,6 +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; @@ -20,18 +21,7 @@ final class AutofillCacheStore { } static Entry findBestMatch(Context context, String webDomain) { - File cacheFile = findCacheFile(context); - if (cacheFile == null) { - Log.i(TAG, "autofill cache file not found"); - return null; - } - List entries; - try { - entries = readEntries(cacheFile); - } catch (IOException err) { - Log.e(TAG, "failed to read autofill cache", err); - return null; - } + List entries = readEntries(context); if (entries.isEmpty()) { return null; } @@ -57,6 +47,50 @@ final class AutofillCacheStore { return chooseEntry(target, parentHost); } + static Entry findByID(Context context, String entryID) { + if (entryID == null || entryID.trim().isEmpty()) { + return null; + } + for (Entry entry : readEntries(context)) { + if (entryID.equals(entry.id)) { + return entry; + } + } + return null; + } + + static List chooserCandidates(Context context, String rawTarget) { + List entries = readEntries(context); + if (entries.isEmpty()) { + return entries; + } + Entry direct = findBestMatch(context, rawTarget); + if (direct != null) { + List resolved = new ArrayList<>(); + resolved.add(direct); + return resolved; + } + 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; + } + + private static List readEntries(Context context) { + File cacheFile = findCacheFile(context); + if (cacheFile == null) { + Log.i(TAG, "autofill cache file not found"); + return new ArrayList<>(); + } + try { + return readEntries(cacheFile); + } catch (IOException err) { + Log.e(TAG, "failed to read autofill cache", err); + return new ArrayList<>(); + } + } + private static File findCacheFile(Context context) { List candidates = new ArrayList<>(); File filesDir = context.getFilesDir(); @@ -103,16 +137,21 @@ final class AutofillCacheStore { } private static Entry readEntry(JsonReader reader) throws IOException { + String id = ""; String title = ""; String username = ""; String password = ""; String host = ""; String url = ""; List targets = new ArrayList<>(); + List path = new ArrayList<>(); reader.beginObject(); while (reader.hasNext()) { String name = reader.nextName(); switch (name) { + case "id": + id = nextString(reader); + break; case "title": title = nextString(reader); break; @@ -135,13 +174,20 @@ final class AutofillCacheStore { } reader.endArray(); break; + case "path": + reader.beginArray(); + while (reader.hasNext()) { + path.add(nextString(reader)); + } + reader.endArray(); + break; default: reader.skipValue(); break; } } reader.endObject(); - return new Entry(title, username, password, host, url, targets); + return new Entry(id, title, username, password, host, url, targets, path); } private static String nextString(JsonReader reader) throws IOException { @@ -293,20 +339,24 @@ final class AutofillCacheStore { } 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 title, String username, String password, String host, String url, List targets) { + 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); } } diff --git a/androidsrc/org/julianfamily/keepassgo/KeePassGOAutofillPickerActivity.java b/androidsrc/org/julianfamily/keepassgo/KeePassGOAutofillPickerActivity.java new file mode 100644 index 0000000..6236036 --- /dev/null +++ b/androidsrc/org/julianfamily/keepassgo/KeePassGOAutofillPickerActivity.java @@ -0,0 +1,182 @@ +package org.julianfamily.keepassgo; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.text.Editable; +import android.text.TextWatcher; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.View; +import android.view.autofill.AutofillId; +import android.view.autofill.AutofillManager; +import android.view.autofill.AutofillValue; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.ListView; +import android.widget.TextView; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import android.service.autofill.Dataset; + +public final class KeePassGOAutofillPickerActivity extends Activity { + static final String EXTRA_TARGET = "org.julianfamily.keepassgo.extra.AUTOFILL_TARGET"; + static final String EXTRA_PACKAGE_NAME = "org.julianfamily.keepassgo.extra.AUTOFILL_PACKAGE"; + static final String EXTRA_USERNAME_ID = "org.julianfamily.keepassgo.extra.USERNAME_ID"; + static final String EXTRA_PASSWORD_ID = "org.julianfamily.keepassgo.extra.PASSWORD_ID"; + + private final List allEntries = new ArrayList<>(); + private final List visibleEntries = new ArrayList<>(); + private ArrayAdapter adapter; + private String matchTarget = ""; + private String packageName = ""; + private AutofillId usernameID; + private AutofillId passwordID; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + Intent intent = getIntent(); + matchTarget = intent.getStringExtra(EXTRA_TARGET); + packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME); + usernameID = intent.getParcelableExtra(EXTRA_USERNAME_ID, AutofillId.class); + passwordID = intent.getParcelableExtra(EXTRA_PASSWORD_ID, AutofillId.class); + + LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + int padding = dp(16); + root.setPadding(padding, padding, padding, padding); + + TextView title = new TextView(this); + title.setTextSize(TypedValue.COMPLEX_UNIT_SP, 20); + title.setGravity(Gravity.START); + title.setPadding(0, 0, 0, dp(8)); + title.setText(packageName == null || packageName.trim().isEmpty() ? "Search KeePassGO" : "Search KeePassGO for " + packageName); + root.addView(title, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + )); + + EditText search = new EditText(this); + search.setHint("Search entries"); + root.addView(search, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + )); + + allEntries.clear(); + allEntries.addAll(AutofillCacheStore.chooserCandidates(this, matchTarget)); + visibleEntries.clear(); + visibleEntries.addAll(allEntries); + + adapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, labelsFor(visibleEntries)); + ListView list = new ListView(this); + list.setAdapter(adapter); + list.setOnItemClickListener(this::onEntrySelected); + + TextView empty = new TextView(this); + empty.setPadding(0, dp(16), 0, 0); + empty.setText("No KeePassGO entries are available for autofill."); + empty.setVisibility(allEntries.isEmpty() ? View.VISIBLE : View.GONE); + list.setVisibility(allEntries.isEmpty() ? View.GONE : View.VISIBLE); + root.addView(empty, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + )); + LinearLayout.LayoutParams listParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + 0 + ); + listParams.weight = 1f; + listParams.topMargin = dp(12); + root.addView(list, listParams); + + search.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + filterEntries(s == null ? "" : s.toString()); + } + + @Override + public void afterTextChanged(Editable s) { + } + }); + + setContentView(root); + } + + private void filterEntries(String rawQuery) { + String query = rawQuery == null ? "" : rawQuery.trim().toLowerCase(Locale.US); + visibleEntries.clear(); + if (query.isEmpty()) { + visibleEntries.addAll(allEntries); + } else { + for (AutofillCacheStore.Entry entry : allEntries) { + if (entryLabel(entry).toLowerCase(Locale.US).contains(query)) { + visibleEntries.add(entry); + } + } + } + adapter.clear(); + adapter.addAll(labelsFor(visibleEntries)); + adapter.notifyDataSetChanged(); + } + + private void onEntrySelected(AdapterView parent, View view, int position, long id) { + if (position < 0 || position >= visibleEntries.size() || passwordID == null) { + setResult(Activity.RESULT_CANCELED); + finish(); + return; + } + AutofillCacheStore.Entry entry = visibleEntries.get(position); + if (matchTarget != null && matchTarget.startsWith("androidapp://")) { + AutofillBindingStore.rememberBinding(this, matchTarget, entry.id); + } + + Dataset.Builder dataset = new Dataset.Builder(); + dataset.setId(entry.id); + if (usernameID != null && entry.username != null && !entry.username.isEmpty()) { + dataset.setValue(usernameID, AutofillValue.forText(entry.username)); + } + dataset.setValue(passwordID, AutofillValue.forText(entry.password)); + + Intent result = new Intent(); + result.putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset.build()); + setResult(Activity.RESULT_OK, result); + finish(); + } + + private static List labelsFor(List entries) { + List labels = new ArrayList<>(entries.size()); + for (AutofillCacheStore.Entry entry : entries) { + labels.add(entryLabel(entry)); + } + return labels; + } + + private static String entryLabel(AutofillCacheStore.Entry entry) { + StringBuilder label = new StringBuilder(); + label.append(entry.title == null || entry.title.trim().isEmpty() ? "Untitled entry" : entry.title.trim()); + if (entry.username != null && !entry.username.trim().isEmpty()) { + label.append(" (").append(entry.username.trim()).append(")"); + } + if (entry.path != null && !entry.path.isEmpty()) { + label.append(" • ").append(String.join(" / ", entry.path)); + } + return label.toString(); + } + + private int dp(int value) { + return Math.round(value * getResources().getDisplayMetrics().density); + } +} diff --git a/androidsrc/org/julianfamily/keepassgo/KeePassGOAutofillService.java b/androidsrc/org/julianfamily/keepassgo/KeePassGOAutofillService.java index ecd83db..557e855 100644 --- a/androidsrc/org/julianfamily/keepassgo/KeePassGOAutofillService.java +++ b/androidsrc/org/julianfamily/keepassgo/KeePassGOAutofillService.java @@ -1,6 +1,8 @@ package org.julianfamily.keepassgo; +import android.app.PendingIntent; import android.app.assist.AssistStructure; +import android.content.Intent; import android.os.CancellationSignal; import android.service.autofill.AutofillService; import android.service.autofill.Dataset; @@ -66,29 +68,21 @@ public final class KeePassGOAutofillService extends AutofillService { return; } - AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, target.matchTarget); + AutofillCacheStore.Entry entry = findBoundOrBestMatch(target.matchTarget); if (entry == null) { - Log.i(TAG, "no autofill cache match"); - callback.onSuccess(null); + FillResponse chooser = chooserResponse(target, fields); + if (chooser == 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); - RemoteViews presentation = new RemoteViews(getPackageName(), android.R.layout.simple_list_item_1); - presentation.setTextViewText( - android.R.id.text1, - entry.title + " (" + entry.username + ")" - ); - - Dataset.Builder dataset = new Dataset.Builder(presentation); - if (fields.usernameId != null) { - dataset.setValue(fields.usernameId, AutofillValue.forText(entry.username)); - } - dataset.setValue(fields.passwordId, AutofillValue.forText(entry.password)); - - FillResponse response = new FillResponse.Builder() - .addDataset(dataset.build()) - .build(); + FillResponse response = directFillResponse(entry, fields); Log.i(TAG, "returning dataset"); callback.onSuccess(response); } catch (Exception err) { @@ -103,6 +97,72 @@ public final class KeePassGOAutofillService extends AutofillService { callback.onSuccess(); } + private AutofillCacheStore.Entry findBoundOrBestMatch(String matchTarget) { + String entryID = AutofillBindingStore.entryIDForTarget(this, matchTarget); + if (!entryID.isEmpty()) { + AutofillCacheStore.Entry bound = AutofillCacheStore.findByID(this, entryID); + if (bound != null) { + return bound; + } + } + return AutofillCacheStore.findBestMatch(this, matchTarget); + } + + private FillResponse directFillResponse(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 + ")" + ); + + Dataset.Builder dataset = new Dataset.Builder(presentation); + dataset.setId(entry.id); + if (fields.usernameId != null) { + dataset.setValue(fields.usernameId, AutofillValue.forText(entry.username)); + } + dataset.setValue(fields.passwordId, AutofillValue.forText(entry.password)); + + return new FillResponse.Builder() + .addDataset(dataset.build()) + .build(); + } + + private FillResponse chooserResponse(ParsedTarget target, ParsedFields fields) { + if (fields.passwordId == null) { + return null; + } + List candidates = AutofillCacheStore.chooserCandidates(this, target.matchTarget); + if (candidates.isEmpty()) { + return null; + } + + RemoteViews presentation = new RemoteViews(getPackageName(), android.R.layout.simple_list_item_1); + presentation.setTextViewText(android.R.id.text1, "Search KeePassGO"); + + Intent intent = new Intent(this, KeePassGOAutofillPickerActivity.class); + intent.putExtra(KeePassGOAutofillPickerActivity.EXTRA_TARGET, target.matchTarget); + intent.putExtra(KeePassGOAutofillPickerActivity.EXTRA_PACKAGE_NAME, target.packageName); + intent.putExtra(KeePassGOAutofillPickerActivity.EXTRA_USERNAME_ID, fields.usernameId); + intent.putExtra(KeePassGOAutofillPickerActivity.EXTRA_PASSWORD_ID, fields.passwordId); + + PendingIntent pendingIntent = PendingIntent.getActivity( + this, + target.matchTarget.hashCode(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE + ); + + AutofillId[] ids; + if (fields.usernameId != null) { + ids = new AutofillId[]{fields.usernameId, fields.passwordId}; + } else { + ids = new AutofillId[]{fields.passwordId}; + } + return new FillResponse.Builder() + .setAuthentication(ids, pendingIntent.getIntentSender(), presentation) + .build(); + } + private static ParsedTarget parseWindow(AssistStructure structure, ParsedFields fields) { String domain = ""; final int windowCount = structure.getWindowNodeCount();