Compare commits

...

18 Commits

Author SHA1 Message Date
Joe Julian 0adf1b8826 Run CI for pull requests
ci / lint-test (pull_request) Successful in 5m56s
ci / build (pull_request) Successful in 5m39s
2026-04-19 21:17:49 -07:00
Joe Julian c517794182 Provision Java 25 directly in CI 2026-04-19 20:37:46 -07:00
Joe Julian b511ab4dc0 Fix CI APK JDK selection 2026-04-19 20:27:14 -07:00
joejulian 7b06388712 Merge pull request 'Add Android autofill chooser and learned app binding' (#7) from feature/android-autofill-chooser into main
ci / lint-test (push) Successful in 3m14s
ci / build (push) Failing after 3m4s
2026-04-20 00:02:55 +00:00
Joe Julian fea1a75cdf Keep release signing secrets out of APK build logs 2026-04-18 22:16:25 -07:00
Joe Julian 0dfaeef7bf Require dedicated release signing for APK builds 2026-04-18 22:00:56 -07:00
Joe Julian 92a7853258 Harden Android shared-vault import intents 2026-04-16 21:33:40 -07:00
Joe Julian 14f22b4ebf Fix Android packaging asset discovery 2026-04-16 21:08:31 -07:00
Joe Julian 4d972bfab0 Simplify Android packaging around gogio 2026-04-16 20:47:51 -07:00
Joe Julian e005a42a3f Point gio-cmd dependency at patched fork 2026-04-16 20:29:27 -07:00
Joe Julian 58d6d510f9 Point gio dependency at patched fork 2026-04-16 18:16:40 -07:00
Joe Julian bb114cee16 Patch gogio at build time for Android snippets 2026-04-13 22:03:00 -07:00
Joe Julian 2431467aa7 Add Android autofill chooser and app binding 2026-04-13 22:02:51 -07:00
Joe Julian c302c29d4f Add autofill app binding helpers 2026-04-13 17:30:33 -07:00
Joe Julian 361d6dbe03 Add failing Android autofill binding tests 2026-04-13 17:26:51 -07:00
joejulian a41e842a65 Merge pull request 'Tighten browser inline overlay qualification' (#6) from bugfix/browser-inline-overlay into main
ci / lint-test (push) Successful in 3m29s
ci / build (push) Successful in 6m6s
2026-04-14 00:24:37 +00:00
Joe Julian 54398837e6 Tighten browser inline overlay qualification 2026-04-13 17:23:41 -07:00
joejulian 989b41735f Merge pull request 'Normalize vault storage root views' (#5) from bugfix/vault-root-view into main
ci / lint-test (push) Successful in 3m25s
ci / build (push) Successful in 6m21s
2026-04-13 16:31:56 +00:00
20 changed files with 1167 additions and 101 deletions
+9 -3
View File
@@ -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.
+7 -1
View File
@@ -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.
+21 -4
View File
@@ -8,6 +8,9 @@ on:
- "v*"
- "release-*"
- "[0-9]+.[0-9]+.[0-9]+*"
pull_request:
branches:
- main
permissions:
contents: write
@@ -16,7 +19,6 @@ env:
GO_VERSION: "1.26.1"
ANDROID_SDK_ROOT: /opt/android-sdk
ANDROID_NDK_ROOT: /opt/android-sdk/ndk
JAVA_HOME: /usr/lib/jvm/java-21-openjdk-amd64
DIST_DIR: dist
jobs:
@@ -31,6 +33,12 @@ jobs:
with:
go-version: ${{ env.GO_VERSION }}
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "25"
- name: Install native build dependencies
shell: bash
run: |
@@ -78,6 +86,12 @@ jobs:
with:
go-version: ${{ env.GO_VERSION }}
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "25"
- name: Install native build dependencies
shell: bash
run: |
@@ -135,11 +149,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
+1 -1
View File
@@ -177,7 +177,7 @@ These features are product requirements, not “nice to have” ideas.
local `ANDROID_NDK_ROOT=/opt/android-ndk`,
CI `ANDROID_NDK_ROOT=/opt/android-sdk/ndk`,
local `JAVA_HOME=/usr/lib/jvm/java-25-openjdk`,
CI `JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64`.
CI `JAVA_HOME` provided by `actions/setup-java` with Temurin 25.
- Remember the known Android runtime regression:
`gioui.org v0.9.0` produced a black screen on the `KeepassGoAPI35` emulator, while `gioui.org v0.8.0` rendered correctly. Treat Gio upgrades on Android as regression-sensitive and verify them on-device or in the emulator.
- When validating an APK in the emulator, prefer the known KeePassGO setup:
+36 -6
View File
@@ -6,17 +6,43 @@ 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. CI provisions Java 25 directly in the build job so release builds
use that same local path instead of nested Docker.
`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 +50,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 and in CI.
- 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
+64 -4
View File
@@ -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))" JAVA_HOME="$(JAVA_HOME)"
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" \
+17 -2
View File
@@ -90,10 +90,25 @@ 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. CI provisions Java 25 directly in the build job so release
packaging follows that same local path. 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
+7
View File
@@ -22,6 +22,10 @@
android:name="android.accessibilityservice"
android:resource="@xml/keepassgo_accessibility_service" />
</service>
<activity
android:name="org.julianfamily.keepassgo.KeePassGOAutofillPickerActivity"
android:exported="false"
android:label="Search KeePassGO" />
<provider
android:name="org.julianfamily.keepassgo.SharedVaultProvider"
android:authorities="org.julianfamily.keepassgo.share"
@@ -41,6 +45,9 @@
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/octet-stream" />
<data android:mimeType="application/x-keepass2" />
<data android:mimeType="application/vnd.keepass" />
<data android:scheme="content" android:pathPattern=".*\\.kdbx" />
<data android:scheme="file" android:pathPattern=".*\\.kdbx" />
</intent-filter>
@@ -0,0 +1,116 @@
package org.julianfamily.keepassgo;
import android.content.Context;
import android.util.JsonReader;
import android.util.JsonWriter;
import android.util.Log;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
final class AutofillBindingStore {
private static final String TAG = "KeePassGOAutofill";
private AutofillBindingStore() {
}
static String entryIDForTarget(Context context, String rawTarget) {
Bindings bindings = read(context);
return bindings.apps.getOrDefault(normalize(rawTarget), "");
}
static void rememberBinding(Context context, String rawTarget, String entryID) {
String target = normalize(rawTarget);
if (target.isEmpty() || entryID == null || entryID.trim().isEmpty()) {
return;
}
Bindings bindings = read(context);
bindings.updatedAt = Instant.now().toString();
bindings.apps.put(target, entryID.trim());
write(context, bindings);
}
private static Bindings read(Context context) {
File path = path(context);
if (!path.isFile()) {
return new Bindings();
}
try (JsonReader reader = new JsonReader(new InputStreamReader(new FileInputStream(path), StandardCharsets.UTF_8))) {
Bindings bindings = new Bindings();
reader.beginObject();
while (reader.hasNext()) {
String name = reader.nextName();
if ("updatedAt".equals(name)) {
bindings.updatedAt = nextString(reader);
continue;
}
if ("apps".equals(name)) {
reader.beginObject();
while (reader.hasNext()) {
bindings.apps.put(normalize(reader.nextName()), nextString(reader));
}
reader.endObject();
continue;
}
reader.skipValue();
}
reader.endObject();
return bindings;
} catch (IOException err) {
Log.e(TAG, "failed to read autofill bindings", err);
return new Bindings();
}
}
private static void write(Context context, Bindings bindings) {
File path = path(context);
File parent = path.getParentFile();
if (parent != null && !parent.exists() && !parent.mkdirs()) {
Log.e(TAG, "failed to create autofill binding directory " + parent.getAbsolutePath());
return;
}
try (JsonWriter writer = new JsonWriter(new OutputStreamWriter(new FileOutputStream(path, false), StandardCharsets.UTF_8))) {
writer.setIndent(" ");
writer.beginObject();
writer.name("updatedAt").value(bindings.updatedAt);
writer.name("apps");
writer.beginObject();
for (Map.Entry<String, String> entry : bindings.apps.entrySet()) {
writer.name(entry.getKey()).value(entry.getValue());
}
writer.endObject();
writer.endObject();
} catch (IOException err) {
Log.e(TAG, "failed to write autofill bindings", err);
}
}
private static String nextString(JsonReader reader) throws IOException {
if (reader.peek() == android.util.JsonToken.NULL) {
reader.nextNull();
return "";
}
return reader.nextString();
}
private static File path(Context context) {
return new File(new File(context.getFilesDir(), "keepassgo"), "autofill-bindings.json");
}
private static String normalize(String rawTarget) {
return rawTarget == null ? "" : rawTarget.trim();
}
private static final class Bindings {
String updatedAt = "";
final Map<String, String> apps = new LinkedHashMap<>();
}
}
@@ -10,6 +10,7 @@ import java.io.IOException;
import java.io.InputStreamReader;
import java.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<Entry> entries;
try {
entries = readEntries(cacheFile);
} catch (IOException err) {
Log.e(TAG, "failed to read autofill cache", err);
return null;
}
List<Entry> 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<Entry> chooserCandidates(Context context, String rawTarget) {
List<Entry> entries = readEntries(context);
if (entries.isEmpty()) {
return entries;
}
Entry direct = findBestMatch(context, rawTarget);
if (direct != null) {
List<Entry> resolved = new ArrayList<>();
resolved.add(direct);
return resolved;
}
entries.sort(Comparator
.comparing((Entry entry) -> entry.title.toLowerCase(Locale.US))
.thenComparing(entry -> String.join("/", entry.path).toLowerCase(Locale.US))
.thenComparing(entry -> entry.id));
return entries;
}
private static List<Entry> readEntries(Context context) {
File cacheFile = findCacheFile(context);
if (cacheFile == null) {
Log.i(TAG, "autofill cache file not found");
return new ArrayList<>();
}
try {
return readEntries(cacheFile);
} catch (IOException err) {
Log.e(TAG, "failed to read autofill cache", err);
return new ArrayList<>();
}
}
private static File findCacheFile(Context context) {
List<File> 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<String> targets = new ArrayList<>();
List<String> 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<String> targets;
final List<String> path;
Entry(String title, String username, String password, String host, String url, List<String> targets) {
Entry(String id, String title, String username, String password, String host, String url, List<String> targets, List<String> path) {
this.id = id;
this.title = title;
this.username = username;
this.password = password;
this.host = host;
this.url = url;
this.targets = new ArrayList<>(targets);
this.path = new ArrayList<>(path);
}
}
@@ -0,0 +1,182 @@
package org.julianfamily.keepassgo;
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.View;
import android.view.autofill.AutofillId;
import android.view.autofill.AutofillManager;
import android.view.autofill.AutofillValue;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.TextView;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import android.service.autofill.Dataset;
public final class KeePassGOAutofillPickerActivity extends Activity {
static final String EXTRA_TARGET = "org.julianfamily.keepassgo.extra.AUTOFILL_TARGET";
static final String EXTRA_PACKAGE_NAME = "org.julianfamily.keepassgo.extra.AUTOFILL_PACKAGE";
static final String EXTRA_USERNAME_ID = "org.julianfamily.keepassgo.extra.USERNAME_ID";
static final String EXTRA_PASSWORD_ID = "org.julianfamily.keepassgo.extra.PASSWORD_ID";
private final List<AutofillCacheStore.Entry> allEntries = new ArrayList<>();
private final List<AutofillCacheStore.Entry> visibleEntries = new ArrayList<>();
private ArrayAdapter<String> adapter;
private String matchTarget = "";
private String packageName = "";
private AutofillId usernameID;
private AutofillId passwordID;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Intent intent = getIntent();
matchTarget = intent.getStringExtra(EXTRA_TARGET);
packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME);
usernameID = intent.getParcelableExtra(EXTRA_USERNAME_ID, AutofillId.class);
passwordID = intent.getParcelableExtra(EXTRA_PASSWORD_ID, AutofillId.class);
LinearLayout root = new LinearLayout(this);
root.setOrientation(LinearLayout.VERTICAL);
int padding = dp(16);
root.setPadding(padding, padding, padding, padding);
TextView title = new TextView(this);
title.setTextSize(TypedValue.COMPLEX_UNIT_SP, 20);
title.setGravity(Gravity.START);
title.setPadding(0, 0, 0, dp(8));
title.setText(packageName == null || packageName.trim().isEmpty() ? "Search KeePassGO" : "Search KeePassGO for " + packageName);
root.addView(title, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
));
EditText search = new EditText(this);
search.setHint("Search entries");
root.addView(search, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
));
allEntries.clear();
allEntries.addAll(AutofillCacheStore.chooserCandidates(this, matchTarget));
visibleEntries.clear();
visibleEntries.addAll(allEntries);
adapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, labelsFor(visibleEntries));
ListView list = new ListView(this);
list.setAdapter(adapter);
list.setOnItemClickListener(this::onEntrySelected);
TextView empty = new TextView(this);
empty.setPadding(0, dp(16), 0, 0);
empty.setText("No KeePassGO entries are available for autofill.");
empty.setVisibility(allEntries.isEmpty() ? View.VISIBLE : View.GONE);
list.setVisibility(allEntries.isEmpty() ? View.GONE : View.VISIBLE);
root.addView(empty, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
));
LinearLayout.LayoutParams listParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
0
);
listParams.weight = 1f;
listParams.topMargin = dp(12);
root.addView(list, listParams);
search.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
filterEntries(s == null ? "" : s.toString());
}
@Override
public void afterTextChanged(Editable s) {
}
});
setContentView(root);
}
private void filterEntries(String rawQuery) {
String query = rawQuery == null ? "" : rawQuery.trim().toLowerCase(Locale.US);
visibleEntries.clear();
if (query.isEmpty()) {
visibleEntries.addAll(allEntries);
} else {
for (AutofillCacheStore.Entry entry : allEntries) {
if (entryLabel(entry).toLowerCase(Locale.US).contains(query)) {
visibleEntries.add(entry);
}
}
}
adapter.clear();
adapter.addAll(labelsFor(visibleEntries));
adapter.notifyDataSetChanged();
}
private void onEntrySelected(AdapterView<?> parent, View view, int position, long id) {
if (position < 0 || position >= visibleEntries.size() || passwordID == null) {
setResult(Activity.RESULT_CANCELED);
finish();
return;
}
AutofillCacheStore.Entry entry = visibleEntries.get(position);
if (matchTarget != null && matchTarget.startsWith("androidapp://")) {
AutofillBindingStore.rememberBinding(this, matchTarget, entry.id);
}
Dataset.Builder dataset = new Dataset.Builder();
dataset.setId(entry.id);
if (usernameID != null && entry.username != null && !entry.username.isEmpty()) {
dataset.setValue(usernameID, AutofillValue.forText(entry.username));
}
dataset.setValue(passwordID, AutofillValue.forText(entry.password));
Intent result = new Intent();
result.putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset.build());
setResult(Activity.RESULT_OK, result);
finish();
}
private static List<String> labelsFor(List<AutofillCacheStore.Entry> entries) {
List<String> labels = new ArrayList<>(entries.size());
for (AutofillCacheStore.Entry entry : entries) {
labels.add(entryLabel(entry));
}
return labels;
}
private static String entryLabel(AutofillCacheStore.Entry entry) {
StringBuilder label = new StringBuilder();
label.append(entry.title == null || entry.title.trim().isEmpty() ? "Untitled entry" : entry.title.trim());
if (entry.username != null && !entry.username.trim().isEmpty()) {
label.append(" (").append(entry.username.trim()).append(")");
}
if (entry.path != null && !entry.path.isEmpty()) {
label.append("").append(String.join(" / ", entry.path));
}
return label.toString();
}
private int dp(int value) {
return Math.round(value * getResources().getDisplayMetrics().density);
}
}
@@ -1,6 +1,8 @@
package org.julianfamily.keepassgo;
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<AutofillCacheStore.Entry> candidates = AutofillCacheStore.chooserCandidates(this, target.matchTarget);
if (candidates.isEmpty()) {
return null;
}
RemoteViews presentation = new RemoteViews(getPackageName(), android.R.layout.simple_list_item_1);
presentation.setTextViewText(android.R.id.text1, "Search KeePassGO");
Intent intent = new Intent(this, KeePassGOAutofillPickerActivity.class);
intent.putExtra(KeePassGOAutofillPickerActivity.EXTRA_TARGET, target.matchTarget);
intent.putExtra(KeePassGOAutofillPickerActivity.EXTRA_PACKAGE_NAME, target.packageName);
intent.putExtra(KeePassGOAutofillPickerActivity.EXTRA_USERNAME_ID, fields.usernameId);
intent.putExtra(KeePassGOAutofillPickerActivity.EXTRA_PASSWORD_ID, fields.passwordId);
PendingIntent pendingIntent = PendingIntent.getActivity(
this,
target.matchTarget.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE
);
AutofillId[] ids;
if (fields.usernameId != null) {
ids = new AutofillId[]{fields.usernameId, fields.passwordId};
} else {
ids = new AutofillId[]{fields.passwordId};
}
return new FillResponse.Builder()
.setAuthentication(ids, pendingIntent.getIntentSender(), presentation)
.build();
}
private static ParsedTarget parseWindow(AssistStructure structure, ParsedFields fields) {
String domain = "";
final int windowCount = structure.getWindowNodeCount();
@@ -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<Uri> 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");
+209 -24
View File
@@ -36,21 +36,138 @@ function normalizeRole(rawRole) {
switch (String(rawRole || "").trim().toLowerCase()) {
case "password":
return "password";
default:
case "username":
return "username";
default:
return "";
}
}
function lowerJoined(values) {
return values
.map((value) => String(value || "").trim().toLowerCase())
.filter(Boolean)
.join(" ");
}
function fieldHintText(input) {
if (!input || typeof input !== "object") {
return "";
}
const labels = input.labels ? Array.from(input.labels).map((label) => label.textContent || "") : [];
return lowerJoined([
input.getAttribute?.("type"),
input.getAttribute?.("name"),
input.getAttribute?.("id"),
input.autocomplete,
input.getAttribute?.("autocomplete"),
input.getAttribute?.("placeholder"),
input.getAttribute?.("aria-label"),
...labels
]);
}
function textLikeInputType(type) {
switch (String(type || "").toLowerCase()) {
case "":
case "text":
case "email":
case "tel":
case "number":
return true;
default:
return false;
}
}
function hintMatches(text, patterns) {
return patterns.some((pattern) => pattern.test(text));
}
function scopeHintText(scope) {
const isDocumentScope = typeof document !== "undefined" && scope === document;
if ((!scope || typeof scope !== "object") || (!isDocumentScope && typeof scope.querySelectorAll !== "function" && typeof scope.getAttribute !== "function")) {
return "";
}
const attrText = isDocumentScope ? "" : lowerJoined([
scope.getAttribute?.("id"),
scope.getAttribute?.("name"),
scope.getAttribute?.("class"),
scope.getAttribute?.("action"),
scope.getAttribute?.("aria-label")
]);
const headingText = lowerJoined(Array.from(scope.querySelectorAll?.("button, h1, h2, h3, h4, legend, label, [role='button']") || [])
.slice(0, 8)
.map((element) => element.textContent || ""));
return lowerJoined([attrText, headingText]);
}
function hasAuthFlowSignals(usernameInput, scope) {
if (usernameInput) {
return true;
}
return hintMatches(scopeHintText(scope), authScopePatterns);
}
const usernameHintPatterns = [
/\buser(name|id)?\b/,
/\blog[\s_-]?in\b/,
/\bsign[\s_-]?in\b/,
/\bemail\b/,
/\be-mail\b/,
/\baccount\b/,
/\bmember\b/,
/\bidentifier\b/
];
const nonLoginHintPatterns = [
/\bsearch\b/,
/\bquery\b/,
/\bfilter\b/,
/\bcomment\b/,
/\bmessage\b/,
/\bcontact\b/,
/\bcity\b/,
/\bstate\b/,
/\bpostal\b/,
/\bzip\b/,
/\bcoupon\b/,
/\bpromo\b/,
/\bnewsletter\b/,
/\bsubscribe\b/
];
const authScopePatterns = [
/\blog[\s_-]?in\b/,
/\bsign[\s_-]?in\b/,
/\bauth\b/,
/\bpassword\b/,
/\bpasscode\b/,
/\b2fa\b/,
/\btwo[\s-]?factor\b/,
/\bverify\b/,
/\baccount\b/
];
function describeFieldRole(input) {
const type = String(input?.getAttribute?.("type") || "").toLowerCase();
if (type === "password") {
return "password";
}
const autocomplete = String(input?.autocomplete || "").toLowerCase();
if (autocomplete.includes("username") || autocomplete.includes("email")) {
if (!textLikeInputType(type)) {
return "";
}
const hints = fieldHintText(input);
if (!hints) {
return "";
}
if (hintMatches(hints, nonLoginHintPatterns)) {
return "";
}
if (hintMatches(hints, usernameHintPatterns)) {
return "username";
}
return "username";
return "";
}
function isUsernameCandidate(input) {
@@ -102,6 +219,40 @@ function firstVisibleUsername(scope) {
return visibleInputs(scope).find(isUsernameCandidate) || null;
}
function authFlowCandidate(anchorInput) {
const scope = (typeof HTMLFormElement !== "undefined" && anchorInput?.form instanceof HTMLFormElement ? anchorInput.form : document);
const scopeInputs = resolveFormInputs(anchorInput);
const passwordInput = scopeInputs.find(isPasswordCandidate) || null;
if (!passwordInput) {
return null;
}
const associated = associatedFieldsForAnchor(anchorInput || passwordInput);
if (!hasAuthFlowSignals(associated.usernameInput, scope)) {
return null;
}
return {
usernameInput: associated.usernameInput,
passwordInput,
anchorInput: anchorInput || passwordInput,
scope
};
}
function loginCandidates() {
const candidates = [];
for (const passwordInput of visibleInputs(document).filter(isPasswordCandidate)) {
const candidate = authFlowCandidate(passwordInput);
if (!candidate) {
continue;
}
if (candidates.some((existing) => existing.passwordInput === candidate.passwordInput)) {
continue;
}
candidates.push(candidate);
}
return candidates;
}
function associatedFieldsForAnchor(anchorInput) {
const scopeInputs = resolveFormInputs(anchorInput);
const passwordInput = scopeInputs.find(isPasswordCandidate) || firstVisiblePassword(document);
@@ -121,11 +272,11 @@ function associatedFieldsForAnchor(anchorInput) {
}
function buildFieldDescriptor(input, role) {
if (!(input instanceof HTMLInputElement)) {
if (typeof HTMLInputElement === "undefined" || !(input instanceof HTMLInputElement)) {
return null;
}
const normalizedRole = normalizeRole(role || describeFieldRole(input));
const form = input.form instanceof HTMLFormElement ? input.form : null;
const form = typeof HTMLFormElement !== "undefined" && input.form instanceof HTMLFormElement ? input.form : null;
const scope = form || document;
const inputs = visibleInputs(scope);
const fieldIndex = inputs.indexOf(input);
@@ -145,6 +296,9 @@ function resolveFieldDescriptor(descriptor) {
return null;
}
const normalizedRole = normalizeRole(descriptor.role);
if (!normalizedRole) {
return null;
}
const forms = Array.from(document.forms || []);
const form = Number.isInteger(descriptor.formIndex) && descriptor.formIndex >= 0 ? forms[descriptor.formIndex] || null : null;
const scope = form || document;
@@ -200,20 +354,22 @@ function chooseFillTargets(targetDescriptor) {
function scanLoginFields() {
const activeElement = document.activeElement instanceof HTMLInputElement ? document.activeElement : null;
const activeUsable = activeElement && isVisibleInput(activeElement) ? activeElement : null;
const targets = chooseFillTargets(buildFieldDescriptor(activeUsable, describeFieldRole(activeUsable)));
const anchorInput = activeUsable || targets.passwordInput || targets.usernameInput;
const focusTarget = buildFieldDescriptor(anchorInput, describeFieldRole(anchorInput));
const allVisible = visibleInputs(document);
const roles = allVisible
.filter((input) => isUsernameCandidate(input) || isPasswordCandidate(input))
.map((input) => {
const descriptor = buildFieldDescriptor(input, describeFieldRole(input));
return `${descriptor.formIndex}:${descriptor.fieldIndex}:${descriptor.role}`;
});
const explicitRole = describeFieldRole(activeUsable);
const activeTargets = activeUsable ? authFlowCandidate(activeUsable) : null;
const candidates = loginCandidates();
const chosen = activeTargets || candidates[0] || null;
const anchorInput = activeUsable || chosen?.passwordInput || chosen?.usernameInput || null;
const focusRole = explicitRole || describeFieldRole(anchorInput);
const focusTarget = anchorInput ? buildFieldDescriptor(anchorInput, focusRole) : null;
const roles = candidates.map((candidate) => {
const passwordDescriptor = buildFieldDescriptor(candidate.passwordInput, "password");
const usernameDescriptor = candidate.usernameInput ? buildFieldDescriptor(candidate.usernameInput, "username") : null;
return `${passwordDescriptor.formIndex}:${passwordDescriptor.fieldIndex}:password:${usernameDescriptor ? `${usernameDescriptor.formIndex}:${usernameDescriptor.fieldIndex}` : "-"}`;
});
return {
pageHasLoginForm: Boolean(targets.usernameInput || targets.passwordInput),
usernameInput: targets.usernameInput,
passwordInput: targets.passwordInput,
pageHasLoginForm: Boolean(chosen),
usernameInput: chosen?.usernameInput || null,
passwordInput: chosen?.passwordInput || null,
anchorInput,
focusTarget,
signature: roles.join("|")
@@ -265,8 +421,8 @@ function inlineMatchSummary(match) {
return parts.join(" · ") || "No username";
}
function shouldShowInlineOverlay(state, hasTarget, suppressed) {
if (suppressed || !hasTarget) {
function shouldShowInlineOverlay(state, hasTarget, suppressed, idleHidden) {
if (suppressed || idleHidden || !hasTarget) {
return false;
}
return Boolean(
@@ -286,7 +442,11 @@ const contentTestExports = {
chooseFillTargets,
inlineMatchSummary,
domainLabel,
shouldShowInlineOverlay
shouldShowInlineOverlay,
fieldHintText,
scopeHintText,
hasAuthFlowSignals,
authFlowCandidate
};
if (isNodeTestEnv) {
@@ -303,7 +463,9 @@ if (isNodeTestEnv) {
};
let chooserOpen = false;
let inlineSuppressed = false;
let inlineIdleHidden = false;
let refreshTimer = null;
let idleHideTimer = null;
let lastReportedSignature = "";
let lastReportedTarget = "";
@@ -464,6 +626,24 @@ if (isNodeTestEnv) {
dock.dataset.open = "false";
}
function clearIdleHideTimer() {
if (idleHideTimer !== null) {
clearTimeout(idleHideTimer);
idleHideTimer = null;
}
}
function refreshInlineLifetime(shouldShow) {
clearIdleHideTimer();
if (!shouldShow || chooserOpen || pageState.pendingFill) {
return;
}
idleHideTimer = window.setTimeout(() => {
inlineIdleHidden = true;
hideDock();
}, 15000);
}
function positionDock() {
const anchor = currentTarget();
if (!anchor || dock.style.display === "none") {
@@ -537,9 +717,10 @@ if (isNodeTestEnv) {
function renderInlineState() {
const target = currentTarget();
const shouldShow = shouldShowInlineOverlay(pageState, Boolean(target), inlineSuppressed);
const shouldShow = shouldShowInlineOverlay(pageState, Boolean(target), inlineSuppressed, inlineIdleHidden);
if (!shouldShow) {
clearIdleHideTimer();
hideDock();
return;
}
@@ -558,17 +739,21 @@ if (isNodeTestEnv) {
dock.dataset.open = chooserOpen ? "true" : "false";
renderMatches();
positionDock();
refreshInlineLifetime(shouldShow);
}
function reportFieldState(force) {
const scan = scanLoginFields();
const nextTarget = JSON.stringify(scan.focusTarget || null);
if (scan.signature !== lastReportedSignature || nextTarget !== lastReportedTarget) {
inlineIdleHidden = false;
}
pageState = {
...pageState,
pageHasLoginForm: scan.pageHasLoginForm,
focusTarget: scan.focusTarget
};
renderInlineState();
const nextTarget = JSON.stringify(scan.focusTarget || null);
if (!force && scan.signature === lastReportedSignature && nextTarget === lastReportedTarget) {
return;
}
+77 -1
View File
@@ -18,6 +18,68 @@ test("domainLabel tolerates invalid URLs", () => {
assert.equal(content.domainLabel("not-a-url"), "");
});
test("describeFieldRole only treats explicit account fields as usernames", () => {
const loginField = {
autocomplete: "username",
labels: [],
getAttribute(name) {
const attrs = {
type: "email",
id: "crew-email",
name: "email",
placeholder: "Email address",
"aria-label": "Email address"
};
return attrs[name] || "";
}
};
const searchField = {
autocomplete: "",
labels: [],
getAttribute(name) {
const attrs = {
type: "text",
id: "site-search",
name: "query",
placeholder: "Search casino news",
"aria-label": "Search"
};
return attrs[name] || "";
}
};
assert.equal(content.describeFieldRole(loginField), "username");
assert.equal(content.describeFieldRole(searchField), "");
});
test("hasAuthFlowSignals rejects generic password scopes and accepts sign-in scopes", () => {
const genericScope = {
getAttribute() {
return "";
},
querySelectorAll() {
return [{ textContent: "Confirm shipment" }];
}
};
const signInScope = {
getAttribute(name) {
const attrs = {
id: "signin-panel",
name: "signin",
action: "/session"
};
return attrs[name] || "";
},
querySelectorAll() {
return [{ textContent: "Sign in to the Bellagio vault" }];
}
};
assert.equal(content.hasAuthFlowSignals(null, genericScope), false);
assert.equal(content.hasAuthFlowSignals(null, signInScope), true);
assert.equal(content.hasAuthFlowSignals({ id: "danny-ocean" }, genericScope), true);
});
test("shouldShowInlineOverlay hides the page overlay after it is suppressed", () => {
const state = {
pageHasLoginForm: true,
@@ -29,5 +91,19 @@ test("shouldShowInlineOverlay hides the page overlay after it is suppressed", ()
};
assert.equal(content.shouldShowInlineOverlay(state, true, false), true);
assert.equal(content.shouldShowInlineOverlay(state, true, true), false);
assert.equal(content.shouldShowInlineOverlay(state, true, true, false), false);
});
test("shouldShowInlineOverlay hides the page overlay after idle expiry", () => {
const state = {
pageHasLoginForm: true,
configured: true,
success: true,
status: { locked: false },
matches: [{ id: "rusty-ryan" }],
pendingFill: false
};
assert.equal(content.shouldShowInlineOverlay(state, true, false, false), true);
assert.equal(content.shouldShowInlineOverlay(state, true, false, true), false);
});
+5 -2
View File
@@ -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
+18 -18
View File
@@ -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=
+87
View File
@@ -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
}
+102
View File
@@ -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)
}
}
+16
View File
@@ -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