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();