#!/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"
# 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,/\n 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 (Landroid/view/View;)V
.locals 0
invoke-direct {p0}, Ljava/lang/Object;->()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 (Landroid/view/View;Ljava/util/List;)V
.locals 0
invoke-direct {p0}, Ljava/lang/Object;->()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;->(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)
p.write_text(t)
print("patched: GioActivity IME insets wiring")
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 ==="