Skip to content

Commit 3f4cf34

Browse files
committed
fix(android): honor scroll releaseBehavior to stop the fling overshoot
scrollAndroid ignored ScrollReleaseBehavior and always released the pan like a fling, so a controlled `scroll` overshot its requested distance by ~60% on both a plain RecyclerView and an RN ScrollView. Android's VelocityTracker treats a truly stationary release as a resampled duplicate and ignores it, so a hold alone can't stop the fling. For the default 'controlled' release, append a short tail after the pan's endpoint that holds the scroll axis exactly fixed (zero velocity there) while nudging the orthogonal axis every frame so no two consecutive samples repeat. 'inertial' (the scroll top/bottom edge passes) is unchanged. Live-measured on a Pixel 9 Pro XL emulator: displacement lands within touch-slop of the requested drag instead of overshooting it. Fixes #2371
1 parent be62249 commit 3f4cf34

2 files changed

Lines changed: 210 additions & 17 deletions

File tree

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

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
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';
34
import {
45
backAndroid,
56
homeAndroid,
@@ -79,7 +80,14 @@ test('scrollAndroid accepts sub-frame public durations at the Android planner mi
7980
async () => {
8081
const outputs: Record<string, unknown>[] = [];
8182
for (const durationMs of [0, 15]) {
82-
outputs.push(await scrollAndroid(ANDROID_EMULATOR, 'down', { durationMs }));
83+
// 'inertial' keeps the injected plan's durationMs equal to the honored move time, so the
84+
// flooring this test targets is not conflated with the 'controlled' release tail below.
85+
outputs.push(
86+
await scrollAndroid(ANDROID_EMULATOR, 'down', {
87+
durationMs,
88+
releaseBehavior: 'inertial',
89+
}),
90+
);
8391
}
8492
return outputs;
8593
},
@@ -95,6 +103,121 @@ test('scrollAndroid accepts sub-frame public durations at the Android planner mi
95103
);
96104
});
97105

106+
test('scrollAndroid defaults to a controlled release: a quivering tail past the pan endpoint', async () => {
107+
const touchCalls: Parameters<AndroidTouchInjector>[0][] = [];
108+
await withAndroidAdbProvider(
109+
{
110+
exec: async () => {
111+
throw new Error('adb must not run');
112+
},
113+
gestureViewport: async () => ({ x: 10, y: 20, width: 1080, height: 1920 }),
114+
touch: async (request) => {
115+
touchCalls.push(request);
116+
return { injected: true };
117+
},
118+
},
119+
{ serial: ANDROID_EMULATOR.id },
120+
async () => await scrollAndroid(ANDROID_EMULATOR, 'down', { pixels: 240, durationMs: 120 }),
121+
);
122+
123+
assert.equal(touchCalls.length, 1);
124+
const [touch] = touchCalls;
125+
const samples = touch!.pointers[0]!.samples;
126+
const endpoint = samples.find((sample) => sample.offsetMs === 120)!;
127+
const tail = samples.filter((sample) => sample.offsetMs > 120);
128+
129+
// The plan carries a >=100ms tail past the honored 120ms move; the CLI-facing `durationMs` in
130+
// the command result (asserted below) stays at the honored move time.
131+
assert.equal(touch!.durationMs, 280);
132+
assert.ok(tail.length >= 100 / GESTURE_SAMPLE_INTERVAL_MS);
133+
for (const sample of tail) assert.equal(sample.point.y, endpoint.point.y);
134+
const allPastEndpoint = [endpoint, ...tail];
135+
for (let index = 1; index < allPastEndpoint.length; index += 1) {
136+
assert.notEqual(allPastEndpoint[index]!.point.x, allPastEndpoint[index - 1]!.point.x);
137+
}
138+
});
139+
140+
test('scrollAndroid composes the duration floor with the default controlled-release tail', async () => {
141+
const touchCalls: Parameters<AndroidTouchInjector>[0][] = [];
142+
await withAndroidAdbProvider(
143+
{
144+
exec: async () => {
145+
throw new Error('adb must not run');
146+
},
147+
gestureViewport: async () => ({ x: 0, y: 0, width: 1080, height: 1920 }),
148+
touch: async (request) => {
149+
touchCalls.push(request);
150+
},
151+
},
152+
{ serial: ANDROID_EMULATOR.id },
153+
async () => await scrollAndroid(ANDROID_EMULATOR, 'down', { durationMs: 0 }),
154+
);
155+
156+
// The move floors to the Android planner minimum (16ms) before the tail is appended, not after.
157+
assert.equal(touchCalls[0]!.durationMs, GESTURE_SAMPLE_INTERVAL_MS + 160);
158+
});
159+
160+
test('scrollAndroid jitters the axis orthogonal to a horizontal scroll, holding the scroll axis fixed', async () => {
161+
const touchCalls: Parameters<AndroidTouchInjector>[0][] = [];
162+
await withAndroidAdbProvider(
163+
{
164+
exec: async () => {
165+
throw new Error('adb must not run');
166+
},
167+
gestureViewport: async () => ({ x: 10, y: 20, width: 1080, height: 1920 }),
168+
touch: async (request) => {
169+
touchCalls.push(request);
170+
return { injected: true };
171+
},
172+
},
173+
{ serial: ANDROID_EMULATOR.id },
174+
async () => await scrollAndroid(ANDROID_EMULATOR, 'left', { pixels: 240, durationMs: 120 }),
175+
);
176+
177+
assert.equal(touchCalls.length, 1);
178+
const [touch] = touchCalls;
179+
const samples = touch!.pointers[0]!.samples;
180+
const endpoint = samples.find((sample) => sample.offsetMs === 120)!;
181+
const tail = samples.filter((sample) => sample.offsetMs > 120);
182+
183+
assert.ok(tail.length >= 100 / GESTURE_SAMPLE_INTERVAL_MS);
184+
// The scroll axis (x, for a horizontal scroll) stays exactly at the endpoint — zero velocity
185+
// there by construction; only the orthogonal axis (y) jitters to dodge the resampling quirk.
186+
for (const sample of tail) assert.equal(sample.point.x, endpoint.point.x);
187+
const allPastEndpoint = [endpoint, ...tail];
188+
for (let index = 1; index < allPastEndpoint.length; index += 1) {
189+
assert.notEqual(allPastEndpoint[index]!.point.y, allPastEndpoint[index - 1]!.point.y);
190+
}
191+
});
192+
193+
test('scrollAndroid honors an inertial release (scroll top/bottom): lifts at the pan endpoint', async () => {
194+
const touchCalls: Parameters<AndroidTouchInjector>[0][] = [];
195+
await withAndroidAdbProvider(
196+
{
197+
exec: async () => {
198+
throw new Error('adb must not run');
199+
},
200+
gestureViewport: async () => ({ x: 10, y: 20, width: 1080, height: 1920 }),
201+
touch: async (request) => {
202+
touchCalls.push(request);
203+
return { injected: true };
204+
},
205+
},
206+
{ serial: ANDROID_EMULATOR.id },
207+
async () =>
208+
await scrollAndroid(ANDROID_EMULATOR, 'down', {
209+
pixels: 240,
210+
durationMs: 120,
211+
releaseBehavior: 'inertial',
212+
}),
213+
);
214+
215+
assert.equal(touchCalls.length, 1);
216+
const [touch] = touchCalls;
217+
assert.equal(touch!.durationMs, 120);
218+
assert.equal(touch!.pointers[0]!.samples.at(-1)!.offsetMs, 120);
219+
});
220+
98221
test('longPressAndroid sends a stationary semantic touch plan', async () => {
99222
const touchCalls: Parameters<AndroidTouchInjector>[0][] = [];
100223
const result = await withAndroidAdbProvider(

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

Lines changed: 86 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,25 @@
33
* IME, and the adb-shell writer — is `text-input.ts`.
44
*/
55
import { DEVICE_ROTATION_SURFACE_INDEX, type DeviceRotation } from '@agent-device/contracts/device';
6-
import { buildGesturePlan } from '@agent-device/contracts/gesture-plan';
6+
import { GESTURE_SAMPLE_INTERVAL_MS, 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 type {
9+
GesturePlan,
10+
PointerTrajectorySample,
11+
SinglePointerTrajectory,
12+
} from '@agent-device/contracts/gesture-plan-types';
13+
import {
14+
DEFAULT_MOBILE_SCROLL_DURATION_MS,
15+
type ScrollReleaseBehavior,
16+
} from '@agent-device/contracts/scroll-command';
917
import {
1018
type ScrollDirection,
1119
buildScrollGesturePlan,
1220
} from '@agent-device/contracts/scroll-gesture';
1321
import { type TvRemoteButton, toAndroidTvRemoteKeyevent } from '@agent-device/contracts/tv-remote';
1422
import type { DeviceInfo } from '@agent-device/kernel/device';
1523
import { AppError } from '@agent-device/kernel/errors';
24+
import type { Rect } from '@agent-device/kernel/snapshot';
1625
import { sleep } from '@agent-device/host-kit/retry';
1726
import { runAndroidAdb } from './adb.ts';
1827
import { executeAndroidTouchPlan, readAndroidGestureViewport } from './touch-executor.ts';
@@ -163,7 +172,12 @@ export async function focusAndroid(device: DeviceInfo, x: number, y: number): Pr
163172
export async function scrollAndroid(
164173
device: DeviceInfo,
165174
direction: ScrollDirection,
166-
options?: { amount?: number; pixels?: number; durationMs?: number } & AndroidHelperSessionOptions,
175+
options?: {
176+
amount?: number;
177+
pixels?: number;
178+
durationMs?: number;
179+
releaseBehavior?: ScrollReleaseBehavior;
180+
} & AndroidHelperSessionOptions,
167181
): Promise<Record<string, unknown>> {
168182
// The viewport read and the gesture are two helper calls one command apart: giving the read the
169183
// command's session scope keeps both on the same instrumentation.
@@ -192,21 +206,25 @@ export async function scrollAndroid(
192206
options?.durationMs ?? DEFAULT_MOBILE_SCROLL_DURATION_MS,
193207
GESTURE_DURATION_MIN_MS,
194208
);
209+
const gesturePlan = buildGesturePlan(
210+
{
211+
intent: 'pan',
212+
origin: { x: scrollPlan.x1, y: scrollPlan.y1 },
213+
delta: {
214+
x: scrollPlan.x2 - scrollPlan.x1,
215+
y: scrollPlan.y2 - scrollPlan.y1,
216+
},
217+
durationMs,
218+
},
219+
viewport,
220+
'android',
221+
);
222+
const releaseBehavior = options?.releaseBehavior ?? 'controlled';
195223
const backend = await executeAndroidTouchPlan(
196224
device,
197-
buildGesturePlan(
198-
{
199-
intent: 'pan',
200-
origin: { x: scrollPlan.x1, y: scrollPlan.y1 },
201-
delta: {
202-
x: scrollPlan.x2 - scrollPlan.x1,
203-
y: scrollPlan.y2 - scrollPlan.y1,
204-
},
205-
durationMs,
206-
},
207-
viewport,
208-
'android',
209-
),
225+
releaseBehavior === 'controlled'
226+
? withControlledReleaseTail(gesturePlan, viewport, direction)
227+
: gesturePlan,
210228
);
211229

212230
return {
@@ -216,6 +234,58 @@ export async function scrollAndroid(
216234
};
217235
}
218236

237+
// Kept an even multiple of GESTURE_SAMPLE_INTERVAL_MS so the tail's last sample lands back on the
238+
// pan's exact endpoint (an odd multiple would still avoid the fling — every consecutive sample
239+
// still differs — but would leave the release 1px off the requested endpoint).
240+
const CONTROLLED_RELEASE_TAIL_MS = 160;
241+
242+
/**
243+
* A short, quivering tail appended after a 'controlled' scroll's endpoint, adding
244+
* `CONTROLLED_RELEASE_TAIL_MS` of real time to the gesture. AOSP's `InputConsumer::rewriteMessage`
245+
* collapses a MOVE that repeats the previous coordinates into a "resampled" sample, and
246+
* `VelocityTracker` skips resampled samples — so a truly stationary tail never reaches the
247+
* tracker, and `ScrollView.onTouchEvent` (which computes release velocity before applying UP)
248+
* still flings at the pan's velocity. Nudging the axis orthogonal to the scroll by 1px every frame
249+
* (holding the scroll axis exactly at the endpoint — zero velocity there by construction) keeps
250+
* every sample distinct without adding net travel along either axis. Measured fling-free for
251+
* vertical scrolls on a `RecyclerView` and an RN `ScrollView` (issue #2371); not independently
252+
* verified against every OEM skin or a Compose `LazyColumn`. An 'inertial' release (the
253+
* `scroll top`/`scroll bottom` edge passes) lifts at the pan's endpoint unchanged.
254+
*/
255+
function withControlledReleaseTail(
256+
plan: GesturePlan,
257+
viewport: Rect,
258+
direction: ScrollDirection,
259+
): GesturePlan {
260+
if (plan.topology !== 'single') return plan;
261+
const [pointer] = plan.pointers;
262+
const end = pointer.samples.at(-1)!;
263+
const horizontal = direction === 'left' || direction === 'right';
264+
const jitterBase = horizontal ? end.point.y : end.point.x;
265+
const jitterMin = (horizontal ? viewport.y : viewport.x) + 1;
266+
const jitterMax = (horizontal ? viewport.y + viewport.height : viewport.x + viewport.width) - 1;
267+
const nudged = jitterBase + 1 <= jitterMax ? jitterBase + 1 : Math.max(jitterMin, jitterBase - 1);
268+
const steps = CONTROLLED_RELEASE_TAIL_MS / GESTURE_SAMPLE_INTERVAL_MS;
269+
const tail: PointerTrajectorySample[] = Array.from({ length: steps }, (_, index) => {
270+
const jitter = index % 2 === 0 ? nudged : jitterBase;
271+
return {
272+
offsetMs: plan.durationMs + (index + 1) * GESTURE_SAMPLE_INTERVAL_MS,
273+
point: horizontal ? { x: end.point.x, y: jitter } : { x: jitter, y: end.point.y },
274+
};
275+
});
276+
const samples: SinglePointerTrajectory['samples'] = [
277+
pointer.samples[0],
278+
pointer.samples[1],
279+
...pointer.samples.slice(2),
280+
...tail,
281+
];
282+
return {
283+
...plan,
284+
durationMs: plan.durationMs + CONTROLLED_RELEASE_TAIL_MS,
285+
pointers: [{ ...pointer, samples }],
286+
};
287+
}
288+
219289
function resolveAndroidUserRotation(orientation: DeviceRotation): string {
220290
const index = DEVICE_ROTATION_SURFACE_INDEX[orientation];
221291
if (index === undefined) {

0 commit comments

Comments
 (0)