Files
orca/docs/android-emulation.md
T
42b2ecc5c6 feat(emulator): Android emulation via scrcpy (cross-platform, iOS parity) (#6434)
* docs: add Android emulation design spec

Adds the design for first-class Android emulator support as a cross-platform
peer of the iOS simulator feature: an extracted EmulatorBackend interface
(iOS + Android), full AVD lifecycle management via the Android SDK, a live
scrcpy H.264 pane decoded in-renderer with WebCodecs, the full control surface
(tap/gesture/type/buttons/rotate), accessibility tree, app install/launch,
runtime permissions, logcat, and a dedicated orca-emulator-android skill.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(emulator): add EmulatorBackend interface + backend/codec session tags

First step of multi-backend emulator support: introduce the EmulatorBackend
type and tag each session with its backend kind + stream codec, defaulting to
ios/mjpeg so existing serve-sim behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(emulator): extract IosEmulatorBackend and make the bridge a router

Move the serve-sim/simctl device + helper + input mechanics out of
EmulatorBridge into IosEmulatorBackend (implementing EmulatorBackend). The
bridge now owns the session registry and lifecycle orchestration and routes
each command to the backend that owns the target device. iOS behavior is
unchanged; the existing bridge tests pass untouched and the backend gains its
own input-op coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(emulator): add pure Android leaf modules (sdk/adb/avd/scrcpy/input/ax)

Dependency-injected building blocks for the Android emulator backend, each unit-
tested in isolation: SDK + tool discovery, adb device/output parsing, AVD list +
boot arg building, scrcpy control-socket byte encoders, normalized<->pixel +
keycode mapping, and a uiautomator XML accessibility-tree parser. Not yet wired;
AndroidEmulatorBackend composes these in the next phase.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(emulator): gate availability on the iOS backend + lock it with tests

inspectEmulatorAvailability now decides iOS host support via the registered iOS
backend instead of a bare platform literal, routing the decision through the
multi-backend seam. Output shape and all messages are unchanged (the settings
pane still reads simctl/serveSim). Adds the previously-missing regression tests
covering the unsupported, ready, no-devices, and tool-failure paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(emulator): add Android app/permissions/logcat arg builders

Pure adb arg-builders + a logcat line parser for app install/launch, runtime
permission grant/revoke/reset, and logcat capture. Unit-tested in isolation;
wired into AndroidEmulatorBackend's capability verbs in a later phase.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(emulator): AndroidEmulatorBackend device management + unified device list

Adds the Android backend (registered alongside iOS in the bridge): SDK-gated
host support, device/AVD discovery and merge, AVD boot + boot-completion wait,
shutdown, and tap/swipe/type/button/rotate/exec via `adb shell input` so control
works without the scrcpy server (the live H.264 stream lands in the streaming
phase). Surfaces everything through a new cross-platform `orca emulator devices`
command (RPC emulator.listDevices -> bridge.listAllDevices) with a platform
column. Device inventory is split into its own module to keep files focused.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(emulator): Android capability verbs (install/launch/permissions/ax/logcat)

Wires the Android capability operations into AndroidEmulatorBackend and exposes
them through a capability-gated bridge router (runCapability), RPC, and CLI:
- orca emulator install/launch/permissions/ax/logcat
Capabilities are advertised per backend; calling one on a backend that lacks it
(e.g. iOS) fails with emulator_unsupported instead of a silent no-op. Input ops
and capability ops are split into focused modules to keep files under the
line cap; the runtime shares one target-param type.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(skill): add orca-emulator-android skill + cross-ref from iOS skill

Documents the cross-platform Android emulator control surface (devices, input,
hardware buttons, rotate, install/launch, permissions, ax, logcat) driveable via
the orca CLI today, and notes the live visual pane is in development. Points the
iOS skill's "when not to use" at the new Android skill.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(emulator): Android live-pane streaming scaffolding (scrcpy + WebCodecs)

Builds the H.264 video path as scaffolding: scrcpy frame/codec-meta parsing,
server-deploy arg builders, control-protocol encoders (committed earlier), the
stream session (server + sockets), a video pub/sub registry, the
emulator:videoStream* IPC channel, and a renderer WebCodecs->canvas hook. Pure
framing/deploy/registry are unit-tested; the socket/WebCodecs/jar integration is
clearly flagged UNVERIFIED and the remaining wiring (startSession, preload,
pane codec branch, packaging the jar) is documented in
docs/android-emulation-streaming.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: fix streaming notes doc path in video-stream hook comment

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(emulator): add diagnostic probes for Android testing

Adds an emulator-probe logger (console + temp file at
os.tmpdir()/orca-android-emu-probe.log) and wires probes at four layers so
errors surface during manual testing: every emulator.* RPC call + error (RPC
dispatcher), every adb/emulator command + non-zero exit (command runner), and
the scrcpy session + video-stream IPC lifecycle. Temporary diagnostics; remove
or gate behind a flag once the Android pane is validated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(emulator): show Mobile Emulator settings cross-platform + aggregate Android availability

The Mobile Emulator settings section is no longer macOS-gated (Android works on
Windows/Linux), and inspectEmulatorAvailability now aggregates the iOS and
Android backends: Android devices/AVDs appear in the device list and a host
without iOS gets the Android setup message instead of "requires macOS".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(emulator): show Mobile Emulator sidebar nav entry on non-mac desktops

The settings sidebar nav registered the Mobile Emulator entry behind isMac, so
it stayed hidden on Windows/Linux even after the section content was ungated.
Widen it to showDesktopOnlySettings to match the section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(emulator): wire Android startSession to scrcpy + client-downloaded jar

AndroidEmulatorBackend.startSession now boots the device, ensures the scrcpy
server jar (downloaded by the client into the per-user cache on first use, not
bundled), starts a ScrcpyStreamSession, and feeds its H.264 frames to the video
registry; stopHelperForDevice tears it down. Sessions carry their backend kind
so worktree-active routing picks the right backend. Boot, host SDK discovery,
and the stream starter are split into focused modules to stay under the line cap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(emulator): stop the iOS backend from claiming Android devices off-mac

iOS ownsDevice now returns false unless the host supports it, so on Windows an
Android serial routes to the Android backend instead of erroring with
"requires macOS". Backend-for-device fallback prefers a host-supported backend.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(emulator): scrcpy scid 31-bit + retry video socket until server delivers

Two fixes validated against a real emulator: scrcpy parses scid as a signed
32-bit hex int, so mask to 31 bits + pad to 8 digits (8-byte values overflowed
and the server exited). And adb accepts the forwarded TCP connection before the
server's abstract socket exists then resets it, so retry the video socket until
it actually delivers the dummy byte before connecting control. H.264 meta now
arrives (576x1280). Adds socket/server-exit diagnostics probes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(emulator): render the Android H.264 pane via WebCodecs

Wires the live Android pane end-to-end: preload exposes emulator video stream
APIs; the pane's device list uses the unified emulator.listDevices (Android +
iOS); and emulator-screen-stream-content renders a WebCodecs <canvas> for
scrcpy:// sessions (H.264, SPS/PPS prepended to the first keyframe) instead of
the MJPEG <img>. The video hook reports the stream size for the device frame.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(emulator): buffer the current GOP for late video subscribers

The renderer subscribes after attach already started the scrcpy stream, so the
registry now caches the current GOP (keyframe + following deltas) alongside the
codec meta and config, and replays it on subscribe. A pane opened mid-stream
decodes from the keyframe immediately instead of showing black until scrcpy's
next periodic keyframe (~10s). Refreshes the now-validated session doc comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(emulator): show New Mobile Emulator tab action off macOS

The tab create menu and its dropdown item gated the New Mobile Emulator action
on isMacOs, hiding it on Windows/Linux where Android emulation is now supported.
Gate on mobileEmulatorEnabled + onNewSimulatorTab (already cross-platform) so
the action appears wherever a mobile emulator backend is available.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(emulator): open the Mobile Emulator tab off macOS

openMobileEmulatorTab and ensureSimulatorTab both returned null unless the host
was macOS, so the New Mobile Emulator action no-opped on Windows/Linux even
though the menu entry showed. Drop the isMacOsHost early-returns; the
mobileEmulatorEnabled setting and backend availability already gate the feature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(emulator): resolve a default attach device across backends

emulatorAttach with no device fell back only to the iOS listSimulators picker
(empty on Windows/Linux), so the pane's no-device launch flow errored. Extract
resolveDefaultAttachDevice: iOS default first, else the first booted (else
first) device across host backends, so Android attaches without an explicit
device. Split into its own module to stay under the line cap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(emulator): render the pane off macOS instead of an unavailable wall

EmulatorPane short-circuited to the "macOS only" EmulatorUnavailablePane on any
non-Mac host, blocking the now-working Android pane. Always render the pane
content; its device discovery and error surface handle a missing backend.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(emulator): let attach boot a shut-down AVD with a stale active session

getReusableActiveForWorktree called resolveDeviceId on the requested device,
which throws for a not-yet-booted Android AVD, aborting the attach. Guard it so
a resolve failure means "not the active device" and the attach falls through to
a fresh boot — so picking a shut-down AVD in the pane and hitting Connect boots
it via ensureBooted instead of erroring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(emulator): launch the AVD detached instead of via execFile

bootAndroidDevice started the emulator through the command runner (execFile with
a timeout + 1MB stdout maxBuffer), which kills the long-running, verbose emulator
process — so booting an AVD from the pane never actually came up. Spawn it
detached with no stdio and unref it so it outlives the call, mirroring how the
scrcpy server is launched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(emulator): dedupe concurrent attaches into one scrcpy stream

Extract AndroidStreamController to own the per-serial scrcpy lifecycle and
dedupe starts: concurrent attaches (e.g. the pane's auto-attach racing the tab
launch) now share one in-flight start and reuse the live stream instead of
spawning a second scrcpy server that fights for the port and kills the first.
Also initialize the registry GOP buffer in register() (latent type error).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(emulator): boot the AVD headless without a console window

The detached spawn opened a Windows console (showing the emulator's verbose
qemu/netsim logs) and a redundant native emulator window. Pass windowsHide and
run the emulator with -no-window so it boots headless — the scrcpy pane is the
view, matching how iOS hides Simulator.app.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(emulator): boot the AVD with a hidden console, not detached

detached: true sets DETACHED_PROCESS, which gives the console-subsystem emulator
no console — so it and its qemu/netsim children pop their own visible cmd window
that windowsHide can't suppress. Drop detached and rely on windowsHide
(CREATE_NO_WINDOW = hidden console) + unref; spawn already keeps it alive past
the launch call, and managed emulators are shut down on app quit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(emulator): keep Android emulators alive when switching devices

Attaching a different device shut down the active one (shutdownDevice: true),
which for Android meant killing the running emulator and cold-booting the target
(~60s) on every switch — and switching back. Add bridge.stopActiveForSwitch:
Android emulators stay running for instant switch-back, while iOS simulators are
still replaced. Switching to an already-running emulator is now immediate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(emulator): only resize the video canvas when dimensions change

The decoder output handler set canvas.width/height on every frame, which
reallocates the canvas backing store and forces an object-contain reflow each
frame — a needless per-frame cost. Resize only when the frame dimensions
actually change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(emulator): genericize copy + add Android Studio setup link

Replace iOS-only wording (Xcode/Simulator/iPhone) in the pane and settings with
backend-neutral copy so Android reads correctly on every platform. When no
emulator is available, the Mobile Emulator settings now show a "Download Android
Studio" link plus setup guidance (ANDROID_HOME / default install path). Removes
the now-unused, macOS-only EmulatorUnavailablePane.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(emulator): show emulator SDK status in settings

The backend availability now reports the resolved Android SDK path, aggregated
into emulator.availability as an `android` block. The Mobile Emulator settings
render an "Emulator SDKs" card showing Android SDK (detected at <path> / not
found, with a Download Android Studio link) and, on macOS, iOS Simulator (Xcode)
status — mirroring the agent-control card.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(emulator): locate a custom Android SDK folder from settings

Add an androidSdkPath setting and a "Locate SDK folder…" / Clear action in the
emulator SDK status card. The path is applied as the highest-priority discovery
candidate (falls back if invalid), and the backend's SDK is re-resolved on use
via a new AndroidSdkState — so locating or installing the SDK takes effect on
Refresh without restarting Orca. Guards the status card against older runtimes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(emulator): settle the scrcpy video socket once to stop retry storms

A failed TCP connect emits both 'error' and 'close', so retry ran twice and
scheduled openVideoSocket(attempt+1) twice — fanning out into an exponential
connection storm while waiting for the server to start listening. A runaway
chain could then hit attempt 100 and fail/close a stream that had already
connected. Replace the delivered flag with a single settled latch so each
socket retries (or delivers) exactly once.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(emulator): address CodeRabbit review findings

- avd-boot: handle spawn 'error' (an unhandled ChildProcess error crashed the
  main process); validate the target is a known AVD before launching.
- capability-ops: propagate adb non-zero exits for launch/permission/logcat and
  check the uiautomator dump before reading (avoids stale XML).
- scrcpy-video-registry: actually replay the buffered GOP on subscribe so late
  subscribers decode immediately.
- android-sdk-state: re-resolve host discovery every call so a changed SDK path
  takes effect live (no restart).
- android-sdk-discovery: require both adb and the emulator binary.
- emulator-bridge: fall back to the platform-primary backend (Android off-mac)
  so setup errors aren't iOS/CoreSimulator on Windows/Linux.
- scrcpy-server-download: dedupe concurrent first-use downloads + add a timeout.
- scrcpy-stream-session: idle-socket connect timeout; surface control-socket
  errors instead of swallowing them.
- android-exec: pass the whole command so the device shell parses quotes/pipes.
- avd-manager: match emulator log prefixes exactly (keep AVD names like
  PixelWARNINGTest).
- permissions: `pm reset-permissions` is global and takes no package argument.
- stream controller/starter: drop stale handles for dead streams; idempotent
  teardown. use-emulator-video-stream: stopVideoStream returns Promise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(emulator): populate the GOP buffer and reuse live scrcpy streams

Self-audit follow-ups in the same class as the CodeRabbit GOP-replay finding:

- scrcpy-video-registry: pushFrame never wrote to entry.gop, so the replay
  loop added for late subscribers iterated an empty array — a no-op. Build the
  GOP on ingest (start at each keyframe, append following deltas; don't buffer
  deltas before the first keyframe). Adds tests for population, reset, and the
  pre-keyframe guard.
- android backend: isSessionReusable was stubbed to always return false with a
  "no persistent stream yet" note, but scrcpy streams are persistent now — so
  every renderer remount tore down and respawned the server. Reuse a live
  stream (scrcpyVideoRegistry.has) so remounts reconnect, matching iOS. The
  device-mismatch check still runs first, so device switching is unaffected.
- Refresh stale comments that implied unfinished/unverified work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* WIP: Changes before auto-review fixes

Co-authored-by: Orca <help@stably.ai>

* Refine mobile emulator availability settings

Co-authored-by: Orca <help@stably.ai>

* Address emulator review follow-ups

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
2026-06-28 21:01:09 -07:00

18 KiB
Raw Permalink Blame History

Android Emulation

Problem

Orca ships a built-in mobile emulator surface (live pane + orca emulator CLI + agent skill), but it is iOS Simulator only and macOS only:

  • src/main/emulator/emulator-availability.ts:32 hard-returns "unavailable" for any platform() !== 'darwin', so Windows and Linux users get nothing.
  • The backend (src/main/emulator/emulator-bridge.ts) is wired directly to serve-sim (serve-sim-*.ts) and xcrun simctl (simctl-simulator-devices.ts), both Apple-only tooling.

Android emulators run on Windows, Linux, and macOS via the Android SDK that Android Studio installs. We want Android emulation as a first-class peer of the iOS feature: full AVD lifecycle management, a live ~60fps pane, the full tap/gesture/type/button/rotate control surface, accessibility tree, app install/launch, runtime permissions, logcat, plus a dedicated orca-emulator-android agent skill.

Current architecture (what we reuse vs. replace)

The existing stack already separates a backend from everything above it. The renderer pane, session registry, RPC/CLI shape, and tab system are effectively backend-agnostic and are reused unchanged:

  • Frame transport is main-owned. src/main/ipc/emulator-frame-stream.ts:40 runs the MJPEG socket in the main process and forwards raw JPEG bytes to the renderer over emulator:frameStreamFrame. The renderer (src/renderer/src/components/emulator-pane/use-emulator-frame-stream.ts:74) just wraps each frame in a Blob/<img>. The renderer is a frame consumer, decoupled from the source.
  • Per-worktree "active emulator" lives in src/main/emulator/emulator-session-registry.ts (like the active browser tab). Backend-agnostic.
  • RPC is declared in src/main/runtime/rpc/methods/emulator.ts and implemented in src/main/runtime/orca-runtime-emulator.ts.
  • CLI is src/cli/specs/emulator.ts + src/cli/handlers/emulator.ts.
  • Pane is src/renderer/src/components/emulator-pane/** (~50 files).

What is iOS-bound and needs an Android sibling:

  • Device management: xcrun simctladb / emulator / avdmanager.
  • Streaming helper: serve-sim (MJPEG/H.264 over HTTP+WS) → scrcpy-server.jar (H.264 + control over adb-forwarded sockets).
  • Input: serve-sim normalized-coord WS → adb-backed Android input commands.
  • Availability gate: darwin-only → SDK-present on any OS.

Goals

  • Android emulation on Windows, Linux, and macOS (macOS users choose iOS or Android in the same pane).
  • Full AVD lifecycle: discover installed AVDs via the SDK, boot/shutdown them from Orca, and attach to already-running emulators + physical adb devices.
  • Live ~60fps pane via scrcpy H.264 decoded in the renderer with WebCodecs.
  • Control parity: tap, swipe/gesture, type, hardware buttons (Back, Home, Recents, Power, Volume), rotate.
  • Extra capabilities: accessibility tree (uiautomator dump), app install/launch (adb install / am start), runtime permissions (pm grant/revoke/reset), logcat capture.
  • A dedicated skills/orca-emulator-android/SKILL.md.

Non-goals (v1)

  • Camera/sensor injection (the Android emulator's virtual-scene path is a much larger problem than serve-sim's iOS camera injection; defer).
  • Remote/SSH device control (matches the current iOS limitation; emulator hardware is local).
  • Wear OS / Android TV / Automotive form factors.
  • Migrating the iOS backend to H.264 (the interface allows it later; not done now).

Design

Extract an EmulatorBackend interface; make the bridge a router

Today EmulatorBridge is the iOS implementation. Refactor it into a thin router over a backend interface so iOS and Android share the session registry, RPC/CLI shape, frame IPC, and tab system. This is the only change to existing iOS behavior, and it is a pure extraction (no semantic change).

New module src/main/emulator/backends/emulator-backend.ts:

export type EmulatorBackendKind = 'ios' | 'android'
export type EmulatorStreamCodec = 'mjpeg' | 'h264'

export type EmulatorDevice = {
  backend: EmulatorBackendKind
  id: string            // opaque: simulator UDID, adb serial, or AVD name
  name: string
  state: 'shutdown' | 'booting' | 'booted'
  kind?: string         // form factor / api level, display only
  isAvailable: boolean
}

export type EmulatorBackendCapabilities = {
  install: boolean
  launch: boolean
  permissions: boolean
  accessibilityTree: boolean
  logcat: boolean
}

export interface EmulatorBackend {
  readonly kind: EmulatorBackendKind
  readonly capabilities: EmulatorBackendCapabilities
  isSupportedOnHost(): boolean
  checkAvailability(): Promise<BackendAvailability>
  listDevices(): Promise<EmulatorDevice[]>
  bootDevice(id: string): Promise<EmulatorDevice>
  startSession(id: string): Promise<EmulatorSessionInfo> // includes streamCodec
  tap(id, x, y): Promise<void>
  gesture(id, points): Promise<void>
  type(id, text): Promise<void>
  button(id, name): Promise<void>
  rotate(id, orientation): Promise<void>
  exec(id, command): Promise<unknown>
  // capability-gated:
  installApp?(id, path): Promise<void>
  launchApp?(id, pkg, activity?): Promise<void>
  setPermission?(id, op): Promise<void>
  accessibilityTree?(id): Promise<unknown>
  logcat?(id, opts): Promise<...>
  stopSession(id): Promise<void>
  kill(id): Promise<void>
  shutdown(id): Promise<void>
}
  • src/main/emulator/backends/ios-emulator-backend.ts — the existing serve-sim
    • simctl logic extracted from EmulatorBridge, implementing the interface with kind: 'ios', streamCodec: 'mjpeg', and capabilities mapped to what serve-sim already supports.
  • src/main/emulator/backends/android-emulator-backend.ts — new, orchestrates the Android modules below with kind: 'android', streamCodec: 'h264'.
  • EmulatorBridge keeps its public method names (so RPC/runtime callers don't churn) but becomes a router: it holds the available backends, resolves which backend owns a given device/session (via the session registry's recorded backend tag, or by probing listDevices() for an unknown id), and delegates. The session registry record gains a backend: EmulatorBackendKind field; EmulatorSessionInfo gains streamCodec. The existing deviceUdid field is retained as the opaque id to preserve wire-compat across the renderer and CLI.

New Android modules — src/main/emulator/android/

Each module is small and single-purpose with a co-located .test.ts, matching the existing serve-sim-* / simctl-* granularity (no file approaches the max-lines limit):

  • android-sdk-discovery.ts — resolve the SDK root and the adb / emulator / avdmanager binaries from ANDROID_HOME, then ANDROID_SDK_ROOT, then per-OS defaults: %LOCALAPPDATA%\Android\Sdk (Windows), ~/Library/Android/sdk (macOS), ~/Android/Sdk (Linux). All paths via path.join.
  • adb-devices.tsadb devices -l, resolve serial, wait-for-device + getprop sys.boot_completed poll, and wm size for the device resolution.
  • avd-manager.tsemulator -list-avds, boot an AVD via a detached emulator @<name> spawn, shut down via adb -s <serial> emu kill.
  • scrcpy-server-deploy.ts — push the version-pinned scrcpy-server.jar, start it via app_process, and set up the adb forward tunnel(s).
  • scrcpy-stream-session.ts — owns the server process, adb tunnel, and video socket lifecycle.
  • scrcpy-video-frame-parser.ts — read the video socket, parse scrcpy frame headers (PTS + length), and emit H.264 access units plus the codec config (SPS/PPS).
  • scrcpy-control-protocol.ts — encode scrcpy control messages (touch down/move/up with pointer id + pressure, inject keycode, inject UTF-8 text, scroll, set screen power, rotate, clipboard) for a future low-latency input path.
  • android-input-mapping.ts — convert normalized 01 ↔ device pixels using the live frame size; map button names → Android keycodes (BACK=4, HOME=3, APP_SWITCH=187, POWER=26, VOLUME_UP=24, VOLUME_DOWN=25).
  • android-input-commands.ts — current adb-backed tap/type/button/rotate/gesture command construction.
  • uiautomator-tree.tsadb shell uiautomator dump → parsed XML tree.
  • android-app-control.tsadb install <apk>, am start package/activity.
  • android-permissions.tspm grant / revoke / reset.
  • android-logcat.ts — tail/filter adb logcat with bounded buffering.
  • android-availability.ts — SDK present? AVDs + connected devices list, with clear, surfaced messages (mirroring the iOS availability message style).

Streaming & control data flow

Control currently uses adb shell input commands. Coordinates stay normalized 01 at every public boundary (CLI, RPC, renderer); the Android backend maps them to device pixels before issuing adb-backed tap/gesture/button commands. The scrcpy control protocol encoders are present for a future low-latency input path, but video streaming does not require that path.

Video path (H.264 → renderer WebCodecs):

  1. android-emulator-backend.startSession() deploys + starts scrcpy-server, opens the video + control sockets, and returns EmulatorSessionInfo with streamCodec: 'h264'.
  2. scrcpy-video-stream.ts reads access units and the SPS/PPS config and pushes them over a new IPC channel emulator:videoStream{Start,Config,Frame,Stop} (a sibling of the existing emulator:frameStream*), keyed by stream id.
  3. New renderer hook src/renderer/src/components/emulator-pane/use-emulator-video-stream.ts feeds the access units to a WebCodecs VideoDecoder, drawing decoded VideoFrames to a <canvas>.
  4. src/renderer/src/components/emulator-pane/emulator-screen-stream-content.tsx branches on session.streamCodec: mjpeg keeps today's <img> path untouched; h264 uses the canvas path. No iOS behavior changes.

Coordinate & input mapping

  • Public API (CLI/RPC/pane gestures) stays normalized 01, top-left origin, as the iOS path already mandates.
  • android-input-mapping.ts multiplies by the current device display size before adb input commands are built. Rotation changes the effective frame size; the mapper reads the current size each gesture rather than caching.
  • Hardware buttons: adb keyevents inject keycodes. Android adds Back and Recents (no iOS equivalent); the button name → keycode map and the renderer's hardware-button row gain Android variants.

RPC + CLI surface

Extend src/main/runtime/rpc/methods/emulator.ts, src/main/runtime/orca-runtime-emulator.ts, src/cli/specs/emulator.ts, and src/cli/handlers/emulator.ts:

  • orca emulator list gains a platform column and shows iOS + Android devices/AVDs together; device selection resolves the backend automatically (by recorded session tag, else by which backend's listDevices() owns the id).
  • Existing verbs (attach, tap, gesture, type, button, rotate, exec, kill, shutdown) route unchanged to the resolved backend.
  • New capability-gated verbs: install, launch, permissions, ax, logcat. On a backend lacking the capability they fail with a clear emulator_unsupported error rather than silently no-op.
  • Existing --worktree / --device targeting is unchanged.

Renderer pane

  • src/renderer/src/components/emulator-pane/emulator-phone-hardware-buttons.tsx → Android variant (Back, Home, Recents, Power, Volume) selected by backend kind.
  • Android device bezel/frame + Android entries (and a "boot AVD" affordance) in the attach/list UI.
  • MobileEmulatorAgentSetupGuide* → Android prerequisites step (install Android Studio / SDK, set ANDROID_HOME).
  • Codec-aware stream content (the canvas path above).
  • All UI follows docs/STYLEGUIDE.md: existing tokens from src/renderer/src/assets/main.css and shadcn primitives in src/renderer/src/components/ui/; no new color/size/shadow values.
  • Shortcut labels and any new accelerators use the platform checks required by AGENTS.md (CmdOrCtrl, /Ctrl+).

Packaging & dependencies

  • Bundle the single, version-pinned scrcpy-server.jar (~80 KB) as an app resource; wire it into config/electron-builder.config.cjs and config/packaged-runtime-node-modules.cjs. Pin the scrcpy version — the server protocol is coupled to the jar.
  • Do not bundle adb / emulator / avdmanager (large; Android Studio installs them). Discover them at runtime and surface a clear setup message if the SDK is absent.
  • No new runtime npm dependency is required for decode (WebCodecs is built into Electron's Chromium). The wasm-decoder fallback (see Risks) would add a dep only if the WebCodecs spike fails.

Skill

New skills/orca-emulator-android/SKILL.md, mirroring skills/orca-emulator/SKILL.md:

  • Prerequisites: Android Studio / SDK installed, ANDROID_HOME (or ANDROID_SDK_ROOT) set, at least one AVD or a connected device.
  • The orca emulator ... command table (shared CLI; Android examples).
  • Gotchas: Orca handles pixel ↔ normalized conversion (agents always pass 01); adb device/serial targeting; no camera injection in v1; scrcpy version coupling.
  • Cross-reference from the iOS skill's "When NOT to use" (which already anticipates an Android backend under the same namespace).
  • Register it the same way orca-emulator is registered.

Availability & platform gating

src/main/emulator/emulator-availability.ts becomes an aggregator that asks each backend isSupportedOnHost() + checkAvailability():

  • iOS backend: supported only on darwin (unchanged behavior/messages).
  • Android backend: supported on any OS where the SDK is discoverable.
  • The combined result drives the pane's availability UI; Windows/Linux report an available mobile backend for the first time.

Edge cases

  • adb device in offline / unauthorized state → clear surfaced error, not a hang.
  • AVD boot timeout (cold boot can take minutes) → bounded wait with a cancel/error path; pane shows "booting".
  • SDK present but no AVDs and no devices → availability message points to "create an AVD in Android Studio".
  • Device rotates while a gesture is mid-flight → mapper re-reads frame size per event; no cached dimensions.
  • Multiple Android devices in one worktree → same "one active per worktree" model as iOS; explicit --device <serial> for the rest.
  • WebCodecs decoder error / key-frame loss → request a new keyframe from scrcpy and surface a transient "reconnecting" state (parity with the MJPEG reconnect in mjpeg-frame-stream.ts).
  • Windows path handling for the SDK and the pushed jar uses path.join only; never assume / or \.
  • App quit / pane close cleans up scrcpy-server, the adb tunnel, and (for managed AVDs) the emulator, mirroring EmulatorBridge.onAppQuit() / destroyAllSessions().

Test plan

Unit tests (co-located, node Vitest, matching the module's existing test density):

  • android-sdk-discovery — env precedence + per-OS default paths (mock env/fs; assert Windows/macOS/Linux branches).
  • adb-devices — parse adb devices -l (booted, offline, unauthorized, physical), boot-complete polling.
  • avd-manager — parse emulator -list-avds, boot command construction.
  • scrcpy-control-channel — exact byte encoding of touch/key/text/scroll messages.
  • android-input-mapping — normalized↔pixel round-trips, rotation, keycode map.
  • uiautomator-tree — XML → tree parsing, including malformed input.
  • android-availability + emulator-availability aggregation — iOS-only, Android-only, both, neither.
  • backend router resolution in emulator-bridge — id → backend, unknown id, cross-backend isolation.

Integration tests mock adb / emulator and the scrcpy sockets the same way the iOS tests mock serve-sim (serve-sim-*.test.ts, emulator-bridge.test.ts).

Electron validation (manual, on a machine with the Android SDK):

  • Boot an AVD from Orca; confirm the live pane streams and is responsive.
  • tap / swipe / type / Back / Home / Recents / rotate.
  • ax, install + launch, permissions grant, logcat.
  • Cross-platform smoke on Windows (primary driver) and macOS (iOS + Android coexistence).

Risks / verify-first

  • Electron H.264 WebCodecs decode — verify in a step-0 spike that an Electron renderer VideoDecoder decodes scrcpy's H.264. Electron ships proprietary codec decode, so this is expected to pass. Fallback if not: a wasm H.264 decoder (Broadway / tinyh264) or dropping to a main-process H.264→JPEG transcode — neither changes the backend interface, since the session advertises its codec.
  • scrcpy-server protocol is version-coupled to the bundled jar (same class of risk as serve-sim's private SimulatorKit APIs). Pin the version; record it next to the bundled jar.
  • adb/emulator environment variance — offline/unauthorized devices, cold-boot timeouts, missing SDK. All handled via explicit, surfaced errors.

Rollout

  1. Step-0 spike: confirm WebCodecs H.264 decode in the Electron renderer.
  2. Extract the EmulatorBackend interface + IosEmulatorBackend (pure refactor); keep all iOS tests green.
  3. Android device management (android-sdk-discovery, adb-devices, avd-manager, android-availability) + availability aggregation; surface Android devices in orca emulator list.
  4. scrcpy streaming (scrcpy-server-deploy, scrcpy-video-stream) + the video IPC channel + the renderer WebCodecs canvas path; live pane renders.
  5. scrcpy control (scrcpy-control-channel, android-input-mapping) + tap/gesture/type/button/rotate end-to-end.
  6. Extra capabilities: ax, install/launch, permissions, logcat.
  7. Renderer polish: Android hardware buttons, bezel, setup guide.
  8. Packaging (scrcpy-server.jar resource) + the orca-emulator-android skill.
  9. Tests at each step; typecheck + lint; Electron validation on Windows + macOS.

Open decisions

  • Whether orca emulator install/launch/logcat should also be exposed for iOS later (iOS install is xcrun simctl install); v1 leaves them Android-only via capability flags.
  • Whether to expose an explicit orca emulator boot <avd> verb vs. folding boot into attach; initial version folds boot into attach (parity with iOS, which boots on attach) and adds a --no-boot opt-out.