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 ==="