199 lines
6.9 KiB
Java
199 lines
6.9 KiB
Java
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<AccessibilityNodeInfo> 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<AccessibilityNodeInfo> 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;
|
|
}
|
|
}
|