package org.julianfamily.keepassgo; import android.content.Context; import android.util.JsonReader; import android.util.Log; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.Locale; final class AutofillCacheStore { private static final String TAG = "KeePassGOAutofill"; private 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; } if (entries.isEmpty()) { return null; } NormalizedTarget target = normalizeURL(webDomain); if (target.host.isEmpty()) { return null; } List exactHost = new ArrayList<>(); List 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); } private static File findCacheFile(Context context) { List candidates = new ArrayList<>(); File filesDir = context.getFilesDir(); 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; } private static List readEntries(File cacheFile) throws IOException { List entries = new ArrayList<>(); try (JsonReader reader = new JsonReader(new InputStreamReader(new FileInputStream(cacheFile), StandardCharsets.UTF_8))) { reader.beginObject(); while (reader.hasNext()) { String name = reader.nextName(); if ("entries".equals(name)) { reader.beginArray(); while (reader.hasNext()) { entries.add(readEntry(reader)); } reader.endArray(); } else { reader.skipValue(); } } reader.endObject(); } return entries; } private static Entry readEntry(JsonReader reader) throws IOException { String title = ""; String username = ""; String password = ""; String host = ""; String url = ""; List targets = new ArrayList<>(); reader.beginObject(); while (reader.hasNext()) { String name = reader.nextName(); switch (name) { case "title": title = nextString(reader); break; case "username": username = nextString(reader); break; case "password": password = nextString(reader); break; case "url": url = nextString(reader); break; case "host": host = normalizeHost(nextString(reader)); break; case "targets": reader.beginArray(); while (reader.hasNext()) { targets.add(nextString(reader)); } reader.endArray(); break; default: reader.skipValue(); break; } } reader.endObject(); return new Entry(title, username, password, host, url, targets); } private static String nextString(JsonReader reader) throws IOException { if (reader.peek() == android.util.JsonToken.NULL) { reader.nextNull(); return ""; } return reader.nextString(); } private static String normalizeHost(String raw) { return normalizeURL(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 entries) { if (entries.isEmpty()) { return null; } if (entries.size() == 1) { return entries.get(0); } List exact = new ArrayList<>(); List 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 entryTargets(Entry entry) { List rawTargets = entry.targets; if (rawTargets.isEmpty()) { rawTargets = new ArrayList<>(); rawTargets.add(entry.url); } List 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 { final String title; final String username; final String password; final String host; final String url; final List targets; Entry(String title, String username, String password, String host, String url, List targets) { this.title = title; this.username = username; this.password = password; this.host = host; this.url = url; this.targets = new ArrayList<>(targets); } } 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; } } }