Pad/tools/touchinject/Injector.java
Greg Pomerantz 180fa966c8 Pinch-to-font-size (continuous, content-point pinned) + IME-open scroll fix
Two feature bodies accumulated in the working tree:

1. Pinch to change the app font size, continuously (no snapping):
   - internal/ui/pinch_tracker.go: logic-free touch state machine.
     Two-mover formation (the resting palm can land first or last;
     movement is the only signal valid for both), pair = the mover
     pair whose distance changed most, baseline = press distance
     (formDist), lazy pending releases, survivor-scroll forwarding
     after a pair break. Robust to ~1 fps frames: a whole pinch can
     land in one drain (formDist/brokeFactor/lazy releases).
   - render.go: pinch probe (raw pointer events) + grab lifecycle so
     the pair is exclusive (scroll sees nothing of the pair) and the
     survivor's finger keeps working as a scroll after the pinch.
   - state.go/logic.go/session.go/frame.go: app-local float font
     scale, content-point pin (buffer byte + offset from baseline,
     not a layout point, so rewrap keeps the same character under
     the center), restore/font pins, session persistence.
   - pinch_test.go, pinch_font_test.go, tag_identity_test.go,
     real_draw_probe_test.go: unit + real-Renderer/real-Router tests.

2. Soft keyboard must not shift content:
   - Root cause: gioui.org/app calls Router.RevealFocus on any frame
     the viewport shrinks (IME open under adjustResize) and
     synthesizes a pointer.Scroll nudge aimed at the focused field's
     stale pre-resize bounds; gesture.Scroll consumed it -> a 32 dp
     content jump.
   - Fix: main.go flags the shrink frame; render.go drains that one
     synthetic scroll for the gesture's tag before Update (scroll-
     range clamping cannot work: the router UNIONs ranges across
     frames). Finger scroll (pointer.Drag) and the flinger are
     untouched. reveal_focus_drain_test.go reproduces RevealFocus at
     the router level and verifies the drain + zero delta.

Also: tools/touchinject (platform-signed emulator multi-touch
injection harness + e2e script, adb has no two-finger input),
docs (spec 2.2 + development_plan 18-20), .gitignore, gofmt.
2026-08-23 09:00:51 -04:00

224 lines
9.0 KiB
Java

package touch.inject;
import android.app.Service;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.hardware.input.InputManager;
import android.os.IBinder;
import android.os.SystemClock;
import android.util.Log;
import android.view.MotionEvent;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Multi-touch gesture injector for the Pad emulator.
*
* Runs as a privileged system app (/system/priv-app) so that the
* signature|privileged INJECT_EVENTS permission is granted; it then injects
* real MotionEvents through InputManager — the exact path a physical touch
* screen takes — which is what the adb `input` applet cannot do (it has no
* multi-touch support).
*
* Usage:
* adb shell am startservice -n touch.inject/.Injector -e script "SCRIPT"
*
* SCRIPT is a whitespace-separated sequence of:
* down <finger> <x> <y> finger (1-based) touches at (x,y) px
* move <finger> <x> <y> finger moves to (x,y) px
* up <finger> finger lifts
* wait <ms> pause
*
* Progress is logged to logcat under the "TouchInject" tag.
*/
public class Injector extends Service {
public static final String TAG = "TouchInject";
/** Shell-triggerable entry point: works even while the app is stopped
* (the shell can broadcast to an explicit component).
* KNOWN FLAKE: the process is "cached" while the thread runs and the
* 1.5GB emulator OOM-killed it once mid-script (during a 200ms wait),
* losing the final UP. Service routing is NOT an alternative — Android
* 12+ blocks background startService from a receiver, and the AVD's
* locked bootloader blocks the system-app escalation. The harness
* therefore verifies "=== done" in logcat after every script and
* re-runs on failure; scripts should stay short. */
public static class ScriptReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
String script = intent.getStringExtra("script");
if (script == null) {
return;
}
InputManager im = (InputManager)
context.getSystemService(Context.INPUT_SERVICE);
new Thread(() -> execute(im, script), "TouchInject").start();
}
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
String script = intent != null ? intent.getStringExtra("script") : null;
if (script == null) {
stopSelf();
return START_NOT_STICKY;
}
InputManager im = (InputManager) getSystemService(INPUT_SERVICE);
new Thread(() -> execute(im, script), "TouchInject").start();
return START_NOT_STICKY;
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
private static void execute(InputManager im, String script) {
Log.i(TAG, "=== script: " + script);
// finger number (1-based) -> [x, y]; insertion order = pointer index.
LinkedHashMap<Integer, float[]> fingers = new LinkedHashMap<>();
long downTime = 0;
String[] toks = script.split("\\s+");
int i = 0;
while (i < toks.length) {
String cmd = toks[i++];
try {
switch (cmd) {
case "down": {
int f = Integer.parseInt(toks[i++]);
float x = Float.parseFloat(toks[i++]);
float y = Float.parseFloat(toks[i++]);
int action = fingers.isEmpty()
? MotionEvent.ACTION_DOWN
: MotionEvent.ACTION_POINTER_DOWN;
int idx = fingers.size();
fingers.put(f, new float[]{x, y});
inject(im, action, idx, fingers, downTime);
if (downTime == 0) {
downTime = SystemClock.uptimeMillis();
}
break;
}
case "move": {
int f = Integer.parseInt(toks[i++]);
float x = Float.parseFloat(toks[i++]);
float y = Float.parseFloat(toks[i++]);
float[] p = fingers.get(f);
if (p == null) {
Log.w(TAG, "move of unknown finger " + f + " (skipped)");
break;
}
p[0] = x;
p[1] = y;
// Generic ACTION_MOVE for all moves (1 or N pointers):
// InputFlinger accepts it and GioView treats every
// pointer as a MOVE.
inject(im, MotionEvent.ACTION_MOVE, 0, fingers, downTime);
break;
}
case "up": {
int f = Integer.parseInt(toks[i++]);
if (!fingers.containsKey(f)) {
Log.w(TAG, "up of unknown finger " + f + " (skipped)");
break;
}
boolean last = fingers.size() == 1;
int action = last
? MotionEvent.ACTION_UP
: MotionEvent.ACTION_POINTER_UP;
int idx = indexOf(fingers, f);
// The lifted pointer must still be part of the event.
inject(im, action, idx, fingers, downTime);
fingers.remove(f);
if (fingers.isEmpty()) {
downTime = 0;
}
break;
}
case "wait": {
int ms = Integer.parseInt(toks[i++]);
Thread.sleep(ms);
break;
}
default:
Log.e(TAG, "unknown command: " + cmd);
}
} catch (Exception e) {
Log.e(TAG, "script error at '" + cmd + "': " + e);
break;
}
}
Log.i(TAG, "=== done");
}
private static int indexOf(LinkedHashMap<Integer, float[]> m, int f) {
int idx = 0;
for (Integer k : m.keySet()) {
if (k == f) {
return idx;
}
idx++;
}
return 0;
}
// INJECT_INPUT_EVENT_MODE_WAIT_FOR_RESULT. The SDK stub jar omits the
// constant (and injectInputEvent itself), so both go through reflection;
// the method is a public API at runtime on the device.
private static final int INJECT_WAIT_FOR_RESULT = 1;
private static void inject(InputManager im, int action, int actionIndex,
Map<Integer, float[]> fingers, long downTime) {
int n = fingers.size();
int[] ids = new int[n];
MotionEvent.PointerCoords[] coords = new MotionEvent.PointerCoords[n];
int j = 0;
for (Map.Entry<Integer, float[]> e : fingers.entrySet()) {
ids[j] = e.getKey() - 1; // pointer id (0-based)
MotionEvent.PointerCoords c = new MotionEvent.PointerCoords();
c.x = e.getValue()[0];
c.y = e.getValue()[1];
c.pressure = 1f;
c.size = 1f;
// TOOL_TYPE_FINGER. The compile-time android.jar lacks
// PointerCoords.setToolType, but the device runtime (API 24+)
// has it, so call it reflectively.
try {
java.lang.reflect.Method stt = MotionEvent.PointerCoords.class
.getMethod("setToolType", int.class);
stt.invoke(c, 1);
} catch (Exception ignored) {
}
coords[j] = c;
j++;
}
long now = SystemClock.uptimeMillis();
int fullAction = action == MotionEvent.ACTION_MOVE
? action
: action | (actionIndex << MotionEvent.ACTION_POINTER_INDEX_SHIFT);
MotionEvent ev = MotionEvent.obtain(downTime, now, fullAction, n, ids, coords,
0 /*edgeFlags*/, 1f /*xPrecision*/, 1f /*yPrecision*/,
0 /*metaState*/, 0 /*deviceId*/,
0x4000003 /*SOURCE_TOUCH|SOURCE_CLASS_MASK*/, 0 /*displayId*/);
boolean ok = doInject(im, ev);
Log.i(TAG, String.format("inject action=%d idx=%d n=%d ids=%s ok=%b",
action, actionIndex, n, java.util.Arrays.toString(ids), ok));
ev.recycle();
}
private static boolean doInject(InputManager im, MotionEvent ev) {
try {
java.lang.reflect.Method m = InputManager.class.getMethod(
"injectInputEvent", android.view.InputEvent.class, int.class);
Object r = m.invoke(im, ev, INJECT_WAIT_FOR_RESULT);
return r instanceof Boolean && (Boolean) r;
} catch (Exception e) {
Throwable c = e.getCause() != null ? e.getCause() : e;
Log.e(TAG, "injectInputEvent failed: " + c);
return false;
}
}
}