Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions docs/adr/0025-foldable-apple-panels.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
## Status

Accepted (2026-09-20; the pose-settle rule amended 2026-09-21 under #2730; the touch-overlay
export diagnosis corrected 2026-09-22 under #2707). Covers iPhone Duo (iOS 27.1, `iPhone19,4`) and
any Apple device that reports more than one integrated CoreDevice display.
export diagnosis corrected 2026-09-22 under #2707; the Android emulator pose added 2026-10-10). Covers iPhone Duo (iOS 27.1, `iPhone19,4`) and
any Apple device that reports more than one integrated CoreDevice display, and, through the amendment below, foldable Android emulators.

An iPhone Duo carries two integrated panels — Apple's **outer display** and **inner display** —
and lights one of them at a time. Which one is lit is the device pose. Two independent facts
Expand Down Expand Up @@ -536,3 +536,21 @@ incorrect viewport, not an inner-panel delivery prohibition.
hidden-sidebar recovery were checked; the simultaneous same-name simulator matrix remains open.
- **Physical foldables.** Device Hub poses simulators only; the leaf fact refuses a physical device,
and the hinge stream on one was not exercised.

## Android emulators (amendment, 2026-10-10)

The same `fold` command poses a foldable Android emulator, with the two facts of this ADR kept
apart in the same way. There is no host display authority to consult: the emulator console's
`posture <id>` moves the hinge sensor to the angle the AVD profile defines for the posture (0°, 90°,
180° on the Pixel folds; 15°, 90°, 165° on the generic "7.6in Foldable"), and the pose is verified
by polling `cmd device_state print-state` until the guest commits the posture's own device state
(`CLOSED`, `HALF_OPENED`, `OPENED`), which is what `WindowManager`'s `FoldingFeature` derives from;
the hinge sensor is read back for the reported angle, not judged. A `half-open` Android pose is
therefore 90° on every profile seen so far, where the Duo's is 130°.

Folding to the cover display can raise the keyguard (Android's "continue using apps on fold"
setting, which emulator images do not honour); `fold` dismisses it, because every later capture
would otherwise read the lock screen instead of the app. A profile that lists none of those device
states has no hinge and is refused as `single-panel-device`, the typed reason a single-panel
simulator gets. Keyframes and panel geometry stay Apple-only: the console poses only fixed
postures, and Android reports the app window on its next snapshot rather than a panel.
2 changes: 1 addition & 1 deletion docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
| [0022 Daemon — Platform Runtime Coupling Audit and Ownership Ratchets](0022-daemon-platform-runtime-coupling.md) | daemon imports of root `platform-runtime-*` modules, the R76 edge classification inventory, R75 session-authority ratchet, entry-to-platform hop routes and roles |
| [0023 End-State Entry-to-Platform Hop Trace](0023-end-state-hop-trace.md) | the hop counting definition, the ordered `press`/Android and dual-arm `snapshot`/iOS chains, per-hop roles, and the deletion test behind the numbers ADR 0019 and ADR 0022 quote |
| [0024 Screen Recording — One Coordinator, Two Facts (Proposed)](0024-screen-recording-provable-signal.md) | `record start`/`stop` lifecycle, the export-exists / recorder-stopped split, backend stop-and-collect contract, recovery through the coordinator, the ADR 0019 §5 amendment and the failed-finish cleanup fix |
| [0025 Foldable Apple Panels — Capture the Lit Panel](0025-foldable-apple-panels.md) | iPhone Duo outer/inner panels, CoreDevice display info as the only screen authority, why `simctl`'s implicit display default captures a black panel, derived (never asserted) pose, and why no pose-control command exists |
| [0025 Foldable Apple Panels — Capture the Lit Panel](0025-foldable-apple-panels.md) | iPhone Duo outer/inner panels, CoreDevice display info as the only screen authority, why `simctl`'s implicit display default captures a black panel, derived (never asserted) pose, the `fold` pose control, and the Android emulator posture amendment |
| [0026 Scroll Clip Authority — Ownership Is the Parent Edge](0026-scroll-clip-authority.md) | iOS scroll indicators and the visible band, why ownership is read from the parent edge instead of an ancestor walk, what may remove a node from an interactive snapshot, and ejection dispositions |
| [0027 Descriptor Root Size vs Eager-Closure Budget](0027-descriptor-root-vs-eager-closure-budget.md) | accepted: the eager-closure budget tolerates a statement-neutral hub split only (containment + added-module novelty + flat top-level-statement weight) and nothing else; splitting `packages/command-registry/src/registry.ts` is unblocked |
| [0028 Capability-Family Cell Vocabulary — One Runtime Source (Proposed)](0028-capability-family-cell-vocabulary.md) | adding a capability operation family, `UnavailablePlatformRuntimeFacts` / `UNAVAILABLE_CELLS`, `INTERACTOR_OPERATIONS`, and why an eight-package fan-out recurs |
Expand Down
286 changes: 286 additions & 0 deletions packages/platform-android/src/foldable/pose.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
import { beforeEach, expect, test, vi } from 'vitest';

vi.mock('../adb.ts', () => ({ runAndroidAdb: vi.fn(), runAndroidShell: vi.fn() }));
vi.mock('@agent-device/host-kit/retry', async (importOriginal) => {
const actual = await importOriginal<typeof import('@agent-device/host-kit/retry')>();
return { ...actual, sleep: vi.fn(async () => {}) };
});
vi.mock('@agent-device/host-kit/diagnostics', async (importOriginal) => {
const actual = await importOriginal<typeof import('@agent-device/host-kit/diagnostics')>();
return { ...actual, emitDiagnostic: vi.fn() };
});

import { sleep } from '@agent-device/host-kit/retry';

import { runAndroidAdb, runAndroidShell } from '../adb.ts';
import { ANDROID_EMULATOR } from '../runtime.fixtures.ts';
import { setAndroidFoldPose } from './pose.ts';

const mockAdb = vi.mocked(runAndroidAdb);
const mockShell = vi.mocked(runAndroidShell);
const mockSleep = vi.mocked(sleep);

/** `cmd device_state print-states` on a Pixel fold: the posture states are numbered from 0. */
const PIXEL_FOLD_STATES = `Supported states: [
DeviceState{identifier=0, name='CLOSED', app_accessible=true, cancel_when_requester_not_on_top=false},
DeviceState{identifier=1, name='HALF_OPENED', app_accessible=true, cancel_when_requester_not_on_top=false},
DeviceState{identifier=2, name='OPENED', app_accessible=true, cancel_when_requester_not_on_top=false},
DeviceState{identifier=3, name='REAR_DISPLAY_MODE', app_accessible=true, cancel_when_requester_not_on_top=false},
]
`;
/** The generic "7.6in Foldable" profile numbers the same states from 1. */
const GENERIC_FOLDABLE_STATES = `Supported states: [
DeviceState{identifier=1, name='CLOSED', app_accessible=true, cancel_when_requester_not_on_top=false},
DeviceState{identifier=2, name='HALF_OPENED', app_accessible=true, cancel_when_requester_not_on_top=false},
DeviceState{identifier=3, name='OPENED', app_accessible=true, cancel_when_requester_not_on_top=false},
]
`;
/** A profile with a hinge that lists only the two end stops. */
const END_STOPS_ONLY_STATES = `Supported states: [
DeviceState{identifier=0, name='CLOSED', app_accessible=true, cancel_when_requester_not_on_top=false},
DeviceState{identifier=2, name='OPENED', app_accessible=true, cancel_when_requester_not_on_top=false},
]
`;
/** A phone profile has one state and no hinge. */
const PHONE_STATES = `Supported states: [
DeviceState{identifier=0, name='DEFAULT', app_accessible=true, cancel_when_requester_not_on_top=false},
]
`;

function answer(stdout: string) {
return { stdout, stderr: '', exitCode: 0 };
}

/** The last entry of a read sequence repeats, as a device holding its state would. */
function nextRead<T>(reads: T[]): T {
return reads.length > 1 ? reads.shift()! : reads[0]!;
}

/**
* One emulator, answered by command: `stateReads` are the successive `print-state` replies,
* `keyguardReads` the successive `isKeyguardShowing` values, `posture` the console's reply.
*/
function stubEmulator(options: {
states?: string;
stateReads: string[];
keyguardReads?: boolean[];
posture?: string;
hinge?: string;
}) {
const stateReads = [...options.stateReads];
const keyguardReads = [...(options.keyguardReads ?? [false])];
mockShell.mockImplementation(async (_device, words) => {
const command = words.join(' ');
if (command === 'cmd device_state print-states') {
return answer(options.states ?? PIXEL_FOLD_STATES);
}
if (command === 'cmd device_state print-state') return answer(`${nextRead(stateReads)}\n`);
if (command === 'dumpsys window') {
return answer(` isKeyguardShowing=${nextRead(keyguardReads)}\n`);
}
if (command === 'wm dismiss-keyguard') return answer('');
throw new Error(`unexpected shell command: ${command}`);
});
mockAdb.mockImplementation(async (_device, args) => {
const command = args.join(' ');
if (command.startsWith('emu posture ')) return answer(options.posture ?? 'OK\n');
if (command === 'emu sensor get hinge-angle0') {
return answer(options.hinge ?? 'hinge-angle0 = 0\nOK\n');
}
throw new Error(`unexpected adb command: ${command}`);
});
}

function adbCommands(): string[] {
return mockAdb.mock.calls.map(([, args]) => args.join(' '));
}

function shellCommands(): string[] {
return mockShell.mock.calls.map(([, words]) => words.join(' '));
}

beforeEach(() => {
mockAdb.mockReset();
mockShell.mockReset();
mockSleep.mockClear();
});

test('sets the console posture, waits for the device state, and dismisses the lock screen the fold raised', async () => {
// No keyguard before the fold; the first state read still sees the open posture; the lock
// screen shows once and is gone for three reads after the dismissal.
stubEmulator({ stateReads: ['2', '0'], keyguardReads: [false, true, false, false, false] });

await expect(setAndroidFoldPose(ANDROID_EMULATOR, { pose: 'closed' })).resolves.toEqual({
pose: 'closed',
hingeAngleDegrees: 0,
});

expect(adbCommands()).toEqual(['emu posture 1', 'emu sensor get hinge-angle0']);
expect(shellCommands()).toEqual([
'cmd device_state print-states',
'dumpsys window',
'cmd device_state print-state',
'cmd device_state print-state',
'dumpsys window',
'wm dismiss-keyguard',
'dumpsys window',
'dumpsys window',
'dumpsys window',
]);
});

test('waits before the first lock-screen read, so a keyguard landing after the state is still caught', async () => {
// No keyguard before the fold or at the moment the state commits; it shows up on the next read.
stubEmulator({ stateReads: ['0'], keyguardReads: [false, false, true, false, false, false] });

await expect(setAndroidFoldPose(ANDROID_EMULATOR, { pose: 'closed' })).resolves.toEqual({
pose: 'closed',
hingeAngleDegrees: 0,
});
expect(shellCommands().filter((command) => command === 'wm dismiss-keyguard')).toHaveLength(1);
// One poll precedes every keyguard read: five reads, five sleeps, none from the state settle.
expect(mockSleep).toHaveBeenCalledTimes(5);
});

test('looks the device state up by name, so a profile numbered from 1 verifies the same pose', async () => {
stubEmulator({
states: GENERIC_FOLDABLE_STATES,
stateReads: ['2'],
hinge: 'hinge-angle0 = 90\nOK\n',
});

await expect(setAndroidFoldPose(ANDROID_EMULATOR, { pose: 'half-open' })).resolves.toEqual({
pose: 'half-open',
hingeAngleDegrees: 90,
});
expect(adbCommands()[0]).toBe('emu posture 2');
// Unfolding lights the inner display and raises no keyguard, so no lock-screen read is paid.
expect(shellCommands()).toEqual([
'cmd device_state print-states',
'cmd device_state print-state',
]);
expect(mockSleep).not.toHaveBeenCalled();
});

test('refuses a posture the profile cannot commit with its own reason, not as single-panel', async () => {
stubEmulator({ states: END_STOPS_ONLY_STATES, stateReads: ['0'] });

await expect(setAndroidFoldPose(ANDROID_EMULATOR, { pose: 'half-open' })).rejects.toMatchObject({
code: 'UNSUPPORTED_OPERATION',
details: {
reason: 'fold-posture-unsupported',
supportedPoses: ['closed', 'open'],
deviceStates: ['CLOSED', 'OPENED'],
hint: 'This profile poses only closed and open; request one of those.',
},
});
expect(mockAdb).not.toHaveBeenCalled();
});

test('a failed print-states read is an adb failure, never an empty state list read as a phone', async () => {
// The managed scoped transport hands back a nonzero result instead of throwing.
mockShell.mockResolvedValue({
stdout: '',
stderr: "cmd: Can't find service: device_state",
exitCode: 1,
});

await expect(setAndroidFoldPose(ANDROID_EMULATOR, { pose: 'closed' })).rejects.toMatchObject({
code: 'COMMAND_FAILED',
details: { exitCode: 1, processExitError: true },
});
await expect(setAndroidFoldPose(ANDROID_EMULATOR, { pose: 'closed' })).rejects.not.toMatchObject({
details: { reason: 'single-panel-device' },
});
expect(mockAdb).not.toHaveBeenCalled();
});

test('refuses a phone profile as single-panel-device before touching the console', async () => {
stubEmulator({ states: PHONE_STATES, stateReads: ['0'] });

await expect(setAndroidFoldPose(ANDROID_EMULATOR, { pose: 'open' })).rejects.toMatchObject({
code: 'UNSUPPORTED_OPERATION',
details: { reason: 'single-panel-device' },
});
expect(mockAdb).not.toHaveBeenCalled();
});

test('refuses keyframes before touching the device', async () => {
stubEmulator({ stateReads: ['0'] });

await expect(
setAndroidFoldPose(ANDROID_EMULATOR, {
keyframes: [
{ atMs: 0, angle: 0 },
{ atMs: 1000, angle: 180 },
],
}),
).rejects.toMatchObject({
code: 'UNSUPPORTED_OPERATION',
details: { reason: 'fold-keyframes-unsupported' },
});
expect(mockAdb).not.toHaveBeenCalled();
expect(mockShell).not.toHaveBeenCalled();
});

test('reads a KO reply as a dispatch failure even though the console exits 0', async () => {
stubEmulator({ stateReads: ['0'], posture: 'KO: Posture 1 not supported\n' });

await expect(setAndroidFoldPose(ANDROID_EMULATOR, { pose: 'closed' })).rejects.toMatchObject({
code: 'COMMAND_FAILED',
details: { reason: 'fold-posture-dispatch-failed' },
});
});

test('fails as fold-pose-unverified when the guest never commits the device state', async () => {
stubEmulator({ stateReads: ['2'] });

await expect(setAndroidFoldPose(ANDROID_EMULATOR, { pose: 'closed' })).rejects.toMatchObject({
code: 'COMMAND_FAILED',
details: { reason: 'fold-pose-unverified', observedDeviceState: 'OPENED' },
});
// The settle is bounded: it ended, every retry was preceded by one poll, and the budget ended
// with a read rather than an unobserved interval.
const stateReads = shellCommands().filter(
(command) => command === 'cmd device_state print-state',
);
expect(stateReads.length).toBeGreaterThan(1);
expect(mockSleep).toHaveBeenCalledTimes(stateReads.length - 1);
expect(shellCommands().at(-1)).toBe('cmd device_state print-state');
});

test('reports the sensor angle as read, without judging it against the pose', async () => {
// Profiles park the hinge at their own angles (the generic "7.6in Foldable" closes at 15°,
// the midpoint of its range), so the committed device state is the pose and the angle is
// information: even one no profile would give for closed is reported, not refused.
stubEmulator({
states: GENERIC_FOLDABLE_STATES,
stateReads: ['1'],
hinge: 'hinge-angle0 = 77\nOK\n',
});

await expect(setAndroidFoldPose(ANDROID_EMULATOR, { pose: 'closed' })).resolves.toEqual({
pose: 'closed',
hingeAngleDegrees: 77,
});
});

test('leaves a keyguard that was already showing before the fold alone', async () => {
stubEmulator({ stateReads: ['0'], keyguardReads: [true] });

await expect(setAndroidFoldPose(ANDROID_EMULATOR, { pose: 'closed' })).resolves.toEqual({
pose: 'closed',
hingeAngleDegrees: 0,
});
expect(shellCommands()).not.toContain('wm dismiss-keyguard');
expect(shellCommands().filter((command) => command === 'dumpsys window')).toHaveLength(1);
});

test('fails when the lock screen the fold raised keeps coming back', async () => {
stubEmulator({ stateReads: ['0'], keyguardReads: [false, true] });

await expect(setAndroidFoldPose(ANDROID_EMULATOR, { pose: 'closed' })).rejects.toMatchObject({
code: 'COMMAND_FAILED',
details: { reason: 'fold-lock-screen-persists' },
});
});
Loading
Loading