#!/usr/bin/env bash # Build the Pad Android APK for a real phone (arm64 + arm), optionally # installing it to a connected device. # # Usage: # ./scripts/build_phone.sh # build + install to the connected device # ./scripts/build_phone.sh --no-install # build only # # Output: cmd/pad/pad-phone.apk # # Same pipeline as build_emu.sh (gogio -> apktool permission injection -> # debug-sign); the only differences are the architectures (arm64,arm covers # any phone from the last decade) and the output name. On Android 11+ the # user must grant the app "All files access" in system settings after # installing (the MANAGE_EXTERNAL_STORAGE permission is a special # app-restricted permission; the emulator AVD granted it, phones do not). 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" # SKIP_CHECK=1 skips the static checks (scripts/release.sh already runs them # as gate 1); a standalone build always runs them. if [ "${SKIP_CHECK:-0}" = "1" ]; then echo "=== static checks (skipped: SKIP_CHECK=1) ===" else echo "=== static checks (go vet + staticcheck) ===" "$REPO/scripts/check.sh" fi 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, arm64+arm) ===" # 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 arm64,arm -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). # NOTE: must stay in sync with the identical block in build_emu.sh. 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) # 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-phone.apk" "$WORK/pad-unsigned.apk" if [ "$INSTALL" = "1" ]; then echo "=== install ===" adb get-state >/dev/null 2>&1 || die "no adb device (plug in the phone with USB debugging, or use --no-install)" adb install -r "$REPO/cmd/pad/pad-phone.apk" fi echo "=== DONE: $REPO/cmd/pad/pad-phone.apk ==="