From 349445219bf89ece5321f23711381c591fd358d7 Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Mon, 24 Aug 2026 09:23:41 -0400 Subject: [PATCH] Android: reliable build script; GUI: fix list not drawn until first tap. scripts/build.sh (documented in README) builds, manifest-patches ( MANAGE_EXTERNAL_STORAGE ), signs and installs the APK in one go. Without the permission the system 'All files access' toggle is grayed out on Android 11+. GUI fix: the event loop only checks the 'updated' channel when it is not blocked in w.Event(), and gioui only emits frames on invalidation or user input, so the initial list load was never drawn until a tap. The updater now signals non-blocking and invalidates the window itself; the animation ticker gets the same wake-up treatment. --- README.md | 28 ++++++++++++ cmd/passgo-gui/main.go | 56 +++++++++++++++++++++--- scripts/build.sh | 99 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 176 insertions(+), 7 deletions(-) create mode 100755 scripts/build.sh diff --git a/README.md b/README.md index b149155..77c2645 100644 --- a/README.md +++ b/README.md @@ -32,3 +32,31 @@ p, err := store.Decrypt("myPass") Also included is a simple GUI front-end using [Gio](https://gioui.org) that works on MacOS and Android. See `cmd/passgo-gui`. + +## Building the Android APK + +Use `scripts/build.sh` — do not build by hand: + +```sh +./scripts/build.sh phone # build + install on the phone +./scripts/build.sh phone --no-install +./scripts/build.sh emu # emulator build (amd64) +``` + +It outputs `cmd/passgo-gui/passgo-gui.apk` (APKs are git-ignored and +regenerated by the script). The script, in order: + +1. `gogio -target android -targetsdk 34` (from `~/go/bin`; packages the + Go code plus the `*.jar` JNI classes found in `cmd/passgo-gui/`), +2. apktool decode → inject `MANAGE_EXTERNAL_STORAGE` into the manifest + (gogio's hardcoded permission table lacks it, and without it the + system "All files access" toggle is grayed out on Android 11+) → + apktool rebuild, +3. sign with the debug keystore (`~/.android/debug.keystore`), +4. `adb install -r`. + +Prereqs: JDK, Go + gogio, Android SDK at `~/android-sdk` +(platform-tools + build-tools 35.0.0). apktool v3.0.3 is auto-downloaded +to `~/android-sdk/tools/apktool.jar` if missing. The script checks each +prereq and says what is missing. The phone is reached via adb over the +WireGuard address (see the global AGENTS.md). diff --git a/cmd/passgo-gui/main.go b/cmd/passgo-gui/main.go index 1a1a7d3..1498613 100644 --- a/cmd/passgo-gui/main.go +++ b/cmd/passgo-gui/main.go @@ -10,6 +10,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "gioui.org/app" @@ -99,7 +100,7 @@ func main() { } reload = make(chan struct{}) - updated = make(chan struct{}) + updated = make(chan struct{}, 1) chdir = make(chan struct{}) passch = make(chan []byte) @@ -127,6 +128,7 @@ var ( chdir chan struct{} passch chan []byte th *material.Theme + win atomic.Pointer[app.Window] ) func Updater() { @@ -140,7 +142,20 @@ func Updater() { mux.Lock() l = ltmp mux.Unlock() - updated <- struct{}{} + // Non-blocking: if a refresh is already pending it will pick up + // the latest list, so dropping the duplicate is fine. + select { + case updated <- struct{}{}: + default: + } + // Wake the window: the event loop blocks in w.Event(), which only + // returns when the window is invalidated or the user interacts + // with it. Without this, a list refresh signalled while the loop + // is blocked (e.g. the initial load on startup) would not be + // drawn until the next tap. + if w := win.Load(); w != nil { + w.Invalidate() + } } update() @@ -212,6 +227,7 @@ func eventLoop() { w.Option( app.Size(unit.Dp(250), unit.Dp(500)), app.Title("passgo")) + win.Store(w) var margincs layout.Constraints @@ -401,18 +417,49 @@ func eventLoop() { noidLabel := material.Label(th, unit.Sp(fontSize), noidLabelText) idLabel := material.Label(th, unit.Sp(fontSize), "Select ID") + // timing variables used for the "copied to clipboard" animation + // (seconds relative to overlayStart) + fade1a, fade1b := 1.5, 2.0 + start2 := float64(Config.ClearDelay) + fade2a, end := start2+1.5, start2+2.0 + anim := &time.Ticker{} + var animDone chan struct{} animating := false animOn := func() { log(Info, "animOn()") anim = time.NewTicker(time.Second / 90) animating = true w.Invalidate() + // The event loop blocks in w.Event() between frames, so ticks it + // misses never trigger a redraw and the fade would freeze. Wake + // the window on each tick, but only while a fade is actually + // visible. + t := anim + animDone = make(chan struct{}) + d := animDone + go func() { + for { + select { + case <-t.C: + x := time.Since(overlayStart).Seconds() + if (x >= fade1a && x < fade1b) || (x > fade2a && x < end) { + w.Invalidate() + } + case <-d: + return + } + } + }() } animOff := func() { log(Info, "animOff()") anim.Stop() animating = false + if animDone != nil { // stops the wake-up goroutine started by animOn + close(animDone) + animDone = nil + } } var listPage, idPage, insertPage, confirmPage, confPage, promptPage, page func(C) D @@ -427,11 +474,6 @@ func eventLoop() { lpf1 := &layout.Flex{Axis: layout.Horizontal} listPage = func(gtx C) D { - // timing variables used for animation - fade1a, fade1b := 1.5, 2.0 - start2 := float64(Config.ClearDelay) - fade2a, end := start2+1.5, start2+2.0 - c2 := layout.Rigid(func(gtx C) D { c21 := layout.Flexed(1, func(gtx C) D { return material.Editor(th, FilterEd, "filter").Layout(gtx) diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..4684210 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Build (and optionally install) the passgo-gui Android APK. +# +# Usage: +# ./scripts/build.sh [phone|emu] # build + install (default: phone) +# ./scripts/build.sh phone --no-install # build only +# +# Output: cmd/passgo-gui/passgo-gui.apk +# +# Prereqs on this workstation: +# - JDK (java on PATH) +# - Go + gogio (~/go/bin) +# - Android SDK at ~/android-sdk (platform-tools, build-tools 35.0.0) +# - debug keystore at ~/.android/debug.keystore +# apktool is not a prerequisite: if it is missing, the script downloads +# v3.0.3 to the stable location ~/android-sdk/tools/apktool.jar. +# +# Why the apktool detour: gogio's manifest permissions come from a hardcoded +# table (only the legacy READ/WRITE_EXTERNAL_STORAGE). On Android 11+ the +# "All files access" settings toggle is grayed out unless the app declares +# MANAGE_EXTERNAL_STORAGE, which Permissions.java opens at startup. So we +# decode the gogio APK, inject the permission, rebuild, and re-sign with +# the debug key. +set -euo pipefail + +REPO=$(cd "$(dirname "$0")/.." && pwd) +export ANDROID_HOME=${ANDROID_HOME:-$HOME/android-sdk} +export PATH=$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/build-tools/35.0.0:$HOME/go/bin + +MODE=${1:-phone} +INSTALL=1 +[ "${2:-}" = "--no-install" ] && INSTALL=0 +case "$MODE" in + phone) ARCH="arm64,arm";; + emu) ARCH="amd64";; + *) echo "usage: $0 [phone|emu] [--no-install]" >&2; exit 1;; +esac + +die() { echo "ERROR: $*" >&2; exit 1; } + +command -v java >/dev/null 2>&1 || die "java not found" +command -v gogio >/dev/null 2>&1 || die "gogio not found (need ~/go/bin)" +command -v apksigner >/dev/null 2>&1 || die "apksigner not found (need $ANDROID_HOME/build-tools/35.0.0)" +command -v adb >/dev/null 2>&1 || die "adb not found (need $ANDROID_HOME/platform-tools)" +[ -f "$HOME/.android/debug.keystore" ] || die "debug keystore missing: $HOME/.android/debug.keystore" + +# apktool: auto-download to a stable location if missing. +APKTOOL="$ANDROID_HOME/tools/apktool.jar" +if [ ! -f "$APKTOOL" ]; then + echo "=== apktool missing; downloading v3.0.3 to $APKTOOL ===" + mkdir -p "$ANDROID_HOME/tools" + curl -fsSL -o "$APKTOOL" \ + https://github.com/iBotPeaches/Apktool/releases/download/v3.0.3/apktool_3.0.3.jar \ + || die "apktool download failed" +fi + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +echo "=== gogio (Go -> APK) ===" +# targetSdk 34: targeting 35 makes Android 15 force edge-to-edge, which the +# app does not handle. +(cd "$REPO/cmd/passgo-gui" && gogio -target android -targetsdk 34 -arch "$ARCH" -o "$WORK/passgo-raw.apk" .) +[ -f "$WORK/passgo-raw.apk" ] || die "gogio did not produce an APK" + +echo "=== apktool decode + inject MANAGE_EXTERNAL_STORAGE ===" +java -jar "$APKTOOL" d "$WORK/passgo-raw.apk" -o "$WORK/decoded" -f >/dev/null +python3 - "$WORK/decoded/AndroidManifest.xml" << 'PYEOF' +import sys, pathlib +p = pathlib.Path(sys.argv[1]) +t = p.read_text() +perm = '' +if perm not in t: + t = t.replace(" /dev/null + +echo "=== sign ===" +apksigner sign \ + --ks "$HOME/.android/debug.keystore" --ks-pass pass:android \ + --key-pass pass:android --ks-key-alias androiddebugkey \ + --out "$REPO/cmd/passgo-gui/passgo-gui.apk" "$WORK/passgo-unsigned.apk" + +# ADB_SERIAL selects the device when several are attached (adb devices). +ADB="adb${ADB_SERIAL:+ -s $ADB_SERIAL}" + +if [ "$INSTALL" = "1" ]; then + echo "=== install ===" + $ADB get-state >/dev/null 2>&1 || die "no adb device (connect the phone; set ADB_SERIAL if several are attached)" + $ADB install -r "$REPO/cmd/passgo-gui/passgo-gui.apk" +fi + +echo "=== DONE: $REPO/cmd/passgo-gui/passgo-gui.apk ==="