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