Android Auto Protocol (AAP / GAL) — Wire Specification
Status: consolidated reverse-engineering reference · Revision date: 2026-09-25
This document specifies the wire protocol spoken between an Android phone and a car head unit — called AAP (Android Auto Projection) in Google's own documentation and GAL (Google Automotive Link) in the binaries and certificates. It is assembled from six independent reverse-engineering lineages plus one leaked official document, and it flags every place those sources disagree rather than silently picking a winner.
It is written for someone implementing the phone side (the FOSS replacement for
com.google.android.projection.gearhead), but the protocol body is descriptive and covers both
directions. Sections carry a > Phone-side note callout where this project has made a concrete
choice.
Vocabulary
Google's terms are used throughout, because the sources use them:
| Term | Meaning |
|---|---|
| MD | Mobile Device — the phone. This is us. |
| HU | Head Unit — the car's infotainment system. |
| AAP | Android Auto Projection — the protocol. |
| GAL | Google Automotive Link — the same thing, as named in binaries and the PKI. |
| DHU | Desktop Head Unit — Google's development head-unit emulator. |
| AOAP / AOA2 | Android Open Accessory Protocol v2 — the USB transport underneath AAP. |
Direction is always written MD→HU or HU→MD. Note carefully: the HU initiates the
protocol (version request) and is the TLS client, while the phone listens and is the TLS
server. Almost every intuition about which side is "the client" is backwards here.
How to read the confidence tags
| Tag | Meaning |
|---|---|
[NORMATIVE] |
Stated by the leaked Head Unit Integration Guide v1.3 — Google's own words. |
[CONFIRMED] |
Agreed by ≥2 independent lineages, or by code verified against real hardware. |
[LIKELY] |
Single good source, internally consistent, nothing contradicting it. |
[CONTESTED] |
Sources disagree. Full evidence table inline; also listed in Appendix B. |
Source fidelity — six lineages, and they are not independent
Judging a claim requires knowing which lineage it came from. Treating the corpus as one pool of evidence produces false confidence, because several projects vendor each other's definitions.
1. GAL-extracted protobuf — previous-work/source/milek7-galdocs/protos.proto
(2104 lines, 186 messages, 54 enums), extracted from a real Google binary. It is byte-identical
to previous-work/source/github/opencardev-aasdk/docs/protos.proto. This is the backbone of this
document: the only single artifact carrying every per-channel message-ID enum plus a
GalConstants block. Highest-fidelity machine-readable source available.
2. Head Unit Integration Guide v1.3.0 (2016-08-10, 104 pp) — Google's own integration guide,
recovered from a search-engine cache. Converted to
previous-work/markdown/milek7-galdocs/head-unit-integration-guide-v1.3.md; cited here as
[HUIG p.N]. Normative for semantics, policy and requirements — not for wire bytes. Its own
introduction says so (p.5): "It does not describe the low-level AAP protocol implementation
abstracted by a sender and receiver library on both the head unit (HU) and the mobile device
(MD)." It also contradicts itself on its own version: the title page says 1.3.0 while the body on
p.11 still says "This document covers version 1.2."
3. aasdk (f1xpl → opencardev → emirhalici) — 2018-era proto2 plus working C++, tested
against real cars for years. Authoritative on framing and transport; known-wrong in several
enums (Appendix C).
4. mrmees open-android-auto — decompilation of gearhead APK v16.1–17.3, with its own
Gold/Silver/Bronze confidence tiers and dated retractions. The most current source.
⚠ mfont-bz17/aa-linux is not independent of it: its .proto files carry
// https://github.com/mrmees/open-android-auto headers, making it a stale snapshot. Its Python
code, however, is independent and live-tested against the DHU — this document cites aa-linux's
code, never its protos.
5. mikereidis/headunit → headunit-revived — the original 2016 reverse engineering.
vincenzobpt/MOTO-HUB is a direct fork (NOTICE:52-61); its generated proto/*.java are
compiler output from headunit-revived's .proto. andyching168/HeadunitPad (Swift) carries no
vendoring markers but matches this lineage too precisely to be coincidence. ⚠ Treat their agreement
as one lineage confirmed twice; their disagreements are the informative signal.
6. Gearhead APK static-analysis notes — previous-work/misc/fixtures/gearhead-reference-notes.md:
clean-room behavioral observations only (wire constants, message sequences, state-machine
structure, flag names, timing values, per-build obfuscated-class anchors) from static analysis of
the phone app itself — builds 7.7.622144 / 8.2.623924 / 17.7.663624 (provenance and SHA-256
hashes: previous-work/markdown/apk/README.md; decompiles in
previous-work/source/apk/jadx-v{77,82,17}/, cross-checked against persisted baksmali trees to
dodge jadx decompile artifacts). No code, certificates or assets are copied — certificate material
appears as metadata (subject/serial/validity) only. It overlaps lineage 4 in subject (both
decompile gearhead) but is independent work on different builds: treat agreement with mrmees as
confirmation, disagreement as signal. The 17.7 dump this lineage analyses is the same one
§3.3 warns not to follow
mrmees's 17.3 class names into. Headline of its 7.7→8.2 diff: almost nothing wire-visible
changed — max protocol version 4.0→4.1 and a fallback-credential rotation only.
Explicitly not a source: mossyhub/openautolink's docs/protocol.md describes that project's
own invented app↔bridge protocol, not AAP. Its ports (5288 control / 5289 audio / 5290 video) have
nothing to do with this specification, despite 5288 coinciding numerically with a real AAP port.
1. Transport layer
AAP is transport-agnostic. Everything from §2 Frame format upward is byte-identical whether the bytes arrive over USB, Wi-Fi, or a loopback TCP socket. Three transports are deployed.
1.1 The USB role inversion — read this first
This trips up nearly every implementer, and Google's own prose does not help. [HUIG p.19-20]:
"The HU operates as a USB Host and AOA accessory. The HU powers the MD, which operates as a USB accessory." … "the host (HU) and client (MD)" … "HU attempts to connect to MD using AOAP and advertise itself as an AOAP accessory."
Note the guide calls both sides an "accessory" within two sentences. Untangled:
- The car is the USB host. It supplies power, enumerates, and issues control transfers. In AOA terms it plays the accessory role — it "advertises itself as an AOAP accessory".
- The phone is the USB peripheral (device). It is switched into accessory mode — which is why Google's second sentence loosely calls it "a USB accessory" too.
So "accessory mode" is a mode the phone is switched into, by the car. The car drives the switch
using the control transfers below; the phone's kernel f_accessory driver responds by
re-enumerating with a new VID:PID and a vendor-specific bulk interface.
Two normative requirements follow [HUIG p.20]:
- The HU MUST attempt AOAP initiation regardless of the MD's initial VID/PID — sniffing the connected device type to decide whether to try AAP is explicitly called out as unreliable.
- A phone that does not support AOAP simply ignores the handshake, letting the HU fall back to MTP or charge-only. So a failed AOA switch must be non-fatal.
1.2 Wired: AOA2 (Android Open Accessory Protocol v2)
Three vendor control requests, issued by the car (host) to the phone. [CONFIRMED ×2] —
f1xpl-aasdk/include/f1x/aasdk/USB/AccessoryMode*Query.hpp and
HeadunitPad/Core/AAP/AndroidOpenAccessory.swift:79-94:
| # | Name | bmRequestType |
wValue |
wIndex |
Data |
|---|---|---|---|---|---|
| 51 | GET_PROTOCOL |
0xC0 (IN, vendor) |
0 | 0 | ← u16 AOA version |
| 52 | SEND_STRING |
0x40 (OUT, vendor) |
0 | string index | → string + NUL |
| 53 | START |
0x40 (OUT, vendor) |
0 | 0 | none |
GET_PROTOCOL returns 1 or 2; aasdk accepts either, and AOAv2 is backward-compatible with v1
(AccessoryModeProtocolVersionQuery.cpp:66). String indices
(AccessoryModeSendStringType.hpp:31-38):
0 MANUFACTURER 1 MODEL 2 DESCRIPTION 3 VERSION 4 URI 5 SERIAL
1.2.1 Identity strings [CONTESTED]
The phone decides whether to enter AA mode by matching these strings. [HUIG p.19] gives the
official table — and apserver.c matches it exactly, field for field:
| Index | Field | HUIG p.19 value |
|---|---|---|
| 0 | Manufacturer Name | Android |
| 1 | Model Name | Android Auto |
| 2 | Description | Android Auto |
| 3 | AAP Protocol Version | 1.0 |
| 4 | URI | (empty) |
| 5 | Serial Number | (empty) |
But field testing disagrees about the model string across the whole decade this protocol has been reverse-engineered:
| Source | Model string | Year | Basis |
|---|---|---|---|
| Mike Reid, original XDA RE thread, post #16 | Android Open Automotive Protocol |
2015 | primary source — see below |
[HUIG p.19] [NORMATIVE] |
Android Auto |
2016 | Google's own table |
milek7 apserver.c:14-23 |
Android Auto |
~2018 | working libusb bridge; matches HUIG exactly |
HeadunitPad AndroidOpenAccessory.swift:12-19 |
Android Auto |
2025 | version 2.0.1, serial HU-AAAAAA001 |
gearhead APK rtt.java |
accepts both | 2025 | decompilation |
solarkennedy PLAN-androidauto.md:33-35 |
Android Open Automotive Protocol |
2026 | field-tested; warns Android Auto gave "false 'no handler' results" |
The 2015 primary source, read directly (source/web/[CLOSED] Headunit app for Android Auto...
XDA Forums.html, post #16, 2015-03-31 — this is the thread that started the entire community
lineage this document draws from): Mike Reid reports "I have a C program that successfully
connects with the AA app, as seen in the logcat" — i.e. the accessory-mode handshake completed —
then, further down the same post, quotes the string his program used: "Ironic string: 'Android
Open Automotive Protocol'. If it's 'Open', where are the documents Google?" Also in that thread:
"The standard 0x2D01... What's important is the special manuf and device/product strings for AA.
Only way I figured those out was by modifying some framework files and building a custom ROM."
Resolution. Android Open Automotive Protocol is not a 2026 rediscovery — it is the original
2015 finding, extracted from AOSP framework code before HUIG or any public documentation existed.
Android Auto (HUIG, milek7, HeadunitPad) is real and independently confirmed too. The most
coherent explanation, given the APK's own rtt.java accepts both: this has been a two-member
accepted set since the beginning, not a value that drifted over time — and Android Open Automotive
Protocol is the historically prior one, originally reverse-engineered by decompiling the
framework directly rather than observed from a working reference implementation.
Recommendation: try Android Open Automotive Protocol first, fall back to Android Auto. The
manufacturer is Android in every source. → Appendix B (downgraded from open question to
low-priority confirmation only).
Further downgrade (APK static analysis, 7.7/8.2/17.7). The phone's own USB accessory filter
(res/xml/car_usb_accessory_filter.xml, byte-identical in 7.7 and 8.2) accepts three
manufacturer/model pairs: Android/Android Open Automotive Protocol, Android/Android Auto,
and Android/Android — and does not filter the version field at all. The accepted set has
three members, not two, and the third is the most generic of all. → Appendix B (fully downgraded;
any of the three pairs is safe).
⚠ Index 3 (VERSION) is the AOAP accessory-descriptor version, not the AAP protocol version.
[HUIG p.19] lists it in the "Configuring AOAP" accessory-identifier table. It has nothing to do
with the in-band major/minor handshake of §3. Conflating the two is a
common error — note apserver.c says 1.0 while HeadunitPad says 2.0.1, and both work.
1.2.2 After the switch
The phone re-enumerates as VID 0x18D1 (Google) with one of [CONFIRMED ×3]:
| PID | Composition |
|---|---|
0x2D00 |
accessory |
0x2D01 |
accessory + ADB |
0x2D02 |
audio |
0x2D03 |
audio + ADB |
0x2D04 |
accessory + audio |
0x2D05 |
accessory + audio + ADB |
Interface is vendor-specific 0xFF / 0xFF / 0x00 with two bulk endpoints — OUT 0x02, IN
0x81; max packet 64 (full-speed) or 512 (high-speed) (aa-linux/usb.py:41-51). All AAP frames
flow over this single bulk pair.
⚠ AOA2's other features are not used by AAP. SET_AUDIO_MODE (request 58, USB Audio Class) and
the HID requests (54–57) are part of AOA2 but AAP uses none of them — audio and input ride AAP's own
channels over the bulk endpoints.
1.3 Wireless path A: Bluetooth handoff
Wi-Fi carries the AAP session, but Bluetooth bootstraps it.
SDP UUID 4de17a00-52cb-11e6-bdf4-0800200c9a66 [CONFIRMED ×4] — aa-linux/wireless.py:42,
nisargjhaveri .../bluetoothHandler.cpp:24, aa-proxy-rs/src/bluetooth.rs:42, and the 17.7 APK
itself. The APK additionally lists a companion UUID 669a0c20-0008-f4bd-e611-cb52007ae14d, an
HFP-HF UUID 0000111e-… used for car-side checks, and — decisively for the transport question —
no mDNS/NSD machinery anywhere (confirmed across 7.7–17.7): wireless discovery is
Bluetooth-based, full stop.
⚠ Editor's analysis: that companion UUID is the same 16 bytes in reverse order as the main
AA UUID (4d e1 7a 00 52 cb 11 e6 bd f4 08 00 20 0c 9a 66, reversed byte-for-byte). It is almost
certainly the same service UUID in the opposite byte order — an SDP endianness artifact, not a
second service to register.
RFCOMM channel 8 [CONFIRMED — see below]:
| Source | Channel | Basis |
|---|---|---|
mrmees wireless-bluetooth-setup.md:54,131,221 |
8 | SDP record construction, three places |
community troubleshooting knowledge (aa-trigger-esp32 project context) |
8 | standard advice: dump SDP on a BT scanner and confirm the AA UUID sits on RFCOMM channel 8 if a connection fails |
mretallack design.md:444,476 |
22 | asserted, no corroboration |
Resolve to 8 — two independent sources agree, and no source beyond one unsupported assertion in
mretallack gives 22.
⚠ 8 is not architecturally fixed — it is the channel every observed server happened to get. The
production Qt/C++ implementation in mrmees-openauto-prodigy/src/core/aa/BluetoothDiscoveryService.cpp
(see below) does not hardcode a channel at all: it calls QBluetoothServer::listen(), takes
whatever RFCOMM channel BlueZ hands back (rfcommPort_, listenForRfcomm()), and writes that
into its own SDP record. 8 is simply the first channel BlueZ tends to assign when only one RFCOMM
profile is registered — the correct client behaviour is to read the channel from the SDP record
(exactly as sdptool browse does), not to hardcode 8. Same principle as the WiFi TCP port
(§1.6): discover it, don't assume it.
On the "missing scripts." wireless-bluetooth-setup.md names three companion files
(sdp_clean.c, aa-combined.py, bt-agent.py) that mrmees's own issue tracker
(open-android-auto #19) confirms are absent from the repository. They were never meant to be
committed. A second, near-identical copy of this same guide survives in a different mrmees repo
— mrmees-openauto-prodigy/docs/archive/openauto-pro/bluetooth-wireless-aa-setup.md — and its own
"Files Reference" section is explicit: "Working test scripts (on Pi at /tmp/)". They were
throwaway files on the author's Raspberry Pi test rig, not source-controlled deliverables; nothing
to recover.
That archived copy is more valuable than the missing scripts would have been, for two reasons.
First, it explains the root cause the scripts existed to work around, with an AOSP citation:
Android's sdpu_compare_uuid_with_attr() (system/bt/stack/sdp/sdp_utils.cc:760) does strict SDP
attribute size comparison — a BlueZ record mixing 16-bit and 128-bit UUIDs (which is how BlueZ 5.82
encodes its own standard PnP/GAP/GATT/DevInfo records) triggers "invalid length for discovery
attribute" and Android drops the connection. This is why the AA-only, no-SPP, single-128-bit-UUID
SDP record matters — it's not superstition, it's dodging a specific strict-comparison code path
(the doc attributes it to a security-motivated commit, 6afad4b). Second, the archive names a
production successor, and that file exists in our corpus:
mrmees-openauto-prodigy/src/core/aa/BluetoothDiscoveryService.{cpp,hpp} (602+109 lines) — a
complete, working Qt implementation of the SDP-cleanup-and-RFCOMM-handshake recipe the scripts
prototyped, confirmed hardware-tested: "Tested with: BlueZ 5.82, Raspberry Pi 4, Moto G Play
(Android 14), Samsung S25 Ultra. Date: 2024-02-24."
⚠ That production file also contains a genuine bug, found by cross-checking it against this document's own sources — see Appendix C.
HFP presence [LIKELY REQUIRED for wireless]. A 16-bit HFP AG UUID 0x111F is probed alongside
the AA UUID. mrmees's two documents (the original and the archived copy, independently dated
2024-02-24) are emphatic and explicit about why: "The Android Auto app requires an active HFP
(Hands-Free Profile) connection to the head unit before it will initiate wireless AA setup. This is
how the phone distinguishes a car head unit from a random Bluetooth device." Failure mode:
WIRELESS_SETUP_FAILED_TO_START_NO_HFP_FROM_HU_PRESENCE. But [HUIG p.23] says the opposite for
the (older, wired-only) protocol it describes:
"While AAP needs a Bluetooth HFP for telephony to function during an active AAP session, the absence of an active HFP connection will not prevent the AAP pairing process from completing."
These are reconcilable by era and transport: HUIG 1.3 predates wireless AAP entirely and is describing USB pairing, where HFP is genuinely optional. The wireless bootstrap is a later addition with its own gate. Treat HFP as required for the wireless path, optional for wired.
⚠ Complication found in web research: headunit-revived (a mature, widely-deployed wireless HU
implementation) describes its own HFP server as optional — it "optionally runs an HFP server to
help phones detect the HU," i.e. an aid to discovery, not a hard gate it depends on. This is a data
point against a universal hard requirement, from an implementation that demonstrably works
wirelessly, set against two dated, causally-explained mrmees sources saying the opposite. It may
mean the requirement is phone-app-version-dependent, or that headunit-revived tolerates what
mrmees's exact gearhead build does not — or that headunit-revived's "optional" server still
reliably gets connected in practice even though the code doesn't hard-block on it. Kept
[LIKELY REQUIRED] rather than fully resolved. Regardless of the answer, registering an HFP AG
profile costs nothing and is the correct move either way — hold the fd open, never answer calls.
→ Appendix B.
RFCOMM framing differs from AAP's own framing — it is simply:
[u16be total length][u16be message id][protobuf body]
WifiSetupMessage IDs and the five-stage sequence, IDs 1–7 [CONFIRMED ×4] (of which 6/7 vs
one outlier), IDs 8–11 [LIKELY ×1] (aa-linux/wireless.py:47-53,535-579, mrmees
wireless-bluetooth-setup.md:160-168, aa-proxy-rs/src/bluetooth.rs:57-58, gearhead 17.7 APK
link-level table):
| ID | Message | Direction |
|---|---|---|
| 1 | WifiStartRequest |
HU→MD |
| 2 | WifiInfoRequest |
MD→HU |
| 3 | WifiInfoResponse |
HU→MD |
| 4 | WifiVersionRequest |
either |
| 5 | WifiVersionResponse |
either |
| 6 | WifiConnectStatus |
MD→HU |
| 7 | WifiStartResponse |
MD→HU |
| 8 | PING_REQUEST |
link-level |
| 9 | PING_RESPONSE |
link-level |
| 10 | CONNECTION_REJECTION |
link-level |
| 11 | SETUP_INFO |
link-level |
1. HU→MD WifiStartRequest { ip_address=1 (string), port=2 (uint32) }
2. MD→HU WifiStartResponse { status = 0 }
3. MD→HU WifiInfoRequest { } -- empty
4. HU→MD WifiInfoResponse { ssid=1, key=2, bssid=3,
security_mode=4, access_point_type=5 }
5. MD→HU WifiConnectStatus { status = 0 } -- after joining the AP
→ MD opens TCP to the ip_address:port from step 1; AAP proper begins.
WifiVersionRequest/Response (4/5) may interleave at any point.
⚠ WifiInfoResponse field 5 [CONTESTED, minor] — the 17.7 APK's RESPONSE_INFO (ID 3) field map
reads {1 ssid, 2 password, 3 bssid, 4 security, 5 status}, a status field where the other three
sources place access_point_type. Fields 1–4 are agreed by everyone. → Appendix B (item 23).
⚠ IDs 6 and 7 are swapped in one source — mrmees-openauto-prodigy's own production C++
(src/core/aa/BluetoothDiscoveryService.cpp:36-40) defines kMsgWifiStartResponse = 6 and
kMsgWifiConnectionStatus = 7, the reverse of the table above. Four independent sources agree
with the table (aa-linux, this same mrmees organization's own markdown doc, aa-proxy-rs —
deployed production firmware talking to real cars — and the 17.7 APK's link-level table, which
names 6 CONNECT_STATUS and 7 RESPONSE_START), against one outlier that is mrmees's other
repo contradicting mrmees's own documentation. Resolve 4-to-1 in the table's favor; treat the
openauto-prodigy file as buggy on this point — see Appendix C.
The HU runs the access point; the phone joins as a station. Reference dongles run hostapd.
AccessPointType: STATIC=0, DYNAMIC=1.
WifiSecurityMode [RESOLVED: bitmask]:
| Source | OPEN | WEP_64 | WEP_128 | WPA_PERSONAL | WPA2_PERSONAL | WPA_WPA2 | Enterprise variants |
|---|---|---|---|---|---|---|---|
| mrmees, aa-proxy-rs (bitmask) | 1 | 2 | 3 | 4 | 8 | 12 | 20/24/28 |
GAL protos.proto:1671 (sequential) |
1 | 2 | 3 | 4 | 5 | 6 | 7/8/9 |
aa-proxy-rs is the deployed firmware behind commercially-sold "AA Wireless Dongle" hardware — its
src/protos/WifiInfoResponse.proto defines the bitmask reading outright (proto2,
WPA2_PERSONAL=8), and it additionally has enterprise variants the GAL numbering lacks
(WPA_ENTERPRISE=20, WPA2_ENTERPRISE=24, WPA_WPA2_ENTERPRISE=28 — each is the personal value plus
16, consistent with a genuine bitmask rather than coincidence). Since this is live production
firmware talking to real cars, not just an extracted proto, resolve to bitmask — emit 8 for
WPA2-Personal, and still accept 5 defensively since the sequential reading remains in the GAL proto.
→ Appendix B.
Upgraded to [CONFIRMED ×2] by the APK itself (17.7, first-hand): the WiFi-projection
channel's credentials response carries the full bitmask enum — 0 UNKNOWN, 1 OPEN, 2 WEP_64,
3 WEP_128, 4 WPA_PERSONAL, 8 WPA2_PERSONAL, 12 WPA_WPA2, 20/24/28 enterprise variants,
32 WPA3_PERSONAL, 40 WPA2_WPA3 — including two values no other source has (WPA3 and the
WPA2/WPA3 union). The sequential GAL numbering is now conclusively a proto-extraction artifact.
On that WiFi-projection channel itself (§6.4): 7.7/8.2 are car-initiated only — a single
0x8002 WifiCredentialsResponse with no phone-side request — while 17.7 adds the phone→car
0x8001 WifiCredentialsRequest (empty) that the §6.4 enum lists. The phone then joins the car's
AP via WifiNetworkSpecifier (hidden SSID allowed, WPA2/WPA3). Wireless timers observed in the
APK: WiFi-info response wait 3000 ms; wireless-RSSI start threshold −50 dBm (both
Phenotype-tunable — §11). → Appendix B (item 4 resolved).
1.4 Wireless path B: QR / deep-link pairing
A Bluetooth-free path, reverse-engineered by MOTO-HUB (AaWirelessPairing.kt:165-173) — original
work, not vendored. Directly relevant to this project because we control the phone.
Gearhead exposes broadcast receivers accepting:
com.google.android.projection.gearhead.START_WIRELESS_PROJECTION_WPP
com.google.android.projection.gearhead.START_WIRELESS_PROJECTION
and a deep link https://androidauto.com/projection/?data=<base64> whose payload is protobuf:
// field 7 (security mode) is omitted — Android Auto defaults it to WPA2_PERSONAL
message WirelessPairingPayload {
optional string ssid = 1;
optional string bssid = 2;
optional string passkey = 3;
optional string host_address = 4; // string, not a packed address
optional uint32 port = 5;
optional string bluetooth_address = 6;
}
1.5 TCP (DHU and development)
Plain TCP; the phone listens and the DHU connects in. The phone is therefore TCP server and
TLS server simultaneously. aa-linux/transport.py:16 binds 0.0.0.0:5277; dhu.sh:67-71 launches
desktop-head-unit --adb=5277. The APK's wireless-projection-over-TCP path carries an overall
45000 ms timeout — do not expect an idle TCP transport to stay up indefinitely.
1.6 Ports — negotiated, not constant [CONTESTED, resolved]
Four different numbers circulate. They are not competing claims about one value; they describe different things:
| Port | What it actually is | Source |
|---|---|---|
| 5277 | DHU listener; also used for wireless AAP by MOTO-HUB and HeadunitPad | transport.py:16, Discovery.swift:6 |
| 5288 | What the dongle projects advertise in WifiStartRequest |
aa-proxy-rs, nisargjhaveri, aa-linux |
| 5289 | "Wireless helper / launcher" auxiliary port | Discovery.swift |
| 30515 | GalConstants.WIFI_PORT in the extracted GAL binary |
protos.proto:2101 |
Resolution: the port is not fixed. It is carried in WifiStartRequest.port (§1.3 step 1) or the
QR payload's field 5 (§1.4). Implementations must read it, never hardcode it. 30515 is the
GAL-declared default; everything else is a deployment choice.
Phone-side note (this project). Bring up TCP/5277 against the DHU first — it exercises the entire stack from §2 up with no USB or Bluetooth complications. Add AOA2 next for the wired car case. Wireless last, and prefer path B (§1.4) over the Bluetooth handoff, since we control the phone and can skip the HFP-presence requirement entirely.
2. Frame format
Every byte above the transport is framed identically. A message (a 2-byte type plus a protobuf body, or a media payload) is split into one or more frames.
2.1 Frame header
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| channel id | flags | frame payload length |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| total message length (ONLY if flags&0x03 == 0x01) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| frame payload ... |
| Offset | Size | Field | Notes |
|---|---|---|---|
| 0 | u8 | channel id | 0 = control; all others negotiated (§6) |
| 1 | u8 | flags | bitfield, below |
| 2 | u16be | frame payload length | bytes of payload in this frame |
| 4 | u32be | total message length | present only when (flags & 0x03) == 0x01 |
So the header is 4 bytes normally, 8 bytes on the first frame of a fragmented message.
Source: opencardev-aasdk/src/Messenger/FrameHeader.cpp + FrameSize.cpp, corroborated by
aa-linux/frame.py:52 (struct.pack(">BBHI", ...)).
2.2 Flag bits
| Bit | Mask | Name | Meaning |
|---|---|---|---|
| 0 | 0x01 |
FIRST | first frame of a fragmented message |
| 1 | 0x02 |
LAST | last frame of a fragmented message |
| 2 | 0x04 |
CONTROL | payload is a control message for this channel |
| 3 | 0x08 |
ENCRYPTED | payload is TLS ciphertext |
Bits 0–1 together form the fragmentation state, mirrored in the GAL proto as
enum FragInfo (protos.proto:1334-1339):
flags & 0x03 |
FragInfo |
aasdk FrameType |
Meaning |
|---|---|---|---|
0b00 |
FRAG_CONTINUATION |
MIDDLE |
middle frame |
0b01 |
FRAG_FIRST |
FIRST |
first of many — carries total length |
0b10 |
FRAG_LAST |
LAST |
last of many |
0b11 |
FRAG_UNFRAGMENTED |
BULK |
complete message in one frame |
⚠ Precision point most of the corpus gets wrong. The 4-byte total-length field appears when the
frame type is FIRST exactly (0b01) — not whenever bit 0 is set. A single unfragmented frame
has 0b11 (FIRST|LAST) and carries no total length, because its frame length already is the
total. MOTO-HUB encodes this as a literal flags == 0x09 test
(AapReadSingleMessage.kt:43-44) — 0x09 = FIRST|ENCRYPTED with LAST clear.
⚠ That same literal is a latent bug worth not copying: a FIRST+CONTROL-not-LAST frame (0x0D)
would be mis-parsed. It assumes control messages never fragment — which the phone app itself
enforces (channel-control messages are never fragmented and must fit in one frame, payload ≤
maxFragment − 4; itx.java:213-214, v7.7–17.7) — but the wire format does not guarantee it.
Observed flag values on the wire, agreed by MOTO-HUB and HeadunitPad: 0x0B single, 0x09 first,
0x08 middle, 0x0A last (all with ENCRYPTED set) — an exact corroboration of the bit assignment.
2.3 Reconciling the "4 vs 6 vs 8 byte header" confusion
Different codebases quote different header sizes for the same bytes. This is an accounting convention, not a wire disagreement, and knowing the mapping makes every other codebase readable:
| Convention | Plaintext frame | Encrypted frame | Used by |
|---|---|---|---|
| Header excludes message type | 4 (or 8 if FIRST) + separate 2-byte type | same | aasdk, aa-linux, this document |
| Header includes message type | 6 — chan, flags, len, type, where len covers the type |
4 — type is inside the ciphertext | MOTO-HUB, HeadunitPad |
Both describe identical bytes. This document uses the first convention throughout.
[CONTESTED] — one source genuinely disagrees. milek7's Wireshark dissector
(androidauto.lua:157-170) reads a single 4-byte length at offset 2 on FIRST frames and starts
the payload at offset 6:
| Source | FIRST-frame header | Basis |
|---|---|---|
aasdk FrameSize.cpp |
8 (u16 frame len + u32 total len) | C++ tested against real cars |
aa-linux/frame.py:52 |
8 | Python, live-tested against Google's DHU |
MOTO-HUB AapReadSingleMessage.kt:43-44 |
8 | independent lineage |
mretallack design.md:475 |
8 (describes it as "2 length + 4 reserved") | validated against uglyoldbob |
milek7 androidauto.lua:157-170 |
6 (one u32 length) | hand-written dissector |
Resolve to 8. Three independent lineages agree, two of them verified against real hardware, and the dissector's own header comment admits to hardcoded simplifying assumptions.
2.4 Message payload
Inside the reassembled frame payload:
[u16be message type][protobuf body]
Message type numbering follows a consistent convention:
< 0x8000— bulk data. On A/V channels,0x0000= media data,0x0001= codec config.>= 0x8000— control messages for that channel.
On the control channel (0) the type is a ControlMessageType (§6.3)
and the range rule does not apply.
2.5 Fragmentation rules
- Maximum frame payload: the negotiated default is 16128 bytes in the phone app itself
(
max_fragment_size, v7.7its.java:26; WiFi deployments can override it via Phenotype — §11). The commonly quoted0x4000(16384) (aa-linux/constants.py:106) is the reader's SSL-ciphertext buffer and the mic-queue limit, not the negotiated send size. Receivers should treat 16384 as the safe upper bound (the u16 frame length allows up to 65535, and no peer is known to exceed 16384); senders should default to - Fragmentation triggers only when the message is fragmentable and its payload exceeds
max_fragment_size − 4. frame payload lengthis u16, so a frame can never exceed 65535 regardless.- Fragmentation happens after encryption. The chunks are ciphertext;
total message lengthrefers to the plaintext total (aa-linux/protocol.py:371-418). - A LAST frame is always sent, even if empty — receivers reassemble on frame type, not by
counting down
total message length(protocol.py:378-380).
2.6 "Control message" does not mean "channel 0"
A non-obvious routing rule that breaks implementations that assume otherwise. CHANNEL_OPEN_REQUEST
is framed on the channel being opened, with the CONTROL bit set — not on channel 0. The receiver
validates the channel in the frame header, so sending it on channel 0 is rejected
(aa-linux/protocol.py:434-446, which notes the DHU's MessageRouter::routeChannelControlMsg
does exactly this check).
Conversely, milek7's dissector treats a frame as control if either the channel byte is 0 or
the CONTROL bit is set (androidauto.lua:185-189).
2.7 QoS, writer priorities, and framing errors
The phone's writer is a QoS queue (on by default for both USB and WiFi; v7.7 ixs): per-channel
priority FIRST(−128) < DEFAULT(0) < AUDIO(1) < VIDEO(2) < LAST(127), with video channels mapped
to VIDEO and audio channels to AUDIO. Dispatch is per-endpoint threads — video on RxVid
(priority −8), audio RxAud (−19), mic RxMic (−16), sensors RxSen, everything else RxDef —
confirming that each channel is an independent state machine with its own reader.
On a framing error the phone sends message 0xFFFF (FRAMING_ERROR) on channel 0 and tears the
session down — the error sentinel of §6.3 has a concrete, fatal
consumer.
Phone-side note (this project). Implement framing as a standalone, unit-tested layer with no knowledge of message semantics. It is the one part of the stack where a subtle error produces symptoms — stalls, "severe static", undecodable video — that look like bugs three layers up.
3. Version handshake
The first exchange after the transport connects. It is not protobuf — raw big-endian 16-bit integers — and it is sent in plaintext, before TLS.
[HUIG p.15] makes the HU the initiator:
"the HU initiates negotiation of the AAP connection by sending a Version Request to the MD with ReceiverLib function
GalReceiver::start(). The HU MUST NOT send additional Version Requests or ping requests while awaiting a response from the MD."
3.1 Wire format
VERSION_REQUEST HU→MD flags 0x03 (BULK, plaintext), channel 0
00 01 message type = 1
<major:u16be> <minor:u16be> -- 6 bytes total
VERSION_RESPONSE MD→HU flags 0x03 (BULK, plaintext), channel 0
00 02 message type = 2
<major:u16be> <minor:u16be>
<status:u16be> -- 8 bytes base
[ VersionResponseOptions protobuf ] -- appended iff the HU's request was >= 1.6
Observed response, verbatim (mrmees 02-version-ssl-auth.md:124):
00 02 00 01 00 07 00 00 -- VERSION_RESPONSE, v1.7, status MATCH
status |
Meaning |
|---|---|
0x0000 |
MATCH — compatible version |
0xFFFF |
MISMATCH — no compatible version; the connection will close |
0xFFFF is MessageStatus.NO_COMPATIBLE_VERSION (-1) rendered as an unsigned 16-bit field. The
whole MessageStatus enum is signed and mostly negative (§6.5), so any status
field that is u16 on the wire shows its errors as large positive values.
⚠ The response is not always 8 bytes. When the HU's request is ≥ 1.6, gearhead appends a
VersionResponseOptions protobuf (ConnectionConfiguration at field 1) — the "WireConfig" gate
(mrmees, APK 17.3 iyk.java:149-193; see the caveat under §3.3). This is exactly why milek7's dissector skips 6 bytes past the message type
before handing the remainder to a protobuf parser. A parser that assumes a fixed 8 bytes will
mis-handle modern head units.
⚠ The request can carry options too. The phone parses an optional appended
VersionRequestOptions protobuf on VERSION_REQUEST — {1 flags, 2 fixed64 "snapshot"} — with
the parsing itself gated behind a flag (v7.7 itj.java:347-370, present through 17.7). The
semantics of the fixed64 "snapshot" beyond logging are undetermined → Appendix B (item 21).
3.2 Which version number? [CONTESTED]
Six values circulate. Most of the apparent conflict is a category error — three different things are being measured:
| Role | Source | Value |
|---|---|---|
| HU advertises (in request) | aasdk build constant | 1.1 |
MOTO-HUB Messages.kt:20 ({0,1,0,2}) |
1.2 | |
HeadunitPad AapTransport.swift:300 ({0,2,0,0}) |
2.0 | |
| MD responds | real gearhead AA 17.3, observed | 1.7 (6.1 if request > 1.7) |
aa-linux/constants.py (mimicking gearhead) |
1.7 | |
| gearhead APK 7.7 / 8.2 / 17.7 (static) | max supported 4.0 / 4.1 / 6.1; answers 1.7 to any request ≤ 1.7 | |
| Document / binary constant | HUIG title page — document revision | 1.3.0 |
| HUIG body p.11 — protocol it describes | 1.2 | |
GalConstants in the extracted binary |
1.6 |
Read this way a coherent timeline appears — HUIG-era ≈1.2 (2016) → GAL binary 1.6 → current
gearhead 1.7 — and different projects simply froze at different points. Real gearhead logs
Car requests protocol version v1.1 / Negotiated protocol version v1.7 (CAR.GAL.GAL.LITE),
so aasdk's 1.1 is deliberate conservatism rather than a claim that aasdk implements AA 1.1.
The APK static analysis fills in the rungs between those observations: the max-supported value
climbed 4.0 (v7.7) → 4.1 (v8.2) → 6.1 (v17.7), and the answering algorithm is uniform across
all three builds — requested ≤ 1.7 → answer 1.7; requested > 1.7 → answer max — with the
STATUS_NO_COMPATIBLE_VERSION branch dead code (the options-proto singleton is never null),
so a mismatch status is never actually emitted. The 4.1 bump in v8.2 has exactly one identified
consumer: wireless version-response BT-adapter-name / BT-MAC fields (§3.3).
⚠ There are two version series, not one continuous run: a legacy 1.x series and a modern 4.x – 6.1 series, with nothing observed in between on the wire — though the APK's own max-supported ladder (4.0 → 4.1 → 6.1) shows the phone side climbed through 4.x continuously while head units stayed on 1.x for years. HeadunitPad's 2.0 is the one genuine outlier — nothing else sits in 2.x, and since nothing validates the exchange (§3.3), a mistake there would never surface. → Appendix B.
3.3 Negotiation is not validated — but the version still matters
No reference implementation checks the peer's version. MOTO-HUB verifies only that the response
type is 2 and ignores the payload (AapTransport.kt:222-227); HeadunitPad proceeds to TLS on any
VERSION_RESPONSE (:1197-1204).
It would be wrong to conclude the version is cosmetic. The phone retains the HU's raw requested
version — not the negotiated response — and gates features on it
(mrmees 02-version-ssl-auth.md:131). The HU's request must therefore describe what it actually
implements; it is not a bid for the highest mutually supported version.
| Min version | Feature unlocked | Evidence (APK 17.3, relayed from mrmees) |
|---|---|---|
| 1.4 | Battery-status notification send (the previously unidentified gate) | hna.java:1347; first-hand 7.7–17.7 |
| 1.6 | WireConfig appended to the version response |
iyk.java:149-193 |
| 1.7 | Default preferred legacy response | iyk.java:24-26,134-148 |
| 4.1 | Wireless version-response BT-adapter-name / BT-MAC fields (kill-switch gated) | APK 8.2 first-hand, gny.java:467-494 |
| 4.3 | AdditionalVideoConfig UI / resize policy |
itt.java:277-299 |
| 5.0 | Ackless audio; extended AV start data | ipq.java:154-164, ipe.java:371-400 |
| 5.1 | Audio MediaOptions updates; VehicleEnergyForecast |
ipe.java:541-551, ija.java:1101-1117 |
| 6.0 | Modern media-options / video paths, incl. conditional H.265 | iky.java, itq.java, its.java |
| 6.1 | Server-pushed ping config {1:{1:{1..4}}} appended to the version response (ties to §5.3); SDP request field 6 |
APK 17.7 first-hand; iyk.java:24-26,134-148 |
⚠ These are second-hand citations into a decompilation this corpus does not contain. They refer
to gearhead 17.3; the APK dump under previous-work/source/apk/jadx-v17/ is 17.7, and
ProGuard/R8 reassigns obfuscated names between builds — iyk in 17.7 is a three-line synthetic
lambda, unrelated to the class mrmees cites. Do not follow these line numbers into our dump. To
re-verify a gate, decompile the matching APK version and search for the version comparison, not the
class name. For 17.7 this is now less of a problem: the APK-notes lineage (lineage 6) re-verifies
the gates first-hand in 7.7/8.2/17.7, and Appendix D's anchor map indexes the per-build class
names — the 17.3 class names still do not carry over, but the gates themselves no longer rest on
the relay alone.
Phone-side note (this project). We are the responder, not the requester: parse and retain the HU's raw requested version, gate our own behaviour off it, and answer. Advertise 1.7 to mirror real gearhead. Do not claim ≥5.0 or ≥6.0 until ackless audio and the modern video paths actually work — the HU will take us at our word and stop sending acks we still depend on.
4. TLS authentication
The single most consequential section for this project. The protocol layer is straightforward; the PKI is where a phone-side implementation actually gets blocked (§4.5).
4.1 Roles — the phone is the TLS server
[NORMATIVE] [HUIG p.21]:
"The AAP protocol uses industry standard TLS 1.2 with Client Authentication to secure communications between the head unit (TLS Client) and mobile device (TLS Server). The first step in establishing a AAP connection is mutual authentication between the MD and HU following the TLS Client-authenticated TLS handshake protocol."
[CONFIRMED ×4] across four independent implementations:
| Implementation | Side | Evidence |
|---|---|---|
aa-linux (phone) |
server | AACrypto.create_server(), ssl.PROTOCOL_TLS_SERVER |
| aasdk (head unit) | client | TLS_client_method(), SSL_set_connect_state() |
| MOTO-HUB (head unit) | client | AapSslContext.kt:15-16 useClientMode = true |
| HeadunitPad (head unit) | client | OpenSslTlsHandler.swift:45,144 |
The HU also dictates our obligations [HUIG p.21] — the MD is responsible for "switching to AOAP
mode when requested by the HU", "responding to HU initiation of Android Auto connection with an
appropriate handshake", and "registering and responding to HU Service Discovery declarations".
4.2 Handshake transport — TLS tunnelled inside AAP
TLS does not run at the socket layer. Handshake records are carried as ordinary AAP control messages, which is why implementations drive TLS through memory BIOs rather than a socket:
message type 0x0003 ENCAPSULATED_SSL -- carries raw TLS handshake bytes
message type 0x0004 AUTH_COMPLETE -- AuthResponse { int32 status = 1 }
The pump: feed received ciphertext into the TLS engine's inbound BIO, step the handshake, drain the
outbound BIO, wrap whatever came out as a new ENCAPSULATED_SSL message, send. Repeat until the
handshake completes. [CONFIRMED ×3] — aa-linux/crypto.py (ssl.MemoryBIO),
HeadunitPad OpenSslTlsHandler.swift:133-143 (BIO_new(BIO_s_mem()) ×2), MOTO-HUB
AapSslContext.kt:328-397 (Java SSLEngine wrap/unwrap). milek7's ssl_preload.cpp:103-105
independently corroborates by hardcoding the 2-byte {0x00, 0x03} prefix onto outbound handshake
bytes.
⚠ These frames are plaintext. The ENCRYPTED flag bit is clear during the handshake — the
payload is TLS handshake data, not application data. [HUIG p.15]: "Encrypted packets MUST NOT be
sent from HU to MD until authentication is complete."
AUTH_COMPLETE concrete bytes, agreed by both headunit-lineage implementations:
00 03 00 04 00 04 08 00
│ │ └──┘ └──┘ └──┘
│ │ │ │ └─ payload: protobuf field 1 (varint) = 0 → status OK
│ │ │ └────── message type 0x0004 = AUTH_COMPLETE
│ │ └──────────── frame payload length = 4
│ └──────────────── flags 0x03 = BULK, plaintext
└─────────────────── channel 0
After AUTH_COMPLETE, every subsequent frame sets the ENCRYPTED bit and its payload is ciphertext.
v17.7 additionally enforces a pre-auth message-type whitelist behind
FrameworkGalFeature__tls_auth_bypass_fix: until the handshake completes, only control messages
{1, 3, 4, 11, 12, 15, 16, 255, 65535} are accepted — version, SSL, auth, ping, byebye, and the two
error sentinels. Note ping (11/12) on that list: the plaintext-ping rule of
§6.3 corroborated from a second direction. (v17.7 also rejects an
auth-success that arrives before the handshake completes, behind the same flag, and maps
UNSUPPORTED_PROTOCOL handshake exceptions to an "obsolete SSL" state.)
4.3 TLS parameters
Version: TLS 1.2 [NORMATIVE]. Notably, no implementation in the corpus pins a cipher
suite — not aa-linux, aasdk, MOTO-HUB, or HeadunitPad. Only aa-linux pins the protocol version
(min = max = TLSv1.2); the others accept whatever their library negotiates. Cipher selection is
therefore left to TLS defaults in practice.
On a real phone the provider is Conscrypt. The CAR.GAL.SECURITY package initialises an SSL
context twice — first TLS 1.2 with AndroidOpenSSL, then TLS 1.2 with GmsCore_OpenSSL
(ethical-hacking thesis, 2021; corroborated by mretallack APK analysis). Both are Conscrypt
builds; GmsCore_OpenSSL is the one shipped inside Google Play Services.
The APK static analysis corroborates this first-hand and sharpens it: no build sets SNI, ALPN, a
cipher-suite restriction, or setEnabledProtocols; v17.7 additionally pins
SSLContext.getInstance("TLSv1.2") and prefers the GmsCore_OpenSSL provider. The phone presents
its chain — CarService leaf plus GAL root — under server alias com.google.android.gms.car, RSA
key type only.
4.4 Interop hazard: the certificates violate X.509
Verified first-hand with openssl against aa-proxy-rs/hu_cert.pem. The head-unit certificate has
two independent standards violations:
1. It is X.509 version 1. No extensions at all — no Basic Constraints, no Key Usage, no EKU, no SKI/AKI.
2. Its validity times are malformed. Both are ASN.1 UTCTime with a numeric timezone offset
instead of the mandatory Z — notBefore = 140704000000-0700, notAfter = 480801102123-0700 —
violating RFC 5280 §4.1.2.5.1, which requires UTCTime values to end in Z. OpenSSL refuses at the
first one it meets:
$ openssl verify -CAfile galroot_cert.pem hu_cert.pem
error 13 at 0 depth lookup: format error in certificate's notBefore field
By contrast md_cert.pem encodes proper Z times (140704000000Z / 260722191755Z), so this is
specific to the HU certificate, not the whole PKI.
Consequence: strict TLS stacks reject these certificates. rustls/webpki refuses v1 certificates
by default, producing a handshake that looks healthy and then dies — the reporter in
rustls#2455 saw ClientHello → ServerHello → 1464 B
→ 2348 B → 1286 B → server cuts the link, with the library waiting for data that never arrives.
The workaround was patching webpki to accept v1; see uglyoldbob/android-auto branch
plain-rustls.
Choose a lenient TLS stack. OpenSSL, BoringSSL and Conscrypt tolerate these certificates in practice.
⚠ As the TLS server we can sidestep the v1 problem entirely — it only bites a peer that
validates the HU certificate. All four reference implementations disable peer verification
outright: aa-linux verify_mode = CERT_NONE, MOTO-HUB NoCheckTrustManager.kt:10-14,
HeadunitPad SSL_CTX_set_verify(..., SSL_VERIFY_NONE, nil).
⚠ A peer presenting the phone-side identity is rejected. The real phone additionally blacklists the car's client certificate if its subject contains "CarService" or "Google Automotive Link" (v7.7/v8.2, verified in smali to avoid a jadx negation artifact) — an anti-phone↔phone / anti-loopback guard stacked on top of the PKIX chain check. A test head unit reusing a copied MD certificate fails here, not in the chain validation.
4.5 PKI and provisioning
[HUIG p.22]: mutual trust rests on two certificates signed by the Google CA — the HU cert
which the MD verifies, and the MD cert which the HU verifies. RSA 2048, X.509. Google issues HU
certificates to integrators and recommends a unique one per make/model/year.
And [HUIG p.21], which is the sentence that matters most for us:
"If the receiver library cannot verify the sender certificate, the HU terminates the connection and disconnects AOAP."
4.5.1 The Google Automotive Link hierarchy
Google Automotive Link root CA X.509 v3, self-signed, RSA 2048, sha1WithRSA
C=US, ST=California, L=Mountain View, O=Google Automotive Link
valid 2014-06-06 .. 2044-06-05
│
├── head-unit certs (O=Android-Auto-Internal / JVC Kenwood / Google-Android-Reference)
│ long-lived — 2044-2048
│
└── mobile-device certs (O=CarService)
short-lived — rotated roughly yearly
Three byte-identical copies of the root CA exist in the corpus (aa-proxy-rs, aa-linux/certs/dev,
mretallack/app/src/main/assets), and it matches the CA embedded in the gearhead APK — independent
corroboration that this is the real trust anchor. The APK static analysis corroborates the metadata
first-hand: the root is embedded as a plaintext PEM literal in every build (v7.7 iwb, v8.2
ixl, v17.7 sbb/rze), byte-identical across versions — serial C14EE7A5A4544D42,
SHA-1 signature, validity 2014-06-06 → 2044-06-05, fingerprint starting 49:E5:2E:FC:13:AD:2E:D0….
4.5.2 ⚠ Every phone-side certificate in the corpus has expired
Verified with openssl x509 -checkend 0 across the whole tree on 2026-09-25:
| Certificate | Subject O= |
Expires | Status |
|---|---|---|---|
aa-proxy-rs/galroot_cert.pem (×3 copies) |
Google Automotive Link | 2044-06-05 | valid |
aa-proxy-rs/hu_cert.pem, AACS/AAClient/ssl/headunit.crt |
Android-Auto-Internal OU=01 | 2048-08-01 | valid |
HeadunitPad/Resources/Raw/cert |
Google-Android-Reference | 2044-07-07 | valid |
opencardev-aasdk/cert/headunit.crt |
JVC Kenwood OU=01 | 2045-04-29 | valid |
aa-proxy-rs/md_cert.pem |
CarService | 2026-07-22 | EXPIRED |
mretallack/carservice_cert.pem |
CarService | 2026-08-19 | EXPIRED |
AACS/android_auto.crt, aa-linux/certs/dev/server_cert.pem |
CarService OU=53 | 2022-08-24 | EXPIRED |
The split is systematic, and AACS issue #3 explains why: head units get long validity because their software is rarely updated, phones get short validity because the AA app updates constantly. The two most recent MD certificates lapsed in July and August 2026 — within two months of this revision date.
We are the phone. We must present a CarService-class identity, and no working public one
exists. This is the project's largest single risk, not a footnote.
Both aa-proxy-rs pairs have matching private keys (verified by comparing public-key digests), so
the expired GAL identity is immediately usable if the verifier's clock cooperates.
The APK's own embedded fallback leaves rotate on the same schedule (metadata only): v7.7
O=CarService, OU=56, serial 0344, expires 2022-11-16 → v8.2 OU=62, serial 0375, expires
2023-04-05 (both notBefore 2014-07-04) → v17.7 expires 2026-12-23 — an embedded CarService leaf
that is still valid at this revision date, though §4.5.5 explains why it is not directly usable.
⚠ This is not a new problem — it dates to the very first reverse-engineering effort. Mike Reid, in the same March 2015 XDA post that found the AOA identity strings (§1.2.1), reports extracting a Pioneer head unit's own certificate and private key from a leaked firmware image: "I'm laughing a bit because the Pioneer SSL/RSA certificates and private keys I extracted seem to match, and should make the crypto handshake possible, though I note one of the certs expires this summer." Real head-unit-vendor certificates going stale, and needing to be re-extracted or worked around, has been a fact of life for this protocol since day one — a decade before this project.
4.5.3 Is expiry actually enforced? [CONTESTED]
| Source | Claim | Basis |
|---|---|---|
mretallack decrypt_key_from_apk.md |
"head units don't check expiry" | their test hardware |
| AACS issue #3 | "Real headunits (Seat's LG-created headunit) DO enforce certificate validation" | Seat Ateca 2019; they rolled the HU clock back to cope |
| AACS issue #3 | OpenAuto checks neither root CA nor validity dates | source inspection |
[HUIG p.21] |
HU MUST terminate if it cannot verify the sender certificate | normative |
Resolution: CA-chain validation appears universally required — the certificate must chain to the GAL root. Expiry enforcement is head-unit-dependent. The DHU and OpenAuto check neither, which is exactly why a self-signed certificate appears to work right up until you try a real car.
The mirror direction is now confirmed first-hand: the phone enforces full PKIX — chain and expiry — against the car's client certificate (its trust store holds only the GAL root; standard PKIX validation, 7.7/8.2/17.7). The open question above is therefore strictly about the HU's treatment of the phone's certificate; do not read either answer as bidirectional. It also means a phone-side implementation that does validate the HU cert (as the real app does) needs a clock and a tolerance policy for HU certificates whose malformed UTCTimes (§4.4) leak past lenient parsers.
Note also [HUIG p.22]: clock skew surfaces as "Certificate not yet valid" or "Certificate
expired", and the HU MUST obtain correct time via GPS/NITZ/NTP. Clock rollback therefore works
against a conformant head unit's own obligations, and may be undone by the HU resynchronising.
4.5.4 Three provisioning routes
| Route | vs DHU / OpenAuto | vs real car | Durability |
|---|---|---|---|
| A. Self-signed MD certificate | works — neither validates | HU-dependent; fails wherever the chain is checked | indefinite |
| B. Expired GAL MD cert + roll back the HU clock | works (aa-linux ships certs/dev/faketime_shim.c, LD_PRELOADed by dhu.sh:74-80) |
AACS confirmed changing the date on a Seat Ateca 2019 | fragile — HUIG requires the HU to fix its clock |
| C. Extract a current cert + key from the gearhead APK | works | works | ~8 months, then repeat |
4.5.5 Route C — extracting the MD identity from the APK
Procedure from mretallack/tools/decrypt_key_from_apk.md. The APK embeds the CarService
certificate alongside an AES-256-CBC encrypted RSA private key.
# 1. Pull the APK from a REAL phone (see caveat below)
adb shell pm path com.google.android.projection.gearhead
adb pull /data/app/.../base.apk /tmp/aa.apk
# 2. Extract the DEX
unzip /tmp/aa.apk classes.dex -d /tmp/aa_dex
3. Locate four blobs. Search JADX or the raw DEX for -----BEGIN CERTIFICATE-----; the class
holding it also holds the key material. Obfuscated names change every release:
| Blob | v16.8 | v6.4 | Size |
|---|---|---|---|
| Salt (KDF parameter) | ivq field b |
SslWrapper field o |
256 bytes |
| Encrypted private key | ivq field c |
SslWrapper field p |
~1712 bytes |
| CarService certificate | PEM string in the same class | — | — |
| GAL root CA | PEM string | — | byte-identical across all versions |
4. Derive the key and decrypt. The seed is device-specific data from the app's shared
preferences mixed with the certificate PEM bytes, run through 7 rounds of a custom hash, then
used for AES/CBC/PKCS5Padding.
⚠ Three gotchas that will cost you a day each:
- The decryption must run on Android (
dalvikvm), not a desktop JVM. Android'sBase64.decode(data, 2)is lenient — it accepts+and/and ignores whitespace — where desktop Java decoders are strict and fail. - The APK must come from a real phone. Mirrors like APKPure cache older builds whose byte arrays do not match the class names above.
- JADX mis-decompiles the key loop, emitting
byte b = bArr2[i2] & 255;where the original isint b = bArr2[i2] & 255;. The narrowing conversion silently corrupts the output.
⚠ Measured caveat (APK static analysis, 7.7 / 8.2 / 17.7): the embedded fallback does not
decrypt. In all three builds the embedded AES-256-CBC blob does not decrypt with its own
embedded tables — treat the fallback material as a decoy or rotation artifact (analysis in
previous-work/markdown/web/aa-phone-key-extraction-research.md). Credential delivery on
production devices is Phenotype-first: six SenderlibCertFeature__* flags
(__backup_key_raw, __backup_key_verify, __backup_table, __backup_table_verify,
__p_table, __p_table_verify — present identically since 7.7, SHA-1 verify-before-use) carry
the real key material; the embedded blob is the last resort, and in these builds a broken one.
The fallback KDF itself, for the record: a 48-byte rolling state seeded from a 256-byte salt,
per-byte rol1 + 33 XOR salt[i%len] XOR input, inputs = CarService PEM bytes → GAL-root PEM
bytes → 7 self-rounds; key = state[0:32], IV = state[32:48]; plaintext recovered by
skip 28, take len−54, base64 URL_SAFE — closely matching, but not identical to, mretallack's
device-seeded description above. Route C is therefore era-dependent: verified working on the
6.4 / 16.8-era builds mretallack analysed, measured broken as a fallback on 7.7+. → Appendix B
(item 9).
The embedded certificate expires roughly 8 months after APK release, so this is a recurring maintenance task, not a one-off.
Distribution note. The two implementations that ship an AA identity took opposite positions:
MOTO-HUB .gitignores aa_cert and aa_identity_data as "maintained privately"
(.gitignore:15-16), while HeadunitPad commits its certificate and private key to a public repo.
Worth a deliberate decision rather than a default.
Phone-side note (this project). Start with route A against the DHU — it exercises the whole stack with zero PKI friction, and the DHU validates nothing. Move to route C before any real-car testing, and budget for periodic re-extraction — but treat route C as era-dependent (the ⚠ above): on 7.7+ builds the embedded fallback is a measured decoy, so budget for the Phenotype-delivery investigation too, not just re-extraction. Route B is useful for bench work but fights a conformant head unit's own clock-correction obligation. Whichever route, keep the identity behind a single swappable interface: it will change.
5. Service discovery
Once TLS is up, the phone asks the head unit what it can do. The HU's answer defines every channel for the rest of the session.
MD→HU 0x0005 SERVICE_DISCOVERY_REQUEST
HU→MD 0x0006 SERVICE_DISCOVERY_RESPONSE
HU→MD 0x001A SERVICE_DISCOVERY_UPDATE -- mid-session addition of a single service
5.1 ServiceDiscoveryRequest (MD→HU)
The phone introduces itself. GAL protos.proto:20-27:
message ServiceDiscoveryRequest {
optional bytes small_icon = 1; // PNG
optional bytes medium_icon = 2;
optional bytes large_icon = 3;
optional string label_text = 4;
optional string device_name = 5;
optional common.PhoneInfo phone_info = 6;
}
[CONTESTED] — fields 4 and 5 are named differently across lineages:
| Source | Field 4 | Field 5 | Field 6 |
|---|---|---|---|
GAL protos.proto:20-27 |
label_text |
device_name |
PhoneInfo |
| aasdk | device_name (required) |
device_brand (required) |
— |
| mrmees (APK 16.2) | device_name |
device_brand |
SessionInfo{session_uuid,…} |
Both readings put two short display strings in 4 and 5, so the wire bytes are compatible and the disagreement is about naming, not layout. aasdk predates the icon fields entirely. → Appendix B.
The APK static analysis (7.7–17.7, first-hand) lands on GAL's side of the naming and adds the
phone's actual field-5 content: 4 = an HU-facing label string; 5 = Build.MANUFACTURER + " " +
Build.MODEL — one combined string, not separate name/brand fields. Field 6 is
{1 = persistent random UUID} and is sent only when the car requested ≥ 1.6 (v7.7
itj.java:222) — the request-side twin of the §3.3 response gate. (17.7's own summary describes
field 6 as "4 strings"; the v7.7 reading is the concrete one — semantics open, Appendix B item 10.)
The icon fields (1–3) are car-launcher PNGs, not WiFi credentials. → Appendix B (item 10 tilts
to GAL).
5.2 ServiceDiscoveryResponse (HU→MD)
GAL protos.proto:29-46. Note how much of it is deprecated — fields 2–11 are the original
flat vehicle description, superseded by HeadUnitInfo at field 17:
message ServiceDiscoveryResponse {
repeated Service services = 1;
optional string make = 2 [deprecated = true];
optional string model = 3 [deprecated = true];
optional string year = 4 [deprecated = true];
optional string vehicle_id = 5 [deprecated = true];
optional DriverPosition driver_position = 6;
optional string head_unit_make = 7 [deprecated = true];
optional string head_unit_model = 8 [deprecated = true];
optional string head_unit_software_build = 9 [deprecated = true];
optional string head_unit_software_version = 10 [deprecated = true];
optional bool can_play_native_media_during_vr = 11 [deprecated = true];
optional int32 session_configuration = 13;
optional string display_name = 14;
optional bool probe_for_support = 15;
optional ConnectionConfiguration connection_configuration = 16;
optional common.HeadUnitInfo headunit_info = 17;
}
Field 6 [CONTESTED, resolved] — a type-level conflict, not a rename:
| Source | Field 6 |
|---|---|
| aasdk | bool left_hand_drive_vehicle |
GAL protos.proto:25 |
DriverPosition driver_position (enum) |
| mrmees (APK) | DriverPosition driver_position (enum) |
Resolve to the enum — two independent lineages against aasdk's one, and a 4-value enum
(LEFT=0, RIGHT=1, CENTER=2, UNKNOWN=3) degrades cleanly to a bool while the reverse does not.
Field 16 [CONTESTED] — GAL defines ConnectionConfiguration here; mrmees retracted this
field in 2026-07, attributing it to unrelated GoogleAuth data. GAL's definition is internally
coherent (it nests PingConfiguration and WirelessTcpConfiguration, both of which are referenced
elsewhere), so this document keeps it and flags the disagreement. → Appendix B.
session_configuration (13) is a bitmask — UI_CONFIG_HIDE_CLOCK=1,
UI_CONFIG_HIDE_PHONE_SIGNAL=2, UI_CONFIG_HIDE_BATTERY_LEVEL=4,
CAN_PLAY_NATIVE_MEDIA_DURING_VR=8 (protos.proto:1348-1353).
5.3 ConnectionConfiguration
message ConnectionConfiguration {
optional PingConfiguration ping_configuration = 1;
optional WirelessTcpConfiguration wireless_tcp_configuration = 2;
}
message PingConfiguration {
optional uint32 timeout_ms = 1;
optional uint32 interval_ms = 2;
optional uint32 high_latency_threshold_ms = 3;
optional uint32 tracked_ping_count = 4;
}
message WirelessTcpConfiguration {
optional uint32 socket_receive_buffer_size_kb = 1;
optional uint32 socket_send_buffer_size_kb = 2;
optional uint32 socket_read_timeout_ms = 3;
}
This resolves an internal contradiction in the mrmees corpus, which disagreed with itself over whether field 1 was a timeout or an interval and whether the units were ms or ns. GAL is unambiguous: field 1 is the timeout, field 2 the interval, both in milliseconds.
5.4 Service — the unit of discovery
message Service {
required int32 id = 1; // becomes the channel id
optional SensorSourceService sensor_source_service = 2;
optional MediaSinkService media_sink_service = 3;
optional InputSourceService input_source_service = 4;
optional MediaSourceService media_source_service = 5;
optional BluetoothService bluetooth_service = 6;
optional RadioService radio_service = 7;
optional NavigationStatusService navigation_status_service = 8;
optional MediaPlaybackStatusService media_playback_service = 9;
optional PhoneStatusService phone_status_service = 10;
optional MediaBrowserService media_browser_service = 11;
optional VendorExtensionService vendor_extension_service = 12;
optional GenericNotificationService generic_notification_service = 13;
optional WifiProjectionService wifi_projection_service = 14;
}
Service.id is the channel number used in the frame header for that service's traffic. Exactly
one of fields 2–14 is set, and which one identifies the service's kind. This is the mechanism
that makes channel IDs dynamic — see §6.1.
The service-type enum (v7.7 irn), carried per Service — the semantic identity a channel id
is validated against, distinct from the per-channel sub-messages:
| 0 | UNKNOWN |
5 | AUDIO_SINK_MEDIA |
10 | NAVIGATION_STATUS |
15 | RADIO |
|---|---|---|---|---|---|---|---|
| 1 | CONTROL |
6 | AUDIO_SOURCE (mic) |
11 | MEDIA_PLAYBACK_STATUS |
16 | VENDOR_EXTENSION |
| 2 | VIDEO_SINK |
7 | SENSOR_SOURCE |
12 | MEDIA_BROWSER |
17 | WIFI_PROJECTION |
| 3 | AUDIO_SINK_GUIDANCE |
8 | INPUT_SOURCE |
13 | PHONE_STATUS |
18 | WIFI_DISCOVERY |
| 4 | AUDIO_SINK_SYSTEM |
9 | BLUETOOTH |
14 | NOTIFICATION |
The wire channel id for control is 0, and the control endpoint carries service type 1; every other
channel id is assigned by the car in ServiceDiscoveryResponse and validated against this type —
corroborating §6.1 from inside the phone app. v17.7
adds service types 19 (car control / CarProperty), 20 (car local media), 21 (buffered
media playback) and 22 (car intent — NAVIGATE from the car), plus dynamically allocated proxy
endpoints for SDP entries it does not recognise.
⚠ [CONTESTED] — aasdk and mrmees call this message ChannelDescriptor with a channel_id field
and a different field ordering (their fields 12–18 disagree with each other and with GAL). Fields
1–6 and 8 are stable across every source; 7 and 12–18 are the least reliable region in the entire
corpus. Parse tolerantly: dispatch on whichever sub-message is present, never on field number
alone. → Appendix B.
5.5 Capability sub-messages
message SensorSourceService {
repeated Sensor sensors = 1; // message Sensor { required SensorType sensor_type = 1; }
optional uint32 location_characterization = 2; // bitmask, protos.proto:1355-1365
repeated FuelType supported_fuel_types = 3;
repeated EvConnectorType supported_ev_connector_types = 4;
}
message MediaSinkService { // HU can RECEIVE this stream
optional MediaCodecType available_type = 1 [default = MEDIA_CODEC_AUDIO_PCM];
optional AudioStreamType audio_type = 2;
repeated AudioConfiguration audio_configs = 3;
repeated VideoConfiguration video_configs = 4;
optional bool available_while_in_call = 5;
optional uint32 display_id = 6;
optional DisplayType display_type = 7;
optional KeyCode initial_content_keycode = 8;
}
message MediaSourceService { // HU can SEND this stream (microphone)
optional MediaCodecType available_type = 1 [default = MEDIA_CODEC_AUDIO_PCM];
optional AudioConfiguration audio_config = 2;
optional bool available_while_in_call = 3;
}
message AudioConfiguration {
required uint32 sampling_rate = 1;
required uint32 number_of_bits = 2;
required uint32 number_of_channels = 3;
}
message InputSourceService {
repeated int32 keycodes_supported = 1 [packed = true]; // raw Android keycodes
repeated TouchScreen touchscreen = 2; // { width=1, height=2, TouchScreenType type=3, bool is_secondary=4 }
repeated TouchPad touchpad = 3; // { width=1, height=2, ui_navigation=3, physical_width=4,
// physical_height=5, ui_absolute=6, tap_as_select=7, sensitivity=8 }
repeated FeedbackEvent feedback_events_supported = 4;
optional uint32 display_id = 5;
}
message BluetoothService {
required string car_address = 1;
repeated BluetoothPairingMethod supported_pairing_methods = 2 [packed = true];
}
message NavigationStatusService {
required int32 minimum_interval_ms = 1;
required InstrumentClusterType type = 2; // enum { IMAGE = 1; ENUM = 2; }
optional ImageOptions image_options = 3; // { height=1, width=2, colour_depth_bits=3 }
}
message VendorExtensionService {
required string service_name = 1;
repeated string package_white_list = 2;
optional bytes data = 3;
}
// MediaPlaybackStatusService, PhoneStatusService, MediaBrowserService,
// GenericNotificationService are all empty — presence alone declares the capability.
⚠ MediaSinkService.available_type defaults to MEDIA_CODEC_AUDIO_PCM (1) — an audio value,
even on a video channel. Do not infer stream kind from this field; infer it from whether
audio_configs or video_configs is populated.
BluetoothPairingMethod, first-hand (7.7–17.7): −1 UNAVAILABLE, 1 OOB, 2 NUMERIC_COMPARISON,
3 PASSKEY_ENTRY, 4 PIN — matching the Appendix C correction of aasdk, plus an UNAVAILABLE
sentinel below the range. The phone's pairing request sends {1 phone BT MAC, 2 method}; a car
address of the literal string SKIP_THIS_BLUETOOTH skips pairing — handy for bench testing.
Phone-side note (this project). For OSMAnd cast, advertise a minimal request (device name plus a label) and parse the response defensively — real head units populate deprecated fields, omit mandatory-looking ones, and use field numbers this document marks contested. Build the channel table from what actually arrives.
6. Channel architecture
AAP multiplexes every logical stream over one transport connection using the channel-id byte in the frame header.
6.1 Channel IDs are negotiated, not constant
This is the most important correction in this document. Nearly every source publishes a table of "the channel IDs", and every one of those tables is different — because each is that project's own convention, not a protocol constant.
The mechanism: the HU's ServiceDiscoveryResponse contains a list of Service entries, each with
its own id (§5.4). That id is the channel number. ChannelOpenRequest then references it:
message ChannelOpenRequest {
required sint32 priority = 1; // note: sint32, zigzag-encoded
required int32 service_id = 2;
}
Only channel 0 = control is fixed. It is hardcoded identically in every source and never appears
as a Service.id.
[CONTESTED] — six published tables, all different (the APK's service-type-keyed table below
makes six):
| Channel | aasdk ChannelId.hpp |
aa-linux constants.py:13-22 |
mretallack design.md:129-142 |
MOTO-HUB / HeadunitPad | mrmees channel-map.md |
|---|---|---|---|---|---|
| Control | 0 | 0 | 0 | 0 | 0 |
| Sensor | 1 | 2 | 2 | 1 | 7 |
| Video | 3 | 3 | 3 | 2 | 3 |
| Input | 8 | 1 | 1 | 3 | 1 |
| Media audio | 4 | 4 | 4 | 6 | 4 |
| Speech audio | 5 | 5 | 5 | 4 | 5 |
| System audio | 6 | 6 | 6 | 5 | 6 |
| Microphone | 9 | 7 | 7 | 7 | 8 |
| Bluetooth | 10 | 8 | 8 | 8 | 9 |
| Navigation | 12 | — | 9 | 10 | 10 |
| Wi-Fi | 18 | 14 | — | 13 | 17 |
The clinching argument. MOTO-HUB and HeadunitPad are both head units — the side that
originates ServiceDiscoveryResponse and therefore chooses these numbers. Neither contains
any code path that reads a peer-assigned channel id, because neither ever receives one. Their tables
are HU conventions by construction. mrmees states the rule outright: "Channel IDs in
ChannelDescriptor are phone-assigned after the HU sends them, not fixed protocol constants… Do not
hardcode channel ID assumptions."
One further corroboration from inside the phone app (7.7–17.7): its endpoint table is keyed on service type (§5.4's enum) — video sink 2, guidance 3, system 4, media 5, mic 6, sensors 7, input 8, Bluetooth 9, nav status 10, … WiFi projection 17 — with channel ids assigned by the car and validated against that type. That is yet another table, and its numbers match yet another convention: more evidence that only channel 0 is a protocol constant and everything else is per-session state.
Phone-side note (this project). Build the channel map at runtime from
ServiceDiscoveryResponse, keyed on which capability sub-message is present. Any constant namedCHANNEL_VIDEO = 3in our code is a bug waiting for a head unit that numbers things differently.
6.2 Channel lifecycle
HU→MD SERVICE_DISCOVERY_RESPONSE services, each with an id and a kind
MD→HU CHANNEL_OPEN_REQUEST (0x0007) on the target channel, CONTROL bit set
HU→MD CHANNEL_OPEN_RESPONSE (0x0008) status 0 = OK
... channel-specific setup (§7, §8, §9, §10) ...
either CHANNEL_CLOSE_NOTIFICATION (0x0009)
⚠ CHANNEL_OPEN_REQUEST is framed on the channel being opened, not on channel 0 — see
§2.6.
The phone sends all CHANNEL_OPEN_REQUESTs back-to-back in the car's service-list order — no
inter-channel wait (7.7–17.7) — and media endpoints send their SETUP message immediately on a
successful open. CHANNEL_CLOSE_NOTIFICATION from either side is acknowledged by the peer with an
empty msg 9; a channel-control message that arrives out of state is answered with msg 255
(MESSAGE_UNEXPECTED_MESSAGE). Whether real HUs require strictly sequential opens is untested
→ Appendix B (item 22).
6.3 Control channel messages
ControlMessageType, GAL protos.proto:1304-1332 [CONFIRMED ×3] — identical in the GAL proto,
milek7's dissector table, and MOTO-HUB's generated Control.java:
| ID | Name | Direction | Body |
|---|---|---|---|
| 1 | MESSAGE_VERSION_REQUEST |
HU→MD | raw u16 pair (§3) |
| 2 | MESSAGE_VERSION_RESPONSE |
MD→HU | raw u16 triple (§3) |
| 3 | MESSAGE_ENCAPSULATED_SSL |
both | raw TLS bytes |
| 4 | MESSAGE_AUTH_COMPLETE ⚠ |
MD→HU (⚠) | AuthResponse{status=1} |
| 5 | MESSAGE_SERVICE_DISCOVERY_REQUEST |
MD→HU | §5.1 |
| 6 | MESSAGE_SERVICE_DISCOVERY_RESPONSE |
HU→MD | §5.2 |
| 7 | MESSAGE_CHANNEL_OPEN_REQUEST |
MD→HU | ChannelOpenRequest |
| 8 | MESSAGE_CHANNEL_OPEN_RESPONSE |
HU→MD | status |
| 9 | MESSAGE_CHANNEL_CLOSE_NOTIFICATION |
both | — |
| 10 | (absent — reserved/unused) | ||
| 11 | MESSAGE_PING_REQUEST |
both | PingRequest{timestamp=1,…} |
| 12 | MESSAGE_PING_RESPONSE |
both | PingResponse{timestamp=1,…} |
| 13 | MESSAGE_NAV_FOCUS_REQUEST |
MD→HU | NavFocusType |
| 14 | MESSAGE_NAV_FOCUS_NOTIFICATION |
HU→MD | NavFocusType |
| 15 | MESSAGE_BYEBYE_REQUEST |
both | ByeByeReason |
| 16 | MESSAGE_BYEBYE_RESPONSE |
both | — |
| 17 | MESSAGE_VOICE_SESSION_NOTIFICATION |
MD→HU | VoiceSessionStatus |
| 18 | MESSAGE_AUDIO_FOCUS_REQUEST |
MD→HU | AudioFocusRequestType |
| 19 | MESSAGE_AUDIO_FOCUS_NOTIFICATION |
HU→MD | AudioFocusStateType |
| 20 | MESSAGE_CAR_CONNECTED_DEVICES_REQUEST |
MD→HU | — |
| 21 | MESSAGE_CAR_CONNECTED_DEVICES_RESPONSE |
HU→MD | — |
| 22 | MESSAGE_USER_SWITCH_REQUEST |
both | — |
| 23 | MESSAGE_BATTERY_STATUS_NOTIFICATION |
MD→HU | — |
| 24 | MESSAGE_CALL_AVAILABILITY_STATUS |
HU→MD | {bool call_available=1} |
| 25 | MESSAGE_USER_SWITCH_RESPONSE |
both | UserSwitchStatus |
| 26 | MESSAGE_SERVICE_DISCOVERY_UPDATE |
HU→MD | ServiceDiscoveryUpdate{Service=1} |
| 255 | MESSAGE_UNEXPECTED_MESSAGE |
both | — |
| 65535 | MESSAGE_FRAMING_ERROR |
both | — |
⚠ AUTH_COMPLETE (4) direction [CONTESTED] — the corpus tables here and in Appendix A label
it MD→HU, but three independent observations all say HU→MD: aasdk's head-unit code is the sender
(ControlServiceChannel::sendAuthComplete, ControlServiceChannel.cpp:65); aa-linux's phone
code is the receiver (AUTH_RECV = 4, protocol.py:490); and the 7.7–17.7 APK's phone enables
encryption on receipt of msg 4, distinguishing CERT_EXPIRED (−24) from
CERT_NOT_YET_VALID (−23) in its payload — i.e. the car reports its own phone-certificate
verdict in it. No source in hand positively shows a phone sending it. Kept unpicked per this
document's policy. → Appendix B (item 20).
⚠ Ping messages (11/12) stay plaintext, bypassing TLS even after encryption is established
(mrmees 02-version-ssl-auth.md). Ping timestamps are microseconds since the UNIX epoch. The APK
corroborates the plaintext rule from the other side — v17.7's pre-auth whitelist (§4.2) admits
11/12 alongside the handshake types — and adds the payloads: PING_REQUEST carries {1 fixed64
timestamp, 2 bool (added v8.2+), 3 bytes}; PING_RESPONSE echoes {1, 2}. Car→phone pings are
always answered. Phone→car active probing exists only for wireless sessions: a
self-rescheduling handler loop, interval WirelessLatencyMonitor__probe_interval_ms default
200 ms, max 10 in flight, warning threshold 300 ms, stats every 5000 ms — and no keepalive
kill on missed pongs (logging only). v17.7 adds HU-side ping-timeout detection flags
(FrameworkGalFeature__detect_hu_gal_ping_timeout, __hu_gal_ping_timeout_delta_ms) and the
server-pushed ping configuration of §3.3/§5.3.
Enum payloads, first-hand (7.7–17.7; name table ilo.java:113-196 in v7.7): ByeByeReason
1 USER_SELECTION, 2 DEVICE_SWITCH, 3 NOT_SUPPORTED, 4 NOT_CURRENTLY_SUPPORTED,
5 PROBE_SUPPORTED — a car-initiated ByeBye makes the phone reply 16 and tear down;
DEVICE_SWITCH triggers handoff instead (the peer cert's DN/serial/validity are serialized into
the handoff Bundle, itj.java:276-311); the phone delays its own ByeBye 200 ms. NavFocusType
1 NATIVE, 2 PROJECTED. AUDIO_FOCUS_NOTIFICATION carries {1 focus state 0..7, 2 unsolicited
bool}. Msg 17 (VOICE_SESSION_NOTIFICATION) is send-only — absent from the v7.7 receive
table, actively sent by v17.7. Messages 20/21 and 22/25 are named but unhandled in 7.7/8.2.
Battery status (23) carries {1, 2 int, 3 bool charging}.
⚠ aasdk's enum stops at 0x13 (19) and omits CHANNEL_CLOSE_NOTIFICATION (9) entirely. It also
names 15/16 SHUTDOWN_* where every other source says BYEBYE_* — same wire IDs.
6.4 Per-channel message IDs
Each channel has its own type space. Control messages are >= 0x8000; bulk data is < 0x8000.
All from GAL protos.proto:1586-1783.
MediaMessageId (A/V channels) SensorMessageId
0x0000 DATA 0x8001 REQUEST
0x0001 CODEC_CONFIG 0x8002 RESPONSE
0x8000 SETUP 0x8003 BATCH
0x8001 START 0x8004 ERROR
0x8002 STOP
0x8003 CONFIG InputMessageId
0x8004 ACK 0x8001 INPUT_REPORT
0x8005 MICROPHONE_REQUEST 0x8002 KEY_BINDING_REQUEST
0x8006 MICROPHONE_RESPONSE 0x8003 KEY_BINDING_RESPONSE
0x8007 VIDEO_FOCUS_REQUEST 0x8004 INPUT_FEEDBACK
0x8008 VIDEO_FOCUS_NOTIFICATION
0x8009 UPDATE_UI_CONFIG_REQUEST BluetoothMessageId
0x800A UPDATE_UI_CONFIG_REPLY 0x8001 PAIRING_REQUEST
0x800B AUDIO_UNDERFLOW_NOTIFICATION 0x8002 PAIRING_RESPONSE
0x8003 AUTHENTICATION_DATA
NavigationStatusMessageId 0x8004 AUTHENTICATION_RESULT
0x8001 INSTRUMENT_CLUSTER_START
0x8002 INSTRUMENT_CLUSTER_STOP MediaPlaybackStatusMessageId
0x8003 NAVIGATION_STATUS 0x8001 STATUS
0x8004 TURN_EVENT [deprecated] 0x8002 INPUT
0x8005 DISTANCE_EVENT [deprecated] 0x8003 METADATA
0x8006 NAVIGATION_STATE
0x8007 CURRENT_POSITION WifiProjectionMessageId
0x8001 CREDENTIALS_REQUEST
PhoneStatusMessageId 0x8002 CREDENTIALS_RESPONSE
0x8001 STATUS
0x8002 INPUT GenericNotificationMessageId
0x8001 SUBSCRIBE 0x8003 MESSAGE
MediaBrowserMessageId 0x8002 UNSUBSCRIBE 0x8004 ACK
0x8001 ROOT_NODE … 0x8006 BROWSE_INPUT
A/V message IDs [CONTESTED, mostly reconcilable]
The two major lineages agree on the numbers and differ on names — these are aliases, not conflicts:
| ID | GAL / milek7 | aasdk | Same message? |
|---|---|---|---|
| 0x8000 | SETUP |
SETUP_REQUEST |
yes |
| 0x8001 | START |
START_INDICATION |
yes |
| 0x8002 | STOP |
STOP_INDICATION |
yes |
| 0x8003 | CONFIG |
SETUP_RESPONSE |
yes — the HU's reply to SETUP |
| 0x8004 | ACK |
AV_MEDIA_ACK_INDICATION |
yes |
| 0x8005 | MICROPHONE_REQUEST |
AV_INPUT_OPEN_REQUEST |
yes — open the mic |
| 0x8006 | MICROPHONE_RESPONSE |
AV_INPUT_OPEN_RESPONSE |
yes |
| 0x8007 | VIDEO_FOCUS_REQUEST |
VIDEO_FOCUS_REQUEST |
yes |
| 0x8008 | VIDEO_FOCUS_NOTIFICATION |
VIDEO_FOCUS_INDICATION |
yes |
Genuine disagreements remained above 0x8008, where mrmees retracted an earlier
VideoFocusNotification@0x8009 reading, cascading a one-slot shift through MediaStats and
MediaOptions. Resolved by the APK static analysis: 7.7, 8.2 and 17.7 all dispatch
0x8009 UPDATE_UI_CONFIG_REQUEST (car→phone; the phone must not apply a theme pushed from this
direction), 0x800A UPDATE_UI_CONFIG_REPLY (phone→car) and 0x800B AUDIO_UNDERFLOW_NOTIFICATION
(car→phone, stats only) — GAL's reading, exactly as listed above. → Appendix B (item 15 resolved).
⚠ aa-linux/constants.py defines AV_BINDING_REQUEST = 0x8002 and AV_STOP_INDICATION = 0x8005,
contradicting both lineages. It interoperates only because it never sends either. Do not copy.
⚠ milek7's dissector table omits 0x8007 and mislabels 0x8008 as the request. Trust the enum.
⚠ Vendor extension (16) is a raw pipe. Its endpoint overrides message dispatch entirely: a raw
byte stream with no 2-byte message-type header, both directions — the only channel whose
frames are not [type][protobuf]. (v7.7 iwh; corroborated across 8.2 and 17.7 — see the endpoint
table in Appendix D.) The mic channel's car→phone DATA arrives as media message 1 — not the
0x0000 DATA id used on sink channels — see §8.4.
6.5 Status codes
MessageStatus (GAL protos.proto:1784+) is shared across nearly every response message in the
protocol. SUCCESS = 0, UNSOLICITED_MESSAGE = 1, and everything else is negative:
| Value | Name | Value | Name |
|---|---|---|---|
| -1 | NO_COMPATIBLE_VERSION |
-9 | INVALID_SENSOR |
| -2 | CERTIFICATE_ERROR |
-10…-17 | Bluetooth pairing / HFP errors |
| -3 | AUTHENTICATION_FAILURE |
-18 | KEYCODE_NOT_BOUND |
| -4 | INVALID_SERVICE |
-19 | RADIO_INVALID_STATION |
| -5 | INVALID_CHANNEL |
-20 | INVALID_INPUT |
| -6 | INVALID_PRIORITY |
-21, -22 | radio preset / comm errors |
| -7 | INTERNAL_ERROR |
-23 | AUTHENTICATION_FAILURE_CERT_NOT_YET_VALID |
| -8 | MEDIA_CONFIG_MISMATCH |
-24 | AUTHENTICATION_FAILURE_CERT_EXPIRED |
⚠ Two consequences. In protobuf, negative int32 enum values encode as 10-byte varints — they
are not compact. And where a status is carried as a raw u16 instead (the version response, §3.1),
-1 appears on the wire as 0xFFFF.
⚠ aasdk collapses this entire taxonomy to enum Status { OK=0; FAIL=1; }. Any implementation built
on aasdk cannot distinguish "certificate expired" from "invalid channel" — relevant when debugging
against a real car, where -23/-24 are exactly the errors you expect (§4.5).
7. Video stream
One H.264 elementary stream, MD→HU, on a channel whose Service carried a MediaSinkService with
video_configs populated.
7.1 Normative requirements
[HUIG p.8] — head-unit decoder requirements:
"H.264/AVC Baseline Profile hardware decoder · H.264 BP level 3.1 REQUIRED for both sides to guarantee 800x480 · H.264 BP level 3.2 REQUIRED for 720p · H.264 BP level 4.2 REQUIRED for 1080p · Max frame rate of 30 FPS or 60 FPS · Any AAP HU integration MUST support 480p at 30 FPS as the minimum default."
Frame rate may be continuous or non-continuous between the maximum and 5 FPS. [HUIG p.29-30]:
- Format: H.264 elementary byte stream, Baseline Profile.
- Only I-frames and P-frames. Buffering set to minimum; the HU SHOULD decode each frame without waiting for additional frames.
- Maximum bitrates: 800×480 → 4000 kbit/s · 1280×720 → 6000 kbit/s · 1920×1080 → 8000 kbit/s.
7.2 Resolution negotiation
[HUIG p.30] — four steps, and note the HU proposes while the MD decides:
- During service discovery the MD reads the
VideoConfigurationlist the HU supports. - After the channel opens, the HU sends
CONFIGwith a prioritised list of indices into that list, most preferred first. - The MD picks one, weighing HU preference against its own encoder capability.
- The MD sends
STARTcarrying the chosenconfiguration_index.
[HUIG p.30]: "The HU SHOULD request the highest resolution video that the protocol supports."
message VideoConfiguration {
optional VideoCodecResolutionType codec_resolution = 1;
optional VideoFrameRateType frame_rate = 2;
optional uint32 width_margin = 3;
optional uint32 height_margin = 4;
optional uint32 density = 5; // Android density bucket
optional uint32 decoder_additional_depth = 6; // extra decoder buffer frames
optional uint32 viewing_distance = 7; // mm; VW MIB3 sends 900, DHU 500
optional uint32 pixel_aspect_ratio_e4 = 8; // ×10000; 10000 = square pixels
optional uint32 real_density = 9; // true DPI before bucket quantisation
optional MediaCodecType video_codec_type = 10;
optional UiConfig ui_config = 11; // margins, content insets, UiTheme
}
VideoCodecResolutionType [CONFIRMED ×3] (protos.proto:1422-1433):
| Value | Resolution | Value | Resolution | |
|---|---|---|---|---|
| 1 | 800×480 | 6 | 720×1280 (portrait) | |
| 2 | 1280×720 | 7 | 1080×1920 (portrait) | |
| 3 | 1920×1080 | 8 | 1440×2560 (portrait) | |
| 4 | 2560×1440 | 9 | 2160×3840 (portrait) | |
| 5 | 3840×2160 |
VideoFrameRateType: VIDEO_FPS_60 = 1, VIDEO_FPS_30 = 2 [CONFIRMED ×3].
MediaCodecType (protos.proto:1439-1447):
| 1 | AUDIO_PCM |
2 | AUDIO_AAC_LC |
3 | VIDEO_H264_BP |
4 | AUDIO_AAC_LC_ADTS |
|---|---|---|---|---|---|---|---|
| 5 | VIDEO_VP9 |
6 | VIDEO_AV1 |
7 | VIDEO_H265 |
⚠ aasdk's VideoResolution enum is wrong for values ≥ 5 — it defines only three values and its
names for 5–9 do not correspond to the table above. Anything built on aasdk's names will advertise
the wrong resolution.
⚠ VideoConfiguration.video_codec_type inherits MediaSinkService.available_type's default of
AUDIO_PCM (1). An unset or unrecognised codec falls back to H.264 phone-side.
⚠ Field 6 semantics [CONTESTED, minor] — GAL names it decoder_additional_depth; the 7.7
APK's parameter set reads it as a layout param, default 4. Same field number and wire position,
differing semantics. Treat as opaque unless the HU documents it. → Appendix B (item 24).
7.3 Channel setup sequence
MD→HU 0x8000 SETUP Setup { MediaCodecType type = 1 } -- e.g. VIDEO_H264_BP (3)
HU→MD 0x8003 CONFIG Config { Status status = 1, -- STATUS_WAIT=1, STATUS_READY=2
uint32 max_unacked = 2,
repeated uint32 configuration_indices = 3 }
MD→HU 0x8007 VIDEO_FOCUS_REQUEST -- resolved MD→HU, see below
HU→MD 0x8008 VIDEO_FOCUS_NOTIFICATION
MD→HU 0x8001 START Start { int32 session_id = 1, uint32 configuration_index = 2 }
MD→HU 0x0001 CODEC_CONFIG -- SPS/PPS
MD→HU 0x0000 DATA … -- frames
HU→MD 0x8004 ACK Ack { int32 session_id = 1, uint32 ack = 2,
repeated uint64 receive_timestamp_ns = 3 }
MD→HU 0x8002 STOP
⚠ Video focus must be granted before frames are sent.
message VideoFocusRequestNotification {
optional int32 disp_channel_id = 1 [deprecated = true];
optional VideoFocusMode mode = 2;
optional VideoFocusReason reason = 3;
}
message VideoFocusNotification {
optional VideoFocusMode focus = 1;
optional bool unsolicited = 2;
}
VideoFocusMode: PROJECTED=1, NATIVE=2, NATIVE_TRANSIENT=3, PROJECTED_NO_INPUT_FOCUS=4.
VideoFocusReason: UNKNOWN=0, PHONE_SCREEN_OFF=1, LAUNCH_NATIVE=2.
A focus loss is not an instantaneous teardown: the phone arms a 15000 ms video-focus-loss timer before reacting (observed in all three builds).
⚠ aasdk models focus as binary FOCUSED/UNFOCUSED — wrong; the real protocol has four states,
and PROJECTED_NO_INPUT_FOCUS (projection visible but input routed to the native UI) has no
representation in the binary model.
Direction, resolved. channel-map.md's summary table lists VideoFocusRequest as HU→MD, but
mrmees's own per-channel documentation is explicit and unambiguous the other way — video.md
states outright: "VideoFocusRequest (0x8007, Phone → HU)... The phone is the initiator of
focus transitions... and the HU responds with the resulting state (via
VideoFocusIndication)." 04-channel-lifecycle.md agrees. Treat channel-map.md's summary row as
the error — it is a table transposition, not a second data point. MD→HU is correct; the message
name itself (…RequestNotification, sent by the side wanting focus) is consistent with this.
7.4 Media frame payload
DATA (0x0000) [u64be timestamp, milliseconds][H.264 Annex-B NAL units]
CODEC_CONFIG (0x0001) [codec configuration bytes] -- NO timestamp
The 8-byte big-endian timestamp on DATA frames only is the corpus's strongest independent
cross-corroboration. MOTO-HUB derives it as a "2- or 10-byte header" counting the message type
(AaAudioTap.kt:55-57, AapVideo.kt:5-6); HeadunitPad, written separately in Swift against live
traffic, checks for an Annex-B start code at offset 8 before falling back
(AapTransport.swift:1941-1942); aa-linux/video.py:593-598 builds it as
struct.pack(">Q", timestamp_ms) + h264.
⚠ CODEC_CONFIG format is ambiguous in the wild [CONTESTED]. [HUIG p.29] says the stream is
an elementary byte stream (Annex-B). But aa-linux sends an AVCC/avcC record as its codec
config, and HeadunitPad carries an explicit normalisation fallback for length-prefixed config it has
observed, while MOTO-HUB assumes pure Annex-B unconditionally. Accept both: sniff for the
Annex-B start code (00 00 01 / 00 00 00 01) and treat anything else as an avcC record.
→ Appendix B.
⚠ Never drop a fragment carrying NAL type 7 (SPS) or 8 (PPS) — HeadunitPad records this as a hard-won bug: losing them makes every subsequent frame undecodable.
7.5 Flow control
Config.max_unacked sets the window; the sender decrements on each frame and replenishes on
ACK (0x8004). Values are pure implementation choice and span an order of magnitude:
| Implementation | max_unacked |
|---|---|
aa-linux |
3 |
| mrmees (observed) | ~10 video, ~1 audio |
| MOTO-HUB | 12 video / 16 other |
| HeadunitPad | 30, all channels |
From protocol version 5.0 onward an "ackless audio" mode exists (§3.3) in which per-buffer acks are not sent at all.
The APK adds the hard limits: more than 400 outstanding unacked frames ⇒ fatal
CAR_NOT_RESPONDING (all three builds); session ids start at 1, increment per START, and are
compared mod 256. Pair those with a conservative max_unacked — the table above spans an order
of magnitude for a reason.
Phone-side note (this project). v1 advertises exactly one configuration —
VIDEO_1280x720+VIDEO_FPS_30,VIDEO_H264_BP— encoded with MediaCodec, and defers H.265. Prepend SPS/PPS to every keyframe so each message is self-contained; three codebases arrived at this independently. Cap the bitrate at HUIG's 6000 kbit/s for 720p —aa-linux's 25 Mbps default is four times over the normative limit and will not behave on real hardware. (Note §7.6: the real app itself exceeds HUIG here — 12 Mbps for 720p over USB — so HUIG's ceiling is the safe choice, not the only working one.)
7.6 Observed phone-side encoder parameters (APK 7.7 / 8.2 / 17.7)
The real app's H.264 encoder: Baseline profile, surface color-format (2130708361), level selected from the larger dimension — 1280 → L3.1, 1920 → L3.2/4.0, 2560 → L5.1, 3840 → L5.1/5.2 — with 60 fps doubling the bitrate. Note the tension with §7.1: the observed levels for 1080p (L3.2/4.0) sit below HUIG's normative BP 4.2 requirement, and the observed bitrate defaults exceed HUIG's 2016 maxima outright — the app ships 1080p at 16 Mbit/s (USB) / 3 M HEVC / 5 M WiFi / 1.5 M WiFi-HEVC; 720p at 12/2/4/0.7 M; 480p at 8/1/3/0.3 M, with 2560 → 1.5× the 1080p rate and 3840 → 2×, I-frame interval 60, and wireless QP clamped 15–30 (USB unclamped) — all Phenotype-tunable (§11). HUIG's numbers describe 2016 head units, not current behaviour: a conservative implementation stays within HUIG, a byte-compatible one treats those maxima as long-since abandoned by the phone side.
Other gates observed: 2.4 GHz WiFi links block >720p unless developer-whitelisted; and the 7.7/8.2 encoders actually support only H264_BP and H265 — the codec enum's other video values (VP9, AV1) exist in the enum, not in the encoders.
8. Audio streams
8.1 Codecs — PCM is the requirement, not AAC
[NORMATIVE] [HUIG p.38]. This table contradicts the common belief that AAP audio is AAC-LC:
| Stream | Direction | Transport | Codec | Format |
|---|---|---|---|---|
| UI | MD→HU | AAP | PCM (REQUIRED) + AAC-LC (OPTIONAL) | 16-bit, 16 kHz, mono |
| Guidance | MD→HU | AAP | PCM | 16-bit, 16 kHz, mono |
| Voice | MD→HU | AAP | PCM (REQUIRED) + AAC-LC (OPTIONAL) | 16-bit, 16 kHz, mono |
| Media | MD→HU | AAP | PCM (REQUIRED) + AAC-LC (OPTIONAL) | 16-bit, 48 kHz, stereo |
| Microphone | HU→MD | AAP | PCM | 16-bit, ≥16 kHz, mono |
| Legacy in-call | both | Bluetooth SCO | HFP 1.5 (CVSD) | — |
Three independent implementations are PCM-only and never advertise AAC-LC: MOTO-HUB, HeadunitPad, and aasdk-based head units. Their configurations match exactly — 48000/16/2 for media, 16000/16/1 for speech, system, and microphone.
[HUIG p.38 fn.4] on the optional AAC path: "AAC-lc format is: RAW AAC frame without header,
decoded buffer size in one frame: 1024 samples for 16 kHz, 2048 samples for 48 kHz." So AAC frames
are raw access units, not ADTS — aa-linux transcodes to ADTS then strips the headers before
sending.
⚠ There is no Opus support. Audio is strictly PCM or AAC-LC.
⚠ Phone-call audio does not ride AAP. It uses Bluetooth HFP/SCO — the TELEPHONY stream type
exists for routing and mixing decisions, not for carrying call audio.
Observed phone-side acceptance (7.7–17.7): the phone accepts only 48000 or 16000 Hz, 16-bit,
1 or 2 channels from the car — no 8000, no 44100. The media channel requires 48 kHz stereo;
guidance and system prefer 48 kHz mono and fall back to 16 kHz mono (a dev preference can force
16 kHz; TELEPHONY is unsupported on GAL in these builds). Note the drift from HUIG's table
above, which specifies 16 kHz for UI/guidance/voice: the phone side moved to 48 kHz in newer
builds while the wire format stayed identical. PCM buffers: 2048 samples per message at
48/44.1 kHz, 1024 at 16 kHz, 16-bit, doubled for stereo. The AAC encoder, when used:
audio/mp4a-latm, LC profile, bitrate min(rate × channels × 8, 512000) — raw access units,
per the footnote above.
8.2 Stream roles
Each audio channel is one MediaSinkService distinguished by audio_type.
AudioStreamType [CONTESTED] — four numberings, only MEDIA = 3 agreed by all:
| Source | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
GAL protos.proto:1449 |
— | GUIDANCE |
SYSTEM_AUDIO |
MEDIA |
TELEPHONY |
|||
aasdk AudioType |
NONE |
SPEECH |
SYSTEM |
MEDIA |
ALARM |
|||
MOTO-HUB Media.java:408 |
NONE |
SPEECH |
SYSTEM |
MEDIA |
ALARM |
GUIDANCE |
ANNOUNCEMENT |
RING |
| mrmees (APK) | TELEPHONY |
SYSTEM_AUDIO |
— | MEDIA |
— | GUIDANCE |
MOTO-HUB and mrmees both place GUIDANCE at 5, partially bridging the headunit lineage and the APK
reading — but GAL puts it at 1. The 7.7–17.7 APK static analysis now corroborates GAL's
numbering from inside the phone: its audio-channel mapping is built on audioStreamType
1=guidance, 2=system, 3=media. Tilt: GAL + APK against MOTO-HUB + mrmees — still not closed, since
the headunit lineage is field-tested on its own convention and both cannot be right for the same
bytes. → Appendix B (item 18).
8.3 Setup and payload
Identical to video (§7.3) — SETUP/CONFIG/START/DATA/ACK — with
Setup.type = MEDIA_CODEC_AUDIO_PCM (1) or AUDIO_AAC_LC (2), and AudioConfiguration in place of
VideoConfiguration.
⚠ Audio focus is requested on the control channel, not the audio channel — 0x0012
AUDIO_FOCUS_REQUEST / 0x0013 AUDIO_FOCUS_NOTIFICATION (§6.3).
message AudioFocusRequestNotification { required AudioFocusRequestType request = 1; }
message AudioFocusNotification { required AudioFocusStateType focus_state = 1;
optional bool unsolicited = 2; }
AudioFocusRequestType: GAIN=1, GAIN_TRANSIENT=2, GAIN_TRANSIENT_MAY_DUCK=3, RELEASE=4.
⚠ aasdk names value 3 GAIN_NAVI; it is the same wire value — the Android-mirrored name
GAIN_TRANSIENT_MAY_DUCK is the accurate one.
AudioFocusStateType: INVALID=0, GAIN=1, GAIN_TRANSIENT=2, LOSS=3, LOSS_TRANSIENT_CAN_DUCK=4,
LOSS_TRANSIENT=5, GAIN_MEDIA_ONLY=6, GAIN_TRANSIENT_GUIDANCE_ONLY=7.
⚠ Payload offset ambiguity [CONTESTED] — field-observed
HeadunitPad found two variants in the wild and had to sniff between them
(AapTransport.swift:1990-2002):
variant 1: [8-byte timestamp][PCM]
variant 2: [2-byte media msg type][8-byte timestamp][PCM]
Its comment: "If we always assume one variant, the other produces severe static." MOTO-HUB uses a fixed offset keyed on the message type and may be over-simplified. A robust receiver should heuristically check whether the leading two bytes parse as a known media message type. → Appendix B.
8.4 Microphone (HU→MD)
message MicrophoneRequest { required bool open = 1; optional bool anc_enabled = 2;
optional bool ec_enabled = 3; optional int32 max_unacked = 4; }
message MicrophoneResponse { required int32 status = 1; optional int32 session_id = 2; }
[HUIG p.73] is unusually prescriptive: PCM, 16 kHz, 16-bit, mono, buffer size 2048 (frames MUST be
multiples of it); noise reduction MUST be disabled for single-mic systems and automatic gain
control MUST be disabled — Google's speech recognition does its own processing and double-processing
degrades accuracy. The APK obeys: its MICROPHONE_REQUEST sends fields 2/3 (ANC/EC) as false
(7.7–17.7). The phone opens the mic with 0x8005, acks the car's frames with 0x8004, and waits on a
5000 ms semaphore for the mic config. Observed quirk worth a capture check: the car's mic DATA
arrives as media message 1 — not the 0x0000 DATA id used on sink channels — with the same
8-byte timestamp prefix (§6.4).
Phone-side note (this project). OSMAnd cast needs the guidance stream (16 kHz mono PCM) and media (48 kHz stereo). Implement PCM first — it is the requirement, the simplest path, and what every other implementation actually ships. Microphone is not needed for v1.
9. Input channel
HU→MD. All events arrive in one InputReport on message 0x8001.
message InputReport {
required uint64 timestamp = 1; // microseconds (elapsedRealtime)
optional int32 disp_channel_id = 2 [deprecated = true];
optional TouchEvent touch_event = 3;
optional KeyEvent key_event = 4;
optional AbsoluteEvent absolute_event = 5;
optional RelativeEvent relative_event = 6;
optional TouchEvent touchpad_event = 7; // same type as field 3, different surface
}
⚠ Field 2 resolves a corpus conflict. aasdk has disp_channel at field 2; mrmees says field 2
"does not exist". GAL shows it deprecated — so it existed, was retired, and newer APKs no longer
emit it. Both sources were right about different eras. Skip it on read; never write it.
9.1 Touch
message TouchEvent {
repeated Pointer pointer_data = 1; // { uint32 x = 1; uint32 y = 2; uint32 pointer_id = 3; }
optional uint32 action_index = 2; // which pointer this action refers to
optional PointerAction action = 3;
}
PointerAction — these are Android MotionEvent constants:
| 0 | ACTION_DOWN |
1 | ACTION_UP |
2 | ACTION_MOVED |
|---|---|---|---|---|---|
| 3 | CANCEL |
4 | OUTSIDE |
5 | ACTION_POINTER_DOWN |
| 6 | ACTION_POINTER_UP |
Values 3 and 4 are absent from the GAL enum but present in the headunit lineage
(Input.java:1828-1895), which completes the set.
⚠ Coordinates are absolute pixels in the negotiated video resolution — not normalised, not
scaled. There is no pressure field; aa-linux notes the phone synthesises a constant 0.8.
Touchscreen and touchpad share the message type, distinguished only by whether it arrives in field 3 or field 7.
9.2 Keys
message KeyEvent {
repeated Key keys = 1;
// message Key { uint32 keycode = 1; bool down = 2; uint32 metastate = 3; bool longpress = 4; }
}
⚠ keycode carries raw Android KeyEvent keycodes, not a closed protocol enum. ButtonCodeEnum
in the various protos is a convenience listing, not an exhaustive type. Standard Android values apply
(KEYCODE_HOME=3, KEYCODE_BACK=4, KEYCODE_DPAD_UP=19, KEYCODE_MEDIA_PLAY_PAUSE=85,
KEYCODE_VOLUME_UP=24, …), plus an AA-specific block from 65536 upward:
| 65536 | SCROLL_WHEEL / rotary controller |
65537 | MEDIA |
|---|---|---|---|
| 65538 | NAVIGATION |
65539 | RADIO |
| 65540 | TEL |
65541 | PRIMARY_BUTTON |
⚠ Media transport control exists only here. There is no play/pause message on the media-status
channel; a head unit's physical media buttons arrive as KeyEvent keycodes.
9.3 Rotary and absolute
message RelativeEvent { repeated Rel data = 1; } // { uint32 keycode = 1; int32 delta = 2; }
message AbsoluteEvent { repeated Abs data = 1; } // { uint32 keycode = 1; int32 value = 2; }
Rotary encoders send RelativeEvent with keycode = 65536 and a signed delta; zero deltas are
dropped. AbsoluteEvent with keycode = 65541 (PRIMARY_BUTTON) maps to a D-pad centre press.
The 7.7–17.7 APK names 65536 ROTARY_CONTROLLER — same wire value as SCROLL_WHEEL, a different
lineage's name — and routes its deltas to AXIS_SCROLL on source 8194: trust the value, not the
name.
9.4 Haptic feedback (MD→HU)
INPUT_FEEDBACK (0x8004) carries InputFeedback { optional FeedbackEvent event = 1; }, where
FeedbackEvent is FEEDBACK_SELECT=1, FOCUS_CHANGE=2, DRAG_SELECT=3, DRAG_START=4, DRAG_END=5.
The HU advertises which it supports in InputSourceService.feedback_events_supported.
Phone-side note (this project). Touch is the only input OSMAnd needs. Map
pointer_idthrough to AndroidMotionEventpointer indices so multitouch gestures survive; the coordinate space is already ours, since we chose the resolution in §7.2.
10. Sensor channel
MD→HU is the request direction; the HU pushes data back. The phone subscribes per sensor type, then receives batches.
MD→HU 0x8001 SENSOR_REQUEST SensorRequest { SensorType type = 1; int64 min_update_period = 2; }
HU→MD 0x8002 SENSOR_RESPONSE SensorResponse { MessageStatus status = 1; }
HU→MD 0x8003 SENSOR_BATCH SensorBatch { ... }
HU→MD 0x8004 SENSOR_ERROR SensorError { SensorType = 1; SensorErrorType = 2; }
One request per sensor type. SensorErrorType: SENSOR_OK=1, TRANSIENT=2, PERMANENT=3. A
min_update_period of −1 unsubscribes (7.7–17.7), and the phone waits synchronously —
2000 ms — on the SENSOR_RESPONSE before giving up.
10.1 The structural key
SensorBatch field numbers are identical to SensorType values. Every field is repeated:
message SensorBatch {
repeated LocationData location_data = 1;
repeated CompassData compass_data = 2;
repeated SpeedData speed_data = 3;
repeated RpmData rpm_data = 4;
repeated OdometerData odometer_data = 5;
repeated FuelData fuel_data = 6;
repeated ParkingBrakeData parking_brake_data = 7;
repeated GearData gear_data = 8;
repeated DiagnosticsData diagnostics_data = 9;
repeated NightModeData night_mode_data = 10;
repeated EnvironmentData environment_data = 11;
repeated HvacData hvac_data = 12;
repeated DrivingStatusData driving_status_data = 13;
repeated DeadReckoningData dead_reckoning_data = 14;
repeated PassengerData passenger_data = 15;
repeated DoorData door_data = 16;
repeated LightData light_data = 17;
repeated TirePressureData tire_pressure_data = 18;
repeated AccelerometerData accelerometer_data = 19;
repeated GyroscopeData gyroscope_data = 20;
repeated GpsSatelliteData gps_satellite_data = 21;
repeated TollCardData toll_card_data = 22;
}
[CONFIRMED ×2] — HeadunitPad independently hand-writes fieldBytes(13, drivingStatus) for driving
status, sensor type 13.
10.2 Sensor types
1–21 are agreed by all six lineages. 22 is in GAL and mrmees but absent from the headunit
lineage, which stops at 21 — and now corroborated by the 7.7–17.7 APK static analysis, a second
independent APK-era source (types 1–22, including TOLL_CARD at 22). 23–26 are modern APK
additions.
| 1 | LOCATION |
8 | GEAR |
15 | PASSENGER |
22 | TOLL_CARD |
|---|---|---|---|---|---|---|---|
| 2 | COMPASS |
9 | DIAGNOSTICS |
16 | DOOR |
23 | VEHICLE_ENERGY_MODEL |
| 3 | CAR_SPEED |
10 | NIGHT_MODE |
17 | LIGHT |
24 | TRAILER |
| 4 | RPM |
11 | ENVIRONMENT |
18 | TIRE_PRESSURE |
25 | RAW_VEHICLE_ENERGY_MODEL |
| 5 | ODOMETER |
12 | HVAC |
19 | ACCELEROMETER |
26 | RAW_EV_TRIP_SETTINGS |
| 6 | FUEL |
13 | DRIVING_STATUS |
20 | GYROSCOPE |
||
| 7 | PARKING_BRAKE |
14 | DEAD_RECKONING |
21 | GPS_SATELLITE |
10.3 Payloads — note the scaled integers
There are no floats anywhere. Values are fixed-point, with the scale encoded in the field name
(_e3 = ×1000, _e7 = ×10⁷):
message LocationData {
optional uint64 timestamp = 1 [deprecated = true];
required int32 latitude_e7 = 2; // degrees × 10^7
required int32 longitude_e7 = 3;
optional uint32 accuracy_e3 = 4; // metres × 1000
optional int32 altitude_e2 = 5; // metres × 100
optional int32 speed_e3 = 6; // m/s × 1000
optional int32 bearing_e6 = 7; // degrees × 10^6
}
message CompassData { int32 bearing_e6 = 1; int32 pitch_e6 = 2; int32 roll_e6 = 3; }
message SpeedData { int32 speed_e3 = 1; bool cruise_engaged = 2; int32 cruise_set_speed = 4; }
message RpmData { int32 rpm_e3 = 1; }
message OdometerData { int32 kms_e1 = 1; int32 trip_kms_e1 = 2; }
message FuelData { int32 fuel_level = 1; int32 range = 2; bool low_fuel_warning = 3; }
message ParkingBrakeData { bool parking_brake = 1; }
message GearData { Gear gear = 1; }
message NightModeData { bool night_mode = 1; }
message EnvironmentData { int32 temperature_e3 = 1; int32 pressure_e3 = 2; int32 rain = 3; }
message DrivingStatusData{ int32 status = 1; }
message DeadReckoningData{ int32 steering_angle_e1 = 1; repeated int32 wheel_speed_e3 = 2; }
⚠ LocationData.timestamp (field 1) resolves another corpus conflict the same way field 2 of
InputReport did: aasdk has it required, mrmees says numbering starts at 2, GAL shows it
deprecated. It existed and was retired.
⚠ SpeedData has no field 3 — it jumps from cruise_engaged (2) to cruise_set_speed (4).
aasdk guesses field 3 is a bool cruise_set_speed; GAL shows the gap is real.
10.4 The two that change head-unit behaviour
Night mode (type 10) — a single bool driving the HU's dark theme. [HUIG] strongly recommends
it. ⚠ HeadunitPad does not advertise it at all, while MOTO-HUB only sends it after an explicit
subscription — so a phone must not assume the HU will ask.
Driving status (type 13) — an int32 bitmask. The individual bits are [CONFIRMED ×3],
identical across GAL, aasdk and the headunit lineage:
| Bit value | GAL name (protos.proto:1565-1572) |
|---|---|
| 0 | DRIVE_STATUS_UNRESTRICTED |
| 1 | DRIVE_STATUS_NO_VIDEO |
| 2 | DRIVE_STATUS_NO_KEYBOARD_INPUT |
| 4 | DRIVE_STATUS_NO_VOICE_INPUT |
| 8 | DRIVE_STATUS_NO_CONFIG |
| 16 | DRIVE_STATUS_LIMIT_MESSAGE_LEN |
⚠ aasdk and the headunit lineage additionally define FULLY_RESTRICTED = 31. The GAL enum does
not — it stops at 16. Since 31 is just 1|2|4|8|16, this is a convenience constant rather than a
protocol difference, but do not expect to find it in the GAL definitions.
This is the mechanism behind driver-distraction lockout. [HUIG] requires DRIVING_STATUS to be
present in every session. Both headunit-lineage implementations always report UNRESTRICTED.
Gear is a closed, non-contiguous enum [CONFIRMED ×2]: GEAR_NEUTRAL=0, GEAR_1=1 …
GEAR_10=10, then a jump to GEAR_DRIVE=100, GEAR_PARK=101, GEAR_REVERSE=102. (The headunit
lineage spells the ordinals FIRST…TENTH; the values are identical.) Being a closed enum, unknown
values must be rejected rather than passed through.
10.5 EV energy model (types 23–26)
[CONFIRMED ×2, independent] — mrmees APK decompilation and openautolink's reverse engineering,
the latter verified on an AAOS emulator and a real Chevrolet Blazer EV (C234, 2024):
| Type | Name | Wrapper |
|---|---|---|
| 23 | SENSOR_VEHICLE_ENERGY_MODEL_DATA |
empty message |
| 24 | SENSOR_TRAILER_DATA |
— |
| 25 | SENSOR_RAW_VEHICLE_ENERGY_MODEL |
bytes |
| 26 | SENSOR_RAW_EV_TRIP_SETTINGS |
bytes |
Flow: the HU advertises 23/25/26 plus FUEL_TYPE_ELECTRIC and its EV connector types → the phone
requests type 23 at session setup → the HU reads its VHAL (EV_BATTERY_LEVEL,
INFO_EV_BATTERY_CAPACITY, RANGE_REMAINING) → sends a VehicleEnergyModel protobuf in a
SensorBatch → Maps computes battery-on-arrival estimates.
⚠ The energy-model payload is opaque to the Android Auto app — it is passed straight through to
GMS. A phone-side implementation never needs to parse it. Note also that Maps interprets
min_usable_capacity as the current state of charge, not a floor.
Phone-side note (this project). We are the consumer here: the car supplies sensors, we subscribe. GPS for OSMAnd comes from the phone's own fix, not the car — subscribe to
LOCATIONonly if we intend to prefer the vehicle's dead-reckoned position. Subscribe toNIGHT_MODEandDRIVING_STATUSfrom day one; they are the two that must visibly change our behaviour.
11. Phone-app tunables (Phenotype flags)
The real gearhead app is remote-configured through Phenotype (Gservices) string flags named
<Group>__<name> with in-code defaults; the registration XMLs in res/xml/ only register the
groups — defaults live in code, not in the XMLs or the assets/phenotype/*.binarypb blobs (those
are registration metadata, not flag values). Counts: 7.7 = 1011, 8.2 = 973 (114 new,
152 removed), 17.7 = 1174 unique flag names. Full lists are kept as fixtures in
previous-work/misc/fixtures/: gearhead-flags-{7.7,8.2,17.7}.txt, plus
gearhead-flags-new-in-8.2.txt / gearhead-flags-removed-in-8.2.txt.
Protocol-relevant groups and where their effects land in this document:
| Group | Acts on | Where |
|---|---|---|
SenderlibCertFeature__* (6 flags) |
CarService credential delivery, SHA-1 verify | §4.5.5 |
FrameworkGalFeature__fragment_size, __fragment_size_for_wifi, __framer_send_buffer_size(_for_wifi) |
frame/fragment sizing (16128 defaults) | §2.5 |
FrameworkGalFeature__use_sequence_numbers, __tls_auth_bypass_fix, __gal_ping_configuration, __use_ping_configuration, __detect_hu_gal_ping_timeout (v17.7) |
pre-auth whitelist, ping config | §3.3, §4.2 |
WirelessLatencyMonitor__probe_interval_ms (200), __report_interval_ms (5000) |
wireless ping cadence | §6.3 |
WirelessProjectionInGearhead__* |
BT-MAC/name kill-switches, nearby, RSSI −50 threshold, HU denylist | §1.3 |
VideoEncoderParams__* |
bitrates, I-frame interval 60, QP clamps | §7.6 |
AudioFocus*__*, AudioPolicy__* |
audio-focus timeouts, single-channel capture, routing kill-switches | §8 |
GalProtocolConditionalRequirementsForMinimumAppVersionsFeature__* (v17.7) |
minimum app versions | — |
New-in-8.2 of note: the nearby-connections wireless set, audio-focus timeouts, handoff
bypass_first_activity_q_and_below, setup_timeout. Removed-in-8.2: all FrameRateLimiter__*,
AudioBufferApproximationsWith*__*, wireless_sdp_manager_use_gh_version_kill_switch.
Appendix A — Message ID quick reference
Channel 0 is the control channel. All other channels are negotiated (§6.1); "channel kind" below
means the Service sub-message that declared them.
Control channel (0)
| ID | Name | Dir | Body |
|---|---|---|---|
| 0x0001 | VERSION_REQUEST |
HU→MD | raw u16 ×2 |
| 0x0002 | VERSION_RESPONSE |
MD→HU | raw u16 ×3 (+ optional protobuf ≥1.6) |
| 0x0003 | ENCAPSULATED_SSL |
both | raw TLS bytes |
| 0x0004 | AUTH_COMPLETE ⚠ |
MD→HU (⚠) | AuthResponse |
| 0x0005 | SERVICE_DISCOVERY_REQUEST |
MD→HU | ServiceDiscoveryRequest |
| 0x0006 | SERVICE_DISCOVERY_RESPONSE |
HU→MD | ServiceDiscoveryResponse |
| 0x0007 | CHANNEL_OPEN_REQUEST |
MD→HU | ChannelOpenRequest |
| 0x0008 | CHANNEL_OPEN_RESPONSE |
HU→MD | status |
| 0x0009 | CHANNEL_CLOSE_NOTIFICATION |
both | — |
| 0x000B | PING_REQUEST |
both | PingRequest (plaintext) |
| 0x000C | PING_RESPONSE |
both | PingResponse (plaintext) |
| 0x000D | NAV_FOCUS_REQUEST |
MD→HU | NavFocusType |
| 0x000E | NAV_FOCUS_NOTIFICATION |
HU→MD | NavFocusType |
| 0x000F | BYEBYE_REQUEST |
both | ByeByeReason |
| 0x0010 | BYEBYE_RESPONSE |
both | — |
| 0x0011 | VOICE_SESSION_NOTIFICATION |
MD→HU | VoiceSessionStatus |
| 0x0012 | AUDIO_FOCUS_REQUEST |
MD→HU | AudioFocusRequestNotification |
| 0x0013 | AUDIO_FOCUS_NOTIFICATION |
HU→MD | AudioFocusNotification |
| 0x0014 | CAR_CONNECTED_DEVICES_REQUEST |
MD→HU | — |
| 0x0015 | CAR_CONNECTED_DEVICES_RESPONSE |
HU→MD | — |
| 0x0016 | USER_SWITCH_REQUEST |
both | — |
| 0x0017 | BATTERY_STATUS_NOTIFICATION |
MD→HU | {1, 2 int, 3 bool charging} (gate: car ≥ 1.4) |
| 0x0018 | CALL_AVAILABILITY_STATUS |
HU→MD | {bool call_available=1} |
| 0x0019 | USER_SWITCH_RESPONSE |
both | UserSwitchStatus |
| 0x001A | SERVICE_DISCOVERY_UPDATE |
HU→MD | ServiceDiscoveryUpdate |
| 0x00FF | UNEXPECTED_MESSAGE |
both | — |
| 0xFFFF | FRAMING_ERROR |
both | — |
⚠ msg 4's direction is contested (§6.3, Appendix B item 20).
A/V channels (MediaSinkService / MediaSourceService)
| ID | Name | Dir | Body |
|---|---|---|---|
| 0x0000 | DATA |
either | [u64be ts ms][codec bytes] |
| 0x0001 | CODEC_CONFIG |
MD→HU | codec config, no timestamp |
| 0x8000 | SETUP |
MD→HU | Setup |
| 0x8001 | START |
MD→HU | Start |
| 0x8002 | STOP |
MD→HU | Stop |
| 0x8003 | CONFIG (aasdk: SETUP_RESPONSE) |
HU→MD | Config |
| 0x8004 | ACK |
HU→MD | Ack |
| 0x8005 | MICROPHONE_REQUEST (aasdk: AV_INPUT_OPEN_REQUEST) |
MD→HU | MicrophoneRequest |
| 0x8006 | MICROPHONE_RESPONSE (aasdk: AV_INPUT_OPEN_RESPONSE) |
HU→MD | MicrophoneResponse |
| 0x8007 | VIDEO_FOCUS_REQUEST |
MD→HU | VideoFocusRequestNotification |
| 0x8008 | VIDEO_FOCUS_NOTIFICATION |
HU→MD | VideoFocusNotification |
| 0x8009 | UPDATE_UI_CONFIG_REQUEST |
HU→MD | UpdateUiConfigRequest (phone must not apply the pushed theme) |
| 0x800A | UPDATE_UI_CONFIG_REPLY |
MD→HU | UpdateUiConfigReply |
| 0x800B | AUDIO_UNDERFLOW_NOTIFICATION |
HU→MD | AudioUnderflowNotification (stats only) |
IDs 0x8009–0x800B resolved in GAL's favour by the APK (§6.4, Appendix B item 15); directions are
the APK's. Item 16 (VideoFocusRequest MD→HU) resolved — see §7.3.
Other channels
| Channel kind | ID | Name | Dir |
|---|---|---|---|
SensorSourceService |
0x8001 / 0x8002 / 0x8003 / 0x8004 | REQUEST / RESPONSE / BATCH / ERROR |
MD→HU / HU→MD ×3 |
InputSourceService |
0x8001 / 0x8002 / 0x8003 / 0x8004 | INPUT_REPORT / KEY_BINDING_REQUEST / KEY_BINDING_RESPONSE / INPUT_FEEDBACK |
HU→MD, then MD→HU |
BluetoothService |
0x8001…0x8004 | PAIRING_REQUEST / PAIRING_RESPONSE / AUTHENTICATION_DATA / AUTHENTICATION_RESULT |
both |
NavigationStatusService |
0x8001…0x8007 | INSTRUMENT_CLUSTER_START / STOP / NAVIGATION_STATUS / TURN_EVENT† / DISTANCE_EVENT† / NAVIGATION_STATE / CURRENT_POSITION |
0x8001/0x8002 HU→MD (cluster start/stop); 0x8003–0x8007 MD→HU |
MediaPlaybackStatusService |
0x8001 / 0x8002 / 0x8003 | STATUS / INPUT / METADATA |
MD→HU; 0x8002 HU→MD (command enum 0..7) |
PhoneStatusService |
0x8001 / 0x8002 | STATUS / INPUT |
0x8001 MD→HU (call list, per-call state enum 0..6); 0x8002 HU→MD (call action) |
MediaBrowserService |
0x8001…0x8006 | ROOT_NODE … BROWSE_INPUT |
MD→HU |
GenericNotificationService |
0x8001…0x8004 | SUBSCRIBE / UNSUBSCRIBE / MESSAGE / ACK |
both |
WifiProjectionService |
0x8001 / 0x8002 | CREDENTIALS_REQUEST (v17.7+, empty) / CREDENTIALS_RESPONSE |
0x8001 MD→HU / 0x8002 HU→MD |
RadioService |
0x8001…0x8019 | active notification, step, seek, scan, tune, program list, presets, spacing, station info, mute, traffic, source, state | both (full car-radio control set) |
† deprecated.
Appendix B — Contested values and how to settle them
This is the project's open-questions backlog. Each entry names the experiment that would resolve it.
| # | § | Question | Positions | Experiment |
|---|---|---|---|---|
| 1 | 1.2.1 | ~~AOA model string~~ DOWNGRADED (web + APK) | Both Android Auto and Android Open Automotive Protocol independently confirmed working, a decade apart (2015 Mike Reid, 2016 HUIG/2018 apserver/2025 HeadunitPad) — APK accepts both, and its own USB filter accepts a third pair, Android/Android (§1.2.1) |
Low priority — try Android Open Automotive Protocol first per the recommendation; all three pairs are known-good. |
| 2 | 1.3 | ~~RFCOMM channel~~ LIKELY RESOLVED (web research) | 8 (mrmees ×3 + independent community troubleshooting knowledge) vs 22 (mretallack, uncorroborated) | Low priority — sdptool browse a real head unit to convert LIKELY to CONFIRMED. |
| 3 | 1.3 | Is HFP required before wireless AA? | Required (mrmees failure code); HUIG p.23 says not required but predates wireless; headunit-revived (web research) treats its own HFP server as a discovery aid, not a dependency |
Attempt the wireless handoff with HFP disconnected against both a real gearhead phone and headunit-revived; the two may legitimately differ. |
| 4 | 1.3 | ~~WifiSecurityMode numbering~~ RESOLVED (web + APK) |
bitmask (WPA2=8) — live production firmware in aa-proxy-rs and first-hand in the 17.7 APK (adds WPA3=32, WPA2_WPA3=40); sequential (WPA2=5) only in the extracted GAL proto |
Resolved: bitmask; emit 8, accept 5 defensively. A capture remains nice-to-have. |
| 5 | 1.6 | Which TCP port | 5277 / 5288 / 5289 / 30515 | Resolved: negotiated via WifiStartRequest.port. No experiment needed. |
| 6 | 2.3 | FIRST-frame header size | 8 bytes (×4 sources) vs 6 (milek7 dissector) | Resolved by weight of evidence; confirm by hand-decoding a captured fragmented message. |
| 7 | 3.2 | Protocol version to advertise | 1.1 / 1.2 / 1.6 / 1.7 / 2.0; APK max-supported ladder 4.0 → 4.1 → 6.1 with "≤1.7 → answer 1.7, else answer max" (§3.2) | Log VERSION_REQUEST major/minor from a real head unit; log gearhead's response to each. |
| 8 | 3.2 | Is HeadunitPad's 2.0 real? | Nothing else sits in 2.x | Send 2.0 to real gearhead and inspect the response and any feature gating. |
| 9 | 4.5.3 | Do head units enforce cert expiry? | "don't check" (mretallack) vs "DO enforce" (AACS, Seat LG); phone-side enforcement of the car's cert (chain + expiry) is confirmed — the open half is HU-side only (§4.5.3); route C's embedded fallback measured as a decoy on 7.7+ (§4.5.5) | Present an expired GAL cert to several head units; record which reject with status −24. |
| 10 | 5.1 | ServiceDiscoveryRequest fields 4/5 (+6) |
label_text+device_name (GAL) vs device_name+device_brand (aasdk, mrmees); APK 7.7–17.7 lands with GAL — 4 = label, 5 = Build.MANUFACTURER + " " + Build.MODEL combined; field 6 = persistent UUID at car ≥ 1.6 ("4 strings" in 17.7's summary — semantics open) |
Capture a real request from gearhead; compare the two strings against the phone's name and brand. |
| 11 | 5.2 | ServiceDiscoveryResponse field 6 |
bool (aasdk) vs DriverPosition enum (GAL, mrmees) |
Resolved to enum; confirm by reading field 6 from a right-hand-drive head unit. |
| 12 | 5.2 | Field 16 ConnectionConfiguration |
present (GAL) vs retracted (mrmees 2026-07); strengthened — v17.7 appends {1:{1:{1..4 ping-config}}} to the version response at car ≥ 1.6 (first-hand), matching GAL's nesting (§3.3, §5.3) |
Request version ≥1.6 and inspect whether the response carries a field-16 submessage. |
| 13 | 5.4 | Service fields 7, 12–18 |
every source differs | Enumerate a real ServiceDiscoveryResponse and map present field numbers to observed behaviour. |
| 14 | 6.1 | Channel ID assignment | Six published tables, all different | Resolved: IDs come from Service.id in ServiceDiscoveryResponse; only channel 0 is fixed. Confirm by opening the same head unit twice and checking whether IDs stay stable across sessions. |
| 15 | 6.4 | ~~A/V message IDs above 0x8008~~ RESOLVED (APK) | GAL's 0x8009/0x800A/0x800B reading confirmed first-hand in APK 7.7/8.2/17.7; mrmees's retraction cascade was the wrong reading (§6.4) |
Resolved in GAL's favour. |
| 16 | 7.3 | ~~VideoFocusRequest direction~~ RESOLVED (web research) |
channel-map.md's HU→MD was a table transposition; video.md's own prose is explicit: MD→HU |
Settled — video.md states it directly, no experiment needed. |
| 17 | 7.4 | CODEC_CONFIG framing |
Annex-B (HUIG, MOTO-HUB) vs avcC (aa-linux sends it; HeadunitPad observed it) | Log the first bytes of 0x0001 from gearhead — 00 00 01 vs a length prefix. |
| 18 | 8.2 | AudioStreamType numbering |
four different tables; only MEDIA=3 agreed; APK 7.7–17.7 tilts to GAL (1=guidance, 2=system, 3=media) against MOTO-HUB/mrmees's GUIDANCE=5 |
Open all three audio channels and correlate each audio_type with what actually plays. |
| 19 | 8.3 | Audio payload offset | [ts][PCM] vs [type][ts][PCM] |
Log leading 10 bytes of audio DATA per head unit; check whether bytes 0–1 parse as a media type. |
| 20 | 6.3 | AUTH_COMPLETE direction |
MD→HU (corpus tables) vs HU→MD (aasdk sends it; aa-linux receives it; APK phone enables encryption on receipt, §6.3) | Sniff one real session — the sender of msg 4 settles it. |
| 21 | 3.1 | VersionRequestOptions "snapshot" (field 2, fixed64) |
appended to VERSION_REQUEST behind a parse flag; purpose beyond logging unknown |
Send one carrying flags/snapshot to gearhead and observe behavioural differences. |
| 22 | 6.2 | Channel-open ordering on the car side | phone sends all msg 7s back-to-back; whether real HUs require strict sequential opens is untested | Open channels out of order against a real HU and record which reject. |
| 23 | 1.3 | WifiInfoResponse field 5 |
access_point_type (aa-linux et al.) vs status (17.7 APK RESPONSE_INFO map) |
Capture one real WifiInfoResponse and read field 5. |
| 24 | 7.2 | VideoConfiguration field 6 semantics |
decoder_additional_depth (GAL) vs layout param default 4 (APK 7.7) |
Send both readings to a real HU and diff behaviour. |
| 25 | App D | res/*.proto binary configs |
unknown — varint records plus int sets {15,16,18,20,21}/{1,3,4,8,9,12}, likely message-ID/channel allowlists (Clearcut/instrumentation config) | Pin the loader class in the 7.7/8.2 decompiles. |
Appendix C — Known-bad sources
Places where a source is confidently wrong. Listed so these errors are not re-imported.
aasdk (all forks)
| What | Wrong | Actual |
|---|---|---|
VideoResolution |
3 values; names wrong for ≥5 | 9 values (§7.2) |
VideoFocusMode |
binary FOCUSED/UNFOCUSED |
4 states (§7.3) |
VideoFocusReason |
UNK_1, UNK_2 placeholders |
PHONE_SCREEN_OFF=1, LAUNCH_NATIVE=2 |
ShutdownReason |
NONE=0, QUIT=1 |
8 named reasons (ByeByeReason) |
Status |
OK=0, FAIL=1 |
34-value signed taxonomy (§6.5) |
BluetoothPairingMethod |
UNK_1, A2DP, UNK_3 |
OOB=1, NUMERIC_COMPARISON=2, PASSKEY_ENTRY=3, PIN=4 |
ControlMessage enum |
stops at 0x13; omits 0x0009 | through 0x001A (§6.3) |
aasdk remains authoritative on framing and transport — it is the best source for §2.
aa-linux
Its Python code is live-tested and reliable; these are defects to avoid copying.
AV_BINDING_REQUEST = 0x8002/AV_STOP_INDICATION = 0x8005— contradicts both lineages (§6.4). Harmless only because it never sends them.WifiInfoResponsefield names are swapped in its generated stub (fields 2/3); the code compensates by reading the wrong attribute names. The canonical order isssid=1, key=2, bssid=3.- The audio-channel queueing branch in service-discovery handling is dead code — audio channels are never opened in that build.
SERVICE_DISCOVERY_UPDATE (0x001A)is defined but never handled.- Default video bitrate is 25 Mbps — four times HUIG's 720p limit (§7.1).
- Its
.protofiles are a stale vendored snapshot of mrmees, not independent evidence.
HeadunitPad
- Dead, wrong message-type constants superseded by live testing but still present in the source
and citable-looking:
KEY_CODE_EVENT=101,VIDEO_FOCUS_REQUEST=102,VIDEO_FOCUS_ACK=103,SENSOR_EVENT=200,VIDEO_CONFIG=300,VIDEO_FRAME=301,AUDIO_CONFIG=400,AUDIO_FOCUS=401,AUDIO_FRAME=402. None are real AAP message types. Its live dispatch correctly uses 0x0000/0x0001/0x8000–0x8003. - Message 17 is mislabelled
AUDIO_FOCUS_RESPONSE; it isVOICE_SESSION_NOTIFICATION. - Its USB path is explicitly unstable (self-disconnects, black screen despite frames arriving); wireless is the tested path.
MOTO-HUB
- Reads the extended length field on a literal
flags == 0x09, so a fragmented control frame (0x0D) would be mis-parsed (§2.2). - Zero unit tests for frame parsing, TLS, or message handling; the repo is a single squashed commit. Its only protocol test covers the QR-pairing payload writer.
milek7 dissector (androidauto.lua)
- Reads a single 4-byte length on FIRST frames; contradicts four other sources (§2.3).
messagemapping[2]omits 0x8007 and mislabels 0x8008.- Channel map (0=control, 1=sensor, 2=media, 3=input) is explicitly described by its own author as a hardcoded convenience assumption.
mrmees
Self-correcting, with dated retractions — check for a retraction note before relying on any entry.
Known: VideoFocusNotification@0x8009 (retracted 2026-03-06, cascading one slot through
MediaStats/MediaOptions); ServiceDiscoveryResponse field 16 (retracted 2026-07, but GAL
defines it — Appendix B item 12); a duplicate SensorStartRequest; a 3-value
BluetoothPairingStatus superseded by the shared status enum.
⚠ wireless-bluetooth-setup.md cites three companion scripts that were never committed
(sdp_clean.c, aa-combined.py, bt-agent.py — confirmed by the org's own issue tracker,
open-android-auto #19, and by a sister document's admission that they were "test scripts on Pi at
/tmp/"). Nothing to recover; not a defect in the protocol claims, just don't expect the files.
mrmees-openauto-prodigy — a sibling repo, and its own internal contradiction
A separate, more production-oriented mrmees project (Qt/C++ head-unit implementation). Its
docs/archive/openauto-pro/bluetooth-wireless-aa-setup.md is a near-duplicate of
open-android-auto's wireless doc, dated 2024-02-24, hardware-tested (BlueZ 5.82, Raspberry Pi
4, Moto G Play, Samsung S25 Ultra) — treat it as a second, independent, dated confirmation of
everything the two documents share (RFCOMM channel discovery, the AA-only SDP record, HFP AG being
required), not merely a copy.
⚠ But its production code disagrees with its own documentation.
src/core/aa/BluetoothDiscoveryService.cpp:36-40 defines kMsgWifiStartResponse = 6 and
kMsgWifiConnectionStatus = 7 — swapped relative to the doc's own table (§1.3), relative to
aa-linux, relative to aa-proxy-rs, and relative to the 17.7 APK's link-level table. Four
sources agree with the doc; only this one C++ file disagrees, with itself. If you ever consult
this file directly, correct IDs 6 and 7 before trusting anything else in it.
mretallack
decrypt_key_from_apk.md's extraction procedure is era-dependent: verified working on the 6.4 / 16.8-era builds it analysed, but the APK static analysis measured the embedded fallback blob as non-decrypting in 7.7/8.2/17.7 — a decoy/rotation artifact, with Phenotype delivering the real credentials (§4.5.5). Not wrong for its era; not to be assumed for current builds.
Not AAP at all
mossyhub/openautolink docs/protocol.md describes that project's own app↔bridge protocol. Its
ports (5288/5289/5290), its 16-byte video header and its 8-byte audio header have nothing to do with
this specification.
Appendix D — Source map
Where to go to re-verify a claim, and what that source is worth.
| Claim class | Primary source | Corroborate with |
|---|---|---|
| Frame format, framing rules | opencardev-aasdk/src/Messenger/{FrameHeader,FrameSize}.cpp |
aa-linux/frame.py, MOTO-HUB AapReadSingleMessage.kt |
| Message IDs, enums, protobuf schemas | milek7-galdocs/protos.proto (= opencardev-aasdk/docs/protos.proto) |
mrmees oaa/, MOTO-HUB generated *.java |
| Requirements, policy, focus semantics | markdown/milek7-galdocs/head-unit-integration-guide-v1.3.md |
— (normative) |
| Handshake ordering, state machine | aa-linux/protocol.py, [HUIG p.15] |
milek7 apserver.c (AOA only) |
| TLS mechanics | aa-linux/crypto.py, HeadunitPad OpenSslTlsHandler.swift |
aasdk SSLWrapper.cpp, MOTO-HUB AapSslContext.kt |
| Certificates / PKI | aa-proxy-rs/*.pem (inspect with openssl) |
mretallack APK analysis, AACS issue #3 |
| AOA2 / USB | f1xpl-aasdk/.../USB/AccessoryMode*Query, [HUIG p.19-20] |
HeadunitPad AndroidOpenAccessory.swift |
| Wireless (Bluetooth) | mrmees wireless-bluetooth-setup.md |
aa-linux/wireless.py, aa-proxy-rs, nisargjhaveri |
| Wireless (QR / deep link) | MOTO-HUB AaWirelessPairing.kt |
— (single source, original RE) |
| Modern APK behaviour, feature gates | mrmees docs/interactions/02-version-ssl-auth.md |
mretallack/docs/research.md, misc/fixtures/gearhead-reference-notes.md (first-hand for 7.7/8.2/17.7) |
| Phone-app behaviour, gearhead 7.7 / 8.2 / 17.7 | previous-work/misc/fixtures/gearhead-reference-notes.md |
previous-work/source/apk/jadx-v{77,82,17}/ trees, baksmali for jadx artifacts |
| EV sensors | openautolink EV energy-model RE | mrmees sensor.md |
APK dump anchor map (gearhead 7.7 / 8.2 / 17.7)
Obfuscated names are reassigned between builds; this map is the cross-build index for the three
decompiled gearhead dumps in previous-work/source/apk/jadx-v{77,82,17}/ (persisted baksmali
trees alongside, for checking jadx decompile artifacts). Roles, per build:
| Role | v7.7 | v8.2 | v17.7 |
|---|---|---|---|
| Control-channel state machine | itj |
ivj |
rze (legacy s stack) + jcj (refactored j stack; both live, identical wire behaviour) |
| Endpoint base class | ivk |
iwx |
jdg/say family (endpoint per channel) |
| Frame writer | itw |
ivw |
— |
| Framer/session (reader+writer) | itx |
ivx |
— |
| SSL wrapper + KDF fallback | iwd |
ixn |
jep / uga.bl (KDF), uga.I (key extract) |
| Key manager (GAL root + chain) | iwb |
ixl |
sbb |
| Fallback credential provider | iwa |
ixk |
jen |
| Phenotype credential provider | iwc/rfd/rff |
ixm/rke/rkg |
jeo/adhz/adib |
| Version value class | ivr |
ixe |
sau / jef |
| Status enum | nhf |
nlw |
xqp |
| Wireless (WPP) TLS socket setup | gdc |
gnc |
— |
Per-channel endpoints (the channel column is the service type of §5.4, not a wire channel id):
| Type | Role | v7.7 | v8.2 | v17.7 |
|---|---|---|---|---|
| 1 | control | itj |
ivj |
rze/jcj |
| 2 | video out | iwj extends ius |
cmy |
jhc |
| 3/4/5 | guidance / system / media audio out | ise (stream param 1/2/3) |
clu |
rya |
| 6 | mic in | ivc extends iur |
cmc |
jfy |
| 7 | sensors | ivy |
cmu/ixi |
say/jel |
| 8 | input | ium |
clz |
jdg |
| 9 | Bluetooth pairing | isi |
iui |
rye |
| 10 | nav/cluster status | ive |
cmm |
jdq |
| 11 | media playback | iuq |
cmb |
jdk |
| 12 / 14 | media browser / notification | presence-only | same | same |
| 13 | phone status | ivg |
cmo |
jdw |
| 15 | radio | ivu |
cms |
jei |
| 16 | vendor extension | iwh (raw pipe) + cap (no-op) |
cmw + cef |
jes |
| 17 | WiFi projection | iwm |
cnb |
jeu |
| — | unhandled service ids | ixj no-op sink |
same pattern | runtime proxy jgn wrapping jgi |
| 19–22 | car control / local media / buffered media / car intent | — | — | jbb / jbi / jfa / jbg |
v17.7 uniquely contains two complete GAL stacks (s* legacy and j* refactored), both live,
selected by a service factory; wire constants agree between them. Log tags are stable across
builds: CAR.GAL.GAL, CAR.GAL.AUDIO/VIDEO/MIC/INPUT/SENSOR/BT/WIFI_PROJ/RADIO-EP, CAR.VENDOR,
plus v17.7's CAR.GAL.CAR_CONTROL, CAR.GAL.CAR_LOCAL_MEDIA, CAR.MEDIA.BUFFERED,
CAR.GAL.CAR_INTENT.
What the dumps contain (and don't): no .proto sources and no certificate stores beyond
the §4 material (root PEM literal + encrypted fallback blob); four small binary protobuf configs
(res/{2MO,EFE,8-l,bBj}.proto — varint records and int sets, likely instrumentation allowlists —
Appendix B item 25); assets/phenotype/*.binarypb (flag-registration metadata, not values — §11);
assets/sdk_impl.jar (~724 KB, the CarService dynamite client loader, not the GAL protocol core
— decompiled at jadx-v{77,82}-sdkimpl/); assets/first_party_common_packages.txt (Google
first-party app-launch allowlist, not protocol).
Independence warning
Six lineages, and two pairs are not independent:
GAL binary ──► milek7 protos.proto ──► vendored into opencardev-aasdk/docs/
HUIG v1.3 (standalone, normative)
aasdk ──► f1xpl ──► opencardev ──► emirhaluci (one lineage, three forks)
mrmees oaa ──► vendored into mfont-bz17/aa-linux (aa-linux protos are NOT independent)
mikereidis/headunit ──► headunit-revived ──► MOTO-HUB (HeadunitPad matches it closely)
gearhead APK notes (7.7/8.2/17.7) (independent; overlaps mrmees in subject only)
Counting "four sources agree" is only meaningful if they come from different rows.
A seventh lineage: the 2015 origin thread
One source predates every lineage above and sits outside the fork tree entirely: Mike Reid's
original XDA reverse-engineering thread (source/web/[CLOSED] Headunit app for Android Auto...html, and
its companion release thread source/web/[Android 4.1+] Headunit for Android Auto...html),
2015-03-31 onward — the root of the entire community effort (every implementation in this
document ultimately traces back to mikereidis/headunit, which began here). It predates HUIG by a
year and is a primary field-research source, not a fork of anything. Read directly (not
web-search-summarized) for this revision; supplied two corrections (§1.2.1, §4.5.2) that neither
web search nor the rest of the corpus surfaced. Its content is narrative forum prose, not code, so
it corroborates facts (a string worked, a cert existed) rather than byte-level schemas.
Corrections from this revision's web research pass
Three items moved from [CONTESTED] toward resolved after checking public sources beyond this
corpus (2026-09-25): VideoFocusRequest direction (§7.3, fully resolved — mrmees's own video.md
prose is unambiguous, channel-map.md's table was a transposition), WifiSecurityMode numbering
(§1.3, resolved toward bitmask — aa-proxy-rs is deployed production firmware, not just an
extracted proto), and the AOA model string (§1.2.1, downgraded from a real conflict to two
long-independently-confirmed values). One new complication surfaced: headunit-revived treats HFP
as optional even for its wireless path, weakening mrmees's "required" claim (§1.3). One source
caveat surfaced: mrmees's own issue tracker confirms wireless-bluetooth-setup.md cites
companion scripts that don't exist in the repo — a reason to want a second source for anything
resting on that document alone. Items not reachable this way — AudioStreamType numbering,
CODEC_CONFIG Annex-B vs AVCC, and cert-expiry enforcement by specific real head units — remain
open; they need a live capture or a real car, not more searching.
Corrections from the gearhead APK static-analysis merge
Same revision date, second pass — merging
previous-work/misc/fixtures/gearhead-reference-notes.md (lineage 6). Resolved: WifiSecurityMode
is a bitmask, first-hand in 17.7 (incl. WPA3=32 / WPA2_WPA3=40 — §1.3); A/V message IDs 0x8009–0x800B
follow GAL's reading, confirmed in three builds (§6.4); the ≥1.4 version gate's purpose is the
battery-status send (§3.3); the negotiated max_fragment_size default is 16128, not 0x4000 (§2.5);
the AOA accepted set has three model strings, not two (§1.2.1). Newly contested: AUTH_COMPLETE
direction — every implementation observation says HU→MD, the corpus tables say MD→HU (§6.3,
Appendix B item 20). Newly caveated: route C certificate extraction is era-dependent — the
embedded fallback is a measured decoy on 7.7+ (§4.5.5, Appendix C). The 6.1 ping-config append (§3.3)
corroborates GAL's ConnectionConfiguration nesting (Appendix B item 12), and the APK's
audioStreamType 1/2/3 mapping tilts the AudioStreamType numbering toward GAL (§8.2).
Hardware verification status
| Implementation | Verified against |
|---|---|
aa-linux |
Google DHU (live) |
| aasdk / openauto | real cars, years of deployment |
| AACS | Seat Ateca 2019 LG head unit; Fiat Tipo Uconnect in progress |
| HeadunitPad | Xiaomi phone over USB (audio confirmed); wireless is the mature path |
| MOTO-HUB | no protocol tests; forked code assumed working |
| openautolink EV | AAOS emulator + Chevrolet Blazer EV C234 (2024) |
| mrmees | VW MIB3 OI, Google DHU 2.1, Sony XAV-AX100 firmware, APK 16.1–17.3 |
| gearhead APK notes (lineage 6) | static analysis only — no hardware; primary first-hand source for phone-side behaviour (7.7/8.2/17.7) |
| mrmees-openauto-prodigy (wireless BT) | BlueZ 5.82 / Raspberry Pi 4 against a Moto G Play (Android 14) and a Samsung S25 Ultra, dated 2024-02-24 |
aa-proxy-rs |
deployed production firmware behind commercially-sold wireless AA dongle hardware |
End of specification.