Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,10 @@ jobs:
yes | "$SDKMANAGER" --licenses >/dev/null
"$SDKMANAGER" "platforms;android-36" "build-tools;36.0.0"

- name: Package npm-bundled Android snapshot helper
run: pnpm package:android-snapshot-helper:npm
- name: Package npm-bundled Android helpers
run: |
pnpm package:android-snapshot-helper:npm
pnpm package:android-multitouch-helper:npm

- name: Run Android smoke checks
uses: reactivecircus/android-emulator-runner@b530d96654c385303d652368551fb075bc2f0b6b # v2.35.0
Expand Down
10 changes: 6 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,8 @@ jobs:
- name: Run typecheck
run: pnpm typecheck

android-snapshot-helper:
name: Android Snapshot Helper Package
android-helpers:
name: Android Helper Packages
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
Expand Down Expand Up @@ -134,8 +134,10 @@ jobs:
javac --version
java --version

- name: Package npm-bundled Android snapshot helper
run: pnpm package:android-snapshot-helper:npm
- name: Package npm-bundled Android helpers
run: |
pnpm package:android-snapshot-helper:npm
pnpm package:android-multitouch-helper:npm

integration:
name: Integration Tests
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,5 @@ xcuserdata/
.skillgym-results/
android-snapshot-helper/build/
android-snapshot-helper/dist/
android-multitouch-helper/build/
android-multitouch-helper/dist/
17 changes: 17 additions & 0 deletions android-multitouch-helper/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="com.callstack.agentdevice.multitouchhelper">
<uses-sdk android:minSdkVersion="23" android:targetSdkVersion="36" />

<application
android:debuggable="false"
android:label="Agent Device MultiTouch Helper"
android:testOnly="true"
android:theme="@android:style/Theme.NoDisplay" />

<instrumentation
android:name=".MultiTouchInstrumentation"
android:targetPackage="com.callstack.agentdevice.multitouchhelper"
android:label="Agent Device MultiTouch Helper"
android:functionalTest="true" />
</manifest>
41 changes: 41 additions & 0 deletions android-multitouch-helper/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Android MultiTouch Helper

Small instrumentation APK used to inject Android two-pointer gestures through
`UiAutomation.injectInputEvent`. The helper accepts a compact base64 JSON payload so local ADB,
remote ADB tunnels, and remote providers that allow `adb install -t` plus `am instrument` can use
the same contract.

The helper is separate from `android-snapshot-helper` because the payload and output protocol are
gesture-specific. The install/version/cache lifecycle should stay aligned with the snapshot helper.

## Build

```sh
VERSION="$(node -p 'require("./package.json").version')"
sh ./scripts/build-android-multitouch-helper.sh "$VERSION" .tmp/android-multitouch-helper
```

## Run

```sh
PAYLOAD="$(printf '%s' '{"kind":"transform","x":672,"y":1500,"dx":80,"dy":-40,"scale":1.8,"degrees":35,"durationMs":700}' | base64)"
adb install -r -t ".tmp/android-multitouch-helper/agent-device-android-multitouch-helper-$VERSION.apk"
adb shell am instrument -w \
-e payloadBase64 "$PAYLOAD" \
com.callstack.agentdevice.multitouchhelper/.MultiTouchInstrumentation
```

## Output Contract

The APK emits instrumentation result records using
`agentDeviceProtocol=android-multitouch-helper-v1`.

Successful results include:

- `ok=true`
- `helperApiVersion=1`
- `kind` (`pinch`, `rotate`, or `transform`)
- `injectedEvents`
- `elapsedMs`

Failures return `ok=false`, `errorType`, and `message`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
package com.callstack.agentdevice.multitouchhelper;

import android.app.Instrumentation;
import android.app.UiAutomation;
import android.os.Bundle;
import android.os.SystemClock;
import android.util.Base64;
import android.view.InputDevice;
import android.view.MotionEvent;
import java.nio.charset.StandardCharsets;
import org.json.JSONObject;

public final class MultiTouchInstrumentation extends Instrumentation {
private static final String PROTOCOL = "android-multitouch-helper-v1";
private static final String HELPER_API_VERSION = "1";
private static final int DEFAULT_RADIUS = 160;
private static final int MIN_RADIUS = 24;
private static final int MAX_RADIUS = 1200;
private static final int MIN_DURATION_MS = 16;
private static final int MAX_DURATION_MS = 10_000;
private Bundle arguments;

@Override
public void onCreate(Bundle arguments) {
super.onCreate(arguments);
this.arguments = arguments;
start();
}

@Override
public void onStart() {
super.onStart();
Bundle result = new Bundle();
result.putString("agentDeviceProtocol", PROTOCOL);
result.putString("helperApiVersion", HELPER_API_VERSION);
try {
long startedAtMs = System.currentTimeMillis();
GestureSpec spec = readSpec(arguments);
int injectedEvents = injectGesture(spec);
result.putString("ok", "true");
result.putString("kind", spec.kind);
result.putString("injectedEvents", Integer.toString(injectedEvents));
result.putString("elapsedMs", Long.toString(System.currentTimeMillis() - startedAtMs));
finish(0, result);
} catch (Throwable error) {
result.putString("ok", "false");
result.putString("errorType", error.getClass().getName());
result.putString(
"message",
error.getMessage() == null ? error.getClass().getName() : error.getMessage());
finish(1, result);
}
}

private GestureSpec readSpec(Bundle arguments) throws Exception {
String payloadBase64 = arguments.getString("payloadBase64", "");
if (payloadBase64.isEmpty()) {
throw new IllegalArgumentException("Missing payloadBase64");
}
String json =
new String(Base64.decode(payloadBase64, Base64.DEFAULT), StandardCharsets.UTF_8);
JSONObject payload = new JSONObject(json);
String protocol = payload.optString("protocol", PROTOCOL);
if (!PROTOCOL.equals(protocol)) {
throw new IllegalArgumentException("Unsupported protocol: " + protocol);
}
String kind = payload.getString("kind");
if (!"pinch".equals(kind) && !"rotate".equals(kind) && !"transform".equals(kind)) {
throw new IllegalArgumentException("Unsupported kind: " + kind);
}
int x = payload.getInt("x");
int y = payload.getInt("y");
int dx = payload.optInt("dx", 0);
int dy = payload.optInt("dy", 0);
int durationMs = clamp(payload.optInt("durationMs", 300), MIN_DURATION_MS, MAX_DURATION_MS);
int radius = clamp(payload.optInt("radius", DEFAULT_RADIUS), MIN_RADIUS, MAX_RADIUS);
double scale = payload.optDouble("scale", 1.0d);
double degrees = payload.optDouble("degrees", 0.0d);
if (("pinch".equals(kind) || "transform".equals(kind)) && (!isFinite(scale) || scale <= 0)) {
throw new IllegalArgumentException("Scale must be > 0");
}
if (("rotate".equals(kind) || "transform".equals(kind)) && !isFinite(degrees)) {
throw new IllegalArgumentException("Degrees must be finite");
}
return new GestureSpec(kind, x, y, dx, dy, durationMs, scale, degrees, radius);
}

private int injectGesture(GestureSpec spec) {
UiAutomation automation = getUiAutomation();
long downTime = SystemClock.uptimeMillis();
long eventTime = downTime;
PointerPair start = pointerPairAt(spec, 0);
PointerPair end = pointerPairAt(spec, 1);
int count = 0;

inject(
automation,
motionEvent(downTime, eventTime, MotionEvent.ACTION_DOWN, start.firstOnly()));
count += 1;
eventTime += 8;
inject(
automation,
motionEvent(
downTime,
eventTime,
MotionEvent.ACTION_POINTER_DOWN | (1 << MotionEvent.ACTION_POINTER_INDEX_SHIFT),
start));
count += 1;

int frameCount = Math.max(3, Math.round(spec.durationMs / 16.0f));
for (int index = 1; index < frameCount; index += 1) {
double t = (double) index / (double) frameCount;
PointerPair frame = pointerPairAt(spec, t);
eventTime = downTime + Math.round(spec.durationMs * t);
inject(automation, motionEvent(downTime, eventTime, MotionEvent.ACTION_MOVE, frame));
count += 1;
}

eventTime = downTime + spec.durationMs;
inject(
automation,
motionEvent(
downTime,
eventTime,
MotionEvent.ACTION_POINTER_UP | (1 << MotionEvent.ACTION_POINTER_INDEX_SHIFT),
end));
count += 1;
inject(
automation,
motionEvent(downTime, eventTime + 8, MotionEvent.ACTION_UP, end.firstOnly()));
count += 1;
return count;
}

private static void inject(UiAutomation automation, MotionEvent event) {
try {
if (!automation.injectInputEvent(event, true)) {
throw new IllegalStateException("injectInputEvent returned false");
}
} finally {
event.recycle();
}
}

private static MotionEvent motionEvent(long downTime, long eventTime, int action, PointerPair pair) {
MotionEvent.PointerProperties[] properties =
new MotionEvent.PointerProperties[pair.pointerCount];
MotionEvent.PointerCoords[] coords = new MotionEvent.PointerCoords[pair.pointerCount];
for (int index = 0; index < pair.pointerCount; index += 1) {
properties[index] = new MotionEvent.PointerProperties();
properties[index].id = index;
properties[index].toolType = MotionEvent.TOOL_TYPE_FINGER;
coords[index] = new MotionEvent.PointerCoords();
coords[index].x = pair.x[index];
coords[index].y = pair.y[index];
coords[index].pressure = 1.0f;
coords[index].size = 1.0f;
}
MotionEvent event =
MotionEvent.obtain(
downTime,
eventTime,
action,
pair.pointerCount,
properties,
coords,
0,
0,
1.0f,
1.0f,
0,
0,
InputDevice.SOURCE_TOUCHSCREEN,
0);
event.setSource(InputDevice.SOURCE_TOUCHSCREEN);
return event;
}

private static PointerPair pointerPairAt(GestureSpec spec, double t) {
if ("pinch".equals(spec.kind)) {
double startRadius = spec.radius / Math.max(spec.scale, 1.0d);
double endRadius = spec.radius;
if (spec.scale < 1.0d) {
startRadius = spec.radius;
endRadius = spec.radius * spec.scale;
}
double radius = startRadius + (endRadius - startRadius) * t;
return new PointerPair(
new float[] {(float) (spec.x - radius), (float) (spec.x + radius)},
new float[] {(float) spec.y, (float) spec.y});
}
double centerX = spec.x;
double centerY = spec.y;
double radius = spec.radius;
if ("transform".equals(spec.kind)) {
centerX = spec.x + spec.dx * t;
centerY = spec.y + spec.dy * t;
double startRadius = spec.radius / Math.max(spec.scale, 1.0d);
double endRadius = spec.radius;
if (spec.scale < 1.0d) {
startRadius = spec.radius;
endRadius = spec.radius * spec.scale;
}
radius = startRadius + (endRadius - startRadius) * t;
}
double angle = Math.toRadians(-90 + spec.degrees * t);
return new PointerPair(
new float[] {
(float) (centerX + Math.cos(angle) * radius),
(float) (centerX - Math.cos(angle) * radius)
},
new float[] {
(float) (centerY + Math.sin(angle) * radius),
(float) (centerY - Math.sin(angle) * radius)
});
}

private static int clamp(int value, int min, int max) {
return Math.min(Math.max(value, min), max);
}

private static boolean isFinite(double value) {
return !Double.isNaN(value) && !Double.isInfinite(value);
}

private static final class GestureSpec {
final String kind;
final int x;
final int y;
final int dx;
final int dy;
final int durationMs;
final double scale;
final double degrees;
final int radius;

GestureSpec(
String kind,
int x,
int y,
int dx,
int dy,
int durationMs,
double scale,
double degrees,
int radius) {
this.kind = kind;
this.x = x;
this.y = y;
this.dx = dx;
this.dy = dy;
this.durationMs = durationMs;
this.scale = scale;
this.degrees = degrees;
this.radius = radius;
}
}

private static final class PointerPair {
final int pointerCount;
final float[] x;
final float[] y;

PointerPair(float[] x, float[] y) {
this.pointerCount = x.length;
this.x = x;
this.y = y;
}

PointerPair firstOnly() {
return new PointerPair(
new float[] {x[0]},
new float[] {y[0]});
}
}
}
5 changes: 5 additions & 0 deletions examples/test-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ pnpm test-app:replay:android

These run the `.ad` replay suite in `examples/test-app/replays`.

`gesture-lab.ad` verifies `gesture pan`, `gesture fling`, `gesture pinch`, and
`gesture rotate` against the gesture metrics rendered by the Home screen on iOS
and Android. Android and iOS simulator sessions also support `gesture transform`
for a combined pan/zoom/rotate gesture.

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

Expand Down
Loading
Loading