From 361d6dbe0399a394d85fc202ce425ad2eaf115f6 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Mon, 13 Apr 2026 17:26:51 -0700 Subject: [PATCH 01/11] Add failing Android autofill binding tests --- internal/autofillcache/bindings_test.go | 102 ++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 internal/autofillcache/bindings_test.go 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) + } +} From c302c29d4fc6d8be1ac7873c4e12b3fde6642143 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Mon, 13 Apr 2026 17:30:33 -0700 Subject: [PATCH 02/11] Add autofill app binding helpers --- internal/autofillcache/bindings.go | 87 ++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 internal/autofillcache/bindings.go 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 +} From 2431467aa72ff40ebc5e2477abbb2032cb74b5a0 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Mon, 13 Apr 2026 22:02:51 -0700 Subject: [PATCH 03/11] Add Android autofill chooser and app binding --- android/application_snippets.xml | 4 + .../keepassgo/AutofillBindingStore.java | 116 +++++++++++ .../keepassgo/AutofillCacheStore.java | 78 ++++++-- .../KeePassGOAutofillPickerActivity.java | 182 ++++++++++++++++++ .../keepassgo/KeePassGOAutofillService.java | 96 +++++++-- 5 files changed, 444 insertions(+), 32 deletions(-) create mode 100644 androidsrc/org/julianfamily/keepassgo/AutofillBindingStore.java create mode 100644 androidsrc/org/julianfamily/keepassgo/KeePassGOAutofillPickerActivity.java diff --git a/android/application_snippets.xml b/android/application_snippets.xml index 8889103..4b05a19 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" /> + 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(); From bb114cee16746e4fc3f04cf86ece8198589bf3db Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Mon, 13 Apr 2026 22:03:00 -0700 Subject: [PATCH 04/11] Patch gogio at build time for Android snippets --- Makefile | 2 +- cmd/build-android-apk/main.go | 292 ++++++++++++++++++++++++++++++++++ 2 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 cmd/build-android-apk/main.go diff --git a/Makefile b/Makefile index 320b7b8..183ba19 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ apk: android/keepassgo-android.jar ANDROID_SDK_ROOT="$(ANDROID_SDK_ROOT)" \ ANDROID_NDK_ROOT="$(ANDROID_NDK_ROOT)" \ JAVA_HOME="$(JAVA_HOME)" \ - go tool gogio -target android \ + go run ./cmd/build-android-apk -target android \ -buildmode exe \ -appid $(APP_ID) \ -ldflags "$(GO_LDFLAGS)" \ diff --git a/cmd/build-android-apk/main.go b/cmd/build-android-apk/main.go new file mode 100644 index 0000000..833ef9b --- /dev/null +++ b/cmd/build-android-apk/main.go @@ -0,0 +1,292 @@ +package main + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "build_android_apk: %v\n", err) + os.Exit(1) + } +} + +func run() error { + workDir, err := os.Getwd() + if err != nil { + return err + } + gogioDir, err := commandOutput("go", "list", "-m", "-f", "{{.Dir}}", "gioui.org/cmd") + if err != nil { + return err + } + tempDir, err := os.MkdirTemp("", "keepassgo-gogio-") + if err != nil { + return err + } + defer os.RemoveAll(tempDir) + + tempModule := filepath.Join(tempDir, "gioui-cmd") + if err := copyDir(gogioDir, tempModule); err != nil { + return err + } + targetFile := filepath.Join(tempModule, "gogio", "androidbuild.go") + if err := patchAndroidBuild(targetFile); err != nil { + return err + } + if err := runCommand(tempModule, "gofmt", "-w", targetFile); err != nil { + return err + } + if err := refreshTempModuleDeps(tempModule); err != nil { + return err + } + + javaHome := os.Getenv("JAVA_HOME") + wrappedJavaHome := javaHome + var cleanup func() + if major := javaMajor(javaHome); major >= 26 { + wrappedJavaHome, cleanup, err = wrapJavaHome(javaHome) + if err != nil { + return err + } + defer cleanup() + } + + patchedArgs := append([]string(nil), os.Args[1:]...) + if len(patchedArgs) != 0 { + last := patchedArgs[len(patchedArgs)-1] + if strings.HasPrefix(last, ".") { + patchedArgs[len(patchedArgs)-1] = filepath.Join(workDir, last) + } + } + gogioFiles, err := filepath.Glob(filepath.Join(tempModule, "gogio", "*.go")) + if err != nil { + return err + } + filteredFiles := make([]string, 0, len(gogioFiles)) + for _, file := range gogioFiles { + if strings.HasSuffix(file, "_test.go") { + continue + } + filteredFiles = append(filteredFiles, file) + } + if len(filteredFiles) == 0 { + return fmt.Errorf("no gogio go files found in %s", tempModule) + } + args := append([]string{"run"}, filteredFiles...) + args = append(args, patchedArgs...) + cmd := exec.Command("go", args...) + cmd.Dir = workDir + cmd.Env = append(os.Environ(), "JAVA_HOME="+wrappedJavaHome) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func patchAndroidBuild(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + content := string(data) + + content = strings.Replace(content, + "\tAppName string\n}", + "\tAppName string\n\tApplicationSnippets string\n}", + 1, + ) + content = strings.Replace(content, + "\tif err := visitPkg(pkgs[0]); err != nil {\n\t\treturn err\n\t}\n\n\tif err := compileAndroid(tmpDir, tools, bi); err != nil {\n", + "\tif err := visitPkg(pkgs[0]); err != nil {\n\t\treturn err\n\t}\n\tmoduleRoot := androidModuleRoot(bi.pkgDir)\n\tmoduleAndroidJars, err := filepath.Glob(filepath.Join(moduleRoot, \"android\", \"*.jar\"))\n\tif err != nil {\n\t\treturn err\n\t}\n\textraJars = append(extraJars, moduleAndroidJars...)\n\n\tif err := compileAndroid(tmpDir, tools, bi); err != nil {\n", + 1, + ) + content = strings.Replace(content, + "\terr = os.WriteFile(filepath.Join(v21Dir, \"themes.xml\"), []byte(themesV21), 0660)\n\tif err != nil {\n\t\treturn err\n\t}\n\tresZip := filepath.Join(tmpDir, \"resources.zip\")\n", + "\terr = os.WriteFile(filepath.Join(v21Dir, \"themes.xml\"), []byte(themesV21), 0660)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmoduleRoot := androidModuleRoot(bi.pkgDir)\n\tcustomResDir := filepath.Join(moduleRoot, \"android\", \"res\")\n\tif _, err := os.Stat(customResDir); err == nil {\n\t\tif err := copyAndroidDir(customResDir, resDir); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tresZip := filepath.Join(tmpDir, \"resources.zip\")\n", + 1, + ) + content = strings.Replace(content, + "\t\tIconSnip: iconSnip,\n\t\tAppName: appName,\n\t}\n", + "\t\tIconSnip: iconSnip,\n\t\tAppName: appName,\n\t\tApplicationSnippets: loadAndroidSnippet(filepath.Join(moduleRoot, \"android\", \"application_snippets.xml\")),\n\t}\n", + 1, + ) + content = strings.Replace(content, + "\t\t\n\t\n`)\n", + "\t\t\n{{.ApplicationSnippets}}\n\t\n`)\n", + 1, + ) + + insertPos := strings.Index(content, "func findNDK(") + if insertPos < 0 { + return fmt.Errorf("findNDK not found") + } + helpers := ` +func loadAndroidSnippet(path string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + content := strings.TrimSpace(string(data)) + if content == "" { + return "" + } + return "\n" + content +} + +func copyAndroidDir(src, dst string) error { + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + if info.IsDir() { + return os.MkdirAll(target, 0755) + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(target, data, 0660) + }) +} + +func androidModuleRoot(start string) string { + dir := start + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + return start + } + dir = parent + } +} + +` + content = content[:insertPos] + helpers + content[insertPos:] + + return os.WriteFile(path, []byte(content), 0o644) +} + +func commandOutput(name string, args ...string) (string, error) { + cmd := exec.Command(name, args...) + out, err := cmd.Output() + if err != nil { + var stderr []byte + if exitErr, ok := err.(*exec.ExitError); ok { + stderr = exitErr.Stderr + } + return "", fmt.Errorf("%s %s failed: %s%s", name, strings.Join(args, " "), out, stderr) + } + return strings.TrimSpace(string(out)), nil +} + +func runCommand(dir, name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Dir = dir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func refreshTempModuleDeps(dir string) error { + overrides := []string{ + "golang.org/x/image@v0.37.0", + "golang.org/x/mod@v0.33.0", + "golang.org/x/sync@v0.20.0", + "golang.org/x/text@v0.35.0", + "golang.org/x/tools@v0.42.0", + } + args := append([]string{"get"}, overrides...) + return runCommand(dir, "go", args...) +} + +func javaMajor(javaHome string) int { + if strings.TrimSpace(javaHome) == "" { + return 0 + } + cmd := exec.Command(filepath.Join(javaHome, "bin", "java"), "-version") + output, err := cmd.CombinedOutput() + if err != nil { + return 0 + } + matches := regexp.MustCompile(`version "([0-9]+)`).FindStringSubmatch(string(output)) + if len(matches) != 2 { + return 0 + } + var major int + if _, err := fmt.Sscanf(matches[1], "%d", &major); err != nil { + return 0 + } + return major +} + +func wrapJavaHome(javaHome string) (string, func(), error) { + tempDir, err := os.MkdirTemp("", "keepassgo-java-home-") + if err != nil { + return "", nil, err + } + binDir := filepath.Join(tempDir, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + os.RemoveAll(tempDir) + return "", nil, err + } + realBin := filepath.Join(javaHome, "bin") + if err := os.WriteFile(filepath.Join(binDir, "javac"), []byte("#!/bin/sh\nexport JAVA_TOOL_OPTIONS=-Xint\nexec "+shellQuote(filepath.Join(realBin, "javac"))+" \"$@\"\n"), 0o755); err != nil { + os.RemoveAll(tempDir) + return "", nil, err + } + for _, name := range []string{"java", "jar"} { + if err := os.WriteFile(filepath.Join(binDir, name), []byte("#!/bin/sh\nexec "+shellQuote(filepath.Join(realBin, name))+" \"$@\"\n"), 0o755); err != nil { + os.RemoveAll(tempDir) + return "", nil, err + } + } + return tempDir, func() { os.RemoveAll(tempDir) }, nil +} + +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" +} + +func copyDir(src, dst string) error { + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + if info.IsDir() { + return os.MkdirAll(target, 0o755) + } + in, err := os.Open(path) + if err != nil { + return err + } + defer in.Close() + out, err := os.Create(target) + if err != nil { + return err + } + defer out.Close() + if _, err := io.Copy(out, in); err != nil { + return err + } + return out.Close() + }) +} From 58d6d510f932cded416ea33780008a8ab35e33b8 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Thu, 16 Apr 2026 18:16:40 -0700 Subject: [PATCH 05/11] Point gio dependency at patched fork --- go.mod | 2 ++ go.sum | 2 ++ 2 files changed, 4 insertions(+) diff --git a/go.mod b/go.mod index 487dee9..ca45529 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module git.julianfamily.org/keepassgo go 1.26 +replace gioui.org => git.julianfamily.org/joejulian/gio-patched v0.9.1-0.20260416220049-9bfa6bc1c2dc + require ( gioui.org v0.8.0 gioui.org/x v0.8.0 diff --git a/go.sum b/go.sum index 6bdda20..e628c04 100644 --- a/go.sum +++ b/go.sum @@ -46,6 +46,8 @@ 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-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= From e005a42a3f2ed9bbdcb6422f9e97c622e4247a4f Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Thu, 16 Apr 2026 20:29:27 -0700 Subject: [PATCH 06/11] Point gio-cmd dependency at patched fork --- go.mod | 5 +++-- go.sum | 34 ++++++++++++++++------------------ 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/go.mod b/go.mod index ca45529..657d0b9 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,10 @@ 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.20260416154203-192acd9d09b0 + 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 @@ -195,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 e628c04..889b323 100644 --- a/go.sum +++ b/go.sum @@ -37,15 +37,13 @@ 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.20260416154203-192acd9d09b0 h1:jZmX7CRuev3KrOW0yB2VQDtCrzRa4Q+qWcl3CGXq2sE= +git.julianfamily.org/joejulian/gio-cmd-patched v0.9.1-0.20260416154203-192acd9d09b0/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= @@ -134,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= @@ -182,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= @@ -226,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= @@ -372,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= @@ -406,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= From 4d972bfab0f1af36f175fce51acdc3ec4f76b79a Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Thu, 16 Apr 2026 20:47:51 -0700 Subject: [PATCH 07/11] Simplify Android packaging around gogio --- APK.md | 18 +- Makefile | 39 +++- README.md | 7 +- cmd/build-android-apk/main.go | 292 ------------------------ packaging/docker/android-apk/Dockerfile | 16 ++ 5 files changed, 67 insertions(+), 305 deletions(-) delete mode 100644 cmd/build-android-apk/main.go create mode 100644 packaging/docker/android-apk/Dockerfile diff --git a/APK.md b/APK.md index 3f7e7d9..71af8c6 100644 --- a/APK.md +++ b/APK.md @@ -6,11 +6,17 @@ Build the APK with: make apk ``` +`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. + 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. @@ -24,9 +30,9 @@ 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 ... @@ -38,10 +44,10 @@ The Android build uses the branded icon asset at: 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 183ba19..cf5eae4 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 @@ -25,8 +26,16 @@ ifneq ($(strip $(SIGNPASS)),) GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS) endif -.PHONY: apk archlinux-pkgbuild browser-bridge browser-extension-validate -apk: android/keepassgo-android.jar +.PHONY: apk apk-local 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; } @@ -38,7 +47,7 @@ apk: android/keepassgo-android.jar ANDROID_SDK_ROOT="$(ANDROID_SDK_ROOT)" \ ANDROID_NDK_ROOT="$(ANDROID_NDK_ROOT)" \ JAVA_HOME="$(JAVA_HOME)" \ - go run ./cmd/build-android-apk -target android \ + go tool gogio -target android \ -buildmode exe \ -appid $(APP_ID) \ -ldflags "$(GO_LDFLAGS)" \ @@ -50,12 +59,32 @@ apk: android/keepassgo-android.jar -icon internal/assets/keepassgo-icon.png \ ./cmd/keepassgo +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)" \ + -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 + +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..16197cb 100644 --- a/README.md +++ b/README.md @@ -90,10 +90,13 @@ 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. ## Automation diff --git a/cmd/build-android-apk/main.go b/cmd/build-android-apk/main.go deleted file mode 100644 index 833ef9b..0000000 --- a/cmd/build-android-apk/main.go +++ /dev/null @@ -1,292 +0,0 @@ -package main - -import ( - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "regexp" - "strings" -) - -func main() { - if err := run(); err != nil { - fmt.Fprintf(os.Stderr, "build_android_apk: %v\n", err) - os.Exit(1) - } -} - -func run() error { - workDir, err := os.Getwd() - if err != nil { - return err - } - gogioDir, err := commandOutput("go", "list", "-m", "-f", "{{.Dir}}", "gioui.org/cmd") - if err != nil { - return err - } - tempDir, err := os.MkdirTemp("", "keepassgo-gogio-") - if err != nil { - return err - } - defer os.RemoveAll(tempDir) - - tempModule := filepath.Join(tempDir, "gioui-cmd") - if err := copyDir(gogioDir, tempModule); err != nil { - return err - } - targetFile := filepath.Join(tempModule, "gogio", "androidbuild.go") - if err := patchAndroidBuild(targetFile); err != nil { - return err - } - if err := runCommand(tempModule, "gofmt", "-w", targetFile); err != nil { - return err - } - if err := refreshTempModuleDeps(tempModule); err != nil { - return err - } - - javaHome := os.Getenv("JAVA_HOME") - wrappedJavaHome := javaHome - var cleanup func() - if major := javaMajor(javaHome); major >= 26 { - wrappedJavaHome, cleanup, err = wrapJavaHome(javaHome) - if err != nil { - return err - } - defer cleanup() - } - - patchedArgs := append([]string(nil), os.Args[1:]...) - if len(patchedArgs) != 0 { - last := patchedArgs[len(patchedArgs)-1] - if strings.HasPrefix(last, ".") { - patchedArgs[len(patchedArgs)-1] = filepath.Join(workDir, last) - } - } - gogioFiles, err := filepath.Glob(filepath.Join(tempModule, "gogio", "*.go")) - if err != nil { - return err - } - filteredFiles := make([]string, 0, len(gogioFiles)) - for _, file := range gogioFiles { - if strings.HasSuffix(file, "_test.go") { - continue - } - filteredFiles = append(filteredFiles, file) - } - if len(filteredFiles) == 0 { - return fmt.Errorf("no gogio go files found in %s", tempModule) - } - args := append([]string{"run"}, filteredFiles...) - args = append(args, patchedArgs...) - cmd := exec.Command("go", args...) - cmd.Dir = workDir - cmd.Env = append(os.Environ(), "JAVA_HOME="+wrappedJavaHome) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} - -func patchAndroidBuild(path string) error { - data, err := os.ReadFile(path) - if err != nil { - return err - } - content := string(data) - - content = strings.Replace(content, - "\tAppName string\n}", - "\tAppName string\n\tApplicationSnippets string\n}", - 1, - ) - content = strings.Replace(content, - "\tif err := visitPkg(pkgs[0]); err != nil {\n\t\treturn err\n\t}\n\n\tif err := compileAndroid(tmpDir, tools, bi); err != nil {\n", - "\tif err := visitPkg(pkgs[0]); err != nil {\n\t\treturn err\n\t}\n\tmoduleRoot := androidModuleRoot(bi.pkgDir)\n\tmoduleAndroidJars, err := filepath.Glob(filepath.Join(moduleRoot, \"android\", \"*.jar\"))\n\tif err != nil {\n\t\treturn err\n\t}\n\textraJars = append(extraJars, moduleAndroidJars...)\n\n\tif err := compileAndroid(tmpDir, tools, bi); err != nil {\n", - 1, - ) - content = strings.Replace(content, - "\terr = os.WriteFile(filepath.Join(v21Dir, \"themes.xml\"), []byte(themesV21), 0660)\n\tif err != nil {\n\t\treturn err\n\t}\n\tresZip := filepath.Join(tmpDir, \"resources.zip\")\n", - "\terr = os.WriteFile(filepath.Join(v21Dir, \"themes.xml\"), []byte(themesV21), 0660)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmoduleRoot := androidModuleRoot(bi.pkgDir)\n\tcustomResDir := filepath.Join(moduleRoot, \"android\", \"res\")\n\tif _, err := os.Stat(customResDir); err == nil {\n\t\tif err := copyAndroidDir(customResDir, resDir); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tresZip := filepath.Join(tmpDir, \"resources.zip\")\n", - 1, - ) - content = strings.Replace(content, - "\t\tIconSnip: iconSnip,\n\t\tAppName: appName,\n\t}\n", - "\t\tIconSnip: iconSnip,\n\t\tAppName: appName,\n\t\tApplicationSnippets: loadAndroidSnippet(filepath.Join(moduleRoot, \"android\", \"application_snippets.xml\")),\n\t}\n", - 1, - ) - content = strings.Replace(content, - "\t\t\n\t\n`)\n", - "\t\t\n{{.ApplicationSnippets}}\n\t\n`)\n", - 1, - ) - - insertPos := strings.Index(content, "func findNDK(") - if insertPos < 0 { - return fmt.Errorf("findNDK not found") - } - helpers := ` -func loadAndroidSnippet(path string) string { - data, err := os.ReadFile(path) - if err != nil { - return "" - } - content := strings.TrimSpace(string(data)) - if content == "" { - return "" - } - return "\n" + content -} - -func copyAndroidDir(src, dst string) error { - return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - rel, err := filepath.Rel(src, path) - if err != nil { - return err - } - target := filepath.Join(dst, rel) - if info.IsDir() { - return os.MkdirAll(target, 0755) - } - data, err := os.ReadFile(path) - if err != nil { - return err - } - return os.WriteFile(target, data, 0660) - }) -} - -func androidModuleRoot(start string) string { - dir := start - for { - if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { - return dir - } - parent := filepath.Dir(dir) - if parent == dir { - return start - } - dir = parent - } -} - -` - content = content[:insertPos] + helpers + content[insertPos:] - - return os.WriteFile(path, []byte(content), 0o644) -} - -func commandOutput(name string, args ...string) (string, error) { - cmd := exec.Command(name, args...) - out, err := cmd.Output() - if err != nil { - var stderr []byte - if exitErr, ok := err.(*exec.ExitError); ok { - stderr = exitErr.Stderr - } - return "", fmt.Errorf("%s %s failed: %s%s", name, strings.Join(args, " "), out, stderr) - } - return strings.TrimSpace(string(out)), nil -} - -func runCommand(dir, name string, args ...string) error { - cmd := exec.Command(name, args...) - cmd.Dir = dir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} - -func refreshTempModuleDeps(dir string) error { - overrides := []string{ - "golang.org/x/image@v0.37.0", - "golang.org/x/mod@v0.33.0", - "golang.org/x/sync@v0.20.0", - "golang.org/x/text@v0.35.0", - "golang.org/x/tools@v0.42.0", - } - args := append([]string{"get"}, overrides...) - return runCommand(dir, "go", args...) -} - -func javaMajor(javaHome string) int { - if strings.TrimSpace(javaHome) == "" { - return 0 - } - cmd := exec.Command(filepath.Join(javaHome, "bin", "java"), "-version") - output, err := cmd.CombinedOutput() - if err != nil { - return 0 - } - matches := regexp.MustCompile(`version "([0-9]+)`).FindStringSubmatch(string(output)) - if len(matches) != 2 { - return 0 - } - var major int - if _, err := fmt.Sscanf(matches[1], "%d", &major); err != nil { - return 0 - } - return major -} - -func wrapJavaHome(javaHome string) (string, func(), error) { - tempDir, err := os.MkdirTemp("", "keepassgo-java-home-") - if err != nil { - return "", nil, err - } - binDir := filepath.Join(tempDir, "bin") - if err := os.MkdirAll(binDir, 0o755); err != nil { - os.RemoveAll(tempDir) - return "", nil, err - } - realBin := filepath.Join(javaHome, "bin") - if err := os.WriteFile(filepath.Join(binDir, "javac"), []byte("#!/bin/sh\nexport JAVA_TOOL_OPTIONS=-Xint\nexec "+shellQuote(filepath.Join(realBin, "javac"))+" \"$@\"\n"), 0o755); err != nil { - os.RemoveAll(tempDir) - return "", nil, err - } - for _, name := range []string{"java", "jar"} { - if err := os.WriteFile(filepath.Join(binDir, name), []byte("#!/bin/sh\nexec "+shellQuote(filepath.Join(realBin, name))+" \"$@\"\n"), 0o755); err != nil { - os.RemoveAll(tempDir) - return "", nil, err - } - } - return tempDir, func() { os.RemoveAll(tempDir) }, nil -} - -func shellQuote(value string) string { - return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" -} - -func copyDir(src, dst string) error { - return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - rel, err := filepath.Rel(src, path) - if err != nil { - return err - } - target := filepath.Join(dst, rel) - if info.IsDir() { - return os.MkdirAll(target, 0o755) - } - in, err := os.Open(path) - if err != nil { - return err - } - defer in.Close() - out, err := os.Create(target) - if err != nil { - return err - } - defer out.Close() - if _, err := io.Copy(out, in); err != nil { - return err - } - return out.Close() - }) -} 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 From 14f22b4ebf28aee07ad11ae7c36209b60ba96870 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Thu, 16 Apr 2026 21:08:31 -0700 Subject: [PATCH 08/11] Fix Android packaging asset discovery --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 657d0b9..88f2684 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ 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.20260416154203-192acd9d09b0 +replace gioui.org/cmd => git.julianfamily.org/joejulian/gio-cmd-patched v0.9.1-0.20260417040456-1762d36ddecc require ( gioui.org v0.9.0 diff --git a/go.sum b/go.sum index 889b323..34e1ce8 100644 --- a/go.sum +++ b/go.sum @@ -42,8 +42,8 @@ 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.20260416154203-192acd9d09b0 h1:jZmX7CRuev3KrOW0yB2VQDtCrzRa4Q+qWcl3CGXq2sE= -git.julianfamily.org/joejulian/gio-cmd-patched v0.9.1-0.20260416154203-192acd9d09b0/go.mod h1:RBQfFU8JCgMjQ2wKU9DG3zMC38TnY97E5MKoBGhGl3s= +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= From 92a7853258b0df5113ad35342eecc6c06a121293 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Thu, 16 Apr 2026 21:33:40 -0700 Subject: [PATCH 09/11] Harden Android shared-vault import intents --- android/application_snippets.xml | 3 ++ .../keepassgo/SharedVaultImportActivity.java | 54 +++++++++++++++++-- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/android/application_snippets.xml b/android/application_snippets.xml index 4b05a19..d2f6caf 100644 --- a/android/application_snippets.xml +++ b/android/application_snippets.xml @@ -45,6 +45,9 @@ + + + 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"); From 0dfaeef7bffe853a51407ba63660147b14dc7a84 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Sat, 18 Apr 2026 22:00:56 -0700 Subject: [PATCH 10/11] Require dedicated release signing for APK builds --- .codex/skills/keepassgo-apk-test/SKILL.md | 12 +++++++++--- .codex/skills/keepassgo-ship-it/SKILL.md | 8 +++++++- .gitea/workflows/ci.yml | 9 ++++++--- APK.md | 21 ++++++++++++++++++++ Makefile | 24 +++++++++++++++++++++-- README.md | 11 +++++++++++ 6 files changed, 76 insertions(+), 9 deletions(-) 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 71af8c6..5899548 100644 --- a/APK.md +++ b/APK.md @@ -6,11 +6,22 @@ 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`. @@ -23,6 +34,13 @@ Environment: - `APK_VERSION` overrides the packaged app version. - `ANDROID_MIN_SDK` overrides the minimum supported Android SDK. - `ANDROID_TARGET_SDK` overrides the target Android SDK. +- `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: @@ -38,6 +56,9 @@ The repo tracks `gogio` as a Go tool, and the local build runs through: 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. + The Android build uses the branded icon asset at: - `internal/assets/keepassgo-icon.png` diff --git a/Makefile b/Makefile index cf5eae4..8f549d2 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,8 @@ ANDROID_MIN_SDK ?= 28 ANDROID_TARGET_SDK ?= 35 SIGNKEY ?= SIGNPASS ?= +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 @@ -26,7 +28,17 @@ ifneq ($(strip $(SIGNPASS)),) GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS) endif -.PHONY: apk apk-local apk-container apk-container-image archlinux-pkgbuild browser-bridge browser-extension-validate +CONTAINER_SIGNKEY_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 + +.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)"; \ @@ -59,6 +71,13 @@ apk-local: 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; } + @signpass="$$(tr -d '\r\n' < "$(RELEASE_SIGNPASS_FILE)")"; \ + test -n "$$signpass" || { echo "Release signing password file is empty"; exit 1; }; \ + $(MAKE) apk SIGNKEY="$(abspath $(RELEASE_SIGNKEY))" SIGNPASS="$$signpass" + 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; } @@ -69,11 +88,12 @@ apk-container: apk-container-image -w "$(CURDIR)" \ -v "$(ANDROID_SDK_ROOT):$(ANDROID_SDK_ROOT)" \ -v "$(ANDROID_NDK_ROOT):$(ANDROID_NDK_ROOT)" \ + $(CONTAINER_SIGNKEY_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 + 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; } diff --git a/README.md b/README.md index 16197cb..fb720d3 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,17 @@ 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 Desktop automation is resolved through the secure gRPC API rather than synthetic auto-type. From fea1a75cdf3de685e0ebdf8136e53bd184c406db Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Sat, 18 Apr 2026 22:16:25 -0700 Subject: [PATCH 11/11] Keep release signing secrets out of APK build logs --- APK.md | 4 +++- Makefile | 17 ++++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/APK.md b/APK.md index 5899548..af93d30 100644 --- a/APK.md +++ b/APK.md @@ -34,6 +34,7 @@ Environment: - `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`. @@ -57,7 +58,8 @@ 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. +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: diff --git a/Makefile b/Makefile index 8f549d2..eba6520 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,7 @@ 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 @@ -29,6 +30,7 @@ GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS) endif CONTAINER_SIGNKEY_MOUNT := +CONTAINER_SIGNPASSFILE_MOUNT := CONTAINER_SIGN_ARGS := ifneq ($(strip $(SIGNKEY)),) CONTAINER_SIGNKEY_MOUNT += -v "$(dir $(abspath $(SIGNKEY))):$(dir $(abspath $(SIGNKEY))):ro" @@ -37,6 +39,10 @@ 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: @@ -55,6 +61,12 @@ apk-local: 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)" \ @@ -74,9 +86,7 @@ apk-local: android/keepassgo-android.jar 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; } - @signpass="$$(tr -d '\r\n' < "$(RELEASE_SIGNPASS_FILE)")"; \ - test -n "$$signpass" || { echo "Release signing password file is empty"; exit 1; }; \ - $(MAKE) apk SIGNKEY="$(abspath $(RELEASE_SIGNKEY))" SIGNPASS="$$signpass" + @$(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; } @@ -89,6 +99,7 @@ apk-container: apk-container-image -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 \