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