Three user-reported selection bugs, one root cause each:
1. Start handle ungrabbable at line start. Two interacting causes:
a) The 48dp grab box straddles two visual lines; a finger in the
lower half mapped (by y-to-line) to the neighbouring line, whose
byte past the other handle clamped to a zero-length selection ->
cleared on the first drag event. The cleared selection
un-registered the drag op, so the router silently stopped
delivering drag events (the observed 'stream cutoff'). Fix:
handle drags now project the finger's x onto the anchor's own
visual line (visualLineOfByte + textPosOnLineAtX); the anchor
never crosses lines during a handle drag.
b) A horizontal flick from the line-start handle (screen x~26px)
started the system back gesture, which cancelled the touch
stream. Fix: report the handle grab rects as system gesture
exclusion rects (setSystemGestureExclusionRects, API 29+),
marshalled to the UI thread via a PadExcl smali Runnable
(generated identically by build_emu.sh/build_phone.sh).
2. End-handle drag downward made the menu chase the finger and cover
the selection. Fix: the menu anchors to the STABLE end of the
selection (the end not being dragged), so it stays parked by the
selection start, clear of the finger and the highlighted text.
3. Menu above the selection vanished permanently when the selection
was extended onto the top line. Fix: off-window anchors no longer
hide the menu while any part of the selection is visible (keep-last
rect, clamped); hiding happens only for fully off-window selections.
Also: registerDrag simplified (single shared drag path, body before
handles in z-order), debug logging removed, regression tests
(mutation-verified) for line projection and menu anchoring, docs
section 17. Verified on device: start-handle drag shrinks the word
without clearing or triggering back navigation; end-handle vertical
drag leaves menu/highlight/handles undisturbed; menu stays visible
with the selection at the top line.
383 lines
12 KiB
Bash
Executable File
383 lines
12 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"
|
|
|
|
# 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)
|
|
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 ==="
|