diff --git a/.codex/skills/keepassgo-apk-test/SKILL.md b/.codex/skills/keepassgo-apk-test/SKILL.md
index b6e4e73..8dc4fd9 100644
--- a/.codex/skills/keepassgo-apk-test/SKILL.md
+++ b/.codex/skills/keepassgo-apk-test/SKILL.md
@@ -45,8 +45,8 @@ Use this skill together with the installed `android-emulator-debug` skill. That
## Build Workflow
1. Verify the JDK/SDK paths match the known working environment.
-2. Build with `make apk`.
-3. If `make apk` fails, inspect the effective `JAVA_HOME`, `ANDROID_SDK_ROOT`, and `ANDROID_NDK_ROOT` before changing code.
+2. Build with `make apk` for debug validation, or `make apk-release` when validating production signing behavior.
+3. If the build fails, inspect the effective `JAVA_HOME`, `ANDROID_SDK_ROOT`, and `ANDROID_NDK_ROOT` before changing code.
4. If the problem is Android-only, avoid desktop-only conclusions from `go test ./...`.
Typical local build:
@@ -55,6 +55,12 @@ Typical local build:
JAVA_HOME=/usr/lib/jvm/java-25-openjdk make apk
```
+Typical local release build:
+
+```sh
+JAVA_HOME=/usr/lib/jvm/java-25-openjdk make apk-release
+```
+
## Emulator Workflow
1. Reuse an existing emulator session if one is already running.
@@ -79,7 +85,7 @@ adb shell dumpsys window | rg 'mCurrentFocus|mFocusedApp'
## Validation Checklist
-- APK builds successfully with `make apk`.
+- APK builds successfully with the intended target: `make apk` for debug validation or `make apk-release` for release-signing validation.
- App launches to `org.julianfamily.keepassgo/org.gioui.GioActivity`.
- Screenshot shows the expected screen, not just a black frame.
- `logcat` shows no app crash or Android runtime fatal error.
diff --git a/.codex/skills/keepassgo-ship-it/SKILL.md b/.codex/skills/keepassgo-ship-it/SKILL.md
index 81f2768..f819b22 100644
--- a/.codex/skills/keepassgo-ship-it/SKILL.md
+++ b/.codex/skills/keepassgo-ship-it/SKILL.md
@@ -52,11 +52,17 @@ The installed package version must correspond to the committed source, not a dir
Use the repo's known-good local JDK unless the environment already proves otherwise:
```sh
-JAVA_HOME=/usr/lib/jvm/java-25-openjdk make apk
+JAVA_HOME=/usr/lib/jvm/java-25-openjdk make apk-release
```
If that JDK is unavailable on the current host, use the working replacement already established for the machine and say so in the closeout.
+- `ship it` must use the dedicated release keystore flow, not Gio's implicit debug or temporary signing path.
+- The default local release-signing paths are:
+ `~/.config/keepassgo/android-release.keystore`
+ `~/.config/keepassgo/android-release.pass`
+- If those files are unavailable, stop and fix signing instead of shipping a differently signed APK.
+
### 4. Zip The APK
- Create the ZIP under the globally required temporary secret-safe directory.
diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index 1f6e61d..b4e8c49 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -135,11 +135,14 @@ jobs:
shell: bash
run: |
set -euo pipefail
- signkey_path="$(mktemp)"
- trap 'rm -f -- "$signkey_path"' EXIT
+ mkdir -p build/ci-signing
+ signkey_path="$(pwd)/build/ci-signing/android-release.keystore"
+ signpass_path="$(pwd)/build/ci-signing/android-release.pass"
+ trap 'rm -f -- "$signkey_path" "$signpass_path"' EXIT
printf '%s' '${{ secrets.APK_SIGNKEY_B64 }}' | base64 -d > "$signkey_path"
+ printf '%s' '${{ secrets.APK_SIGNPASS }}' > "$signpass_path"
export APP_VERSION="$(git describe --tags --always --dirty)"
- make apk SIGNKEY="$signkey_path" SIGNPASS='${{ secrets.APK_SIGNPASS }}'
+ make apk-release RELEASE_SIGNKEY="$signkey_path" RELEASE_SIGNPASS_FILE="$signpass_path"
cp build/keepassgo.apk "${DIST_DIR}/keepassgo.apk"
- name: Upload CI artifacts
diff --git a/APK.md b/APK.md
index 3f7e7d9..af93d30 100644
--- a/APK.md
+++ b/APK.md
@@ -6,17 +6,42 @@ Build the APK with:
make apk
```
+Build the release-signed APK with:
+
+```sh
+make apk-release
+```
+
+`make apk` uses a local Java 25 install when `JAVA_HOME` points to one.
+If the host does not have a working Java 25 install, it falls back to the
+repo-managed Docker image in `packaging/docker/android-apk/`, which also builds
+with Java 25.
+
+`make apk` remains a developer build path and may use Gio's default debug or
+ephemeral signing behavior if no explicit signing key is provided.
+`make apk-release` is the production-signing path and fails unless a dedicated
+release keystore and password file are present.
+
Environment:
- `ANDROID_SDK_ROOT` defaults to `/opt/android-sdk`.
- `ANDROID_NDK_ROOT` defaults to `/opt/android-ndk`.
- `JAVA_HOME` defaults to `/usr/lib/jvm/java-25-openjdk`.
+- `APK_BUILD_IMAGE` overrides the Docker image name used by `make apk-container`.
- `APP_ID` overrides the Android application id.
- `APP_VERSION` overrides the version shown inside KeePassGO itself.
- `APK_OUT` overrides the output path.
- `APK_VERSION` overrides the packaged app version.
- `ANDROID_MIN_SDK` overrides the minimum supported Android SDK.
- `ANDROID_TARGET_SDK` overrides the target Android SDK.
+- `SIGNPASS_FILE` provides the signing password by file instead of a command-line argument.
+- `RELEASE_SIGNKEY` overrides the release keystore path used by `make apk-release`.
+- `RELEASE_SIGNPASS_FILE` overrides the password file path used by `make apk-release`.
+
+Default release-signing paths:
+
+- `~/.config/keepassgo/android-release.keystore`
+- `~/.config/keepassgo/android-release.pass`
Installed machine prerequisites expected by this repo:
@@ -24,24 +49,28 @@ Installed machine prerequisites expected by this repo:
- `android-sdk-build-tools`
- `android-platform-35`
- `android-sdk-platform-tools`
-- a working JDK install
+- a working Java 25 JDK install for `make apk-local`, or Docker for `make apk`
-The repo tracks `gogio` as a Go tool, so the build runs through:
+The repo tracks `gogio` as a Go tool, and the local build runs through:
```sh
go tool gogio -target android ./cmd/keepassgo ...
```
+The release target wraps `make apk` and injects explicit signing credentials so
+local release builds and CI use the same stable key without echoing the release
+password in build logs.
+
The Android build uses the branded icon asset at:
- `internal/assets/keepassgo-icon.png`
Note:
-- Gio's Android doc currently references Java 1.8, but the Android build-tools
- installed on this machine (`d8` from build-tools 37) do not run on Java 8.
-- In this environment, KeePassGO's APK build requires a newer JDK runtime on
- `PATH`, which is why the repo defaults `JAVA_HOME` to `/usr/lib/jvm/java-25-openjdk`.
+- KeePassGO's documented Android build uses Java 25 locally.
+- If that host setup is unavailable, `make apk` falls back to the Docker image
+ so the build still runs under Java 25 instead of encoding a newer host JDK as
+ a requirement.
- Android runtime testing on the `KeepassGoAPI35` emulator showed a black-screen
regression with `gioui.org v0.9.0` while a stock Gio example and KeePassGO both
rendered correctly with `gioui.org v0.8.0` on the same emulator and SDK/JDK
diff --git a/Makefile b/Makefile
index 320b7b8..eba6520 100644
--- a/Makefile
+++ b/Makefile
@@ -2,6 +2,7 @@ ANDROID_SDK_ROOT ?= /opt/android-sdk
ANDROID_NDK_ROOT ?= /opt/android-ndk
JAVA_HOME ?= /usr/lib/jvm/java-25-openjdk
PATH := $(JAVA_HOME)/bin:$(ANDROID_SDK_ROOT)/cmdline-tools/latest/bin:$(ANDROID_SDK_ROOT)/platform-tools:$(PATH)
+APK_BUILD_IMAGE ?= keepassgo/android-apk-build:java25
APP_ID ?= org.julianfamily.keepassgo
APK_OUT ?= build/keepassgo.apk
APK_VERSION ?= 0.1.0.1
@@ -11,6 +12,9 @@ ANDROID_MIN_SDK ?= 28
ANDROID_TARGET_SDK ?= 35
SIGNKEY ?=
SIGNPASS ?=
+SIGNPASS_FILE ?=
+RELEASE_SIGNKEY ?= $(HOME)/.config/keepassgo/android-release.keystore
+RELEASE_SIGNPASS_FILE ?= $(HOME)/.config/keepassgo/android-release.pass
ARCH_PKG_DIR ?= packaging/archlinux/keepassgo-git
ARCH_PKG_TMPL ?= $(ARCH_PKG_DIR)/PKGBUILD.tmpl
ARCH_PKGBUILD ?= $(ARCH_PKG_DIR)/PKGBUILD
@@ -25,8 +29,31 @@ ifneq ($(strip $(SIGNPASS)),)
GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS)
endif
-.PHONY: apk archlinux-pkgbuild browser-bridge browser-extension-validate
-apk: android/keepassgo-android.jar
+CONTAINER_SIGNKEY_MOUNT :=
+CONTAINER_SIGNPASSFILE_MOUNT :=
+CONTAINER_SIGN_ARGS :=
+ifneq ($(strip $(SIGNKEY)),)
+CONTAINER_SIGNKEY_MOUNT += -v "$(dir $(abspath $(SIGNKEY))):$(dir $(abspath $(SIGNKEY))):ro"
+CONTAINER_SIGN_ARGS += SIGNKEY="$(abspath $(SIGNKEY))"
+endif
+ifneq ($(strip $(SIGNPASS)),)
+CONTAINER_SIGN_ARGS += SIGNPASS="$(SIGNPASS)"
+endif
+ifneq ($(strip $(SIGNPASS_FILE)),)
+CONTAINER_SIGNPASSFILE_MOUNT += -v "$(dir $(abspath $(SIGNPASS_FILE))):$(dir $(abspath $(SIGNPASS_FILE))):ro"
+CONTAINER_SIGN_ARGS += SIGNPASS_FILE="$(abspath $(SIGNPASS_FILE))"
+endif
+
+.PHONY: apk apk-local apk-release apk-container apk-container-image archlinux-pkgbuild browser-bridge browser-extension-validate
+apk:
+ @if [ -x "$(JAVA_HOME)/bin/java" ] && "$(JAVA_HOME)/bin/java" -version 2>&1 | grep -q 'version "25'; then \
+ $(MAKE) apk-local JAVA_HOME="$(JAVA_HOME)"; \
+ else \
+ echo "Using Dockerized Java 25 Android build because JAVA_HOME is not a working Java 25 install."; \
+ $(MAKE) apk-container; \
+ fi
+
+apk-local: android/keepassgo-android.jar
@test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
@test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
@test -d "$(ANDROID_NDK_ROOT)" || { echo "ANDROID_NDK_ROOT must point to an Android NDK install"; exit 1; }
@@ -34,6 +61,12 @@ apk: android/keepassgo-android.jar
@test -d "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)" || { echo "Android platform android-$(ANDROID_TARGET_SDK) is missing"; exit 1; }
@test -d "$(ANDROID_SDK_ROOT)/build-tools" || { echo "Android build-tools are missing"; exit 1; }
@mkdir -p "$(dir $(APK_OUT))"
+ @set -eu; \
+ if [ -n "$(SIGNPASS_FILE)" ]; then \
+ test -f "$(SIGNPASS_FILE)" || { echo "SIGNPASS_FILE does not exist: $(SIGNPASS_FILE)"; exit 1; }; \
+ export GOGIO_SIGNPASS="$$(tr -d '\r\n' < "$(SIGNPASS_FILE)")"; \
+ test -n "$$GOGIO_SIGNPASS" || { echo "SIGNPASS_FILE is empty: $(SIGNPASS_FILE)"; exit 1; }; \
+ fi; \
ANDROID_HOME="$(ANDROID_SDK_ROOT)" \
ANDROID_SDK_ROOT="$(ANDROID_SDK_ROOT)" \
ANDROID_NDK_ROOT="$(ANDROID_NDK_ROOT)" \
@@ -50,12 +83,39 @@ apk: android/keepassgo-android.jar
-icon internal/assets/keepassgo-icon.png \
./cmd/keepassgo
+apk-release:
+ @test -f "$(RELEASE_SIGNKEY)" || { echo "Release signing key not found at $(RELEASE_SIGNKEY)"; exit 1; }
+ @test -f "$(RELEASE_SIGNPASS_FILE)" || { echo "Release signing password file not found at $(RELEASE_SIGNPASS_FILE)"; exit 1; }
+ @$(MAKE) apk SIGNKEY="$(abspath $(RELEASE_SIGNKEY))" SIGNPASS_FILE="$(abspath $(RELEASE_SIGNPASS_FILE))"
+
+apk-container: apk-container-image
+ @command -v docker >/dev/null 2>&1 || { echo "docker is required for apk-container"; exit 1; }
+ @test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
+ @test -d "$(ANDROID_NDK_ROOT)" || { echo "ANDROID_NDK_ROOT must point to an Android NDK install"; exit 1; }
+ docker run --rm \
+ -u "$$(id -u):$$(id -g)" \
+ -v "$(CURDIR):$(CURDIR)" \
+ -w "$(CURDIR)" \
+ -v "$(ANDROID_SDK_ROOT):$(ANDROID_SDK_ROOT)" \
+ -v "$(ANDROID_NDK_ROOT):$(ANDROID_NDK_ROOT)" \
+ $(CONTAINER_SIGNKEY_MOUNT) \
+ $(CONTAINER_SIGNPASSFILE_MOUNT) \
+ -e ANDROID_SDK_ROOT="$(ANDROID_SDK_ROOT)" \
+ -e ANDROID_NDK_ROOT="$(ANDROID_NDK_ROOT)" \
+ -e JAVA_HOME=/opt/java/openjdk \
+ $(APK_BUILD_IMAGE) \
+ make apk-local JAVA_HOME=/opt/java/openjdk $(CONTAINER_SIGN_ARGS)
+
+apk-container-image:
+ @command -v docker >/dev/null 2>&1 || { echo "docker is required for apk-container-image"; exit 1; }
+ docker build --load -t $(APK_BUILD_IMAGE) packaging/docker/android-apk
+
android/keepassgo-android.jar: $(shell find androidsrc -type f | sort)
@test -x "$(JAVA_HOME)/bin/javac" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
@test -f "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)/android.jar" || { echo "Android platform android-$(ANDROID_TARGET_SDK) is missing"; exit 1; }
@mkdir -p android
- @zsh -lc 'tmpdir=$$(mktemp -d); \
- trap '\''python3 -c "import shutil,sys; shutil.rmtree(sys.argv[1], ignore_errors=True)" "$$tmpdir"'\'' EXIT; \
+ @sh -ec 'tmpdir=$$(mktemp -d); \
+ trap "rm -rf $$tmpdir" EXIT; \
"$(JAVA_HOME)/bin/javac" \
-classpath "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)/android.jar" \
-d "$$tmpdir" \
diff --git a/README.md b/README.md
index a84c875..fb720d3 100644
--- a/README.md
+++ b/README.md
@@ -90,10 +90,24 @@ go get -tool gioui.org/cmd/gogio@latest
Package:
```bash
-go tool gogio -target android -icon internal/assets/keepassgo-icon.png ./cmd/keepassgo
+make apk
```
-You will need the Android SDK and NDK installed and configured for real device or release packaging.
+`make apk` prefers a local Java 25 install at `JAVA_HOME`. If that is not
+available, it falls back to the repo-managed Docker build image, which also
+uses Java 25. You still need the Android SDK and NDK installed and configured
+for real device or release packaging.
+
+Release package:
+
+```bash
+make apk-release
+```
+
+`make apk-release` is the production-signing path. It requires a dedicated
+release keystore at `~/.config/keepassgo/android-release.keystore` and a
+password file at `~/.config/keepassgo/android-release.pass`, unless you
+override `RELEASE_SIGNKEY` and `RELEASE_SIGNPASS_FILE`.
## Automation
diff --git a/android/application_snippets.xml b/android/application_snippets.xml
index 8889103..d2f6caf 100644
--- a/android/application_snippets.xml
+++ b/android/application_snippets.xml
@@ -22,6 +22,10 @@
android:name="android.accessibilityservice"
android:resource="@xml/keepassgo_accessibility_service" />
+
+
+
+
diff --git a/androidsrc/org/julianfamily/keepassgo/AutofillBindingStore.java b/androidsrc/org/julianfamily/keepassgo/AutofillBindingStore.java
new file mode 100644
index 0000000..79d732b
--- /dev/null
+++ b/androidsrc/org/julianfamily/keepassgo/AutofillBindingStore.java
@@ -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 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 apps = new LinkedHashMap<>();
+ }
+}
diff --git a/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java b/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java
index 770f949..5686193 100644
--- a/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java
+++ b/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java
@@ -10,6 +10,7 @@ import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
+import java.util.Comparator;
import java.util.List;
import java.util.Locale;
@@ -20,18 +21,7 @@ final class 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;
- }
+ List entries = readEntries(context);
if (entries.isEmpty()) {
return null;
}
@@ -57,6 +47,50 @@ final class AutofillCacheStore {
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 chooserCandidates(Context context, String rawTarget) {
+ List entries = readEntries(context);
+ if (entries.isEmpty()) {
+ return entries;
+ }
+ Entry direct = findBestMatch(context, rawTarget);
+ if (direct != null) {
+ List 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 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) {
List candidates = new ArrayList<>();
File filesDir = context.getFilesDir();
@@ -103,16 +137,21 @@ final class AutofillCacheStore {
}
private static Entry readEntry(JsonReader reader) throws IOException {
+ String id = "";
String title = "";
String username = "";
String password = "";
String host = "";
String url = "";
List targets = new ArrayList<>();
+ List path = new ArrayList<>();
reader.beginObject();
while (reader.hasNext()) {
String name = reader.nextName();
switch (name) {
+ case "id":
+ id = nextString(reader);
+ break;
case "title":
title = nextString(reader);
break;
@@ -135,13 +174,20 @@ final class AutofillCacheStore {
}
reader.endArray();
break;
+ case "path":
+ reader.beginArray();
+ while (reader.hasNext()) {
+ path.add(nextString(reader));
+ }
+ reader.endArray();
+ break;
default:
reader.skipValue();
break;
}
}
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 {
@@ -293,20 +339,24 @@ final class AutofillCacheStore {
}
static final class Entry {
+ final String id;
final String title;
final String username;
final String password;
final String host;
final String url;
final List targets;
+ final List path;
- Entry(String title, String username, String password, String host, String url, List targets) {
+ Entry(String id, String title, String username, String password, String host, String url, List targets, List 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);
}
}
diff --git a/androidsrc/org/julianfamily/keepassgo/KeePassGOAutofillPickerActivity.java b/androidsrc/org/julianfamily/keepassgo/KeePassGOAutofillPickerActivity.java
new file mode 100644
index 0000000..6236036
--- /dev/null
+++ b/androidsrc/org/julianfamily/keepassgo/KeePassGOAutofillPickerActivity.java
@@ -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 allEntries = new ArrayList<>();
+ private final List visibleEntries = new ArrayList<>();
+ private ArrayAdapter 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 labelsFor(List entries) {
+ List 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);
+ }
+}
diff --git a/androidsrc/org/julianfamily/keepassgo/KeePassGOAutofillService.java b/androidsrc/org/julianfamily/keepassgo/KeePassGOAutofillService.java
index ecd83db..557e855 100644
--- a/androidsrc/org/julianfamily/keepassgo/KeePassGOAutofillService.java
+++ b/androidsrc/org/julianfamily/keepassgo/KeePassGOAutofillService.java
@@ -1,6 +1,8 @@
package org.julianfamily.keepassgo;
+import android.app.PendingIntent;
import android.app.assist.AssistStructure;
+import android.content.Intent;
import android.os.CancellationSignal;
import android.service.autofill.AutofillService;
import android.service.autofill.Dataset;
@@ -66,29 +68,21 @@ public final class KeePassGOAutofillService extends AutofillService {
return;
}
- AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, target.matchTarget);
+ AutofillCacheStore.Entry entry = findBoundOrBestMatch(target.matchTarget);
if (entry == null) {
- Log.i(TAG, "no autofill cache match");
- callback.onSuccess(null);
+ FillResponse chooser = chooserResponse(target, fields);
+ if (chooser == null) {
+ Log.i(TAG, "no autofill cache match");
+ callback.onSuccess(null);
+ 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);
- 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();
+ FillResponse response = directFillResponse(entry, fields);
Log.i(TAG, "returning dataset");
callback.onSuccess(response);
} catch (Exception err) {
@@ -103,6 +97,72 @@ public final class KeePassGOAutofillService extends AutofillService {
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 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) {
String domain = "";
final int windowCount = structure.getWindowNodeCount();
diff --git a/androidsrc/org/julianfamily/keepassgo/SharedVaultImportActivity.java b/androidsrc/org/julianfamily/keepassgo/SharedVaultImportActivity.java
index b669fa7..778b50c 100644
--- a/androidsrc/org/julianfamily/keepassgo/SharedVaultImportActivity.java
+++ b/androidsrc/org/julianfamily/keepassgo/SharedVaultImportActivity.java
@@ -1,6 +1,7 @@
package org.julianfamily.keepassgo;
import android.app.Activity;
+import android.content.ClipData;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
@@ -10,9 +11,11 @@ import android.util.Log;
import java.io.File;
import java.io.FileOutputStream;
+import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
public final class SharedVaultImportActivity extends Activity {
private static final String TAG = "KeePassGOImport";
@@ -36,6 +39,7 @@ public final class SharedVaultImportActivity extends Activity {
}
private void handleIntent(Intent intent) {
+ logIntent(intent);
Uri uri = resolveSharedUri(intent);
if (uri == null) {
Log.i(TAG, "no shared vault URI on intent");
@@ -55,10 +59,29 @@ public final class SharedVaultImportActivity extends Activity {
}
String action = intent.getAction();
if (Intent.ACTION_SEND.equals(action)) {
- return intent.getParcelableExtra(Intent.EXTRA_STREAM);
+ Uri extraStream = intent.getParcelableExtra(Intent.EXTRA_STREAM);
+ if (extraStream != null) {
+ return extraStream;
+ }
+ }
+ if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
+ ArrayList streams = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
+ if (streams != null && !streams.isEmpty()) {
+ return streams.get(0);
+ }
}
if (Intent.ACTION_VIEW.equals(action)) {
- return intent.getData();
+ Uri data = intent.getData();
+ if (data != null) {
+ return data;
+ }
+ }
+ ClipData clipData = intent.getClipData();
+ if (clipData != null && clipData.getItemCount() > 0) {
+ Uri clipUri = clipData.getItemAt(0).getUri();
+ if (clipUri != null) {
+ return clipUri;
+ }
}
return null;
}
@@ -69,7 +92,7 @@ public final class SharedVaultImportActivity extends Activity {
throw new IOException("failed to create " + dir.getAbsolutePath());
}
File pendingFile = new File(dir, "pending-shared-vault.kdbx");
- try (InputStream in = getContentResolver().openInputStream(uri)) {
+ try (InputStream in = openSharedInputStream(uri)) {
if (in == null) {
throw new IOException("failed to open shared vault stream");
}
@@ -88,6 +111,17 @@ public final class SharedVaultImportActivity extends Activity {
}
}
+ private InputStream openSharedInputStream(Uri uri) throws IOException {
+ if ("file".equalsIgnoreCase(uri.getScheme())) {
+ String path = uri.getPath();
+ if (path == null || path.trim().isEmpty()) {
+ throw new IOException("file URI is missing a path");
+ }
+ return new FileInputStream(new File(path));
+ }
+ return getContentResolver().openInputStream(uri);
+ }
+
private String resolveDisplayName(Uri uri) {
String displayName = queryDisplayName(uri);
if (!displayName.isEmpty()) {
@@ -123,6 +157,20 @@ public final class SharedVaultImportActivity extends Activity {
return "";
}
+ private void logIntent(Intent intent) {
+ if (intent == null) {
+ return;
+ }
+ Log.i(TAG, "intent action=" + intent.getAction()
+ + " type=" + intent.getType()
+ + " data=" + intent.getData()
+ + " flags=0x" + Integer.toHexString(intent.getFlags()));
+ ClipData clipData = intent.getClipData();
+ if (clipData != null) {
+ Log.i(TAG, "intent clip items=" + clipData.getItemCount());
+ }
+ }
+
private void launchMainActivity() {
Intent launch = new Intent();
launch.setClassName(this, "org.gioui.GioActivity");
diff --git a/go.mod b/go.mod
index 487dee9..88f2684 100644
--- a/go.mod
+++ b/go.mod
@@ -2,8 +2,12 @@ module git.julianfamily.org/keepassgo
go 1.26
+replace gioui.org => git.julianfamily.org/joejulian/gio-patched v0.9.1-0.20260416220049-9bfa6bc1c2dc
+
+replace gioui.org/cmd => git.julianfamily.org/joejulian/gio-cmd-patched v0.9.1-0.20260417040456-1762d36ddecc
+
require (
- gioui.org v0.8.0
+ gioui.org v0.9.0
gioui.org/x v0.8.0
github.com/atotto/clipboard v0.1.4
github.com/tobischo/gokeepasslib/v3 v3.6.2
@@ -193,7 +197,6 @@ require (
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.24.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
- golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
golang.org/x/image v0.37.0 // indirect
golang.org/x/mod v0.33.0 // indirect
diff --git a/go.sum b/go.sum
index 6bdda20..34e1ce8 100644
--- a/go.sum
+++ b/go.sum
@@ -37,15 +37,15 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY=
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
-gioui.org v0.8.0 h1:QV5p5JvsmSmGiIXVYOKn6d9YDliTfjtLlVf5J+BZ9Pg=
-gioui.org v0.8.0/go.mod h1:vEMmpxMOd/iwJhXvGVIzWEbxMWhnMQ9aByOGQdlQ8rc=
-gioui.org/cmd v0.8.0 h1:oy5qOlc1UXcglc5HBCMZQELiIzQ2obhT98mw+SuWafQ=
-gioui.org/cmd v0.8.0/go.mod h1:wKLAyAgRR25VMYFzGX2Ecia0m0Td562wDcZ3LaPHPTI=
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
gioui.org/x v0.8.0 h1:RhIlQNOFKKn8D8FeaKKaXCo7vB3x+fq4VcD10HW/YpA=
gioui.org/x v0.8.0/go.mod h1:aXtQb+kyqoUOjDl5/uMqAopjzVzMkeHBbMQOGT5KnSE=
+git.julianfamily.org/joejulian/gio-cmd-patched v0.9.1-0.20260417040456-1762d36ddecc h1:jyfCTx9wk/uLaEMkdKsg491C/kjfbG2EKAVTORhZxHo=
+git.julianfamily.org/joejulian/gio-cmd-patched v0.9.1-0.20260417040456-1762d36ddecc/go.mod h1:RBQfFU8JCgMjQ2wKU9DG3zMC38TnY97E5MKoBGhGl3s=
+git.julianfamily.org/joejulian/gio-patched v0.9.1-0.20260416220049-9bfa6bc1c2dc h1:p2AaZUAXa/ExPybNyeB05+GjTSZGA9lCfDpWz49IT5Y=
+git.julianfamily.org/joejulian/gio-patched v0.9.1-0.20260416220049-9bfa6bc1c2dc/go.mod h1:BdI7mF5DCa3kxlo3G93XHL7khtZnk1gu4335pExk8gs=
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 h1:bGG/g4ypjrCJoSvFrP5hafr9PPB5aw8SjcOWWila7ZI=
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0/go.mod h1:+axXBRUTIDlCeE73IKeD/os7LoEnTKdkp8/gQOFjqyo=
github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E=
@@ -132,10 +132,12 @@ github.com/charithe/durationcheck v0.0.10 h1:wgw73BiocdBDQPik+zcEoBG/ob8uyBHf2iy
github.com/charithe/durationcheck v0.0.10/go.mod h1:bCWXb7gYRysD1CU3C+u4ceO49LoGOY1C1L6uouGNreQ=
github.com/chavacava/garif v0.1.0 h1:2JHa3hbYf5D9dsgseMKAmc/MZ109otzgNFk5s87H9Pc=
github.com/chavacava/garif v0.1.0/go.mod h1:XMyYCkEL58DF0oyW4qDjjnPWONs2HBqYKI+UIPD+Gww=
-github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4 h1:QD3KxSJ59L2lxG6MXBjNHxiQO2RmxTQ3XcK+wO44WOg=
-github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4/go.mod h1:PfAWWKJqjlGFYJEidUM6aVIWPr0EpobeyVWEEmplX7g=
-github.com/chromedp/chromedp v0.5.2 h1:W8xBXQuUnd2dZK0SN/lyVwsQM7KgW+kY5HGnntms194=
-github.com/chromedp/chromedp v0.5.2/go.mod h1:rsTo/xRo23KZZwFmWk2Ui79rBaVRRATCjLzNQlOFSiA=
+github.com/chromedp/cdproto v0.0.0-20250429231605-6ed5b53462d4 h1:UZdrvid2JFwnvPlUSEFlE794XZL4Jmrj8fuxfcLECJE=
+github.com/chromedp/cdproto v0.0.0-20250429231605-6ed5b53462d4/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
+github.com/chromedp/chromedp v0.13.6 h1:xlNunMyzS5bu3r/QKrb3fzX6ow3WBQ6oao+J65PGZxk=
+github.com/chromedp/chromedp v0.13.6/go.mod h1:h8GPP6ZtLMLsU8zFbTcb7ZDGCvCy8j/vRoFmRltQx9A=
+github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
+github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
@@ -180,6 +182,8 @@ github.com/go-critic/go-critic v0.12.0/go.mod h1:DpE0P6OVc6JzVYzmM5gq5jMU31zLr4a
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+github.com/go-json-experiment/json v0.0.0-20250417205406-170dfdcf87d1 h1:+VexzzkMLb1tnvpuQdGT/DicIRW7MN8ozsXqBMgp0Hk=
+github.com/go-json-experiment/json v0.0.0-20250417205406-170dfdcf87d1/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
@@ -224,12 +228,12 @@ github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUW
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
-github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0=
-github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
-github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
-github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
-github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
-github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
+github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
+github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
+github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
+github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
+github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
+github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro=
github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
@@ -370,8 +374,6 @@ github.com/kisielk/errcheck v1.9.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE=
github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg=
-github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08 h1:V0an7KRw92wmJysvFvtqtKMAPmvS5O0jtB0nYo6t+gs=
-github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08/go.mod h1:dFWs1zEqDjFtnBXsd1vPOZaLsESovai349994nHx3e0=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
@@ -404,8 +406,6 @@ github.com/macabu/inamedparam v0.1.3 h1:2tk/phHkMlEL/1GNe/Yf6kkR/hkcUdAEY3L0hjYV
github.com/macabu/inamedparam v0.1.3/go.mod h1:93FLICAIk/quk7eaPPQvbzihUdn/QkGDwIZEoLtpH6I=
github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo=
github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
-github.com/mailru/easyjson v0.7.0 h1:aizVhC/NAAcKWb+5QsU1iNOZb4Yws5UO2I+aIprQITM=
-github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs=
github.com/maratori/testableexamples v1.0.0 h1:dU5alXRrD8WKSjOUnmJZuzdxWOEQ57+7s93SLMxb2vI=
github.com/maratori/testableexamples v1.0.0/go.mod h1:4rhjL1n20TUTT4vdh3RDqSizKLyXp7K2u6HgraZCGzE=
github.com/maratori/testpackage v1.1.1 h1:S58XVV5AD7HADMmD0fNnziNHqKvSdDuEKdPD1rNTU04=
diff --git a/internal/autofillcache/bindings.go b/internal/autofillcache/bindings.go
new file mode 100644
index 0000000..e6078cc
--- /dev/null
+++ b/internal/autofillcache/bindings.go
@@ -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
+}
diff --git a/internal/autofillcache/bindings_test.go b/internal/autofillcache/bindings_test.go
new file mode 100644
index 0000000..8617d58
--- /dev/null
+++ b/internal/autofillcache/bindings_test.go
@@ -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)
+ }
+}
diff --git a/packaging/docker/android-apk/Dockerfile b/packaging/docker/android-apk/Dockerfile
new file mode 100644
index 0000000..7556788
--- /dev/null
+++ b/packaging/docker/android-apk/Dockerfile
@@ -0,0 +1,16 @@
+FROM golang:1.26-bookworm AS gobase
+
+FROM eclipse-temurin:25-jdk
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ findutils \
+ git \
+ make \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY --from=gobase /usr/local/go /usr/local/go
+
+ENV JAVA_HOME=/opt/java/openjdk
+ENV PATH=/usr/local/go/bin:${PATH}
+
+WORKDIR /workspace