Phone Mirror & Android Auto
OCTAVE's built-in scrcpy-protocol client with its forked phone server, and Android Auto: the built-in head unit (native receiver) or Google's DHU
Overview
OCTAVE has two ways of putting a connected Android phone on the dash:
- Phone Mirror -- OCTAVE is its own scrcpy-protocol client. It pushes OCTAVE's forked
scrcpy server to the phone over
adb, decodes the H.264 stream itself, plays the phone's audio, and injects real multitouch over the control socket. The server jar is bundled; adb and a decoder must also be available (see prerequisites below). It is desktop-only (mobile builds register a stub). - Android Auto -- real Android Auto from the phone, with two backends picked by the
androidAutoBackendsetting:- Built-in (
"native", beta) -- OCTAVE is the car's head unit. It speaks the Android Auto Protocol (AAP) itself to the phone's Android Auto head unit server through an adb port forward (phone port 5277), decodes the video and plays the audio. No Google software is needed on the OCTAVE machine. See Built-in Android Auto. - Google DHU (
"dhu", the default) -- runs Google's Desktop Head Unit as a separate process and is meant to embed its window. In practice that window is not shown inside OCTAVE; see Google DHU backend: what actually works.
- Built-in (
The paths share adb, found once by PhoneMirrorManager::findAdb() and reused by Android Auto, and
the built-in Android Auto receiver picks the phone the same way mirroring does. Phone Mirror and built-in Android Auto
both decode H.264 with the shared H264Decoder (src/util/h264decoder.{h,cpp}) and push
frames into a QVideoSink bound to a QML VideoOutput; the DHU backend launches the DHU in its own
window and registers a QQuickImageProvider (image://dhuframe/) that no QML currently reads.
Phone Mirror
Phone Mirror shows an Android phone inside OCTAVE and lets the user drive it from the dash with real
multitouch. OCTAVE is a scrcpy-protocol client: it pushes the bundled phone
server jar (tools/phone-server/octave-phone-server) to
/data/local/tmp/octave-phone-server.jar with adb, starts it with
app_process, decodes the H.264 stream and injects input over the control socket. The server is
OCTAVE's fork of the scrcpy server (phone_server/,
Apache-2.0, forked at v3.3.4, Java package org.octave.phoneserver, version string
3.3.4-octave). It needs shell privileges, which only an adb-launched process gets, so it cannot
be an installed app. There is no external scrcpy binary, v4l2loopback or ffmpeg subprocess in the mirroring path. Touch uses the control socket; wakePhone() still uses adb shell input keyevent 224 as a wake-up fallback.
| File | Class | Role |
|---|---|---|
| src/managers/phonemirrormanager.{h,cpp} | PhoneMirrorManager | adb discovery, device probes, settings, session lifecycle, keeper policy, ducking; QML context property phoneMirrorManager |
| src/phone_mirror/scrcpyclient.{h,cpp} | ScrcpyClient | One session: server push/launch or attach, sockets, demux, decode, black-frame hold, audio playback, control and device messages |
| phone_server/src/main/java/org/octave/phoneserver/ | Server, DesktopConnection, Streamer, Controller, MirrorKeeper, NewDisplayCapture, CleanUp | The program running on the phone |
| frontend/PhoneMirrorView.qml | -- | VideoOutput + MultiPointTouchArea + nav row, reconnect and asleep overlays, notice banner, phone picker, error/retry screen |
| frontend/settings/AccessoriesSettingsPage.qml | -- | Phone mirror settings card (Wi-Fi QR pairing, display size, phone screen, audio, gain, ducking, status line) |
Logging: categories octave.phonemirror (manager) and octave.phonemirror.client.
Server log lines are relayed at INFO (WARN/ERROR lines at warning level) with a server: prefix.
Measured on the Orange Pi 5 Plus with a Galaxy S22 Ultra (Android 16), from the click on the button: handshake at +0.51 s, first decoded frame at +0.65–0.83 s regardless of how long the phone has been idle, and a tap visible in the stream after ~0.4 s. Numbers and history: docs/PHONE_MIRROR_NATIVE_PLAN.md.
Tests (QtTest, tests/cpp/): tst_scrcpyclient runs the client against a fake
scrcpy 3.x server (handshake, config and key packets, a mid-stream resolution change, a corrupt packet,
a garbage header, early exits), with tests/cpp/fake-adb.sh standing in for adb and the
H.264 clips in tests/fixtures/h264/; tst_phonemirror covers the manager (adb
parsing, serial choice, Wi-Fi messages, QR pairing, the media hand-off rules, notices, the encoder fallback and the
async start flow); tst_phonemirrorview drives PhoneMirrorView.qml against a
fake manager; tst_adaptivebitrate feeds simulated packet streams to the adaptive bit-rate logic.
PhoneMirrorManager
Owns the single session (one phone at a time), the adb probes the UI needs, and the settings that shape a
session. Mobile builds compile a reduced compatibility stub that reports nativeAvailable = false and "Phone mirroring is desktop-only".
Properties
| Property | Type | Description |
|---|---|---|
adbPath | str | adb in use. Search order: bundled platform-tools (<app dir>/platform-tools/, <app dir>/../tools/platform-tools/<os>/, <app dir>/../Resources/platform-tools/), then PATH, then common locations (~/Android/Sdk/platform-tools/adb before /usr/bin/adb on Linux/macOS). The first candidate whose adb version reports platform-tools 30 or newer wins (adbToolsMajor(), cached; kAdbWirelessMinTools), since older builds have no pair / mdns; with none that new, the first one found is used for USB. The chosen path and version are logged at INFO on startup. The lookup is PhoneMirrorManager::findAdb() (public static), which AndroidAutoManager reuses so both features drive the same binary |
nativeAvailable | bool | Decoder present (built with OCTAVE_HAVE_FFMPEG), server jar found (Qt resource :/phone-server/octave-phone-server), adb found |
serverVersion | str | "3.3.4-octave" -- the string the client sends on launch; the server refuses anything else |
isRunning | bool | A client thread is up (true from start(), not only after the handshake) |
displaySize | str | Requested virtual display "WxH" (setting scrcpyDisplaySize, default "auto", each dimension snapped down to a multiple of 8, minimum 8, because scrcpy rounds a --new-display size the same way); "auto" uses the view's pixel size reported through setViewSize() (longest side capped at 1920, kAutoMaxSide; 1280x800 until the view has reported); "" mirrors the phone's own screen |
activeDisplaySize | str | Size actually in use once connected; "" when mirroring the phone screen (also when the phone's SDK is below 30 / Android 11, in which case the virtual display is skipped with a warning) |
videoSink | QObject | QML assigns VideoOutput.videoSink in Component.onCompleted; decoded frames go straight to it |
frameWidth, frameHeight | int | Decoded frame size (from the codec meta, updated if the decoder reports a different size) |
audioActive | bool | The audio socket delivered codec id "raw" and PCM is being played through OCTAVE |
audioPlaying | bool | The phone is producing sound right now (peak above the threshold within the last 1.5 s); drives ducking |
phoneAsleep | bool | The keeper reports the phone dozing (power button / lock); the virtual display stops compositing and drops touch until it is awake again |
phoneInUse | bool | The keeper reports the user has the phone in hand (keyguard dismissed, or a repeated power press); its panel is left alone until it is locked again or resumeMirroring() |
wirelessAddress | str | "ip:port" of the phone last connected over Wi-Fi (setting scrcpyWirelessAddress), see Mirroring over Wi-Fi |
connectionType | str | "usb" / "wifi" for the running session, "" when idle |
wirelessBusy | bool | A QR pairing started from the UI is in progress |
qrPairingRows, qrPairingActive | list, bool | The QR code being shown (one '0'/'1' string per row) and whether one is waiting |
currentBitRate | int | Video bit rate of the running session in bps, following adaptive changes; 0 when idle |
mediaAvailable, mediaPlaying, mediaTitle, mediaArtist, mediaAlbum, mediaApp, mediaDuration, mediaCanSeek | various | The phone's active media session as PhoneMedia reports it (mediaApp is the package name, mediaDuration ms); notify mediaChanged. See Phone media |
mediaArtUrl | str | file:// URL of the album art (<app data>/temp/phone_media_art_{0,1}.jpg, alternating so QML reloads), "" for none; notify mediaArtChanged |
mediaQueue, mediaQueueActiveId, mediaQueueTitle | list, qint64, str | Phone app’s published play queue, current item ID (-1 for none), and queue title; notify mediaQueueChanged. Each entry has id, title, artist, art_uri and a local art URL |
mediaActive | bool | The phone is the source OCTAVE's media controls drive; notify mediaActiveChanged(bool) |
lastErrorKind | str | What the last scrcpyError was about: "noPhone" / "device" (both retried by the view) / "setup" / "fatal"; "" after a successful start |
deviceState | str | Last adb devices probe: "device" / "unauthorized" / "no-permissions" / "offline" / "none" / "no-adb" (best usable state wins when several phones are listed). Probes run on a one-thread probe pool, never on the GUI thread; notify deviceStateChanged |
deviceHint | str | Extra context the probe found: an iPhone on USB (Linux sysfs, Apple vendor 05ac, product 0x1290–0x12af), or another adb server fighting this one ("doesn't match this client"). "" otherwise; shown under the QR screen and the error text |
devices | list | [{serial, state, wireless, model, selected}] for every phone adb lists. model (ro.product.model) is fetched only when more than one is usable; selected marks the running session's phone. Drives the phone picker |
mirrorNotice | str | Notices for the running session that do not stop it, joined by newlines: touch blocked by the phone (server INJECT_EVENTS error, Xiaomi), no audio before Android 11, software-encoder fallback. Cleared at every start and by dismissNotice(); notify mirrorNoticeChanged |
Signals
| Signal | Parameters | Description |
|---|---|---|
scrcpyStarted | int | Handshake done, stream is up (handle is always 1; the name predates the built-in client and PhoneMirrorView.qml depends on it) |
scrcpyStopped | -- | Session ended by request |
scrcpyError | str | The only error channel (there is no separate error signal). Specific failure text: describeDeviceState() for unauthorized / offline / no device / no adb, getInstallInstructions() for a missing prerequisite, or the client's own reason ("Phone disconnected. Reconnect the USB cable." / its Wi-Fi variant, "scrcpy server did not answer within 20 s", "handshake failed", ...) |
isRunningChanged, displaySizeChanged, videoSinkChanged | -- | Property notifications |
frameSizeChanged | int, int | Frame size; also notifies activeDisplaySize, frameWidth, frameHeight |
frameReady | -- | A frame reached the sink (the view counts these as proof of life; "Connecting..." shows until the first one) |
audioActiveChanged, audioPlayingChanged, phoneAsleepChanged, phoneInUseChanged | bool | Property notifications |
duckingChanged | float | Factor other sources should apply: the duck level while audioPlaying and ducking is on, 1.0 otherwise. main.cpp routes it to MediaManager.setDucking() |
wirelessAddressChanged | str | Remembered Wi-Fi phone changed; main.cpp saves it to scrcpyWirelessAddress |
connectionTypeChanged, wirelessBusyChanged, qrPairingChanged | -- | Property notifications |
audioPlayed | QByteArray, int | PCM handed to the audio sink and buffered delay in ms; connected to AudioAnalyzer.feed_live_pcm() for the phone waveform |
currentBitRateChanged, mediaChanged, mediaArtChanged, mediaQueueChanged | -- | Property notifications (mediaChanged on every media state report: play/pause, position, metadata) |
mediaTrackChanged | -- | The track itself changed (title, artist or app differ from the last report), or went away on reset |
mediaActiveChanged | bool | The phone takes or releases OCTAVE’s media controls |
wirelessResult | bool, str | Outcome of QR pairing, message ready to show |
deviceStateChanged, mirrorNoticeChanged | -- | Property notifications (deviceStateChanged covers deviceState, deviceHint and devices) |
Slots
| Slot | Returns | Description |
|---|---|---|
startScrcpy(), stopScrcpy(), cleanup() | -- | Session lifecycle (singleton). startScrcpy() re-emits scrcpyStarted(1) if a session is already ready; a deliberate stop also runs adb shell pkill -f org.octave.phoneserver |
refreshDeviceState() | -- | Probe adb devices on the probe thread; deviceStateChanged follows. A request while a probe runs re-runs it once afterwards |
getDeviceState(), hasConnectedDevice() | str, bool | The cached deviceState (they also schedule a refresh). Never block; the blocking getDeviceSerial() / getDeviceName() / getDeviceResolution() / getDeviceSdk() probes were removed in 0.9.5 |
selectDevice(serial) | -- | Mirror this phone from now on (the picker); a running session on another phone restarts. Otherwise the pick is USB first, then Wi-Fi |
dismissNotice() | -- | Hide the current mirrorNotice |
environmentOk() | bool | Same as nativeAvailable; the view shows setup text only when false |
getInstallInstructions() | str | Names exactly which prerequisite is missing (decoder / jar / adb), or the USB-debugging steps |
setDisplaySize(str), setDisplayDpi(int), setBitRateUsb(int), setBitRateWifi(int) | -- | Restart a running session (stop, then start 300 ms later); a bit-rate change only restarts a session on that link |
setViewSize(int, int) | -- | The mirror view's size in device pixels (PhoneMirrorView reports it on activation and on every resize). With "auto" a running session whose size no longer matches is restarted once the size has been stable for 1.5 s |
setAutoWifi(bool) | -- | Setting scrcpyAutoWifi; read at the next USB start |
setBitRateAuto(bool) | -- | Setting scrcpyBitRateAuto; restarts a running session |
setAudioGain(float), setAudioDuckEnabled(bool), setAudioDuckLevel(float), setAudioOutputDevice(QString), setPhoneScreenOff(bool) | -- | Applied live. Gain is clamped 0.25..8 (−12..+18 dB), duck level 0..1 |
setVolume(float) | -- | Called by VolumeController on every volume change with the 0..1 linear value; the phone-audio QAudioSink follows it. Mute is applied separately through setMuted(bool) |
injectTouch(pointerId, action, relX, relY) | -- | Touch on the mirrored display: action 0 down / 1 up / 2 move, coordinates 0..1 of the frame (scaled to frame pixels here) |
injectKey(keycode), pressBack(), pressHome(), pressAppSwitch() | -- | Android key events (down + up). pressHome() on a virtual display runs am start --display <id> instead, see Touch and navigation |
wakePhone() | -- | adb shell input keyevent 224 (KEYCODE_WAKEUP) from OCTAVE's side; the keeper normally wakes the phone itself, this is the asleep overlay's tap-to-retry |
resumeMirroring() | -- | Sends OCTAVE_TAKE_BACK: the keeper leaves the in-use state, wakes the phone if needed and re-applies the panel policy. Not exposed in the view |
forgetWireless(), setWirelessAddress(address), startQrPairing(), cancelQrPairing() | -- | Wi-Fi, see Mirroring over Wi-Fi |
mediaPlayPause(), mediaPlay(), mediaPause(), mediaNext(), mediaPrevious(), mediaSeek(ms) | -- | Drive the phone's media session (OCTAVE_MEDIA) |
mediaSkipToQueueItem(qint64 id) | -- | Ask the phone app to play an item from mediaQueue |
mediaPosition() | int | Position in ms, extrapolated from the last report while playing (the phone reports only on changes) |
releaseMedia(pausePhone) | -- | An OCTAVE source took over: clear mediaActive, optionally pausing the phone |
Session lifecycle
After manager preflight, session setup and video decoding run on the client's worker thread (a std::thread named scrcpy-client).
- Preflight:
nativeAvailableon the GUI thread; then, on the probe thread (so a dead Wi-Fi link or an adb restart never freezes the dash),adb devices, the phone to use (the one picked withselectDevice(), else USB first),getprop ro.serialno(the phone's identity, whatever the transport), a stale-server kill (adb shell pkill -f org.octave.phoneserver) unless reattaching to this same phone's persisted session, andgetprop ro.build.version.sdk. The result is handed back to the GUI thread, which drops it ifstopScrcpy()ran meanwhile (a generation counter).ScrcpyClient::bundledServerJar()extracts the embedded jar to<temp>/octave-phone-server-3.3.4-octave(reused when the size matches). - Push the jar to
/data/local/tmp/octave-phone-server.jar-- every session, becausecleanup=truestarts a CleanUp helper that unlinks the pushed jar as soon as the server is up. - Tunnel: pick a random 31-bit scid (
QRandomGenerator::system()), formatted as 8 hex digits. Reap stale forwards first (adb forward --list, remove everylocalabstract:scrcpy_*entry for this serial -- forwards live in the adb server, so a hard-killed OCTAVE strands one listening port per session), thenadb forward tcp:PORT localabstract:scrcpy_SCIDwith an ephemeral local port (OCTAVE_SCRCPY_PORToverrides it for the fake-server tests intst_scrcpyclient; it falls back to 27183 if the probe listen fails). - Launch:
Theadb -s SERIAL shell "CLASSPATH=/data/local/tmp/octave-phone-server.jar nohup app_process / org.octave.phoneserver.Server 3.3.4-octave \ scid=SCID tunnel_forward=true video=true audio=true|false control=true clipboard_autosync=false \ video_codec=h264 audio_codec=raw max_size=0 video_bit_rate=8000000 max_fps=60 stay_awake=true cleanup=true \ send_device_meta=true send_frame_meta=true send_codec_meta=true send_dummy_byte=true log_level=info \ octave_persist_ms=60000|0 [video_codec_options=...] [new_display=WxH display_ime_policy=local] 2>&1 & wait"CLASSPATH=assignment must come beforenohup(an assignment after it is exec'd as a literal command; commitbba3de6).nohup ... & waitkeeps the adb shell relaying stdout for as long as the server lives, while the server survives adbd killing that shell on a USB dropout (it is reparented to init).octave_persist_msis 60000 for a virtual-display session and 0 (exit like scrcpy) when mirroring the phone screen. On Linux the adb child getsPR_SET_PDEATHSIG=SIGTERM(setChildProcessModifier) so it dies with OCTAVE. The child's merged stdout/stderr is the server log; aNew display: ... (id=N)line yields the virtual display id used bypressHome().video_codec_optionsdefaults topriority:int=0,latency:int=1,vendor.qti-ext-enc-low-latency.enable:int=1(ScrcpyClient::kVideoCodecOptions): realtime priority, no frame lookahead and Qualcomm's low-latency mode, which took the lag out of dragging on an S26 Ultra. Encoders ignore keys they do not know. TheOCTAVE_VIDEO_CODEC_OPTIONSenvironment variable replaces the list (empty sends none). On WindowsScrcpyClient::hideConsoleWindow()setsCREATE_NO_WINDOWon every adb / serverQProcess(probes, stale-server kill,pressHome,wakePhone, the session's adb child) so no console window flashes up. - Connect: with
tunnel_forwardthe local TCP connect succeeds before the server listens and then EOFs, so connect every 100 ms until the dummy byte actually arrives; that socket is the video socket. Deadline 20 s from the click (4 s when attaching); if the server process exits meanwhile the failure is "scrcpy server exited during startup (see log)". Then open the audio socket (only if audio is on) and the control socket (both withTCP_NODELAY). The server sends device and codec meta only once every socket it expects is connected (DesktopConnection.openaccepts them in order), so reading before opening control would deadlock. A connection that received no dummy byte is closed, otherwise the server would later hand it out as the audio or control socket. - Handshake on the video socket: 64-byte NUL-padded device name, then codec id (u32 FourCC
h264), width and height (u32 each, big-endian).frameSizeChangedandconnectedfire; the manager emitsscrcpyStarted(1)and, 500 ms later, sends the keeper policy (the server powers the device on asynchronously at start). - Stream: the worker thread demuxes and decodes video until stop or EOF; a
scrcpy-devmsgthread reads device messages on the control socket; ascrcpy-audiothread reads PCM. Control messages are written to the control socket from any thread under a send mutex, straight to the raw descriptor with::send(). - Teardown: close the sockets, terminate the adb child (2 s, then kill),
adb forward --remove tcp:PORT(also repeated fromstop()so a clean exit never strands a port).stop()never waits out a slow step: adb calls and the connect wait pollm_stoppingevery 100-200 ms (cleanup calls excepted). The audio socket belongs to the audio thread; the session only shuts its descriptor down, under a mutex the audio thread takes before closing it. The server destroys the virtual display itself (cleanup=true) unless it is persisting. A stream that ends without a requested stop surfaces as "Phone disconnected. Reconnect the USB cable.". src/main.cpp routes SIGTERM/SIGINT through the normal quit path (a self-pipesocketpairhandler) so launcher scripts and systemd get this cleanup too.
Protocol byte layouts
All integers big-endian. Verified against phone_server/src/main/java/org/octave/phoneserver/
(device/DesktopConnection, device/Streamer, control/ControlMessageReader,
control/DeviceMessageWriter) and src/phone_mirror/scrcpyclient.cpp; the same table lives in
docs/PHONE_MIRROR_NATIVE_PLAN.md.
Sockets (in accept order, all on localabstract:scrcpy_<scid>)
| Socket | Server → client |
|---|---|
| video (first) | [u8 dummy] then [64 B device name, NUL-padded] then [u32 codec id "h264"][u32 width][u32 height] then packets |
audio (only with audio=true) | [u32 codec id]: 0x00726177 ("raw") then packets; 0 = the phone cannot capture; 1 = configuration error. The client logs a warning and continues video-only in either case (kAudioStreamError never ends the session) |
| control (last) | Device messages (below); client → server control messages |
Stream packets (video and audio, send_frame_meta=true)
[u64 pts_and_flags][u32 size][size bytes]
bit 63 = config packet (SPS/PPS for H.264; pts invalid) // Streamer.PACKET_FLAG_CONFIG
bit 62 = key frame // Streamer.PACKET_FLAG_KEY_FRAME
video data: Annex-B H.264; audio data: PCM s16le 48 kHz stereo (AudioConfig, up to 4096 B per read)
The client treats a size above 32 MiB (video) or 1 MiB (audio) as a desynced stream: video fails the session
("video stream out of sync"), audio stops and video continues.
Control messages (client → server), first byte = type
| Type | Name | Payload | Total | Used by OCTAVE |
|---|---|---|---|---|
| 0 | INJECT_KEYCODE | [u8 action][i32 keycode][i32 repeat][i32 metastate] | 14 B | yes (action 0 down / 1 up, repeat 0) |
| 2 | INJECT_TOUCH_EVENT | [u8 action][i64 pointer id][i32 x][i32 y][u16 screen w][u16 screen h][u16 pressure][i32 action_button][i32 buttons] | 32 B | yes: x/y in frame pixels (clamped), screen w/h = frame size, pressure 0xFFFF (0 on up), buttons 0 |
| 10 | SET_DISPLAY_POWER | [u8 on] | 2 B | not sent: the client has no display-power call (the keeper owns the panel) |
| 100 | OCTAVE_SET_KEEPER | [u8 enabled][u8 panelDark][i32 graceMs] | 7 B | yes, 500 ms after connect and on every setting change |
| 101 | OCTAVE_TAKE_BACK | none | 1 B | resumeMirroring() |
| 102 | OCTAVE_SET_BIT_RATE | [i32 bps] | 5 B | Adaptive quality, from the video thread (VideoBitRate.java: MediaCodec.setParameters on the running encoder, and the rate for every encoder created later) |
| 103 | OCTAVE_MEDIA | [u8 action][i64 arg] | 10 B | PhoneMedia: action 0 WATCH (arg 1 = on, sent on connect), 1 PLAY, 2 PAUSE, 3 PLAY_PAUSE, 4 NEXT, 5 PREVIOUS, 6 SEEK (arg = ms), 7 SKIP_TO_QUEUE_ITEM (arg = 64-bit queue ID) |
| 1, 3, 4, 5..9, 11.. | upstream text / scroll / back-or-screen-on / clipboard / panels / UHID / start-app / reset-video | as in scrcpy 3.3.4 | parsed by the server, never sent |
Device messages (server → client on the control socket), first byte = type
| Type | Name | Payload | Client action |
|---|---|---|---|
| 0 | CLIPBOARD | [u32 len][utf8] | skipped (clipboard_autosync=false keeps these away anyway) |
| 1 | ACK_CLIPBOARD | [u64 sequence] | skipped |
| 2 | UHID_OUTPUT | [u16 id][u16 len][data] | skipped |
| 100 | OCTAVE_PHONE_STATE | [u8 asleep][u8 inUse][u8 panelDark] | phoneStateChanged → phoneAsleep / phoneInUse, black-frame hold |
| 101 | OCTAVE_MEDIA_STATE | [u8 flags: 1 session, 2 playing, 4 canSeek][i64 positionMs][i64 durationMs] then package, title, artist, album as [u16 len][utf8] | mediaStateChanged → the media* properties, mediaActive |
| 102 | OCTAVE_MEDIA_ART | [u32 len][JPEG], len 0 = no art | mediaArtChanged → file → mediaArtUrl |
| 103 | OCTAVE_MEDIA_QUEUE | [i64 activeId][u16 titleLen][title][u16 count], then each item: [i64 id] and title, artist/subtitle, art URI as [u16 len][utf8] | Up to 500 entries → mediaQueue, mediaQueueActiveId, mediaQueueTitle |
| 104 | OCTAVE_MEDIA_QUEUE_ART | Art URI as [u16 len][utf8], then [u32 len][JPEG]; zero bytes means unreadable art | Cache the art locally and update the queue entries |
| other | reader logs a warning and stops (the stream keeps running) |
Coordinates are relative to the mirrored display: the server routes injection to the display it captures, so a virtual display needs no display-id routing on the touch path.
Video decode and frame delivery
A config packet (SPS/PPS) is not decoded on its own; it is prepended to the next packet, as the scrcpy client does.
The client decodes with libavcodec through H264Decoder (src/util/h264decoder.{h,cpp}, shared with
the built-in Android Auto receiver; it owns the codec context, the SPS/PPS prepend and the frame copy, while the black-frame hold and
size tracking stay in ScrcpyClient's frame callback) (avcodec_find_decoder(AV_CODEC_ID_H264), slice threading only, set before
avcodec_open2, packets padded with AV_INPUT_BUFFER_PADDING_SIZE). Frame threading is avoided because it
delays every picture by one frame per thread. It accepts YUV420P/YUVJ420P (other formats are dropped)
and copies the three planes row by row into a QVideoFrame(Format_YUV420P) -- no RGB conversion, the
GPU does the colour conversion in VideoOutput. The frame is stored as "latest" under a mutex and
frameReady is emitted; a queued slot on the GUI thread pulls the latest frame and calls
QVideoSink::setVideoFrame(), so a slow GUI thread coalesces rather than queues frames. The client never
clears the sink: the last frame stays on the VideoOutput across a reconnect.
Why libavcodec and not QMediaPlayer::setSourceDevice(): Qt's FFmpeg backend refuses a raw Annex-B
QIODevice (FormatError "Could not open file" after 1.5 MB fed, Phase 0 spike). OCTAVE therefore links
libavcodec/libavutil when CMake finds them (OCTAVE_HAVE_FFMPEG, pkg-config on Linux/macOS, vcpkg on Windows;
without them the build still succeeds and nativeAvailable is false). The native-client plan measured
libavcodec's software H.264 decoder at 1280x800 at 12–15 % of one RK3588 core; this is hardware-specific.
Black-frame holding
A dozing phone streams pure black frames (status bar still drawn) until it is woken, and the first composite of a reattached stream is black plus a strip of status icons. Rather than flash the dash black, the client decides per decoded frame whether to hand it to the sink:
- Test: maximum luma over every other pixel in both directions of the Y plane (stride 2), early exit on the
first sample above
20. Point grids (8x8, then 24x24) were tried first and missed a 2 px icon stroke, so a dark app with small bright UI was held as if the phone were dozing (commit7090531). ~256k byte reads on 1280x800, under 1 ms. Frames narrower or shorter than 24 px are never "black". - Lenient mode (attached stream, first 3 s of decode): the frame is black if fewer than 5 % of
samples are bright -- a solid status strip is 3–4 % of samples (commit
cfc1d4c). Normal viewing keeps the strict test so a dark app with a bright toolbar is never held. - Hold rules: while the keeper reports the phone asleep, every frame is held (the doze fade is a
uniformly dimmed composite, neither black nor content; commit
525882f). Otherwise a run of black frames is held for3 s(kBlackHoldMs); a black run that outlives that is real content and is shown. The very first frame of a fresh session is always shown (the test only applies once a frame has been delivered, or when attached).
Audio and ducking
Phone audio always plays through OCTAVE while mirroring (the scrcpyAudioEnabled toggle was removed): the server is started with audio=true audio_codec=raw, and the stream follows OCTAVE's volume (setVolume) and mute (setMuted, wired from VolumeController.mutedChanged); the server's
default source is AudioSource.OUTPUT, i.e. REMOTE_SUBMIX, which captures every app and mutes playback
on the handset. Raw PCM means no decoder on our side. The audio socket is read on its own thread
(scrcpy-audio) so video never waits on it:
- Each chunk's peak before gain is measured; a peak of
164or more (about −46 dBFS; a silent remote submix is exact zeros, so this only rejects dither) flags "sound seen". - Gain (setting
scrcpyAudioGain, default 2.0 = +6 dB, clamped 0.25..8) multiplies the samples through atanhsoft limiter so a hot source cannot clip harshly. Phones capture at their own media level, measured ~10 dB under local music on the Pi; the phone's own volume is deliberately never touched (an earlier attempt usedadb shell media volume, which does not exist on the test handset and would leave the phone at maximum after a crash; commit19c3b10). - Chunks are queued with a cap of 48000 bytes (~250 ms); when the GUI thread falls behind the oldest are dropped
so audio stays close to the picture.
audioReadywakes a queued slot on the GUI thread. - On the GUI thread a
QAudioSink(48 kHz, stereo, Int16, default output device, ~200 ms device buffer) is created lazily; chunks are written only whilebytesFree()allows, never blocking.setVolume()fromVolumeControllersets the sink volume, so the knob and mute apply. If the default device is missing or refuses the format, a warning is logged and audio is silently skipped. - The same GUI slot turns "sound seen" into
audioPlaying = trueand (re)arms a single-shot 1.5 s release timer; silence longer than that drops it. Long enough to bridge the gaps between navigation sentences, short enough that music comes back promptly after a prompt.
Ducking (settings scrcpyAudioDuckEnabled, default on; scrcpyAudioDuckLevel, default 0.1 =
−20 dB): the manager computes duckingFactor = duckLevel while audioPlaying && duckEnabled, else 1.0,
and emits duckingChanged on change. main.cpp connects it to
MediaManager.setDucking(), which ramps the local output (80 ms attack, 500 ms release) independently of the
user's volume and mute. Expect lip-sync to trail the picture
slightly. Validated on the Pi by measurement (+6 dB = x1.89 RMS, live slider, clean teardown with zero underruns);
whether −20 dB is enough against a loudness-mastered MP3 in the car is still an open question in the plan doc.
Phone screen, sleep and the MirrorKeeper
Measured on the S22 Ultra / Android 16: the virtual display requests VIRTUAL_DISPLAY_FLAG_OWN_DISPLAY_GROUP but
lands in power group 0, so a power press dozes it with the phone -- app content stops compositing (black frames) and
injected touch is silently dropped, with nothing on the wire to notice. The only way to keep mirroring is to wake the
phone again. Doing that from the dash (an adb poll every second, then a keyevent) left ~1 s of black and a visible
panel relight, so the logic moved into the phone:
phone_server/.../control/MirrorKeeper.java (commit 67809d1). OCTAVE only sets the
policy, can take the phone back, and shows the state; it no longer polls adb to maintain the panel policy. Explicit wake and virtual-display Home actions still launch adb commands.
Policy: OCTAVE_SET_KEEPER(enabled=1, panelDark=scrcpyPhoneScreenOff, graceMs=8000), sent 500 ms after
the handshake and again whenever the setting changes (the server's own default grace is 5000 ms if a client never sets
one). The keeper is a process-wide singleton on the phone: Controller attaches it on the first
OCTAVE_SET_KEEPER, swaps the message sender per client session, and its in-use / panel state survives a reconnect.
Sensing: a DisplayListener on display 0 plus a 250 ms poll on its own HandlerThread, reading
PowerManager.isScreenOn(0) (interactive) and IWindowManager.isKeyguardLocked() through the server's wrappers.
The panel is blanked or lit with Device.setDisplayPower(0, on) (SurfaceControl power mode -- the phone stays awake,
stream and touch keep working) and CleanUp is told to restore it when the server process ends. Waking uses
KEYCODE_WAKEUP (224, wakes and never toggles off), rate-limited to one per 500 ms.
Idle timeout: a woken lock screen overrides the phone's screen timeout with a few seconds
(mUserActivityTimeoutOverrideFromWindowManager=5000 on a Galaxy S-series), and stay_awake only holds
while the phone is charging. Left alone, a cradled locked phone therefore dozed every ~5 s; each doze blanked the mirror for
a moment and looked like a power press, so the panel was relit. While enabled, awake and not in use, the keeper calls
IPowerManager.userActivity() every 1.5 s (the shell holds DEVICE_POWER), so the only dozes left
are real power presses.
Decisions (what a power press meant):
- Start: an awake, unlocked phone is in the user's hand (in use); an awake, locked one is in the cradle and gets the panel policy; an asleep one is woken.
- Press while locked (phone in the cradle): wake it, then force the panel on once interactive (the wake
alone does not reliably relight a SurfaceControl-dark panel; commit
2cdd28e). The forced relight only runs whilepanelDarkis on: setting the power mode bypasses the brightness controller and wakes a Galaxy S22 dim, so a phone the keeper never darkened is left alone. Leave it lit for the 8 s grace window. If the keyguard is dismissed in that window (or at any time), or the phone turns out to be unlocked already (a lock-after delay had not run), or the button is pressed again within grace + 2 s (nothing visible happened for them the first time, so they are trying to get in), the phone is in use: panel on, locks not undone. If the grace passes still locked, the press meant "screen off" and the panel is blanked. - In use: the mirror keeps working (DeX on the virtual display is independent of the panel). It ends when the
keyguard re-engages (lock shortcut or lock-after delay: grace, then blank) or on
OCTAVE_TAKE_BACK. - Press while in use: the user is putting the phone down -- no grace, the panel-off goes out before the wake and again the instant the phone is interactive, so it never relights.
- In-use is edge-triggered on the keyguard transition (locked → unlocked enters, unlocked → locked leaves);
a level check raced Samsung's 5 s lock-after delay and undid
resumeMirroring()on the next poll (commite1fa1c4).
Every change is reported as PHONE_STATE(asleep, inUse, panelDark). The manager maps it to
phoneAsleep / phoneInUse, tell the client to hold every frame while asleep, and reset both on disconnect.
PhoneMirrorView dims the held frame with a "Phone is asleep -- Waking it up... tap to retry" overlay only if the
phone is still asleep after 1.5 s; a normal press resolves in about a second and dimming the dash for that read
as a flash (commit a4aa9a6). Tapping the overlay calls wakePhone().
Phone media: OCTAVE's controls drive the phone
Music playing on the phone (any app with a media session: Spotify, YouTube Music, podcasts...) shows up in
OCTAVE's own now-playing UI and is driven by its normal controls: the bottom bar, the Media Room, the home
screen card, the dashboard NowPlayingWidget / MediaControlsWidget, gesture swipes and
Album Art Capture theming. These controls and metadata use the control socket, independently of whether the phone successfully supplies audio.
Phone side: phone_server/.../control/PhoneMedia.java, created by
Controller on the first OCTAVE_MEDIA and stopped with the client session. It lists sessions
through the media_session binder (ISessionManager.getSessions(null, 0[, null]): the third
argument, a package filter, appeared in Android 17; the shell holds MEDIA_CONTENT_CONTROL), follows the
one that is playing or else the highest priority one (re-picked every second), and registers a
MediaController.Callback on it, so changes are reported as they happen. Transport goes through
MediaController.TransportControls; with no session, play falls back to KEYCODE_MEDIA_PLAY.
Art (album art, art or display icon) is scaled to 400 px and sent as JPEG only when it changes (identity: size and a
4×4 pixel sample); a "no art" after a track change is held back 1 s because apps send the title first and the art
a moment later.
Who has the controls (mediaActive): the phone is the media source for the whole
mirroring session. It takes the controls with the session's first media report (sent right after WATCH,
even with no media app), keeps them while paused or with no media app, and loses them when the session ends or
releaseMedia() is called; after that it takes them back only when it starts playing again.
Pressing play with no media app sends KEYCODE_MEDIA_PLAY, which restarts the last media app. NowPlaying holds the hand-offs: when the phone takes over, local playback
pauses; a local track starting calls releaseMedia(true) (the phone pauses). Frontends read the live source through nowPlaying, not these properties. With no media app the views show "Phone" / "Nothing playing". The Media Room shows a "Phone" source pill meanwhile; tapping it (nowPlaying.useLocal()) hands the controls
back to local media and pauses the phone. The same rules with Android Auto: Which source plays the music. Shuffle is dimmed (it belongs to the phone app) and there are no side cards.
Queue: PhoneMedia follows the selected app’s MediaController queue and active queue ID, sending up to 500 entries. Queue artwork is fetched separately, scaled to 160 px and sent once per URI per session. The manager stores it under <app data>/temp/phone_queue_art/ and batches artwork notifications for 150 ms. MediaPlayer.qml shows this queue while the phone is active and a queue is available; selecting an entry calls mediaSkipToQueueItem(id). The available tracks and skip behavior depend on what the phone app publishes. This is its active queue, not a browsable account library.
Waveform: the client emits audioPlayed(pcm, bufferedMs) as audio is handed to the sink. main.cpp routes it to AudioAnalyzer.feed_live_pcm(); main.cpp also switches it to live analysis while nowPlaying's source is the phone. Without captured phone audio, the controls can still work but there is no live PCM waveform.
QML reads what is playing from nowPlaying, which
follows the phone while mediaActive and local media otherwise. Position is not streamed by the phone:
nowPlaying extrapolates mediaPosition() every 500 ms while the phone plays (one clock for
every view). Every view uses it; phone-only features still read phoneMirrorManager: the queue
(MediaPlayer.qml, shown while nowPlaying.source is the phone, following source changes while the
page is open) and Media Room's "Phone" pill (releaseMedia(true)).
Persistent sessions and USB drops
The phone re-enumerates on the bus now and then (picked up, put down, a nudged cable). adbd then kills the server's
shell; with the stock scrcpy design the server exits, the virtual display goes with it
(VIRTUAL_DISPLAY_FLAG_DESTROY_CONTENT_ON_REMOVAL) and the user lands on a fresh DeX home screen. Two layers
keep what they had open (commit 2992d67):
Server (option octave_persist_ms): Server.scrcpy runs a session loop on a sessions
thread while the main looper serves handlers for the whole process. One NewDisplayCapture marked persistent is
shared across sessions -- its release() only detaches the encoder surface (virtualDisplay.setSurface(null)),
the real destroy() runs at process exit. Each client connection gets a fresh Controller, encoder and
streamer and re-sends device meta. After a client drops, DesktopConnection.open(..., acceptTimeoutMs) re-listens on
the same localabstract:scrcpy_<scid> for up to octave_persist_ms; because closing a
LocalServerSocket does not reliably unblock accept() on Android, a watchdog thread sets a flag and pokes
the socket with a connection of its own (commit 0a26f8e). No reconnect within the window: "No client reconnected
within N ms; exiting", the display is destroyed and MirrorKeeper.shutdown() runs.
Client: on a mid-stream disconnect of a ready virtual-display session the manager remembers the scid and a
deadline of now + 60 s − 5 s. The next startScrcpy() inside that window attaches, but only to the same phone: the session's
ro.serialno is kept with the scid, and a different phone picked inside the window starts fresh instead of
spending the 4 s attach deadline on a session that isn't there (an unknown serial keeps the time-only rule). Attaching
means no push, no pkill,
re-forward the same scid, connect with a 4 s deadline, and follow the server log with
adb logcat -v raw -T 1 -s scrcpy:I (its stdout went with the old shell). The reattached stream uses the lenient
black test for its first 3 s so the handover never shows black. If the attach fails, the manager starts fresh on the
next event-loop turn (which kills the old server). A deliberate stopScrcpy() always kills the server.
Recovery is the manager's (sessionState): a device error (lastErrorKind
"device" or "noPhone") after the stream had been up is a quiet reconnect ("reconnecting"):
it polls every second (kReconnectPollMs) and restarts the moment adb reports the phone (typically 2 s), keeps
reconnecting through failed retries, and moves to "waitingForPhone" after kReconnectGraceMs (10 s).
Any other device error goes straight to "waitingForPhone", which polls every 2 s (kWaitPollMs)
and restarts when the phone is back; setup and fatal errors are "failed" and never retried. Polling runs only
while setViewActive(true) (the mirror page is on screen), so nothing spawns adb in the background. The page
holds the last frame while "reconnecting" (a "Reconnecting to phone..." badge after 2 s) and shows the
error page otherwise. A dropped session's server persists for 55 s, so a phone back after the 10 s grace still reattaches. Because the keeper is process-wide, in-use / panel state survives the reconnect too.
Mirroring over Wi-Fi
adb carries the whole session over TCP, so the client needs no changes for Wi-Fi: once the phone is listed by
adb devices -- as "ip:port" after adb connect, or as an mDNS name
(adb-<serial>-<id>._adb-tls-connect._tcp) when adb auto-connects a phone it has paired with -- the
client's adb -s <serial> push, forward and shell work exactly as over USB. The manager only gets the phone
into that list and keeps it there. ScrcpyClient::isNetworkSerial() tells the
two apart (USB serials never contain :).
QR pairing is the wireless setup offered by the UI (Android 11+); a USB session can also enable the legacy TCP connection described below. Typing a pairing code, an IP:port, or scanning for adb services was removed again in favour of it; USB needs no setup and stays as it was.
- Where the code appears:
PhoneMirrorViewbecomes the QR screen whenever no phone is connected and none is remembered (noPhone: the error text starts with "No Android device"); it starts pairing on its own and cancels when the view is left or mirroring starts. With a remembered phone the page says "Waiting for your phone" and offers "Connect a different phone". In Settings → Accessories → Phone Dock the "Connect phone" button opens the same panel in a centred modal. Both use frontend/PhoneQrPairingPanel.qml: the code on aCanvas, black on white with a 4-module quiet zone, sized from the screen height (55 %) so it scans from arm's length, the three steps, the result line and Cancel / "Show QR code". - Protocol (Android Studio's):
startQrPairing()makes a random service name (octave-<8 chars>) and password (12 chars) and encodesWIFI:T:ADB;S:<name>;P:<password>;;(qrPairingPayload(), ECC level M) with Project Nayuki's QR library, vendored as src/phone_mirror/qrcodegen.{hpp,cpp} (MIT, credited in NOTICE). The rows reach QML asqrPairingRows. The phone's Wireless debugging → Pair device with QR code reads it and advertises an_adb-tls-pairingservice under that name (if the adb in use is older than platform-tools 30,startQrPairingfails at once with "adb is too old for wireless pairing" instead of waiting); the worker pollsadb mdns servicesevery second for up to 2 minutes (kQrPairingTimeoutMs), runsadb pair <ip:port> <password>, then finds the phone's_adb-tls-connectport on the same host over mDNS (polled up to 8 s) and runsadb connect(adb exits 0 on failure, so success is read from the output). Cancel bumps a generation counter the waiting job checks every second; the outcome arrives aswirelessResult. - Remembering and reconnecting: a successful pairing sets
wirelessAddress, whichmain.cppsaves toscrcpyWirelessAddress. At startup and whenevergetDeviceState()finds no usable phone (the view polls it), the manager tries that address in the background at most every 5 s. Wireless debugging picks a new port each time it is switched on, so when the saved address fails it looks for an_adb-tls-connectservice on the same host and, if that connects, remembers the new port; adb itself also auto-connects paired phones it sees over mDNS. adb can keep listing the oldip:portas a device for a while after the phone rejoins Wi-Fi on a new port, so a session that fails to start on anip:portserial runsadb disconnecton it, re-resolves the port over mDNS, connects and starts once more (m_staleRetried, one retry per start). Forget (forgetWireless()) stops a Wi-Fi session, runsadb disconnectand clears the address; adb may still auto-connect a paired phone until OCTAVE is removed from the phone's paired devices. - USB first:
pickSerial()prefers the phone picked in the UI, then a USB serial, then Wi-Fi, so a phone on both links mirrors over USB; every adb call for the session (the SDKgetprop, the stale-server pkill, push, forward, shell) passes-s, since a bareadb shellfails with "more than one device". Unplugging the cable mid-session drops into the normal quiet reconnect, which then finds the Wi-Fi serial and carries on (attaching to the persisted virtual display). - Bit rate: per link from the settings,
scrcpyBitRateUsb(default 16 Mbps) andscrcpyBitRateWifi(default 8 Mbps). WithscrcpyBitRateAuto(default on) those are the ceiling and the rate adapts; see Adaptive quality. - Wi-Fi without re-pairing (
scrcpyAutoWifi, off by default since 0.9.5: until the phone reboots, its adbd then accepts connections on whatever network it joins): before a USB session the manager reads the phone's Wi-Fi IPv4 fromip -4 -o addr show(awlan*interface first, then a phone-run hotspotswlan*/ap*; mobile data and VPNs are skipped). Ifservice.adb.tcp.portis not already 5555 it runsadb tcpip 5555, which restarts the phone's adbd, and waits up to 9 s for the USB device to return. It then remembersip:5555aswirelessAddress. This runs on the wireless worker; the session starts when it finishes, and astopScrcpy()in between cancels that start (a generation counter). The legacy TCP mode needs no Wireless debugging toggle, works when the phone runs the hotspot, and survives network changes, but ends when the phone reboots. Its connections still need the phone's RSA authorization, like USB. When the savedip:5555fails the existing mDNS fallback still finds a Wireless-debugging port on the same host. - Messages: a dropped Wi-Fi link reports "Phone disconnected. Check that it is on the same Wi-Fi network and
Wireless debugging is on." instead of the USB-cable text; offline / unauthorized network devices and "no device while a Wi-Fi
phone is remembered" get Wi-Fi wording. All are classified by
PhoneMirrorManager::errorKindFor()(tst_phonemirror::everyMessageHasItsKindpins each message's kind).
When adb mdns services returns no usable services, the manager tries avahi-browse on Linux. This supports distro adb builds without working mDNS; the fallback requires avahi-browse to be installed.
Threading: QR waits and reconnects block on adb, so they run on one worker (a QThreadPool with
one thread) and post results back to the GUI thread. One worker serialises them, so a
background reconnect never races a pairing; the destructor cancels a QR wait and waits for the worker.
Networking: no internet is involved; adb, the video, audio and touch all stay on the local link. OCTAVE does not
create the network. The phone and the OCTAVE machine just have to share one: a home router, or in a car the head unit's own
access point (e.g. a 5 GHz hostapd / NetworkManager hotspot) that the phone joins. Android reports that network as
"no internet" and keeps using mobile data for its apps, which is what you want. A phone-hosted hotspot does not work with
Wireless debugging (Android only enables it while the phone is a Wi-Fi client), but it does work with the legacy TCP mode that
scrcpyAutoWifi turns on over USB.
What survives what: USB authorization is kept across plug-ins (Android revokes it after 7 days without a connection unless Developer options → "Disable adb authorization timeout" is on). Wireless debugging is switched off by Android when the phone reboots and, on most phones, when it leaves the network it was enabled on. The legacy TCP mode lasts until the phone reboots; one USB session turns it back on.
Adaptive quality
With scrcpyBitRateAuto on, the client keeps the video bit rate as high as the link carries. The logic is one small
pure class, src/phone_mirror/adaptivebitrate.h (covered by
tst_adaptivebitrate), fed from the video thread for every media packet.
- Signal: each packet's phone presentation time against its arrival on our steady clock. The difference is the one-way delay plus an unknown clock offset; the smallest delay over the last 10 one-second windows stands in for an idle link, so the excess is queueing. It is measured on arrival, before decoding, so a decoder that cannot keep up (large frames on a small board) backs the rate off just like weak Wi-Fi. A jump over 3 s is taken as a clock step and restarts the baseline.
- Decisions (per one-second window, after a 2 s warm-up; the window's single worst packet is left out so a key
frame is not congestion): mean queueing over 150 ms cuts the rate to 70 % (at most every 2 s); under 40 ms for two windows in
a row, 5 s after the last cut, and with the stream actually using at least half the rate (a still screen proves nothing)
raises it by 10 %, at least 1 Mbps. The rate stays between 2 Mbps (
kAdaptiveMinBps) and the link's setting. Changes under 5 % are not sent. - Start: USB at the ceiling, Wi-Fi at 60 % of it (
kAdaptiveWifiStart). The starting rate is sent with the first packet too, since a reattached server may still run an earlier session's rate. - Phone side:
OCTAVE_SET_BIT_RATE→VideoBitRate.set()→MediaCodec.setParameters(PARAMETER_KEY_VIDEO_BITRATE)on the running encoder, no restart and no key frame. Encoders that ignore live changes keep their rate; the cut still helps once the encoder is recreated. - Visible:
currentBitRate(bps, 0 when idle) follows every change; the Phone Dock card shows "Now: … Mbps over USB / Wi-Fi", and each change is logged with its queueing delay.
Touch and navigation
PhoneMirrorView.qml uses a MultiPointTouchArea (mouseEnabled, 1–10 points; mouse arrives as one
point) and forwards every point's pressed/updated/released (and canceled, as a release) through
injectTouch(pointId, action, relX, relY), mapped through VideoOutput.contentRect so letterboxing is
accounted for. A touch that starts in the letterbox (phone-screen mode) is ignored for its whole life, so a thumb resting
on the bars can't tap the phone's edge; one that starts on the picture is clamped to 0..1 if it drags off it. Tap, drag, scroll and long-press are verified on hardware; multi-finger gestures are
implemented but untested (the test rig has no touchscreen).
Navigation row (◁ ○ ▢ → pressBack / pressHome / pressAppSwitch) sits on the bottom
edge of the picture (not the letterbox band, where a translucent button on black is invisible) and is shown
only when mirroring the phone's own screen: a virtual display draws system decorations, i.e. Samsung's DeX taskbar
with its own back/home/recents exactly where the row would sit. HOME and APP_SWITCH are system keys that Android routes to
the default display, so injecting them does nothing on a virtual one (Back is dispatched to the focused window and works);
pressHome() therefore runs adb shell am start --display <id> -a android.intent.action.MAIN -c android.intent.category.HOME
when a virtual display id is known (via an async QProcess) and falls back to the
keycode otherwise. Samsung's recents UI renders empty on a secondary display.
Errors and recovery
A missing prerequisite is reported with getInstallInstructions() naming it and the view shows a "Setup" block.
Phone-side failures (unplugged, offline, USB debugging not authorized, Linux USB "no permissions") get the one-line
describeDeviceState() text; the error page then asks for a probe (refreshDeviceState()) every 2 s
while it is on screen and restarts on deviceStateChanged when the phone is back -- a nudged cable in a vehicle
is the normal case. Failures that retrying cannot fix (the video encoder) stop the loop.
Encoder fallback: when the server logs a video encoder error (Capture/encoding error,
Video encoding error, Could not create (default) video encoder) and the session drops within 5 s,
the manager restarts once with video_encoder=c2.android.avc.encoder (Google's software encoder;
OMX.google.h264.encoder before Android 10) and keeps using it for that phone until OCTAVE restarts, with a
notice. If the software encoder fails too, the view shows the cause and stops retrying.
| Symptom | Cause | Fix |
|---|---|---|
| "USB permissions keep adb from opening it" (Linux) | adb devices says no permissions: no udev rule for the phone's vendor | sudo cp packaging/linux/51-android.rules /etc/udev/rules.d/ && sudo udevadm control --reload-rules && sudo udevadm trigger, then replug |
| Picture works, touch does nothing; notice about Xiaomi | MIUI / HyperOS block input injection (INJECT_EVENTS) | Developer options > "USB debugging (Security settings)", then restart the phone |
| Phone keeps dropping; hint about another adb | Android Studio or a distro adb of another version restarts the adb server (server version ... doesn't match this client), which kills OCTAVE's forwards | Close the other tool, or point both at the same adb |
| "An iPhone is plugged in" | Mirroring needs adb, i.e. Android | -- |
| No sound from the phone; notice about Android 10 | Audio capture arrived in Android 11 | Sound stays on the phone |
| "video encoder failed, even in software mode" | The phone's encoder rejects the size | A smaller scrcpyDisplaySize, or mirror the phone screen |
| Two phones connected | USB is picked first | The ⇄ button (top left of the mirror) switches phones |
The bottom-bar button pops back to an existing
PhoneMirrorView (found by objectName) instead of stacking a second one, and the view resumes a session that
is already running.
Why not QtMultimedia's Camera? The design before the built-in client streamed scrcpy into a v4l2loopback node
and read it with QCamera. Qt snapshots the V4L2 device list on first use per process and never refreshes it,
the loopback only appears while a producer streams, and Qt's V4L2 path sheared frames whose width was not a multiple of
64. That knowledge still applies to camera enumeration; the path itself is gone.
The phone server fork and the version invariant
phone_server/ is scrcpy's server/ tree at v3.3.4 with the package renamed to
org.octave.phoneserver (LICENSE and NOTICE kept, phone_server/LICENSE.scrcpy). OCTAVE's
additions are marked with // OCTAVE comments: control/MirrorKeeper.java, the TYPE_OCTAVE_*
control and device messages, Options.octavePersistMs, the session loop in Server.scrcpy,
DesktopConnection.open(..., acceptTimeoutMs), NewDisplayCapture.setPersistent()/destroy(), and the
WindowManager.isKeyguardLocked wrapper.
Build: phone_server/build_without_gradle.sh (upstream's no-Gradle script) generates
BuildConfig.VERSION_NAME = "3.3.4-octave" from SCRCPY_VERSION_NAME, compiles against
platforms;android-36 / build-tools;36.0.0 and produces octave-phone-server (a jar of
classes.dex). The result is committed at tools/phone-server/octave-phone-server;
the binary embeds it as the Qt resource :/phone-server/octave-phone-server (CMake
qt_add_resources) so no packaging target carries a loose file.
Version invariant: Options.parse throws "The server version (3.3.4-octave) does not match the
client (...)" unless the first argument equals BuildConfig.VERSION_NAME. So
SCRCPY_VERSION_NAME in the build script and kServerVersion in scrcpyclient.h
must be bumped together. The CI job phone-server rebuilds the
jar under JDK 21 on every push and errors if either the fresh or the committed jar lacks the
org/octave/phoneserver/Server class or the 3.3.4-octave string; a byte-level difference between the
two is reported as a warning only (javac/d8 output can differ across toolchains). Changing the server is a
deliberate step: edit phone_server/src, rebuild, commit the jar with the source, bump the version in both
places if the protocol changed, and re-verify the byte layouts above against DesktopConnection,
ControlMessageReader, DeviceMessageWriter and Streamer.
Bundled adb: scripts/fetch_platform_tools.py downloads Google's platform-tools
37.0.1 (SHA-256 verified), keeps only what adb needs plus Google's NOTICE under
tools/platform-tools/<linux|windows|darwin>/ (gitignored). The Windows, macOS and Linux x86_64 CI jobs run it
and ship the result next to the binary (platform-tools/ beside the executable, Contents/Resources/platform-tools
in the .app, usr/bin/platform-tools in the AppImage). Google publishes x86_64 only, so the aarch64 AppImage and ARM
boards fall back to the distro's adb on PATH.
Settings and wiring
| Setting | Default | Effect | Applied |
|---|---|---|---|
phoneMirrorEnabled | false | Shows the Phone Mirror button in the bottom bar (hidden on Android regardless) | live |
scrcpyDisplaySize | "auto" | Virtual display size (new_display=); "auto" = the view's pixel size, "" = phone screen (the "Display Mode" control picks Mirror Phone "" or Desktop (DeX) "auto"). The settings field is re-written with the snapped value | restarts the session |
scrcpyDisplayDpi | 0 | Appended as new_display=WxH/dpi; 0 = the server scales the phone's own density | restarts the session |
scrcpyBitRateUsb / scrcpyBitRateWifi | 16 / 8 | video_bit_rate in Mbps for that link | restarts a session on that link |
scrcpyAutoWifi | false | adb tcpip 5555 before a USB session; remembers ip:5555 | next USB start |
scrcpyBitRateAuto | true | Adaptive quality; the per-link rates become the ceiling | restarts the session |
scrcpyPhoneScreenOff | false | Keeper panelDark | live (OCTAVE_SET_KEEPER) |
scrcpyWirelessAddress | "" | Phone last connected over Wi-Fi; written by the manager (wirelessAddressChanged → save_scrcpy_wireless_address) | startup + live |
scrcpyAudioGain | 2.0 | Linear PCM gain before OCTAVE's volume (UI shows dB) | live |
scrcpyAudioDuckEnabled | true | Duck local media while the phone makes sound | live |
scrcpyAudioDuckLevel | 0.1 | Linear factor applied to local media while ducked (UI shows dB) | live |
src/main.cpp does the wiring: read each setting into the manager at startup, connect the
SettingsManager change signals to the live setters, and connect duckingChanged to
MediaManager.setDucking. The Accessories settings page shows a status line: "Built-in mirror client ready
(scrcpy server 3.3.4-octave, adb: ...)" or the install instructions. Full schema:
Settings Reference.
Design decisions
Grounded in the code comments, commit messages and docs/PHONE_MIRROR_NATIVE_PLAN.md.
Why a built-in client instead of the scrcpy binary
The first working path (commit 0f9ecaa) chained the scrcpy desktop binary, a v4l2loopback kernel module, an
ffmpeg subprocess reading the loopback node, adb shell input for touch, and on Windows a GDI window grab. Every
link was an install step or a failure mode on someone else's box: distro scrcpy versions are years old (Ubuntu 22.04's
1.21 cannot mirror Android 14+), v4l2loopback needs DKMS and boot parameters and is Linux-only, the loopback starves on
a static display so first paint took 4–60 s, adb shell input costs ~130 ms per event with no multitouch,
long-press, drag or pinch, and the window grab kept a hidden scrcpy window alive. The scrcpy server is the part
that is hard to replace and does not need replacing (it creates the virtual display, captures without a consent dialog,
encodes on the phone's hardware encoder and injects input); the desktop client is what OCTAVE replaced with code of its
own. Result: first paint 0.4–0.8 s, touch at socket latency, and the only external pieces are two Apache-2.0 files
OCTAVE ships itself.
Why fork the server
Pinning an upstream jar was enough for the protocol, but the in-car problems (a power press dozing the virtual display,
USB dropouts destroying the display and its apps) needed behaviour the phone-side program does not have. Forking under
org.octave.phoneserver (commit 200c8c8) gave a place for the MirrorKeeper, the persistent session loop
and the TYPE_OCTAVE_* messages (numbered from 100 to stay clear of upstream), while the version string
3.3.4-octave keeps the mismatch check loud. Upstream changes are pulled deliberately and the byte layouts
re-verified.
Why a virtual display
new_display=<view size> gives a landscape surface sized for the dash instead of a portrait phone screen, keeps the
phone's own screen free (and dark) for the passenger, and on Samsung draws DeX with a taskbar that replaces the nav row.
Coordinates are relative to the mirrored display so touch needs no display-id routing. The costs are documented rather
than hidden: HOME/APP_SWITCH do nothing there (hence am start --display), the display shares the phone's power
group, and Android 11+ is required (the manager falls back to the phone screen below SDK 30). With it the client sends display_ime_policy=local, so the on-screen keyboard opens on the mirrored display rather than on the phone's own screen, where the driver can't see it.
Why the keeper logic lives on the phone
From the dash the reaction is an adb round trip on a 1 s poll: ~1 s of black frames and a visible relight per press,
plus adb processes spawned while a session is up. Inside the device a DisplayListener and a 250 ms poll react
within a frame or two, the keyguard is read directly from IWindowManager, and the state survives a client
reconnect because the keeper is process-wide. OCTAVE keeps only policy (OCTAVE_SET_KEEPER), an escape hatch
(OCTAVE_TAKE_BACK) and display (PHONE_STATE).
Why the phone's volume is never touched
Gain is applied to the PCM inside OCTAVE. The alternative -- raising the phone's media volume for the session -- failed silently on the test handset and would leave the phone at maximum after a crash. A crash in the car must not change the phone's state.
Why hold frames instead of clearing the sink
Every transition (doze, wake, reconnect, reattach) was measured with a 30 fps capture of the dash and each sub-second
artifact fixed at its source: the last good frame is the correct thing to show until real content arrives, so the
client never clears the VideoOutput, black and doze-fade frames are held, and overlays appear only after a delay
(1.5 s asleep, 2 s reconnect badge).
Why raw PCM audio
audio_codec=raw means PCM s16le 48 kHz stereo on the wire and no audio decoder in OCTAVE (the
code comment on the launch option); the 192 KB/s it costs over USB is small next to the 8 Mbit/s video stream.
Android Auto
Android Auto has two backends behind one manager (androidAutoManager) and one page
(frontend/AndroidAutoView.qml), picked by the androidAutoBackend setting
(Settings → Accessories → Phone Dock → Android Auto → Head unit):
- Built-in (
"native", beta): OCTAVE is the head unit and speaks the Android Auto Protocol to the phone itself. Desktop builds with OpenSSL and FFmpeg only (nativeAvailable); the Head unit choice is hidden where it is missing. - Google DHU (
"dhu", the default): drives Google's Desktop Head Unit, the SDK tool that implements a complete Android Auto head unit against a phone over adb. OCTAVE launches it, sets up its adb forward and the phone-side head unit server, and is meant to show its window inside the dash. A build without the built-in receiver always reportsbackendas"dhu".
Files:
| File | Class | Purpose |
|---|---|---|
| src/managers/androidautomanager.{h,cpp} | AndroidAutoManager | DHU discovery and process lifecycle, adb setup, window discovery; QML context property and service object androidAutoManager |
| src/managers/androidautomanager_native.cpp | AndroidAutoManager (native mode) | Built-in backend: finds the phone over adb, checks the head unit server, forwards a port, loads the certificate, runs an AapConnection, retries, maps touch |
| src/android_auto/ | AapConnection, AapSession, AapTls, framer, protobuf | The protocol core of the built-in receiver (below; Architecture) |
| src/util/h264decoder.{h,cpp} | H264Decoder | libavcodec H.264 decoder shared with Phone Mirror |
| same files as the manager | DhuCapture, DhuFrameProvider | DHU backend: 30 FPS window capture into an image provider; click-forwarding stub |
| src/items/embeddeddhuitem.{h,cpp} | EmbeddedDhuItem | DHU backend: QML item registered as OCTAVE.AndroidAuto 1.0 |
| frontend/AndroidAutoView.qml | -- | Built-in: a VideoOutput, a multitouch area, a status screen and a close button. DHU: launches the DHU on load, shows the embedded item, an error page with setup instructions and a retry button |
| docs/ANDROID_AUTO_PROTOCOL.md | -- | AAP framing, handshake, discovery and channel messages as checked against a real phone |
Logging: categories octave.androidauto (the manager), octave.androidauto.session and
octave.androidauto.connection (the built-in receiver), and octave.embeddeddhu. The bottom-bar button is
shown when setting androidAutoEnabled is on (Settings → Accessories → Phone Dock → Android Auto →
Show in nav bar).
Built-in Android Auto (native receiver)
With androidAutoBackend set to "native", OCTAVE takes the place of the car's head unit. The phone runs
Android Auto as it would in a car; the Android Auto app on the phone draws the car screen (Maps, media apps, messaging) and sends
it to OCTAVE as H.264 video with PCM audio, and OCTAVE sends touches, night mode and driving status back. Nothing extra runs on
the phone and no Google software is needed on the OCTAVE machine.
The link is the phone's Android Auto head unit server, a developer-mode feature of the Android Auto app that listens on phone TCP port 5277 (the socket Google's DHU uses). OCTAVE reaches it through an adb port forward, so it works over a USB cable with USB debugging or over wireless debugging, the same adb connection Phone Mirror uses. Plain "plug in a stock phone" Android Auto over a USB accessory link is not implemented yet.
Checked on a Samsung SM-S948U with Android Auto 17.9 over wireless adb: Maps and Spotify render, taps and drags work, media audio plays.
Desktop onlyPhone and OCTAVE setup
On the phone, once
- adb -- USB debugging on (or the phone paired for wireless debugging in Phone Mirror), exactly as for Phone Mirror.
- Android Auto developer mode -- open Android Auto's settings on the phone and tap Version about 10 times until it confirms developer mode.
- Head unit server -- in Android Auto's settings, open the ⋮ menu and tap Start head unit server. It keeps running across USB unplugs and serves one session after another.
In OCTAVE
- Settings → Accessories → Phone Dock → Android Auto: turn on Show in nav bar
(
androidAutoEnabled). - Under Head unit, pick Built-in (
androidAutoBackend = "native"). Only shown when the build has the built-in receiver. - Optionally pick a Video size: 800x480, 1280x720 (default) or 1920x1080 (
androidAutoResolution). It applies to the next connection. Frame rate (androidAutoFps) and density (androidAutoDpi) have no control yet; edit the settings file (Settings Reference). - Provide the head-unit certificate (next section). The Status row on the same settings card shows the certificate folder.
- Tap Android Auto in the nav bar. The page connects on its own as soon as the phone, its head unit server and the certificate are all there.
The Status row on the settings card shows the manager's nativeStatus (green while running, red for
error / needsCertificate), or "Not connected. Open Android Auto from the nav bar to connect." while idle.
The head-unit certificate
Android Auto only accepts a head unit whose TLS client certificate chains to Google's head-unit certificate authority. OCTAVE does not ship such a certificate and never will: nothing in the repository or in the release downloads contains one. The user supplies a PEM certificate and its private key, and is responsible for having the right to use them. OCTAVE looks for them, in order:
androidAutoCertPathset: that file. If it contains aPRIVATE KEYblock, the key is taken from the same file; otherwise from a sibling file with the same base name and a.keyextension (/path/unit.pem→/path/unit.key).androidAutoCertPathempty (the default):headunit.crtandheadunit.keyin<config dir>/android-auto/(Linux~/.config/OCTAVE/android-auto/, Windows%APPDATA%\OCTAVE\android-auto\, macOS~/Library/Application Support/OCTAVE/android-auto/). The manager'scertificateDirproperty holds this path and both the page and the settings card show it.
The files are read at every connection attempt, so dropping them in while the page shows "Android Auto needs a certificate" is enough: the next retry (within 3 s) picks them up. A certificate OpenSSL can't parse, or a key that doesn't match it, ends the attempt with an error; a certificate the phone does not accept fails the TLS handshake (see Troubleshooting).
What works, what doesn't yet
| Area | Status |
|---|---|
| Video | Works. H.264 Baseline at the chosen size (800x480, 1280x720 or 1920x1080) and 30 or 60 fps, decoded with libavcodec into the page's VideoOutput, letterboxed to fit. While the page is hidden OCTAVE tells the phone (video focus) and it stops sending video; audio carries on. |
| Touch | Works, multitouch (up to 10 points). Touches map through the letterboxed picture into video pixels; a touch that starts on the black bars is ignored for its whole life. |
| Audio | Works. Media (48 kHz stereo), guidance (16 kHz mono) and system sounds (16 kHz mono) each play on OCTAVE's selected audio output at OCTAVE's volume and mute, through the volume system; the phone's own volume is not touched. Android Auto plays music only when OCTAVE lets it; navigation prompts and system sounds duck OCTAVE's own music (below). |
| Now playing | Works. The phone reports the track, artist, album, cover art, length and position; nowPlaying shows them (Media Room, home card, dashboard widget, Album Art Capture), and the bottom bar, knob and gestures send play / pause / next / previous to the phone as media keys. |
| Night mode | Follows OCTAVE's theme: any theme outside the light group counts as night, and a theme change is sent to the phone at once. |
| Driving status | Always reported as unrestricted (parked), so the phone does not lock the keyboard or lists. |
| Microphone | OCTAVE records from the default input only while the phone has the microphone open (Google Assistant, voice replies) and sends it as 16 kHz mono; OCTAVE's own music pauses meanwhile. Covered by the tests; not yet tried with someone talking to it. |
| Transport | adb only (USB debugging or wireless debugging, plus the head unit server). No USB accessory (AOA) mode, so a phone without developer mode can't connect, and no wireless Android Auto. |
| Platforms | Desktop builds only (Linux, Windows, macOS). Checked on desktop Linux so far; Windows, macOS and the Orange Pi head unit are still to be tried. Not in the Android, iOS or browser builds. |
| Build | Needs OpenSSL and FFmpeg (libavcodec) at build time (OCTAVE_HAVE_NATIVE_AA, Building). Without them nativeAvailable is false and only the DHU backend exists. |
Which source plays the music
OCTAVE's own player, the mirrored phone and Android Auto can all play music, and exactly one of them owns it at a time:
nowPlaying.source ("local", "phone"
or "androidauto"). Only a user action changes the owner. A phone connecting, Android Auto starting,
a reconnect, or Android Auto's Start music automatically never does. Android Auto asks the head unit before it plays
(an audio focus request), so for Android Auto OCTAVE decides; phone mirroring can only be noticed once the phone plays.
| Situation | What happens |
|---|---|
| Local music plays (or nothing does) and Android Auto connects, or Spotify starts on the phone by itself | Refused: the phone pauses, local keeps playing |
| A tap on the Android Auto screen, OCTAVE's play / next / previous aimed at Android Auto, or a voice request, then the phone asks to play within 6 s | Granted: Android Auto owns the music; local pauses, a mirrored phone is released |
| Android Auto owns the music and asks again (after a pause) | Granted |
| Play in OCTAVE's player while Android Auto owns | Local owns; OCTAVE takes focus back and the phone pauses |
| The Media Room's source pill ("Android Auto" / "Phone") | nowPlaying.useLocal(): the other source pauses, nothing plays until asked |
| Navigation prompt or system sound from Android Auto | Local music ducks to 30 % (the same MediaManager::setDucking phone mirroring uses; the lower of the two wins) |
| Google Assistant opens the microphone | Local music pauses, and resumes when the microphone closes unless something else started |
| The owner goes away (phone unplugged, session ended) | Paused, still shown; nothing else starts. Play then goes to OCTAVE's own player |
| OCTAVE restarts (car off and on) while Android Auto owned and played | Android Auto resumes when it connects; if the phone doesn't start its music within 4 s, OCTAVE presses play. Local auto-play on startup stays off that time. Playing anything local first cancels it |
Owner and play state are saved as mediaLastSource / mediaLastPlaying
(Settings reference), not overwritten by what stops at shutdown or when the phone
disconnects. Refusing is done the way a car radio does it: OCTAVE grants the request and at once takes focus back
(unsolicited LOSS); answering LOSS makes Android Auto 17.9 ask again about 50 times a second. Phone mirroring keeps its own
rule for now (the phone's first media report takes the controls); bringing it under the same rules is in
TODO/media-source-arbiter.md. Code: src/managers/nowplaying.cpp
(the rules), src/managers/androidautomedia.cpp (Android Auto's side); tests:
tst_nowplaying.
What the page shows and what to do
Opening the page calls startNative(). From then on the manager keeps trying until the page's close button (X, top
right) calls stopNative(): a missing phone, head unit server or certificate is retried every 3 s, an error after 8 s,
while the page is on screen. Leaving the page through the nav bar keeps a running session going (audio continues, video
pauses); with no session, the retries pause until the page comes back. Switching Head unit to Google DHU stops a
built-in session. A certificate or key OpenSSL can't use is caught before anything connects to the phone. The state is
nativeState; the sentence under the title is nativeStatus.
nativeState | Page title | Meaning | What to do |
|---|---|---|---|
idle | -- | Not started, or stopped with the close button | Open Android Auto from the nav bar |
connecting | Starting Android Auto... | Looking for the phone, in the AAP handshake, or reconnecting after a session ended | Wait; it normally takes a second or two |
waitingForPhone | Waiting for the phone | adb devices lists no usable phone | Connect the phone with USB debugging on, or pair it for wireless debugging in Phone Mirror |
waitingForServer | Waiting for the phone's head unit server | The phone is there but nothing listens on its port 5277 | On the phone: Android Auto settings → ⋮ → Start head unit server (no menu: tap Version about 10 times first) |
needsCertificate | Android Auto needs a certificate | No certificate at androidAutoCertPath / in the certificate folder, or no key next to it | Put the certificate and key where the status says (above) |
running | (the phone's picture) | Session up: "Android Auto on <phone>"; phoneName holds the name the phone reported | -- |
error | Android Auto stopped ("Retrying on its own.") | adb not found, the port forward failed, the head unit server did not answer within 10 s, the certificate or TLS handshake failed, the phone closed the connection, or the phone stopped answering pings for 10 s | Read the status line; see Troubleshooting. A server that accepts but does not answer needs Stop head unit server / Start head unit server on the phone |
How the built-in receiver works
- Find the phone (off the GUI thread,
QtConcurrent) --adb devices, then the same serial choice as Phone Mirror (PhoneMirrorManager::pickSerial(), preferring the last phone). - Check the head unit server --
adb shell cat /proc/net/tcp6 /proc/net/tcpmust show a listening socket on local port 5277 (:149D, state0A).dumpsysis not used: it can't tell a running service from a stale record. - Forward --
adb forward tcp:0 tcp:5277picks a free local port, so it never collides with a DHU forward or a mirroring tunnel; the forward is removed when the session ends. - Session -- an
AapConnectionon its own thread connects to127.0.0.1:<port>and runs anAapSession: version exchange, TLS 1.2 with the head-unit certificate (OCTAVE is the TLS client), auth complete, then service discovery, where OCTAVE announces video, three audio sinks, a microphone, a touchscreen with media keys, sensors (driving status, night mode) and media playback status. From TCP connect to discovery it allows 10 s. Every video frame and audio packet is acknowledged; OCTAVE pings every 1.5 s. - Media -- H.264 goes through
H264Decoderinto the page'sQVideoSink; PCM goes to oneQAudioSinkper stream with at most 500 ms queued. - Music -- the phone's media focus requests go to
AndroidAutoMediaandnowPlayinganswers them (above); the media playback status channel brings the track, art and position; OCTAVE's controls go out as key events (KEYCODE_MEDIA_PLAY_PAUSE,NEXT,PREVIOUS). - End -- the close button and application quit send a bye-bye and wait for the phone's answer (quit waits up to 3 s): a session cut off mid-stream can leave the phone's head unit server accepting connections but never answering.
Byte layouts and the message flow: docs/ANDROID_AUTO_PROTOCOL.md. Plan, phases and risks:
TODO/android-auto-native-receiver.md. The protocol core is tested without a phone by
tst_androidauto (Development).
AndroidAutoManager
The built-in backend's members are marked experimental in the published API (Q_CLASSINFO("octave.experimental", ...)):
they may still change without a protocol version bump. The mobile stub keeps their names with inert values
(backend "dhu", nativeAvailable false, nativeState "idle").
Signals
| Signal | Parameters | Description |
|---|---|---|
connectionProgress | str | DHU: human-readable status updates ("Setting up ADB port forwarding...", "Waiting for head unit server... (Ns)", "Android Auto running") |
error | str | Error messages; the DHU view switches to its error page. Built-in: also emitted when a session fails |
dhuWindowReady | int | Stand-in window handle: the DHU's process id on Windows, 0 elsewhere |
dhuEmbeddedChanged | bool | Embedding state changed (false when the DHU process ends) |
backendChanged | -- | Built-in: androidAutoBackend was saved |
nativeStateChanged | -- | Built-in: nativeState, nativeStatus or phoneName changed |
videoStreamingChanged | -- | Built-in: the phone started or stopped sending video |
videoSinkChanged | -- | Built-in: videoSink was set |
Properties
| Property | Type | Description |
|---|---|---|
isDhuInstalled | bool | DHU executable found (constant) |
dhuPath | str | Path to the DHU executable (constant) |
isDhuEmbedded | bool | Whether a DHU is considered embedded/running |
dhuWindowHandle | int | Native window handle of the DHU (see above for what it really holds) |
dhuCapture | QObject | The DhuCapture instance |
backend | str | "native" or "dhu", from androidAutoBackend; always "dhu" when nativeAvailable is false |
nativeAvailable | bool | The build has the built-in receiver (OCTAVE_HAVE_NATIVE_AA; constant) |
nativeState | str | idle | connecting | waitingForPhone | waitingForServer | needsCertificate | running | error (table above) |
nativeStatus | str | The same state in a sentence for a person, including what to do |
phoneName | str | The device name the phone reported in service discovery |
videoStreaming | bool | The phone is sending video |
certificateDir | str | <config dir>/android-auto, where headunit.crt + headunit.key are looked for when androidAutoCertPath is empty (constant) |
videoSink | QObject | Read/write: the QVideoSink of the page's VideoOutput, set by the view |
Slots and invokables
| Slot | Returns | Description |
|---|---|---|
launchDhuSeamless() | bool | Close any running DHU, run the adb preparation (blocking on the GUI thread, up to 60 s while waiting for the head unit server), write headunit.ini, start the DHU |
getDhuInstallInstructions() | str | Android Studio → SDK Manager → SDK Tools → "Android Auto Desktop Head Unit Emulator" |
sendDhuClick(x, y) | -- | Forwards to DhuCapture.sendMouseClick; not called by any QML |
closeDhu() | -- | Stop capture, terminate the DHU (5 s, then kill), reset state |
cleanup() | -- | Ends a built-in session with a bye-bye (waiting up to 3 s), then closeDhu() plus adb forward --remove tcp:5277 (only its own forward); called at application quit |
startNative() | bool | Built-in: start connecting and keep retrying until stopNative(). False when it can't even begin (nativeState says why); true when already connecting or running |
stopNative() | -- | Built-in: stop retrying, end a session with a bye-bye, remove the forward; back to idle |
injectTouch(pointerId, action, x, y) | -- | Built-in: a touch on the picture; action 0 down / 1 up / 2 move, x / y in 0..1 of the video. Ignored unless running; a move or release without its press is dropped |
setViewVisible(visible) | -- | Built-in: the page is on screen; false tells the phone to stop sending video (audio continues) |
Not invokable from QML, wired by AppCore: setSettingsManager(), setVolume() /
setMuted() (from VolumeController), setAudioOutputDevice() (the audioOutputDevice
setting) and setNightMode() (from themeCatalog).
Google DHU backend: what actually works
dhuEmbeddedChanged(true) / dhuWindowReady fire, but no capture starts. On Linux and
macOS, 3 s after launch the manager hands DhuCapture the handle 0; startCapture() refuses it
with "No window handle set" (the view's error page flashes up) and the manager then reports the DHU as embedded anyway.
In both cases the view ends up black behind the close button, and EmbeddedDhuItem only tracks state.
sendMouseClick() is a stub. The header comments call this out as intentional ("DHU is rarely used").
Google DHU Integration
The DHU is discovered at the standard SDK location (a cached lookup):
# SDK_PATH / extras / google / auto / desktop-head-unit[.exe]
# SDK roots checked:
Windows: %LOCALAPPDATA%\Android\Sdk, %USERPROFILE%\AppData\Local\Android\Sdk, C:\Android\Sdk, C:\Users\%USERNAME%\Android\Sdk
macOS/Linux: ~/Android/Sdk, ~/Library/Android/sdk, /opt/android-sdk, /usr/local/android-sdk
adb comes from the same discovery as the phone mirror: findAdbPath() calls the public static
PhoneMirrorManager::findAdb() (cached after the first hit) -- bundled platform-tools, then PATH, then the common SDK
locations -- so both features drive the same binary.
DHU Configuration
Before launching, a config file is written to ~/.android/headunit.ini:
[general]
resolution = 1920 1080
dpi = 160
ADB Connection Setup
prepareAdbConnection() runs synchronously on the GUI thread:
-
Check device --
adb devicesmust list at least one line containing the substringdevice(no state parsing like the phone mirror'sgetDeviceState()). -
Port forwarding --
adb forward --remove tcp:5277(a stale DHU forward only), thenadb forward tcp:5277 tcp:5277(the DHU's port). An earlier--remove-allalso tore down a live phone-mirrorlocalabstract:scrcpy_*tunnel; Android Auto now touches only its own forward, here and incleanup(). -
Head unit server --
dumpsys activity services com.google.android.projection.gearhead/.companion.DeveloperHeadUnitNetworkServicemust show aServiceRecord. If not, OCTAVE triesam start -n com.google.android.projection.gearhead/.companion.MainActivity -a com.google.android.gms.car.action.START_HEAD_UNIT_SERVER(wait 2 s), thenam startservice -n ...DeveloperHeadUnitNetworkService(wait 1 s). If neither works it polls once a second for up to 60 s for the user to start "Head unit server" in Android Auto's developer settings, reporting the remaining time every 5 s throughconnectionProgress.
DhuCapture & Frame Provider
DhuCapture captures a window at 30 FPS on a QTimer into DhuFrameProvider
with QScreen::grabWindow(WId) on the primary screen. In practice it never runs against a real DHU window (see
What actually works): on Windows capture is never started, and elsewhere
startCapture() refuses the handle 0 it is given.
DhuFrameProvider
A QQuickImageProvider registered as image://dhuframe/ in src/main.cpp. It returns the latest frame
under a lock (the timer runs on the GUI thread, QML may request images from a render thread), or a black 800x480 placeholder
when none is available. No QML file currently references
image://dhuframe/.
Launch on Windows
The DHU process is started with CREATE_NO_WINDOW and SW_MINIMIZE (through
setCreateProcessArgumentsModifier). There is no window enumeration, so the 100 ms window poll always runs out
after 5 s and the process id stands in for the handle.
Touch Input Forwarding
Touch forwarding is not implemented. DhuCapture.sendMouseClick(x, y), reached through the manager's
sendDhuClick(), only logs the coordinates and returns, and no QML calls it. The source comment notes that
Windows would need SendMessage / mouse_event and that Linux and macOS have no universal API, so
ADB-based input would be the route there.
EmbeddedDhuItem
src/items/embeddeddhuitem.{h,cpp} does no window embedding. It connects to the
manager's dhuWindowReady / dhuEmbeddedChanged and to DhuCapture.frameReady, exposes
isStreaming and a frameCounter property (frameRefresh signal) intended for an
Image { source: "image://dhuframe/" + frameCounter } that AndroidAutoView.qml does not contain. detach() / reattach(), called when the view is
hidden or shown, only toggle isStreaming.
It connects via connectToManager(androidAutoManager) from the item's Component.onCompleted and picks up a
DHU that is already running (with a non-zero handle) when the user navigates back to the view.
iPhone (AirPlay)
An iPhone can't be mirrored the way an Android phone is: iOS has nothing like adb, no program can capture its screen or
fake touches from outside, and CarPlay needs Apple's authentication chip. What iOS does have is AirPlay. OCTAVE is an
AirPlay receiver (airPlayManager, src/managers/airplaymanager.{h,cpp}), so the
iPhone mirrors its screen to OCTAVE, or plays its music through it, the way it would to an Apple TV. AirPlay sends no touch
back, so OCTAVE is also a Bluetooth LE mouse: with AssistiveTouch on, a tap on the mirrored screen moves the iPhone's pointer
there and clicks. No app on the iPhone, no developer mode, no extra hardware.
Built when libplist, OpenSSL and libavcodec are found (OCTAVE_HAVE_AIRPLAY, Linux desktop and the Pi for now);
touch also needs BlueZ (OCTAVE_BLUEZ). airPlayManager.available / touchAvailable say
which a build has.
Setting it up
- Settings > Accessories > Phone Dock > iPhone (AirPlay): turn on AirPlay receiver
(
airPlayEnabled, off by default: it advertises OCTAVE on the local network). The iPhone page (frontend/AirPlayView.qml, also reached from Phone Mirror's "Using an iPhone?" button) has the same switch. - Put the iPhone on the same network as OCTAVE (the page lists OCTAVE's addresses). In the car that means the head unit joins the phone's hotspot, or the other way round.
- On the iPhone: Control Center > Screen Mirroring > OCTAVE (the
airPlayNamesetting). The first time, the iPhone asks for a PIN, which OCTAVE shows in large digits; it is remembered after that (airPlayRequirePin, on by default). For music only, pick OCTAVE as the speaker in Control Center's music card. - When mirroring starts, OCTAVE opens the iPhone page by itself (
mirroringStarted). Its X leaves the page; the iPhone keeps mirroring until it is stopped there. The phone button in the nav bar goes back to it.
The receiver's identity (a random, locally administered device id) and the iPhones that paired with the PIN are kept in <config>/airplay/state.json, its pairing key in receiver.pem. Forget paired iPhones clears the list.
Touch through a Bluetooth mouse
With airPlayTouchEnabled on (the default), OCTAVE registers a HID-over-GATT mouse with BlueZ
(src/platform/bluezhidmouse.{h,cpp}, D-Bus only, no root) named "OCTAVE Touch". Once:
- Tap Pair touch (on the iPhone page, the mirrored screen's badge, or the settings card). For three minutes
(
pairTouch()) OCTAVE accepts a Just Works pairing; at any other time pairing requests are refused, so a stray device (a Bluetooth keyboard) can't pair with the head unit. - On the iPhone: Settings > Bluetooth, tap OCTAVE Touch.
- Settings > Accessibility > Touch > AssistiveTouch: on. The grey pointer appears.
iOS only takes a relative pointer, so src/airplay/pointerpath.{h,cpp} turns a tap into: push the pointer into the top-left corner (40 full-speed reports, after which its position is known), move to the spot in steps of at most 12 counts (small steps keep iOS's pointer acceleration out of the way), press; a drag moves with the button held, and the lift releases. Reports go out every 8 ms. While the position stays known, later taps move from where the last one ended; it is forgotten when the mirrored picture changes size (a rotation) or the mouse reconnects. A mouse wheel on OCTAVE scrolls.
How far the pointer moves per count depends on the iPhone and its AssistiveTouch tracking speed, so it is calibrated:
airPlayTouchScale is the number of counts across the screen's short side (600 to start). Calibrate
touch on the mirrored screen homes the pointer and moves it 240 counts right and down; tap the pointer where it shows
and the scale is worked out from both axes (AirPlayManager::scaleFromCalibration).
What the page shows
state | Meaning |
|---|---|
unavailable | This build has no AirPlay receiver |
off | airPlayEnabled is off; the page offers Turn on AirPlay |
error | The receiver could not start (no port, mDNS port 5353 unusable, the key file); status says which |
waiting | Advertised; the setup steps and OCTAVE's addresses |
pin | A new iPhone is pairing: the PIN to type |
connected | An iPhone is connecting |
mirroring | The screen is mirrored (the page shows it) |
audio | AirPlay audio only, with the track's title and artist |
How it works
The protocol is UxPlay's library, compiled in from third_party/uxplay/ as
octave_uxplay (RTSP over llhttp, legacy and AirPlay 2 pairing, FairPlay setup, the mirror and audio RTP
streams, NTP timing, and its own mDNS responder, so no Avahi or Bonjour is needed). It advertises
_airplay._tcp and _raop._tcp with UxPlay's mirroring + audio feature set, without AirPlay video (HLS)
or H.265. src/airplay/airplayreceiver.{h,cpp} does what UxPlay's GStreamer renderers did:
- Video: the Annex B H.264 access units go through the shared
H264Decoderon UxPlay's mirror thread; the newest frame goes to the page'sVideoOutputsink, as with phone mirroring and Android Auto. The display size offered to the iPhone isairPlayResolution(1280x720 at 30 fps by default). - Audio: ALAC (music), AAC-LC or AAC-ELD (mirroring) through libavcodec into a
QAudioSinkon the picked output, at OCTAVE's volume (VolumeController) times the iPhone's own slider through the same curve. Muting OCTAVE mutes it. - Track info (DMAP
minm/asar/asal) and cover art go totitle/artist/albumandAirPlayManager::coverArt().
Checked without an iPhone: tst_airplay starts a real receiver and reads its RTSP /info, and a running
OCTAVE answers mDNS queries for both services.
What is not checked yet
Everything that needs an iPhone (TODO/iphone-airplay.md): the PIN pairing, the picture and audio,
the Bluetooth mouse pairing and reconnecting after a restart, the pointer path and the calibration, and the head unit's
software decode at 1280x720. AirPlay audio does not take part in nowPlaying's one-source rules yet, so local music
keeps playing under it. CarPlay is a separate plan (TODO/carplay-native.md).
Custom QML Types
One custom type remains. It is registered in src/main.cpp:
// src/main.cpp
qmlRegisterType<EmbeddedDhuItem>("OCTAVE.AndroidAuto", 1, 0, "EmbeddedDhuItem");
QML imports it as import OCTAVE.AndroidAuto 1.0. The former
OCTAVE.PhoneMirror module (EmbeddedScrcpyItem, ScrcpyHostItem, ScrcpyCaptureItem) was removed with
the scrcpy-binary path; Phone Mirror uses a plain QtMultimedia VideoOutput. On Android the build registers
a stub EmbeddedDhuItem so AndroidAutoView.qml still parses.
Image Providers
| Provider | URI Scheme | Class | Registered in | Default Size | FPS | Consumer |
|---|---|---|---|---|---|---|
| Android Auto | image://dhuframe/ |
DhuFrameProvider |
src/main.cpp (androidAutoManager.frameProvider()) |
800x480 | 30 | none in the current QML |
Phone Mirror does not use an image provider: decoded frames are pushed as QVideoFrames into the
QVideoSink of the view's VideoOutput, which avoids a per-frame RGB conversion and QImage copy.
The intended usage pattern for the remaining provider, should a view adopt it:
// Refresh the image by changing the source URL
Image {
source: "image://dhuframe/" + embeddedDhu.frameCounter
cache: false
}