Skip to content

Commit f1518b7

Browse files
committed
fix(android): ease controlled scrolls within the requested duration
1 parent be62249 commit f1518b7

7 files changed

Lines changed: 152 additions & 12 deletions

File tree

packages/platform-android/src/__tests__/input-actions.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,64 @@ test('scrollAndroid accepts sub-frame public durations at the Android planner mi
9595
);
9696
});
9797

98+
test.each([undefined, 'inertial'] as const)(
99+
'scrollAndroid preserves path and duration with %s release',
100+
async (releaseBehavior) => {
101+
const touchCalls: Parameters<AndroidTouchInjector>[0][] = [];
102+
await withAndroidAdbProvider(
103+
{
104+
exec: async () => {
105+
throw new Error('adb must not run');
106+
},
107+
gestureViewport: async () => ({ x: 10, y: 20, width: 1080, height: 1920 }),
108+
touch: async (request) => {
109+
touchCalls.push(request);
110+
},
111+
},
112+
{ serial: ANDROID_EMULATOR.id },
113+
async () => {
114+
for (const direction of ['up', 'down', 'left', 'right'] as const) {
115+
for (const durationMs of [16, 120, 300, 9841, 10000]) {
116+
await scrollAndroid(ANDROID_EMULATOR, direction, {
117+
pixels: 240,
118+
durationMs,
119+
releaseBehavior,
120+
});
121+
}
122+
}
123+
},
124+
);
125+
for (const touch of touchCalls) {
126+
const samples = touch.pointers[0]!.samples;
127+
const start = samples[0]!;
128+
const end = samples.at(-1)!;
129+
assert.equal(start.offsetMs, 0);
130+
assert.equal(end.offsetMs, touch.durationMs);
131+
const distance = (a: typeof start, b: typeof start) =>
132+
Math.hypot(b.point.x - a.point.x, b.point.y - a.point.y);
133+
assert.equal(distance(start, end), 240);
134+
const velocities = samples
135+
.slice(1)
136+
.map(
137+
(sample, index) =>
138+
distance(samples[index]!, sample) / (sample.offsetMs - samples[index]!.offsetMs),
139+
);
140+
if (releaseBehavior === 'inertial') {
141+
for (const velocity of velocities) assert.ok(Math.abs(velocity - velocities[0]!) < 1e-8);
142+
continue;
143+
}
144+
const firstMove = samples[1]!;
145+
assert.ok(
146+
distance(start, firstMove) <= ((240 * firstMove.offsetMs) / touch.durationMs) * 1.1,
147+
);
148+
assert.ok(velocities.at(-1)! < Math.max(...velocities) / 2);
149+
for (let i = Math.ceil(velocities.length / 2); i < velocities.length; i += 1) {
150+
assert.ok(velocities[i]! <= velocities[i - 1]! + 1e-8);
151+
}
152+
}
153+
},
154+
);
155+
98156
test('longPressAndroid sends a stationary semantic touch plan', async () => {
99157
const touchCalls: Parameters<AndroidTouchInjector>[0][] = [];
100158
const result = await withAndroidAdbProvider(

packages/platform-android/src/input-actions.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
import { DEVICE_ROTATION_SURFACE_INDEX, type DeviceRotation } from '@agent-device/contracts/device';
66
import { buildGesturePlan } from '@agent-device/contracts/gesture-plan';
77
import { GESTURE_DURATION_MIN_MS } from '@agent-device/contracts/gesture-plan-types';
8-
import { DEFAULT_MOBILE_SCROLL_DURATION_MS } from '@agent-device/contracts/scroll-command';
8+
import {
9+
type ScrollReleaseBehavior,
10+
DEFAULT_MOBILE_SCROLL_DURATION_MS,
11+
} from '@agent-device/contracts/scroll-command';
912
import {
1013
type ScrollDirection,
1114
buildScrollGesturePlan,
@@ -163,7 +166,12 @@ export async function focusAndroid(device: DeviceInfo, x: number, y: number): Pr
163166
export async function scrollAndroid(
164167
device: DeviceInfo,
165168
direction: ScrollDirection,
166-
options?: { amount?: number; pixels?: number; durationMs?: number } & AndroidHelperSessionOptions,
169+
options?: {
170+
amount?: number;
171+
pixels?: number;
172+
durationMs?: number;
173+
releaseBehavior?: ScrollReleaseBehavior;
174+
} & AndroidHelperSessionOptions,
167175
): Promise<Record<string, unknown>> {
168176
// The viewport read and the gesture are two helper calls one command apart: giving the read the
169177
// command's session scope keeps both on the same instrumentation.
@@ -192,9 +200,8 @@ export async function scrollAndroid(
192200
options?.durationMs ?? DEFAULT_MOBILE_SCROLL_DURATION_MS,
193201
GESTURE_DURATION_MIN_MS,
194202
);
195-
const backend = await executeAndroidTouchPlan(
196-
device,
197-
buildGesturePlan(
203+
const backend = await executeAndroidTouchPlan(device, {
204+
...buildGesturePlan(
198205
{
199206
intent: 'pan',
200207
origin: { x: scrollPlan.x1, y: scrollPlan.y1 },
@@ -207,7 +214,8 @@ export async function scrollAndroid(
207214
viewport,
208215
'android',
209216
),
210-
);
217+
releaseBehavior: options?.releaseBehavior ?? 'controlled',
218+
});
211219

212220
return {
213221
...scrollPlan,

packages/platform-android/src/touch-plan-lowering.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import type { ScrollReleaseBehavior } from '@agent-device/contracts/scroll-command';
12
import {
3+
GESTURE_SAMPLE_INTERVAL_MS,
24
interpolateGesturePoint,
35
sampleGestureOffsets,
46
} from '@agent-device/contracts/gesture-plan';
@@ -18,7 +20,9 @@ export type AndroidLongPressTouchPlan = {
1820
pointers: readonly [PointerTrajectory];
1921
};
2022

21-
export type AndroidTouchPlan = GesturePlan | AndroidLongPressTouchPlan;
23+
export type AndroidTouchPlan =
24+
| (GesturePlan & { releaseBehavior?: ScrollReleaseBehavior })
25+
| AndroidLongPressTouchPlan;
2226

2327
/**
2428
* Transport samples are strictly denser than the canonical endpoint pair, so the shared plan a
@@ -57,7 +61,8 @@ export type AndroidProviderTouchPlan =
5761
export function lowerAndroidTouchPlan(plan: AndroidTouchPlan): AndroidLoweredTouchPlan {
5862
if (plan.topology === 'two' || plan.intent === 'longPress') return plan;
5963

60-
const [{ pointerId, samples: canonicalSamples }] = plan.pointers;
64+
const { releaseBehavior, ...gesturePlan } = plan;
65+
const [{ pointerId, samples: canonicalSamples }] = gesturePlan.pointers;
6166
const offsets = [
6267
...new Set([
6368
...sampleGestureOffsets(plan.durationMs, 'android'),
@@ -75,6 +80,7 @@ export function lowerAndroidTouchPlan(plan: AndroidTouchPlan): AndroidLoweredTou
7580
const start = canonicalSamples[segmentIndex]!;
7681
const end = canonicalSamples[segmentIndex + 1]!;
7782
const segmentDurationMs = end.offsetMs - start.offsetMs;
83+
const progress = (offsetMs - start.offsetMs) / segmentDurationMs;
7884
return {
7985
offsetMs,
8086
point:
@@ -83,15 +89,24 @@ export function lowerAndroidTouchPlan(plan: AndroidTouchPlan): AndroidLoweredTou
8389
: interpolateGesturePoint(
8490
start.point,
8591
end.point,
86-
(offsetMs - start.offsetMs) / segmentDurationMs,
92+
releaseBehavior === 'controlled'
93+
? controlledScrollProgress(progress, segmentDurationMs)
94+
: progress,
8795
),
8896
};
8997
});
9098
// The sampler floors the frame count at three, so `dense` always holds at least four samples.
9199
const samples: AndroidTransportSamples = [dense[0]!, dense[1]!, dense[2]!, ...dense.slice(3)];
92100

93101
return {
94-
...plan,
102+
...gesturePlan,
95103
pointers: [{ pointerId, samples }],
96104
};
97105
}
106+
107+
function controlledScrollProgress(progress: number, durationMs: number): number {
108+
const accelerationFraction = Math.min(GESTURE_SAMPLE_INTERVAL_MS / durationMs, 0.5);
109+
return progress < accelerationFraction
110+
? progress ** 2 / accelerationFraction
111+
: 1 - (1 - progress) ** 2 / (1 - accelerationFraction);
112+
}

src/__tests__/test-utils/property-arbitraries.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,13 @@ const viewportRectArb: fc.Arbitrary<Rect> = fc.oneof(
7777
}),
7878
);
7979

80+
export const scrollInViewportArb = fc.record({
81+
viewport: viewportRectArb.filter(({ width, height }) => width >= 32 && height >= 32),
82+
direction: fc.constantFrom(...SCROLL_DIRECTIONS),
83+
durationMs: fc.integer({ min: 16, max: 10000 }),
84+
pixels: fc.integer({ min: 1, max: 2000 }),
85+
});
86+
8087
export const scrollingContainerTypeArb = fc.constantFrom(
8188
'XCUIElementTypeScrollView',
8289
'XCUIElementTypeTable',

src/commands/interaction/metadata.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ const interactionCommandDescriptions = {
6868
'Move input focus to explicit screen coordinates without entering text. Prefer semantic interactions when a snapshot ref or selector is available; use type or fill after focus.',
6969
type: 'Append text to the currently focused input. Use fill when the existing field value should be replaced, and focus first when no input is active.',
7070
scroll:
71-
'Scroll in a direction, or toward the top/bottom edge of scrollable content. The optional amount is the finger-path fraction of the viewport axis; app scroll physics determine the final content offset.',
71+
'Scroll in a direction, or toward the top/bottom edge of scrollable content. The optional amount is the finger-path fraction of the viewport axis; directional scrolls reduce release momentum, while app scroll physics determine the final content offset.',
7272
get: 'Read text or accessibility attributes from a snapshot ref or selector without changing the app. Use format text for visible content or attrs for the element attribute map.',
7373
is: 'Check whether a selector satisfies a UI predicate such as visible, hidden, exists, absent, editable, selected, focused, or text. `absent` passes only when one readable, complete, unscoped, full-depth accessibility capture has zero matches. Use wait when the condition may appear asynchronously.',
7474
find: 'Find by text/label/value/role/id and run action',

src/core/__tests__/gesture-plan-viewport.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { lowerAndroidTouchPlan } from '@agent-device/platform-android/mechanics';
2+
import { buildScrollGesturePlan } from '@agent-device/contracts/scroll-gesture';
13
import {
24
gesturePayloadFromPositionals,
35
normalizePublicGesture,
@@ -11,6 +13,7 @@ import { describe, test } from 'vitest';
1113
import {
1214
COMPACT_VIEWPORTS,
1315
gestureInViewportArb,
16+
scrollInViewportArb,
1417
PROPERTY_RUNS_SMALL,
1518
} from '../../__tests__/test-utils/property-arbitraries.ts';
1619
import {
@@ -206,3 +209,52 @@ function buildTransformPlan(durationMs: number) {
206209
),
207210
);
208211
}
212+
213+
test('Android controlled scroll sampling preserves the inertial path and viewport bounds', () => {
214+
fc.assert(
215+
fc.property(scrollInViewportArb, ({ viewport, direction, durationMs, pixels }) => {
216+
const scroll = buildScrollGesturePlan({
217+
direction,
218+
pixels,
219+
referenceWidth: viewport.width,
220+
referenceHeight: viewport.height,
221+
});
222+
const plan = buildGesturePlan(
223+
{
224+
intent: 'pan',
225+
origin: { x: viewport.x + scroll.x1, y: viewport.y + scroll.y1 },
226+
delta: { x: scroll.x2 - scroll.x1, y: scroll.y2 - scroll.y1 },
227+
durationMs,
228+
},
229+
viewport,
230+
'android',
231+
);
232+
const controlled = lowerAndroidTouchPlan({ ...plan, releaseBehavior: 'controlled' });
233+
const inertial = lowerAndroidTouchPlan({ ...plan, releaseBehavior: 'inertial' });
234+
assert.equal(controlled.durationMs, durationMs);
235+
assert.equal(inertial.durationMs, durationMs);
236+
const samples = controlled.pointers[0].samples;
237+
const linear = inertial.pointers[0].samples;
238+
assert.deepEqual(
239+
samples.map(({ offsetMs }) => offsetMs),
240+
linear.map(({ offsetMs }) => offsetMs),
241+
);
242+
assert.deepEqual(samples[0], linear[0]);
243+
assert.deepEqual(samples.at(-1), linear.at(-1));
244+
for (const axis of ['x', 'y'] as const) {
245+
const from = samples[0]!.point[axis];
246+
const to = samples.at(-1)!.point[axis];
247+
for (let i = 1; i < samples.length; i += 1) {
248+
const sample = samples[i]!;
249+
assert.ok(
250+
sample.point[axis] >= Math.min(from, to) && sample.point[axis] <= Math.max(from, to),
251+
);
252+
assert.ok((sample.point[axis] - samples[i - 1]!.point[axis]) * (to - from) >= 0);
253+
const expectedLinear = from + ((to - from) * sample.offsetMs) / durationMs;
254+
assert.ok(Math.abs(linear[i]!.point[axis] - expectedLinear) < 1e-8);
255+
}
256+
}
257+
}),
258+
{ numRuns: PROPERTY_RUNS_SMALL },
259+
);
260+
});

website/docs/docs/commands.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -485,7 +485,7 @@ Target-authored drag is supported on Android touch devices and iOS/iPadOS. Backe
485485
`gesture transform` accepts `x y dx dy scale degrees [durationMs]` for one combined two-finger pan/zoom/rotate gesture on Android and iOS simulators. Pinch, rotate, two-finger pan, and transform use the same viewport-aware pointer planning; impossible paths fail before injection instead of clamping or distorting the requested motion.
486486
On iOS simulators it uses private XCTest synthesis for a continuous two-finger pan/scale/rotation path, so verify app-level metrics instead of assuming the requested values map exactly to recognizer output.
487487
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.
488-
`scroll` accepts either a relative amount (`0.5` means a finger path spanning half of the viewport on that axis) or `--pixels <n>` for a fixed-distance gesture. The final content offset can differ because apps apply pan-recognition thresholds, collapsing headers, bounds, and their own scroll physics. Large distances are clamped to the usable drag band so the gesture stays reliable across Android, iOS, and macOS.
488+
`scroll` accepts either a relative amount (`0.5` means a finger path spanning half of the viewport on that axis) or `--pixels <n>` for a fixed-distance gesture. Directional scrolls decelerate through the drag on Android to reduce release momentum within the requested duration; `scroll top` and `scroll bottom` retain inertial release for edge traversal. Reduced momentum does not guarantee an exact content offset, especially for very short gestures: apps apply pan-recognition thresholds, collapsing headers, bounds, and their own scroll physics. Large distances are clamped to the usable drag band so the gesture stays reliable across Android, iOS, and macOS.
489489
Default snapshot text output is visible-first, so off-screen interactive content is summarized instead of shown as tappable refs.
490490
When a target only appears in an off-screen summary, use `scroll <direction> --settle`: the response waits for the UI to go quiet and returns the diff against the tree you last observed, with fresh refs on the added lines, so no follow-up `snapshot -i` is needed. `back --settle` does the same for navigation. Both are best-effort and never fail the action. For repeated checks without settle, a small shell loop is enough:
491491

0 commit comments

Comments
 (0)