SimDeck is a developer tool built for streamlining mobile app development for coding agents.
- Drive Simulator from the CLI using agents, browser, and automated tests on macOS.
+ Drive iOS Simulators and Android emulators from the CLI using agents, browser, and automated tests on macOS.
@@ -35,8 +35,9 @@ view inside the editor.
## Features
-- Local simulator video stream over browser-native WebRTC H.264 with H.264 WebSocket fallback
-- Full simulator control & inspection using private accessibility APIs - available using `simdeck` CLI
+- Local iOS Simulator and Android emulator video over browser-native WebRTC H.264 with H.264 WebSocket fallback
+- Android emulator frames are sourced from emulator gRPC and encoded through macOS VideoToolbox
+- Full simulator control & inspection using private iOS accessibility APIs and Android UIAutomator - available using `simdeck` CLI
- Real-time screen `describe` command using accessibility view tree - available in token-efficient format for agents
- CoreSimulator chrome asset rendering for device bezels
- NativeScript, React Native, UIKit and SwiftUI runtime inspector plugins to view app's view hierarchy live
@@ -138,6 +139,7 @@ simdeck boot
simdeck shutdown
simdeck erase
simdeck install /path/to/App.app
+simdeck install android: /path/to/app.apk
simdeck uninstall com.example.App
simdeck open-url https://example.com
simdeck launch com.apple.Preferences
@@ -179,6 +181,14 @@ simdeck logs --seconds 30 --limit 200
without launching Simulator.app, then falls back to `xcrun simctl` when private
booting is unavailable.
+Android emulators appear in `simdeck list` with IDs like
+`android:SimDeck_Pixel_8_API_36`. For Android IDs, lifecycle, install, launch,
+URL, screenshot, logs, UIAutomator `describe`, tap, swipe, text, key, home, app
+switcher, rotation, pasteboard, and browser live view route through the Android
+SDK tools (`emulator` and `adb`) plus the emulator gRPC screenshot stream for
+live video. `simdeck stream` remains iOS-only because it writes the iOS H.264
+transport stream.
+
`stream` writes an Annex B H.264 elementary stream to stdout for diagnostics or
external tools such as `ffplay`.
diff --git a/cli/native/XCWNativeBridge.h b/cli/native/XCWNativeBridge.h
index a7d81d61..ff4ac7f1 100644
--- a/cli/native/XCWNativeBridge.h
+++ b/cli/native/XCWNativeBridge.h
@@ -89,6 +89,11 @@ bool xcw_native_session_rotate_right(void * _Nonnull handle, char * _Nullable *
bool xcw_native_session_rotate_left(void * _Nonnull handle, char * _Nullable * _Nullable error_message);
void xcw_native_session_set_frame_callback(void * _Nonnull handle, xcw_native_frame_callback _Nullable callback, void * _Nullable user_data);
+void * _Nullable xcw_native_h264_encoder_create(xcw_native_frame_callback _Nullable callback, void * _Nullable user_data, char * _Nullable * _Nullable error_message);
+void xcw_native_h264_encoder_destroy(void * _Nullable handle);
+bool xcw_native_h264_encoder_encode_rgba(void * _Nonnull handle, const uint8_t * _Nonnull rgba, size_t length, uint32_t width, uint32_t height, uint64_t timestamp_us, char * _Nullable * _Nullable error_message);
+void xcw_native_h264_encoder_request_keyframe(void * _Nonnull handle);
+
void xcw_native_free_string(char * _Nullable value);
void xcw_native_free_bytes(xcw_native_owned_bytes bytes);
void xcw_native_release_shared_bytes(xcw_native_shared_bytes bytes);
diff --git a/cli/native/XCWNativeBridge.m b/cli/native/XCWNativeBridge.m
index 3fc2376a..e93241e7 100644
--- a/cli/native/XCWNativeBridge.m
+++ b/cli/native/XCWNativeBridge.m
@@ -3,11 +3,13 @@
#import "DFPrivateSimulatorDisplayBridge.h"
#import "XCWAccessibilityBridge.h"
#import "XCWChromeRenderer.h"
+#import "XCWH264Encoder.h"
#import "XCWNativeSession.h"
#import "XCWSimctl.h"
#import
#import
+#import
#include
#include
@@ -63,10 +65,190 @@ static xcw_native_owned_bytes XCWOwnedBytesFromData(NSData *data) {
return bytes;
}
+static xcw_native_shared_bytes XCWSharedBytesFromData(NSData *data) {
+ if (data.length == 0) {
+ return (xcw_native_shared_bytes){0};
+ }
+
+ CFTypeRef owner = CFRetain((__bridge CFTypeRef)data);
+ return (xcw_native_shared_bytes){
+ .data = data.bytes,
+ .length = data.length,
+ .owner = (const void *)owner,
+ };
+}
+
static XCWNativeSession *XCWNativeSessionFromHandle(void *handle) {
return (__bridge XCWNativeSession *)handle;
}
+@interface XCWNativeH264Encoder : NSObject
+
+- (instancetype)initWithFrameCallback:(xcw_native_frame_callback)callback
+ userData:(void *)userData;
+- (BOOL)encodeRGBA:(const uint8_t *)rgba
+ length:(size_t)length
+ width:(uint32_t)width
+ height:(uint32_t)height
+ error:(NSError * _Nullable __autoreleasing *)error;
+- (void)requestKeyFrame;
+- (void)invalidate;
+
+@end
+
+@implementation XCWNativeH264Encoder {
+ XCWH264Encoder *_encoder;
+ xcw_native_frame_callback _callback;
+ void *_callbackUserData;
+ uint64_t _frameSequence;
+}
+
+- (instancetype)initWithFrameCallback:(xcw_native_frame_callback)callback
+ userData:(void *)userData {
+ self = [super init];
+ if (self == nil) {
+ return nil;
+ }
+
+ _callback = callback;
+ _callbackUserData = userData;
+ __weak typeof(self) weakSelf = self;
+ @synchronized (XCWNativeH264Encoder.class) {
+ const char *previousCodec = getenv("SIMDECK_VIDEO_CODEC");
+ char *previousCodecCopy = previousCodec != NULL ? strdup(previousCodec) : NULL;
+ const char *androidCodec = getenv("SIMDECK_ANDROID_VIDEO_CODEC");
+ if (androidCodec == NULL || strlen(androidCodec) == 0) {
+ androidCodec = "software";
+ }
+ setenv("SIMDECK_VIDEO_CODEC", androidCodec, 1);
+ _encoder = [[XCWH264Encoder alloc] initWithOutputHandler:^(NSData *sampleData,
+ uint64_t timestampUs,
+ BOOL isKeyFrame,
+ NSString * _Nullable codec,
+ NSData * _Nullable decoderConfig,
+ CGSize dimensions) {
+ __strong typeof(weakSelf) strongSelf = weakSelf;
+ if (strongSelf == nil || strongSelf->_callback == NULL || sampleData.length == 0) {
+ return;
+ }
+ strongSelf->_frameSequence += 1;
+ xcw_native_frame frame = {
+ .frame_sequence = strongSelf->_frameSequence,
+ .timestamp_us = timestampUs,
+ .is_keyframe = isKeyFrame,
+ .width = (uint32_t)llround(dimensions.width),
+ .height = (uint32_t)llround(dimensions.height),
+ .codec = codec.UTF8String,
+ .description = XCWSharedBytesFromData(decoderConfig),
+ .data = XCWSharedBytesFromData(sampleData),
+ };
+ strongSelf->_callback(&frame, strongSelf->_callbackUserData);
+ }];
+ if (previousCodecCopy != NULL) {
+ setenv("SIMDECK_VIDEO_CODEC", previousCodecCopy, 1);
+ free(previousCodecCopy);
+ } else {
+ unsetenv("SIMDECK_VIDEO_CODEC");
+ }
+ }
+ return self;
+}
+
+- (void)dealloc {
+ [self invalidate];
+}
+
+- (BOOL)encodeRGBA:(const uint8_t *)rgba
+ length:(size_t)length
+ width:(uint32_t)width
+ height:(uint32_t)height
+ error:(NSError * _Nullable __autoreleasing *)error {
+ if (rgba == NULL || width == 0 || height == 0) {
+ if (error != NULL) {
+ *error = [NSError errorWithDomain:@"SimDeck.NativeH264Encoder"
+ code:1
+ userInfo:@{ NSLocalizedDescriptionKey: @"RGBA frame input was empty." }];
+ }
+ return NO;
+ }
+ size_t expectedLength = (size_t)width * (size_t)height * 4;
+ if (length < expectedLength) {
+ if (error != NULL) {
+ *error = [NSError errorWithDomain:@"SimDeck.NativeH264Encoder"
+ code:2
+ userInfo:@{ NSLocalizedDescriptionKey: @"RGBA frame input was truncated." }];
+ }
+ return NO;
+ }
+
+ NSDictionary *attributes = @{
+ (__bridge NSString *)kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_32BGRA),
+ (__bridge NSString *)kCVPixelBufferWidthKey: @(width),
+ (__bridge NSString *)kCVPixelBufferHeightKey: @(height),
+ (__bridge NSString *)kCVPixelBufferIOSurfacePropertiesKey: @{},
+ };
+ CVPixelBufferRef pixelBuffer = NULL;
+ CVReturn createStatus = CVPixelBufferCreate(kCFAllocatorDefault,
+ (size_t)width,
+ (size_t)height,
+ kCVPixelFormatType_32BGRA,
+ (__bridge CFDictionaryRef)attributes,
+ &pixelBuffer);
+ if (createStatus != kCVReturnSuccess || pixelBuffer == NULL) {
+ if (error != NULL) {
+ *error = [NSError errorWithDomain:@"SimDeck.NativeH264Encoder"
+ code:createStatus
+ userInfo:@{ NSLocalizedDescriptionKey: @"Unable to allocate a VideoToolbox pixel buffer." }];
+ }
+ return NO;
+ }
+
+ CVReturn lockStatus = CVPixelBufferLockBaseAddress(pixelBuffer, 0);
+ if (lockStatus != kCVReturnSuccess) {
+ CVPixelBufferRelease(pixelBuffer);
+ if (error != NULL) {
+ *error = [NSError errorWithDomain:@"SimDeck.NativeH264Encoder"
+ code:lockStatus
+ userInfo:@{ NSLocalizedDescriptionKey: @"Unable to lock a VideoToolbox pixel buffer." }];
+ }
+ return NO;
+ }
+
+ uint8_t *dst = CVPixelBufferGetBaseAddress(pixelBuffer);
+ size_t dstRowBytes = CVPixelBufferGetBytesPerRow(pixelBuffer);
+ size_t srcRowBytes = (size_t)width * 4;
+ for (uint32_t y = 0; y < height; y += 1) {
+ const uint8_t *srcRow = rgba + ((size_t)y * srcRowBytes);
+ uint8_t *dstRow = dst + ((size_t)y * dstRowBytes);
+ for (uint32_t x = 0; x < width; x += 1) {
+ const uint8_t *src = srcRow + ((size_t)x * 4);
+ uint8_t *pixel = dstRow + ((size_t)x * 4);
+ pixel[0] = src[2];
+ pixel[1] = src[1];
+ pixel[2] = src[0];
+ pixel[3] = src[3];
+ }
+ }
+ CVPixelBufferUnlockBaseAddress(pixelBuffer, 0);
+ [_encoder encodePixelBuffer:pixelBuffer];
+ CVPixelBufferRelease(pixelBuffer);
+ return YES;
+}
+
+- (void)requestKeyFrame {
+ [_encoder requestKeyFrame];
+}
+
+- (void)invalidate {
+ [_encoder invalidate];
+}
+
+@end
+
+static XCWNativeH264Encoder *XCWNativeH264EncoderFromHandle(void *handle) {
+ return (__bridge XCWNativeH264Encoder *)handle;
+}
+
static BOOL XCWPerformSimctlAction(char **errorMessage, BOOL (^action)(XCWSimctl *simctl, NSError **error)) {
XCWSimctl *simctl = [[XCWSimctl alloc] init];
NSError *error = nil;
@@ -889,6 +1071,58 @@ void xcw_native_session_set_frame_callback(void *handle, xcw_native_frame_callba
}
}
+void *xcw_native_h264_encoder_create(xcw_native_frame_callback callback, void *user_data, char **error_message) {
+ @autoreleasepool {
+ XCWNativeH264Encoder *encoder = [[XCWNativeH264Encoder alloc] initWithFrameCallback:callback
+ userData:user_data];
+ if (encoder == nil) {
+ if (error_message != NULL) {
+ *error_message = XCWCopyCString(@"Unable to create the native H.264 encoder.");
+ }
+ return NULL;
+ }
+ return (__bridge_retained void *)encoder;
+ }
+}
+
+void xcw_native_h264_encoder_destroy(void *handle) {
+ if (handle == NULL) {
+ return;
+ }
+ @autoreleasepool {
+ XCWNativeH264Encoder *encoder = CFBridgingRelease(handle);
+ [encoder invalidate];
+ }
+}
+
+bool xcw_native_h264_encoder_encode_rgba(void *handle,
+ const uint8_t *rgba,
+ size_t length,
+ uint32_t width,
+ uint32_t height,
+ uint64_t timestamp_us,
+ char **error_message) {
+ (void)timestamp_us;
+ @autoreleasepool {
+ NSError *error = nil;
+ BOOL ok = [XCWNativeH264EncoderFromHandle(handle) encodeRGBA:rgba
+ length:length
+ width:width
+ height:height
+ error:&error];
+ if (!ok) {
+ XCWSetErrorMessage(error_message, error);
+ }
+ return ok;
+ }
+}
+
+void xcw_native_h264_encoder_request_keyframe(void *handle) {
+ @autoreleasepool {
+ [XCWNativeH264EncoderFromHandle(handle) requestKeyFrame];
+ }
+}
+
void xcw_native_free_string(char *value) {
if (value != NULL) {
free(value);
diff --git a/client/src/api/types.ts b/client/src/api/types.ts
index 713a0155..9579aef7 100644
--- a/client/src/api/types.ts
+++ b/client/src/api/types.ts
@@ -28,11 +28,17 @@ export interface PrivateDisplayInfo {
export interface SimulatorMetadata {
udid: string;
name: string;
+ platform?: "ios-simulator" | "android-emulator" | string;
runtimeName?: string;
runtimeIdentifier?: string;
deviceTypeName?: string;
deviceTypeIdentifier?: string;
isBooted: boolean;
+ android?: {
+ avdName?: string;
+ grpcPort?: number;
+ serial?: string;
+ };
privateDisplay?: PrivateDisplayInfo;
}
@@ -107,6 +113,7 @@ export interface ChromeProfile {
screenWidth: number;
screenHeight: number;
cornerRadius: number;
+ chromeStyle?: "asset" | string;
hasScreenMask?: boolean;
buttons?: ChromeButtonProfile[];
}
diff --git a/client/src/app/AppShell.tsx b/client/src/app/AppShell.tsx
index f475fccd..bb65b352 100644
--- a/client/src/app/AppShell.tsx
+++ b/client/src/app/AppShell.tsx
@@ -746,9 +746,9 @@ export function AppShell({
: "",
[selectedSimulator?.udid, streamStamp],
);
+ const chromeUsesAsset = Boolean(viewportChromeProfile && chromeUrl);
const chromeRequired = Boolean(
- (shouldRenderChrome && !chromeProfileReady) ||
- (viewportChromeProfile && chromeUrl),
+ (shouldRenderChrome && !chromeProfileReady) || chromeUsesAsset,
);
const simulatorRotationQuarterTurns =
normalizeSimulatorRotationQuarterTurns(selectedSimulator);
diff --git a/client/src/features/simulators/simulatorDisplay.test.ts b/client/src/features/simulators/simulatorDisplay.test.ts
index 58c0b2f9..f39a5507 100644
--- a/client/src/features/simulators/simulatorDisplay.test.ts
+++ b/client/src/features/simulators/simulatorDisplay.test.ts
@@ -51,4 +51,16 @@ describe("simulatorDisplay", () => {
),
).toBe(false);
});
+
+ it("keeps native chrome off for Android emulators", () => {
+ expect(
+ shouldRenderNativeChrome(
+ simulator({
+ deviceTypeIdentifier: "android-emulator",
+ name: "SimDeck Pixel",
+ platform: "android-emulator",
+ }),
+ ),
+ ).toBe(false);
+ });
});
diff --git a/docs/api/rest.md b/docs/api/rest.md
index 6955ee44..575fe822 100644
--- a/docs/api/rest.md
+++ b/docs/api/rest.md
@@ -102,7 +102,8 @@ When
### `GET /api/simulators`
-Returns every simulator known to the native bridge, enriched with any session state SimDeck has attached:
+Returns every iOS Simulator known to the native bridge plus every Android AVD
+found in the Android SDK, enriched with any session state SimDeck has attached:
```json
{
@@ -113,6 +114,7 @@ Returns every simulator known to the native bridge, enriched with any session st
"runtimeName": "iOS 18.0",
"deviceTypeIdentifier": "com.apple.CoreSimulator.SimDeviceType.iPhone-15-Pro",
"isBooted": true,
+ "platform": "ios-simulator",
"privateDisplay": {
"displayReady": true,
"displayStatus": "running",
@@ -126,13 +128,32 @@ Returns every simulator known to the native bridge, enriched with any session st
}
```
-`privateDisplay` is `null` until a stream attaches.
+Android emulators use IDs prefixed with `android:` and include Android metadata:
+
+```json
+{
+ "udid": "android:SimDeck_Pixel_8_API_36",
+ "name": "SimDeck_Pixel_8_API_36",
+ "platform": "android-emulator",
+ "runtimeName": "Android",
+ "deviceTypeName": "Android Emulator",
+ "isBooted": true,
+ "android": {
+ "avdName": "SimDeck_Pixel_8_API_36",
+ "serial": "emulator-5554",
+ "grpcPort": 8554
+ }
+}
+```
+
+For iOS, `privateDisplay` is `null` until a stream attaches. For Android,
+SimDeck fills display size from `adb shell wm size` when the emulator is booted.
## Simulator lifecycle
### `POST /api/simulators/{udid}/boot`
-Boots the simulator and returns the refreshed simulator metadata:
+Boots the simulator or Android emulator and returns the refreshed device metadata:
```json
{ "simulator": { ... } }
@@ -140,11 +161,12 @@ Boots the simulator and returns the refreshed simulator metadata:
### `POST /api/simulators/{udid}/shutdown`
-Tears down the live session (if any) and shuts the simulator down.
+Tears down the live session (if any) and shuts the simulator or emulator down.
### `POST /api/simulators/{udid}/toggle-appearance`
-Toggles between light and dark appearance via `simctl ui appearance`.
+Toggles between light and dark appearance via `simctl ui appearance` on iOS or
+`cmd uimode night` on Android.
```json
{ "ok": true }
@@ -152,7 +174,10 @@ Toggles between light and dark appearance via `simctl ui appearance`.
### `POST /api/simulators/{udid}/refresh`
-Forces the encoder to emit a fresh keyframe. Useful after a discontinuity or when the client decoder drifts.
+Forces the iOS encoder to emit a fresh frame. For Android IDs, this route is a
+no-op that returns `{ "ok": true, "stream": "screenshot" }`; Android WebRTC
+keyframe requests are handled through the WebRTC control channel and RTCP
+feedback.
```json
{ "ok": true }
@@ -182,13 +207,30 @@ and the server responds with an SDP answer for a receive-only H.264 video track:
}
```
-The endpoint requires the active simulator stream to produce H.264-compatible
-samples. The bundled browser client always uses this endpoint.
+The endpoint requires the selected device stream to produce H.264-compatible
+samples. For iOS, samples come from the native simulator display session. For
+Android, the server reads raw frames from the emulator gRPC `streamScreenshot`
+API, encodes them through VideoToolbox, and writes the resulting H.264 samples
+to the same WebRTC video track.
The browser also opens `simdeck-control` and `simdeck-telemetry` data channels.
In addition to input messages, clients can request a keyframe or tune the
stream attached to that peer:
+### `GET /api/simulators/{udid}/android/frames`
+
+Android-only WebSocket stream backed by the emulator gRPC `streamScreenshot`
+API. This is retained as a raw-frame diagnostic/fallback path; the browser live
+view uses the WebRTC H.264 endpoint by default. The server sends binary raw RGBA
+frames with a small SimDeck header, already flipped into top-down row order for
+canvas rendering.
+Query parameters:
+
+| Query parameter | Default | Notes |
+| --------------- | ------- | ------------------------------------------ |
+| `maxEdge` | `960` | Longest output edge requested from gRPC. |
+| `maxFps` | `30` | Max frames per second forwarded to client. |
+
```json
{ "type": "streamControl", "forceKeyframe": true }
```
@@ -274,6 +316,13 @@ Content-Type: application/json
{ "ok": true }
```
+### `GET /api/simulators/{udid}/screenshot.png`
+
+Returns a PNG screenshot for the selected device. iOS screenshots come from the
+native simulator bridge; Android screenshots come from `adb exec-out screencap
+-p`. The browser client uses this endpoint for still-image diagnostics and
+fallbacks.
+
## Input
### `POST /api/simulators/{udid}/touch`
diff --git a/docs/cli/commands.md b/docs/cli/commands.md
index 7ad378be..09318c31 100644
--- a/docs/cli/commands.md
+++ b/docs/cli/commands.md
@@ -177,12 +177,17 @@ simdeck shutdown
simdeck erase
```
-`list` returns the same simulator inventory the browser UI renders. Lifecycle commands return JSON and use the native bridge, preferring private CoreSimulator paths when available and falling back to `xcrun simctl`.
+`list` returns the same simulator inventory the browser UI renders, including
+Android AVDs as IDs like `android:Pixel_8_API_36`. iOS lifecycle commands use
+the native bridge, preferring private CoreSimulator paths when available and
+falling back to `xcrun simctl`. Android lifecycle commands use the Android SDK
+`emulator` and `adb` tools.
## Apps And URLs
```sh
simdeck install /path/to/App.app
+simdeck install android: /path/to/app.apk
simdeck uninstall com.example.App
simdeck launch com.example.App
simdeck open-url https://example.com
@@ -267,9 +272,13 @@ simdeck chrome-profile
`stream` writes Annex B H.264 samples to stdout and runs until interrupted, or
until `--frames` samples have been written. It is intended for diagnostics and
-external tools.
+external tools, and is iOS-only. Android live viewing in the browser uses the
+WebRTC H.264 endpoint; raw frames come from emulator gRPC and are encoded
+through VideoToolbox.
-`logs` fetches recent simulator logs. `chrome-profile` returns the CoreSimulator chrome layout used by the browser viewport.
+`logs` fetches recent simulator logs or Android `logcat` output. `chrome-profile`
+returns the CoreSimulator chrome layout for iOS and a screen-sized profile for
+Android.
## HTTP Fast Path
diff --git a/docs/extensions/browser-client.md b/docs/extensions/browser-client.md
index b9124c30..336356cd 100644
--- a/docs/extensions/browser-client.md
+++ b/docs/extensions/browser-client.md
@@ -39,15 +39,15 @@ client/
└── styles/
```
-| Folder | Responsibility |
-| ------------------------- | ----------------------------------------------------------------------- |
-| `api/` | Typed wrappers around the SimDeck REST API and shared TypeScript types. |
-| `features/simulators/` | Sidebar list of simulators plus boot/shutdown affordances. |
-| `features/viewport/` | Frame canvas, chrome compositing, hit testing. |
-| `features/stream/` | WebRTC client, receiver stats, and video frame plumbing. |
-| `features/input/` | Touch / keyboard / hardware-button affordances. |
-| `features/accessibility/` | Accessibility tree pane and source switcher. |
-| `features/toolbar/` | Top toolbar (rotate, home, app switcher, dark mode toggle, refresh). |
+| Folder | Responsibility |
+| ------------------------- | ---------------------------------------------------------------------------- |
+| `api/` | Typed wrappers around the SimDeck REST API and shared TypeScript types. |
+| `features/simulators/` | Sidebar list of simulators plus boot/shutdown affordances. |
+| `features/viewport/` | Frame canvas, chrome compositing, hit testing. |
+| `features/stream/` | WebRTC H.264 client for iOS and Android, receiver stats, and frame plumbing. |
+| `features/input/` | Touch / keyboard / hardware-button affordances. |
+| `features/accessibility/` | Accessibility tree pane and source switcher. |
+| `features/toolbar/` | Top toolbar (rotate, home, app switcher, dark mode toggle, refresh). |
## Bootstrap flow
@@ -55,8 +55,8 @@ client/
2. `main.tsx` mounts the React tree at `#root`.
3. `AppShell` calls `GET /api/health` to learn the active encoder mode.
4. The simulator sidebar fetches `GET /api/simulators` and renders the list.
-5. Selecting a simulator posts an SDP offer to `/api/simulators//webrtc/offer`.
-6. The browser renders the H.264 video track through native WebRTC playback.
+5. Selecting a device posts an SDP offer to `/api/simulators//webrtc/offer`.
+6. The browser renders the H.264 video track through native WebRTC playback. Android emulator frames are sourced from emulator gRPC on the server and encoded through VideoToolbox before WebRTC delivery.
7. Touch and key events round-trip through `POST /api/simulators//touch` and `/key`.
## Dev workflow
diff --git a/docs/guide/architecture.md b/docs/guide/architecture.md
index e2af3763..122c8b48 100644
--- a/docs/guide/architecture.md
+++ b/docs/guide/architecture.md
@@ -4,13 +4,13 @@ SimDeck is intentionally split into a small number of clearly-scoped layers. Eve
## High-level layout
-SimDeck has three layers stacked between the browser and the iOS Simulator:
+SimDeck has three layers stacked between the browser and the target device:
-1. **Browser / VS Code** runs the React client from `client/`. It speaks HTTP for control and WebRTC for live video, served by the Rust server.
+1. **Browser / VS Code** runs the React client from `client/`. It speaks HTTP for control and WebRTC H.264 for live video, served by the Rust server.
2. **The Rust server** (`server/`, built on `axum` + `tokio`) owns the CLI entrypoint, project daemon lifecycle, REST routes (`api/`), the stream transports (`transport/`), the inspector WebSocket hub (`inspector.rs`), the per-UDID session registry (`simulators/`), metrics, and log streaming.
-3. **The Objective-C bridge** (`cli/`) is reached through a narrow C ABI in `cli/native/XCWNativeBridge.*`. It wraps `xcrun simctl`, the private `CoreSimulator` direct-boot path, the per-session hardware/software H.264 encoder, the headless display bridge that produces frames and accepts HID input, and the device-chrome renderer.
+3. **Native device bridges** own platform-specific work. The Objective-C bridge (`cli/`) is reached through a narrow C ABI in `cli/native/XCWNativeBridge.*` for iOS. The Rust Android bridge (`server/src/android.rs`) shells out to the Android SDK for AVD discovery, emulator lifecycle, ADB input, screenshots, UIAutomator, and logcat.
-Underneath all of that is the iOS Simulator itself — `CoreSimulator` for lifecycle, `SimulatorKit` for chrome assets.
+Underneath all of that are the iOS Simulator (`CoreSimulator` and `SimulatorKit`) and the Android emulator (`emulator` and `adb`).
## Layer responsibilities
@@ -20,17 +20,18 @@ Owns the public CLI shape (`simdeck`, `simdeck ui`, `daemon`, `boot`, `shutdown`
Key modules:
-| Module | Responsibility |
-| ----------------------------------- | ---------------------------------------------------------------------------------------------------------- |
-| `server/src/main.rs` | CLI entrypoint, project daemon management, AppKit main-thread shim, tokio runtime bootstrap. |
-| `server/src/api/routes.rs` | Every `/api/*` route, including simulator control, accessibility, and inspector proxy. |
-| `server/src/transport/webrtc.rs` | WebRTC offer/answer endpoint for H.264 browser video. |
-| `server/src/transport/packet.rs` | Shared encoded frame type used between simulator sessions and transports. |
-| `server/src/inspector.rs` | WebSocket hub for the NativeScript runtime inspector. |
-| `server/src/simulators/registry.rs` | Per-UDID session registry with lazy attachment to the native bridge. |
-| `server/src/simulators/session.rs` | Frame broadcast channel, keyframe gating, refresh requests. |
-| `server/src/metrics/counters.rs` | Atomic counters and per-client stream stats accepted from stream transports or `/api/client-stream-stats`. |
-| `server/src/logs.rs` | `os_log` log streaming and filtering. |
+| Module | Responsibility |
+| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- |
+| `server/src/main.rs` | CLI entrypoint, project daemon management, AppKit main-thread shim, tokio runtime bootstrap. |
+| `server/src/api/routes.rs` | Every `/api/*` route, including simulator control, accessibility, and inspector proxy. |
+| `server/src/android.rs` | Android AVD discovery, emulator lifecycle, emulator gRPC input/video, screenshots, UIAutomator, and logcat. |
+| `server/src/transport/webrtc.rs` | WebRTC offer/answer endpoint for H.264 browser video. |
+| `server/src/transport/packet.rs` | Shared encoded frame type used between simulator sessions and transports. |
+| `server/src/inspector.rs` | WebSocket hub for the NativeScript runtime inspector. |
+| `server/src/simulators/registry.rs` | Per-UDID session registry with lazy attachment to the native bridge. |
+| `server/src/simulators/session.rs` | Frame broadcast channel, keyframe gating, refresh requests. |
+| `server/src/metrics/counters.rs` | Atomic counters and per-client stream stats accepted from stream transports or `/api/client-stream-stats`. |
+| `server/src/logs.rs` | `os_log` log streaming and filtering. |
The Rust server runs the tokio runtime on a worker thread while the AppKit main loop spins on the main thread. The native bridge needs the main loop to deliver display callbacks and HID events.
@@ -53,13 +54,16 @@ Inside the bridge:
### `client/` — React browser UI
-The React app served at `/` is a thin shell that calls the REST API and consumes live video over WebRTC H.264.
+The React app served at `/` is a thin shell that calls the REST API. It consumes
+live device video over WebRTC H.264. iOS frames come from the native simulator
+display bridge; Android frames come from emulator gRPC `streamScreenshot` and
+are encoded through VideoToolbox on the server.
Layout under `client/src/`:
- `app/AppShell.tsx` — top-level shell.
- `api/` — typed wrappers around `/api/*` (`client.ts`, `controls.ts`, `simulators.ts`, `types.ts`).
-- `features/stream/` — WebRTC client, receiver stats, and video frame plumbing.
+- `features/stream/` — WebRTC client, receiver stats, and frame plumbing.
- `features/viewport/` — frame canvas, hit testing, chrome compositing.
- `features/input/` — touch/keyboard/hardware button affordances.
- `features/accessibility/` — accessibility tree pane and source switcher.
@@ -84,7 +88,7 @@ Most control endpoints follow the same path: a typed Rust handler in `server/src
### Live video
-The browser posts an SDP offer to `/api/simulators/{udid}/webrtc/offer`. The handler in `transport::webrtc` ensures the per-UDID `SimulatorSession` is started, waits up to ~3 s for the first H.264 keyframe, returns an SDP answer, and writes the simulator frame source to a WebRTC video track.
+The browser posts an SDP offer to `/api/simulators/{udid}/webrtc/offer`. The handler in `transport::webrtc` starts the selected frame source, waits for the first H.264 keyframe, returns an SDP answer, and writes H.264 samples to a WebRTC video track. For Android, that source is emulator gRPC raw pixels passed through the shared VideoToolbox encoder path.
### Input
diff --git a/docs/guide/installation.md b/docs/guide/installation.md
index 43e33926..0fabaa30 100644
--- a/docs/guide/installation.md
+++ b/docs/guide/installation.md
@@ -10,6 +10,7 @@ SimDeck only runs on macOS. The native bridge links private `CoreSimulator` and
| ---------------------------------- | ------------------------------------------------------------------------------------ |
| **macOS 13+** | Required for current `CoreSimulator` and Apple's VideoToolbox H.264 encoder. |
| **Xcode + iOS Simulator runtimes** | The native bridge invokes `xcrun simctl` and the Simulator app. |
+| **Android SDK tools** | Optional. Required for Android emulator support (`emulator`, `adb`, and AVD images). |
| **Node.js ≥ 18** | The launcher (`bin/simdeck.mjs`) and the bundled client tooling. |
| **Rust (stable)** | Required only when building from source. Installed via [rustup](https://rustup.rs/). |
diff --git a/scripts/integration/cli.mjs b/scripts/integration/cli.mjs
index 461cf2b7..c9551b7f 100644
--- a/scripts/integration/cli.mjs
+++ b/scripts/integration/cli.mjs
@@ -837,6 +837,28 @@ async function ensureFixtureForeground(label, options = {}) {
if (launchError === null) {
throw verifyError;
}
+ logStep(`${label}: opening fixture URL after launch timeout`);
+ }
+
+ try {
+ await retrySimdeckJson(
+ cliArgs(["open-url", simulatorUDID, fixtureUrl]),
+ `${label} fixture URL fallback`,
+ {
+ attempts: 2,
+ delayMs: 2_000,
+ timeoutMs: 180_000,
+ },
+ );
+ return await verifyUi(label, {
+ expectFixture: true,
+ attempts: options.fallbackVerifyAttempts ?? 12,
+ delayMs: options.fallbackVerifyDelayMs ?? 1_500,
+ });
+ } catch (urlError) {
+ logStep(
+ `${label}: fixture URL fallback failed: ${summarizeError(urlError)}`,
+ );
logStep(`${label}: tapping fixture icon after launch timeout`);
}
diff --git a/server/Cargo.lock b/server/Cargo.lock
index a0120b67..9c7c7db3 100644
--- a/server/Cargo.lock
+++ b/server/Cargo.lock
@@ -150,6 +150,28 @@ dependencies = [
"syn",
]
+[[package]]
+name = "async-stream"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
+dependencies = [
+ "async-stream-impl",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-stream-impl"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "async-trait"
version = "0.1.89"
@@ -173,13 +195,40 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+[[package]]
+name = "axum"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
+dependencies = [
+ "async-trait",
+ "axum-core 0.4.5",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "itoa",
+ "matchit 0.7.3",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "rustversion",
+ "serde",
+ "sync_wrapper",
+ "tower 0.5.3",
+ "tower-layer",
+ "tower-service",
+]
+
[[package]]
name = "axum"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
- "axum-core",
+ "axum-core 0.5.6",
"base64",
"bytes",
"form_urlencoded",
@@ -190,7 +239,7 @@ dependencies = [
"hyper",
"hyper-util",
"itoa",
- "matchit",
+ "matchit 0.8.4",
"memchr",
"mime",
"percent-encoding",
@@ -203,12 +252,32 @@ dependencies = [
"sync_wrapper",
"tokio",
"tokio-tungstenite",
- "tower",
+ "tower 0.5.3",
"tower-layer",
"tower-service",
"tracing",
]
+[[package]]
+name = "axum-core"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
+dependencies = [
+ "async-trait",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "rustversion",
+ "sync_wrapper",
+ "tower-layer",
+ "tower-service",
+]
+
[[package]]
name = "axum-core"
version = "0.5.6"
@@ -314,9 +383,9 @@ dependencies = [
[[package]]
name = "cc"
-version = "1.2.60"
+version = "1.2.62"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
+checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
dependencies = [
"find-msvc-tools",
"shlex",
@@ -486,9 +555,9 @@ dependencies = [
[[package]]
name = "data-encoding"
-version = "2.10.0"
+version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
+checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
name = "der"
@@ -561,6 +630,12 @@ dependencies = [
"spki",
]
+[[package]]
+name = "either"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+
[[package]]
name = "elliptic-curve"
version = "0.13.8"
@@ -620,6 +695,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
[[package]]
name = "foldhash"
version = "0.1.5"
@@ -791,6 +872,31 @@ dependencies = [
"subtle",
]
+[[package]]
+name = "h2"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "http",
+ "indexmap 2.14.0",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -802,9 +908,9 @@ dependencies = [
[[package]]
name = "hashbrown"
-version = "0.17.0"
+version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
@@ -897,6 +1003,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
+ "h2",
"http",
"http-body",
"httparse",
@@ -905,6 +1012,20 @@ dependencies = [
"pin-project-lite",
"smallvec",
"tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-timeout"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
+dependencies = [
+ "hyper",
+ "hyper-util",
+ "pin-project-lite",
+ "tokio",
+ "tower-service",
]
[[package]]
@@ -914,12 +1035,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"bytes",
+ "futures-channel",
+ "futures-util",
"http",
"http-body",
"hyper",
+ "libc",
"pin-project-lite",
+ "socket2 0.6.3",
"tokio",
"tower-service",
+ "tracing",
]
[[package]]
@@ -1023,14 +1149,24 @@ dependencies = [
[[package]]
name = "idna_adapter"
-version = "1.2.1"
+version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [
"icu_normalizer",
"icu_properties",
]
+[[package]]
+name = "indexmap"
+version = "1.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
+dependencies = [
+ "autocfg",
+ "hashbrown 0.12.3",
+]
+
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -1038,7 +1174,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
- "hashbrown 0.17.0",
+ "hashbrown 0.17.1",
"serde",
"serde_core",
]
@@ -1085,6 +1221,15 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+[[package]]
+name = "itertools"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
+dependencies = [
+ "either",
+]
+
[[package]]
name = "itoa"
version = "1.0.18"
@@ -1093,10 +1238,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
-version = "0.3.95"
+version = "0.3.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
+checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"
dependencies = [
+ "cfg-if",
+ "futures-util",
"once_cell",
"wasm-bindgen",
]
@@ -1115,9 +1262,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
-version = "0.2.185"
+version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "litemap"
@@ -1149,6 +1296,12 @@ dependencies = [
"regex-automata",
]
+[[package]]
+name = "matchit"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
+
[[package]]
name = "matchit"
version = "0.8.4"
@@ -1378,6 +1531,26 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+[[package]]
+name = "pin-project"
+version = "1.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9"
+dependencies = [
+ "pin-project-internal",
+]
+
+[[package]]
+name = "pin-project-internal"
+version = "1.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -1407,7 +1580,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
dependencies = [
"base64",
- "indexmap",
+ "indexmap 2.14.0",
"quick-xml",
"serde",
"time",
@@ -1483,6 +1656,29 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "prost"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
+dependencies = [
+ "bytes",
+ "prost-derive",
+]
+
+[[package]]
+name = "prost-derive"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
+dependencies = [
+ "anyhow",
+ "itertools",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "quick-xml"
version = "0.39.4"
@@ -1648,6 +1844,12 @@ dependencies = [
"windows-sys 0.52.0",
]
+[[package]]
+name = "roxmltree"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
+
[[package]]
name = "rtcp"
version = "0.12.0"
@@ -1694,9 +1896,9 @@ dependencies = [
[[package]]
name = "rustls"
-version = "0.23.38"
+version = "0.23.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21"
+checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"once_cell",
"ring",
@@ -1708,9 +1910,9 @@ dependencies = [
[[package]]
name = "rustls-pki-types"
-version = "1.14.0"
+version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
+checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"zeroize",
]
@@ -1904,7 +2106,7 @@ name = "simdeck-server"
version = "0.1.0"
dependencies = [
"anyhow",
- "axum",
+ "axum 0.8.9",
"base64",
"bytes",
"cc",
@@ -1914,6 +2116,8 @@ dependencies = [
"http",
"libc",
"plist",
+ "prost",
+ "roxmltree",
"serde",
"serde_json",
"sha2",
@@ -1921,6 +2125,7 @@ dependencies = [
"tokio",
"tokio-stream",
"tokio-tungstenite",
+ "tonic",
"tower-http",
"tracing",
"tracing-subscriber",
@@ -2144,9 +2349,9 @@ dependencies = [
[[package]]
name = "tokio"
-version = "1.52.1"
+version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
+checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"bytes",
"libc",
@@ -2206,6 +2411,56 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "tonic"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52"
+dependencies = [
+ "async-stream",
+ "async-trait",
+ "axum 0.7.9",
+ "base64",
+ "bytes",
+ "h2",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-timeout",
+ "hyper-util",
+ "percent-encoding",
+ "pin-project",
+ "prost",
+ "socket2 0.5.10",
+ "tokio",
+ "tokio-stream",
+ "tower 0.4.13",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tower"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "indexmap 1.9.3",
+ "pin-project",
+ "pin-project-lite",
+ "rand 0.8.6",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
[[package]]
name = "tower"
version = "0.5.3"
@@ -2224,9 +2479,9 @@ dependencies = [
[[package]]
name = "tower-http"
-version = "0.6.8"
+version = "0.6.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
+checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
dependencies = [
"bitflags 2.11.1",
"bytes",
@@ -2322,6 +2577,12 @@ dependencies = [
"tracing-log",
]
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
[[package]]
name = "tungstenite"
version = "0.29.0"
@@ -2455,6 +2716,15 @@ dependencies = [
"atomic-waker",
]
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@@ -2481,9 +2751,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
-version = "0.2.118"
+version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89"
+checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
dependencies = [
"cfg-if",
"once_cell",
@@ -2494,9 +2764,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
-version = "0.2.118"
+version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed"
+checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -2504,9 +2774,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
-version = "0.2.118"
+version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904"
+checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -2517,9 +2787,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
-version = "0.2.118"
+version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129"
+checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
dependencies = [
"unicode-ident",
]
@@ -2541,7 +2811,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
- "indexmap",
+ "indexmap 2.14.0",
"wasm-encoder",
"wasmparser",
]
@@ -2554,7 +2824,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags 2.11.1",
"hashbrown 0.15.5",
- "indexmap",
+ "indexmap 2.14.0",
"semver",
]
@@ -2911,7 +3181,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
- "indexmap",
+ "indexmap 2.14.0",
"prettyplease",
"syn",
"wasm-metadata",
@@ -2942,7 +3212,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags 2.11.1",
- "indexmap",
+ "indexmap 2.14.0",
"log",
"serde",
"serde_derive",
@@ -2961,7 +3231,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
- "indexmap",
+ "indexmap 2.14.0",
"log",
"semver",
"serde",
diff --git a/server/Cargo.toml b/server/Cargo.toml
index 90270a9b..0a376bc6 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -15,6 +15,8 @@ hex = "0.4"
http = "1.1"
libc = "0.2"
plist = "1.7"
+prost = "0.13"
+roxmltree = "0.20"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sha2 = "0.10"
@@ -22,6 +24,7 @@ thiserror = "2.0"
tokio = { version = "1.42", features = ["fs", "io-util", "macros", "process", "rt-multi-thread", "signal", "sync", "time"] }
tokio-stream = "0.1"
tokio-tungstenite = "0.29"
+tonic = { version = "0.12", features = ["transport"] }
tower-http = { version = "0.6", features = ["cors", "fs", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
diff --git a/server/src/android.rs b/server/src/android.rs
new file mode 100644
index 00000000..3e6d66e5
--- /dev/null
+++ b/server/src/android.rs
@@ -0,0 +1,1355 @@
+use crate::error::AppError;
+use bytes::BytesMut;
+use http::uri::PathAndQuery;
+use serde_json::{json, Value};
+use std::collections::{HashMap, HashSet};
+use std::env;
+use std::ffi::OsString;
+use std::future::Future;
+use std::path::{Path, PathBuf};
+use std::process::{Command, Stdio};
+use std::sync::{Mutex, OnceLock};
+use std::thread;
+use std::time::{Duration, Instant};
+use tonic::metadata::MetadataValue;
+use tonic::transport::{Channel, Endpoint};
+
+const ANDROID_ID_PREFIX: &str = "android:";
+const DEFAULT_GRPC_PORT_BASE: u16 = 8554;
+const DEFAULT_ANDROID_STREAM_MAX_EDGE: u32 = 960;
+const ANDROID_TOUCH_IDENTIFIER: i32 = 1;
+const RUNNING_EMULATOR_CACHE_TTL: Duration = Duration::from_secs(2);
+const AVD_GRPC_PORT_CACHE_TTL: Duration = Duration::from_secs(60);
+const SCREEN_SIZE_CACHE_TTL: Duration = Duration::from_secs(60);
+
+type TimedMap = Option<(Instant, HashMap)>;
+type ScreenSizeCache = HashMap;
+
+#[derive(Clone, Default)]
+pub struct AndroidBridge;
+
+#[derive(Clone, Debug)]
+pub struct AndroidDevice {
+ pub avd_name: String,
+ pub serial: Option,
+ pub is_booted: bool,
+ pub grpc_port: u16,
+}
+
+#[derive(Debug)]
+pub struct AndroidFrame {
+ pub width: u32,
+ pub height: u32,
+ pub seq: u32,
+ pub timestamp_us: u64,
+ pub rgba: Vec,
+}
+
+pub struct AndroidGrpcFrameStream {
+ inner: tonic::Streaming,
+}
+
+pub fn is_android_id(id: &str) -> bool {
+ id.starts_with(ANDROID_ID_PREFIX)
+}
+
+pub fn avd_from_id(id: &str) -> Result {
+ id.strip_prefix(ANDROID_ID_PREFIX)
+ .filter(|value| !value.trim().is_empty())
+ .map(ToOwned::to_owned)
+ .ok_or_else(|| AppError::bad_request(format!("Invalid Android emulator id `{id}`.")))
+}
+
+pub fn id_for_avd(avd_name: &str) -> String {
+ format!("{ANDROID_ID_PREFIX}{avd_name}")
+}
+
+impl AndroidBridge {
+ pub fn list_devices(&self) -> Result, AppError> {
+ if !self.emulator_path().exists() {
+ return Ok(Vec::new());
+ }
+
+ let avds = self
+ .run_emulator(["-list-avds"])?
+ .lines()
+ .map(str::trim)
+ .filter(|line| !line.is_empty())
+ .map(ToOwned::to_owned)
+ .collect::>();
+ if avds.is_empty() {
+ return Ok(Vec::new());
+ }
+
+ let running = self.running_emulators().unwrap_or_default();
+ Ok(avds
+ .into_iter()
+ .enumerate()
+ .map(|(index, avd_name)| AndroidDevice {
+ serial: running.get(&avd_name).cloned(),
+ is_booted: running.contains_key(&avd_name),
+ grpc_port: DEFAULT_GRPC_PORT_BASE + index as u16,
+ avd_name,
+ })
+ .collect())
+ }
+
+ pub fn enrich_devices(&self, devices: Vec) -> Vec {
+ devices
+ .into_iter()
+ .map(|device| self.device_value(device))
+ .collect()
+ }
+
+ pub fn boot(&self, id: &str) -> Result<(), AppError> {
+ let avd_name = avd_from_id(id)?;
+ if self.resolve_serial(&avd_name).is_ok() {
+ return Ok(());
+ }
+ let grpc_port = self.grpc_port_for_avd(&avd_name)?;
+ Command::new(self.emulator_path())
+ .args([
+ "-avd",
+ &avd_name,
+ "-no-window",
+ "-no-audio",
+ "-gpu",
+ "swiftshader_indirect",
+ "-grpc",
+ &grpc_port.to_string(),
+ ])
+ .stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .spawn()
+ .map_err(|error| {
+ AppError::native(format!(
+ "Unable to start Android emulator `{avd_name}`: {error}"
+ ))
+ })?;
+ Ok(())
+ }
+
+ pub fn shutdown(&self, id: &str) -> Result<(), AppError> {
+ let avd_name = avd_from_id(id)?;
+ let serial = self.resolve_serial(&avd_name)?;
+ let _ = self.run_adb(["-s", &serial, "emu", "kill"])?;
+ Ok(())
+ }
+
+ pub fn erase(&self, id: &str) -> Result<(), AppError> {
+ let avd_name = avd_from_id(id)?;
+ if self.resolve_serial(&avd_name).is_ok() {
+ return Err(AppError::bad_request(
+ "Shutdown the Android emulator before erasing it.",
+ ));
+ }
+ let avd_dir = self.avd_dir(&avd_name);
+ for file_name in [
+ "userdata-qemu.img",
+ "cache.img",
+ "data.img",
+ "sdcard.img",
+ "snapshots.img",
+ ] {
+ let path = avd_dir.join(file_name);
+ if path.exists() {
+ std::fs::remove_file(&path).map_err(|error| {
+ AppError::native(format!("Unable to remove {}: {error}", path.display()))
+ })?;
+ }
+ }
+ Ok(())
+ }
+
+ pub fn wait_until_booted(&self, id: &str, timeout_duration: Duration) -> Result<(), AppError> {
+ let avd_name = avd_from_id(id)?;
+ let deadline = Instant::now() + timeout_duration;
+ loop {
+ if let Ok(serial) = self.resolve_serial(&avd_name) {
+ if self
+ .run_adb(["-s", &serial, "shell", "getprop", "sys.boot_completed"])
+ .unwrap_or_default()
+ .trim()
+ == "1"
+ {
+ return Ok(());
+ }
+ }
+ if Instant::now() >= deadline {
+ return Err(AppError::native(format!(
+ "Android emulator `{avd_name}` did not finish booting in time."
+ )));
+ }
+ thread::sleep(Duration::from_millis(500));
+ }
+ }
+
+ pub fn screenshot_png(&self, id: &str) -> Result, AppError> {
+ let serial = self.serial_for_id(id)?;
+ self.run_adb_bytes(["-s", &serial, "exec-out", "screencap", "-p"])
+ }
+
+ pub fn install_app(&self, id: &str, app_path: &str) -> Result<(), AppError> {
+ if !app_path.ends_with(".apk") {
+ return Err(AppError::bad_request(
+ "Android install expects an `.apk` path.",
+ ));
+ }
+ let serial = self.serial_for_id(id)?;
+ self.run_adb(["-s", &serial, "install", "-r", app_path])?;
+ Ok(())
+ }
+
+ pub fn uninstall_app(&self, id: &str, package_name: &str) -> Result<(), AppError> {
+ let serial = self.serial_for_id(id)?;
+ self.run_adb(["-s", &serial, "uninstall", package_name])?;
+ Ok(())
+ }
+
+ pub fn open_url(&self, id: &str, url: &str) -> Result<(), AppError> {
+ let serial = self.serial_for_id(id)?;
+ self.run_adb([
+ "-s",
+ &serial,
+ "shell",
+ "am",
+ "start",
+ "-a",
+ "android.intent.action.VIEW",
+ "-d",
+ url,
+ ])?;
+ Ok(())
+ }
+
+ pub fn launch_package(&self, id: &str, package: &str) -> Result<(), AppError> {
+ let serial = self.serial_for_id(id)?;
+ self.run_adb([
+ "-s",
+ &serial,
+ "shell",
+ "monkey",
+ "-p",
+ package,
+ "-c",
+ "android.intent.category.LAUNCHER",
+ "1",
+ ])?;
+ Ok(())
+ }
+
+ pub fn set_pasteboard_text(&self, id: &str, text: &str) -> Result<(), AppError> {
+ let serial = self.serial_for_id(id)?;
+ self.run_adb_shell(&serial, &format!("cmd clipboard set {}", shell_quote(text)))?;
+ Ok(())
+ }
+
+ pub fn pasteboard_text(&self, id: &str) -> Result {
+ let serial = self.serial_for_id(id)?;
+ self.run_adb_shell(&serial, "cmd clipboard get")
+ }
+
+ pub fn send_touch(&self, id: &str, x: f64, y: f64, phase: &str) -> Result<(), AppError> {
+ if self.send_touch_grpc(id, x, y, phase).is_ok() {
+ return Ok(());
+ }
+ if phase != "ended" && phase != "cancelled" {
+ return Ok(());
+ }
+ let serial = self.serial_for_id(id)?;
+ let (width, height) = self.screen_size_for_serial(&serial)?;
+ let px = (x.clamp(0.0, 1.0) * (width - 1.0)).round().max(0.0);
+ let py = (y.clamp(0.0, 1.0) * (height - 1.0)).round().max(0.0);
+ self.run_adb([
+ "-s",
+ &serial,
+ "shell",
+ "input",
+ "tap",
+ &px.to_string(),
+ &py.to_string(),
+ ])?;
+ Ok(())
+ }
+
+ pub fn send_swipe(
+ &self,
+ id: &str,
+ start_x: f64,
+ start_y: f64,
+ end_x: f64,
+ end_y: f64,
+ duration_ms: u64,
+ ) -> Result<(), AppError> {
+ if self
+ .send_swipe_grpc(id, start_x, start_y, end_x, end_y, duration_ms)
+ .is_ok()
+ {
+ return Ok(());
+ }
+ let serial = self.serial_for_id(id)?;
+ let (width, height) = self.screen_size_for_serial(&serial)?;
+ let coords = [start_x, start_y, end_x, end_y]
+ .into_iter()
+ .enumerate()
+ .map(|(index, value)| {
+ let max = if index % 2 == 0 {
+ width - 1.0
+ } else {
+ height - 1.0
+ };
+ (value.clamp(0.0, 1.0) * max).round().max(0.0).to_string()
+ })
+ .collect::>();
+ self.run_adb([
+ "-s",
+ &serial,
+ "shell",
+ "input",
+ "swipe",
+ &coords[0],
+ &coords[1],
+ &coords[2],
+ &coords[3],
+ &duration_ms.to_string(),
+ ])?;
+ Ok(())
+ }
+
+ pub fn send_key(&self, id: &str, key_code: u16, _modifiers: u32) -> Result<(), AppError> {
+ if self
+ .send_key_grpc(id, grpc::KeyboardEvent::usb_keypress(i32::from(key_code)))
+ .is_ok()
+ {
+ return Ok(());
+ }
+ let serial = self.serial_for_id(id)?;
+ let android_key = android_key_code(key_code);
+ self.run_adb([
+ "-s",
+ &serial,
+ "shell",
+ "input",
+ "keyevent",
+ &android_key.to_string(),
+ ])?;
+ Ok(())
+ }
+
+ pub fn type_text(&self, id: &str, text: &str) -> Result<(), AppError> {
+ if self
+ .send_key_grpc(id, grpc::KeyboardEvent::text(text.to_owned()))
+ .is_ok()
+ {
+ return Ok(());
+ }
+ let serial = self.serial_for_id(id)?;
+ let escaped = text.replace('%', "%25").replace(' ', "%s");
+ self.run_adb(["-s", &serial, "shell", "input", "text", &escaped])?;
+ Ok(())
+ }
+
+ pub fn press_home(&self, id: &str) -> Result<(), AppError> {
+ let serial = self.serial_for_id(id)?;
+ self.run_adb(["-s", &serial, "shell", "input", "keyevent", "3"])?;
+ Ok(())
+ }
+
+ pub fn open_app_switcher(&self, id: &str) -> Result<(), AppError> {
+ let serial = self.serial_for_id(id)?;
+ self.run_adb(["-s", &serial, "shell", "input", "keyevent", "187"])?;
+ Ok(())
+ }
+
+ pub fn press_button(&self, id: &str, button: &str, duration_ms: u32) -> Result<(), AppError> {
+ match button {
+ "home" => self.press_home(id),
+ "lock" | "side-button" => {
+ let serial = self.serial_for_id(id)?;
+ self.run_adb(["-s", &serial, "shell", "input", "keyevent", "26"])?;
+ if duration_ms > 500 {
+ thread::sleep(Duration::from_millis(u64::from(duration_ms)));
+ self.run_adb(["-s", &serial, "shell", "input", "keyevent", "26"])?;
+ }
+ Ok(())
+ }
+ "back" => {
+ let serial = self.serial_for_id(id)?;
+ self.run_adb(["-s", &serial, "shell", "input", "keyevent", "4"])?;
+ Ok(())
+ }
+ _ => Err(AppError::bad_request(format!(
+ "Unsupported Android hardware button `{button}`."
+ ))),
+ }
+ }
+
+ pub fn rotate_right(&self, id: &str) -> Result<(), AppError> {
+ let serial = self.serial_for_id(id)?;
+ self.run_adb(["-s", &serial, "emu", "rotate"])?;
+ Ok(())
+ }
+
+ pub fn toggle_appearance(&self, id: &str) -> Result<(), AppError> {
+ let serial = self.serial_for_id(id)?;
+ let current = self.run_adb_shell(&serial, "cmd uimode night")?;
+ let mode = if current.to_lowercase().contains("yes") {
+ "no"
+ } else {
+ "yes"
+ };
+ self.run_adb(["-s", &serial, "shell", "cmd", "uimode", "night", mode])?;
+ Ok(())
+ }
+
+ pub fn logs(&self, id: &str, limit: usize) -> Result, AppError> {
+ let serial = self.serial_for_id(id)?;
+ let raw = self.run_adb([
+ "-s",
+ &serial,
+ "logcat",
+ "-d",
+ "-v",
+ "threadtime",
+ "-t",
+ &limit.max(1).to_string(),
+ ])?;
+ Ok(raw
+ .lines()
+ .map(|line| {
+ json!({
+ "timestamp": "",
+ "level": android_log_level(line),
+ "process": "",
+ "pid": Value::Null,
+ "subsystem": "android",
+ "category": "logcat",
+ "message": line,
+ })
+ })
+ .collect())
+ }
+
+ pub fn chrome_profile(&self, id: &str) -> Result {
+ let serial = self.serial_for_id(id)?;
+ let (width, height) = self.screen_size_for_serial(&serial)?;
+ Ok(json!({
+ "totalWidth": width,
+ "totalHeight": height,
+ "screenX": 0,
+ "screenY": 0,
+ "screenWidth": width,
+ "screenHeight": height,
+ "cornerRadius": 0,
+ "hasScreenMask": false,
+ }))
+ }
+
+ pub async fn grpc_frame_stream(
+ &self,
+ id: &str,
+ max_edge: Option,
+ ) -> Result {
+ let avd_name = avd_from_id(id)?;
+ let port = self.grpc_port_for_avd(&avd_name)?;
+ let mut format = grpc::ImageFormat {
+ format: grpc::image_format::ImgFormat::Rgba8888 as i32,
+ width: 0,
+ height: 0,
+ display: 0,
+ transport: None,
+ };
+ if let Ok(serial) = self.resolve_serial(&avd_name) {
+ if let Ok((width, height)) = self.screen_size_for_serial(&serial) {
+ let max_edge = max_edge
+ .unwrap_or(DEFAULT_ANDROID_STREAM_MAX_EDGE)
+ .clamp(240, 2400) as f64;
+ let largest = width.max(height);
+ if largest > max_edge {
+ let scale = max_edge / largest;
+ format.width = (width * scale).round().max(1.0) as u32;
+ format.height = (height * scale).round().max(1.0) as u32;
+ }
+ }
+ }
+
+ let endpoint = Endpoint::from_shared(format!("http://127.0.0.1:{port}"))
+ .map_err(|error| AppError::native(format!("Invalid Android gRPC endpoint: {error}")))?
+ .connect()
+ .await
+ .map_err(|error| {
+ AppError::native(format!(
+ "Unable to connect to Android emulator gRPC: {error}"
+ ))
+ })?;
+ let mut grpc = tonic::client::Grpc::new(endpoint);
+ grpc.ready().await.map_err(|error| {
+ AppError::native(format!("Android emulator gRPC is not ready: {error}"))
+ })?;
+ let path = PathAndQuery::from_static(
+ "/android.emulation.control.EmulatorController/streamScreenshot",
+ );
+ let mut request = tonic::Request::new(format);
+ if let Some(token) = emulator_grpc_token(port) {
+ let value = MetadataValue::try_from(format!("Bearer {token}")).map_err(|error| {
+ AppError::native(format!("Invalid Android emulator gRPC token: {error}"))
+ })?;
+ request.metadata_mut().insert("authorization", value);
+ }
+ let response = grpc
+ .server_streaming(request, path, tonic::codec::ProstCodec::default())
+ .await
+ .map_err(|error| {
+ AppError::native(format!(
+ "Android emulator screenshot stream failed: {error}"
+ ))
+ })?;
+ Ok(AndroidGrpcFrameStream {
+ inner: response.into_inner(),
+ })
+ }
+
+ pub fn accessibility_tree(
+ &self,
+ id: &str,
+ max_depth: Option,
+ ) -> Result {
+ let serial = self.serial_for_id(id)?;
+ let raw = self.run_adb_shell(
+ &serial,
+ "uiautomator dump /sdcard/simdeck_ui.xml >/dev/null && cat /sdcard/simdeck_ui.xml",
+ )?;
+ let xml = extract_xml(&raw);
+ let document = roxmltree::Document::parse(xml).map_err(|error| {
+ AppError::native(format!("Unable to parse UIAutomator XML: {error}"))
+ })?;
+ let mut roots = Vec::new();
+ let root = document.root_element();
+ let max_depth = max_depth.unwrap_or(80).min(80);
+ for child in root.children().filter(|node| node.has_tag_name("node")) {
+ roots.push(android_node_value(child, 0, max_depth));
+ }
+ let (width, height) = self.screen_size_for_serial(&serial)?;
+ if roots.is_empty() {
+ roots.push(json!({
+ "type": "screen",
+ "role": "screen",
+ "frame": frame_value(0.0, 0.0, width, height),
+ "children": [],
+ }));
+ }
+ Ok(json!({
+ "source": "android-uiautomator",
+ "availableSources": ["android-uiautomator"],
+ "roots": roots,
+ }))
+ }
+
+ fn send_touch_grpc(&self, id: &str, x: f64, y: f64, phase: &str) -> Result<(), AppError> {
+ self.block_on_grpc(self.send_touch_grpc_async(id, x, y, phase))
+ }
+
+ async fn send_touch_grpc_async(
+ &self,
+ id: &str,
+ x: f64,
+ y: f64,
+ phase: &str,
+ ) -> Result<(), AppError> {
+ let avd_name = avd_from_id(id)?;
+ let serial = self.resolve_serial(&avd_name)?;
+ let (width, height) = self.screen_size_for_serial(&serial)?;
+ let pressure = match phase {
+ "began" | "moved" => 1,
+ "ended" | "cancelled" => 0,
+ _ => return Ok(()),
+ };
+ let event = grpc::TouchEvent {
+ touches: vec![grpc::Touch {
+ x: normalized_to_pixel(x, width),
+ y: normalized_to_pixel(y, height),
+ identifier: ANDROID_TOUCH_IDENTIFIER,
+ pressure,
+ touch_major: 8,
+ touch_minor: 8,
+ expiration: grpc::touch::EventExpiration::NeverExpire as i32,
+ orientation: 0,
+ }],
+ display: 0,
+ };
+ self.grpc_unary_for_avd::(
+ &avd_name,
+ "/android.emulation.control.EmulatorController/sendTouch",
+ event,
+ )
+ .await?;
+ Ok(())
+ }
+
+ fn send_swipe_grpc(
+ &self,
+ id: &str,
+ start_x: f64,
+ start_y: f64,
+ end_x: f64,
+ end_y: f64,
+ duration_ms: u64,
+ ) -> Result<(), AppError> {
+ let duration_ms = duration_ms.clamp(50, 1500);
+ let steps = (duration_ms / 8).clamp(4, 120);
+ self.send_touch_grpc(id, start_x, start_y, "began")?;
+ for step in 1..steps {
+ let t = step as f64 / steps as f64;
+ self.send_touch_grpc(
+ id,
+ start_x + (end_x - start_x) * t,
+ start_y + (end_y - start_y) * t,
+ "moved",
+ )?;
+ thread::sleep(Duration::from_millis((duration_ms / steps).max(1)));
+ }
+ self.send_touch_grpc(id, end_x, end_y, "ended")
+ }
+
+ fn send_key_grpc(&self, id: &str, event: grpc::KeyboardEvent) -> Result<(), AppError> {
+ self.block_on_grpc(async {
+ let avd_name = avd_from_id(id)?;
+ self.grpc_unary_for_avd::(
+ &avd_name,
+ "/android.emulation.control.EmulatorController/sendKey",
+ event,
+ )
+ .await?;
+ Ok(())
+ })
+ }
+
+ fn block_on_grpc(&self, future: F) -> Result
+ where
+ F: Future