Broaden Android accessibility autofill fallback
This commit is contained in:
@@ -25,26 +25,7 @@ final class AutofillCacheStore {
|
|||||||
if (entries.isEmpty()) {
|
if (entries.isEmpty()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
NormalizedTarget target = normalizeURL(webDomain);
|
return fromMatcherEntry(AutofillTargetMatcher.findBestMatch(toMatcherEntries(entries), webDomain));
|
||||||
if (target.host.isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
List<Entry> exactHost = new ArrayList<>();
|
|
||||||
List<Entry> parentHost = new ArrayList<>();
|
|
||||||
for (Entry entry : entries) {
|
|
||||||
if (entryMatchesHost(entry, target.host)) {
|
|
||||||
exactHost.add(entry);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (entryMatchesParentHost(entry, target.host)) {
|
|
||||||
parentHost.add(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Entry matched = chooseEntry(target, exactHost);
|
|
||||||
if (matched != null) {
|
|
||||||
return matched;
|
|
||||||
}
|
|
||||||
return chooseEntry(target, parentHost);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static Entry findByID(Context context, String entryID) {
|
static Entry findByID(Context context, String entryID) {
|
||||||
@@ -64,7 +45,7 @@ final class AutofillCacheStore {
|
|||||||
if (entries.isEmpty()) {
|
if (entries.isEmpty()) {
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
Entry direct = findBestMatch(context, rawTarget);
|
Entry direct = fromMatcherEntry(AutofillTargetMatcher.findBestMatch(toMatcherEntries(entries), rawTarget));
|
||||||
if (direct != null) {
|
if (direct != null) {
|
||||||
List<Entry> resolved = new ArrayList<>();
|
List<Entry> resolved = new ArrayList<>();
|
||||||
resolved.add(direct);
|
resolved.add(direct);
|
||||||
@@ -77,6 +58,30 @@ final class AutofillCacheStore {
|
|||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static List<AutofillTargetMatcher.Entry> toMatcherEntries(List<Entry> entries) {
|
||||||
|
List<AutofillTargetMatcher.Entry> converted = new ArrayList<>(entries.size());
|
||||||
|
for (Entry entry : entries) {
|
||||||
|
converted.add(new AutofillTargetMatcher.Entry(
|
||||||
|
entry.id,
|
||||||
|
entry.title,
|
||||||
|
entry.username,
|
||||||
|
entry.password,
|
||||||
|
entry.host,
|
||||||
|
entry.url,
|
||||||
|
entry.targets,
|
||||||
|
entry.path
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return converted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Entry fromMatcherEntry(AutofillTargetMatcher.Entry entry) {
|
||||||
|
if (entry == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new Entry(entry.id, entry.title, entry.username, entry.password, entry.host, entry.url, entry.targets, entry.path);
|
||||||
|
}
|
||||||
|
|
||||||
private static List<Entry> readEntries(Context context) {
|
private static List<Entry> readEntries(Context context) {
|
||||||
File cacheFile = findCacheFile(context);
|
File cacheFile = findCacheFile(context);
|
||||||
if (cacheFile == null) {
|
if (cacheFile == null) {
|
||||||
@@ -199,143 +204,7 @@ final class AutofillCacheStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static String normalizeHost(String raw) {
|
private static String normalizeHost(String raw) {
|
||||||
return normalizeURL(raw).host;
|
return AutofillTargetMatcher.normalize(raw).host;
|
||||||
}
|
|
||||||
|
|
||||||
private static NormalizedTarget normalizeURL(String raw) {
|
|
||||||
if (raw == null) {
|
|
||||||
return new NormalizedTarget("", "", "");
|
|
||||||
}
|
|
||||||
String value = raw.trim().toLowerCase(Locale.US);
|
|
||||||
if (value.startsWith("http://")) {
|
|
||||||
value = value.substring("http://".length());
|
|
||||||
} else if (value.startsWith("https://")) {
|
|
||||||
value = value.substring("https://".length());
|
|
||||||
}
|
|
||||||
int slash = value.indexOf('/');
|
|
||||||
if (slash >= 0) {
|
|
||||||
value = value.substring(0, slash);
|
|
||||||
}
|
|
||||||
int colon = value.indexOf(':');
|
|
||||||
if (colon >= 0) {
|
|
||||||
value = value.substring(0, colon);
|
|
||||||
}
|
|
||||||
String host = value;
|
|
||||||
String path = "/";
|
|
||||||
int schemeSep = raw.indexOf("://");
|
|
||||||
String original = raw.trim();
|
|
||||||
if (schemeSep < 0) {
|
|
||||||
original = "https://" + original;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
java.net.URI uri = java.net.URI.create(original);
|
|
||||||
if (uri.getHost() != null) {
|
|
||||||
host = uri.getHost().toLowerCase(Locale.US);
|
|
||||||
}
|
|
||||||
path = cleanPath(uri.getPath());
|
|
||||||
} catch (IllegalArgumentException ignored) {
|
|
||||||
path = "/";
|
|
||||||
}
|
|
||||||
return new NormalizedTarget(host, path, host + path);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String cleanPath(String raw) {
|
|
||||||
if (raw == null || raw.trim().isEmpty() || "/".equals(raw.trim())) {
|
|
||||||
return "/";
|
|
||||||
}
|
|
||||||
String value = raw.trim();
|
|
||||||
while (value.endsWith("/") && value.length() > 1) {
|
|
||||||
value = value.substring(0, value.length() - 1);
|
|
||||||
}
|
|
||||||
if (!value.startsWith("/")) {
|
|
||||||
value = "/" + value;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Entry chooseEntry(NormalizedTarget target, List<Entry> entries) {
|
|
||||||
if (entries.isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (entries.size() == 1) {
|
|
||||||
return entries.get(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Entry> exact = new ArrayList<>();
|
|
||||||
List<Entry> prefix = new ArrayList<>();
|
|
||||||
int bestPrefixLen = -1;
|
|
||||||
for (Entry entry : entries) {
|
|
||||||
MatchQuality quality = bestTargetMatch(entry, target);
|
|
||||||
if (quality.exact) {
|
|
||||||
exact.add(entry);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (quality.prefixLength <= 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (quality.prefixLength > bestPrefixLen) {
|
|
||||||
prefix.clear();
|
|
||||||
prefix.add(entry);
|
|
||||||
bestPrefixLen = quality.prefixLength;
|
|
||||||
} else if (quality.prefixLength == bestPrefixLen) {
|
|
||||||
prefix.add(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (exact.size() == 1) {
|
|
||||||
return exact.get(0);
|
|
||||||
}
|
|
||||||
if (exact.size() > 1 || prefix.isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return prefix.size() == 1 ? prefix.get(0) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean entryMatchesHost(Entry entry, String host) {
|
|
||||||
for (NormalizedTarget target : entryTargets(entry)) {
|
|
||||||
if (target.host.equals(host)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean entryMatchesParentHost(Entry entry, String host) {
|
|
||||||
for (NormalizedTarget target : entryTargets(entry)) {
|
|
||||||
if (!target.host.isEmpty() && host.endsWith("." + target.host)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<NormalizedTarget> entryTargets(Entry entry) {
|
|
||||||
List<String> rawTargets = entry.targets;
|
|
||||||
if (rawTargets.isEmpty()) {
|
|
||||||
rawTargets = new ArrayList<>();
|
|
||||||
rawTargets.add(entry.url);
|
|
||||||
}
|
|
||||||
List<NormalizedTarget> targets = new ArrayList<>();
|
|
||||||
for (String rawTarget : rawTargets) {
|
|
||||||
NormalizedTarget target = normalizeURL(rawTarget);
|
|
||||||
if (!target.host.isEmpty()) {
|
|
||||||
targets.add(target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return targets;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static MatchQuality bestTargetMatch(Entry entry, NormalizedTarget target) {
|
|
||||||
int bestPrefixLen = -1;
|
|
||||||
for (NormalizedTarget entryTarget : entryTargets(entry)) {
|
|
||||||
if (entryTarget.url.equals(target.url)) {
|
|
||||||
return new MatchQuality(true, 0);
|
|
||||||
}
|
|
||||||
if (!"/".equals(entryTarget.path) && target.path.startsWith(entryTarget.path)) {
|
|
||||||
bestPrefixLen = Math.max(bestPrefixLen, entryTarget.path.length());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new MatchQuality(false, bestPrefixLen);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static final class Entry {
|
static final class Entry {
|
||||||
@@ -359,26 +228,4 @@ final class AutofillCacheStore {
|
|||||||
this.path = new ArrayList<>(path);
|
this.path = new ArrayList<>(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class MatchQuality {
|
|
||||||
final boolean exact;
|
|
||||||
final int prefixLength;
|
|
||||||
|
|
||||||
MatchQuality(boolean exact, int prefixLength) {
|
|
||||||
this.exact = exact;
|
|
||||||
this.prefixLength = prefixLength;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class NormalizedTarget {
|
|
||||||
final String host;
|
|
||||||
final String path;
|
|
||||||
final String url;
|
|
||||||
|
|
||||||
NormalizedTarget(String host, String path, String url) {
|
|
||||||
this.host = host;
|
|
||||||
this.path = path;
|
|
||||||
this.url = url;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package org.julianfamily.keepassgo;
|
||||||
|
|
||||||
|
final class AutofillFallbackTarget {
|
||||||
|
private static final String APP_SCHEME = "androidapp://";
|
||||||
|
|
||||||
|
private AutofillFallbackTarget() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static String resolve(String packageName, String webDomain) {
|
||||||
|
String domain = trim(webDomain);
|
||||||
|
if (!domain.isEmpty()) {
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
String pkg = trim(packageName);
|
||||||
|
if (pkg.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return APP_SCHEME + pkg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trim(String value) {
|
||||||
|
return value == null ? "" : value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package org.julianfamily.keepassgo;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
final class AutofillTargetMatcher {
|
||||||
|
private AutofillTargetMatcher() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static Entry findBestMatch(List<Entry> entries, String rawTarget) {
|
||||||
|
if (entries == null || entries.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
NormalizedTarget target = normalize(rawTarget);
|
||||||
|
if (target.host.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<Entry> exactHost = new ArrayList<>();
|
||||||
|
List<Entry> parentHost = new ArrayList<>();
|
||||||
|
for (Entry entry : entries) {
|
||||||
|
if (entryMatchesHost(entry, target.host)) {
|
||||||
|
exactHost.add(entry);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entryMatchesParentHost(entry, target.host)) {
|
||||||
|
parentHost.add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Entry matched = chooseEntry(target, exactHost);
|
||||||
|
if (matched != null) {
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
return chooseEntry(target, parentHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<Entry> chooserCandidates(List<Entry> entries, String rawTarget) {
|
||||||
|
if (entries == null || entries.isEmpty()) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
Entry direct = findBestMatch(entries, rawTarget);
|
||||||
|
if (direct != null) {
|
||||||
|
List<Entry> resolved = new ArrayList<>();
|
||||||
|
resolved.add(direct);
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
return new ArrayList<>(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
static NormalizedTarget normalize(String raw) {
|
||||||
|
if (raw == null) {
|
||||||
|
return new NormalizedTarget("", "", "");
|
||||||
|
}
|
||||||
|
String trimmed = raw.trim();
|
||||||
|
if (trimmed.isEmpty()) {
|
||||||
|
return new NormalizedTarget("", "", "");
|
||||||
|
}
|
||||||
|
String original = trimmed;
|
||||||
|
String value = trimmed.toLowerCase(Locale.US);
|
||||||
|
if (value.startsWith("http://")) {
|
||||||
|
value = value.substring("http://".length());
|
||||||
|
} else if (value.startsWith("https://")) {
|
||||||
|
value = value.substring("https://".length());
|
||||||
|
}
|
||||||
|
int slash = value.indexOf('/');
|
||||||
|
if (slash >= 0) {
|
||||||
|
value = value.substring(0, slash);
|
||||||
|
}
|
||||||
|
int colon = value.indexOf(':');
|
||||||
|
if (colon >= 0) {
|
||||||
|
value = value.substring(0, colon);
|
||||||
|
}
|
||||||
|
String host = value;
|
||||||
|
String path = "/";
|
||||||
|
int schemeSep = original.indexOf("://");
|
||||||
|
if (schemeSep < 0) {
|
||||||
|
original = "https://" + original;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
URI uri = URI.create(original);
|
||||||
|
if (uri.getHost() != null) {
|
||||||
|
host = uri.getHost().toLowerCase(Locale.US);
|
||||||
|
}
|
||||||
|
path = cleanPath(uri.getPath());
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
path = "/";
|
||||||
|
}
|
||||||
|
return new NormalizedTarget(host, path, host + path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String cleanPath(String raw) {
|
||||||
|
if (raw == null || raw.trim().isEmpty() || "/".equals(raw.trim())) {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
String value = raw.trim();
|
||||||
|
while (value.endsWith("/") && value.length() > 1) {
|
||||||
|
value = value.substring(0, value.length() - 1);
|
||||||
|
}
|
||||||
|
if (!value.startsWith("/")) {
|
||||||
|
value = "/" + value;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Entry chooseEntry(NormalizedTarget target, List<Entry> entries) {
|
||||||
|
if (entries.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (entries.size() == 1) {
|
||||||
|
return entries.get(0);
|
||||||
|
}
|
||||||
|
List<Entry> exact = new ArrayList<>();
|
||||||
|
List<Entry> prefix = new ArrayList<>();
|
||||||
|
int bestPrefixLen = -1;
|
||||||
|
for (Entry entry : entries) {
|
||||||
|
MatchQuality quality = bestTargetMatch(entry, target);
|
||||||
|
if (quality.exact) {
|
||||||
|
exact.add(entry);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (quality.prefixLength <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (quality.prefixLength > bestPrefixLen) {
|
||||||
|
prefix.clear();
|
||||||
|
prefix.add(entry);
|
||||||
|
bestPrefixLen = quality.prefixLength;
|
||||||
|
} else if (quality.prefixLength == bestPrefixLen) {
|
||||||
|
prefix.add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (exact.size() == 1) {
|
||||||
|
return exact.get(0);
|
||||||
|
}
|
||||||
|
if (exact.size() > 1 || prefix.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return prefix.size() == 1 ? prefix.get(0) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean entryMatchesHost(Entry entry, String host) {
|
||||||
|
for (NormalizedTarget target : entryTargets(entry)) {
|
||||||
|
if (target.host.equals(host)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean entryMatchesParentHost(Entry entry, String host) {
|
||||||
|
for (NormalizedTarget target : entryTargets(entry)) {
|
||||||
|
if (!target.host.isEmpty() && host.endsWith("." + target.host)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<NormalizedTarget> entryTargets(Entry entry) {
|
||||||
|
List<String> rawTargets = entry.targets;
|
||||||
|
if (rawTargets.isEmpty()) {
|
||||||
|
rawTargets = new ArrayList<>();
|
||||||
|
rawTargets.add(entry.url);
|
||||||
|
}
|
||||||
|
List<NormalizedTarget> targets = new ArrayList<>();
|
||||||
|
for (String rawTarget : rawTargets) {
|
||||||
|
NormalizedTarget target = normalize(rawTarget);
|
||||||
|
if (!target.host.isEmpty()) {
|
||||||
|
targets.add(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MatchQuality bestTargetMatch(Entry entry, NormalizedTarget target) {
|
||||||
|
int bestPrefixLen = -1;
|
||||||
|
for (NormalizedTarget entryTarget : entryTargets(entry)) {
|
||||||
|
if (entryTarget.url.equals(target.url)) {
|
||||||
|
return new MatchQuality(true, 0);
|
||||||
|
}
|
||||||
|
if (!"/".equals(entryTarget.path) && target.path.startsWith(entryTarget.path)) {
|
||||||
|
bestPrefixLen = Math.max(bestPrefixLen, entryTarget.path.length());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new MatchQuality(false, bestPrefixLen);
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class NormalizedTarget {
|
||||||
|
final String host;
|
||||||
|
final String path;
|
||||||
|
final String url;
|
||||||
|
|
||||||
|
NormalizedTarget(String host, String path, String url) {
|
||||||
|
this.host = host;
|
||||||
|
this.path = path;
|
||||||
|
this.url = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class Entry {
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
final String username;
|
||||||
|
final String password;
|
||||||
|
final String host;
|
||||||
|
final String url;
|
||||||
|
final List<String> targets;
|
||||||
|
final List<String> path;
|
||||||
|
|
||||||
|
Entry(String id, String title, String username, String password, String host, String url, List<String> targets, List<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class MatchQuality {
|
||||||
|
final boolean exact;
|
||||||
|
final int prefixLength;
|
||||||
|
|
||||||
|
MatchQuality(boolean exact, int prefixLength) {
|
||||||
|
this.exact = exact;
|
||||||
|
this.prefixLength = prefixLength;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,6 @@ import java.util.List;
|
|||||||
|
|
||||||
public final class KeePassGOAccessibilityService extends AccessibilityService {
|
public final class KeePassGOAccessibilityService extends AccessibilityService {
|
||||||
private static final String TAG = "KeePassGOA11y";
|
private static final String TAG = "KeePassGOA11y";
|
||||||
|
|
||||||
private String lastFilledSignature = "";
|
private String lastFilledSignature = "";
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -22,22 +21,17 @@ public final class KeePassGOAccessibilityService extends AccessibilityService {
|
|||||||
if (root == null) {
|
if (root == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
CharSequence packageName = root.getPackageName();
|
ChromeForm form = inspectWindow(root);
|
||||||
if (packageName == null || !"com.android.chrome".contentEquals(packageName)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ChromeForm form = inspectChrome(root);
|
|
||||||
if (form == null || form.passwordField == null) {
|
if (form == null || form.passwordField == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, form.url);
|
AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, form.matchTarget);
|
||||||
if (entry == null) {
|
if (entry == null) {
|
||||||
Log.i(TAG, "no accessibility-fill match for " + form.url);
|
Log.i(TAG, "no accessibility-fill match for " + form.matchTarget);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
String signature = form.url + "|" + entry.username + "|" + nodeKey(form.passwordField);
|
String signature = form.matchTarget + "|" + entry.username + "|" + nodeKey(form.passwordField);
|
||||||
if (signature.equals(lastFilledSignature)) {
|
if (signature.equals(lastFilledSignature)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -49,7 +43,7 @@ public final class KeePassGOAccessibilityService extends AccessibilityService {
|
|||||||
filled |= setNodeText(form.passwordField, entry.password);
|
filled |= setNodeText(form.passwordField, entry.password);
|
||||||
if (filled) {
|
if (filled) {
|
||||||
lastFilledSignature = signature;
|
lastFilledSignature = signature;
|
||||||
Log.i(TAG, "filled login form for " + form.url + " with " + entry.username);
|
Log.i(TAG, "filled login form for " + form.matchTarget + " with " + entry.username);
|
||||||
}
|
}
|
||||||
} catch (Exception err) {
|
} catch (Exception err) {
|
||||||
Log.e(TAG, "accessibility fill failed", err);
|
Log.e(TAG, "accessibility fill failed", err);
|
||||||
@@ -61,11 +55,11 @@ public final class KeePassGOAccessibilityService extends AccessibilityService {
|
|||||||
Log.i(TAG, "accessibility service interrupted");
|
Log.i(TAG, "accessibility service interrupted");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ChromeForm inspectChrome(AccessibilityNodeInfo root) {
|
private static ChromeForm inspectWindow(AccessibilityNodeInfo root) {
|
||||||
List<AccessibilityNodeInfo> editables = new ArrayList<>();
|
List<AccessibilityNodeInfo> editables = new ArrayList<>();
|
||||||
ChromeForm form = new ChromeForm();
|
ChromeForm form = new ChromeForm();
|
||||||
walk(root, editables, form);
|
walk(root, editables, form);
|
||||||
if (form.url.isEmpty()) {
|
if (form.matchTarget.isEmpty()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (form.passwordField == null) {
|
if (form.passwordField == null) {
|
||||||
@@ -111,7 +105,13 @@ public final class KeePassGOAccessibilityService extends AccessibilityService {
|
|||||||
if (viewID != null && viewID.toString().endsWith(":id/url_bar")) {
|
if (viewID != null && viewID.toString().endsWith(":id/url_bar")) {
|
||||||
CharSequence text = node.getText();
|
CharSequence text = node.getText();
|
||||||
if (text != null) {
|
if (text != null) {
|
||||||
form.url = text.toString().trim();
|
form.webDomain = text.toString().trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (form.packageName.isEmpty()) {
|
||||||
|
CharSequence packageName = node.getPackageName();
|
||||||
|
if (packageName != null) {
|
||||||
|
form.packageName = packageName.toString().trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (node.isEditable()) {
|
if (node.isEditable()) {
|
||||||
@@ -128,6 +128,7 @@ public final class KeePassGOAccessibilityService extends AccessibilityService {
|
|||||||
walk(child, editables, form);
|
walk(child, editables, form);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
form.matchTarget = AutofillFallbackTarget.resolve(form.packageName, form.webDomain);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isPasswordNode(AccessibilityNodeInfo node) {
|
private static boolean isPasswordNode(AccessibilityNodeInfo node) {
|
||||||
@@ -188,7 +189,9 @@ public final class KeePassGOAccessibilityService extends AccessibilityService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static final class ChromeForm {
|
private static final class ChromeForm {
|
||||||
String url = "";
|
String packageName = "";
|
||||||
|
String webDomain = "";
|
||||||
|
String matchTarget = "";
|
||||||
AccessibilityNodeInfo usernameField;
|
AccessibilityNodeInfo usernameField;
|
||||||
AccessibilityNodeInfo passwordField;
|
AccessibilityNodeInfo passwordField;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package org.julianfamily.keepassgo;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class AutofillCacheStoreBehaviorTest {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
testFindBestMatchUsesAndroidAppTargets();
|
||||||
|
testChooserCandidatesCollapseToExactAndroidAppMatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void testFindBestMatchUsesAndroidAppTargets() {
|
||||||
|
List<AutofillTargetMatcher.Entry> entries = new ArrayList<>();
|
||||||
|
entries.add(new AutofillTargetMatcher.Entry(
|
||||||
|
"blink-entry",
|
||||||
|
"Blink",
|
||||||
|
"linuscaldwell",
|
||||||
|
"bellagio-stack",
|
||||||
|
"account.blinknetwork.com",
|
||||||
|
"https://account.blinknetwork.com",
|
||||||
|
Arrays.asList("https://account.blinknetwork.com", "androidapp://com.blinknetwork.mobile2"),
|
||||||
|
Arrays.asList("Crew", "Apps")
|
||||||
|
));
|
||||||
|
|
||||||
|
AutofillTargetMatcher.Entry got = AutofillTargetMatcher.findBestMatch(entries, "androidapp://com.blinknetwork.mobile2");
|
||||||
|
if (got == null || !"blink-entry".equals(got.id)) {
|
||||||
|
throw new AssertionError("findBestMatch(entries, androidapp target) = " + describe(got) + ", want blink-entry");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void testChooserCandidatesCollapseToExactAndroidAppMatch() {
|
||||||
|
List<AutofillTargetMatcher.Entry> entries = new ArrayList<>();
|
||||||
|
entries.add(new AutofillTargetMatcher.Entry(
|
||||||
|
"blink-entry",
|
||||||
|
"Blink",
|
||||||
|
"linuscaldwell",
|
||||||
|
"bellagio-stack",
|
||||||
|
"account.blinknetwork.com",
|
||||||
|
"https://account.blinknetwork.com",
|
||||||
|
Arrays.asList("https://account.blinknetwork.com", "androidapp://com.blinknetwork.mobile2"),
|
||||||
|
Arrays.asList("Crew", "Apps")
|
||||||
|
));
|
||||||
|
entries.add(new AutofillTargetMatcher.Entry(
|
||||||
|
"night-fox-entry",
|
||||||
|
"Night Fox",
|
||||||
|
"nightfox",
|
||||||
|
"vault-code",
|
||||||
|
"gitlab.com",
|
||||||
|
"https://gitlab.com",
|
||||||
|
Arrays.asList("https://gitlab.com"),
|
||||||
|
Arrays.asList("Crew", "Internet")
|
||||||
|
));
|
||||||
|
|
||||||
|
List<AutofillTargetMatcher.Entry> got = AutofillTargetMatcher.chooserCandidates(entries, "androidapp://com.blinknetwork.mobile2");
|
||||||
|
if (got.size() != 1 || !"blink-entry".equals(got.get(0).id)) {
|
||||||
|
throw new AssertionError("chooserCandidates(entries, androidapp target) = " + describe(got) + ", want [blink-entry]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String describe(AutofillTargetMatcher.Entry entry) {
|
||||||
|
if (entry == null) {
|
||||||
|
return "null";
|
||||||
|
}
|
||||||
|
return entry.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String describe(List<AutofillTargetMatcher.Entry> entries) {
|
||||||
|
List<String> ids = new ArrayList<>();
|
||||||
|
for (AutofillTargetMatcher.Entry entry : entries) {
|
||||||
|
ids.add(entry.id);
|
||||||
|
}
|
||||||
|
return ids.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package org.julianfamily.keepassgo;
|
||||||
|
|
||||||
|
public final class AutofillFallbackTargetBehaviorTest {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
testPrefersWebDomainWhenPresent();
|
||||||
|
testFallsBackToAndroidAppPackage();
|
||||||
|
testEmptyWhenNeitherSignalExists();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void testPrefersWebDomainWhenPresent() {
|
||||||
|
String got = AutofillFallbackTarget.resolve("com.android.chrome", "gitlab.com");
|
||||||
|
if (!"gitlab.com".equals(got)) {
|
||||||
|
throw new AssertionError("resolve(chrome, gitlab.com) = " + got + ", want gitlab.com");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void testFallsBackToAndroidAppPackage() {
|
||||||
|
String got = AutofillFallbackTarget.resolve("com.blinknetwork.mobile2", "");
|
||||||
|
if (!"androidapp://com.blinknetwork.mobile2".equals(got)) {
|
||||||
|
throw new AssertionError("resolve(package-only) = " + got + ", want androidapp://com.blinknetwork.mobile2");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void testEmptyWhenNeitherSignalExists() {
|
||||||
|
String got = AutofillFallbackTarget.resolve("", "");
|
||||||
|
if (!"".equals(got)) {
|
||||||
|
throw new AssertionError("resolve(empty) = " + got + ", want empty");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Android Autofill
|
||||||
|
|
||||||
|
## App Target Matching
|
||||||
|
|
||||||
|
User story:
|
||||||
|
|
||||||
|
- When an entry carries an Android-specific target such as
|
||||||
|
`androidapp://com.blinknetwork.mobile2`, KeePassGO should treat that as a
|
||||||
|
first-class autofill target on Android.
|
||||||
|
- If an exact app target exists, Android autofill should resolve that entry
|
||||||
|
directly instead of falling back to a generic chooser for the whole cache.
|
||||||
|
|
||||||
|
Expected behavior:
|
||||||
|
|
||||||
|
- `AndroidApp*` custom fields exported into the autofill cache must match the
|
||||||
|
Android package target used by the autofill and accessibility services.
|
||||||
|
- The Android-side matcher must normalize `androidapp://...` targets the same
|
||||||
|
way the Go cache builder does.
|
||||||
|
- The chooser path should still collapse to a single direct result when there
|
||||||
|
is one exact app-target match.
|
||||||
|
|
||||||
|
## Accessibility Fallback
|
||||||
|
|
||||||
|
User story:
|
||||||
|
|
||||||
|
- When Android accessibility fallback is needed, KeePassGO should not be
|
||||||
|
limited to Chrome-only URL bar parsing.
|
||||||
|
- Apps with stable package identities should still be fillable when an entry
|
||||||
|
carries a matching `AndroidApp*` target.
|
||||||
|
|
||||||
|
Expected behavior:
|
||||||
|
|
||||||
|
- Accessibility fallback derives its match target from the web domain when one
|
||||||
|
is available.
|
||||||
|
- If no web domain is available, accessibility fallback uses the active app
|
||||||
|
package as `androidapp://<package>`.
|
||||||
|
- The fallback path can therefore fill supported apps that never expose a
|
||||||
|
browser-style URL bar.
|
||||||
Reference in New Issue
Block a user