Skip to content

feat(android): fold a foldable emulator through the console posture - #3361

Merged
thymikee merged 10 commits into
callstack:mainfrom
thiagobrez:feat/android-emulator-fold
Oct 10, 2026
Merged

thymikee merged 10 commits into
callstack:mainfrom
thiagobrez:feat/android-emulator-fold

Conversation

@thiagobrez

@thiagobrez thiagobrez commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

agent-device fold closed|half-open|open now works on foldable Android emulators. Until now the Android owner refused it ("the Android emulator posture control is not driven by agent-device yet").

How it works

  • adb emu posture 1|2|3 moves the hinge 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"), so half-open is 90° here (the Duo's is 130°).
  • The pose is verified by polling cmd device_state print-state until the guest commits CLOSED, HALF_OPENED, or OPENED, looked up by name because profiles number the states differently (Pixel folds from 0, the generic foldables from 1). That committed state is the verification; the hinge sensor is read back for hingeAngleDegrees as information.
  • Folding to the cover display raises "Swipe up to continue" on Pixel images (the continue-on-fold setting is ignored by emulator images), so a fold to closed dismisses a lock screen it raised; otherwise every later snapshot reads the lock screen instead of the app. Unfolding raises none, and a keyguard already showing before the fold is left alone.
  • A phone profile is refused as single-panel-device, a profile missing the requested posture state as fold-posture-unsupported, a physical device by kind, and keyframes with fold-keyframes-unsupported. There is no screen report: Android reports the app window on the next snapshot.

Help, MCP metadata, the commands page, and ADR 0025 are updated. 11 files changed, 645 insertions(+), 25 deletions(-)

Verified

  • pnpm check:affected --run on 71202f8 (the pushed head): pass, including test:integration:node.

  • Live on a Pixel 10 Pro Fold AVD (API 36, google_apis, arm64), app session open, each pose read back with adb afterwards:

    fold closed     → {"pose":"closed","hingeAngleDegrees":0}     device_state=0  hinge-angle0=0    isKeyguardShowing=false
    fold half-open  → {"pose":"half-open","hingeAngleDegrees":90} device_state=1  hinge-angle0=90
    fold open       → {"pose":"open","hingeAngleDegrees":180}     device_state=2  hinge-angle0=180
    fold closed     → {"pose":"closed","hingeAngleDegrees":0}     device_state=0  hinge-angle0=0    isKeyguardShowing=false
    

    The app keeps the window focus across the folds (mCurrentFocus=…arrangementview.example.MainActivity). A raw adb emu posture 1 on the same AVD leaves isKeyguardShowing=true; agent-device fold closed leaves it false.

  • Pixel 10 Pro AVD (phone): UNSUPPORTED_OPERATION, reason: single-panel-device, nothing sent to the console.

  • All three poses also verified on the generic "7.6in Foldable" profile (states numbered from 1) on API 35 and API 36, and on the Pixel 10 Pro Fold on API 37.1, with the raw print-states/print-state output: see this comment thread.

  • The same driver (console posture, device-state readback by name, lock-screen dismissal) has been running on GitHub Actions for a React Native foldable library: Add e2e tests for the Features demo on iOS and Android thiagobrez/react-native-arrangement-view#22, as a yarn patch to @e2e-dev/mobile (e2e refuses Android before reaching agent-device, so it was patched there), on a Pixel 9 Pro Fold emulator: passing run.

Not done here: keyframes on Android, and a screen report. Both can follow if wanted.

🤖 Generated with Claude Code

View guided diff

`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>
Copilot AI balanced review requested due to automatic review settings October 10, 2026 00:55

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 11 files

Reply to a comment to ask cubic a question or push back. It learns from your replies.

View guided diff | Re-trigger cubic

Comment thread src/commands/system/index.ts Outdated
Comment thread website/docs/docs/commands.md Outdated
Comment thread packages/platform-android/src/foldable/pose.ts
Comment thread packages/platform-android/src/runtime.test.ts
Comment thread packages/platform-android/src/foldable/pose.ts Outdated
Comment thread packages/platform-android/src/foldable/runtime.test.ts Outdated
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The keyguard observation window can end before a delayed post-fold lock screen appears.

1 open finding
What changed in this PR

Adds Android emulator support to the existing fold command through platform runtime operations and verified emulator posture control.

Changes:

  • Drives foldable emulator postures through ADB and verifies device state and hinge angle.
  • Adds runtime admission and comprehensive unit coverage.
  • Updates command metadata, documentation, and ADR 0025.
File Description
website/​docs/​docs/​commands.md Documents Android fold behavior and limitations.
website/​docs/​docs/​client-api.md Notes Android accepts pose presets only.
src/​commands/​system/​index.ts Updates fold schemas, help, and metadata.
packages/​platform-android/​src/​runtime.ts Wires Android fold facts and operations.
packages/​platform-android/​src/​runtime.test.ts Tests runtime admission by device kind.
packages/​platform-android/​src/​foldable/​runtime.ts Defines fold admission and binding.
packages/​platform-android/​src/​foldable/​runtime.test.ts Tests facts, binding, and cancellation.
packages/​platform-android/​src/​foldable/​pose.ts Implements posture dispatch and verification.
packages/​platform-android/​src/​foldable/​pose.test.ts Tests Android folding behavior and failures.
docs/​adr/​README.md Updates the ADR 0025 index entry.
docs/​adr/​0025-foldable-apple-panels.md Adds the Android emulator amendment.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/platform-android/src/foldable/pose.ts
Copilot AI balanced review requested due to automatic review settings October 10, 2026 01:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Half-open verification can accept incorrect angles, and keyguard handling can alter pre-existing device state.

1 open finding
Previously missed (5)

In code that hasn't changed since last review

Medium severity Dismiss only lock screens introduced by the fold

packages/​platform-android/​src/​foldable/​pose.ts:72

Lock-screen dismissal runs after every requested pose and does not establish whether the keyguard was already showing before the fold. Consequently, fold open or fold half-open can unlock an unrelated pre-existing unsecured keyguard, even though the documented side effect is specifically to clear the keyguard introduced by folding to the cover display. Record the pre-dispatch keyguard state and only dismiss a newly introduced lock screen (with corresponding tests).

Medium severity Validate Android half-open sensor angle against 90°

packages/​platform-android/​src/​foldable/​pose.ts:74

The final sensor check still uses the cross-platform category classifier, so any angle from 2° through 178° is accepted for Android half-open. That can return success while the fixed 90° emulator posture is still moving or the sensor disagrees, despite this implementation and its public docs defining Android half-open as 90°. Compare against the posture’s expected fixed angle (with a small tolerance), and add a case where HALF_OPENED reports a non-90° sensor value.

Low severity Include Android foldable emulators in ADR scope

docs/​adr/​0025-foldable-apple-panels.md:7

The amended status says Android emulator pose support was added, but the following scope sentence still limits the ADR to iPhone Duo and Apple devices. Include foldable Android emulators in the stated coverage so the status agrees with the new amendment.

Low severity Avoid promising panel switches for every pose change

src/​commands/​system/​index.ts:241

The description now covers Android but still states that every pose change moves the app to a different panel and point size. Android intentionally has no panel report, and transitions such as open to half-open need not switch panels. Keep the stale-ref guidance, but describe the viewport as potentially changing rather than promising a panel switch.

Low severity Clarify fold simulator support applies to iOS

website/​docs/​docs/​commands.md:123

This Android support contradicts the immediately preceding public bullet, which still says unqualifiedly that fold is simulator-only and requires Xcode. Qualify that existing bullet as the iOS behavior so readers are not told both that Android emulators are and are not supported.

🧠 Review effort: Balanced

Pass the scope signal to the settle sleeps, give the unreadable hinge
angle its own hint, scope the iOS-only wording in the fold description
and the commands page, and cover the synthetic simulator kind and the
exact bound signal in the tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 10, 2026 01:30

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 5 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

Comment thread packages/platform-android/src/foldable/pose.ts Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The public command description incorrectly states that every Android pose change switches displays.

2 open findings

🧠 Review effort: Balanced

Comment thread src/commands/system/index.ts Outdated
The keyguard can land after the device state commits; the first read was
immediate and the loop returned 250 ms later, so a late keyguard could be
missed. Every read now waits a poll first and the keyguard must stay away
for three consecutive reads.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 10, 2026 01:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The implementation introduces a test-only production export contrary to repository testing guidance.

1 open finding
1 resolved since last review
Previously missed (1)

In code that hasn't changed since last review

Low severity Avoid exporting a production constant solely for test access

packages/​platform-android/​src/​foldable/​pose.ts:28

This constant is exported only so the test can read the implementation's retry count, which expands the production module surface for test convenience. Repository testing guidance explicitly forbids test-only production exports (docs/agents/testing.md:113-114). Keep this constant private and have the test verify the bounded failure through observable calls without importing the implementation constant.

🧠 Review effort: Balanced

Verify the hinge sensor against the posture's own angle within 0.5°
instead of the cross-platform category, so a half-open that reads 120°
is refused. Read the keyguard before the fold and dismiss only a lock
screen the fold raised. Keep the settle budget private and prove the
bound through the observed reads. Scope the public description and the
ADR's coverage sentence to what Android actually does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 10, 2026 02:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The implementation is bounded, documented, and thoroughly covered without identified correctness issues.

0 open findings

1 resolved since last review

🧠 Review effort: Balanced

@thymikee

Copy link
Copy Markdown
Member

The code in e80139e looks correct to me, and the 16 checks are green, but I would like live proof of the generic foldable profile before merge. The Pixel 10 Pro Fold run and the phone refusal come from the PR body, and I did not rerun them. The by-name lookup for the generic "7.6in Foldable" profile (states numbered from 1) is covered only by a hand-written print-states fixture. Can you run fold closed, fold half-open and fold open on that profile on a live emulator, and show that the committed state id from the print-states output matches the requested posture? Please also say which API levels you ran, since the assumption that the first integer in that output is the committed state id is checked only on the API 36 run and the fixture.

Not blocking: dismissFoldLockScreen runs for every pose when no keyguard showed before the fold, so open and half-open always pay at least 3 x 250 ms and 3 dumpsys window reads, and it could run only for closed (or keep the current behavior with a comment if unfolding can raise the keyguard too); the single-panel refusal at pose.ts#L157 keys on the requested state name, so a profile that lists CLOSED and OPENED but not HALF_OPENED would refuse fold half-open as single-panel, and deciding it from "none of the three listed" with its own reason for a missing posture would match the hint and docs; and the ADR at 0025-foldable-apple-panels.md now also owns the Android amendment, so a later retitle is optional. You can take or leave all of these.

I looked for a smaller design and found none. The change adds one leaf owner under packages/platform-android/src/foldable/ that mirrors the Apple foldable module through the existing setFoldPose fact and whenAdmitted seam, with no new contract, daemon route or CLI flag. Is there an existing Android keyguard or adb emu console helper I missed that could replace the two short polling loops? I found none, and the only reduction I see is scoping the keyguard settle to closed.

Nothing else stops this from merging once the live run above is posted.

…posture

Unfolding lights the inner display and raises no keyguard, so only a
fold to closed reads and settles it. A profile that lists some posture
states but not the requested one is refused as fold-posture-unsupported
naming what it lists; single-panel-device is kept for a profile that
lists none.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 10, 2026 13:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The runtime path is bounded, typed, thoroughly tested, documented, and supported by successful live-device validation.

0 open findings

🧠 Review effort: Balanced

@thymikee

Copy link
Copy Markdown
Member

Thanks for 7c60ef4. The lock screen settle is now limited to closed, and a profile without a posture now gets its own reason. Both match my earlier notes. I have not reviewed the new code in detail yet.

The live run I asked for at e80139e is still missing. Can you run fold closed, fold half-open and fold open on the generic "7.6in Foldable" profile on a live emulator? Please show that the committed state id in the print-state output matches each requested posture, and say which API levels you used. This commit changes the pose path, so please also re-run the Pixel 10 Pro Fold check on this head.

I will review it again once those results are posted.

The emulator console parks the hinge at the angle the AVD profile defines
for the posture, not a fixed one: the Pixel folds use 0, 90 and 180
degrees, the generic "7.6in Foldable" 15, 90 and 165, the midpoints of
its posture ranges. Judging the sensor against 0/90/180 refused closed
and open on that profile in a live run, so the committed device state,
which WindowManager's FoldingFeature derives from, is now the sole
verification and the angle is reported as read.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 10, 2026 14:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Managed ADB failures are not consistently checked, allowing false success or misleading fold errors.

0 open findings

Previously missed (1)

In code that hasn't changed since last review

Medium severity Reject failed ADB results before parsing display state

packages/​platform-android/​src/​foldable/​pose.ts:134

This read assumes a nonzero ADB result will throw, but managed scoped ADB can return failed results instead (packages/platform-android/src/__tests__/test-utils/fake-adb.ts:31-33). A print-states failure is therefore parsed as an empty map and incorrectly reported as single-panel-device. Check exitCode and throw androidAdbResultError before parsing stdout, as the display probe does in input-actions.ts:151-160.

This issue also appears in the following locations of the same file:

  • line 146
  • line 194
  • line 246
  • line 255

🧠 Review effort: Balanced

@thiagobrez

Copy link
Copy Markdown
Contributor Author

Thanks, the live run on the generic profile was worth asking for: it caught a real defect, now fixed in a927301.

What the generic profile showed

The console parks the hinge at the angle the AVD profile defines for the posture, not a fixed one: the Pixel folds use 0°/90°/180°, the generic "7.6in Foldable" uses 15°/90°/165° (the midpoints of its 0-30, 30-150, 150-180 posture ranges). The sensor check I'd added in the last review round (and the category classifier before it) refused closed and open there. The committed device state, which WindowManager's FoldingFeature derives from, was right in every case, so it is now the sole verification and the angle is reported as the sensor reads it. Help, docs and the ADR say so.

Live proof on a927301 (its build), agent-device fold with a Settings session, then read back with adb

7.6in Foldable, API 35 (Android 15), system-images;android-35;google_apis;arm64-v8a

$ adb shell cmd device_state print-states
  DeviceState{identifier=1, name='CLOSED', …}
  DeviceState{identifier=2, name='HALF_OPENED', …}
  DeviceState{identifier=3, name='OPENED', …}
$ agent-device fold closed     → {"pose":"closed","hingeAngleDegrees":15}      print-state → 1   hinge-angle0 = 15
$ agent-device fold half-open  → {"pose":"half-open","hingeAngleDegrees":90}   print-state → 2   hinge-angle0 = 90
$ agent-device fold open       → {"pose":"open","hingeAngleDegrees":165}       print-state → 3   hinge-angle0 = 165

7.6in Foldable, API 36 (Android 16), system-images;android-36;google_apis;arm64-v8a

$ adb shell cmd device_state print-states   → identifier=1 CLOSED, 2 HALF_OPENED, 3 OPENED (same as above)
$ agent-device fold closed     → {"pose":"closed","hingeAngleDegrees":15}      print-state → 1   hinge-angle0 = 15
$ agent-device fold half-open  → {"pose":"half-open","hingeAngleDegrees":90}   print-state → 2   hinge-angle0 = 90
$ agent-device fold open       → {"pose":"open","hingeAngleDegrees":165}       print-state → 3   hinge-angle0 = 165

pixel_10_pro_fold, API 37 (Android 17), system-images;android-37.1;google_apis_playstore_ps16k;arm64-v8a

$ adb shell cmd device_state print-states
  DeviceState{identifier=0, name='CLOSED', …}
  DeviceState{identifier=1, name='HALF_OPENED', …}
  DeviceState{identifier=2, name='OPENED', …}
  DeviceState{identifier=3, name='REAR_DISPLAY_MODE', …}
$ agent-device fold closed     → {"pose":"closed","hingeAngleDegrees":0}       print-state → 0   hinge-angle0 = 0
$ agent-device fold half-open  → {"pose":"half-open","hingeAngleDegrees":90}   print-state → 1   hinge-angle0 = 90
$ agent-device fold open       → {"pose":"open","hingeAngleDegrees":180}       print-state → 2   hinge-angle0 = 180

isKeyguardShowing=false after every fold on all three. print-state printed the bare id on all three API levels (35, 36, 37.1), so "first integer is the committed id" held on each; the PR body's earlier runs were API 36 (google_apis) for the Pixel 10 Pro Fold and the phone refusal.

The optional points

  • Keyguard settle only for closed: taken (7c60ef4). On the API 36 Pixel image, which is the one that raises the lock screen, raw console postures gave closed → true, half-open → false, open → false three times over, including half-open and open entered from closed, so unfolding never raises it. half-open and open no longer read dumpsys window at all.
  • Missing posture vs single-panel: taken (7c60ef4). A profile that lists none of the three states is single-panel-device; one that lists some but not the requested one is fold-posture-unsupported naming what it lists.
  • ADR retitle: left as is.
  • A smaller design: I found no existing keyguard or adb emu console helper either; the Android package only touches the console ad hoc (emu geo fix, emu finger touch, emu avd name) and nothing reads the keyguard.

pnpm check:affected --run passes on a927301.

An executor may hand back a failed result instead of throwing (the
managed scoped transport does), so a failed `print-states` parsed as an
empty state list and was reported as single-panel-device. Every shell and
console read now runs with allowFailure and throws androidAdbResultError
on a nonzero exit before stdout is trusted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 10, 2026 14:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The settle loop can falsely fail at its deadline, and one error hint recommends invalid CLI arguments.

0 open findings

Previously missed (3)

In code that hasn't changed since last review

Medium severity Test expects an extra trailing sleep instead of N−1 sleeps

packages/​platform-android/​src/​foldable/​pose.test.ts:242

This equality locks in the extra trailing sleep: with the intended “every read after the first is preceded by a poll” order, N reads require N−1 sleeps. As written, the test masks the production loop’s unobserved final interval.

Medium severity Final poll interval ends without a verification read

packages/​platform-android/​src/​foldable/​pose.ts:174

After the 60th failed read, this sleeps for one final poll interval and exits without reading again. A guest that commits during that last 250 ms is reported as fold-pose-unverified even though it met the stated 15-second settle budget. Poll before each retry so the final interval ends with an observation.

Low severity Hint exposes Android states instead of valid fold pose arguments

packages/​platform-android/​src/​foldable/​pose.ts:109

listed contains Android state names, but this hint presents them as valid fold pose arguments. For the covered end-stop profile it recommends OPENED, which parseFoldPose rejects; translate the states back to closed, half-open, and open.

🧠 Review effort: Balanced

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 5 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

Comment thread packages/platform-android/src/foldable/pose.test.ts Outdated
Poll before each retry so a guest that commits in the last interval is
still seen, translate a profile's device states back to fold poses in
the missing-posture hint, and lock in that the sensor angle is reported
as read with an angle no profile would give for closed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 10, 2026 15:21
…ames

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Required CI checks for the current head remain in progress, so final human approval should wait for completion.

0 open findings

🧠 Review effort: Balanced

@thymikee

Copy link
Copy Markdown
Member

This follow-up review of 71202f8 finds no problems in the code, and the earlier open items are now resolved. The live run on the generic profile satisfies the evidence we asked for. That run was on a927301, not on 71202f8. From reading the code, I judge that the later exit-code wrappers and the sleep reorder do not change the success route, but I did not re-run the device on this commit and ran no local tests. All 16 checks pass at 71202f8, and there are no conflicts. The cubic-dev-ai P2 thread on the pose test (#3361 (comment)) is fixed at this head: the "reports the sensor angle as read" test now feeds 77° for a committed closed state and expects 77 back, so it does not check the angle against the pose. Please resolve that thread. Nothing else must happen before merge, so this is ready for maintainer merge.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 10, 2026
@thymikee
thymikee merged commit 6eef69a into callstack:main Oct 10, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants