Skip to content

Commit 004fb38

Browse files
committed
fix: stabilize Android transform injection
1 parent 5f75b64 commit 004fb38

4 files changed

Lines changed: 121 additions & 44 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/src/screens/GestureLab.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export function GestureLab() {
6969

7070
function beginTransformGesture(handlerTag: number) {
7171
const activeHandlerTags = activeTransformHandlerTagsRef.current;
72-
if (!activeHandlerTags.has(handlerTag) && activeHandlerTags.size === 0) {
72+
if (activeHandlerTags.size === 0) {
7373
gestureStartRef.current = transformRef.current;
7474
}
7575
activeHandlerTags.add(handlerTag);

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,

0 commit comments

Comments
 (0)