Neither existing action can produce "hold stationary for LONG_PRESS, then move without lifting, then release": tap has no hold at all, and swipe X1 Y1 X2 Y2 MS interpolates motion across its whole duration starting at t=0, so a long swipe with a short first segment is still continuous motion throughout, never a hold followed by a drag. holddrag X1 Y1 X2 Y2 HOLD_MS MOVE_MS extends the same MotionEvent/injectInputEvent mechanism swipe already uses: DOWN, sleep HOLD_MS, then MOVE at the same fixed 10ms cadence swipe uses over MOVE_MS, then UP -- one continuous touch. Additive; existing commands unchanged. Verified against a real device (this repo has no unit test harness, so verification is driving a device, matching its existing posture): `ui-trace record --do "holddrag 300 1850 300 2050 600 300"` against ai-app-2's iris transcript screen produced a real long-press-then-drag selection (confirmed by the app's own logcat and a screenshot showing the resulting highlighted selection) that neither tap nor swipe could reach. javac --release 17 -Xlint:all -Werror clean. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
384 lines
18 KiB
Java
384 lines
18 KiB
Java
// The on-device half of ui-trace: samples the accessibility tree in a loop and
|
|
// prints one text frame per sample.
|
|
//
|
|
// This exists because `uiautomator dump` costs about two seconds per call --
|
|
// almost all of it starting a JVM and connecting to the accessibility service --
|
|
// which is half a hertz, so an animation happens entirely between two samples.
|
|
// Connecting once and then sampling in a loop is the whole trick; everything
|
|
// else here is bookkeeping around it.
|
|
//
|
|
// Runs under app_process as the shell user, the same way /system/bin/uiautomator
|
|
// does. Three pieces of the connection are hidden API and are reached by
|
|
// reflection: android.app.UiAutomationConnection, the (Looper, connection)
|
|
// constructor of UiAutomation, and its connect() method. There is no public way
|
|
// for a shell process to obtain a UiAutomation -- the public entry points all
|
|
// require an Instrumentation and therefore an app -- and this is the same path
|
|
// the platform's own uiautomator command takes.
|
|
|
|
import android.accessibilityservice.AccessibilityServiceInfo;
|
|
import android.app.UiAutomation;
|
|
import android.graphics.Rect;
|
|
import android.os.HandlerThread;
|
|
import android.os.Looper;
|
|
import android.os.SystemClock;
|
|
import android.view.InputDevice;
|
|
import android.view.MotionEvent;
|
|
import android.view.accessibility.AccessibilityNodeInfo;
|
|
import java.io.PrintWriter;
|
|
import java.lang.reflect.Constructor;
|
|
import java.util.ArrayList;
|
|
import java.util.List;
|
|
|
|
public final class UiTrace {
|
|
private static final int MAX_DEPTH = 60;
|
|
/** How long `tap LABEL` keeps looking before it decides the label is not there. */
|
|
private static final long FIND_TIMEOUT_MS = 3000;
|
|
|
|
private final UiAutomation automation;
|
|
private final PrintWriter out;
|
|
private final long origin = SystemClock.uptimeMillis();
|
|
|
|
private UiTrace(UiAutomation automation, PrintWriter out) {
|
|
this.automation = automation;
|
|
this.out = out;
|
|
}
|
|
|
|
// prepareMainLooper() is deprecated because an app never needs it -- the
|
|
// Android environment makes the main looper. This is not an app: app_process
|
|
// starts a bare VM, so there is no main looper and no supported way to ask
|
|
// for one. The framework needs it anyway (see below), so the deprecated call
|
|
// is the only route, and it is scoped to this method alone.
|
|
@SuppressWarnings("deprecation")
|
|
public static void main(String[] args) {
|
|
// The accessibility client builds a Handler on the main looper the first
|
|
// time a callback arrives, and a bare app_process has no main looper, so
|
|
// connecting from here died with an NPE inside the framework and the
|
|
// shell reported only "Killed". So the main thread prepares the looper
|
|
// and then does nothing but run it; the trace itself is a worker.
|
|
Looper.prepareMainLooper();
|
|
Thread worker = new Thread(() -> record(args), "ui-trace");
|
|
worker.start();
|
|
Looper.loop();
|
|
}
|
|
|
|
private static void record(String[] args) {
|
|
PrintWriter out = new PrintWriter(new java.io.BufferedWriter(new java.io.OutputStreamWriter(System.out), 1 << 16));
|
|
try {
|
|
long duration = 3000;
|
|
long interval = 0;
|
|
List<String[]> script = new ArrayList<>();
|
|
for (int i = 0; i < args.length; i++) {
|
|
switch (args[i]) {
|
|
case "--duration" -> duration = Long.parseLong(args[++i]);
|
|
case "--interval" -> interval = Long.parseLong(args[++i]);
|
|
case "--do" -> script.add(args[++i].trim().split("\\s+"));
|
|
default -> throw new IllegalArgumentException("unknown option: " + args[i]);
|
|
}
|
|
}
|
|
UiTrace trace = new UiTrace(connect(), out);
|
|
trace.run(duration, interval, script);
|
|
out.println("# done");
|
|
} catch (Throwable problem) {
|
|
out.println("# error " + problem);
|
|
for (StackTraceElement frame : problem.getStackTrace()) out.println("# at " + frame);
|
|
out.flush();
|
|
System.exit(1);
|
|
}
|
|
out.flush();
|
|
// The accessibility connection keeps non-daemon threads alive, so a
|
|
// returning main() would hang here rather than ending the trace.
|
|
System.exit(0);
|
|
}
|
|
|
|
private static UiAutomation connect() throws Exception {
|
|
HandlerThread thread = new HandlerThread("ui-trace");
|
|
thread.start();
|
|
Object connection = Class.forName("android.app.UiAutomationConnection")
|
|
.getDeclaredConstructor().newInstance();
|
|
Constructor<?> ctor = UiAutomation.class.getDeclaredConstructor(
|
|
Looper.class, Class.forName("android.app.IUiAutomationConnection"));
|
|
ctor.setAccessible(true);
|
|
UiAutomation automation = (UiAutomation) ctor.newInstance(thread.getLooper(), connection);
|
|
UiAutomation.class.getDeclaredMethod("connect").invoke(automation);
|
|
// Without these two the tree is the one a screen reader would use:
|
|
// decorative and off-stage nodes are dropped and view ids are withheld,
|
|
// and both are things a layout question is usually about.
|
|
AccessibilityServiceInfo info = automation.getServiceInfo();
|
|
info.flags |= AccessibilityServiceInfo.FLAG_INCLUDE_NOT_IMPORTANT_VIEWS
|
|
| AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS;
|
|
automation.setServiceInfo(info);
|
|
return automation;
|
|
}
|
|
|
|
private void run(long duration, long interval, List<String[]> script) {
|
|
// The actions run beside the sampling rather than between samples: an
|
|
// action that blocked the loop would leave a hole in the recording at
|
|
// exactly the moment being recorded.
|
|
Thread actor = new Thread(() -> {
|
|
for (String[] step : script) {
|
|
try {
|
|
perform(step);
|
|
} catch (Exception problem) {
|
|
// "# error", not a gentler word: the reader of this trace is usually a
|
|
// benchmark script, and an action that did not happen must fail the run
|
|
// rather than leave numbers that look measured. ui-trace's own `record`
|
|
// exits on this line.
|
|
synchronized (out) { out.println("# error action failed: " + problem); }
|
|
}
|
|
}
|
|
}, "ui-trace-actions");
|
|
actor.setDaemon(true);
|
|
actor.start();
|
|
|
|
long end = origin + duration;
|
|
int frame = 0;
|
|
while (SystemClock.uptimeMillis() < end) {
|
|
long at = SystemClock.uptimeMillis() - origin;
|
|
AccessibilityNodeInfo root = automation.getRootInActiveWindow();
|
|
synchronized (out) {
|
|
out.println("F " + frame + " " + at);
|
|
if (root == null) out.println("# no active window");
|
|
else emit(root, 0);
|
|
out.flush();
|
|
}
|
|
frame++;
|
|
if (interval > 0) {
|
|
long slack = interval - (SystemClock.uptimeMillis() - origin - at);
|
|
if (slack > 0) SystemClock.sleep(slack);
|
|
}
|
|
}
|
|
|
|
// Waited for rather than left behind. The actions run beside the sampling, so a script
|
|
// whose last step outlasts the recording used to have its outcome thrown away by the
|
|
// exit -- including the "# error" from a `tap` that never found its label, which is the
|
|
// one line the caller most needs. A run that could not press what it meant to press must
|
|
// not end quietly.
|
|
try {
|
|
actor.join(FIND_TIMEOUT_MS + 1000);
|
|
} catch (InterruptedException interrupted) {
|
|
Thread.currentThread().interrupt();
|
|
}
|
|
if (actor.isAlive()) {
|
|
synchronized (out) {
|
|
out.println("# error the recording ended before its actions did -- raise -d");
|
|
out.flush();
|
|
}
|
|
}
|
|
}
|
|
|
|
private void emit(AccessibilityNodeInfo node, int depth) {
|
|
if (node == null || depth > MAX_DEPTH) return;
|
|
Rect box = new Rect();
|
|
node.getBoundsInScreen(box);
|
|
// Tab-separated with the free text last, because a label may contain
|
|
// anything at all including the separator of a friendlier format.
|
|
StringBuilder line = new StringBuilder("N\t");
|
|
line.append(depth).append('\t')
|
|
.append(box.left).append('\t').append(box.top).append('\t')
|
|
.append(box.right).append('\t').append(box.bottom).append('\t')
|
|
.append(flags(node)).append('\t')
|
|
.append(shortName(node.getClassName())).append('\t')
|
|
.append(clean(node.getViewIdResourceName())).append('\t')
|
|
.append(clean(node.getText())).append('\t')
|
|
.append(clean(node.getContentDescription()));
|
|
out.println(line);
|
|
for (int i = 0; i < node.getChildCount(); i++) emit(node.getChild(i), depth + 1);
|
|
}
|
|
|
|
private static String flags(AccessibilityNodeInfo node) {
|
|
StringBuilder marks = new StringBuilder();
|
|
if (node.isClickable()) marks.append('c');
|
|
if (node.isScrollable()) marks.append('s');
|
|
if (node.isFocused()) marks.append('f');
|
|
if (!node.isEnabled()) marks.append('d');
|
|
if (!node.isVisibleToUser()) marks.append('h');
|
|
return marks.length() == 0 ? "-" : marks.toString();
|
|
}
|
|
|
|
private static String shortName(CharSequence name) {
|
|
String text = clean(name);
|
|
int dot = text.lastIndexOf('.');
|
|
return dot < 0 ? text : text.substring(dot + 1);
|
|
}
|
|
|
|
private static String clean(CharSequence value) {
|
|
if (value == null) return "";
|
|
return value.toString().replace('\t', ' ').replace('\n', '⏎').replace('\r', ' ');
|
|
}
|
|
|
|
private void perform(String[] step) throws Exception {
|
|
long at = SystemClock.uptimeMillis() - origin;
|
|
synchronized (out) { out.println("A " + at + "\t" + String.join(" ", step)); out.flush(); }
|
|
switch (step[0]) {
|
|
case "wait" -> SystemClock.sleep(Long.parseLong(step[1]));
|
|
case "tap" -> {
|
|
if (step.length == 3 && isNumber(step[1]) && isNumber(step[2])) {
|
|
tap(Integer.parseInt(step[1]), Integer.parseInt(step[2]));
|
|
} else {
|
|
tapLabel(join(step));
|
|
}
|
|
}
|
|
case "swipe" -> swipe(Integer.parseInt(step[1]), Integer.parseInt(step[2]),
|
|
Integer.parseInt(step[3]), Integer.parseInt(step[4]),
|
|
step.length > 5 ? Long.parseLong(step[5]) : 300);
|
|
case "holddrag" -> holdDrag(Integer.parseInt(step[1]), Integer.parseInt(step[2]),
|
|
Integer.parseInt(step[3]), Integer.parseInt(step[4]),
|
|
Long.parseLong(step[5]), Long.parseLong(step[6]));
|
|
default -> throw new IllegalArgumentException("unknown action: " + step[0]);
|
|
}
|
|
}
|
|
|
|
private static boolean isNumber(String value) {
|
|
if (value.isEmpty()) return false;
|
|
for (int i = 0; i < value.length(); i++) {
|
|
if (!Character.isDigit(value.charAt(i))) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** Everything after the verb, as one label, with any quotes the shell left on it removed. */
|
|
private static String join(String[] step) {
|
|
StringBuilder joined = new StringBuilder();
|
|
for (int i = 1; i < step.length; i++) {
|
|
if (i > 1) joined.append(' ');
|
|
joined.append(step[i]);
|
|
}
|
|
String label = joined.toString();
|
|
if (label.length() >= 2) {
|
|
char first = label.charAt(0), last = label.charAt(label.length() - 1);
|
|
if ((first == '\'' || first == '"') && first == last) {
|
|
label = label.substring(1, label.length() - 1);
|
|
}
|
|
}
|
|
return label;
|
|
}
|
|
|
|
/**
|
|
* Presses whatever is currently labelled `label`, wherever it is.
|
|
*
|
|
* This is the action to reach for, and `tap X Y` is the exception. A coordinate is a position
|
|
* measured once by hand: anything that moves the thing being pressed -- a control added to the
|
|
* row, a font size, a density, a different device -- makes the tap land on whatever now sits
|
|
* there, and the script then reports a number that was never measured, which reads exactly
|
|
* like a result. A name is what the control already carries for assistive technology, so it
|
|
* survives all of that and fails loudly when it genuinely is not there.
|
|
*
|
|
* The box is read from the tree at the moment of the gesture rather than from an earlier
|
|
* frame, because the screen the gesture lands on is the only one that decides where the mark
|
|
* is.
|
|
*/
|
|
private void tapLabel(String label) {
|
|
// Looked for repeatedly for a moment rather than once. Two things make a single look
|
|
// wrong: the accessibility connection has no window at all for the first frames after it
|
|
// is made, so an action at the very start of a trace found nothing to search; and a
|
|
// control revealed by the step before this one -- a dialog's button, a row that has just
|
|
// loaded -- arrives a frame or two later. The wait is bounded and the failure is still
|
|
// loud, which is the whole point: what must not happen is pressing the wrong thing.
|
|
List<AccessibilityNodeInfo> found = new ArrayList<>();
|
|
long giveUp = SystemClock.uptimeMillis() + FIND_TIMEOUT_MS;
|
|
while (true) {
|
|
AccessibilityNodeInfo root = automation.getRootInActiveWindow();
|
|
if (root != null) collect(root, label, 0, found);
|
|
if (!found.isEmpty() || SystemClock.uptimeMillis() >= giveUp) break;
|
|
SystemClock.sleep(50);
|
|
}
|
|
if (found.isEmpty()) {
|
|
throw new IllegalArgumentException("nothing on screen is labelled \"" + label
|
|
+ "\" (looked for " + FIND_TIMEOUT_MS + "ms)");
|
|
}
|
|
// The clickable one where there is a choice: a label and the padded control wrapping it
|
|
// both carry the text, and the control is the thing a finger is meant to find. Its centre
|
|
// is also where a ripple is drawn, so this presses what the reader would have pressed.
|
|
AccessibilityNodeInfo target = found.get(0);
|
|
for (AccessibilityNodeInfo candidate : found) {
|
|
if (candidate.isClickable()) { target = candidate; break; }
|
|
}
|
|
Rect box = new Rect();
|
|
target.getBoundsInScreen(box);
|
|
if (box.width() <= 0 || box.height() <= 0) {
|
|
throw new IllegalStateException("\"" + label + "\" has no size on screen");
|
|
}
|
|
synchronized (out) {
|
|
out.println("# tap \"" + label + "\" at " + box.centerX() + "," + box.centerY()
|
|
+ " in " + box.left + "," + box.top + "," + box.right + "," + box.bottom);
|
|
out.flush();
|
|
}
|
|
tap(box.centerX(), box.centerY());
|
|
}
|
|
|
|
/** Every visible node whose text or description is exactly `label`, in tree order. */
|
|
private static void collect(AccessibilityNodeInfo node, String label, int depth,
|
|
List<AccessibilityNodeInfo> found) {
|
|
if (node == null || depth > MAX_DEPTH) return;
|
|
if (node.isVisibleToUser()
|
|
&& (label.equals(clean(node.getText())) || label.equals(clean(node.getContentDescription())))) {
|
|
found.add(node);
|
|
}
|
|
for (int i = 0; i < node.getChildCount(); i++) collect(node.getChild(i), label, depth + 1, found);
|
|
}
|
|
|
|
private void send(int action, long down, int x, int y) {
|
|
MotionEvent event = MotionEvent.obtain(down, SystemClock.uptimeMillis(), action, x, y, 0);
|
|
event.setSource(InputDevice.SOURCE_TOUCHSCREEN);
|
|
automation.injectInputEvent(event, true);
|
|
event.recycle();
|
|
}
|
|
|
|
private void tap(int x, int y) {
|
|
long down = SystemClock.uptimeMillis();
|
|
send(MotionEvent.ACTION_DOWN, down, x, y);
|
|
SystemClock.sleep(60);
|
|
send(MotionEvent.ACTION_UP, down, x, y);
|
|
}
|
|
|
|
// Moves are emitted on a fixed 10ms cadence so the velocity tracker behind a
|
|
// fling sees a real gesture. `input swipe` interpolates over far fewer
|
|
// points, which is why it so often scrolls without ever flinging.
|
|
private void swipe(int x1, int y1, int x2, int y2, long millis) {
|
|
long down = SystemClock.uptimeMillis();
|
|
send(MotionEvent.ACTION_DOWN, down, x1, y1);
|
|
int steps = (int) Math.max(2, millis / 10);
|
|
for (int i = 1; i <= steps; i++) {
|
|
float part = (float) i / steps;
|
|
long due = down + (long) (millis * part);
|
|
long slack = due - SystemClock.uptimeMillis();
|
|
if (slack > 0) SystemClock.sleep(slack);
|
|
send(MotionEvent.ACTION_MOVE, down, Math.round(x1 + (x2 - x1) * part),
|
|
Math.round(y1 + (y2 - y1) * part));
|
|
}
|
|
send(MotionEvent.ACTION_UP, down, x2, y2);
|
|
}
|
|
|
|
/**
|
|
* A press held stationary for `holdMs`, then moved to `(x2, y2)` over `moveMs`, then
|
|
* released -- one continuous touch, never lifted between the hold and the move.
|
|
*
|
|
* Neither existing action can produce this. `tap` has no hold at all, and `swipe`
|
|
* interpolates motion across its *whole* duration starting at t=0, so a long `swipe` with a
|
|
* short first segment is still continuous motion throughout, never a hold followed by a
|
|
* drag. That gap matters for anything that decides pan-vs-select the way Android itself
|
|
* does -- a stationary press held past `LONG_PRESS` (500ms) starts a selection, which
|
|
* further drag then extends -- because neither of the other two actions can reach the
|
|
* "starts a selection" branch at all.
|
|
*
|
|
* The move phase reuses `swipe`'s own fixed 10ms cadence for the same reason: a velocity
|
|
* tracker needs a real sequence of points, not two.
|
|
*/
|
|
private void holdDrag(int x1, int y1, int x2, int y2, long holdMs, long moveMs) {
|
|
long down = SystemClock.uptimeMillis();
|
|
send(MotionEvent.ACTION_DOWN, down, x1, y1);
|
|
SystemClock.sleep(holdMs);
|
|
int steps = (int) Math.max(2, moveMs / 10);
|
|
long moveStart = SystemClock.uptimeMillis();
|
|
for (int i = 1; i <= steps; i++) {
|
|
float part = (float) i / steps;
|
|
long due = moveStart + (long) (moveMs * part);
|
|
long slack = due - SystemClock.uptimeMillis();
|
|
if (slack > 0) SystemClock.sleep(slack);
|
|
send(MotionEvent.ACTION_MOVE, down, Math.round(x1 + (x2 - x1) * part),
|
|
Math.round(y1 + (y2 - y1) * part));
|
|
}
|
|
send(MotionEvent.ACTION_UP, down, x2, y2);
|
|
}
|
|
}
|