Skip to content

Commit e7723f5

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

9 files changed

Lines changed: 174 additions & 316 deletions

File tree

docs/adr/0013-unified-gesture-plans.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,11 @@ Platform adapters consume the canonical plan:
7474
- Android's `executeAndroidTouchPlan` adapter seam sends planned touch, including gesture plans plus
7575
the physical movement for scroll and long-press, to provider-native touch injection when
7676
available, otherwise to the bundled instrumentation helper. One-contact endpoint plans lower in
77-
`packages/platform-android/src/touch-plan.ts` to 16 ms linear transport samples before either injection
78-
path; two-contact plans retain their exact planned samples. Transport samples are typed as
77+
`packages/platform-android/src/touch-plan-lowering.ts` to approximately 16 ms transport samples before
78+
either injection path. Controlled directional scrolls accelerate for one frame, then decelerate
79+
through release within the requested duration, without an appended tail. Inertial scrolls and
80+
general one-contact plans retain linear interpolation; two-contact plans retain their exact
81+
planned samples. Easing reduces release momentum but does not guarantee an exact content offset. Transport samples are typed as
7982
strictly denser than the canonical endpoint pair, so skipping that lowering is a type error at
8083
the injection seams instead of a silently sparse gesture. A stationary long-press needs no
8184
viewport on the helper path; the executor adds the paired provider-owned viewport only for

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

Lines changed: 55 additions & 199 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import { test, vi } from 'vitest';
22
import assert from 'node:assert/strict';
3-
import { GESTURE_SAMPLE_INTERVAL_MS } from '@agent-device/contracts/gesture-plan';
4-
import { GESTURE_DURATION_MAX_MS } from '@agent-device/contracts/gesture-plan-types';
53
import {
64
backAndroid,
75
homeAndroid,
@@ -81,14 +79,7 @@ test('scrollAndroid accepts sub-frame public durations at the Android planner mi
8179
async () => {
8280
const outputs: Record<string, unknown>[] = [];
8381
for (const durationMs of [0, 15]) {
84-
// 'inertial' keeps the injected plan's durationMs equal to the honored move time, so the
85-
// flooring this test targets is not conflated with the 'controlled' release tail below.
86-
outputs.push(
87-
await scrollAndroid(ANDROID_EMULATOR, 'down', {
88-
durationMs,
89-
releaseBehavior: 'inertial',
90-
}),
91-
);
82+
outputs.push(await scrollAndroid(ANDROID_EMULATOR, 'down', { durationMs }));
9283
}
9384
return outputs;
9485
},
@@ -104,198 +95,63 @@ test('scrollAndroid accepts sub-frame public durations at the Android planner mi
10495
);
10596
});
10697

107-
test('scrollAndroid defaults to a controlled release: a quivering tail past the pan endpoint', async () => {
108-
const touchCalls: Parameters<AndroidTouchInjector>[0][] = [];
109-
await withAndroidAdbProvider(
110-
{
111-
exec: async () => {
112-
throw new Error('adb must not run');
113-
},
114-
gestureViewport: async () => ({ x: 10, y: 20, width: 1080, height: 1920 }),
115-
touch: async (request) => {
116-
touchCalls.push(request);
117-
return { injected: true };
118-
},
119-
},
120-
{ serial: ANDROID_EMULATOR.id },
121-
async () => await scrollAndroid(ANDROID_EMULATOR, 'down', { pixels: 240, durationMs: 120 }),
122-
);
123-
124-
assert.equal(touchCalls.length, 1);
125-
const [touch] = touchCalls;
126-
const samples = touch!.pointers[0]!.samples;
127-
const endpoint = samples.find((sample) => sample.offsetMs === 120)!;
128-
const tail = samples.filter((sample) => sample.offsetMs > 120);
129-
130-
// The plan carries a >=100ms tail past the honored 120ms move; the CLI-facing `durationMs` in
131-
// the command result (asserted below) stays at the honored move time.
132-
assert.equal(touch!.durationMs, 280);
133-
assert.ok(tail.length >= 100 / GESTURE_SAMPLE_INTERVAL_MS);
134-
for (const sample of tail) assert.equal(sample.point.y, endpoint.point.y);
135-
const allPastEndpoint = [endpoint, ...tail];
136-
for (let index = 1; index < allPastEndpoint.length; index += 1) {
137-
assert.notEqual(allPastEndpoint[index]!.point.x, allPastEndpoint[index - 1]!.point.x);
138-
}
139-
});
140-
141-
test('scrollAndroid composes the duration floor with the default controlled-release tail', async () => {
142-
const touchCalls: Parameters<AndroidTouchInjector>[0][] = [];
143-
await withAndroidAdbProvider(
144-
{
145-
exec: async () => {
146-
throw new Error('adb must not run');
147-
},
148-
gestureViewport: async () => ({ x: 0, y: 0, width: 1080, height: 1920 }),
149-
touch: async (request) => {
150-
touchCalls.push(request);
151-
},
152-
},
153-
{ serial: ANDROID_EMULATOR.id },
154-
async () => await scrollAndroid(ANDROID_EMULATOR, 'down', { durationMs: 0 }),
155-
);
156-
157-
// The move floors to the Android planner minimum (16ms) before the tail is appended, not after.
158-
assert.equal(touchCalls[0]!.durationMs, GESTURE_SAMPLE_INTERVAL_MS + 160);
159-
});
160-
161-
test('scrollAndroid runs the full controlled-release tail at the largest duration that still leaves it room', async () => {
162-
const touchCalls: Parameters<AndroidTouchInjector>[0][] = [];
163-
const maxControlledMoveMs = GESTURE_DURATION_MAX_MS - 160;
164-
const result = await withAndroidAdbProvider(
165-
{
166-
exec: async () => {
167-
throw new Error('adb must not run');
168-
},
169-
gestureViewport: async () => ({ x: 0, y: 0, width: 1080, height: 1920 }),
170-
touch: async (request) => {
171-
touchCalls.push(request);
172-
},
173-
},
174-
{ serial: ANDROID_EMULATOR.id },
175-
async () =>
176-
await scrollAndroid(ANDROID_EMULATOR, 'down', {
177-
pixels: 1800,
178-
durationMs: maxControlledMoveMs,
179-
}),
180-
);
181-
182-
// The requested move is honored in full, and the tail always runs at its full length — the
183-
// dispatched plan lands exactly at GESTURE_DURATION_MAX_MS, never past it.
184-
assert.equal(result.durationMs, maxControlledMoveMs);
185-
const [touch] = touchCalls;
186-
assert.equal(touch!.durationMs, GESTURE_DURATION_MAX_MS);
187-
assert.equal(touch!.pointers[0]!.samples.at(-1)!.offsetMs, GESTURE_DURATION_MAX_MS);
188-
});
189-
190-
test('scrollAndroid rejects a controlled-release durationMs that would leave the release tail no room, without shortening the move', async () => {
191-
await withAndroidAdbProvider(
192-
{
193-
exec: async () => {
194-
throw new Error('adb must not run');
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+
},
195111
},
196-
gestureViewport: async () => ({ x: 0, y: 0, width: 1080, height: 1920 }),
197-
touch: async () => {
198-
throw new Error('touch must not run for a rejected request');
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+
}
199123
},
200-
},
201-
{ serial: ANDROID_EMULATOR.id },
202-
async () => {
203-
await assert.rejects(
204-
scrollAndroid(ANDROID_EMULATOR, 'down', {
205-
pixels: 1800,
206-
durationMs: GESTURE_DURATION_MAX_MS - 159,
207-
}),
208-
/scroll durationMs must be at most 9840 for a controlled release/,
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,
209147
);
210-
},
211-
);
212-
});
213-
214-
test("scrollAndroid accepts the full GESTURE_DURATION_MAX_MS for an 'inertial' release, which needs no tail", async () => {
215-
const touchCalls: Parameters<AndroidTouchInjector>[0][] = [];
216-
await withAndroidAdbProvider(
217-
{
218-
exec: async () => {
219-
throw new Error('adb must not run');
220-
},
221-
gestureViewport: async () => ({ x: 0, y: 0, width: 1080, height: 1920 }),
222-
touch: async (request) => {
223-
touchCalls.push(request);
224-
},
225-
},
226-
{ serial: ANDROID_EMULATOR.id },
227-
async () =>
228-
await scrollAndroid(ANDROID_EMULATOR, 'down', {
229-
pixels: 1800,
230-
durationMs: GESTURE_DURATION_MAX_MS,
231-
releaseBehavior: 'inertial',
232-
}),
233-
);
234-
235-
const [touch] = touchCalls;
236-
assert.equal(touch!.durationMs, GESTURE_DURATION_MAX_MS);
237-
});
238-
239-
test('scrollAndroid jitters the axis orthogonal to a horizontal scroll, holding the scroll axis fixed', async () => {
240-
const touchCalls: Parameters<AndroidTouchInjector>[0][] = [];
241-
await withAndroidAdbProvider(
242-
{
243-
exec: async () => {
244-
throw new Error('adb must not run');
245-
},
246-
gestureViewport: async () => ({ x: 10, y: 20, width: 1080, height: 1920 }),
247-
touch: async (request) => {
248-
touchCalls.push(request);
249-
return { injected: true };
250-
},
251-
},
252-
{ serial: ANDROID_EMULATOR.id },
253-
async () => await scrollAndroid(ANDROID_EMULATOR, 'left', { pixels: 240, durationMs: 120 }),
254-
);
255-
256-
assert.equal(touchCalls.length, 1);
257-
const [touch] = touchCalls;
258-
const samples = touch!.pointers[0]!.samples;
259-
const endpoint = samples.find((sample) => sample.offsetMs === 120)!;
260-
const tail = samples.filter((sample) => sample.offsetMs > 120);
261-
262-
assert.ok(tail.length >= 100 / GESTURE_SAMPLE_INTERVAL_MS);
263-
// The scroll axis (x, for a horizontal scroll) stays exactly at the endpoint — zero velocity
264-
// there by construction; only the orthogonal axis (y) jitters to dodge the resampling quirk.
265-
for (const sample of tail) assert.equal(sample.point.x, endpoint.point.x);
266-
const allPastEndpoint = [endpoint, ...tail];
267-
for (let index = 1; index < allPastEndpoint.length; index += 1) {
268-
assert.notEqual(allPastEndpoint[index]!.point.y, allPastEndpoint[index - 1]!.point.y);
269-
}
270-
});
271-
272-
test('scrollAndroid honors an inertial release (scroll top/bottom): lifts at the pan endpoint', async () => {
273-
const touchCalls: Parameters<AndroidTouchInjector>[0][] = [];
274-
await withAndroidAdbProvider(
275-
{
276-
exec: async () => {
277-
throw new Error('adb must not run');
278-
},
279-
gestureViewport: async () => ({ x: 10, y: 20, width: 1080, height: 1920 }),
280-
touch: async (request) => {
281-
touchCalls.push(request);
282-
return { injected: true };
283-
},
284-
},
285-
{ serial: ANDROID_EMULATOR.id },
286-
async () =>
287-
await scrollAndroid(ANDROID_EMULATOR, 'down', {
288-
pixels: 240,
289-
durationMs: 120,
290-
releaseBehavior: 'inertial',
291-
}),
292-
);
293-
294-
assert.equal(touchCalls.length, 1);
295-
const [touch] = touchCalls;
296-
assert.equal(touch!.durationMs, 120);
297-
assert.equal(touch!.pointers[0]!.samples.at(-1)!.offsetMs, 120);
298-
});
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+
);
299155

300156
test('longPressAndroid sends a stationary semantic touch plan', async () => {
301157
const touchCalls: Parameters<AndroidTouchInjector>[0][] = [];
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import fc from 'fast-check';
2+
import type { Rect } from '@agent-device/kernel/snapshot';
3+
import { SCROLL_DIRECTIONS } from '@agent-device/contracts/scroll-gesture';
4+
5+
export const PROPERTY_RUNS_SMALL = 40;
6+
7+
const viewportRectArb: fc.Arbitrary<Rect> = fc.oneof(
8+
fc.constantFrom({ x: 0, y: 0, width: 320, height: 568 }, { x: 0, y: 0, width: 375, height: 667 }),
9+
fc.record({
10+
x: fc.integer({ min: 0, max: 200 }),
11+
y: fc.integer({ min: 0, max: 200 }),
12+
width: fc.integer({ min: 1, max: 2400 }),
13+
height: fc.integer({ min: 1, max: 2400 }),
14+
}),
15+
);
16+
17+
export const scrollInViewportArb = fc.record({
18+
viewport: viewportRectArb.filter(({ width, height }) => width >= 32 && height >= 32),
19+
direction: fc.constantFrom(...SCROLL_DIRECTIONS),
20+
durationMs: fc.integer({ min: 16, max: 10000 }),
21+
pixels: fc.integer({ min: 1, max: 2000 }),
22+
});

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import fc from 'fast-check';
2+
import { buildScrollGesturePlan } from '@agent-device/contracts/scroll-gesture';
3+
import { PROPERTY_RUNS_SMALL, scrollInViewportArb } from './touch-plan-lowering.fixtures.ts';
14
import assert from 'node:assert/strict';
25
import { expectTypeOf, test } from 'vitest';
36
import { buildDragGesturePlan, buildGesturePlan } from '@agent-device/contracts/gesture-plan';
@@ -128,3 +131,52 @@ test('a canonical endpoint plan cannot reach the transport unlowered', () => {
128131
expectTypeOf<SinglePointerGesturePlan>().not.toExtend<AndroidLoweredTouchPlan>();
129132
expectTypeOf<ReturnType<typeof lowerAndroidTouchPlan>>().toExtend<AndroidLoweredTouchPlan>();
130133
});
134+
135+
test('Android controlled scroll sampling preserves the inertial path and viewport bounds', () => {
136+
fc.assert(
137+
fc.property(scrollInViewportArb, ({ viewport, direction, durationMs, pixels }) => {
138+
const scroll = buildScrollGesturePlan({
139+
direction,
140+
pixels,
141+
referenceWidth: viewport.width,
142+
referenceHeight: viewport.height,
143+
});
144+
const plan = buildGesturePlan(
145+
{
146+
intent: 'pan',
147+
origin: { x: viewport.x + scroll.x1, y: viewport.y + scroll.y1 },
148+
delta: { x: scroll.x2 - scroll.x1, y: scroll.y2 - scroll.y1 },
149+
durationMs,
150+
},
151+
viewport,
152+
'android',
153+
);
154+
const controlled = lowerAndroidTouchPlan({ ...plan, releaseBehavior: 'controlled' });
155+
const inertial = lowerAndroidTouchPlan({ ...plan, releaseBehavior: 'inertial' });
156+
assert.equal(controlled.durationMs, durationMs);
157+
assert.equal(inertial.durationMs, durationMs);
158+
const samples = controlled.pointers[0].samples;
159+
const linear = inertial.pointers[0].samples;
160+
assert.deepEqual(
161+
samples.map(({ offsetMs }) => offsetMs),
162+
linear.map(({ offsetMs }) => offsetMs),
163+
);
164+
assert.deepEqual(samples[0], linear[0]);
165+
assert.deepEqual(samples.at(-1), linear.at(-1));
166+
for (const axis of ['x', 'y'] as const) {
167+
const from = samples[0]!.point[axis];
168+
const to = samples.at(-1)!.point[axis];
169+
for (let i = 1; i < samples.length; i += 1) {
170+
const sample = samples[i]!;
171+
assert.ok(
172+
sample.point[axis] >= Math.min(from, to) && sample.point[axis] <= Math.max(from, to),
173+
);
174+
assert.ok((sample.point[axis] - samples[i - 1]!.point[axis]) * (to - from) >= 0);
175+
const expectedLinear = from + ((to - from) * sample.offsetMs) / durationMs;
176+
assert.ok(Math.abs(linear[i]!.point[axis] - expectedLinear) < 1e-8);
177+
}
178+
}
179+
}),
180+
{ numRuns: PROPERTY_RUNS_SMALL },
181+
);
182+
});

0 commit comments

Comments
 (0)