Repository navigation
Android input through scrcpy's control channel #1018
Description
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
- control-only scrcpy:
com.genymobile.scrcpy.Server 5.0.1withtunnel_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>thatadb shell dumpsys window displaysprints for display 0, which is the size in the current rotation (F17). A portrait 1080×2400 phone turned to landscape readscur=2400x1080. - A tap at
{"type":"begin","x":0.5,"y":0.25}oncur=1080x2400becomes 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,ywhen given, else at the screen centre. Sign, a choice made here: positivedy(content down, F19) sends a positive vertical scroll; positivedx(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 sendsinputUnavailable: 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'sinput(the member Input socket for a lease #1017 adds toDriver, next toscreenCapture), with the same targets as the capture (F6).src/drivers/android/screen/: exports what the input reuses:ScreenTarget, the push, andopenDeviceSocket. 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]
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)
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,spawnandconnect, 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 setspower_on=falseand nostay_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=1080x2400and oncur=2400x1080; scroll{dx:0, dy:0.1}sends vscroll +2 at the screen centre; scroll withx,ysends 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, digits1(0x1e) → 8 and0(0x27) → 7, Enter (0x28) → 66, Backspace (0x2a) → 67; Shift held thenagivesA(meta state): Shift down (0xe1) sends keycode 59 down, thenagoes 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, thencur=2400x1080at the next 2 s read → oneonConfig); 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.tsopens a lease and the Appduct fixture through agent-device, opensWS /v1/leases/{id}/input, gets 0x83 and 0x82, taps the centre oftab-statusas agent-device's snapshot places it (as a fraction of the screen size in the samesnapshot --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.tssends a tap and the person confirms it landed (slow lane, physical Android, person at the phone). watching-the-screen.mdxlists 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
keyandshiftedfields of serve-sim key messages: onlyusageandtypeare 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
test: a phone passes the adb check before an Android device input sta…
feat(input): a phone's closed adb check makes its input unavailable w…
test: an Android device input whose scrcpy does not start, whose push…
feat(input): an Android device input bounds its start, cleans up afte…
test: a rotation after an Android device input opens is reported once…
feat(input): an Android device input reads the screen's size every 2 …
test: touches and scrolls on Android reach scrcpy as pixel positions …
feat(input): Android touches and scrolls reach scrcpy as pixel positi…
test: keys and buttons on Android reach scrcpy as keycodes (#1018)
feat(input): Android keys and buttons reach scrcpy as keycodes with h…
2 remaining items
feat(input): an Android device input fails when its control socket cl…
docs(input): the Android device input's manual, the website's Android…
refactor(input): drop exports nothing imports and flatten the snapsho…
test(input): the size poll's 2 s period is straddled, and the manual …
test: a position at fraction 1 lands on the last pixel and a scroll p…
test: a failing size read keeps the last size and is logged once, and…
feat(input): a failing size read is logged once through the daemon's …
docs(input): the size read's failure is logged once (#1018)
test(input): the close test fails when the shell is not killed (#1018)
test(e2e): the input socket tap is proven by the Status screen's own …
test(e2e): the screen-id comment cites the Appduct source at the pin (#…
V3RON commentedon Oct 26, 2026
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.