Compare commits
4 Commits
a41e842a65
...
bb114cee16
| Author | SHA1 | Date | |
|---|---|---|---|
| bb114cee16 | |||
| 2431467aa7 | |||
| c302c29d4f | |||
| 361d6dbe03 |
@@ -38,7 +38,7 @@ apk: android/keepassgo-android.jar
|
|||||||
ANDROID_SDK_ROOT="$(ANDROID_SDK_ROOT)" \
|
ANDROID_SDK_ROOT="$(ANDROID_SDK_ROOT)" \
|
||||||
ANDROID_NDK_ROOT="$(ANDROID_NDK_ROOT)" \
|
ANDROID_NDK_ROOT="$(ANDROID_NDK_ROOT)" \
|
||||||
JAVA_HOME="$(JAVA_HOME)" \
|
JAVA_HOME="$(JAVA_HOME)" \
|
||||||
go tool gogio -target android \
|
go run ./cmd/build-android-apk -target android \
|
||||||
-buildmode exe \
|
-buildmode exe \
|
||||||
-appid $(APP_ID) \
|
-appid $(APP_ID) \
|
||||||
-ldflags "$(GO_LDFLAGS)" \
|
-ldflags "$(GO_LDFLAGS)" \
|
||||||
|
|||||||
@@ -22,6 +22,10 @@
|
|||||||
android:name="android.accessibilityservice"
|
android:name="android.accessibilityservice"
|
||||||
android:resource="@xml/keepassgo_accessibility_service" />
|
android:resource="@xml/keepassgo_accessibility_service" />
|
||||||
</service>
|
</service>
|
||||||
|
<activity
|
||||||
|
android:name="org.julianfamily.keepassgo.KeePassGOAutofillPickerActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:label="Search KeePassGO" />
|
||||||
<provider
|
<provider
|
||||||
android:name="org.julianfamily.keepassgo.SharedVaultProvider"
|
android:name="org.julianfamily.keepassgo.SharedVaultProvider"
|
||||||
android:authorities="org.julianfamily.keepassgo.share"
|
android:authorities="org.julianfamily.keepassgo.share"
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package org.julianfamily.keepassgo;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.util.JsonReader;
|
||||||
|
import android.util.JsonWriter;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.io.OutputStreamWriter;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
final class AutofillBindingStore {
|
||||||
|
private static final String TAG = "KeePassGOAutofill";
|
||||||
|
|
||||||
|
private AutofillBindingStore() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static String entryIDForTarget(Context context, String rawTarget) {
|
||||||
|
Bindings bindings = read(context);
|
||||||
|
return bindings.apps.getOrDefault(normalize(rawTarget), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void rememberBinding(Context context, String rawTarget, String entryID) {
|
||||||
|
String target = normalize(rawTarget);
|
||||||
|
if (target.isEmpty() || entryID == null || entryID.trim().isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Bindings bindings = read(context);
|
||||||
|
bindings.updatedAt = Instant.now().toString();
|
||||||
|
bindings.apps.put(target, entryID.trim());
|
||||||
|
write(context, bindings);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Bindings read(Context context) {
|
||||||
|
File path = path(context);
|
||||||
|
if (!path.isFile()) {
|
||||||
|
return new Bindings();
|
||||||
|
}
|
||||||
|
try (JsonReader reader = new JsonReader(new InputStreamReader(new FileInputStream(path), StandardCharsets.UTF_8))) {
|
||||||
|
Bindings bindings = new Bindings();
|
||||||
|
reader.beginObject();
|
||||||
|
while (reader.hasNext()) {
|
||||||
|
String name = reader.nextName();
|
||||||
|
if ("updatedAt".equals(name)) {
|
||||||
|
bindings.updatedAt = nextString(reader);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ("apps".equals(name)) {
|
||||||
|
reader.beginObject();
|
||||||
|
while (reader.hasNext()) {
|
||||||
|
bindings.apps.put(normalize(reader.nextName()), nextString(reader));
|
||||||
|
}
|
||||||
|
reader.endObject();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
reader.skipValue();
|
||||||
|
}
|
||||||
|
reader.endObject();
|
||||||
|
return bindings;
|
||||||
|
} catch (IOException err) {
|
||||||
|
Log.e(TAG, "failed to read autofill bindings", err);
|
||||||
|
return new Bindings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void write(Context context, Bindings bindings) {
|
||||||
|
File path = path(context);
|
||||||
|
File parent = path.getParentFile();
|
||||||
|
if (parent != null && !parent.exists() && !parent.mkdirs()) {
|
||||||
|
Log.e(TAG, "failed to create autofill binding directory " + parent.getAbsolutePath());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try (JsonWriter writer = new JsonWriter(new OutputStreamWriter(new FileOutputStream(path, false), StandardCharsets.UTF_8))) {
|
||||||
|
writer.setIndent(" ");
|
||||||
|
writer.beginObject();
|
||||||
|
writer.name("updatedAt").value(bindings.updatedAt);
|
||||||
|
writer.name("apps");
|
||||||
|
writer.beginObject();
|
||||||
|
for (Map.Entry<String, String> entry : bindings.apps.entrySet()) {
|
||||||
|
writer.name(entry.getKey()).value(entry.getValue());
|
||||||
|
}
|
||||||
|
writer.endObject();
|
||||||
|
writer.endObject();
|
||||||
|
} catch (IOException err) {
|
||||||
|
Log.e(TAG, "failed to write autofill bindings", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String nextString(JsonReader reader) throws IOException {
|
||||||
|
if (reader.peek() == android.util.JsonToken.NULL) {
|
||||||
|
reader.nextNull();
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return reader.nextString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static File path(Context context) {
|
||||||
|
return new File(new File(context.getFilesDir(), "keepassgo"), "autofill-bindings.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalize(String rawTarget) {
|
||||||
|
return rawTarget == null ? "" : rawTarget.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class Bindings {
|
||||||
|
String updatedAt = "";
|
||||||
|
final Map<String, String> apps = new LinkedHashMap<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import java.io.IOException;
|
|||||||
import java.io.InputStreamReader;
|
import java.io.InputStreamReader;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
|
||||||
@@ -20,18 +21,7 @@ final class AutofillCacheStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static Entry findBestMatch(Context context, String webDomain) {
|
static Entry findBestMatch(Context context, String webDomain) {
|
||||||
File cacheFile = findCacheFile(context);
|
List<Entry> entries = readEntries(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()) {
|
if (entries.isEmpty()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -57,6 +47,50 @@ final class AutofillCacheStore {
|
|||||||
return chooseEntry(target, parentHost);
|
return chooseEntry(target, parentHost);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Entry findByID(Context context, String entryID) {
|
||||||
|
if (entryID == null || entryID.trim().isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (Entry entry : readEntries(context)) {
|
||||||
|
if (entryID.equals(entry.id)) {
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<Entry> chooserCandidates(Context context, String rawTarget) {
|
||||||
|
List<Entry> entries = readEntries(context);
|
||||||
|
if (entries.isEmpty()) {
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
Entry direct = findBestMatch(context, rawTarget);
|
||||||
|
if (direct != null) {
|
||||||
|
List<Entry> resolved = new ArrayList<>();
|
||||||
|
resolved.add(direct);
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
entries.sort(Comparator
|
||||||
|
.comparing((Entry entry) -> entry.title.toLowerCase(Locale.US))
|
||||||
|
.thenComparing(entry -> String.join("/", entry.path).toLowerCase(Locale.US))
|
||||||
|
.thenComparing(entry -> entry.id));
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Entry> readEntries(Context context) {
|
||||||
|
File cacheFile = findCacheFile(context);
|
||||||
|
if (cacheFile == null) {
|
||||||
|
Log.i(TAG, "autofill cache file not found");
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return readEntries(cacheFile);
|
||||||
|
} catch (IOException err) {
|
||||||
|
Log.e(TAG, "failed to read autofill cache", err);
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static File findCacheFile(Context context) {
|
private static File findCacheFile(Context context) {
|
||||||
List<File> candidates = new ArrayList<>();
|
List<File> candidates = new ArrayList<>();
|
||||||
File filesDir = context.getFilesDir();
|
File filesDir = context.getFilesDir();
|
||||||
@@ -103,16 +137,21 @@ final class AutofillCacheStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static Entry readEntry(JsonReader reader) throws IOException {
|
private static Entry readEntry(JsonReader reader) throws IOException {
|
||||||
|
String id = "";
|
||||||
String title = "";
|
String title = "";
|
||||||
String username = "";
|
String username = "";
|
||||||
String password = "";
|
String password = "";
|
||||||
String host = "";
|
String host = "";
|
||||||
String url = "";
|
String url = "";
|
||||||
List<String> targets = new ArrayList<>();
|
List<String> targets = new ArrayList<>();
|
||||||
|
List<String> path = new ArrayList<>();
|
||||||
reader.beginObject();
|
reader.beginObject();
|
||||||
while (reader.hasNext()) {
|
while (reader.hasNext()) {
|
||||||
String name = reader.nextName();
|
String name = reader.nextName();
|
||||||
switch (name) {
|
switch (name) {
|
||||||
|
case "id":
|
||||||
|
id = nextString(reader);
|
||||||
|
break;
|
||||||
case "title":
|
case "title":
|
||||||
title = nextString(reader);
|
title = nextString(reader);
|
||||||
break;
|
break;
|
||||||
@@ -135,13 +174,20 @@ final class AutofillCacheStore {
|
|||||||
}
|
}
|
||||||
reader.endArray();
|
reader.endArray();
|
||||||
break;
|
break;
|
||||||
|
case "path":
|
||||||
|
reader.beginArray();
|
||||||
|
while (reader.hasNext()) {
|
||||||
|
path.add(nextString(reader));
|
||||||
|
}
|
||||||
|
reader.endArray();
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
reader.skipValue();
|
reader.skipValue();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
reader.endObject();
|
reader.endObject();
|
||||||
return new Entry(title, username, password, host, url, targets);
|
return new Entry(id, title, username, password, host, url, targets, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String nextString(JsonReader reader) throws IOException {
|
private static String nextString(JsonReader reader) throws IOException {
|
||||||
@@ -293,20 +339,24 @@ final class AutofillCacheStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static final class Entry {
|
static final class Entry {
|
||||||
|
final String id;
|
||||||
final String title;
|
final String title;
|
||||||
final String username;
|
final String username;
|
||||||
final String password;
|
final String password;
|
||||||
final String host;
|
final String host;
|
||||||
final String url;
|
final String url;
|
||||||
final List<String> targets;
|
final List<String> targets;
|
||||||
|
final List<String> path;
|
||||||
|
|
||||||
Entry(String title, String username, String password, String host, String url, List<String> targets) {
|
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.title = title;
|
||||||
this.username = username;
|
this.username = username;
|
||||||
this.password = password;
|
this.password = password;
|
||||||
this.host = host;
|
this.host = host;
|
||||||
this.url = url;
|
this.url = url;
|
||||||
this.targets = new ArrayList<>(targets);
|
this.targets = new ArrayList<>(targets);
|
||||||
|
this.path = new ArrayList<>(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package org.julianfamily.keepassgo;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.text.Editable;
|
||||||
|
import android.text.TextWatcher;
|
||||||
|
import android.util.TypedValue;
|
||||||
|
import android.view.Gravity;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.autofill.AutofillId;
|
||||||
|
import android.view.autofill.AutofillManager;
|
||||||
|
import android.view.autofill.AutofillValue;
|
||||||
|
import android.widget.AdapterView;
|
||||||
|
import android.widget.ArrayAdapter;
|
||||||
|
import android.widget.EditText;
|
||||||
|
import android.widget.LinearLayout;
|
||||||
|
import android.widget.ListView;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
import android.service.autofill.Dataset;
|
||||||
|
|
||||||
|
public final class KeePassGOAutofillPickerActivity extends Activity {
|
||||||
|
static final String EXTRA_TARGET = "org.julianfamily.keepassgo.extra.AUTOFILL_TARGET";
|
||||||
|
static final String EXTRA_PACKAGE_NAME = "org.julianfamily.keepassgo.extra.AUTOFILL_PACKAGE";
|
||||||
|
static final String EXTRA_USERNAME_ID = "org.julianfamily.keepassgo.extra.USERNAME_ID";
|
||||||
|
static final String EXTRA_PASSWORD_ID = "org.julianfamily.keepassgo.extra.PASSWORD_ID";
|
||||||
|
|
||||||
|
private final List<AutofillCacheStore.Entry> allEntries = new ArrayList<>();
|
||||||
|
private final List<AutofillCacheStore.Entry> visibleEntries = new ArrayList<>();
|
||||||
|
private ArrayAdapter<String> adapter;
|
||||||
|
private String matchTarget = "";
|
||||||
|
private String packageName = "";
|
||||||
|
private AutofillId usernameID;
|
||||||
|
private AutofillId passwordID;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
|
||||||
|
Intent intent = getIntent();
|
||||||
|
matchTarget = intent.getStringExtra(EXTRA_TARGET);
|
||||||
|
packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME);
|
||||||
|
usernameID = intent.getParcelableExtra(EXTRA_USERNAME_ID, AutofillId.class);
|
||||||
|
passwordID = intent.getParcelableExtra(EXTRA_PASSWORD_ID, AutofillId.class);
|
||||||
|
|
||||||
|
LinearLayout root = new LinearLayout(this);
|
||||||
|
root.setOrientation(LinearLayout.VERTICAL);
|
||||||
|
int padding = dp(16);
|
||||||
|
root.setPadding(padding, padding, padding, padding);
|
||||||
|
|
||||||
|
TextView title = new TextView(this);
|
||||||
|
title.setTextSize(TypedValue.COMPLEX_UNIT_SP, 20);
|
||||||
|
title.setGravity(Gravity.START);
|
||||||
|
title.setPadding(0, 0, 0, dp(8));
|
||||||
|
title.setText(packageName == null || packageName.trim().isEmpty() ? "Search KeePassGO" : "Search KeePassGO for " + packageName);
|
||||||
|
root.addView(title, new LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
|
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||||
|
));
|
||||||
|
|
||||||
|
EditText search = new EditText(this);
|
||||||
|
search.setHint("Search entries");
|
||||||
|
root.addView(search, new LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
|
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||||
|
));
|
||||||
|
|
||||||
|
allEntries.clear();
|
||||||
|
allEntries.addAll(AutofillCacheStore.chooserCandidates(this, matchTarget));
|
||||||
|
visibleEntries.clear();
|
||||||
|
visibleEntries.addAll(allEntries);
|
||||||
|
|
||||||
|
adapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, labelsFor(visibleEntries));
|
||||||
|
ListView list = new ListView(this);
|
||||||
|
list.setAdapter(adapter);
|
||||||
|
list.setOnItemClickListener(this::onEntrySelected);
|
||||||
|
|
||||||
|
TextView empty = new TextView(this);
|
||||||
|
empty.setPadding(0, dp(16), 0, 0);
|
||||||
|
empty.setText("No KeePassGO entries are available for autofill.");
|
||||||
|
empty.setVisibility(allEntries.isEmpty() ? View.VISIBLE : View.GONE);
|
||||||
|
list.setVisibility(allEntries.isEmpty() ? View.GONE : View.VISIBLE);
|
||||||
|
root.addView(empty, new LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
|
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||||
|
));
|
||||||
|
LinearLayout.LayoutParams listParams = new LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
listParams.weight = 1f;
|
||||||
|
listParams.topMargin = dp(12);
|
||||||
|
root.addView(list, listParams);
|
||||||
|
|
||||||
|
search.addTextChangedListener(new TextWatcher() {
|
||||||
|
@Override
|
||||||
|
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onTextChanged(CharSequence s, int start, int before, int count) {
|
||||||
|
filterEntries(s == null ? "" : s.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterTextChanged(Editable s) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setContentView(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void filterEntries(String rawQuery) {
|
||||||
|
String query = rawQuery == null ? "" : rawQuery.trim().toLowerCase(Locale.US);
|
||||||
|
visibleEntries.clear();
|
||||||
|
if (query.isEmpty()) {
|
||||||
|
visibleEntries.addAll(allEntries);
|
||||||
|
} else {
|
||||||
|
for (AutofillCacheStore.Entry entry : allEntries) {
|
||||||
|
if (entryLabel(entry).toLowerCase(Locale.US).contains(query)) {
|
||||||
|
visibleEntries.add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
adapter.clear();
|
||||||
|
adapter.addAll(labelsFor(visibleEntries));
|
||||||
|
adapter.notifyDataSetChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onEntrySelected(AdapterView<?> parent, View view, int position, long id) {
|
||||||
|
if (position < 0 || position >= visibleEntries.size() || passwordID == null) {
|
||||||
|
setResult(Activity.RESULT_CANCELED);
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AutofillCacheStore.Entry entry = visibleEntries.get(position);
|
||||||
|
if (matchTarget != null && matchTarget.startsWith("androidapp://")) {
|
||||||
|
AutofillBindingStore.rememberBinding(this, matchTarget, entry.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Dataset.Builder dataset = new Dataset.Builder();
|
||||||
|
dataset.setId(entry.id);
|
||||||
|
if (usernameID != null && entry.username != null && !entry.username.isEmpty()) {
|
||||||
|
dataset.setValue(usernameID, AutofillValue.forText(entry.username));
|
||||||
|
}
|
||||||
|
dataset.setValue(passwordID, AutofillValue.forText(entry.password));
|
||||||
|
|
||||||
|
Intent result = new Intent();
|
||||||
|
result.putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset.build());
|
||||||
|
setResult(Activity.RESULT_OK, result);
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> labelsFor(List<AutofillCacheStore.Entry> entries) {
|
||||||
|
List<String> labels = new ArrayList<>(entries.size());
|
||||||
|
for (AutofillCacheStore.Entry entry : entries) {
|
||||||
|
labels.add(entryLabel(entry));
|
||||||
|
}
|
||||||
|
return labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String entryLabel(AutofillCacheStore.Entry entry) {
|
||||||
|
StringBuilder label = new StringBuilder();
|
||||||
|
label.append(entry.title == null || entry.title.trim().isEmpty() ? "Untitled entry" : entry.title.trim());
|
||||||
|
if (entry.username != null && !entry.username.trim().isEmpty()) {
|
||||||
|
label.append(" (").append(entry.username.trim()).append(")");
|
||||||
|
}
|
||||||
|
if (entry.path != null && !entry.path.isEmpty()) {
|
||||||
|
label.append(" • ").append(String.join(" / ", entry.path));
|
||||||
|
}
|
||||||
|
return label.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int dp(int value) {
|
||||||
|
return Math.round(value * getResources().getDisplayMetrics().density);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package org.julianfamily.keepassgo;
|
package org.julianfamily.keepassgo;
|
||||||
|
|
||||||
|
import android.app.PendingIntent;
|
||||||
import android.app.assist.AssistStructure;
|
import android.app.assist.AssistStructure;
|
||||||
|
import android.content.Intent;
|
||||||
import android.os.CancellationSignal;
|
import android.os.CancellationSignal;
|
||||||
import android.service.autofill.AutofillService;
|
import android.service.autofill.AutofillService;
|
||||||
import android.service.autofill.Dataset;
|
import android.service.autofill.Dataset;
|
||||||
@@ -66,29 +68,21 @@ public final class KeePassGOAutofillService extends AutofillService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, target.matchTarget);
|
AutofillCacheStore.Entry entry = findBoundOrBestMatch(target.matchTarget);
|
||||||
if (entry == null) {
|
if (entry == null) {
|
||||||
|
FillResponse chooser = chooserResponse(target, fields);
|
||||||
|
if (chooser == null) {
|
||||||
Log.i(TAG, "no autofill cache match");
|
Log.i(TAG, "no autofill cache match");
|
||||||
callback.onSuccess(null);
|
callback.onSuccess(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
Log.i(TAG, "returning chooser dataset for " + target.matchTarget);
|
||||||
|
callback.onSuccess(chooser);
|
||||||
|
return;
|
||||||
|
}
|
||||||
Log.i(TAG, "matched entry title=" + entry.title + " user=" + entry.username + " host=" + entry.host);
|
Log.i(TAG, "matched entry title=" + entry.title + " user=" + entry.username + " host=" + entry.host);
|
||||||
|
|
||||||
RemoteViews presentation = new RemoteViews(getPackageName(), android.R.layout.simple_list_item_1);
|
FillResponse response = directFillResponse(entry, fields);
|
||||||
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();
|
|
||||||
Log.i(TAG, "returning dataset");
|
Log.i(TAG, "returning dataset");
|
||||||
callback.onSuccess(response);
|
callback.onSuccess(response);
|
||||||
} catch (Exception err) {
|
} catch (Exception err) {
|
||||||
@@ -103,6 +97,72 @@ public final class KeePassGOAutofillService extends AutofillService {
|
|||||||
callback.onSuccess();
|
callback.onSuccess();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private AutofillCacheStore.Entry findBoundOrBestMatch(String matchTarget) {
|
||||||
|
String entryID = AutofillBindingStore.entryIDForTarget(this, matchTarget);
|
||||||
|
if (!entryID.isEmpty()) {
|
||||||
|
AutofillCacheStore.Entry bound = AutofillCacheStore.findByID(this, entryID);
|
||||||
|
if (bound != null) {
|
||||||
|
return bound;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return AutofillCacheStore.findBestMatch(this, matchTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
private FillResponse directFillResponse(AutofillCacheStore.Entry entry, ParsedFields fields) {
|
||||||
|
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);
|
||||||
|
dataset.setId(entry.id);
|
||||||
|
if (fields.usernameId != null) {
|
||||||
|
dataset.setValue(fields.usernameId, AutofillValue.forText(entry.username));
|
||||||
|
}
|
||||||
|
dataset.setValue(fields.passwordId, AutofillValue.forText(entry.password));
|
||||||
|
|
||||||
|
return new FillResponse.Builder()
|
||||||
|
.addDataset(dataset.build())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private FillResponse chooserResponse(ParsedTarget target, ParsedFields fields) {
|
||||||
|
if (fields.passwordId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<AutofillCacheStore.Entry> candidates = AutofillCacheStore.chooserCandidates(this, target.matchTarget);
|
||||||
|
if (candidates.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoteViews presentation = new RemoteViews(getPackageName(), android.R.layout.simple_list_item_1);
|
||||||
|
presentation.setTextViewText(android.R.id.text1, "Search KeePassGO");
|
||||||
|
|
||||||
|
Intent intent = new Intent(this, KeePassGOAutofillPickerActivity.class);
|
||||||
|
intent.putExtra(KeePassGOAutofillPickerActivity.EXTRA_TARGET, target.matchTarget);
|
||||||
|
intent.putExtra(KeePassGOAutofillPickerActivity.EXTRA_PACKAGE_NAME, target.packageName);
|
||||||
|
intent.putExtra(KeePassGOAutofillPickerActivity.EXTRA_USERNAME_ID, fields.usernameId);
|
||||||
|
intent.putExtra(KeePassGOAutofillPickerActivity.EXTRA_PASSWORD_ID, fields.passwordId);
|
||||||
|
|
||||||
|
PendingIntent pendingIntent = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
target.matchTarget.hashCode(),
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE
|
||||||
|
);
|
||||||
|
|
||||||
|
AutofillId[] ids;
|
||||||
|
if (fields.usernameId != null) {
|
||||||
|
ids = new AutofillId[]{fields.usernameId, fields.passwordId};
|
||||||
|
} else {
|
||||||
|
ids = new AutofillId[]{fields.passwordId};
|
||||||
|
}
|
||||||
|
return new FillResponse.Builder()
|
||||||
|
.setAuthentication(ids, pendingIntent.getIntentSender(), presentation)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
private static ParsedTarget parseWindow(AssistStructure structure, ParsedFields fields) {
|
private static ParsedTarget parseWindow(AssistStructure structure, ParsedFields fields) {
|
||||||
String domain = "";
|
String domain = "";
|
||||||
final int windowCount = structure.getWindowNodeCount();
|
final int windowCount = structure.getWindowNodeCount();
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "build_android_apk: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() error {
|
||||||
|
workDir, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
gogioDir, err := commandOutput("go", "list", "-m", "-f", "{{.Dir}}", "gioui.org/cmd")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tempDir, err := os.MkdirTemp("", "keepassgo-gogio-")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
tempModule := filepath.Join(tempDir, "gioui-cmd")
|
||||||
|
if err := copyDir(gogioDir, tempModule); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
targetFile := filepath.Join(tempModule, "gogio", "androidbuild.go")
|
||||||
|
if err := patchAndroidBuild(targetFile); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := runCommand(tempModule, "gofmt", "-w", targetFile); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := refreshTempModuleDeps(tempModule); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
javaHome := os.Getenv("JAVA_HOME")
|
||||||
|
wrappedJavaHome := javaHome
|
||||||
|
var cleanup func()
|
||||||
|
if major := javaMajor(javaHome); major >= 26 {
|
||||||
|
wrappedJavaHome, cleanup, err = wrapJavaHome(javaHome)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
patchedArgs := append([]string(nil), os.Args[1:]...)
|
||||||
|
if len(patchedArgs) != 0 {
|
||||||
|
last := patchedArgs[len(patchedArgs)-1]
|
||||||
|
if strings.HasPrefix(last, ".") {
|
||||||
|
patchedArgs[len(patchedArgs)-1] = filepath.Join(workDir, last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gogioFiles, err := filepath.Glob(filepath.Join(tempModule, "gogio", "*.go"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
filteredFiles := make([]string, 0, len(gogioFiles))
|
||||||
|
for _, file := range gogioFiles {
|
||||||
|
if strings.HasSuffix(file, "_test.go") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filteredFiles = append(filteredFiles, file)
|
||||||
|
}
|
||||||
|
if len(filteredFiles) == 0 {
|
||||||
|
return fmt.Errorf("no gogio go files found in %s", tempModule)
|
||||||
|
}
|
||||||
|
args := append([]string{"run"}, filteredFiles...)
|
||||||
|
args = append(args, patchedArgs...)
|
||||||
|
cmd := exec.Command("go", args...)
|
||||||
|
cmd.Dir = workDir
|
||||||
|
cmd.Env = append(os.Environ(), "JAVA_HOME="+wrappedJavaHome)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func patchAndroidBuild(path string) error {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
content := string(data)
|
||||||
|
|
||||||
|
content = strings.Replace(content,
|
||||||
|
"\tAppName string\n}",
|
||||||
|
"\tAppName string\n\tApplicationSnippets string\n}",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
content = strings.Replace(content,
|
||||||
|
"\tif err := visitPkg(pkgs[0]); err != nil {\n\t\treturn err\n\t}\n\n\tif err := compileAndroid(tmpDir, tools, bi); err != nil {\n",
|
||||||
|
"\tif err := visitPkg(pkgs[0]); err != nil {\n\t\treturn err\n\t}\n\tmoduleRoot := androidModuleRoot(bi.pkgDir)\n\tmoduleAndroidJars, err := filepath.Glob(filepath.Join(moduleRoot, \"android\", \"*.jar\"))\n\tif err != nil {\n\t\treturn err\n\t}\n\textraJars = append(extraJars, moduleAndroidJars...)\n\n\tif err := compileAndroid(tmpDir, tools, bi); err != nil {\n",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
content = strings.Replace(content,
|
||||||
|
"\terr = os.WriteFile(filepath.Join(v21Dir, \"themes.xml\"), []byte(themesV21), 0660)\n\tif err != nil {\n\t\treturn err\n\t}\n\tresZip := filepath.Join(tmpDir, \"resources.zip\")\n",
|
||||||
|
"\terr = os.WriteFile(filepath.Join(v21Dir, \"themes.xml\"), []byte(themesV21), 0660)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmoduleRoot := androidModuleRoot(bi.pkgDir)\n\tcustomResDir := filepath.Join(moduleRoot, \"android\", \"res\")\n\tif _, err := os.Stat(customResDir); err == nil {\n\t\tif err := copyAndroidDir(customResDir, resDir); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tresZip := filepath.Join(tmpDir, \"resources.zip\")\n",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
content = strings.Replace(content,
|
||||||
|
"\t\tIconSnip: iconSnip,\n\t\tAppName: appName,\n\t}\n",
|
||||||
|
"\t\tIconSnip: iconSnip,\n\t\tAppName: appName,\n\t\tApplicationSnippets: loadAndroidSnippet(filepath.Join(moduleRoot, \"android\", \"application_snippets.xml\")),\n\t}\n",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
content = strings.Replace(content,
|
||||||
|
"\t\t</activity>\n\t</application>\n</manifest>`)\n",
|
||||||
|
"\t\t</activity>\n{{.ApplicationSnippets}}\n\t</application>\n</manifest>`)\n",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
insertPos := strings.Index(content, "func findNDK(")
|
||||||
|
if insertPos < 0 {
|
||||||
|
return fmt.Errorf("findNDK not found")
|
||||||
|
}
|
||||||
|
helpers := `
|
||||||
|
func loadAndroidSnippet(path string) string {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
content := strings.TrimSpace(string(data))
|
||||||
|
if content == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "\n" + content
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyAndroidDir(src, dst string) error {
|
||||||
|
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(src, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
target := filepath.Join(dst, rel)
|
||||||
|
if info.IsDir() {
|
||||||
|
return os.MkdirAll(target, 0755)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(target, data, 0660)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func androidModuleRoot(start string) string {
|
||||||
|
dir := start
|
||||||
|
for {
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir {
|
||||||
|
return start
|
||||||
|
}
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
`
|
||||||
|
content = content[:insertPos] + helpers + content[insertPos:]
|
||||||
|
|
||||||
|
return os.WriteFile(path, []byte(content), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandOutput(name string, args ...string) (string, error) {
|
||||||
|
cmd := exec.Command(name, args...)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
var stderr []byte
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
stderr = exitErr.Stderr
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("%s %s failed: %s%s", name, strings.Join(args, " "), out, stderr)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCommand(dir, name string, args ...string) error {
|
||||||
|
cmd := exec.Command(name, args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshTempModuleDeps(dir string) error {
|
||||||
|
overrides := []string{
|
||||||
|
"golang.org/x/image@v0.37.0",
|
||||||
|
"golang.org/x/mod@v0.33.0",
|
||||||
|
"golang.org/x/sync@v0.20.0",
|
||||||
|
"golang.org/x/text@v0.35.0",
|
||||||
|
"golang.org/x/tools@v0.42.0",
|
||||||
|
}
|
||||||
|
args := append([]string{"get"}, overrides...)
|
||||||
|
return runCommand(dir, "go", args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func javaMajor(javaHome string) int {
|
||||||
|
if strings.TrimSpace(javaHome) == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
cmd := exec.Command(filepath.Join(javaHome, "bin", "java"), "-version")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
matches := regexp.MustCompile(`version "([0-9]+)`).FindStringSubmatch(string(output))
|
||||||
|
if len(matches) != 2 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var major int
|
||||||
|
if _, err := fmt.Sscanf(matches[1], "%d", &major); err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return major
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapJavaHome(javaHome string) (string, func(), error) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "keepassgo-java-home-")
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
binDir := filepath.Join(tempDir, "bin")
|
||||||
|
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||||
|
os.RemoveAll(tempDir)
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
realBin := filepath.Join(javaHome, "bin")
|
||||||
|
if err := os.WriteFile(filepath.Join(binDir, "javac"), []byte("#!/bin/sh\nexport JAVA_TOOL_OPTIONS=-Xint\nexec "+shellQuote(filepath.Join(realBin, "javac"))+" \"$@\"\n"), 0o755); err != nil {
|
||||||
|
os.RemoveAll(tempDir)
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
for _, name := range []string{"java", "jar"} {
|
||||||
|
if err := os.WriteFile(filepath.Join(binDir, name), []byte("#!/bin/sh\nexec "+shellQuote(filepath.Join(realBin, name))+" \"$@\"\n"), 0o755); err != nil {
|
||||||
|
os.RemoveAll(tempDir)
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tempDir, func() { os.RemoveAll(tempDir) }, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func shellQuote(value string) string {
|
||||||
|
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyDir(src, dst string) error {
|
||||||
|
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(src, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
target := filepath.Join(dst, rel)
|
||||||
|
if info.IsDir() {
|
||||||
|
return os.MkdirAll(target, 0o755)
|
||||||
|
}
|
||||||
|
in, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
out, err := os.Create(target)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
if _, err := io.Copy(out, in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return out.Close()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package autofillcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BindingsFile struct {
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
Apps map[string]string `json:"apps,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadBindings(path string) (BindingsFile, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return BindingsFile{}, nil
|
||||||
|
}
|
||||||
|
return BindingsFile{}, err
|
||||||
|
}
|
||||||
|
var bindings BindingsFile
|
||||||
|
if err := json.Unmarshal(data, &bindings); err != nil {
|
||||||
|
return BindingsFile{}, err
|
||||||
|
}
|
||||||
|
if bindings.Apps == nil {
|
||||||
|
bindings.Apps = make(map[string]string)
|
||||||
|
}
|
||||||
|
return bindings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RememberBinding(path, rawTarget, entryID string, now time.Time) error {
|
||||||
|
bindings, err := ReadBindings(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if bindings.Apps == nil {
|
||||||
|
bindings.Apps = make(map[string]string)
|
||||||
|
}
|
||||||
|
target := strings.TrimSpace(rawTarget)
|
||||||
|
id := strings.TrimSpace(entryID)
|
||||||
|
if target == "" || id == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
bindings.Apps[target] = id
|
||||||
|
bindings.UpdatedAt = now.UTC().Format(time.RFC3339)
|
||||||
|
data, err := json.MarshalIndent(bindings, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, data, 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveWithBindings(cache File, bindings BindingsFile, rawTarget string) MatchResult {
|
||||||
|
target := strings.TrimSpace(rawTarget)
|
||||||
|
if entryID := strings.TrimSpace(bindings.Apps[target]); entryID != "" {
|
||||||
|
for _, entry := range cache.Entries {
|
||||||
|
if entry.ID == entryID {
|
||||||
|
return MatchResult{Status: MatchStatusFound, Entry: entry}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Resolve(cache, rawTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ChooserCandidates(cache File, rawTarget string) []Entry {
|
||||||
|
if result := Resolve(cache, rawTarget); result.Status == MatchStatusFound {
|
||||||
|
return []Entry{result.Entry}
|
||||||
|
}
|
||||||
|
candidates := append([]Entry(nil), cache.Entries...)
|
||||||
|
slices.SortFunc(candidates, func(left, right Entry) int {
|
||||||
|
if cmp := strings.Compare(strings.ToLower(strings.TrimSpace(left.Title)), strings.ToLower(strings.TrimSpace(right.Title))); cmp != 0 {
|
||||||
|
return cmp
|
||||||
|
}
|
||||||
|
if cmp := strings.Compare(strings.ToLower(strings.Join(left.Path, "/")), strings.ToLower(strings.Join(right.Path, "/"))); cmp != 0 {
|
||||||
|
return cmp
|
||||||
|
}
|
||||||
|
return strings.Compare(left.ID, right.ID)
|
||||||
|
})
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package autofillcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolvePrefersLearnedAndroidAppBinding(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache := File{
|
||||||
|
Entries: []Entry{
|
||||||
|
{
|
||||||
|
ID: "danny-ocean",
|
||||||
|
Title: "Bellagio Vault",
|
||||||
|
Username: "danny",
|
||||||
|
Password: "secret1",
|
||||||
|
URL: "https://bellagio.example.invalid/login",
|
||||||
|
Host: "bellagio.example.invalid",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rusty-ryan",
|
||||||
|
Title: "Mirage Crew",
|
||||||
|
Username: "rusty",
|
||||||
|
Password: "secret2",
|
||||||
|
URL: "https://mirage.example.invalid/login",
|
||||||
|
Host: "mirage.example.invalid",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
bindings := BindingsFile{
|
||||||
|
Apps: map[string]string{
|
||||||
|
"androidapp://com.samsung.android.shealth": "rusty-ryan",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := ResolveWithBindings(cache, bindings, "androidapp://com.samsung.android.shealth")
|
||||||
|
if got.Status != MatchStatusFound {
|
||||||
|
t.Fatalf("ResolveWithBindings() status = %q, want found", got.Status)
|
||||||
|
}
|
||||||
|
if got.Entry.ID != "rusty-ryan" {
|
||||||
|
t.Fatalf("ResolveWithBindings() entry = %q, want rusty-ryan", got.Entry.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChooserCandidatesFallBackToAllEntriesForUnknownAndroidApp(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache := File{
|
||||||
|
Entries: []Entry{
|
||||||
|
{
|
||||||
|
ID: "basher-tarr",
|
||||||
|
Title: "Bellagio Vault",
|
||||||
|
Username: "basher",
|
||||||
|
Password: "secret1",
|
||||||
|
URL: "https://bellagio.example.invalid/login",
|
||||||
|
Host: "bellagio.example.invalid",
|
||||||
|
Path: []string{"Crew"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "linus-caldwell",
|
||||||
|
Title: "Bank Floor",
|
||||||
|
Username: "linus",
|
||||||
|
Password: "secret2",
|
||||||
|
URL: "https://bank.example.invalid/sign-in",
|
||||||
|
Host: "bank.example.invalid",
|
||||||
|
Path: []string{"Operations"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := ChooserCandidates(cache, "androidapp://com.samsung.android.shealth")
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("len(ChooserCandidates()) = %d, want 2", len(got))
|
||||||
|
}
|
||||||
|
if got[0].ID != "linus-caldwell" || got[1].ID != "basher-tarr" {
|
||||||
|
t.Fatalf("ChooserCandidates() = %#v, want title-sorted fallback candidates", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRememberBindingPersistsAndroidAppSelection(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "autofill-bindings.json")
|
||||||
|
now := time.Date(2026, time.April, 13, 18, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
if err := RememberBinding(path, "androidapp://com.samsung.android.shealth", "saul-bloom", now); err != nil {
|
||||||
|
t.Fatalf("RememberBinding() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := ReadBindings(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadBindings() error = %v", err)
|
||||||
|
}
|
||||||
|
if got.UpdatedAt != now.UTC().Format(time.RFC3339) {
|
||||||
|
t.Fatalf("UpdatedAt = %q, want %q", got.UpdatedAt, now.UTC().Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
if got.Apps["androidapp://com.samsung.android.shealth"] != "saul-bloom" {
|
||||||
|
t.Fatalf("binding = %#v, want samsung health -> saul-bloom", got.Apps)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user