Pad/scripts/build_phone.sh
Greg Pomerantz 941285e7cc Fix scroll-anchored selection (shadowing) + IME insets keyboard handling
Two user-reported bugs in the post-wrap build:

1. Selection 'jumped' when scrolling: frameOf's chunked branch shadowed the
   outer start/end with 'start, end, winLine := cb.VisibleByteRange(...)',
   leaving IMEWindowStartByte at 0 for chunked files while scrolled, so the
   window-relative selection highlight (and IME commits) mis-mapped near the
   file top. Fix: declare winLine outside, assign. Mutation-verified
   regression tests: real_file_scroll_selection_test.go.

2. Bottom bar hidden behind the keyboard: GioView extends SurfaceView, which
   unconditionally marks the window FORMAT_TRANSLUCENT; translucent windows
   are never IME-resized, so adjustResize is dead. Fix (build scripts):
   smali-patch a PadInsetsListener OnApplyWindowInsetsListener onto the
   GioView (shrinks it by the IME inset bottom) + setDecorFitsSystemWindows
   (false) on API 30+ so insets are dispatched. targetSdk kept at 34.

3. (Found while fixing 2) Keyboard could not be dismissed: TextField.Draw
   issued SoftKeyboardCmd{Show:true} every frame; with insets-driven
   resizes, the hide animation triggered redraws that re-showed the keyboard
   mid-animation. Fix: ShowIMESeq pulse from the logic layer (open/tap/
   double-tap); renderer shows only on pulse change, re-arming on focus
   loss — matching widget.Editor.

On-device (emulator): BACK dismisses and stays down; tap re-shows; typing
works; bottom bar above keyboard; word selection anchored across flick
scroll. go test -race ./... green. Phone APK rebuilt with the same patch.
2026-08-17 21:52:53 -04:00

234 lines
8.2 KiB
Bash
Executable File

#!/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"
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,/<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).
# 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 <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
.method public onApplyWindowInsets(Landroid/view/View;Landroid/view/WindowInsets;)Landroid/view/WindowInsets;
.locals 4
# int imeBottom = 0;
const/4 v0, 0x0
# if (Build.VERSION.SDK_INT >= 30)
# imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom;
sget v1, Landroid/os/Build$VERSION;->SDK_INT:I
const/16 v2, 0x1e
if-lt v1, v2, :cond_pad_noime
const/16 v1, 0x8
invoke-virtual {p2, v1}, Landroid/view/WindowInsets;->getInsets(I)Landroid/graphics/Insets;
move-result-object v1
iget v0, v1, Landroid/graphics/Insets;->bottom:I
:cond_pad_noime
# int h = displayHeight - imeBottom;
iget-object v1, p0, Lorg/gioui/PadInsetsListener;->mView:Landroid/view/View;
invoke-virtual {v1}, Landroid/view/View;->getResources()Landroid/content/res/Resources;
move-result-object v1
invoke-virtual {v1}, Landroid/content/res/Resources;->getDisplayMetrics()Landroid/util/DisplayMetrics;
move-result-object v1
iget v1, v1, Landroid/util/DisplayMetrics;->heightPixels:I
sub-int v1, v1, v0
# if (mView.getLayoutParams().height != h) { ... = h; requestLayout(); }
iget-object v2, p0, Lorg/gioui/PadInsetsListener;->mView:Landroid/view/View;
invoke-virtual {v2}, Landroid/view/View;->getLayoutParams()Landroid/view/ViewGroup$LayoutParams;
move-result-object v2
iget v3, v2, Landroid/view/ViewGroup$LayoutParams;->height:I
if-eq v3, v1, :cond_pad_done
iput v1, v2, Landroid/view/ViewGroup$LayoutParams;->height:I
iget-object v3, p0, Lorg/gioui/PadInsetsListener;->mView:Landroid/view/View;
invoke-virtual {v3}, Landroid/view/View;->requestLayout()V
:cond_pad_done
return-object p2
.end method
'''
(base / "smali" / "org" / "gioui" / "PadInsetsListener.smali").write_text(listener)
# 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)
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-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 ==="