Skip to content

Commit dd7a6d7

Browse files
thiagobrezclaude
andcommitted
feat(android): fold a foldable emulator through the console posture
`fold closed|half-open|open` now poses a foldable Android emulator. The emulator console's `posture <id>` moves the hinge sensor to the posture's angle (0, 90, 180 degrees); the pose is verified by polling `cmd device_state print-state` until the guest commits the posture's device state, looked up by name since profiles number the states differently, and the hinge sensor is read back for the reported angle. A fold to the cover display can raise Android's "Swipe up to continue" lock screen on Pixel images; it is dismissed so the app stays reachable. A phone profile is refused as `single-panel-device`, a physical device by kind, and keyframes with `fold-keyframes-unsupported`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent ca227e2 commit dd7a6d7

11 files changed

Lines changed: 645 additions & 25 deletions

File tree

‎docs/adr/0025-foldable-apple-panels.md‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
## Status
44

55
Accepted (2026-09-20; the pose-settle rule amended 2026-09-21 under #2730; the touch-overlay
6-
export diagnosis corrected 2026-09-22 under #2707). Covers iPhone Duo (iOS 27.1, `iPhone19,4`) and
6+
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
77
any Apple device that reports more than one integrated CoreDevice display.
88

99
An iPhone Duo carries two integrated panels — Apple's **outer display** and **inner display** —
@@ -536,3 +536,19 @@ incorrect viewport, not an inner-panel delivery prohibition.
536536
hidden-sidebar recovery were checked; the simultaneous same-name simulator matrix remains open.
537537
- **Physical foldables.** Device Hub poses simulators only; the leaf fact refuses a physical device,
538538
and the hinge stream on one was not exercised.
539+
540+
## Android emulators (amendment, 2026-10-10)
541+
542+
The same `fold` command poses a foldable Android emulator, with the two facts of this ADR kept
543+
apart in the same way. There is no host display authority to consult: the emulator console's
544+
`posture <id>` moves the hinge sensor to a fixed angle (0°, 90°, 180°), and the pose is verified by
545+
polling `cmd device_state print-state` until the guest commits the posture's own device state
546+
(`CLOSED`, `HALF_OPENED`, `OPENED`); the hinge sensor is read back for the reported angle. A
547+
`half-open` Android pose is therefore 90°, the emulator's definition, where the Duo's is 130°.
548+
549+
Folding to the cover display can raise the keyguard (Android's "continue using apps on fold"
550+
setting, which emulator images do not honour); `fold` dismisses it, because every later capture
551+
would otherwise read the lock screen instead of the app. A profile that lists none of those device
552+
states has no hinge and is refused as `single-panel-device`, the typed reason a single-panel
553+
simulator gets. Keyframes and panel geometry stay Apple-only: the console poses only fixed
554+
postures, and Android reports the app window on its next snapshot rather than a panel.

‎docs/adr/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
| [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 |
2727
| [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 |
2828
| [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 |
29-
| [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 |
29+
| [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 |
3030
| [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 |
3131
| [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 |
3232
| [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 |
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import { beforeEach, expect, test, vi } from 'vitest';
2+
3+
vi.mock('../adb.ts', () => ({ runAndroidAdb: vi.fn(), runAndroidShell: vi.fn() }));
4+
vi.mock('@agent-device/host-kit/retry', async (importOriginal) => {
5+
const actual = await importOriginal<typeof import('@agent-device/host-kit/retry')>();
6+
return { ...actual, sleep: vi.fn(async () => {}) };
7+
});
8+
vi.mock('@agent-device/host-kit/diagnostics', async (importOriginal) => {
9+
const actual = await importOriginal<typeof import('@agent-device/host-kit/diagnostics')>();
10+
return { ...actual, emitDiagnostic: vi.fn() };
11+
});
12+
13+
import { sleep } from '@agent-device/host-kit/retry';
14+
15+
import { runAndroidAdb, runAndroidShell } from '../adb.ts';
16+
import { ANDROID_EMULATOR } from '../runtime.fixtures.ts';
17+
import { ANDROID_FOLD_SETTLE_ATTEMPTS, setAndroidFoldPose } from './pose.ts';
18+
19+
const mockAdb = vi.mocked(runAndroidAdb);
20+
const mockShell = vi.mocked(runAndroidShell);
21+
const mockSleep = vi.mocked(sleep);
22+
23+
/** `cmd device_state print-states` on a Pixel fold: the posture states are numbered from 0. */
24+
const PIXEL_FOLD_STATES = `Supported states: [
25+
DeviceState{identifier=0, name='CLOSED', app_accessible=true, cancel_when_requester_not_on_top=false},
26+
DeviceState{identifier=1, name='HALF_OPENED', app_accessible=true, cancel_when_requester_not_on_top=false},
27+
DeviceState{identifier=2, name='OPENED', app_accessible=true, cancel_when_requester_not_on_top=false},
28+
DeviceState{identifier=3, name='REAR_DISPLAY_MODE', app_accessible=true, cancel_when_requester_not_on_top=false},
29+
]
30+
`;
31+
/** The generic "7.6in Foldable" profile numbers the same states from 1. */
32+
const GENERIC_FOLDABLE_STATES = `Supported states: [
33+
DeviceState{identifier=1, name='CLOSED', app_accessible=true, cancel_when_requester_not_on_top=false},
34+
DeviceState{identifier=2, name='HALF_OPENED', app_accessible=true, cancel_when_requester_not_on_top=false},
35+
DeviceState{identifier=3, name='OPENED', app_accessible=true, cancel_when_requester_not_on_top=false},
36+
]
37+
`;
38+
/** A phone profile has one state and no hinge. */
39+
const PHONE_STATES = `Supported states: [
40+
DeviceState{identifier=0, name='DEFAULT', app_accessible=true, cancel_when_requester_not_on_top=false},
41+
]
42+
`;
43+
44+
function answer(stdout: string) {
45+
return { stdout, stderr: '', exitCode: 0 };
46+
}
47+
48+
/** The last entry of a read sequence repeats, as a device holding its state would. */
49+
function nextRead<T>(reads: T[]): T {
50+
return reads.length > 1 ? reads.shift()! : reads[0]!;
51+
}
52+
53+
/**
54+
* One emulator, answered by command: `stateReads` are the successive `print-state` replies,
55+
* `keyguardReads` the successive `isKeyguardShowing` values, `posture` the console's reply.
56+
*/
57+
function stubEmulator(options: {
58+
states?: string;
59+
stateReads: string[];
60+
keyguardReads?: boolean[];
61+
posture?: string;
62+
hinge?: string;
63+
}) {
64+
const stateReads = [...options.stateReads];
65+
const keyguardReads = [...(options.keyguardReads ?? [false])];
66+
mockShell.mockImplementation(async (_device, words) => {
67+
const command = words.join(' ');
68+
if (command === 'cmd device_state print-states') {
69+
return answer(options.states ?? PIXEL_FOLD_STATES);
70+
}
71+
if (command === 'cmd device_state print-state') return answer(`${nextRead(stateReads)}\n`);
72+
if (command === 'dumpsys window') {
73+
return answer(` isKeyguardShowing=${nextRead(keyguardReads)}\n`);
74+
}
75+
if (command === 'wm dismiss-keyguard') return answer('');
76+
throw new Error(`unexpected shell command: ${command}`);
77+
});
78+
mockAdb.mockImplementation(async (_device, args) => {
79+
const command = args.join(' ');
80+
if (command.startsWith('emu posture ')) return answer(options.posture ?? 'OK\n');
81+
if (command === 'emu sensor get hinge-angle0') {
82+
return answer(options.hinge ?? 'hinge-angle0 = 0\nOK\n');
83+
}
84+
throw new Error(`unexpected adb command: ${command}`);
85+
});
86+
}
87+
88+
function adbCommands(): string[] {
89+
return mockAdb.mock.calls.map(([, args]) => args.join(' '));
90+
}
91+
92+
function shellCommands(): string[] {
93+
return mockShell.mock.calls.map(([, words]) => words.join(' '));
94+
}
95+
96+
beforeEach(() => {
97+
mockAdb.mockReset();
98+
mockShell.mockReset();
99+
mockSleep.mockClear();
100+
});
101+
102+
test('sets the console posture, waits for the device state, and dismisses the lock screen the fold raised', async () => {
103+
// The first state read still sees the open posture; the lock screen shows once and is gone
104+
// for two reads after the dismissal.
105+
stubEmulator({ stateReads: ['2', '0'], keyguardReads: [true, false, false] });
106+
107+
await expect(setAndroidFoldPose(ANDROID_EMULATOR, { pose: 'closed' })).resolves.toEqual({
108+
pose: 'closed',
109+
hingeAngleDegrees: 0,
110+
});
111+
112+
expect(adbCommands()).toEqual(['emu posture 1', 'emu sensor get hinge-angle0']);
113+
expect(shellCommands()).toEqual([
114+
'cmd device_state print-states',
115+
'cmd device_state print-state',
116+
'cmd device_state print-state',
117+
'dumpsys window',
118+
'wm dismiss-keyguard',
119+
'dumpsys window',
120+
'dumpsys window',
121+
]);
122+
});
123+
124+
test('looks the device state up by name, so a profile numbered from 1 verifies the same pose', async () => {
125+
stubEmulator({
126+
states: GENERIC_FOLDABLE_STATES,
127+
stateReads: ['2'],
128+
hinge: 'hinge-angle0 = 90\nOK\n',
129+
});
130+
131+
await expect(setAndroidFoldPose(ANDROID_EMULATOR, { pose: 'half-open' })).resolves.toEqual({
132+
pose: 'half-open',
133+
hingeAngleDegrees: 90,
134+
});
135+
expect(adbCommands()[0]).toBe('emu posture 2');
136+
expect(shellCommands()).not.toContain('wm dismiss-keyguard');
137+
});
138+
139+
test('refuses a phone profile as single-panel-device before touching the console', async () => {
140+
stubEmulator({ states: PHONE_STATES, stateReads: ['0'] });
141+
142+
await expect(setAndroidFoldPose(ANDROID_EMULATOR, { pose: 'open' })).rejects.toMatchObject({
143+
code: 'UNSUPPORTED_OPERATION',
144+
details: { reason: 'single-panel-device' },
145+
});
146+
expect(mockAdb).not.toHaveBeenCalled();
147+
});
148+
149+
test('refuses keyframes before touching the device', async () => {
150+
stubEmulator({ stateReads: ['0'] });
151+
152+
await expect(
153+
setAndroidFoldPose(ANDROID_EMULATOR, {
154+
keyframes: [
155+
{ atMs: 0, angle: 0 },
156+
{ atMs: 1000, angle: 180 },
157+
],
158+
}),
159+
).rejects.toMatchObject({
160+
code: 'UNSUPPORTED_OPERATION',
161+
details: { reason: 'fold-keyframes-unsupported' },
162+
});
163+
expect(mockAdb).not.toHaveBeenCalled();
164+
expect(mockShell).not.toHaveBeenCalled();
165+
});
166+
167+
test('reads a KO reply as a dispatch failure even though the console exits 0', async () => {
168+
stubEmulator({ stateReads: ['0'], posture: 'KO: Posture 1 not supported\n' });
169+
170+
await expect(setAndroidFoldPose(ANDROID_EMULATOR, { pose: 'closed' })).rejects.toMatchObject({
171+
code: 'COMMAND_FAILED',
172+
details: { reason: 'fold-posture-dispatch-failed' },
173+
});
174+
});
175+
176+
test('fails as fold-pose-unverified when the guest never commits the device state', async () => {
177+
stubEmulator({ stateReads: ['2'] });
178+
179+
await expect(setAndroidFoldPose(ANDROID_EMULATOR, { pose: 'closed' })).rejects.toMatchObject({
180+
code: 'COMMAND_FAILED',
181+
details: { reason: 'fold-pose-unverified', observedDeviceState: 'OPENED' },
182+
});
183+
expect(mockSleep).toHaveBeenCalledTimes(ANDROID_FOLD_SETTLE_ATTEMPTS);
184+
});
185+
186+
test('fails as fold-pose-unverified when the hinge sensor disagrees with the device state', async () => {
187+
stubEmulator({ stateReads: ['0'], hinge: 'hinge-angle0 = 180\nOK\n' });
188+
189+
await expect(setAndroidFoldPose(ANDROID_EMULATOR, { pose: 'closed' })).rejects.toMatchObject({
190+
code: 'COMMAND_FAILED',
191+
details: { reason: 'fold-pose-unverified', hingeAngleDegrees: 180 },
192+
});
193+
});
194+
195+
test('fails when the lock screen keeps coming back', async () => {
196+
stubEmulator({ stateReads: ['0'], keyguardReads: [true] });
197+
198+
await expect(setAndroidFoldPose(ANDROID_EMULATOR, { pose: 'closed' })).rejects.toMatchObject({
199+
code: 'COMMAND_FAILED',
200+
details: { reason: 'fold-lock-screen-persists' },
201+
});
202+
});

0 commit comments

Comments
 (0)