Pad/scripts/build_phone.sh
Greg Pomerantz d8b5bc704b Handle drags: 1:1 finger tracking with cross-flip; fix caret/taps on empty lines
Selection handles now track the finger 1:1 (anchor grab point + displacement)
instead of snapping by whole lines, and crossing the opposite handle flips
the selection (native behaviour) instead of clearing it.

Caret and tap/handle line resolution use VisualLineStarts instead of the
min-Y baseline: the window's first visual line may be an empty line with no
recorded glyphs, which used to draw boundary carets one line too low per
leading empty line and land taps/dragged handles one line below the finger.
New exported ui.CaretPoint centralises byte->insertion-point mapping.

The off-screen caret no longer clamps to the window edge: EditorLayout ships
the true (possibly negative / past-end) window-relative cursor and the
renderer skips the caret when the cursor is outside the shaped window, so
scrolling past the caret no longer makes it jump onto the top/bottom line.

IME/router replay fixes: key.FocusCmd is issued only on a focus transition
(a per-frame no-op still takes the immediate-command path and re-queues all
pointer events), and the key.SelectionCmd IME sync is deferred while a
handle drag is in progress (each push re-injected the drag into every
gesture). Handle drags forward only Grabbed events; a tap inside a handle
grab box is a no-op.

Also: key.FocusEvent no longer logs as unexpected in main; dead code removed
(worker taskWrapper, browser applyXxxResult stubs, scrollIndex, mock_setup
sortModeKey/lineSpan helpers); mock FileSystem.ListPaths prefix match uses
strings.HasPrefix; build scripts run the new scripts/check.sh static gate
(go vet + staticcheck). Tests: caret_point_test, touch_selection updates
(flip/empty-line cases), off-window caret e2e, selection drag e2e grab step.
2026-08-19 22:34:12 -04:00

379 lines
12 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"
echo "=== static checks (go vet + staticcheck) ==="
"$REPO/scripts/check.sh"
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
# 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)
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 ==="