Skip to content

Commit c72cf0e

Browse files
authored
fix: clarify Android gesture transform behavior (#584)
* fix: clarify Android gesture transform behavior * fix: stabilize Android transform injection
1 parent 93b04b2 commit c72cf0e

9 files changed

Lines changed: 164 additions & 49 deletions

File tree

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

Lines changed: 62 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ public final class MultiTouchInstrumentation extends Instrumentation {
1818
private static final int MAX_RADIUS = 1200;
1919
private static final int MIN_DURATION_MS = 16;
2020
private static final int MAX_DURATION_MS = 10_000;
21+
private static final int MOVE_FRAME_INTERVAL_MS = 16;
2122
private Bundle arguments;
2223

2324
@Override
@@ -91,57 +92,82 @@ private int injectGesture(GestureSpec spec) {
9192
long eventTime = downTime;
9293
PointerPair start = pointerPairAt(spec, 0);
9394
PointerPair end = pointerPairAt(spec, 1);
95+
PointerPair activePointers = start.firstOnly();
9496
int count = 0;
9597

96-
inject(
97-
automation,
98-
motionEvent(downTime, eventTime, MotionEvent.ACTION_DOWN, start.firstOnly()));
99-
count += 1;
100-
eventTime += 8;
101-
inject(
102-
automation,
103-
motionEvent(
104-
downTime,
105-
eventTime,
106-
MotionEvent.ACTION_POINTER_DOWN | (1 << MotionEvent.ACTION_POINTER_INDEX_SHIFT),
107-
start));
108-
count += 1;
98+
try {
99+
inject(
100+
automation,
101+
motionEvent(downTime, eventTime, MotionEvent.ACTION_DOWN, activePointers),
102+
true);
103+
count += 1;
104+
eventTime += 8;
105+
inject(
106+
automation,
107+
motionEvent(
108+
downTime,
109+
eventTime,
110+
MotionEvent.ACTION_POINTER_DOWN | (1 << MotionEvent.ACTION_POINTER_INDEX_SHIFT),
111+
start),
112+
true);
113+
count += 1;
114+
activePointers = start;
115+
116+
int frameCount =
117+
Math.max(3, Math.round(spec.durationMs / (float) MOVE_FRAME_INTERVAL_MS));
118+
for (int index = 1; index < frameCount; index += 1) {
119+
double t = (double) index / (double) frameCount;
120+
PointerPair frame = pointerPairAt(spec, t);
121+
eventTime = downTime + Math.round(spec.durationMs * t);
122+
inject(automation, motionEvent(downTime, eventTime, MotionEvent.ACTION_MOVE, frame), false);
123+
count += 1;
124+
activePointers = frame;
125+
}
109126

110-
int frameCount = Math.max(3, Math.round(spec.durationMs / 16.0f));
111-
for (int index = 1; index < frameCount; index += 1) {
112-
double t = (double) index / (double) frameCount;
113-
PointerPair frame = pointerPairAt(spec, t);
114-
eventTime = downTime + Math.round(spec.durationMs * t);
115-
inject(automation, motionEvent(downTime, eventTime, MotionEvent.ACTION_MOVE, frame));
127+
eventTime = downTime + spec.durationMs;
128+
inject(
129+
automation,
130+
motionEvent(
131+
downTime,
132+
eventTime,
133+
MotionEvent.ACTION_POINTER_UP | (1 << MotionEvent.ACTION_POINTER_INDEX_SHIFT),
134+
end),
135+
true);
136+
count += 1;
137+
activePointers = end.firstOnly();
138+
inject(
139+
automation,
140+
motionEvent(downTime, eventTime + 8, MotionEvent.ACTION_UP, activePointers),
141+
true);
116142
count += 1;
143+
return count;
144+
} catch (RuntimeException error) {
145+
if (count > 0) {
146+
injectCancel(automation, downTime, eventTime + 16, activePointers);
147+
}
148+
throw error;
117149
}
118-
119-
eventTime = downTime + spec.durationMs;
120-
inject(
121-
automation,
122-
motionEvent(
123-
downTime,
124-
eventTime,
125-
MotionEvent.ACTION_POINTER_UP | (1 << MotionEvent.ACTION_POINTER_INDEX_SHIFT),
126-
end));
127-
count += 1;
128-
inject(
129-
automation,
130-
motionEvent(downTime, eventTime + 8, MotionEvent.ACTION_UP, end.firstOnly()));
131-
count += 1;
132-
return count;
133150
}
134151

135-
private static void inject(UiAutomation automation, MotionEvent event) {
152+
private static void inject(UiAutomation automation, MotionEvent event, boolean waitForDispatch) {
136153
try {
137-
if (!automation.injectInputEvent(event, true)) {
154+
if (!automation.injectInputEvent(event, waitForDispatch)) {
138155
throw new IllegalStateException("injectInputEvent returned false");
139156
}
140157
} finally {
141158
event.recycle();
142159
}
143160
}
144161

162+
private static void injectCancel(
163+
UiAutomation automation, long downTime, long eventTime, PointerPair pair) {
164+
try {
165+
inject(automation, motionEvent(downTime, eventTime, MotionEvent.ACTION_CANCEL, pair), true);
166+
} catch (RuntimeException ignored) {
167+
// Best-effort cleanup; preserve the original injection failure.
168+
}
169+
}
170+
145171
private static MotionEvent motionEvent(long downTime, long eventTime, int action, PointerPair pair) {
146172
MotionEvent.PointerProperties[] properties =
147173
new MotionEvent.PointerProperties[pair.pointerCount];

examples/test-app/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,9 @@ These run the `.ad` replay suite in `examples/test-app/replays`.
9191
`gesture-lab.ad` verifies `gesture pan`, `gesture fling`, `gesture pinch`, and
9292
`gesture rotate` against the gesture metrics rendered by the Home screen on iOS
9393
and Android. Android and iOS simulator sessions also support `gesture transform`
94-
for a combined pan/zoom/rotate gesture.
94+
for a combined pan/zoom/rotate gesture. On Android, treat combined transform
95+
assertions as qualitative because recognizers can report non-exact centroid,
96+
scale, and rotation values for one simultaneous two-finger gesture.
9597

9698
To target a specific iOS simulator or an installed Expo development build, run the
9799
underlying command directly so global flags stay before replay inputs:

examples/test-app/src/screens/GestureLab.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,23 @@ export function GestureLab() {
6060
const pinchRef = useRef(null);
6161
const rotationRef = useRef(null);
6262
const flingRefs = [flingLeftRef, flingRightRef, flingUpRef, flingDownRef];
63+
const activeTransformHandlerTagsRef = useRef(new Set<number>());
6364

6465
function updateTransform(nextTransform: TransformState) {
6566
transformRef.current = nextTransform;
6667
setTransform(nextTransform);
6768
}
6869

69-
function beginTransformGesture() {
70-
gestureStartRef.current = transformRef.current;
70+
function beginTransformGesture(handlerTag: number) {
71+
const activeHandlerTags = activeTransformHandlerTagsRef.current;
72+
if (activeHandlerTags.size === 0) {
73+
gestureStartRef.current = transformRef.current;
74+
}
75+
activeHandlerTags.add(handlerTag);
76+
}
77+
78+
function endTransformGesture(handlerTag: number) {
79+
activeTransformHandlerTagsRef.current.delete(handlerTag);
7180
}
7281

7382
function handlePan(event: PanGestureHandlerGestureEvent) {
@@ -102,7 +111,16 @@ export function GestureLab() {
102111
| RotationGestureHandlerStateChangeEvent,
103112
) {
104113
if (event.nativeEvent.state === State.BEGAN) {
105-
beginTransformGesture();
114+
beginTransformGesture(event.nativeEvent.handlerTag);
115+
return;
116+
}
117+
118+
if (
119+
event.nativeEvent.state === State.END ||
120+
event.nativeEvent.state === State.FAILED ||
121+
event.nativeEvent.state === State.CANCELLED
122+
) {
123+
endTransformGesture(event.nativeEvent.handlerTag);
106124
}
107125
}
108126

@@ -163,6 +181,7 @@ export function GestureLab() {
163181
simultaneousHandlers={[panRef, rotationRef, ...flingRefs]}
164182
>
165183
<PanGestureHandler
184+
avgTouches
166185
minDist={4}
167186
onGestureEvent={handlePan}
168187
onHandlerStateChange={handleTransformStateChange}

src/platforms/android/__tests__/multitouch-helper.test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,11 @@ test('parseAndroidMultiTouchHelperOutput returns final instrumentation gesture m
5959

6060
test('runAndroidMultiTouchHelperGesture encodes protocol payload for instrumentation', async () => {
6161
let capturedArgs: string[] | undefined;
62+
let capturedOptions: Parameters<AndroidAdbExecutor>[1];
6263
const result = await runAndroidMultiTouchHelperGesture({
63-
adb: async (args) => {
64+
adb: async (args, options) => {
6465
capturedArgs = args;
66+
capturedOptions = options;
6567
return {
6668
exitCode: 0,
6769
stdout: [resultRecord({ ok: 'true', kind: 'rotate' }), 'INSTRUMENTATION_CODE: 0'].join(
@@ -96,6 +98,41 @@ test('runAndroidMultiTouchHelperGesture encodes protocol payload for instrumenta
9698
durationMs: 250,
9799
});
98100
assert.equal(capturedArgs.at(-1), manifest.instrumentationRunner);
101+
assert.equal(capturedOptions?.timeoutMs, 45_000);
102+
});
103+
104+
test('parseAndroidMultiTouchHelperOutput distinguishes missing final results', () => {
105+
assert.throws(() => parseAndroidMultiTouchHelperOutput('INSTRUMENTATION_CODE: 0'), {
106+
code: 'ANDROID_MULTITOUCH_HELPER_NO_FINAL_RESULT',
107+
message: 'Android multi-touch helper did not return a final result',
108+
});
109+
});
110+
111+
test('runAndroidMultiTouchHelperGesture preserves helper failure messages', async () => {
112+
await assert.rejects(
113+
() =>
114+
runAndroidMultiTouchHelperGesture({
115+
adb: async () => ({
116+
exitCode: 1,
117+
stdout: [
118+
resultRecord({
119+
ok: 'false',
120+
errorType: 'java.lang.IllegalStateException',
121+
message: 'injectInputEvent returned false',
122+
}),
123+
'INSTRUMENTATION_CODE: 1',
124+
].join('\n'),
125+
stderr: '',
126+
}),
127+
request: { kind: 'pinch', x: 100, y: 200, scale: 1.5, radius: 120, durationMs: 250 },
128+
packageName: manifest.packageName,
129+
instrumentationRunner: manifest.instrumentationRunner,
130+
}),
131+
{
132+
code: 'COMMAND_FAILED',
133+
message: 'injectInputEvent returned false',
134+
},
135+
);
99136
});
100137

101138
test('pinchAndroid, rotateGestureAndroid, and transformGestureAndroid prefer provider-native touch injection', async () => {

src/platforms/android/multitouch-helper.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,14 @@ const ANDROID_MULTITOUCH_HELPER_RUNNER =
2222
'com.callstack.agentdevice.multitouchhelper/.MultiTouchInstrumentation';
2323
const ANDROID_MULTITOUCH_HELPER_PROTOCOL = 'android-multitouch-helper-v1';
2424
const ANDROID_MULTITOUCH_HELPER_INSTALL_TIMEOUT_MS = 30_000;
25-
const ANDROID_MULTITOUCH_HELPER_GESTURE_TIMEOUT_MS = 15_000;
25+
const ANDROID_MULTITOUCH_HELPER_GESTURE_TIMEOUT_MS = 45_000;
2626
const ANDROID_MULTITOUCH_HELPER_DEFAULT_DURATION_MS = 300;
2727
const ANDROID_MULTITOUCH_HELPER_DEFAULT_RADIUS = 160;
2828
const ANDROID_MULTITOUCH_HELPER_ROTATE_MAX_DEGREES_PER_FRAME = 3;
2929
const ANDROID_MULTITOUCH_HELPER_ROTATE_FRAME_INTERVAL_MS = 16;
3030
const ANDROID_MULTITOUCH_HELPER_ROTATE_MAX_DURATION_MS = 2_400;
31+
const ANDROID_MULTITOUCH_HELPER_NO_FINAL_RESULT = 'ANDROID_MULTITOUCH_HELPER_NO_FINAL_RESULT';
32+
const ANDROID_MULTITOUCH_HELPER_REPORTED_FAILURE = 'ANDROID_MULTITOUCH_HELPER_REPORTED_FAILURE';
3133

3234
type AndroidMultiTouchHelperManifest = {
3335
name: 'android-multitouch-helper';
@@ -309,6 +311,14 @@ export async function runAndroidMultiTouchHelperGesture(options: {
309311
try {
310312
output = parseAndroidMultiTouchHelperOutput(`${result.stdout}\n${result.stderr}`);
311313
} catch (error) {
314+
if (error instanceof AppError) {
315+
if (error.code === ANDROID_MULTITOUCH_HELPER_REPORTED_FAILURE) {
316+
throw new AppError('COMMAND_FAILED', error.message, error.details, error);
317+
}
318+
if (error.code !== ANDROID_MULTITOUCH_HELPER_NO_FINAL_RESULT) {
319+
throw error;
320+
}
321+
}
312322
throw new AppError(
313323
'COMMAND_FAILED',
314324
result.exitCode === 0
@@ -339,15 +349,19 @@ export function parseAndroidMultiTouchHelperOutput(output: string): Record<strin
339349
);
340350
if (!finalResult) {
341351
throw new AppError(
342-
'COMMAND_FAILED',
352+
ANDROID_MULTITOUCH_HELPER_NO_FINAL_RESULT,
343353
'Android multi-touch helper did not return a final result',
344354
);
345355
}
346356
if (finalResult.ok !== 'true') {
347-
throw new AppError('COMMAND_FAILED', readHelperErrorMessage(finalResult), {
348-
errorType: finalResult.errorType,
349-
helper: finalResult,
350-
});
357+
throw new AppError(
358+
ANDROID_MULTITOUCH_HELPER_REPORTED_FAILURE,
359+
readHelperErrorMessage(finalResult),
360+
{
361+
errorType: finalResult.errorType,
362+
helper: finalResult,
363+
},
364+
);
351365
}
352366
return {
353367
kind: finalResult.kind,

src/utils/__tests__/args.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -948,6 +948,9 @@ test('usageForCommand resolves workflow help topic', () => {
948948
assert.match(help, /wait for a concrete result before returning to chat\/form state/);
949949
assert.match(help, /choose a point near the center of the intended app-owned target/);
950950
assert.match(help, /Avoid screen edges, tab bars, navigation bars, and home indicators/);
951+
assert.match(help, /Android transform injects a geometric two-finger path/);
952+
assert.match(help, /verify qualitative state such as "pan changed yes"/);
953+
assert.match(help, /prefer isolated gesture pan, gesture pinch, or gesture rotate/);
951954
assert.match(help, /longpress accepts coordinates, @refs, or selectors/);
952955
assert.match(help, /use help react-native for Metro\/Fast Refresh/);
953956
assert.match(help, /iOS Allow Paste prompt cannot be exercised under XCUITest/);

src/utils/command-schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,8 @@ Navigation and gestures:
324324
agent-device gesture rotate 35 200 420
325325
agent-device gesture transform 200 420 80 -40 2 35 700
326326
iOS simulator transform uses XCTest gesture primitives; verify app metrics instead of assuming requested degrees map exactly to recognizer output.
327+
Android transform injects a geometric two-finger path; app recognizers may report non-exact pan/scale/rotation. For Android combined transforms, verify qualitative state such as "pan changed yes" / "pinch changed yes" / "rotate changed yes" unless the app explicitly promises exact centroid metrics.
328+
If Android needs exact app-state values, prefer isolated gesture pan, gesture pinch, or gesture rotate commands over one combined transform.
327329
328330
Validation and evidence:
329331
Nearby mutation diff: agent-device diff snapshot -i.

test/skillgym/suites/agent-device-smoke-suite.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1530,14 +1530,25 @@ const SKILL_GUIDANCE_CASES: Case[] = [
15301530
'Zoom scale is 2',
15311531
'Rotation is 35 degrees',
15321532
'Duration is 700ms',
1533+
'After the command, verify Android changed qualitatively instead of asserting exact x, y, scale, or rotate values',
1534+
],
1535+
task: 'Plan the direct agent-device command for the combined pan, zoom, and rotate gesture, then verify qualitative state.',
1536+
outputs: [
1537+
plannedCommand('gesture transform'),
1538+
/200\s+420\s+80\s+-40\s+2\s+35\s+700/i,
1539+
plannedCommand('wait'),
1540+
/pan changed yes/i,
1541+
/pinch changed yes/i,
1542+
/rotate changed yes/i,
15331543
],
1534-
task: 'Plan the direct agent-device command for the combined pan, zoom, and rotate gesture.',
1535-
outputs: [plannedCommand('gesture transform'), /200\s+420\s+80\s+-40\s+2\s+35\s+700/i],
15361544
forbiddenOutputs: [
15371545
plannedCommand('gesture pan'),
15381546
plannedCommand('gesture pinch'),
15391547
plannedCommand('gesture rotate'),
15401548
plannedCommand('compose-gestures'),
1549+
/wait\s+["']?x\s/i,
1550+
/wait\s+["']?scale\s/i,
1551+
/wait\s+["']?rotate\s+\d/i,
15411552
],
15421553
}),
15431554
makeCase({

website/docs/docs/commands.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,7 @@ On iOS, swipe duration is clamped to a safe range (`16..60ms`) to avoid longpres
281281
`gesture rotate` accepts `degrees [x] [y] [velocity]`; the degree sign controls direction and velocity controls speed.
282282
`gesture transform` accepts `x y dx dy scale degrees [durationMs]` for one combined pan/zoom/rotate gesture on Android and iOS simulators.
283283
On iOS simulators it is implemented with XCTest gesture primitives, so verify app-level metrics instead of assuming the requested degrees map exactly to recognizer output.
284+
On Android, `gesture transform` injects a geometric two-finger path. App recognizers may report non-exact pan, scale, and rotation values, so verify qualitative state such as `pan changed yes`, `pinch changed yes`, and `rotate changed yes` unless the app explicitly promises exact centroid metrics. If exact app-state values matter, prefer isolated `gesture pan`, `gesture pinch`, or `gesture rotate` commands.
284285
`scroll` accepts either a relative amount (`0.5` means roughly half of the viewport on that axis) or `--pixels <n>` for a fixed-distance gesture. Large distances are clamped to the usable drag band so the gesture stays reliable across Android, iOS, and macOS.
285286
Default snapshot text output is visible-first, so off-screen interactive content is summarized instead of shown as tappable refs.
286287
When a target only appears in an off-screen summary, use `scroll <direction>` and then take a fresh `snapshot -i`. For repeated checks, a small shell loop is enough:

0 commit comments

Comments
 (0)