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.
This commit is contained in:
Greg Pomerantz 2026-08-24 09:23:41 -04:00
parent 4c337ff06d
commit 349445219b
3 changed files with 176 additions and 7 deletions

View File

@ -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).

View File

@ -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)

99
scripts/build.sh Executable file
View File

@ -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 = '<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>'
if perm not in t:
t = t.replace(" <application", " " + perm + "\n <application", 1)
if perm not in t:
sys.exit("ERROR: failed to inject MANAGE_EXTERNAL_STORAGE")
p.write_text(t)
print("manifest: MANAGE_EXTERNAL_STORAGE present")
PYEOF
echo "=== apktool rebuild ==="
java -jar "$APKTOOL" b "$WORK/decoded" -o "$WORK/passgo-unsigned.apk" >/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 ==="