Add Android autofill service packaging
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user