Skip to content

Commit 6f221ce

Browse files
committed
feat: unify gesture planning and multi-touch execution
1 parent d3adea4 commit 6f221ce

105 files changed

Lines changed: 4306 additions & 1642 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CONTEXT.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@
4747
- Runner command traits: per-command-type classification for iOS/macOS runner lifecycle behavior, distinct from the public command surface and daemon command registry. The Swift runner traits classify interaction, read-only, and runner-lifecycle axes for XCTest execution; Swift resolves the alert command as read-only only for its `get` action. The TypeScript runner command traits classify daemon-side runner send/recovery policy such as read-only retry routing, readiness probes, and recent-healthy-mutation preflight skips; the TypeScript table is command-type keyed and currently classifies alert as read-only for daemon retry policy. Each side keeps one source of truth keyed by runner command type.
4848
- Coordinate-first resolved element activation: iOS/macOS runner interaction pattern where a selector or text query resolves the semantic `XCUIElement`, then activation uses the element's resolved center coordinate when a frame is available. This keeps target selection semantic while avoiding `XCUIElement.tap()` post-action element re-resolution after normal navigation. tvOS remains focus/remote-driven.
4949
- Interaction dispatch path: one concrete route an interaction command takes to the device (runtime selector/ref resolution, direct iOS selector, native ref via web clickRef, coordinate, maestro non-hittable fallback). Every path classifies every guarantee in the ADR 0011 registry.
50+
- Gesture plan: typed, platform-neutral normalization of one- or two-contact gesture intent into bounded pointer trajectories. Contact topology is separate from motion; two-contact intent remains pan/pinch/rotate/transform even when native injection shares one executor. See ADR 0013.
51+
- Multi-touch geometry: the internal initial span and angle plus centroid translation, scale, and rotation used to build both contact trajectories. Geometry is viewport-aware and fails early when the requested motion cannot fit; it is not a public tuning surface.
5052
- Guarantee cell: one (dispatch path, guarantee) entry in `src/contracts/interaction-guarantees.ts`, classified as runtime/runner/delegated/inapplicable/waived. Completeness is a compile error; honesty is gate-tested.
5153
- Owned waiver: a `gap:`-prefixed waived cell carrying a `trackingIssue` URL. Waivers are diffable debt with an owner, never folklore.
5254
- Parity table: golden JSON fixture under `contracts/fixtures/` consumed by both vitest and the runner's gated Swift tests, so a cross-language rule (e.g. tap-point policy) cannot drift silently. Change the rule only via the table.

android-multitouch-helper/src/main/java/com/callstack/agentdevice/multitouchhelper/MultiTouchInstrumentation.java

Lines changed: 234 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import android.view.InputDevice;
99
import android.view.MotionEvent;
1010
import java.nio.charset.StandardCharsets;
11+
import org.json.JSONArray;
1112
import org.json.JSONObject;
1213

1314
public final class MultiTouchInstrumentation extends Instrumentation {
@@ -72,13 +73,21 @@ private GestureSpec readSpec(Bundle arguments) throws Exception {
7273
&& !"transform".equals(kind)) {
7374
throw new IllegalArgumentException("Unsupported kind: " + kind);
7475
}
75-
int x = "swipe".equals(kind) ? payload.getInt("x1") : payload.getInt("x");
76-
int y = "swipe".equals(kind) ? payload.getInt("y1") : payload.getInt("y");
76+
boolean hasPlannedPointers = payload.has("pointers");
77+
int x = hasPlannedPointers
78+
? 0
79+
: ("swipe".equals(kind) ? payload.getInt("x1") : payload.getInt("x"));
80+
int y = hasPlannedPointers
81+
? 0
82+
: ("swipe".equals(kind) ? payload.getInt("y1") : payload.getInt("y"));
7783
int dx = payload.optInt("dx", 0);
7884
int dy = payload.optInt("dy", 0);
7985
int x2 = payload.optInt("x2", x + dx);
8086
int y2 = payload.optInt("y2", y + dy);
81-
int durationMs = clamp(payload.optInt("durationMs", 300), MIN_DURATION_MS, MAX_DURATION_MS);
87+
int requestedDurationMs = payload.optInt("durationMs", 300);
88+
int durationMs = hasPlannedPointers
89+
? requireInRange(requestedDurationMs, MIN_DURATION_MS, MAX_DURATION_MS, "durationMs")
90+
: clamp(requestedDurationMs, MIN_DURATION_MS, MAX_DURATION_MS);
8291
int radius = clamp(payload.optInt("radius", DEFAULT_RADIUS), MIN_RADIUS, MAX_RADIUS);
8392
double scale = payload.optDouble("scale", 1.0d);
8493
double degrees = payload.optDouble("degrees", 0.0d);
@@ -88,10 +97,17 @@ private GestureSpec readSpec(Bundle arguments) throws Exception {
8897
if (("rotate".equals(kind) || "transform".equals(kind)) && !isFinite(degrees)) {
8998
throw new IllegalArgumentException("Degrees must be finite");
9099
}
91-
return new GestureSpec(kind, x, y, dx, dy, x2, y2, durationMs, scale, degrees, radius);
100+
PlannedPointerPath[] plannedPointers = hasPlannedPointers
101+
? readPlannedPointers(payload.getJSONArray("pointers"), kind, durationMs)
102+
: null;
103+
return new GestureSpec(
104+
kind, x, y, dx, dy, x2, y2, durationMs, scale, degrees, radius, plannedPointers);
92105
}
93106

94107
private int injectGesture(GestureSpec spec) {
108+
if (spec.plannedPointers != null) {
109+
return injectPlannedGesture(spec);
110+
}
95111
if ("swipe".equals(spec.kind)) {
96112
return injectSinglePointerGesture(spec);
97113
}
@@ -160,6 +176,115 @@ private int injectGesture(GestureSpec spec) {
160176
}
161177
}
162178

179+
private int injectPlannedGesture(GestureSpec spec) {
180+
return spec.plannedPointers.length == 1
181+
? injectPlannedSinglePointerGesture(spec)
182+
: injectPlannedTwoPointerGesture(spec);
183+
}
184+
185+
private int injectPlannedSinglePointerGesture(GestureSpec spec) {
186+
UiAutomation automation = getUiAutomation();
187+
long downTime = SystemClock.uptimeMillis();
188+
long eventTime = downTime;
189+
PointerPair activePointer = plannedPointerPairAt(spec, 0);
190+
int count = 0;
191+
192+
try {
193+
injectScheduledEvent(
194+
automation,
195+
motionEvent(downTime, eventTime, MotionEvent.ACTION_DOWN, activePointer),
196+
true);
197+
count += 1;
198+
199+
int lastIndex = spec.plannedPointers[0].samples.length - 1;
200+
for (int index = 1; index <= lastIndex; index += 1) {
201+
activePointer = plannedPointerPairAt(spec, index);
202+
eventTime = downTime + spec.plannedPointers[0].samples[index].offsetMs;
203+
injectScheduledEvent(
204+
automation,
205+
motionEvent(downTime, eventTime, MotionEvent.ACTION_MOVE, activePointer),
206+
false);
207+
count += 1;
208+
}
209+
210+
injectScheduledEvent(
211+
automation,
212+
motionEvent(downTime, eventTime, MotionEvent.ACTION_UP, activePointer),
213+
true);
214+
count += 1;
215+
return count;
216+
} catch (RuntimeException error) {
217+
if (count > 0) {
218+
injectCancel(automation, downTime, eventTime + 16, activePointer);
219+
}
220+
throw error;
221+
}
222+
}
223+
224+
private int injectPlannedTwoPointerGesture(GestureSpec spec) {
225+
UiAutomation automation = getUiAutomation();
226+
long downTime = SystemClock.uptimeMillis();
227+
long eventTime = downTime;
228+
PointerPair start = plannedPointerPairAt(spec, 0);
229+
PointerPair activePointers = start.firstOnly();
230+
int count = 0;
231+
232+
try {
233+
injectScheduledEvent(
234+
automation,
235+
motionEvent(downTime, eventTime, MotionEvent.ACTION_DOWN, activePointers),
236+
true);
237+
count += 1;
238+
239+
long firstMoveOffset = spec.plannedPointers[0].samples[1].offsetMs;
240+
long secondPointerOffset = Math.max(1, Math.min(8, firstMoveOffset - 1));
241+
eventTime = downTime + secondPointerOffset;
242+
injectScheduledEvent(
243+
automation,
244+
motionEvent(
245+
downTime,
246+
eventTime,
247+
MotionEvent.ACTION_POINTER_DOWN | (1 << MotionEvent.ACTION_POINTER_INDEX_SHIFT),
248+
start),
249+
true);
250+
count += 1;
251+
activePointers = start;
252+
253+
int lastIndex = spec.plannedPointers[0].samples.length - 1;
254+
for (int index = 1; index <= lastIndex; index += 1) {
255+
activePointers = plannedPointerPairAt(spec, index);
256+
eventTime = downTime + spec.plannedPointers[0].samples[index].offsetMs;
257+
injectScheduledEvent(
258+
automation,
259+
motionEvent(downTime, eventTime, MotionEvent.ACTION_MOVE, activePointers),
260+
false);
261+
count += 1;
262+
}
263+
264+
injectScheduledEvent(
265+
automation,
266+
motionEvent(
267+
downTime,
268+
eventTime,
269+
MotionEvent.ACTION_POINTER_UP | (1 << MotionEvent.ACTION_POINTER_INDEX_SHIFT),
270+
activePointers),
271+
true);
272+
count += 1;
273+
activePointers = activePointers.firstOnly();
274+
injectScheduledEvent(
275+
automation,
276+
motionEvent(downTime, eventTime + 8, MotionEvent.ACTION_UP, activePointers),
277+
true);
278+
count += 1;
279+
return count;
280+
} catch (RuntimeException error) {
281+
if (count > 0) {
282+
injectCancel(automation, downTime, eventTime + 16, activePointers);
283+
}
284+
throw error;
285+
}
286+
}
287+
163288
private int injectSinglePointerGesture(GestureSpec spec) {
164289
UiAutomation automation = getUiAutomation();
165290
long downTime = SystemClock.uptimeMillis();
@@ -314,10 +439,89 @@ private static PointerPair pointerPairAt(GestureSpec spec, double t) {
314439
});
315440
}
316441

442+
private static PlannedPointerPath[] readPlannedPointers(
443+
JSONArray pointers, String kind, int durationMs) throws Exception {
444+
int expectedCount = "swipe".equals(kind) ? 1 : 2;
445+
if (pointers.length() != expectedCount) {
446+
throw new IllegalArgumentException(
447+
"Planned " + kind + " gesture requires exactly " + expectedCount + " pointer paths");
448+
}
449+
PlannedPointerPath[] result = new PlannedPointerPath[expectedCount];
450+
for (int pointerIndex = 0; pointerIndex < expectedCount; pointerIndex += 1) {
451+
JSONObject pointer = pointers.getJSONObject(pointerIndex);
452+
int pointerId = pointer.getInt("pointerId");
453+
if (pointerId != pointerIndex) {
454+
throw new IllegalArgumentException("Planned pointer ids must be ordered from 0");
455+
}
456+
JSONArray samples = pointer.getJSONArray("samples");
457+
if (samples.length() < 2) {
458+
throw new IllegalArgumentException("Planned pointer path requires at least two samples");
459+
}
460+
PlannedPointerSample[] parsedSamples = new PlannedPointerSample[samples.length()];
461+
long previousOffsetMs = -1;
462+
for (int sampleIndex = 0; sampleIndex < samples.length(); sampleIndex += 1) {
463+
JSONObject sample = samples.getJSONObject(sampleIndex);
464+
double rawOffsetMs = sample.getDouble("offsetMs");
465+
double x = sample.getDouble("x");
466+
double y = sample.getDouble("y");
467+
if (!isFinite(rawOffsetMs) || rawOffsetMs != Math.rint(rawOffsetMs)) {
468+
throw new IllegalArgumentException("Planned sample offsetMs must be a finite integer");
469+
}
470+
if (!isFinite(x) || !isFinite(y)) {
471+
throw new IllegalArgumentException("Planned sample coordinates must be finite");
472+
}
473+
long offsetMs = (long) rawOffsetMs;
474+
if (offsetMs <= previousOffsetMs) {
475+
throw new IllegalArgumentException("Planned sample offsets must be strictly increasing");
476+
}
477+
parsedSamples[sampleIndex] = new PlannedPointerSample(offsetMs, (float) x, (float) y);
478+
previousOffsetMs = offsetMs;
479+
}
480+
if (parsedSamples[0].offsetMs != 0
481+
|| parsedSamples[parsedSamples.length - 1].offsetMs != durationMs) {
482+
throw new IllegalArgumentException(
483+
"Planned pointer path must start at 0 and end at durationMs");
484+
}
485+
result[pointerIndex] = new PlannedPointerPath(pointerId, parsedSamples);
486+
}
487+
if (expectedCount == 2) {
488+
PlannedPointerSample[] first = result[0].samples;
489+
PlannedPointerSample[] second = result[1].samples;
490+
if (first.length != second.length) {
491+
throw new IllegalArgumentException("Planned pointer paths must have matching samples");
492+
}
493+
for (int index = 0; index < first.length; index += 1) {
494+
if (first[index].offsetMs != second[index].offsetMs) {
495+
throw new IllegalArgumentException("Planned pointer sample offsets must match");
496+
}
497+
}
498+
}
499+
return result;
500+
}
501+
502+
private static PointerPair plannedPointerPairAt(GestureSpec spec, int sampleIndex) {
503+
int pointerCount = spec.plannedPointers.length;
504+
float[] x = new float[pointerCount];
505+
float[] y = new float[pointerCount];
506+
for (int pointerIndex = 0; pointerIndex < pointerCount; pointerIndex += 1) {
507+
PlannedPointerSample sample = spec.plannedPointers[pointerIndex].samples[sampleIndex];
508+
x[pointerIndex] = sample.x;
509+
y[pointerIndex] = sample.y;
510+
}
511+
return new PointerPair(x, y);
512+
}
513+
317514
private static int clamp(int value, int min, int max) {
318515
return Math.min(Math.max(value, min), max);
319516
}
320517

518+
private static int requireInRange(int value, int min, int max, String field) {
519+
if (value < min || value > max) {
520+
throw new IllegalArgumentException(field + " must be between " + min + " and " + max);
521+
}
522+
return value;
523+
}
524+
321525
private static boolean isFinite(double value) {
322526
return !Double.isNaN(value) && !Double.isInfinite(value);
323527
}
@@ -334,6 +538,7 @@ private static final class GestureSpec {
334538
final double scale;
335539
final double degrees;
336540
final int radius;
541+
final PlannedPointerPath[] plannedPointers;
337542

338543
GestureSpec(
339544
String kind,
@@ -346,7 +551,8 @@ private static final class GestureSpec {
346551
int durationMs,
347552
double scale,
348553
double degrees,
349-
int radius) {
554+
int radius,
555+
PlannedPointerPath[] plannedPointers) {
350556
this.kind = kind;
351557
this.x = x;
352558
this.y = y;
@@ -358,6 +564,29 @@ private static final class GestureSpec {
358564
this.scale = scale;
359565
this.degrees = degrees;
360566
this.radius = radius;
567+
this.plannedPointers = plannedPointers;
568+
}
569+
}
570+
571+
private static final class PlannedPointerPath {
572+
final int pointerId;
573+
final PlannedPointerSample[] samples;
574+
575+
PlannedPointerPath(int pointerId, PlannedPointerSample[] samples) {
576+
this.pointerId = pointerId;
577+
this.samples = samples;
578+
}
579+
}
580+
581+
private static final class PlannedPointerSample {
582+
final long offsetMs;
583+
final float x;
584+
final float y;
585+
586+
PlannedPointerSample(long offsetMs, float x, float y) {
587+
this.offsetMs = offsetMs;
588+
this.x = x;
589+
this.y = y;
361590
}
362591
}
363592

apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#import "RunnerSynthesizedGesture.h"
22

33
#import <CoreGraphics/CoreGraphics.h>
4+
#import <float.h>
45
#import <math.h>
56
#import <objc/message.h>
67

@@ -13,6 +14,12 @@
1314
typedef void (*RunnerMsgSendSetInteger)(id, SEL, NSInteger);
1415
typedef BOOL (*RunnerMsgSendSynthesize)(id, SEL, NSError **);
1516

17+
static double RunnerStagedComponentProgress(double progress, int index, int count) {
18+
double start = (double)index / (double)MAX(count, 1);
19+
double end = (double)(index + 1) / (double)MAX(count, 1);
20+
return MIN(MAX((progress - start) / (end - start), 0.0), 1.0);
21+
}
22+
1623
typedef struct {
1724
Class recordClass;
1825
Class pathClass;
@@ -538,16 +545,30 @@ static CGPoint RunnerPointerPointAt(
538545
double t,
539546
double side
540547
) {
541-
double centerX = x + dx * t;
542-
double centerY = y + dy * t;
548+
BOOL translates = fabs(dx) > DBL_EPSILON || fabs(dy) > DBL_EPSILON;
549+
BOOL scales = fabs(scale - 1.0) > DBL_EPSILON;
550+
BOOL rotates = fabs(degrees) > DBL_EPSILON;
551+
int componentCount = (translates ? 1 : 0) + (scales ? 1 : 0) + (rotates ? 1 : 0);
552+
int componentIndex = 0;
553+
double translationProgress = translates
554+
? RunnerStagedComponentProgress(t, componentIndex++, componentCount)
555+
: 0.0;
556+
double scaleProgress = scales
557+
? RunnerStagedComponentProgress(t, componentIndex++, componentCount)
558+
: 0.0;
559+
double rotationProgress = rotates
560+
? RunnerStagedComponentProgress(t, componentIndex++, componentCount)
561+
: 0.0;
562+
double centerX = x + dx * translationProgress;
563+
double centerY = y + dy * translationProgress;
543564
double startRadius = baseRadius / MAX(scale, 1.0);
544565
double endRadius = baseRadius;
545566
if (scale < 1.0) {
546567
startRadius = baseRadius;
547568
endRadius = baseRadius * scale;
548569
}
549-
double radius = startRadius + (endRadius - startRadius) * t;
550-
double angle = (-M_PI_2) + (degrees * M_PI / 180.0) * t;
570+
double radius = startRadius + (endRadius - startRadius) * scaleProgress;
571+
double angle = (-M_PI_2) + (degrees * M_PI / 180.0) * rotationProgress;
551572
return CGPointMake(centerX + cos(angle) * radius * side, centerY + sin(angle) * radius * side);
552573
}
553574

0 commit comments

Comments
 (0)