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.
224 lines
9.0 KiB
Java
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;
|
|
}
|
|
}
|
|
}
|