diff --git a/android/application_snippets.xml b/android/application_snippets.xml
index 322d1b8..0990299 100644
--- a/android/application_snippets.xml
+++ b/android/application_snippets.xml
@@ -10,3 +10,15 @@
android:name="android.autofill"
android:resource="@xml/keepassgo_autofill_service" />
+
+
+
+
+
+
diff --git a/android/keepassgo-android.jar b/android/keepassgo-android.jar
index d69db1e..b2345ae 100644
Binary files a/android/keepassgo-android.jar and b/android/keepassgo-android.jar differ
diff --git a/android/res/xml/keepassgo_accessibility_service.xml b/android/res/xml/keepassgo_accessibility_service.xml
new file mode 100644
index 0000000..79990a1
--- /dev/null
+++ b/android/res/xml/keepassgo_accessibility_service.xml
@@ -0,0 +1,8 @@
+
+
diff --git a/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java b/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java
index 3d491b9..41b0fac 100644
--- a/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java
+++ b/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java
@@ -57,17 +57,21 @@ final class AutofillCacheStore {
if (filesDir != null) {
candidates.add(new File(filesDir, "keepassgo/autofill-cache.json"));
candidates.add(new File(filesDir, ".config/keepassgo/autofill-cache.json"));
+ candidates.add(new File(filesDir, "config/keepassgo/autofill-cache.json"));
}
File baseDir = context.getDataDir();
if (baseDir != null) {
candidates.add(new File(baseDir, "files/keepassgo/autofill-cache.json"));
candidates.add(new File(baseDir, "files/.config/keepassgo/autofill-cache.json"));
+ candidates.add(new File(baseDir, "files/config/keepassgo/autofill-cache.json"));
}
for (File candidate : candidates) {
if (candidate.isFile()) {
+ Log.i(TAG, "using autofill cache " + candidate.getAbsolutePath());
return candidate;
}
}
+ Log.i(TAG, "no autofill cache file in " + candidates);
return null;
}
diff --git a/androidsrc/org/julianfamily/keepassgo/KeePassGOAccessibilityService.java b/androidsrc/org/julianfamily/keepassgo/KeePassGOAccessibilityService.java
new file mode 100644
index 0000000..b347812
--- /dev/null
+++ b/androidsrc/org/julianfamily/keepassgo/KeePassGOAccessibilityService.java
@@ -0,0 +1,195 @@
+package org.julianfamily.keepassgo;
+
+import android.accessibilityservice.AccessibilityService;
+import android.os.Build;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.accessibility.AccessibilityNodeInfo;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public final class KeePassGOAccessibilityService extends AccessibilityService {
+ private static final String TAG = "KeePassGOA11y";
+
+ private String lastFilledSignature = "";
+
+ @Override
+ public void onAccessibilityEvent(AccessibilityEvent event) {
+ try {
+ AccessibilityNodeInfo root = getRootInActiveWindow();
+ if (root == null) {
+ return;
+ }
+ CharSequence packageName = root.getPackageName();
+ if (packageName == null || !"com.android.chrome".contentEquals(packageName)) {
+ return;
+ }
+
+ ChromeForm form = inspectChrome(root);
+ if (form == null || form.passwordField == null) {
+ return;
+ }
+ AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, form.url);
+ if (entry == null) {
+ Log.i(TAG, "no accessibility-fill match for " + form.url);
+ return;
+ }
+
+ String signature = form.url + "|" + entry.username + "|" + nodeKey(form.passwordField);
+ if (signature.equals(lastFilledSignature)) {
+ return;
+ }
+
+ boolean filled = false;
+ if (form.usernameField != null) {
+ filled |= setNodeText(form.usernameField, entry.username);
+ }
+ filled |= setNodeText(form.passwordField, entry.password);
+ if (filled) {
+ lastFilledSignature = signature;
+ Log.i(TAG, "filled login form for " + form.url + " with " + entry.username);
+ }
+ } catch (Exception err) {
+ Log.e(TAG, "accessibility fill failed", err);
+ }
+ }
+
+ @Override
+ public void onInterrupt() {
+ Log.i(TAG, "accessibility service interrupted");
+ }
+
+ private static ChromeForm inspectChrome(AccessibilityNodeInfo root) {
+ List editables = new ArrayList<>();
+ ChromeForm form = new ChromeForm();
+ walk(root, editables, form);
+ if (form.url.isEmpty()) {
+ return null;
+ }
+ if (form.passwordField == null) {
+ for (AccessibilityNodeInfo node : editables) {
+ if (isPasswordNode(node)) {
+ form.passwordField = node;
+ break;
+ }
+ }
+ }
+ if (form.usernameField == null && form.passwordField != null) {
+ for (AccessibilityNodeInfo node : editables) {
+ if (node.equals(form.passwordField)) {
+ continue;
+ }
+ if (isUsernameNode(node)) {
+ form.usernameField = node;
+ break;
+ }
+ }
+ if (form.usernameField == null) {
+ for (AccessibilityNodeInfo node : editables) {
+ if (node.equals(form.passwordField)) {
+ continue;
+ }
+ form.usernameField = node;
+ break;
+ }
+ }
+ }
+ return form;
+ }
+
+ private static void walk(
+ AccessibilityNodeInfo node,
+ List editables,
+ ChromeForm form
+ ) {
+ if (node == null) {
+ return;
+ }
+ CharSequence viewID = node.getViewIdResourceName();
+ if (viewID != null && viewID.toString().endsWith(":id/url_bar")) {
+ CharSequence text = node.getText();
+ if (text != null) {
+ form.url = text.toString().trim();
+ }
+ }
+ if (node.isEditable()) {
+ editables.add(node);
+ if (form.passwordField == null && isPasswordNode(node)) {
+ form.passwordField = node;
+ } else if (form.usernameField == null && isUsernameNode(node)) {
+ form.usernameField = node;
+ }
+ }
+ for (int i = 0; i < node.getChildCount(); i++) {
+ AccessibilityNodeInfo child = node.getChild(i);
+ if (child != null) {
+ walk(child, editables, form);
+ }
+ }
+ }
+
+ private static boolean isPasswordNode(AccessibilityNodeInfo node) {
+ if (node.isPassword()) {
+ return true;
+ }
+ return nodeMatches(node, "password");
+ }
+
+ private static boolean isUsernameNode(AccessibilityNodeInfo node) {
+ if (isPasswordNode(node)) {
+ return false;
+ }
+ return nodeMatches(node, "username")
+ || nodeMatches(node, "email")
+ || nodeMatches(node, "login")
+ || nodeMatches(node, "user");
+ }
+
+ private static boolean nodeMatches(AccessibilityNodeInfo node, String term) {
+ String lowerTerm = term.toLowerCase();
+ return containsLower(node.getHintText(), lowerTerm)
+ || containsLower(node.getText(), lowerTerm)
+ || containsLower(node.getContentDescription(), lowerTerm)
+ || containsLower(node.getViewIdResourceName(), lowerTerm)
+ || containsLower(node.getPaneTitle(), lowerTerm);
+ }
+
+ private static boolean containsLower(CharSequence value, String term) {
+ return value != null && value.toString().toLowerCase().contains(term);
+ }
+
+ private static boolean setNodeText(AccessibilityNodeInfo node, String value) {
+ if (node == null || !node.isEditable() || value == null) {
+ return false;
+ }
+ Bundle args = new Bundle();
+ args.putCharSequence(
+ AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE,
+ value
+ );
+ return node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args);
+ }
+
+ private static String nodeKey(AccessibilityNodeInfo node) {
+ CharSequence viewID = node.getViewIdResourceName();
+ if (viewID != null) {
+ return viewID.toString();
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ return String.valueOf(node.getUniqueId());
+ }
+ CharSequence hint = node.getHintText();
+ if (hint != null) {
+ return hint.toString();
+ }
+ return String.valueOf(node.hashCode());
+ }
+
+ private static final class ChromeForm {
+ String url = "";
+ AccessibilityNodeInfo usernameField;
+ AccessibilityNodeInfo passwordField;
+ }
+}
diff --git a/androidsrc/org/julianfamily/keepassgo/KeePassGOAutofillService.java b/androidsrc/org/julianfamily/keepassgo/KeePassGOAutofillService.java
index 9133ad2..a2a48de 100644
--- a/androidsrc/org/julianfamily/keepassgo/KeePassGOAutofillService.java
+++ b/androidsrc/org/julianfamily/keepassgo/KeePassGOAutofillService.java
@@ -22,6 +22,18 @@ import java.util.List;
public final class KeePassGOAutofillService extends AutofillService {
private static final String TAG = "KeePassGOAutofill";
+ @Override
+ public void onConnected() {
+ super.onConnected();
+ Log.i(TAG, "service connected");
+ }
+
+ @Override
+ public void onDisconnected() {
+ Log.i(TAG, "service disconnected");
+ super.onDisconnected();
+ }
+
@Override
public void onFillRequest(
FillRequest request,
@@ -29,8 +41,10 @@ public final class KeePassGOAutofillService extends AutofillService {
FillCallback callback
) {
try {
+ Log.i(TAG, "fill request flags=" + request.getFlags());
List contexts = request.getFillContexts();
if (contexts.isEmpty()) {
+ Log.i(TAG, "fill request had no contexts");
callback.onSuccess(null);
return;
}
@@ -38,16 +52,20 @@ public final class KeePassGOAutofillService extends AutofillService {
AssistStructure structure = contexts.get(contexts.size() - 1).getStructure();
ParsedFields fields = new ParsedFields();
String webDomain = parseWindow(structure, fields);
+ Log.i(TAG, "parsed domain=" + webDomain + " usernameId=" + fields.usernameId + " passwordId=" + fields.passwordId);
if (fields.passwordId == null) {
+ Log.i(TAG, "no password field found");
callback.onSuccess(null);
return;
}
AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, webDomain);
if (entry == null) {
+ Log.i(TAG, "no autofill cache match");
callback.onSuccess(null);
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(
@@ -64,6 +82,7 @@ public final class KeePassGOAutofillService extends AutofillService {
FillResponse response = new FillResponse.Builder()
.addDataset(dataset.build())
.build();
+ Log.i(TAG, "returning dataset");
callback.onSuccess(response);
} catch (Exception err) {
Log.e(TAG, "fill request failed", err);
@@ -73,6 +92,7 @@ public final class KeePassGOAutofillService extends AutofillService {
@Override
public void onSaveRequest(SaveRequest request, SaveCallback callback) {
+ Log.i(TAG, "save request");
callback.onSuccess();
}