The OS provides no user-space hook for a process kill, but the activity onStop fires on every 'going away' transition the framework still controls: entering recents (the swipe-wipe path), app switch, and home. Recents-wipe then kills the process right after onStop returns, so that moment is the last reliable flush. - Logic.FlushSession (any goroutine, buffered, non-blocking) -> flushSessionSave on the owner: persist the snapshot now, bypassing the rate limit (still honoring the restore-pending suppression). saveSessionIfChanged's persist tail is deduplicated into writeSession. - JNI: GioActivity.onStop (patched into the smali by the build scripts, in sync) now calls the static native padFlushSession, which maps to the pad_flush_session cgo export. Verified on emulator: the flush fires on home/app-switch and when the app is backgrounded into recents before a kill; a state change made inside the 250ms rate window and then backed out of the app persists the latest position. A hard kill while foregrounded (force-stop, memory pressure) still has no hook — the immediate edit saves plus the 250ms rate window bound that loss.
409 lines
13 KiB
Bash
Executable File
409 lines
13 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Build (and optionally install) the Pad Android APK for the local emulator.
|
|
#
|
|
# Usage:
|
|
# ./scripts/build_emu.sh # build + install to the connected device
|
|
# ./scripts/build_emu.sh --no-install # build only
|
|
#
|
|
# Output: cmd/pad/pad-emu.apk
|
|
#
|
|
# Prereqs on this workstation (the script checks each and explains what is
|
|
# missing):
|
|
# - JDK 17 (java on PATH)
|
|
# - Go + gogio v0.10.0 (~/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 does not offer a way to add
|
|
# MANAGE_EXTERNAL_STORAGE to the manifest, and Android 11+ blocks the app's
|
|
# directory listing without it. 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
|
|
|
|
INSTALL=1
|
|
[ "${1:-}" = "--no-install" ] && INSTALL=0
|
|
|
|
die() { echo "ERROR: $*" >&2; exit 1; }
|
|
|
|
command -v java >/dev/null 2>&1 || die "java not found (need JDK 17)"
|
|
command -v gogio >/dev/null 2>&1 || die "gogio not found (need v0.10.0 in ~/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"
|
|
|
|
echo "=== static checks (go vet + staticcheck) ==="
|
|
"$REPO/scripts/check.sh"
|
|
|
|
# apktool: auto-download to a stable location if missing (never /tmp, which
|
|
# gets wiped between sessions).
|
|
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. We keep 34
|
|
# and instead consume the IME insets ourselves (PadInsetsListener patch below);
|
|
# on API 30+ devices setDecorFitsSystemWindows(false) gets us the same
|
|
# inset delivery without the blanket edge-to-edge enforcement.
|
|
(cd "$REPO/cmd/pad" && gogio -target android -targetsdk 34 -arch amd64 -o "$WORK/pad-raw.apk" .)
|
|
|
|
echo "=== apktool decode ==="
|
|
java -jar "$APKTOOL" d "$WORK/pad-raw.apk" -o "$WORK/decoded" -f >/dev/null
|
|
|
|
echo "=== inject MANAGE_EXTERNAL_STORAGE ==="
|
|
grep -q "MANAGE_EXTERNAL_STORAGE" "$WORK/decoded/AndroidManifest.xml" || \
|
|
sed -i '0,/<application/s#<application#<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>\n <application#' \
|
|
"$WORK/decoded/AndroidManifest.xml"
|
|
grep -q "MANAGE_EXTERNAL_STORAGE" "$WORK/decoded/AndroidManifest.xml" || die "permission injection failed"
|
|
|
|
# Consume the IME insets in the app. The Gio window contains a SurfaceView
|
|
# (GioView), which unconditionally marks the window FORMAT_TRANSLUCENT
|
|
# (SurfaceView.requestTransparentRegion -> ViewRootImpl). Translucent windows
|
|
# are never resized by the IME, so windowSoftInputMode=adjustResize is dead
|
|
# and the keyboard covers the bottom bar. The supported fix: on API 30+ opt
|
|
# out of decor auto-fitting (setDecorFitsSystemWindows(false)) and shrink the
|
|
# GioView by the IME inset height; the Go side sees the surface resize and
|
|
# lays the bottom bar above the keyboard. On API < 30 there are no IME insets
|
|
# and the keyboard overlaps (accepted limitation).
|
|
echo "=== patch IME insets handling ==="
|
|
python3 - "$WORK/decoded" << 'PYEOF'
|
|
import sys, pathlib
|
|
base = pathlib.Path(sys.argv[1])
|
|
|
|
# 1. New class: shrinks the GioView by the current IME inset.
|
|
listener = '''
|
|
.class public Lorg/gioui/PadInsetsListener;
|
|
.super Ljava/lang/Object;
|
|
.source "PadInsetsListener.java"
|
|
|
|
# interfaces
|
|
.implements Landroid/view/View$OnApplyWindowInsetsListener;
|
|
|
|
|
|
# instance fields
|
|
.field private final mView:Landroid/view/View;
|
|
|
|
|
|
# direct methods
|
|
.method public constructor <init>(Landroid/view/View;)V
|
|
.locals 0
|
|
|
|
invoke-direct {p0}, Ljava/lang/Object;-><init>()V
|
|
|
|
iput-object p1, p0, Lorg/gioui/PadInsetsListener;->mView:Landroid/view/View;
|
|
|
|
return-void
|
|
.end method
|
|
|
|
|
|
# virtual methods
|
|
# Resizes the GioView so the app draws above the visible keyboard instead of
|
|
# behind it, and re-applies the status/nav bar insets as margins (the window
|
|
# is edge-to-edge via setDecorFitsSystemWindows(false)).
|
|
#
|
|
# Coordinate frames: the window is full screen, but
|
|
# DisplayMetrics.heightPixels is the app CONTENT height (status bar and nav
|
|
# bar excluded). The typed insets are measured from the absolute display
|
|
# edges, so the absolute display height is reconstructed as
|
|
# contentH + statusTop + navBottom.
|
|
#
|
|
# v0 = statusTop, v1 = navBottom, v2 = imeInset, v3 = display height /
|
|
# bottomLimit / height (int, sequential), v4 = object scratch, v5 = int
|
|
# scratch. All registers <= v6 (const/16, if-* and invoke register lists are
|
|
# 4-bit).
|
|
.method public onApplyWindowInsets(Landroid/view/View;Landroid/view/WindowInsets;)Landroid/view/WindowInsets;
|
|
.locals 6
|
|
|
|
const/4 v0, 0x0
|
|
|
|
const/4 v1, 0x0
|
|
|
|
const/4 v2, 0x0
|
|
|
|
# content height (valid on every path; the API < 30 branch leaves the
|
|
# inset values at 0)
|
|
iget-object v4, p0, Lorg/gioui/PadInsetsListener;->mView:Landroid/view/View;
|
|
|
|
invoke-virtual {v4}, Landroid/view/View;->getResources()Landroid/content/res/Resources;
|
|
|
|
move-result-object v4
|
|
|
|
invoke-virtual {v4}, Landroid/content/res/Resources;->getDisplayMetrics()Landroid/util/DisplayMetrics;
|
|
|
|
move-result-object v4
|
|
|
|
iget v3, v4, Landroid/util/DisplayMetrics;->heightPixels:I
|
|
|
|
sget v4, Landroid/os/Build$VERSION;->SDK_INT:I
|
|
|
|
const/16 v5, 0x1e
|
|
|
|
# API < 30: no typed insets - fall through with all insets 0.
|
|
if-lt v4, v5, :goto_pad_geom
|
|
|
|
# statusTop = insets.getInsets(Type.statusBars()).top
|
|
const/4 v4, 0x1
|
|
|
|
invoke-virtual {p2, v4}, Landroid/view/WindowInsets;->getInsets(I)Landroid/graphics/Insets;
|
|
|
|
move-result-object v4
|
|
|
|
iget v0, v4, Landroid/graphics/Insets;->top:I
|
|
|
|
# navBottom = insets.getInsets(Type.navigationBars()).bottom
|
|
const/4 v4, 0x6
|
|
|
|
invoke-virtual {p2, v4}, Landroid/view/WindowInsets;->getInsets(I)Landroid/graphics/Insets;
|
|
|
|
move-result-object v4
|
|
|
|
iget v1, v4, Landroid/graphics/Insets;->bottom:I
|
|
|
|
# imeInset = insets.getInsets(Type.ime()).bottom
|
|
const/16 v4, 0x8
|
|
|
|
invoke-virtual {p2, v4}, Landroid/view/WindowInsets;->getInsets(I)Landroid/graphics/Insets;
|
|
|
|
move-result-object v4
|
|
|
|
iget v2, v4, Landroid/graphics/Insets;->bottom:I
|
|
|
|
:goto_pad_geom
|
|
# absolute display height
|
|
add-int v3, v3, v0
|
|
|
|
add-int v3, v3, v1
|
|
|
|
# bottomLimit: where the view bottom must not go below.
|
|
# IME present: display bottom minus the IME inset (reaches the visible
|
|
# keyboard top; the keyboard also covers the nav area).
|
|
# otherwise: display bottom minus the nav bar.
|
|
if-lez v2, :cond_pad_noime
|
|
|
|
sub-int v3, v3, v2
|
|
|
|
goto :goto_pad_bottom
|
|
|
|
:cond_pad_noime
|
|
sub-int v3, v3, v1
|
|
|
|
:goto_pad_bottom
|
|
# height = bottomLimit - statusTop, clamped to >= 0
|
|
sub-int v3, v3, v0
|
|
|
|
if-gez v3, :cond_pad_pos
|
|
|
|
const/4 v3, 0x0
|
|
|
|
:cond_pad_pos
|
|
# apply to LayoutParams (height + topMargin = statusTop, bottomMargin =
|
|
# navBottom)
|
|
iget-object v4, p0, Lorg/gioui/PadInsetsListener;->mView:Landroid/view/View;
|
|
|
|
invoke-virtual {v4}, Landroid/view/View;->getLayoutParams()Landroid/view/ViewGroup$LayoutParams;
|
|
|
|
move-result-object v4
|
|
|
|
check-cast v4, Landroid/view/ViewGroup$MarginLayoutParams;
|
|
|
|
# v2 (free after geometry) = changed flag
|
|
const/4 v2, 0x0
|
|
|
|
iget v5, v4, Landroid/view/ViewGroup$LayoutParams;->height:I
|
|
|
|
if-eq v5, v3, :cond_pad_checktop
|
|
|
|
iput v3, v4, Landroid/view/ViewGroup$LayoutParams;->height:I
|
|
|
|
const/4 v2, 0x1
|
|
|
|
:cond_pad_checktop
|
|
iget v5, v4, Landroid/view/ViewGroup$MarginLayoutParams;->topMargin:I
|
|
|
|
if-eq v5, v0, :cond_pad_checkbot
|
|
|
|
iput v0, v4, Landroid/view/ViewGroup$MarginLayoutParams;->topMargin:I
|
|
|
|
const/4 v2, 0x1
|
|
|
|
:cond_pad_checkbot
|
|
iget v5, v4, Landroid/view/ViewGroup$MarginLayoutParams;->bottomMargin:I
|
|
|
|
if-eq v5, v1, :cond_pad_layout
|
|
|
|
iput v1, v4, Landroid/view/ViewGroup$MarginLayoutParams;->bottomMargin:I
|
|
|
|
const/4 v2, 0x1
|
|
|
|
:cond_pad_layout
|
|
if-eqz v2, :cond_pad_done
|
|
|
|
iget-object v5, p0, Lorg/gioui/PadInsetsListener;->mView:Landroid/view/View;
|
|
|
|
invoke-virtual {v5}, Landroid/view/View;->requestLayout()V
|
|
|
|
:cond_pad_done
|
|
return-object p2
|
|
.end method
|
|
|
|
'''
|
|
(base / "smali" / "org" / "gioui" / "PadInsetsListener.smali").write_text(listener)
|
|
|
|
# 1b. New class: a Runnable that applies system-gesture exclusion rects on
|
|
# the UI thread. The Go frame loop runs on its OWN thread (not the UI
|
|
# thread), so View.setSystemGestureExclusionRects must be posted: it is the
|
|
# relayout that reports the rects to the window manager, which owns the edge
|
|
# back-gesture zone. Without the post the rects are set on the view but never
|
|
# reach the window manager, and the back gesture still steals edge drags.
|
|
padexcl = '''
|
|
.class public Lorg/gioui/PadExcl;
|
|
.super Ljava/lang/Object;
|
|
.source "PadExcl.java"
|
|
|
|
# interfaces
|
|
.implements Ljava/lang/Runnable;
|
|
|
|
|
|
# instance fields
|
|
.field public final mView:Landroid/view/View;
|
|
|
|
.field public final mList:Ljava/util/List;
|
|
|
|
|
|
# direct methods
|
|
.method public constructor <init>(Landroid/view/View;Ljava/util/List;)V
|
|
.locals 0
|
|
|
|
invoke-direct {p0}, Ljava/lang/Object;-><init>()V
|
|
|
|
iput-object p1, p0, Lorg/gioui/PadExcl;->mView:Landroid/view/View;
|
|
|
|
iput-object p2, p0, Lorg/gioui/PadExcl;->mList:Ljava/util/List;
|
|
|
|
return-void
|
|
.end method
|
|
|
|
|
|
# virtual methods
|
|
.method public run()V
|
|
.locals 2
|
|
|
|
iget-object v0, p0, Lorg/gioui/PadExcl;->mView:Landroid/view/View;
|
|
|
|
iget-object v1, p0, Lorg/gioui/PadExcl;->mList:Ljava/util/List;
|
|
|
|
invoke-virtual {v0, v1}, Landroid/view/View;->setSystemGestureExclusionRects(Ljava/util/List;)V
|
|
|
|
return-void
|
|
.end method
|
|
|
|
'''
|
|
(base / "smali" / "org" / "gioui" / "PadExcl.smali").write_text(padexcl)
|
|
|
|
# 2. Wire it up in GioActivity.onCreate (and opt out of decor auto-fit).
|
|
p = base / "smali" / "org" / "gioui" / "GioActivity.smali"
|
|
t = p.read_text()
|
|
old = """ invoke-virtual {p1, v0}, Landroid/widget/FrameLayout;->addView(Landroid/view/View;)V
|
|
|
|
.line 32"""
|
|
new = """ invoke-virtual {p1, v0}, Landroid/widget/FrameLayout;->addView(Landroid/view/View;)V
|
|
|
|
# Pad: consume IME insets by shrinking the GioView (API 21+).
|
|
sget v1, Landroid/os/Build$VERSION;->SDK_INT:I
|
|
|
|
const/16 v2, 0x15
|
|
|
|
if-lt v1, v2, :cond_pad_insets
|
|
|
|
new-instance v1, Lorg/gioui/PadInsetsListener;
|
|
|
|
invoke-direct {v1, v0}, Lorg/gioui/PadInsetsListener;-><init>(Landroid/view/View;)V
|
|
|
|
invoke-virtual {v0, v1}, Landroid/view/View;->setOnApplyWindowInsetsListener(Landroid/view/View$OnApplyWindowInsetsListener;)V
|
|
|
|
:cond_pad_insets
|
|
|
|
# Pad: API 30+ opt out of decor auto-fit so IME insets are dispatched.
|
|
sget v1, Landroid/os/Build$VERSION;->SDK_INT:I
|
|
|
|
const/16 v2, 0x1e
|
|
|
|
if-lt v1, v2, :cond_pad_nofit
|
|
|
|
invoke-virtual {p0}, Lorg/gioui/GioActivity;->getWindow()Landroid/view/Window;
|
|
|
|
move-result-object v1
|
|
|
|
const/4 v2, 0x0
|
|
|
|
invoke-virtual {v1, v2}, Landroid/view/Window;->setDecorFitsSystemWindows(Z)V
|
|
|
|
:cond_pad_nofit
|
|
|
|
.line 32"""
|
|
if old not in t:
|
|
sys.exit("ERROR: GioActivity addView anchor not found")
|
|
t = t.replace(old, new)
|
|
old2 = ".method public onCreate(Landroid/os/Bundle;)V\n .locals 2"
|
|
new2 = ".method public onCreate(Landroid/os/Bundle;)V\n .locals 3"
|
|
if old2 not in t:
|
|
sys.exit("ERROR: GioActivity onCreate .locals anchor not found")
|
|
t = t.replace(old2, new2)
|
|
# 3. onStop hook: persist the session snapshot the moment the activity goes
|
|
# away (recents-wipe, app switch). The OS provides no user-space hook for
|
|
# the process kill that follows, so this is the last reliable flush. Go
|
|
# side: pad_flush_session (impl_android.go) -> Logic.FlushSession.
|
|
old3 = """.method public onStop()V
|
|
.locals 1
|
|
|
|
.line 47
|
|
iget-object v0, p0, Lorg/gioui/GioActivity;->view:Lorg/gioui/GioView;"""
|
|
new3 = """.method public static native padFlushSession()V
|
|
.end method
|
|
|
|
.method public onStop()V
|
|
.locals 1
|
|
|
|
# Pad: persist the session snapshot before the activity goes away.
|
|
invoke-static {}, Lorg/gioui/GioActivity;->padFlushSession()V
|
|
|
|
.line 47
|
|
iget-object v0, p0, Lorg/gioui/GioActivity;->view:Lorg/gioui/GioView;"""
|
|
if old3 not in t:
|
|
sys.exit("ERROR: GioActivity onStop anchor not found")
|
|
t = t.replace(old3, new3)
|
|
p.write_text(t)
|
|
print("patched: GioActivity IME insets wiring + onStop session flush")
|
|
PYEOF
|
|
|
|
echo "=== apktool rebuild ==="
|
|
java -jar "$APKTOOL" b "$WORK/decoded" -o "$WORK/pad-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/pad/pad-emu.apk" "$WORK/pad-unsigned.apk"
|
|
|
|
if [ "$INSTALL" = "1" ]; then
|
|
echo "=== install ==="
|
|
adb get-state >/dev/null 2>&1 || die "no adb device (start the emulator or plug in a phone)"
|
|
adb install -r "$REPO/cmd/pad/pad-emu.apk"
|
|
fi
|
|
|
|
echo "=== DONE: $REPO/cmd/pad/pad-emu.apk ==="
|