Skip to content

Commit cfb529b

Browse files
committed
fix(android): return from orientation once the display reports the rotation
`orientation` wrote accelerometer_rotation and user_rotation and returned at once, while the display rotated some time later. On the loaded CI emulator that takes seconds, and accessibility reads hang meanwhile: the Android smoke's `wait text landscape` right after `orientation landscape-left` got a helper request timeout and then no readable capture for its whole 10s budget, with the failed-step snapshot taken afterwards already in landscape (PR #2344, run 34025424834). The command now polls `dumpsys display` for mCurrentOrientation to match the requested rotation before returning, each probe bounded by what is left of the 15s settle budget so a stuck probe ends the settle as a failure. A display that never gets there fails the command with the observed rotation instead of reporting success; a display that reports no rotation at all is left to the setting as before. The provider scenario scripts the display read against the last user_rotation write.
1 parent 5ba4ac7 commit cfb529b

6 files changed

Lines changed: 203 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@
99
opening the request log. Long waits keep the first five and last twenty-five polls. The replay
1010
landmark-mismatch refusal carries the same poll evidence next to its mismatch details; `wait
1111
--stable` timeouts and a never-readable strict absence keep their existing diagnostics.
12+
- Fixed: Android `orientation` now returns once the display reports the requested rotation
13+
(polling `dumpsys display`, up to 15s) instead of right after writing the settings. On a loaded
14+
emulator the rotation takes seconds, during which accessibility reads hang, so the next command
15+
paid for the transition; a `wait` issued right after `orientation` could spend its whole budget
16+
there. A display that never reaches the requested rotation now fails the command with the
17+
observed rotation instead of reporting success; a display that reports no rotation is left to
18+
the setting as before.
1219
- Fixed: the iOS Simulator AX snapshot route bounds how long a capture waits for app discovery
1320
and stops starting a discovery per capture. Discovery (`simctl launchctl list` through xcrun)
1421
takes seconds on a loaded host; a capture now waits at most 1.5s for the one in-flight

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

Lines changed: 107 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { test } from 'vitest';
1+
import { test, vi } from 'vitest';
22
import assert from 'node:assert/strict';
33
import {
44
backAndroid,
@@ -175,15 +175,114 @@ test('pressAndroidEnter presses the ENTER keyevent', async () => {
175175
);
176176
});
177177

178-
test('setAndroidOrientation locks auto-rotate and sets user rotation', async () => {
178+
// The orientation settle polls at its own interval; the clock is the assertion, not the wait.
179+
vi.mock('@agent-device/host-kit/retry', () => ({ sleep: async () => {} }));
180+
181+
const ORIENTATION_CALLS = [
182+
['shell', 'settings', 'put', 'system', 'accelerometer_rotation', '0'],
183+
['shell', 'settings', 'put', 'system', 'user_rotation', '1'],
184+
];
185+
const DISPLAY_READ = ['shell', 'dumpsys', 'display'];
186+
187+
function displayReporting(rotations: string[]): (args: string[]) => string | undefined {
188+
let reads = 0;
189+
return (args) => {
190+
if (args[1] !== 'dumpsys') return undefined;
191+
const rotation = rotations[Math.min(reads, rotations.length - 1)];
192+
reads += 1;
193+
return rotation === undefined ? '' : ` mCurrentOrientation=${rotation}\n`;
194+
};
195+
}
196+
197+
test('setAndroidOrientation locks auto-rotate, sets user rotation, and returns once the display rotated', async () => {
198+
await withFakeAdb(displayReporting(['0', '0', '1']), async ({ calls, device }) => {
199+
await setAndroidOrientation(device, 'landscape-left');
200+
assert.deepEqual(calls, [...ORIENTATION_CALLS, DISPLAY_READ, DISPLAY_READ, DISPLAY_READ]);
201+
});
202+
});
203+
204+
test('setAndroidOrientation fails when the display never reports the requested rotation', async () => {
205+
vi.useFakeTimers({ now: 0, toFake: ['Date'] });
206+
const probeBudgets: number[] = [];
207+
try {
208+
await withFakeAdb(
209+
(args, options) => {
210+
// Every display read costs wall clock; the display stays where it was.
211+
if (args[1] === 'dumpsys') {
212+
probeBudgets.push(options?.timeoutMs ?? -1);
213+
vi.setSystemTime(Date.now() + 4_000);
214+
}
215+
return displayReporting(['0'])(args);
216+
},
217+
async ({ calls, device }) => {
218+
await assert.rejects(setAndroidOrientation(device, 'landscape-left'), (error: unknown) => {
219+
assert.ok(error instanceof Error);
220+
assert.match(error.message, /orientation landscape-left did not take effect/);
221+
const details = (error as { details?: Record<string, unknown> }).details ?? {};
222+
assert.equal(details.requestedRotation, 1);
223+
assert.equal(details.observedRotation, 0);
224+
return true;
225+
});
226+
assert.ok(calls.filter((call) => call[1] === 'dumpsys').length >= 4);
227+
// Each probe may use only what is left of the 15s settle budget.
228+
assert.equal(probeBudgets[0], 15_000);
229+
for (let index = 1; index < probeBudgets.length; index += 1) {
230+
assert.ok(probeBudgets[index]! > 0 && probeBudgets[index]! < probeBudgets[index - 1]!);
231+
}
232+
},
233+
);
234+
} finally {
235+
vi.useRealTimers();
236+
}
237+
});
238+
239+
test('a display probe that hangs for the whole budget ends the settle as a failure', async () => {
240+
vi.useFakeTimers({ now: 0, toFake: ['Date'] });
241+
try {
242+
await withFakeAdb(
243+
(args, options) => {
244+
if (args[1] !== 'dumpsys') return undefined;
245+
// The probe blocks until its own timeout, which is the whole remaining budget.
246+
vi.setSystemTime(Date.now() + (options?.timeoutMs ?? 0));
247+
return new Error(`adb shell dumpsys display timed out after ${options?.timeoutMs}ms`);
248+
},
249+
async ({ calls, device }) => {
250+
await assert.rejects(
251+
setAndroidOrientation(device, 'landscape-left'),
252+
/orientation landscape-left could not confirm the display rotation: adb shell dumpsys display timed out after 15000ms/,
253+
);
254+
assert.equal(calls.filter((call) => call[1] === 'dumpsys').length, 1);
255+
assert.equal(Date.now(), 15_000);
256+
},
257+
);
258+
} finally {
259+
vi.useRealTimers();
260+
}
261+
});
262+
263+
test('a display probe that exits non-zero fails the settle instead of passing as no field', async () => {
179264
await withFakeAdb(
180-
() => undefined,
265+
(args) =>
266+
args[1] === 'dumpsys'
267+
? { stdout: '', stderr: 'dumpsys: permission denied', exitCode: 1 }
268+
: undefined,
181269
async ({ calls, device }) => {
182-
await setAndroidOrientation(device, 'landscape-left');
183-
assert.deepEqual(calls, [
184-
['shell', 'settings', 'put', 'system', 'accelerometer_rotation', '0'],
185-
['shell', 'settings', 'put', 'system', 'user_rotation', '1'],
186-
]);
270+
await assert.rejects(
271+
setAndroidOrientation(device, 'landscape-left'),
272+
/orientation landscape-left could not confirm the display rotation: .*exited with code 1/,
273+
);
274+
assert.equal(calls.filter((call) => call[1] === 'dumpsys').length, 1);
187275
},
188276
);
189277
});
278+
279+
test('setAndroidOrientation leaves a display that reports no rotation to the setting', async () => {
280+
await withFakeAdb(displayReporting([]), async ({ calls, device }) => {
281+
await setAndroidOrientation(device, 'portrait');
282+
assert.deepEqual(calls, [
283+
['shell', 'settings', 'put', 'system', 'accelerometer_rotation', '0'],
284+
['shell', 'settings', 'put', 'system', 'user_rotation', '0'],
285+
DISPLAY_READ,
286+
]);
287+
});
288+
});

packages/platform-android/src/__tests__/test-utils/fake-adb.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ import { ANDROID_EMULATOR } from './device-fixtures.ts';
1010
import { bindAndroidAdbTestHost } from './android-host-test-setup.ts';
1111

1212
export type FakeAdbResponse = string | Partial<AndroidAdbExecutorResult> | Error;
13-
export type FakeAdbScript = (args: string[]) => FakeAdbResponse | undefined;
13+
export type FakeAdbScript = (
14+
args: string[],
15+
options?: AndroidAdbExecutorOptions,
16+
) => FakeAdbResponse | undefined;
1417

1518
export type FakeAdbProviderExtras = AndroidAdbProvider extends infer P
1619
? P extends AndroidAdbProvider
@@ -34,7 +37,7 @@ export async function withFakeAdb<T>(
3437
execOptions?: AndroidAdbExecutorOptions,
3538
): Promise<AndroidAdbExecutorResult> => {
3639
calls.push([...args]);
37-
const response = script(args);
40+
const response = script(args, execOptions);
3841
if (response instanceof Error) throw response;
3942
const result: AndroidAdbExecutorResult =
4043
typeof response === 'string'

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import { type TvRemoteButton, toAndroidTvRemoteKeyevent } from '@agent-device/contracts/tv-remote';
1414
import type { DeviceInfo } from '@agent-device/kernel/device';
1515
import { AppError } from '@agent-device/kernel/errors';
16+
import { sleep } from '@agent-device/host-kit/retry';
1617
import { runAndroidAdb } from './adb.ts';
1718
import { executeAndroidTouchPlan, readAndroidGestureViewport } from './touch-executor.ts';
1819
import type { AndroidHelperSessionOptions } from './snapshot-helper-types.ts';
@@ -64,6 +65,68 @@ export async function setAndroidOrientation(
6465
'user_rotation',
6566
userRotation,
6667
]);
68+
await settleAndroidOrientation(device, orientation, userRotation);
69+
}
70+
71+
const ORIENTATION_SETTLE_TIMEOUT_MS = 15_000;
72+
const ORIENTATION_SETTLE_POLL_MS = 500;
73+
74+
/**
75+
* The display rotates some time after the setting lands; on a loaded emulator that takes
76+
* seconds, during which accessibility reads hang. Returning once the display reports the
77+
* requested rotation keeps the next command from paying for the transition. A display that never
78+
* gets there is a fact the caller must see (a foreground app pinning its orientation, a device
79+
* ignoring `user_rotation`); one that reports no rotation at all cannot be checked and is left to
80+
* the setting.
81+
*/
82+
async function settleAndroidOrientation(
83+
device: DeviceInfo,
84+
orientation: DeviceRotation,
85+
userRotation: string,
86+
): Promise<void> {
87+
const deadline = Date.now() + ORIENTATION_SETTLE_TIMEOUT_MS;
88+
let observed = await readAndroidDisplayRotation(device, orientation, deadline);
89+
while (observed !== undefined && observed !== userRotation && Date.now() < deadline) {
90+
await sleep(Math.min(ORIENTATION_SETTLE_POLL_MS, remainingMs(deadline)));
91+
observed = await readAndroidDisplayRotation(device, orientation, deadline);
92+
}
93+
if (observed === undefined || observed === userRotation) return;
94+
throw new AppError(
95+
'COMMAND_FAILED',
96+
`orientation ${orientation} did not take effect: the display still reports rotation ${observed} after ${ORIENTATION_SETTLE_TIMEOUT_MS}ms`,
97+
{
98+
requestedRotation: Number(userRotation),
99+
observedRotation: Number(observed),
100+
hint: 'The foreground app may pin its orientation, or the device may ignore user_rotation. Check `adb shell dumpsys display | grep mCurrentOrientation` and the app manifest.',
101+
},
102+
);
103+
}
104+
105+
/**
106+
* One display read, bounded by what is left of the settle budget so a stuck probe ends the
107+
* settle. A probe that fails (non-zero exit, timeout) is a failed settle, never "no field".
108+
*/
109+
async function readAndroidDisplayRotation(
110+
device: DeviceInfo,
111+
orientation: DeviceRotation,
112+
deadline: number,
113+
): Promise<string | undefined> {
114+
try {
115+
const result = await runAndroidAdb(device, ['shell', 'dumpsys', 'display'], {
116+
timeoutMs: remainingMs(deadline),
117+
});
118+
return /mCurrentOrientation=(\d)/.exec(result.stdout)?.[1];
119+
} catch (error) {
120+
throw new AppError(
121+
'COMMAND_FAILED',
122+
`orientation ${orientation} could not confirm the display rotation: ${error instanceof Error ? error.message : String(error)}`,
123+
{ hint: 'The device did not answer `dumpsys display` within the orientation budget.' },
124+
);
125+
}
126+
}
127+
128+
function remainingMs(deadline: number): number {
129+
return Math.max(1, deadline - Date.now());
67130
}
68131

69132
export async function appSwitcherAndroid(device: DeviceInfo): Promise<void> {

test/integration/provider-scenarios/android-ime-lifecycle-world.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export type AndroidProviderShellState = {
88
searchText: string;
99
clipboardText: string;
1010
secureSettings: Map<string, string>;
11+
/** The last `settings put system user_rotation`; the scripted display reports it back. */
12+
userRotation: string;
1113
};
1214

1315
const IME_INPUT_TEXT_ACTION = 'com.callstack.agentdevice.imehelper.ACTION_INPUT_TEXT_B64';
@@ -51,6 +53,7 @@ export function createAndroidProviderShellState(): AndroidProviderShellState {
5153
searchText: '',
5254
clipboardText: 'hello',
5355
secureSettings: new Map([['default_input_method', 'com.android.inputmethod.latin/.LatinIME']]),
56+
userRotation: '0',
5457
};
5558
}
5659

test/integration/provider-scenarios/android-world.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@ export function respondToAndroidSettingsAdbCommand(
235235
): { stdout: string; stderr: string; exitCode: number; stdoutBuffer?: Buffer } {
236236
const key = args.join(' ');
237237
const result =
238+
androidDisplayRotationAdbResult(key, options.ime) ??
238239
androidDeviceAvailabilityAdbResult(key, args, options.pidof) ??
239240
androidImeLifecycleAdbResult(key, args, options.ime) ??
240241
androidClipboardAdbResult(key, clipboardText) ??
@@ -254,6 +255,10 @@ type AndroidAdbResult = {
254255
const ANDROID_CLIPBOARD_SET_TEXT_PREFIX = ['shell', 'cmd', 'clipboard', 'set', 'text'];
255256

256257
function updateAndroidProviderShellState(args: string[], state: AndroidProviderShellState): void {
258+
if (argsStartWith(args, ['shell', 'settings', 'put', 'system', 'user_rotation'])) {
259+
state.userRotation = String(args[5] ?? '0');
260+
return;
261+
}
257262
if (args[0] === 'shell' && args[1] === 'input' && args[2] === 'text') {
258263
state.searchText = String(args[3] ?? '').replaceAll('%s', ' ');
259264
return;
@@ -484,6 +489,19 @@ function androidPermissionMutationAdbResult(args: string[]): AndroidAdbResult |
484489
return undefined;
485490
}
486491

492+
/** The scripted display rotates the moment `user_rotation` lands, the way the settle expects. */
493+
function androidDisplayRotationAdbResult(
494+
key: string,
495+
state: AndroidProviderShellState | undefined,
496+
): AndroidAdbResult | undefined {
497+
if (key !== 'shell dumpsys display') return undefined;
498+
return {
499+
stdout: ` mCurrentOrientation=${state?.userRotation ?? '0'}\n`,
500+
stderr: '',
501+
exitCode: 0,
502+
};
503+
}
504+
487505
function androidSettingsPutAdbResult(args: string[]): AndroidAdbResult | undefined {
488506
if (
489507
args.length === 6 &&

0 commit comments

Comments
 (0)