Add Android autofill service packaging

This commit is contained in:
Joe Julian
2026-03-31 23:13:10 -07:00
parent 322dc84917
commit eef209ae43
42 changed files with 6324 additions and 3 deletions
@@ -0,0 +1,167 @@
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<Entry> entries;
try {
entries = readEntries(cacheFile);
} catch (IOException err) {
Log.e(TAG, "failed to read autofill cache", err);
return null;
}
if (entries.isEmpty()) {
return null;
}
String normalizedDomain = normalizeHost(webDomain);
if (normalizedDomain.isEmpty()) {
return entries.get(0);
}
Entry fallback = null;
for (Entry entry : entries) {
if (entry.host.equals(normalizedDomain)) {
return entry;
}
if (fallback == null && normalizedDomain.endsWith("." + entry.host)) {
fallback = entry;
}
}
return fallback;
}
private static File findCacheFile(Context context) {
List<File> 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"));
}
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"));
}
for (File candidate : candidates) {
if (candidate.isFile()) {
return candidate;
}
}
return null;
}
private static List<Entry> readEntries(File cacheFile) throws IOException {
List<Entry> 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 = "";
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 "host":
host = normalizeHost(nextString(reader));
break;
default:
reader.skipValue();
break;
}
}
reader.endObject();
return new Entry(title, username, password, host);
}
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) {
if (raw == null) {
return "";
}
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);
}
return value;
}
static final class Entry {
final String title;
final String username;
final String password;
final String host;
Entry(String title, String username, String password, String host) {
this.title = title;
this.username = username;
this.password = password;
this.host = host;
}
}
}
@@ -0,0 +1,168 @@
package org.julianfamily.keepassgo;
import android.app.assist.AssistStructure;
import android.os.CancellationSignal;
import android.service.autofill.AutofillService;
import android.service.autofill.Dataset;
import android.service.autofill.FillCallback;
import android.service.autofill.FillContext;
import android.service.autofill.FillRequest;
import android.service.autofill.FillResponse;
import android.service.autofill.SaveCallback;
import android.service.autofill.SaveRequest;
import android.text.InputType;
import android.util.Log;
import android.view.View;
import android.view.autofill.AutofillId;
import android.view.autofill.AutofillValue;
import android.widget.RemoteViews;
import java.util.List;
public final class KeePassGOAutofillService extends AutofillService {
private static final String TAG = "KeePassGOAutofill";
@Override
public void onFillRequest(
FillRequest request,
CancellationSignal cancellationSignal,
FillCallback callback
) {
try {
List<FillContext> contexts = request.getFillContexts();
if (contexts.isEmpty()) {
callback.onSuccess(null);
return;
}
AssistStructure structure = contexts.get(contexts.size() - 1).getStructure();
ParsedFields fields = new ParsedFields();
String webDomain = parseWindow(structure, fields);
if (fields.passwordId == null) {
callback.onSuccess(null);
return;
}
AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, webDomain);
if (entry == null) {
callback.onSuccess(null);
return;
}
RemoteViews presentation = new RemoteViews(getPackageName(), android.R.layout.simple_list_item_1);
presentation.setTextViewText(
android.R.id.text1,
entry.title + " (" + entry.username + ")"
);
Dataset.Builder dataset = new Dataset.Builder(presentation);
if (fields.usernameId != null) {
dataset.setValue(fields.usernameId, AutofillValue.forText(entry.username));
}
dataset.setValue(fields.passwordId, AutofillValue.forText(entry.password));
FillResponse response = new FillResponse.Builder()
.addDataset(dataset.build())
.build();
callback.onSuccess(response);
} catch (Exception err) {
Log.e(TAG, "fill request failed", err);
callback.onFailure(err.getMessage());
}
}
@Override
public void onSaveRequest(SaveRequest request, SaveCallback callback) {
callback.onSuccess();
}
private static String parseWindow(AssistStructure structure, ParsedFields fields) {
String domain = "";
final int windowCount = structure.getWindowNodeCount();
for (int i = 0; i < windowCount; i++) {
AssistStructure.ViewNode root = structure.getWindowNodeAt(i).getRootViewNode();
String next = parseNode(root, fields);
if (!next.isEmpty()) {
domain = next;
}
}
return domain;
}
private static String parseNode(AssistStructure.ViewNode node, ParsedFields fields) {
String domain = "";
if (node.getWebDomain() != null) {
domain = node.getWebDomain();
}
AutofillId id = node.getAutofillId();
if (id != null) {
if (fields.passwordId == null && isPasswordNode(node)) {
fields.passwordId = id;
} else if (fields.usernameId == null && isUsernameNode(node)) {
fields.usernameId = id;
}
}
for (int i = 0; i < node.getChildCount(); i++) {
String childDomain = parseNode(node.getChildAt(i), fields);
if (!childDomain.isEmpty()) {
domain = childDomain;
}
}
return domain;
}
private static boolean isPasswordNode(AssistStructure.ViewNode node) {
int inputType = node.getInputType();
int variation = inputType & InputType.TYPE_MASK_VARIATION;
if (variation == InputType.TYPE_TEXT_VARIATION_PASSWORD
|| variation == InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
|| variation == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD) {
return true;
}
return nodeMatches(node, "password");
}
private static boolean isUsernameNode(AssistStructure.ViewNode node) {
int autofillType = node.getAutofillType();
if (autofillType != View.AUTOFILL_TYPE_TEXT) {
return false;
}
if (isPasswordNode(node)) {
return false;
}
return nodeMatches(node, "username")
|| nodeMatches(node, "email")
|| nodeMatches(node, "login")
|| nodeMatches(node, "user");
}
private static boolean nodeMatches(AssistStructure.ViewNode node, String term) {
String lowerTerm = term.toLowerCase();
String[] hints = node.getAutofillHints();
if (hints != null) {
for (String hint : hints) {
if (hint != null && hint.toLowerCase().contains(lowerTerm)) {
return true;
}
}
}
if (containsLower(node.getHint(), lowerTerm)
|| containsLower(node.getIdEntry(), lowerTerm)
|| containsLower(node.getText(), lowerTerm)
|| containsLower(node.getContentDescription(), lowerTerm)) {
return true;
}
return false;
}
private static boolean containsLower(CharSequence value, String term) {
return value != null && value.toString().toLowerCase().contains(term);
}
private static final class ParsedFields {
AutofillId usernameId;
AutofillId passwordId;
}
}