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; } ChromeForm form = inspectWindow(root); if (form == null || form.passwordField == null) { return; } AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, form.matchTarget); if (entry == null) { Log.i(TAG, "no accessibility-fill match for " + form.matchTarget); return; } String signature = form.matchTarget + "|" + 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.matchTarget + " 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 inspectWindow(AccessibilityNodeInfo root) { List editables = new ArrayList<>(); ChromeForm form = new ChromeForm(); walk(root, editables, form); if (form.matchTarget.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.webDomain = text.toString().trim(); } } if (form.packageName.isEmpty()) { CharSequence packageName = node.getPackageName(); if (packageName != null) { form.packageName = packageName.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); } } form.matchTarget = AutofillFallbackTarget.resolve(form.packageName, form.webDomain); } 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 packageName = ""; String webDomain = ""; String matchTarget = ""; AccessibilityNodeInfo usernameField; AccessibilityNodeInfo passwordField; } }