Skip to content

Android input through scrcpy's control channel #1018

Open

Description

@V3RON

Part of #1013.

Scope

After this PR, a person holding an Android lease taps, swipes, scrolls, types, and presses Home, Recents, Power and Back through WS /v1/leases/{id}/input, on an emulator and on an enrolled phone. Before it, #1017's socket answers inputUnavailable: true for every Android device. Rotation is not part of it: #1017's session drops 0x07, and agents rotate through agent-device.

In short

An Android lease's device input is one control-only scrcpy server: the same vendored server the stream uses, started with video off and control on. Simlock turns each serve-sim message into a scrcpy control packet. Positions arrive as fractions of the screen and leave as pixels, so the device input reads the screen's size as it is now, in its current rotation, at open and every 2 s after; no new read starts while one is running. When the size changes (an agent rotated the device), it reports the new size, and #1017 sends a new 0x82.

flowchart LR
  S[input module, #1017] --> P[device input port, #1017]
  P --> A[Android device input NEW]
  A -- dumpsys window displays, every 2 s --> D[(device)]
  A -- control-only scrcpy, control socket --> D
Loading
  • control-only scrcpy: com.genymobile.scrcpy.Server 5.0.1 with tunnel_forward=true video=false audio=false control=true power_on=false send_device_meta=false send_dummy_byte=true cleanup=true. It reads control packets on one socket and sends no picture.
  • screen size: the cur=<w>x<h> that adb shell dumpsys window displays prints for display 0, which is the size in the current rotation (F17). A portrait 1080×2400 phone turned to landscape reads cur=2400x1080.
  • A tap at {"type":"begin","x":0.5,"y":0.25} on cur=1080x2400 becomes a touch-down packet at pixel (540, 600), with screen size 1080×2400 in the packet.
  • scroll: wheel units are clamp(round(d × 16), −16, 16) per axis, sent at x, y when given, else at the screen centre. Sign, a choice made here: positive dy (content down, F19) sends a positive vertical scroll; positive dx (content right) sends a negative horizontal scroll. {dx:0, dy:0.1} sends vscroll +2.
  • keys: the table in F21 covers letters, digits, Enter, Escape, Backspace, Tab, Space, the punctuation row, the arrows, and the modifiers 0xe0–0xe7. A modifier goes out as its own key event and is also held in the meta state (F22) of every later key until its up.
  • unavailable: a phone whose adb check is closed. The device input resolves {unavailable: <the gate's message>}, and Input socket for a lease #1017 sends inputUnavailable: true. Failed: anything else that stops the open (no size, the server does not start) rejects, and Input socket for a lease #1017 closes with 1013.

If this goes wrong, a person sees taps land in the wrong place, or keys type the wrong letters.

Technical spec

Facts

Commit e1f2f43.

# Fact Source
F1 The stream's scrcpy server starts with tunnel_forward=true, video=true, audio=false, control=false, send_device_meta=false, send_dummy_byte=true, cleanup=true, and stay_awake=true only for a phone. src/drivers/android/screen/session.ts:252-273
F2 A session gets a random 31-bit scid, a socket scrcpy_<scid hex> and a jar simlock-scrcpy-<scid hex>.jar, so two servers on one device do not collide. src/drivers/android/screen/session.ts:94, src/drivers/android/screen/session.ts:140-143
F3 ScreenTarget gives serial, spawn, run and connect, with args starting at the adb subcommand. src/drivers/android/screen/session.ts:60-68
F4 A phone's target runs the 5037 check (#gate.check()) before every connect, spawn and run, and throws ScreenUnavailableError with the gate's message when it is closed. src/drivers/android/physical-handler.ts:97-125
F5 An emulator's target uses Simlock's own adb server port and adb -s <serial>. src/drivers/android/index.ts:2767-2788
F6 The driver builds its screen capture from scrcpyServerPath and tcpStreamConnector, and picks the target by device kind. src/drivers/android/index.ts:581-604
F7 The stream's bounds: push 30 s (PUSH_TIMEOUT_MS), start 15 s from the push (START_DEADLINE_MS), close 5 s (CLOSE_DEADLINE_MS). src/drivers/android/screen/session.ts:29, src/drivers/android/screen/session.ts:39, src/drivers/android/screen/session.ts:46
F8 scrcpy 5.0.1 accepts the control socket after video and audio, and sends the dummy byte to the first socket it accepts. With video and audio off, that is the control socket. Genymobile/scrcpy v5.0.1:server/src/main/java/com/genymobile/scrcpy/device/DesktopConnection.java:66-88
F9 video, audio, control, cleanup and power_on are server options; control and power_on default to true. Genymobile/scrcpy v5.0.1:server/src/main/java/com/genymobile/scrcpy/Options.java:42, v5.0.1:server/src/main/java/com/genymobile/scrcpy/Options.java:65, v5.0.1:server/src/main/java/com/genymobile/scrcpy/Options.java:465-469
F10 With power_on true, the server presses Power at start when the screen is off. Genymobile/scrcpy v5.0.1:server/src/main/java/com/genymobile/scrcpy/control/Controller.java:229-232
F11 Control packet types: 0 keycode, 2 touch, 3 scroll, 4 back-or-screen-on. Genymobile/scrcpy v5.0.1:server/src/main/java/com/genymobile/scrcpy/control/ControlMessage.java:10-21
F12 Keycode packet: u8 action, i32 keycode, i32 repeat, i32 metaState. Genymobile/scrcpy v5.0.1:server/src/main/java/com/genymobile/scrcpy/control/ControlMessageReader.java:72-78
F13 Touch packet: u8 action, i64 pointerId, position, u16 pressure, i32 actionButton, i32 buttons; position is i32 x, i32 y, u16 width, u16 height. Genymobile/scrcpy v5.0.1:server/src/main/java/com/genymobile/scrcpy/control/ControlMessageReader.java:111-119, v5.0.1:server/src/main/java/com/genymobile/scrcpy/control/ControlMessageReader.java:193-199
F14 Scroll packet: position, then two i16 fixed-point values scaled to [-16, 16], then i32 buttons. Genymobile/scrcpy v5.0.1:server/src/main/java/com/genymobile/scrcpy/control/ControlMessageReader.java:121-128
F15 Back packet: u8 action only. Genymobile/scrcpy v5.0.1:server/src/main/java/com/genymobile/scrcpy/control/ControlMessageReader.java:130-133
F16 With no video display, a position is used as raw device pixels. Genymobile/scrcpy v5.0.1:server/src/main/java/com/genymobile/scrcpy/control/Controller.java:503-506
F17 dumpsys window displays dumps each display; each block prints cur=<logicalWidth>x<logicalHeight>, and the logical size is the base size swapped when the rotation is 90 or 270. aosp-mirror/platform_frameworks_base v1cdfff555f4a:services/core/java/com/android/server/wm/WindowManagerService.java:7301-7303, v1cdfff555f4a:services/core/java/com/android/server/wm/RootWindowContainer.java:1137-1143, v1cdfff555f4a:services/core/java/com/android/server/wm/DisplayContent.java:3670-3672, v1cdfff555f4a:services/core/java/com/android/server/wm/DisplayContent.java:2316-2330
F18 serve-sim key messages carry type down or up and usage from 0 to 0xff (USB HID keyboard page). expo/expo-device-hub v1357114:packages/serve-sim/packages/serve-sim/src/device-session.ts:1572-1581
F19 serve-sim scroll {dx, dy, x?, y?} are fractions of the display; positive dy moves the content down, positive dx moves it right. expo/expo-device-hub v1357114:packages/serve-sim/packages/serve-sim/src/device-session.ts:1683-1691; EvanBacon/serve-sim v6d009686:packages/serve-sim/Sources/SimNative/HIDInjector.swift:366-368
F20 scrcpy has no HID-usage-to-Android-keycode table; its client maps SDL keycodes. Genymobile/scrcpy v5.0.1:app/src/keyboard_sdk.c:35-43
F21 Android keycodes: HOME 3, BACK 4, 0–9 are 7–16, DPAD up/down/left/right 19/20/21/22, POWER 26, A–Z are 29–54, COMMA 55, PERIOD 56, ALT left/right 57/58, SHIFT left/right 59/60, TAB 61, SPACE 62, ENTER 66, DEL 67, GRAVE 68, MINUS 69, EQUALS 70, brackets 71/72, BACKSLASH 73, SEMICOLON 74, APOSTROPHE 75, SLASH 76, ESCAPE 111, CTRL left/right 113/114, META left/right 117/118, APP_SWITCH 187. The file is Apache-2.0, copied from AOSP. Genymobile/scrcpy v5.0.1:app/src/android/keycodes.h:1-12, v5.0.1:app/src/android/keycodes.h:41-101, v5.0.1:app/src/android/keycodes.h:151-199, v5.0.1:app/src/android/keycodes.h:303-318, v5.0.1:app/src/android/keycodes.h:491
F22 Meta state bits: SHIFT_ON 0x01, ALT_ON 0x02, ALT_LEFT 0x10, ALT_RIGHT 0x20, SHIFT_LEFT 0x40, SHIFT_RIGHT 0x80, CTRL_ON 0x1000, CTRL_LEFT 0x2000, CTRL_RIGHT 0x4000, META_ON 0x10000, META_LEFT 0x20000, META_RIGHT 0x40000. Genymobile/scrcpy v5.0.1:app/src/android/input.h:31-70
F23 The slow lane's agent-device test opens a lease per platform, installs the Appduct fixture, and its snapshot shows tab-status. e2e/slow-agent-device.test.ts:118-123, e2e/slow-agent-device.test.ts:176-187

Modules touched

Searches: screenCapture, createAndroidScreenCapture, ScreenTarget, screenTargetFor, input, KEYCODE_ in src/; snapshot in e2e/.

  • src/drivers/android/input/ (new): the Android device input: server args, packets, the key table, the size read.
  • src/drivers/android/index.ts: sets the driver's input (the member Input socket for a lease #1017 adds to Driver, next to screenCapture), with the same targets as the capture (F6).
  • src/drivers/android/screen/: exports what the input reuses: ScreenTarget, the push, and openDeviceSocket. No behaviour change.
  • src/drivers/android/AGENTS.md, src/drivers/android/screen/AGENTS.md: "The wake is the only key sent" now holds for the stream only.
  • website/src/content/docs/docs/using-devices/watching-the-screen.mdx: what input does on Android.
  • e2e/slow-agent-device.test.ts: one tap case in the shared suite, skipped on iOS until iOS simulator input through the HID injector #1019 turns it on. e2e/slow-android-physical-manual.test.ts: one tap with a person at the phone.
flowchart LR
  core[core driver catalog, #1017] --> drv[android/index.ts *]
  drv --> inp[android/input NEW]
  inp --> scr[android/screen * exports]
  inp --> ph[physical-handler: 5037 check]
Loading
sequenceDiagram
  participant M as input module
  participant I as Android device input
  participant D as device
  M->>I: openInput(device)
  I->>D: dumpsys window displays (cur=WxH)
  I->>D: push jar, start control-only server
  I->>D: connect control socket (dummy byte)
  I-->>M: device input; onConfig {width, height}
  M->>I: 0x03 touch begin {x:0.5, y:0.25}
  I->>D: touch packet at pixels
  Note over I,D: phone 5037 check closed
  I-->>M: {unavailable: gate message}
  Note over I,D: server does not start in 15 s
  I-->>M: open rejects (1013)
Loading

Surface and extension points

createAndroidDeviceInput(options) returns the driver's input, the implementation of #1017's device input port (feature Shared values, "Device input port"). Its options mirror AndroidScreenCaptureOptions: clock, serverPath, targetFor. It is one of two implementations beside #1019's; the port and the Driver.input member are #1017's.

Contract and event changes

none. Searched src/contract/ for input shapes: the messages are #1017's core/input.ts. No event.

Other code on the same state

Searches: scrcpy_, simlock-scrcpy-, stay_awake, KEYCODE_, user_rotation in src/ (6 hits, all in src/drivers/android/screen/).

  • The stream's scrcpy session: both run a scrcpy server on one device at once. Different scid, socket and jar (F2). Neither reads the other's socket. Both clean up their own jar.
  • A phone's stay_awake: only the stream's server sets it (F1). The input server does not, so its clean-up restores nothing it did not set.
  • The phone wake before a stream: the input does not wake the screen and starts with power_on=false (F10). A tap on a dark screen does nothing.
  • agent-device on the same device: both inject input, and agent-device may rotate. Whichever input lands last wins; that is the take-over the feature asks for. After a rotation, taps use the old size for at most 2 s, until the next size read.

Failure modes

Call Outcome What happens Limit, counted from
phone 5037 check closed nothing is sent; resolves {unavailable} the gate's own bound
dumpsys window displays at open ok size read from display 0's cur= 10 s, from the call
dumpsys window displays at open error, no cur= line, or a size of 0 open rejects; #1017 closes 1013 10 s, from the call
push jar fails or over 30 s open rejects 30 s, from the push (F7)
start control-only server no control socket open rejects 15 s, from the push's end (F7)
dumpsys window displays, every 2 s error or no line the last size stays; logged once 10 s, from the call
dumpsys window displays, every 2 s a new size onConfig with the new size —
control socket closes mid-session onFailed; #1017 closes sockets with 1013 none
close ok size reads stop, server ended, jar removed 5 s, from close (F7)

A worker restart mid-session leaves a control-only server that cleanup=true ends with its socket. The device input changes no device setting, so nothing is left to put back. #1017 shares one device input per lease, so two opens never race here.

Rules in play

  • safety rule 1, "never runs a command on one that is not enrolled": the input reaches only a leased, enrolled phone through screenTargetFor.
  • safety, physical Android: "Right before each physical command, Simlock checks that server's adb protocol version": every run, spawn and connect, the size reads included, goes through the phone's target (F4).
  • architecture rule 2, "iOS and Android are encapsulated in their own driver modules": key tables and scrcpy packets stay in src/drivers/android/.
  • architecture rule 11, "Every wait that crosses a process boundary is bounded": each adb call, the push, the start and the close have a limit (Failure modes).
  • src/drivers/android/screen/AGENTS.md, "No other option changes a device setting": the input sets power_on=false and no stay_awake.

Tests

Seam: createAndroidDeviceInput with a fake target and the scrcpy fixtures, on a FakeClock; model on src/drivers/android/screen/session.test.ts.

  • Touches and scrolls on Android reach scrcpy as pixel positions for the device's size: begin/move/end at fractions on cur=1080x2400 and on cur=2400x1080; scroll {dx:0, dy:0.1} sends vscroll +2 at the screen centre; scroll with x, y sends it at that point; a position at fraction 1 lands on the last pixel (x = width - 1); a scroll delta past 16 wheel units is clamped to 16 (decided in delivery, pending the maintainer).
  • Keys and buttons on Android reach scrcpy as keycodes: home 3; app_switcher 187; lock 26; back 4; HID letters a (0x04) → 29, digits 1 (0x1e) → 8 and 0 (0x27) → 7, Enter (0x28) → 66, Backspace (0x2a) → 67; Shift held then a gives A (meta state): Shift down (0xe1) sends keycode 59 down, then a goes with metaState 0x41, and Shift up clears it; an unknown usage (0x3a, the F1 key) dropped.
  • An Android device input starts a control-only scrcpy, and a phone passes the adb check first: emulator (args as In short); phone with the check open; phone with it closed → unavailable with the gate's message, nothing started; scrcpy fails to start → rejected; screen size read in the current rotation, and a change after an agent rotates reported (cur=1080x2400, then cur=2400x1080 at the next 2 s read → one onConfig); close after a successful open stops the size reads, ends the server and removes the jar; no second size read starts while one runs; a failing size read keeps the last size and is logged once (decided in delivery, pending the maintainer).

Done when

  • The three Tests lines pass in pnpm test.
  • On an emulator, e2e/slow-agent-device.test.ts opens a lease and the Appduct fixture through agent-device, opens WS /v1/leases/{id}/input, gets 0x83 and 0x82, taps the centre of tab-status as agent-device's snapshot places it (as a fraction of the screen size in the same snapshot --json), and the next snapshot shows the Status tab's screen (slow lane, Android emulator). The case is skipped on iOS until iOS simulator input through the HID injector #1019.
  • With a person at the phone, e2e/slow-android-physical-manual.test.ts sends a tap and the person confirms it landed (slow lane, physical Android, person at the phone).
  • watching-the-screen.mdx lists what input does on Android.

Out of scope

  • Rotation (0x07), on Android and anywhere: agents rotate through agent-device.
  • Text messages (scrcpy's inject-text); serve-sim sends keys.
  • The key and shifted fields of serve-sim key messages: only usage and type are read, so a non-US layout types US keys.
  • Function keys, the keypad, and HID usages outside the table in F21.
  • Waking a dark phone screen.
  • Multi-touch.

Depends on

Written by an agent.

Activity

  1. added
    task:draftScope written; technical spec or its spec check missing.
    on Oct 26, 2026
  2. added
    task:readySpec checked. An agent may implement it once its Depends on allow.
    and removed
    task:draftScope written; technical spec or its spec check missing.
    on Oct 26, 2026
  3. self-assigned this
    on Oct 26, 2026
  4. 2 remaining items

  5. V3RON commented on Oct 26, 2026

    @V3RON
    ContributorAuthor

    Handoff

    Done: Android input over scrcpy's control channel is on task/1018 (PR #1036, ready, in the #1013 stack). All Tests lines pass. Review passed: R1, R2 and R5 are fixed, R3 is a duplicate and R4 is nonblocking. The five audit rows are folded into Tests, pending the maintainer.

    Not done: Done when lines 2 and 3 have not run on the slow lane. Both tests are written.

    Findings: the hardware verifier found 15.0 GiB free, and the lane needs 15. Line 3 also needs a person at an Android phone (SIMLOCK_PHYSICAL_PERSON=1).

    Blocked on: a slow-lane run on a machine with disk space, with a person at the phone for line 3. #1019 does not wait on this run.

    Written by an agent.

  6. added
    task:blockedAgent stopped on a blocker in its handoff. Maintainer re-adds the label it came from.
    and removed
    task:readySpec checked. An agent may implement it once its Depends on allow.
    on Oct 26, 2026
  7. removed their assignment
    on Oct 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    task:blockedAgent stopped on a blocker in its handoff. Maintainer re-adds the label it came from.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions