New paste Use cases Explore public pastes Text tools Developer API The Paste Library Journal Security Sign in with Google
MARKDOWNCreated 2026-09-2527 viewsNo expiry

Untitled

Raw New paste
markdownRead-only
1
# 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](#33-negotiation-is-not-validated--but-the-version-still-matters) 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](#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](#3-version-handshake). 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](#16-ports--negotiated-not-constant-contested-resolved)): 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](#appendix-c--known-bad-sources).

**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:

```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](#2-frame-format) 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](#6-channel-architecture)) |
| 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](#63-control-channel-messages))
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.7 `its.java:26`; WiFi deployments can override it via Phenotype —
  [§11](#11-phone-app-tunables-phenotype-flags)). The commonly quoted **`0x4000` (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
  16128. Fragmentation triggers only when the message is fragmentable **and** its payload exceeds
  `max_fragment_size − 4`.
- `frame payload length` is u16, so a frame can never exceed 65535 regardless.
- **Fragmentation happens after encryption.** The chunks are ciphertext; `total message length`
  refers 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](#63-control-channel-messages) 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](#65-status-codes)), 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](#45-pki-and-provisioning)).

## 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](#63-control-channel-messages) 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](https://github.com/rustls/rustls/issues/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_PRELOAD`ed 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**.

```bash
# 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's
  `Base64.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 is
  `int 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 `.gitignore`s `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`:

```protobuf
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:

```protobuf
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

```protobuf
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

```protobuf
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](#61-channel-ids-are-negotiated-not-constant).

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](#61-channel-ids-are-negotiated-not-constant) 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

```protobuf
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:

```protobuf
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 named
> `CHANNEL_VIDEO = 3` in 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](#26-control-message-does-not-mean-channel-0).

The phone sends **all** `CHANNEL_OPEN_REQUEST`s 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:

1. During service discovery the MD reads the `VideoConfiguration` list the HU supports.
2. After the channel opens, the HU sends `CONFIG` with a **prioritised list of indices** into that
   list, most preferred first.
3. The MD picks one, weighing HU preference against its own encoder capability.
4. The MD sends `START` carrying the chosen `configuration_index`.

`[HUIG p.30]`: *"The HU SHOULD request the highest resolution video that the protocol supports."*

```protobuf
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.**

```protobuf
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](#71-normative-requirements):
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](#11-phone-app-tunables-phenotype-flags)). 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).

```protobuf
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)

```protobuf
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`.

```protobuf
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

```protobuf
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

```protobuf
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

```protobuf
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_id` through
> to Android `MotionEvent` pointer 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`:

```protobuf
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⁷):

```protobuf
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 `LOCATION`
> only if we intend to prefer the vehicle's dead-reckoned position. Subscribe to `NIGHT_MODE` and
> `DRIVING_STATUS` from 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.
- `WifiInfoResponse` field names are swapped in its generated stub (fields 2/3); the code
  compensates by reading the wrong attribute names. The canonical order is
  `ssid=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 `.proto` files 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 is `VOICE_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.*
Report abuse

Paste details

Visibility
Public
Size
143.5 KB
Protection
Standard link access
Retention
No automatic expiry

Share with confidence

Unlisted links are not searchable, but anyone with the URL can open them. Never paste live credentials or personal data.