From 3f4cf34fe8dc5243548b5ecd111ba89c6f04bfe9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 7 Sep 2026 10:44:46 +0200 Subject: [PATCH 1/4] 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 --- .../src/__tests__/input-actions.test.ts | 125 +++++++++++++++++- .../platform-android/src/input-actions.ts | 102 +++++++++++--- 2 files changed, 210 insertions(+), 17 deletions(-) diff --git a/packages/platform-android/src/__tests__/input-actions.test.ts b/packages/platform-android/src/__tests__/input-actions.test.ts index a6f55b214a..68ae0fe72e 100644 --- a/packages/platform-android/src/__tests__/input-actions.test.ts +++ b/packages/platform-android/src/__tests__/input-actions.test.ts @@ -1,5 +1,6 @@ import { test, vi } from 'vitest'; import assert from 'node:assert/strict'; +import { GESTURE_SAMPLE_INTERVAL_MS } from '@agent-device/contracts/gesture-plan'; import { backAndroid, homeAndroid, @@ -79,7 +80,14 @@ test('scrollAndroid accepts sub-frame public durations at the Android planner mi async () => { const outputs: Record[] = []; for (const durationMs of [0, 15]) { - outputs.push(await scrollAndroid(ANDROID_EMULATOR, 'down', { durationMs })); + // 'inertial' keeps the injected plan's durationMs equal to the honored move time, so the + // flooring this test targets is not conflated with the 'controlled' release tail below. + outputs.push( + await scrollAndroid(ANDROID_EMULATOR, 'down', { + durationMs, + releaseBehavior: 'inertial', + }), + ); } return outputs; }, @@ -95,6 +103,121 @@ test('scrollAndroid accepts sub-frame public durations at the Android planner mi ); }); +test('scrollAndroid defaults to a controlled release: a quivering tail past the pan endpoint', async () => { + const touchCalls: Parameters[0][] = []; + await withAndroidAdbProvider( + { + exec: async () => { + throw new Error('adb must not run'); + }, + gestureViewport: async () => ({ x: 10, y: 20, width: 1080, height: 1920 }), + touch: async (request) => { + touchCalls.push(request); + return { injected: true }; + }, + }, + { serial: ANDROID_EMULATOR.id }, + async () => await scrollAndroid(ANDROID_EMULATOR, 'down', { pixels: 240, durationMs: 120 }), + ); + + assert.equal(touchCalls.length, 1); + const [touch] = touchCalls; + const samples = touch!.pointers[0]!.samples; + const endpoint = samples.find((sample) => sample.offsetMs === 120)!; + const tail = samples.filter((sample) => sample.offsetMs > 120); + + // The plan carries a >=100ms tail past the honored 120ms move; the CLI-facing `durationMs` in + // the command result (asserted below) stays at the honored move time. + assert.equal(touch!.durationMs, 280); + assert.ok(tail.length >= 100 / GESTURE_SAMPLE_INTERVAL_MS); + for (const sample of tail) assert.equal(sample.point.y, endpoint.point.y); + const allPastEndpoint = [endpoint, ...tail]; + for (let index = 1; index < allPastEndpoint.length; index += 1) { + assert.notEqual(allPastEndpoint[index]!.point.x, allPastEndpoint[index - 1]!.point.x); + } +}); + +test('scrollAndroid composes the duration floor with the default controlled-release tail', async () => { + const touchCalls: Parameters[0][] = []; + await withAndroidAdbProvider( + { + exec: async () => { + throw new Error('adb must not run'); + }, + gestureViewport: async () => ({ x: 0, y: 0, width: 1080, height: 1920 }), + touch: async (request) => { + touchCalls.push(request); + }, + }, + { serial: ANDROID_EMULATOR.id }, + async () => await scrollAndroid(ANDROID_EMULATOR, 'down', { durationMs: 0 }), + ); + + // The move floors to the Android planner minimum (16ms) before the tail is appended, not after. + assert.equal(touchCalls[0]!.durationMs, GESTURE_SAMPLE_INTERVAL_MS + 160); +}); + +test('scrollAndroid jitters the axis orthogonal to a horizontal scroll, holding the scroll axis fixed', async () => { + const touchCalls: Parameters[0][] = []; + await withAndroidAdbProvider( + { + exec: async () => { + throw new Error('adb must not run'); + }, + gestureViewport: async () => ({ x: 10, y: 20, width: 1080, height: 1920 }), + touch: async (request) => { + touchCalls.push(request); + return { injected: true }; + }, + }, + { serial: ANDROID_EMULATOR.id }, + async () => await scrollAndroid(ANDROID_EMULATOR, 'left', { pixels: 240, durationMs: 120 }), + ); + + assert.equal(touchCalls.length, 1); + const [touch] = touchCalls; + const samples = touch!.pointers[0]!.samples; + const endpoint = samples.find((sample) => sample.offsetMs === 120)!; + const tail = samples.filter((sample) => sample.offsetMs > 120); + + assert.ok(tail.length >= 100 / GESTURE_SAMPLE_INTERVAL_MS); + // The scroll axis (x, for a horizontal scroll) stays exactly at the endpoint — zero velocity + // there by construction; only the orthogonal axis (y) jitters to dodge the resampling quirk. + for (const sample of tail) assert.equal(sample.point.x, endpoint.point.x); + const allPastEndpoint = [endpoint, ...tail]; + for (let index = 1; index < allPastEndpoint.length; index += 1) { + assert.notEqual(allPastEndpoint[index]!.point.y, allPastEndpoint[index - 1]!.point.y); + } +}); + +test('scrollAndroid honors an inertial release (scroll top/bottom): lifts at the pan endpoint', async () => { + const touchCalls: Parameters[0][] = []; + await withAndroidAdbProvider( + { + exec: async () => { + throw new Error('adb must not run'); + }, + gestureViewport: async () => ({ x: 10, y: 20, width: 1080, height: 1920 }), + touch: async (request) => { + touchCalls.push(request); + return { injected: true }; + }, + }, + { serial: ANDROID_EMULATOR.id }, + async () => + await scrollAndroid(ANDROID_EMULATOR, 'down', { + pixels: 240, + durationMs: 120, + releaseBehavior: 'inertial', + }), + ); + + assert.equal(touchCalls.length, 1); + const [touch] = touchCalls; + assert.equal(touch!.durationMs, 120); + assert.equal(touch!.pointers[0]!.samples.at(-1)!.offsetMs, 120); +}); + test('longPressAndroid sends a stationary semantic touch plan', async () => { const touchCalls: Parameters[0][] = []; const result = await withAndroidAdbProvider( diff --git a/packages/platform-android/src/input-actions.ts b/packages/platform-android/src/input-actions.ts index 607122ed4a..9b8127d7d6 100644 --- a/packages/platform-android/src/input-actions.ts +++ b/packages/platform-android/src/input-actions.ts @@ -3,9 +3,17 @@ * IME, and the adb-shell writer — is `text-input.ts`. */ import { DEVICE_ROTATION_SURFACE_INDEX, type DeviceRotation } from '@agent-device/contracts/device'; -import { buildGesturePlan } from '@agent-device/contracts/gesture-plan'; +import { GESTURE_SAMPLE_INTERVAL_MS, buildGesturePlan } from '@agent-device/contracts/gesture-plan'; import { GESTURE_DURATION_MIN_MS } from '@agent-device/contracts/gesture-plan-types'; -import { DEFAULT_MOBILE_SCROLL_DURATION_MS } from '@agent-device/contracts/scroll-command'; +import type { + GesturePlan, + PointerTrajectorySample, + SinglePointerTrajectory, +} from '@agent-device/contracts/gesture-plan-types'; +import { + DEFAULT_MOBILE_SCROLL_DURATION_MS, + type ScrollReleaseBehavior, +} from '@agent-device/contracts/scroll-command'; import { type ScrollDirection, buildScrollGesturePlan, @@ -13,6 +21,7 @@ import { import { type TvRemoteButton, toAndroidTvRemoteKeyevent } from '@agent-device/contracts/tv-remote'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; +import type { Rect } from '@agent-device/kernel/snapshot'; import { sleep } from '@agent-device/host-kit/retry'; import { runAndroidAdb } from './adb.ts'; import { executeAndroidTouchPlan, readAndroidGestureViewport } from './touch-executor.ts'; @@ -163,7 +172,12 @@ export async function focusAndroid(device: DeviceInfo, x: number, y: number): Pr export async function scrollAndroid( device: DeviceInfo, direction: ScrollDirection, - options?: { amount?: number; pixels?: number; durationMs?: number } & AndroidHelperSessionOptions, + options?: { + amount?: number; + pixels?: number; + durationMs?: number; + releaseBehavior?: ScrollReleaseBehavior; + } & AndroidHelperSessionOptions, ): Promise> { // The viewport read and the gesture are two helper calls one command apart: giving the read the // command's session scope keeps both on the same instrumentation. @@ -192,21 +206,25 @@ export async function scrollAndroid( options?.durationMs ?? DEFAULT_MOBILE_SCROLL_DURATION_MS, GESTURE_DURATION_MIN_MS, ); + const gesturePlan = buildGesturePlan( + { + intent: 'pan', + origin: { x: scrollPlan.x1, y: scrollPlan.y1 }, + delta: { + x: scrollPlan.x2 - scrollPlan.x1, + y: scrollPlan.y2 - scrollPlan.y1, + }, + durationMs, + }, + viewport, + 'android', + ); + const releaseBehavior = options?.releaseBehavior ?? 'controlled'; const backend = await executeAndroidTouchPlan( device, - buildGesturePlan( - { - intent: 'pan', - origin: { x: scrollPlan.x1, y: scrollPlan.y1 }, - delta: { - x: scrollPlan.x2 - scrollPlan.x1, - y: scrollPlan.y2 - scrollPlan.y1, - }, - durationMs, - }, - viewport, - 'android', - ), + releaseBehavior === 'controlled' + ? withControlledReleaseTail(gesturePlan, viewport, direction) + : gesturePlan, ); return { @@ -216,6 +234,58 @@ export async function scrollAndroid( }; } +// Kept an even multiple of GESTURE_SAMPLE_INTERVAL_MS so the tail's last sample lands back on the +// pan's exact endpoint (an odd multiple would still avoid the fling — every consecutive sample +// still differs — but would leave the release 1px off the requested endpoint). +const CONTROLLED_RELEASE_TAIL_MS = 160; + +/** + * A short, quivering tail appended after a 'controlled' scroll's endpoint, adding + * `CONTROLLED_RELEASE_TAIL_MS` of real time to the gesture. AOSP's `InputConsumer::rewriteMessage` + * collapses a MOVE that repeats the previous coordinates into a "resampled" sample, and + * `VelocityTracker` skips resampled samples — so a truly stationary tail never reaches the + * tracker, and `ScrollView.onTouchEvent` (which computes release velocity before applying UP) + * still flings at the pan's velocity. Nudging the axis orthogonal to the scroll by 1px every frame + * (holding the scroll axis exactly at the endpoint — zero velocity there by construction) keeps + * every sample distinct without adding net travel along either axis. Measured fling-free for + * vertical scrolls on a `RecyclerView` and an RN `ScrollView` (issue #2371); not independently + * verified against every OEM skin or a Compose `LazyColumn`. An 'inertial' release (the + * `scroll top`/`scroll bottom` edge passes) lifts at the pan's endpoint unchanged. + */ +function withControlledReleaseTail( + plan: GesturePlan, + viewport: Rect, + direction: ScrollDirection, +): GesturePlan { + if (plan.topology !== 'single') return plan; + const [pointer] = plan.pointers; + const end = pointer.samples.at(-1)!; + const horizontal = direction === 'left' || direction === 'right'; + const jitterBase = horizontal ? end.point.y : end.point.x; + const jitterMin = (horizontal ? viewport.y : viewport.x) + 1; + const jitterMax = (horizontal ? viewport.y + viewport.height : viewport.x + viewport.width) - 1; + const nudged = jitterBase + 1 <= jitterMax ? jitterBase + 1 : Math.max(jitterMin, jitterBase - 1); + const steps = CONTROLLED_RELEASE_TAIL_MS / GESTURE_SAMPLE_INTERVAL_MS; + const tail: PointerTrajectorySample[] = Array.from({ length: steps }, (_, index) => { + const jitter = index % 2 === 0 ? nudged : jitterBase; + return { + offsetMs: plan.durationMs + (index + 1) * GESTURE_SAMPLE_INTERVAL_MS, + point: horizontal ? { x: end.point.x, y: jitter } : { x: jitter, y: end.point.y }, + }; + }); + const samples: SinglePointerTrajectory['samples'] = [ + pointer.samples[0], + pointer.samples[1], + ...pointer.samples.slice(2), + ...tail, + ]; + return { + ...plan, + durationMs: plan.durationMs + CONTROLLED_RELEASE_TAIL_MS, + pointers: [{ ...pointer, samples }], + }; +} + function resolveAndroidUserRotation(orientation: DeviceRotation): string { const index = DEVICE_ROTATION_SURFACE_INDEX[orientation]; if (index === undefined) { From 5ab05308848d2b90110482025ece8ccf7a3dd551 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 7 Sep 2026 12:59:35 +0200 Subject: [PATCH 2/4] fix(android): cap the controlled-release tail at the gesture duration ceiling The release tail was appended after buildGesturePlan's own duration validation, so a maximum-duration (10000ms) scroll silently produced a 10160ms plan beyond GESTURE_DURATION_MAX_MS. Cap the tail so the dispatched plan never exceeds that shared ceiling, shrinking (and, at the exact maximum, dropping) the tail near the boundary instead of truncating the requested move. Add regression coverage for the exact maximum and near-maximum cases. Also fix the stale android-lifecycle provider-integration expectation: a plain scroll resolves to 'controlled' release by default, so its dispatched plan is now 510ms (350ms move + 160ms tail), not 350ms. --- .../src/__tests__/input-actions.test.ts | 59 +++++++++++++++++++ .../platform-android/src/input-actions.ts | 20 +++++-- .../android-lifecycle.test.ts | 7 ++- 3 files changed, 81 insertions(+), 5 deletions(-) diff --git a/packages/platform-android/src/__tests__/input-actions.test.ts b/packages/platform-android/src/__tests__/input-actions.test.ts index 68ae0fe72e..24ab8784c3 100644 --- a/packages/platform-android/src/__tests__/input-actions.test.ts +++ b/packages/platform-android/src/__tests__/input-actions.test.ts @@ -1,6 +1,7 @@ import { test, vi } from 'vitest'; import assert from 'node:assert/strict'; import { GESTURE_SAMPLE_INTERVAL_MS } from '@agent-device/contracts/gesture-plan'; +import { GESTURE_DURATION_MAX_MS } from '@agent-device/contracts/gesture-plan-types'; import { backAndroid, homeAndroid, @@ -157,6 +158,64 @@ test('scrollAndroid composes the duration floor with the default controlled-rele assert.equal(touchCalls[0]!.durationMs, GESTURE_SAMPLE_INTERVAL_MS + 160); }); +test('scrollAndroid caps the controlled-release tail at the maximum gesture duration without shortening the move', async () => { + const touchCalls: Parameters[0][] = []; + const result = await withAndroidAdbProvider( + { + exec: async () => { + throw new Error('adb must not run'); + }, + gestureViewport: async () => ({ x: 0, y: 0, width: 1080, height: 1920 }), + touch: async (request) => { + touchCalls.push(request); + }, + }, + { serial: ANDROID_EMULATOR.id }, + async () => + await scrollAndroid(ANDROID_EMULATOR, 'down', { + pixels: 1800, + durationMs: GESTURE_DURATION_MAX_MS, + }), + ); + + // The requested move is honored in full — never truncated to make room for the tail. + assert.equal(result.durationMs, GESTURE_DURATION_MAX_MS); + const [touch] = touchCalls; + // At the exact ceiling there is no headroom left for a tail; the dispatched plan still never + // exceeds GESTURE_DURATION_MAX_MS, the same boundary every gesture plan is built under. + assert.equal(touch!.durationMs, GESTURE_DURATION_MAX_MS); + assert.equal(touch!.pointers[0]!.samples.at(-1)!.offsetMs, GESTURE_DURATION_MAX_MS); +}); + +test('scrollAndroid shrinks (but keeps) the controlled-release tail just under the maximum gesture duration', async () => { + const touchCalls: Parameters[0][] = []; + const nearMaxDurationMs = GESTURE_DURATION_MAX_MS - 50; + const result = await withAndroidAdbProvider( + { + exec: async () => { + throw new Error('adb must not run'); + }, + gestureViewport: async () => ({ x: 0, y: 0, width: 1080, height: 1920 }), + touch: async (request) => { + touchCalls.push(request); + }, + }, + { serial: ANDROID_EMULATOR.id }, + async () => + await scrollAndroid(ANDROID_EMULATOR, 'down', { + pixels: 1800, + durationMs: nearMaxDurationMs, + }), + ); + + assert.equal(result.durationMs, nearMaxDurationMs); + const [touch] = touchCalls; + // Only 50ms of headroom below the ceiling: the tail shrinks to 3 whole 16ms steps (48ms) + // rather than pushing the plan past GESTURE_DURATION_MAX_MS. + assert.equal(touch!.durationMs, nearMaxDurationMs + 48); + assert.ok(touch!.durationMs <= GESTURE_DURATION_MAX_MS); +}); + test('scrollAndroid jitters the axis orthogonal to a horizontal scroll, holding the scroll axis fixed', async () => { const touchCalls: Parameters[0][] = []; await withAndroidAdbProvider( diff --git a/packages/platform-android/src/input-actions.ts b/packages/platform-android/src/input-actions.ts index 9b8127d7d6..af6782473d 100644 --- a/packages/platform-android/src/input-actions.ts +++ b/packages/platform-android/src/input-actions.ts @@ -4,7 +4,10 @@ */ import { DEVICE_ROTATION_SURFACE_INDEX, type DeviceRotation } from '@agent-device/contracts/device'; import { GESTURE_SAMPLE_INTERVAL_MS, buildGesturePlan } from '@agent-device/contracts/gesture-plan'; -import { GESTURE_DURATION_MIN_MS } from '@agent-device/contracts/gesture-plan-types'; +import { + GESTURE_DURATION_MAX_MS, + GESTURE_DURATION_MIN_MS, +} from '@agent-device/contracts/gesture-plan-types'; import type { GesturePlan, PointerTrajectorySample, @@ -240,7 +243,7 @@ export async function scrollAndroid( const CONTROLLED_RELEASE_TAIL_MS = 160; /** - * A short, quivering tail appended after a 'controlled' scroll's endpoint, adding + * A short, quivering tail appended after a 'controlled' scroll's endpoint, adding up to * `CONTROLLED_RELEASE_TAIL_MS` of real time to the gesture. AOSP's `InputConsumer::rewriteMessage` * collapses a MOVE that repeats the previous coordinates into a "resampled" sample, and * `VelocityTracker` skips resampled samples — so a truly stationary tail never reaches the @@ -251,6 +254,10 @@ const CONTROLLED_RELEASE_TAIL_MS = 160; * vertical scrolls on a `RecyclerView` and an RN `ScrollView` (issue #2371); not independently * verified against every OEM skin or a Compose `LazyColumn`. An 'inertial' release (the * `scroll top`/`scroll bottom` edge passes) lifts at the pan's endpoint unchanged. + * + * The dispatched plan never exceeds `GESTURE_DURATION_MAX_MS` — the same ceiling every gesture + * plan is built under — so the tail shrinks (and, at the exact maximum, disappears) for a move + * already at or near that limit; the requested move itself is never shortened to make room. */ function withControlledReleaseTail( plan: GesturePlan, @@ -258,6 +265,12 @@ function withControlledReleaseTail( direction: ScrollDirection, ): GesturePlan { if (plan.topology !== 'single') return plan; + const tailMs = Math.max( + 0, + Math.min(CONTROLLED_RELEASE_TAIL_MS, GESTURE_DURATION_MAX_MS - plan.durationMs), + ); + const steps = Math.floor(tailMs / GESTURE_SAMPLE_INTERVAL_MS); + if (steps === 0) return plan; const [pointer] = plan.pointers; const end = pointer.samples.at(-1)!; const horizontal = direction === 'left' || direction === 'right'; @@ -265,7 +278,6 @@ function withControlledReleaseTail( const jitterMin = (horizontal ? viewport.y : viewport.x) + 1; const jitterMax = (horizontal ? viewport.y + viewport.height : viewport.x + viewport.width) - 1; const nudged = jitterBase + 1 <= jitterMax ? jitterBase + 1 : Math.max(jitterMin, jitterBase - 1); - const steps = CONTROLLED_RELEASE_TAIL_MS / GESTURE_SAMPLE_INTERVAL_MS; const tail: PointerTrajectorySample[] = Array.from({ length: steps }, (_, index) => { const jitter = index % 2 === 0 ? nudged : jitterBase; return { @@ -281,7 +293,7 @@ function withControlledReleaseTail( ]; return { ...plan, - durationMs: plan.durationMs + CONTROLLED_RELEASE_TAIL_MS, + durationMs: plan.durationMs + steps * GESTURE_SAMPLE_INTERVAL_MS, pointers: [{ ...pointer, samples }], }; } diff --git a/test/integration/provider-scenarios/android-lifecycle.test.ts b/test/integration/provider-scenarios/android-lifecycle.test.ts index a66d35e013..96a4b29a8f 100644 --- a/test/integration/provider-scenarios/android-lifecycle.test.ts +++ b/test/integration/provider-scenarios/android-lifecycle.test.ts @@ -226,7 +226,12 @@ test(ANDROID_TOUCH_CONTRACT_EVIDENCE.testName, async () => { })); assert.deepEqual(touchCalls, [ { topology: 'single', intent: 'longPress', pointerCount: 1, durationMs: 750 }, - { topology: 'single', intent: 'pan', pointerCount: 1, durationMs: 350 }, + // `scroll down` has no edge, so it resolves to the default 'controlled' release: the + // requested 350ms move plus the 160ms release tail that defeats Android's fling (#2371). + // 'inertial' (the `scroll top`/`scroll bottom` edge passes, which lift at the pan's + // endpoint unchanged) is covered at the unit level in + // packages/platform-android/src/__tests__/input-actions.test.ts. + { topology: 'single', intent: 'pan', pointerCount: 1, durationMs: 510 }, { topology: 'single', intent: 'pan', pointerCount: 1, durationMs: 300 }, { topology: 'single', intent: 'pan', pointerCount: 1, durationMs: 500 }, { topology: 'two', intent: 'pan', pointerCount: 2, durationMs: 500 }, From daa39df49d59026ae43004b3399e804e88abae14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 7 Sep 2026 17:11:04 +0200 Subject: [PATCH 3/4] fix(android): reject a controlled scroll that leaves the release tail no room, instead of shrinking it The previous fix capped the tail near GESTURE_DURATION_MAX_MS, which silently dropped the fling-suppressing braking step for scrolls close to the ceiling -- defeating the point of the fix exactly where a long scroll needs it. A controlled scroll's own accepted duration range now stops CONTROLLED_RELEASE_TAIL_MS short of that shared ceiling (9840ms), rejecting anything past it with a clear INVALID_ARGS error instead of truncating the move or the tail. Every accepted controlled scroll now runs the full, unshortened tail; 'inertial' releases are unaffected and keep the full 10000ms range. Also drop the multi-line explanatory comment on the corrected android-lifecycle.test.ts expectation (a same-line comment instead) so the fix doesn't grow that file past its line-count tripwire. --- .../src/__tests__/input-actions.test.ts | 50 +++++++++++++------ .../platform-android/src/input-actions.ts | 39 ++++++++++----- .../android-lifecycle.test.ts | 7 +-- 3 files changed, 63 insertions(+), 33 deletions(-) diff --git a/packages/platform-android/src/__tests__/input-actions.test.ts b/packages/platform-android/src/__tests__/input-actions.test.ts index 24ab8784c3..12b0ca8126 100644 --- a/packages/platform-android/src/__tests__/input-actions.test.ts +++ b/packages/platform-android/src/__tests__/input-actions.test.ts @@ -158,8 +158,9 @@ test('scrollAndroid composes the duration floor with the default controlled-rele assert.equal(touchCalls[0]!.durationMs, GESTURE_SAMPLE_INTERVAL_MS + 160); }); -test('scrollAndroid caps the controlled-release tail at the maximum gesture duration without shortening the move', async () => { +test('scrollAndroid runs the full controlled-release tail at the largest duration that still leaves it room', async () => { const touchCalls: Parameters[0][] = []; + const maxControlledMoveMs = GESTURE_DURATION_MAX_MS - 160; const result = await withAndroidAdbProvider( { exec: async () => { @@ -174,23 +175,45 @@ test('scrollAndroid caps the controlled-release tail at the maximum gesture dura async () => await scrollAndroid(ANDROID_EMULATOR, 'down', { pixels: 1800, - durationMs: GESTURE_DURATION_MAX_MS, + durationMs: maxControlledMoveMs, }), ); - // The requested move is honored in full — never truncated to make room for the tail. - assert.equal(result.durationMs, GESTURE_DURATION_MAX_MS); + // The requested move is honored in full, and the tail always runs at its full length — the + // dispatched plan lands exactly at GESTURE_DURATION_MAX_MS, never past it. + assert.equal(result.durationMs, maxControlledMoveMs); const [touch] = touchCalls; - // At the exact ceiling there is no headroom left for a tail; the dispatched plan still never - // exceeds GESTURE_DURATION_MAX_MS, the same boundary every gesture plan is built under. assert.equal(touch!.durationMs, GESTURE_DURATION_MAX_MS); assert.equal(touch!.pointers[0]!.samples.at(-1)!.offsetMs, GESTURE_DURATION_MAX_MS); }); -test('scrollAndroid shrinks (but keeps) the controlled-release tail just under the maximum gesture duration', async () => { +test('scrollAndroid rejects a controlled-release durationMs that would leave the release tail no room, without shortening the move', async () => { + await withAndroidAdbProvider( + { + exec: async () => { + throw new Error('adb must not run'); + }, + gestureViewport: async () => ({ x: 0, y: 0, width: 1080, height: 1920 }), + touch: async () => { + throw new Error('touch must not run for a rejected request'); + }, + }, + { serial: ANDROID_EMULATOR.id }, + async () => { + await assert.rejects( + scrollAndroid(ANDROID_EMULATOR, 'down', { + pixels: 1800, + durationMs: GESTURE_DURATION_MAX_MS - 159, + }), + /scroll durationMs must be at most 9840 for a controlled release/, + ); + }, + ); +}); + +test("scrollAndroid accepts the full GESTURE_DURATION_MAX_MS for an 'inertial' release, which needs no tail", async () => { const touchCalls: Parameters[0][] = []; - const nearMaxDurationMs = GESTURE_DURATION_MAX_MS - 50; - const result = await withAndroidAdbProvider( + await withAndroidAdbProvider( { exec: async () => { throw new Error('adb must not run'); @@ -204,16 +227,13 @@ test('scrollAndroid shrinks (but keeps) the controlled-release tail just under t async () => await scrollAndroid(ANDROID_EMULATOR, 'down', { pixels: 1800, - durationMs: nearMaxDurationMs, + durationMs: GESTURE_DURATION_MAX_MS, + releaseBehavior: 'inertial', }), ); - assert.equal(result.durationMs, nearMaxDurationMs); const [touch] = touchCalls; - // Only 50ms of headroom below the ceiling: the tail shrinks to 3 whole 16ms steps (48ms) - // rather than pushing the plan past GESTURE_DURATION_MAX_MS. - assert.equal(touch!.durationMs, nearMaxDurationMs + 48); - assert.ok(touch!.durationMs <= GESTURE_DURATION_MAX_MS); + assert.equal(touch!.durationMs, GESTURE_DURATION_MAX_MS); }); test('scrollAndroid jitters the axis orthogonal to a horizontal scroll, holding the scroll axis fixed', async () => { diff --git a/packages/platform-android/src/input-actions.ts b/packages/platform-android/src/input-actions.ts index af6782473d..7e94fbc940 100644 --- a/packages/platform-android/src/input-actions.ts +++ b/packages/platform-android/src/input-actions.ts @@ -209,6 +209,8 @@ export async function scrollAndroid( options?.durationMs ?? DEFAULT_MOBILE_SCROLL_DURATION_MS, GESTURE_DURATION_MIN_MS, ); + const releaseBehavior = options?.releaseBehavior ?? 'controlled'; + if (releaseBehavior === 'controlled') assertRoomForControlledReleaseTail(durationMs); const gesturePlan = buildGesturePlan( { intent: 'pan', @@ -222,7 +224,6 @@ export async function scrollAndroid( viewport, 'android', ); - const releaseBehavior = options?.releaseBehavior ?? 'controlled'; const backend = await executeAndroidTouchPlan( device, releaseBehavior === 'controlled' @@ -242,8 +243,28 @@ export async function scrollAndroid( // still differs — but would leave the release 1px off the requested endpoint). const CONTROLLED_RELEASE_TAIL_MS = 160; +// The dispatched plan (move + tail) must never exceed GESTURE_DURATION_MAX_MS, the same ceiling +// every gesture plan is built under. Rather than silently dropping the tail for a move that +// leaves it no room, a controlled scroll's own accepted range stops short of the shared ceiling +// by the tail's length — the full tail runs for every accepted controlled scroll, and a request +// past this narrower range is rejected with the reason, not truncated. +const CONTROLLED_RELEASE_MAX_MOVE_MS = GESTURE_DURATION_MAX_MS - CONTROLLED_RELEASE_TAIL_MS; + +function assertRoomForControlledReleaseTail(durationMs: number): void { + if (durationMs <= CONTROLLED_RELEASE_MAX_MOVE_MS) return; + throw new AppError( + 'INVALID_ARGS', + `scroll durationMs must be at most ${CONTROLLED_RELEASE_MAX_MOVE_MS} for a controlled release ` + + `(leaves room for the ${CONTROLLED_RELEASE_TAIL_MS}ms release tail within the ` + + `${GESTURE_DURATION_MAX_MS}ms gesture ceiling)`, + { + hint: "Pass a shorter durationMs, or releaseBehavior 'inertial' if the fling is acceptable.", + }, + ); +} + /** - * A short, quivering tail appended after a 'controlled' scroll's endpoint, adding up to + * A short, quivering tail appended after a 'controlled' scroll's endpoint, adding * `CONTROLLED_RELEASE_TAIL_MS` of real time to the gesture. AOSP's `InputConsumer::rewriteMessage` * collapses a MOVE that repeats the previous coordinates into a "resampled" sample, and * `VelocityTracker` skips resampled samples — so a truly stationary tail never reaches the @@ -255,9 +276,8 @@ const CONTROLLED_RELEASE_TAIL_MS = 160; * verified against every OEM skin or a Compose `LazyColumn`. An 'inertial' release (the * `scroll top`/`scroll bottom` edge passes) lifts at the pan's endpoint unchanged. * - * The dispatched plan never exceeds `GESTURE_DURATION_MAX_MS` — the same ceiling every gesture - * plan is built under — so the tail shrinks (and, at the exact maximum, disappears) for a move - * already at or near that limit; the requested move itself is never shortened to make room. + * Callers must have already checked `assertRoomForControlledReleaseTail` on the move duration — + * this always appends the full tail. */ function withControlledReleaseTail( plan: GesturePlan, @@ -265,12 +285,7 @@ function withControlledReleaseTail( direction: ScrollDirection, ): GesturePlan { if (plan.topology !== 'single') return plan; - const tailMs = Math.max( - 0, - Math.min(CONTROLLED_RELEASE_TAIL_MS, GESTURE_DURATION_MAX_MS - plan.durationMs), - ); - const steps = Math.floor(tailMs / GESTURE_SAMPLE_INTERVAL_MS); - if (steps === 0) return plan; + const steps = CONTROLLED_RELEASE_TAIL_MS / GESTURE_SAMPLE_INTERVAL_MS; const [pointer] = plan.pointers; const end = pointer.samples.at(-1)!; const horizontal = direction === 'left' || direction === 'right'; @@ -293,7 +308,7 @@ function withControlledReleaseTail( ]; return { ...plan, - durationMs: plan.durationMs + steps * GESTURE_SAMPLE_INTERVAL_MS, + durationMs: plan.durationMs + CONTROLLED_RELEASE_TAIL_MS, pointers: [{ ...pointer, samples }], }; } diff --git a/test/integration/provider-scenarios/android-lifecycle.test.ts b/test/integration/provider-scenarios/android-lifecycle.test.ts index 96a4b29a8f..b62fa06345 100644 --- a/test/integration/provider-scenarios/android-lifecycle.test.ts +++ b/test/integration/provider-scenarios/android-lifecycle.test.ts @@ -226,12 +226,7 @@ test(ANDROID_TOUCH_CONTRACT_EVIDENCE.testName, async () => { })); assert.deepEqual(touchCalls, [ { topology: 'single', intent: 'longPress', pointerCount: 1, durationMs: 750 }, - // `scroll down` has no edge, so it resolves to the default 'controlled' release: the - // requested 350ms move plus the 160ms release tail that defeats Android's fling (#2371). - // 'inertial' (the `scroll top`/`scroll bottom` edge passes, which lift at the pan's - // endpoint unchanged) is covered at the unit level in - // packages/platform-android/src/__tests__/input-actions.test.ts. - { topology: 'single', intent: 'pan', pointerCount: 1, durationMs: 510 }, + { topology: 'single', intent: 'pan', pointerCount: 1, durationMs: 510 }, // 'controlled': 350ms move + 160ms tail (#2371) { topology: 'single', intent: 'pan', pointerCount: 1, durationMs: 300 }, { topology: 'single', intent: 'pan', pointerCount: 1, durationMs: 500 }, { topology: 'two', intent: 'pan', pointerCount: 2, durationMs: 500 }, From 01bb115ac261d898eb1c5623e9a7eec82a00476d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 8 Sep 2026 07:55:40 +0200 Subject: [PATCH 4/4] docs(scroll): describe reduced momentum and the Android controlled-scroll limit The scroll docs only carried the generic app-physics caveat, not the release-behavior contract itself. State plainly that scroll releases with reduced momentum rather than an exact stop, and document Android's controlled-release durationMs ceiling (9840ms, reserved headroom for the braking tail added in #2371) and that a longer request is rejected rather than silently truncated. --- src/commands/interaction/metadata.ts | 2 +- website/docs/docs/commands.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/commands/interaction/metadata.ts b/src/commands/interaction/metadata.ts index 5c004007a4..f24bbc6f2e 100644 --- a/src/commands/interaction/metadata.ts +++ b/src/commands/interaction/metadata.ts @@ -68,7 +68,7 @@ const interactionCommandDescriptions = { '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.', 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.', scroll: - '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.', + '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; the gesture releases with reduced momentum rather than stopping at an exact offset, so app scroll physics determine where content actually lands.', 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.', 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.', find: 'Find by text/label/value/role/id and run action', diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 7c93a218f4..560f244557 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -485,7 +485,8 @@ Target-authored drag is supported on Android touch devices and iOS/iPadOS. Backe `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. 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. 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. -`scroll` accepts either a relative amount (`0.5` means a finger path spanning half of the viewport on that axis) or `--pixels ` 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. +`scroll` accepts either a relative amount (`0.5` means a finger path spanning half of the viewport on that axis) or `--pixels ` for a fixed-distance gesture. It releases with reduced momentum toward the requested distance, not an exact stop there — the final content offset can still 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. +On Android, a plain `scroll ` releases in this reduced-momentum ("controlled") mode by default — it appends a short braking tail after the drag so the list does not fling past the requested distance — while `scroll top`/`scroll bottom` release inertially instead, coasting to the edge. That braking tail needs headroom within Android's 10000ms gesture ceiling, so a controlled scroll's own `--duration-ms` accepts at most 9840ms; a longer request is rejected with `INVALID_ARGS` rather than silently shortened or losing the braking tail. Default snapshot text output is visible-first, so off-screen interactive content is summarized instead of shown as tappable refs. When a target only appears in an off-screen summary, use `scroll --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: